Skip to content

Commit b8a5fb6

Browse files
reesehyderadoering
andauthored
Pass Install Extras to Markers (#9553)
Adds support for conflicting dependencies in extras. Co-authored-by: Randy Döring <30527984+radoering@users.noreply.github.com>
1 parent bf85a12 commit b8a5fb6

13 files changed

+1003
-120
lines changed

docs/dependency-specification.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,109 @@ pathlib2 = { version = "^2.2", markers = "python_version <= '3.4' or sys_platfor
572572
{{< /tab >}}
573573
{{< /tabs >}}
574574

575+
### `extra` environment marker
576+
577+
Poetry populates the `extra` marker with each of the selected extras of the root package.
578+
For example, consider the following dependency:
579+
```toml
580+
[project.optional-dependencies]
581+
paths = [
582+
"pathlib2 (>=2.2,<3.0) ; sys_platform == 'win32'"
583+
]
584+
```
585+
586+
`pathlib2` will be installed when you install your package with `--extras paths` on a `win32` machine.
587+
588+
#### Exclusive extras
589+
590+
{{% warning %}}
591+
The first example will only work completely if you configure Poetry to not re-resolve for installation:
592+
593+
```bash
594+
poetry config installer.re-resolve false
595+
```
596+
597+
This is a new feature of Poetry 2.0 that may become the default in a future version of Poetry.
598+
599+
{{% /warning %}}
600+
601+
Keep in mind that all combinations of possible extras available in your project need to be compatible with each other.
602+
This means that in order to use differing or incompatible versions across different combinations, you need to make your
603+
extra markers *exclusive*. For example, the following installs PyTorch from one source repository with CPU versions
604+
when the `cuda` extra is *not* specified, while the other installs from another repository with a separate version set
605+
for GPUs when the `cuda` extra *is* specified:
606+
607+
```toml
608+
[project]
609+
name = "torch-example"
610+
requires-python = ">=3.10"
611+
dependencies = [
612+
"torch (==2.3.1+cpu) ; extra != 'cuda'",
613+
]
614+
615+
[project.optional-dependencies]
616+
cuda = [
617+
"torch (==2.3.1+cu118)",
618+
]
619+
620+
[tool.poetry]
621+
package-mode = false
622+
623+
[tool.poetry.dependencies]
624+
torch = [
625+
{ markers = "extra != 'cuda'", source = "pytorch-cpu"},
626+
{ markers = "extra == 'cuda'", source = "pytorch-cuda"},
627+
]
628+
629+
[[tool.poetry.source]]
630+
name = "pytorch-cpu"
631+
url = "https://download.pytorch.org/whl/cpu"
632+
priority = "explicit"
633+
634+
[[tool.poetry.source]]
635+
name = "pytorch-cuda"
636+
url = "https://download.pytorch.org/whl/cu118"
637+
priority = "explicit"
638+
```
639+
640+
For the CPU case, we have to specify `"extra != 'cuda'"` because the version specified is not compatible with the
641+
GPU (`cuda`) version.
642+
643+
This same logic applies when you want either-or extras:
644+
645+
```toml
646+
[project]
647+
name = "torch-example"
648+
requires-python = ">=3.10"
649+
650+
[project.optional-dependencies]
651+
cpu = [
652+
"torch (==2.3.1+cpu)",
653+
]
654+
cuda = [
655+
"torch (==2.3.1+cu118)",
656+
]
657+
658+
[tool.poetry]
659+
package-mode = false
660+
661+
[tool.poetry.dependencies]
662+
torch = [
663+
{ markers = "extra == 'cpu' and extra != 'cuda'", source = "pytorch-cpu"},
664+
{ markers = "extra == 'cuda' and extra != 'cpu'", source = "pytorch-cuda"},
665+
]
666+
667+
[[tool.poetry.source]]
668+
name = "pytorch-cpu"
669+
url = "https://download.pytorch.org/whl/cpu"
670+
priority = "explicit"
671+
672+
[[tool.poetry.source]]
673+
name = "pytorch-cuda"
674+
url = "https://download.pytorch.org/whl/cu118"
675+
priority = "explicit"
676+
```
677+
575678
## Multiple constraints dependencies
576679

577680
Sometimes, one of your dependency may have different version ranges depending

poetry.lock

Lines changed: 92 additions & 97 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/poetry/installation/installer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ def _do_install(self) -> int:
305305
self._installed_repository.packages,
306306
locked_repository.packages,
307307
NullIO(),
308+
active_root_extras=self._extras,
308309
)
309310
# Everything is resolved at this point, so we no longer need
310311
# to load deferred dependencies (i.e. VCS, URL and path dependencies)

src/poetry/puzzle/provider.py

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from collections import defaultdict
99
from contextlib import contextmanager
1010
from typing import TYPE_CHECKING
11+
from typing import Any
1112
from typing import ClassVar
1213
from typing import cast
1314

@@ -17,6 +18,7 @@
1718
from poetry.core.constraints.version import VersionRange
1819
from poetry.core.packages.utils.utils import get_python_constraint_from_marker
1920
from poetry.core.version.markers import AnyMarker
21+
from poetry.core.version.markers import parse_marker
2022
from poetry.core.version.markers import union as marker_union
2123

2224
from poetry.mixology.incompatibility import Incompatibility
@@ -115,6 +117,7 @@ def __init__(
115117
io: IO,
116118
*,
117119
locked: list[Package] | None = None,
120+
active_root_extras: Collection[NormalizedName] | None = None,
118121
) -> None:
119122
self._package = package
120123
self._pool = pool
@@ -130,6 +133,9 @@ def __init__(
130133
self._direct_origin_packages: dict[str, Package] = {}
131134
self._locked: dict[NormalizedName, list[DependencyPackage]] = defaultdict(list)
132135
self._use_latest: Collection[NormalizedName] = []
136+
self._active_root_extras = (
137+
frozenset(active_root_extras) if active_root_extras is not None else None
138+
)
133139

134140
self._explicit_sources: dict[str, str] = {}
135141
for package in locked or []:
@@ -416,21 +422,12 @@ def incompatibilities_for(
416422
)
417423
]
418424

419-
_dependencies = [
420-
dep
421-
for dep in dependencies
422-
if dep.name not in self.UNSAFE_PACKAGES
423-
and self._python_constraint.allows_any(dep.python_constraint)
424-
and (not self._env or dep.marker.validate(self._env.marker_env))
425-
]
426-
dependencies = self._get_dependencies_with_overrides(_dependencies, package)
427-
428425
return [
429426
Incompatibility(
430427
[Term(package.to_dependency(), True), Term(dep, False)],
431428
DependencyCauseError(),
432429
)
433-
for dep in dependencies
430+
for dep in self._get_dependencies_with_overrides(dependencies, package)
434431
]
435432

436433
def complete_package(
@@ -480,7 +477,7 @@ def complete_package(
480477
package = dependency_package.package
481478
dependency = dependency_package.dependency
482479
new_dependency = package.without_features().to_dependency()
483-
new_dependency.marker = AnyMarker()
480+
new_dependency.marker = dependency.marker
484481

485482
# When adding dependency foo[extra] -> foo, preserve foo's source, if it's
486483
# specified. This prevents us from trying to get foo from PyPI
@@ -497,8 +494,14 @@ def complete_package(
497494
if dep.name in self.UNSAFE_PACKAGES:
498495
continue
499496

500-
if self._env and not dep.marker.validate(self._env.marker_env):
501-
continue
497+
if self._env:
498+
marker_values = (
499+
self._marker_values(self._active_root_extras)
500+
if package.is_root()
501+
else self._env.marker_env
502+
)
503+
if not dep.marker.validate(marker_values):
504+
continue
502505

503506
if not package.is_root() and (
504507
(dep.is_optional() and dep.name not in optional_dependencies)
@@ -509,6 +512,24 @@ def complete_package(
509512
):
510513
continue
511514

515+
# For normal dependency resolution, we have to make sure that root extras
516+
# are represented in the markers. This is required to identify mutually
517+
# exclusive markers in cases like 'extra == "foo"' and 'extra != "foo"'.
518+
# However, for installation with re-resolving (installer.re-resolve=true,
519+
# which results in self._env being not None), this spoils the result
520+
# because we have to keep extras so that they are uninstalled
521+
# when calculating the operations of the transaction.
522+
if self._env is None and package.is_root() and dep.in_extras:
523+
# The clone is required for installation with re-resolving
524+
# without an existing lock file because the root package is used
525+
# once for solving and a second time for re-resolving for installation.
526+
dep = dep.clone()
527+
dep.marker = dep.marker.intersect(
528+
parse_marker(
529+
" or ".join(f'extra == "{extra}"' for extra in dep.in_extras)
530+
)
531+
)
532+
512533
_dependencies.append(dep)
513534

514535
if self._load_deferred:
@@ -545,7 +566,7 @@ def complete_package(
545566
# • pypiwin32 (219); sys_platform == "win32" and python_version < "3.6"
546567
duplicates: dict[str, list[Dependency]] = defaultdict(list)
547568
for dep in dependencies:
548-
duplicates[dep.complete_name].append(dep)
569+
duplicates[dep.name].append(dep)
549570

550571
dependencies = []
551572
for dep_name, deps in duplicates.items():
@@ -556,9 +577,39 @@ def complete_package(
556577
self.debug(f"<debug>Duplicate dependencies for {dep_name}</debug>")
557578

558579
# For dependency resolution, markers of duplicate dependencies must be
559-
# mutually exclusive.
560-
active_extras = None if package.is_root() else dependency.extras
561-
deps = self._resolve_overlapping_markers(package, deps, active_extras)
580+
# mutually exclusive. However, we have to take care about duplicates
581+
# with differing extras.
582+
duplicates_by_extras: dict[str, list[Dependency]] = defaultdict(list)
583+
for dep in deps:
584+
duplicates_by_extras[dep.complete_name].append(dep)
585+
586+
if len(duplicates_by_extras) == 1:
587+
active_extras = (
588+
self._active_root_extras if package.is_root() else dependency.extras
589+
)
590+
deps = self._resolve_overlapping_markers(package, deps, active_extras)
591+
else:
592+
# There are duplicates with different extras.
593+
for complete_dep_name, deps_by_extra in duplicates_by_extras.items():
594+
if len(deps_by_extra) > 1:
595+
duplicates_by_extras[complete_dep_name] = (
596+
self._resolve_overlapping_markers(package, deps, None)
597+
)
598+
if all(len(d) == 1 for d in duplicates_by_extras.values()) and all(
599+
d1[0].marker.intersect(d2[0].marker).is_empty()
600+
for d1, d2 in itertools.combinations(
601+
duplicates_by_extras.values(), 2
602+
)
603+
):
604+
# Since all markers are mutually exclusive,
605+
# we can trigger overrides.
606+
deps = list(itertools.chain(*duplicates_by_extras.values()))
607+
else:
608+
# Too complicated to handle with overrides,
609+
# fallback to basic handling without overrides.
610+
for d in duplicates_by_extras.values():
611+
dependencies.extend(d)
612+
continue
562613

563614
if len(deps) == 1:
564615
self.debug(f"<debug>Merging requirements for {dep_name}</debug>")
@@ -909,3 +960,19 @@ def _resolve_overlapping_markers(
909960
# dependencies by constraint again. After overlapping markers were
910961
# resolved, there might be new dependencies with the same constraint.
911962
return self._merge_dependencies_by_constraint(new_dependencies)
963+
964+
def _marker_values(
965+
self, extras: Collection[NormalizedName] | None = None
966+
) -> dict[str, Any]:
967+
"""
968+
Marker values, from `self._env` if present plus the supplied extras
969+
970+
:param extras: the values to add to the 'extra' marker value
971+
"""
972+
result = self._env.marker_env.copy() if self._env is not None else {}
973+
if extras is not None:
974+
assert (
975+
"extra" not in result
976+
), "'extra' marker key is already present in environment"
977+
result["extra"] = set(extras)
978+
return result

src/poetry/puzzle/solver.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,21 @@ def __init__(
4949
installed: list[Package],
5050
locked: list[Package],
5151
io: IO,
52+
active_root_extras: Collection[NormalizedName] | None = None,
5253
) -> None:
5354
self._package = package
5455
self._pool = pool
5556
self._installed_packages = installed
5657
self._locked_packages = locked
5758
self._io = io
5859

59-
self._provider = Provider(self._package, self._pool, self._io, locked=locked)
60+
self._provider = Provider(
61+
self._package,
62+
self._pool,
63+
self._io,
64+
locked=locked,
65+
active_root_extras=active_root_extras,
66+
)
6067
self._overrides: list[dict[Package, dict[str, Dependency]]] = []
6168

6269
@property

src/poetry/puzzle/transaction.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def calculate_operations(
7878
else:
7979
priorities = defaultdict(int)
8080
relevant_result_packages: set[NormalizedName] = set()
81-
uninstalls: set[NormalizedName] = set()
81+
pending_extra_uninstalls: list[Package] = [] # list for deterministic order
8282
for result_package in self._result_packages:
8383
is_unsolicited_extra = False
8484
if self._marker_env:
@@ -95,11 +95,12 @@ def calculate_operations(
9595
else:
9696
continue
9797
else:
98-
relevant_result_packages.add(result_package.name)
9998
is_unsolicited_extra = extras is not None and (
10099
result_package.optional
101100
and result_package.name not in extra_packages
102101
)
102+
if not is_unsolicited_extra:
103+
relevant_result_packages.add(result_package.name)
103104

104105
installed = False
105106
for installed_package in self._installed_packages:
@@ -108,9 +109,7 @@ def calculate_operations(
108109

109110
# Extras that were not requested are always uninstalled.
110111
if is_unsolicited_extra:
111-
uninstalls.add(installed_package.name)
112-
if installed_package.name not in system_site_packages:
113-
operations.append(Uninstall(installed_package))
112+
pending_extra_uninstalls.append(installed_package)
114113

115114
# We have to perform an update if the version or another
116115
# attribute of the package has changed (source type, url, ref, ...).
@@ -153,6 +152,13 @@ def calculate_operations(
153152
op.skip("Not required")
154153
operations.append(op)
155154

155+
uninstalls: set[NormalizedName] = set()
156+
for package in pending_extra_uninstalls:
157+
if package.name not in (relevant_result_packages | uninstalls):
158+
uninstalls.add(package.name)
159+
if package.name not in system_site_packages:
160+
operations.append(Uninstall(package))
161+
156162
if with_uninstalls:
157163
for current_package in self._current_packages:
158164
found = current_package.name in (relevant_result_packages | uninstalls)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[[package]]
2+
name = "conflicting-dep"
3+
version = "1.1.0"
4+
description = ""
5+
optional = true
6+
python-versions = "*"
7+
files = [ ]
8+
groups = [ "main" ]
9+
markers = "extra == \"extra-one\" and extra != \"extra-two\""
10+
11+
[[package]]
12+
name = "conflicting-dep"
13+
version = "1.2.0"
14+
description = ""
15+
optional = true
16+
python-versions = "*"
17+
files = [ ]
18+
groups = [ "main" ]
19+
markers = "extra != \"extra-one\" and extra == \"extra-two\""
20+
21+
[extras]
22+
extra-one = [ "conflicting-dep", "conflicting-dep" ]
23+
extra-two = [ "conflicting-dep", "conflicting-dep" ]
24+
25+
[metadata]
26+
lock-version = "2.1"
27+
python-versions = "*"
28+
content-hash = "123456789"

0 commit comments

Comments
 (0)