Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pass Install Extras to Markers #9553

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
47 changes: 47 additions & 0 deletions docs/dependency-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,53 @@ via the `markers` property:
pathlib2 = { version = "^2.2", markers = "python_version <= '3.4' or sys_platform == 'win32'" }
```

### `extra` environment marker

Poetry populates the `extra` marker with each of the selected extras for the parent declaring the dependency. For
example, consider the following dependency in your root package:
```toml
[tool.poetry.dependencies]
pathlib2 = { version = "^2.2", markers = "extra == 'paths' and sys_platform == 'win32'", optional = true}
```
`pathlib2` will be installed when you install your package with `--extras paths` on a `win32` machine.
You'll also need to [define the `paths` extra in your project](./pyproject.md#extras).

#### Exclusive extras

Keep in mind that all combinations of possible extras available in your project need to be compatible with each other.
This means that in order to use differing or incompatible versions across different combinations, you need to make your
extra markers *exclusive*. For example, the following installs PyTorch from one source repository with CPU versions
when the `cuda` extra is *not* specified, while the other installs from another repository with a separate version set
for GPUs when the `cuda` extra *is* specified:

```toml
[tool.poetry.dependencies]
torch = [
{ markers = "extra != 'cuda'", version = "2.3.1+cpu", source = "pytorch-cpu", optional = true},
{ markers = "extra == 'cuda'", version = "2.3.1+cu118", source = "pytorch-cu118", optional = true},
]

[tool.poetry.extras]
cuda = ["torch"]

[[tool.poetry.source]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
priority = "explicit"

[[tool.poetry.source]]
name = "pytorch-cu118"
url = "https://download.pytorch.org/whl/cu118"
priority = "explicit"
```

For the CPU case, we have to specify `"extra != 'cuda'"` because the version specified is not compatible with the
GPU (`cuda`) version.

This same logic applies when you want either-or extras. If a dependency for `extra-one` and
`extra-two` conflict, they will need to be restricted using `markers = "extra == 'extra-one' and extra != 'extra-two'"`
and vice versa.

## Multiple constraints dependencies

Sometimes, one of your dependency may have different version ranges depending
Expand Down
1 change: 1 addition & 0 deletions src/poetry/installation/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ def _do_install(self) -> int:
self._installed_repository.packages,
locked_repository.packages,
NullIO(),
active_root_extras=self._extras,
)
# Everything is resolved at this point, so we no longer need
# to load deferred dependencies (i.e. VCS, URL and path dependencies)
Expand Down
45 changes: 41 additions & 4 deletions src/poetry/puzzle/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from collections import defaultdict
from contextlib import contextmanager
from typing import TYPE_CHECKING
from typing import Any
from typing import ClassVar
from typing import cast

Expand Down Expand Up @@ -117,6 +118,7 @@ def __init__(
*,
installed: list[Package] | None = None,
locked: list[Package] | None = None,
active_root_extras: Collection[NormalizedName] | None = None,
) -> None:
self._package = package
self._pool = pool
Expand All @@ -133,6 +135,9 @@ def __init__(
self._direct_origin_packages: dict[str, Package] = {}
self._locked: dict[NormalizedName, list[DependencyPackage]] = defaultdict(list)
self._use_latest: Collection[NormalizedName] = []
self._active_root_extras = (
frozenset(active_root_extras) if active_root_extras is not None else None
)

self._explicit_sources: dict[str, str] = {}
for package in locked or []:
Expand Down Expand Up @@ -457,7 +462,16 @@ def incompatibilities_for(
for dep in dependencies
if dep.name not in self.UNSAFE_PACKAGES
and self._python_constraint.allows_any(dep.python_constraint)
and (not self._env or dep.marker.validate(self._env.marker_env))
and (
not self._env
or dep.marker.validate(
self._marker_values(
self._active_root_extras
if dependency_package.package.is_root()
else dependency_package.dependency.extras
)
)
)
Comment on lines +465 to +474
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this change is not required by any test. To be honest, I have currently no idea if this change is redundant or if a test is missing...

]
dependencies = self._get_dependencies_with_overrides(_dependencies, package)

Expand Down Expand Up @@ -541,7 +555,12 @@ def complete_package(
if dep.name in self.UNSAFE_PACKAGES:
continue

if self._env and not dep.marker.validate(self._env.marker_env):
active_extras = (
self._active_root_extras if package.is_root() else dependency.extras
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the non root part is not tested yet. (Tests succeed when replacing dependency.extras with None.) Maybe, this path can be triggered by taking one of your tests and introducing an intermediate package?

)
if self._env and not dep.marker.validate(
self._marker_values(active_extras)
):
continue

if not package.is_root() and (
Expand Down Expand Up @@ -601,7 +620,9 @@ def complete_package(

# For dependency resolution, markers of duplicate dependencies must be
# mutually exclusive.
active_extras = None if package.is_root() else dependency.extras
active_extras = (
self._active_root_extras if package.is_root() else dependency.extras
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like that is not tested yet. (Tests succeed without this change.) You probably have to add a test similar to test_solver_resolves_duplicate_dependency_in_extra, which tests this for non root extras.

)
deps = self._resolve_overlapping_markers(package, deps, active_extras)

if len(deps) == 1:
Expand Down Expand Up @@ -860,7 +881,7 @@ def _is_relevant_marker(
get_python_constraint_from_marker(marker)
)
and (active_extras is None or marker.validate({"extra": active_extras}))
and (not self._env or marker.validate(self._env.marker_env))
and (not self._env or marker.validate(self._marker_values(active_extras)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change should not be necessary because extras are already handled one line above (no matter if self._env is set or not).

)

def _resolve_overlapping_markers(
Expand Down Expand Up @@ -953,3 +974,19 @@ def _resolve_overlapping_markers(
# dependencies by constraint again. After overlapping markers were
# resolved, there might be new dependencies with the same constraint.
return self._merge_dependencies_by_constraint(new_dependencies)

def _marker_values(
self, extras: Collection[NormalizedName] | None = None
) -> dict[str, Any]:
"""
Marker values, per:
1. marker_env of `self._env`
2. 'extras' will be added to the 'extra' marker if not already present
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. cannot happen, can it?

"""
result = self._env.marker_env.copy() if self._env is not None else {}
if extras is not None:
if "extra" not in result:
result["extra"] = set(extras)
else:
result["extra"] = set(result["extra"]).union(extras)
Comment on lines +988 to +991
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think that it is possible that extra is already present. This might even be an error. Thus, maybe we just assert "extra" not in result.

return result
8 changes: 7 additions & 1 deletion src/poetry/puzzle/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def __init__(
installed: list[Package],
locked: list[Package],
io: IO,
active_root_extras: Collection[NormalizedName] | None = None,
) -> None:
self._package = package
self._pool = pool
Expand All @@ -49,7 +50,12 @@ def __init__(
self._io = io

self._provider = Provider(
self._package, self._pool, self._io, installed=installed, locked=locked
self._package,
self._pool,
self._io,
installed=installed,
locked=locked,
active_root_extras=active_root_extras,
)
self._overrides: list[dict[Package, dict[str, Dependency]]] = []

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[[package]]
name = "demo"
version = "1.0.0"
description = ""
optional = true
python-versions = "*"
files = []

[package.extras]
demo-extra-one = ["transitive-dep-one"]
demo-extra-two = ["transitive-dep-two"]

[package.dependencies]
transitive-dep-one = {version = "1.1.0", optional = true}
transitive-dep-two = {version = "1.2.0", optional = true}

[[package]]
name = "transitive-dep-one"
version = "1.1.0"
description = ""
optional = true
python-versions = "*"
files = []

[[package]]
name = "transitive-dep-two"
version = "1.2.0"
description = ""
optional = true
python-versions = "*"
files = []

[extras]
extra-one = ["demo", "demo"]
extra-two = ["demo", "demo"]

[metadata]
python-versions = "*"
lock-version = "2.0"
content-hash = "123456789"
34 changes: 34 additions & 0 deletions tests/installation/fixtures/with-exclusive-extras.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[[package]]
name = "torch"
version = "1.11.0+cpu"
description = ""
optional = true
python-versions = "*"
files = []

[package.source]
reference = "pytorch-cpu"
type = "legacy"
url = "https://download.pytorch.org/whl/cpu"

[[package]]
name = "torch"
version = "1.11.0+cuda"
description = ""
optional = true
python-versions = "*"
files = []

[package.source]
reference = "pytorch-cuda"
type = "legacy"
url = "https://download.pytorch.org/whl/cuda"

[extras]
cpu = ["torch", "torch"]
cuda = ["torch", "torch"]

[metadata]
python-versions = "*"
lock-version = "2.0"
content-hash = "123456789"
Loading
Loading