Skip to content

Commit 15c234d

Browse files
committed
solver: full support for duplicate dependencies with overlapping markers
1 parent b17b082 commit 15c234d

File tree

6 files changed

+385
-224
lines changed

6 files changed

+385
-224
lines changed

src/poetry/puzzle/provider.py

Lines changed: 129 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import itertools
34
import logging
45
import re
56
import time
@@ -12,10 +13,11 @@
1213
from cleo.ui.progress_indicator import ProgressIndicator
1314
from poetry.core.constraints.version import EmptyConstraint
1415
from poetry.core.constraints.version import Version
16+
from poetry.core.constraints.version import VersionRange
1517
from poetry.core.packages.utils.utils import get_python_constraint_from_marker
1618
from poetry.core.version.markers import AnyMarker
17-
from poetry.core.version.markers import EmptyMarker
18-
from poetry.core.version.markers import MarkerUnion
19+
from poetry.core.version.markers import intersection as marker_intersection
20+
from poetry.core.version.markers import union as marker_union
1921

2022
from poetry.mixology.incompatibility import Incompatibility
2123
from poetry.mixology.incompatibility_cause import DependencyCause
@@ -59,10 +61,22 @@ class IncompatibleConstraintsError(Exception):
5961
Exception when there are duplicate dependencies with incompatible constraints.
6062
"""
6163

62-
def __init__(self, package: Package, *dependencies: Dependency) -> None:
63-
constraints = "\n".join(dep.to_pep_508() for dep in dependencies)
64+
def __init__(
65+
self, package: Package, *dependencies: Dependency, with_sources: bool = False
66+
) -> None:
67+
constraints = []
68+
for dep in dependencies:
69+
constraint = dep.to_pep_508()
70+
if dep.is_direct_origin():
71+
# add version info because issue might be a version conflict
72+
# with a version constraint
73+
constraint += f" ({dep.constraint})"
74+
if with_sources and dep.source_name:
75+
constraint += f" ; source={dep.source_name}"
76+
constraints.append(constraint)
6477
super().__init__(
65-
f"Incompatible constraints in requirements of {package}:\n{constraints}"
78+
f"Incompatible constraints in requirements of {package}:\n"
79+
+ "\n".join(constraints)
6680
)
6781

6882

@@ -590,55 +604,15 @@ def complete_package(
590604

591605
self.debug(f"<debug>Duplicate dependencies for {dep_name}</debug>")
592606

593-
# Group dependencies for merging.
594-
# We must not merge dependencies from different sources!
595-
dep_groups = self._group_by_source(deps)
596-
deps = []
597-
for group in dep_groups:
598-
# In order to reduce the number of overrides we merge duplicate
599-
# dependencies by constraint. For instance, if we have:
600-
# • foo (>=2.0) ; python_version >= "3.6" and python_version < "3.7"
601-
# • foo (>=2.0) ; python_version >= "3.7"
602-
# we can avoid two overrides by merging them to:
603-
# • foo (>=2.0) ; python_version >= "3.6"
604-
# However, if we want to merge dependencies by constraint we have to
605-
# merge dependencies by markers first in order to avoid unnecessary
606-
# solver failures. For instance, if we have:
607-
# • foo (>=2.0) ; python_version >= "3.6" and python_version < "3.7"
608-
# • foo (>=2.0) ; python_version >= "3.7"
609-
# • foo (<2.1) ; python_version >= "3.7"
610-
# we must not merge the first two constraints but the last two:
611-
# • foo (>=2.0) ; python_version >= "3.6" and python_version < "3.7"
612-
# • foo (>=2.0,<2.1) ; python_version >= "3.7"
613-
deps += self._merge_dependencies_by_constraint(
614-
self._merge_dependencies_by_marker(group)
615-
)
607+
# For dependency resolution, markers of duplicate dependencies must be
608+
# mutually exclusive.
609+
deps = self._resolve_overlapping_markers(package, deps)
610+
616611
if len(deps) == 1:
617612
self.debug(f"<debug>Merging requirements for {deps[0]!s}</debug>")
618613
dependencies.append(deps[0])
619614
continue
620615

621-
# We leave dependencies as-is if they have the same
622-
# python/platform constraints.
623-
# That way the resolver will pickup the conflict
624-
# and display a proper error.
625-
seen = set()
626-
for dep in deps:
627-
pep_508_dep = dep.to_pep_508(False)
628-
if ";" not in pep_508_dep:
629-
_requirements = ""
630-
else:
631-
_requirements = pep_508_dep.split(";")[1].strip()
632-
633-
if _requirements not in seen:
634-
seen.add(_requirements)
635-
636-
if len(deps) != len(seen):
637-
for dep in deps:
638-
dependencies.append(dep)
639-
640-
continue
641-
642616
# At this point, we raise an exception that will
643617
# tell the solver to make new resolutions with specific overrides.
644618
#
@@ -664,8 +638,6 @@ def fmt_warning(d: Dependency) -> str:
664638
f"<warning>Different requirements found for {warnings}.</warning>"
665639
)
666640

667-
deps = self._handle_any_marker_dependencies(package, deps)
668-
669641
overrides = []
670642
overrides_marker_intersection: BaseMarker = AnyMarker()
671643
for dep_overrides in self._overrides.values():
@@ -690,18 +662,18 @@ def fmt_warning(d: Dependency) -> str:
690662
clean_dependencies = []
691663
for dep in dependencies:
692664
if not dependency.transitive_marker.without_extras().is_any():
693-
marker_intersection = (
665+
transitive_marker_intersection = (
694666
dependency.transitive_marker.without_extras().intersect(
695667
dep.marker.without_extras()
696668
)
697669
)
698-
if marker_intersection.is_empty():
670+
if transitive_marker_intersection.is_empty():
699671
# The dependency is not needed, since the markers specified
700672
# for the current package selection are not compatible with
701673
# the markers for the current dependency, so we skip it
702674
continue
703675

704-
dep.transitive_marker = marker_intersection
676+
dep.transitive_marker = transitive_marker_intersection
705677

706678
if not dependency.python_constraint.is_any():
707679
python_constraint_intersection = dep.python_constraint.intersect(
@@ -845,118 +817,121 @@ def _merge_dependencies_by_constraint(
845817
"""
846818
Merge dependencies with the same constraint
847819
by building a union of their markers.
848-
"""
849-
by_constraint: dict[VersionConstraint, list[Dependency]] = defaultdict(list)
850-
for dep in dependencies:
851-
by_constraint[dep.constraint].append(dep)
852-
for constraint, _deps in by_constraint.items():
853-
new_markers = [dep.marker for dep in _deps]
854820
855-
dep = _deps[0]
856-
857-
# Union with EmptyMarker is to make sure we get the benefit of marker
858-
# simplifications.
859-
dep.marker = MarkerUnion(*new_markers).union(EmptyMarker())
860-
by_constraint[constraint] = [dep]
861-
862-
return [value[0] for value in by_constraint.values()]
863-
864-
def _merge_dependencies_by_marker(
865-
self, dependencies: Iterable[Dependency]
866-
) -> list[Dependency]:
821+
For instance, if we have:
822+
- foo (>=2.0) ; python_version >= "3.6" and python_version < "3.7"
823+
- foo (>=2.0) ; python_version >= "3.7"
824+
we can avoid two overrides by merging them to:
825+
- foo (>=2.0) ; python_version >= "3.6"
867826
"""
868-
Merge dependencies with the same marker
869-
by building the intersection of their constraints.
827+
dep_groups = self._group_by_source(dependencies)
828+
merged_dependencies = []
829+
for group in dep_groups:
830+
by_constraint: dict[VersionConstraint, list[Dependency]] = defaultdict(list)
831+
for dep in group:
832+
by_constraint[dep.constraint].append(dep)
833+
for constraint, deps in by_constraint.items():
834+
new_markers = [dep.marker for dep in deps]
835+
dep = deps[0]
836+
dep.marker = marker_union(*new_markers)
837+
by_constraint[constraint] = [dep]
838+
839+
merged_dependencies += [value[0] for value in by_constraint.values()]
840+
841+
return merged_dependencies
842+
843+
def _is_relevant_marker(self, marker: BaseMarker) -> bool:
870844
"""
871-
by_marker: dict[BaseMarker, list[Dependency]] = defaultdict(list)
872-
for dep in dependencies:
873-
by_marker[dep.marker].append(dep)
874-
deps = []
875-
for _deps in by_marker.values():
876-
if len(_deps) == 1:
877-
deps.extend(_deps)
878-
else:
879-
new_constraint = _deps[0].constraint
880-
for dep in _deps[1:]:
881-
new_constraint = new_constraint.intersect(dep.constraint)
882-
if new_constraint.is_empty():
883-
# leave dependencies as-is so the resolver will pickup
884-
# the conflict and display a proper error.
885-
deps.extend(_deps)
886-
else:
887-
self.debug(
888-
f"<debug>Merging constraints for {_deps[0].name} for"
889-
f" marker {_deps[0].marker}</debug>"
890-
)
891-
deps.append(_deps[0].with_constraint(new_constraint))
892-
return deps
845+
A marker is relevant if
846+
- it is not empty
847+
- allowed by the project's python constraint
848+
- allowed by the environment (only during installation)
849+
"""
850+
return (
851+
not marker.is_empty()
852+
and self._python_constraint.allows_any(
853+
get_python_constraint_from_marker(marker)
854+
)
855+
and (not self._env or marker.validate(self._env.marker_env))
856+
)
893857

894-
def _handle_any_marker_dependencies(
858+
def _resolve_overlapping_markers(
895859
self, package: Package, dependencies: list[Dependency]
896860
) -> list[Dependency]:
897861
"""
898-
We need to check if one of the duplicate dependencies
899-
has no markers. If there is one, we need to change its
900-
environment markers to the inverse of the union of the
901-
other dependencies markers.
902-
For instance, if we have the following dependencies:
903-
• ipython
904-
• ipython (1.2.4) ; implementation_name == "pypy"
905-
906-
the marker for `ipython` will become `implementation_name != "pypy"`.
907-
908-
Further, we have to merge the constraints of the requirements
909-
without markers into the constraints of the requirements with markers.
910-
for instance, if we have the following dependencies:
911-
• foo (>= 1.2)
912-
• foo (!= 1.2.1) ; python == 3.10
913-
914-
the constraint for the second entry will become (!= 1.2.1, >= 1.2).
862+
Convert duplicate dependencies with potentially overlapping markers
863+
into duplicate dependencies with mutually exclusive markers.
864+
865+
Therefore, the intersections of all combinations of markers and inverted markers
866+
have to be calculated. If such an intersection is relevant (not empty, etc.),
867+
the intersection of all constraints, whose markers were not inverted is built
868+
and a new dependency with the calculated version constraint and marker is added.
869+
(The marker of such a dependency does not overlap with the marker
870+
of any other new dependency.)
915871
"""
916-
any_markers_dependencies = [d for d in dependencies if d.marker.is_any()]
917-
other_markers_dependencies = [d for d in dependencies if not d.marker.is_any()]
918-
919-
if any_markers_dependencies:
920-
for dep_other in other_markers_dependencies:
921-
new_constraint = dep_other.constraint
922-
for dep_any in any_markers_dependencies:
923-
new_constraint = new_constraint.intersect(dep_any.constraint)
924-
if new_constraint.is_empty():
925-
raise IncompatibleConstraintsError(
926-
package, dep_other, *any_markers_dependencies
927-
)
928-
dep_other.constraint = new_constraint
929-
930-
marker = other_markers_dependencies[0].marker
931-
for other_dep in other_markers_dependencies[1:]:
932-
marker = marker.union(other_dep.marker)
933-
inverted_marker = marker.invert()
934-
935-
if (
936-
not inverted_marker.is_empty()
937-
and self._python_constraint.allows_any(
938-
get_python_constraint_from_marker(inverted_marker)
872+
# In order to reduce the number of intersections,
873+
# we merge duplicate dependencies by constraint.
874+
dependencies = self._merge_dependencies_by_constraint(dependencies)
875+
876+
new_dependencies = []
877+
for uses in itertools.product([True, False], repeat=len(dependencies)):
878+
# intersection of markers
879+
used_marker_intersection = marker_intersection(
880+
*(
881+
dep.marker if use else dep.marker.invert()
882+
for use, dep in zip(uses, dependencies)
883+
)
939884
)
940-
and (not self._env or inverted_marker.validate(self._env.marker_env))
941-
):
942-
if any_markers_dependencies:
943-
for dep_any in any_markers_dependencies:
944-
dep_any.marker = inverted_marker
945-
else:
946-
# If there is no any marker dependency
947-
# and the inverted marker is not empty,
948-
# a dependency with the inverted union of all markers is required
949-
# in order to not miss other dependencies later, for instance:
885+
if not self._is_relevant_marker(used_marker_intersection):
886+
continue
887+
888+
# intersection of constraints
889+
constraint: VersionConstraint = VersionRange()
890+
specific_source_dependency = None
891+
used_dependencies = list(itertools.compress(dependencies, uses))
892+
for dep in used_dependencies:
893+
if dep.is_direct_origin() or dep.source_name:
894+
# if direct origin or specific source:
895+
# conflict if specific source already set and not the same
896+
if specific_source_dependency and (
897+
not dep.is_same_source_as(specific_source_dependency)
898+
or dep.source_name != specific_source_dependency.source_name
899+
):
900+
raise IncompatibleConstraintsError(
901+
package, dep, specific_source_dependency, with_sources=True
902+
)
903+
specific_source_dependency = dep
904+
constraint = constraint.intersect(dep.constraint)
905+
if constraint.is_empty():
906+
# conflict in overlapping area
907+
raise IncompatibleConstraintsError(package, *used_dependencies)
908+
909+
if set(uses) == {False}:
910+
# This is an edge case where the dependency is not required
911+
# for the resulting marker. However, we have to consider it anyway
912+
# in order to not miss other dependencies later, for instance:
950913
# • foo (1.0) ; python == 3.7
951914
# • foo (2.0) ; python == 3.8
952915
# • bar (2.0) ; python == 3.8
953916
# • bar (3.0) ; python == 3.9
954-
#
955917
# the last dependency would be missed without this,
956918
# because the intersection with both foo dependencies is empty.
957-
inverted_marker_dep = dependencies[0].with_constraint(EmptyConstraint())
958-
inverted_marker_dep.marker = inverted_marker
959-
dependencies.append(inverted_marker_dep)
960-
else:
961-
dependencies = other_markers_dependencies
962-
return dependencies
919+
920+
# Set constraint to empty to mark dependency as "not required".
921+
constraint = EmptyConstraint()
922+
used_dependencies = dependencies
923+
924+
# build new dependency with intersected constraint and marker
925+
# (and correct source)
926+
new_dep = (
927+
specific_source_dependency
928+
if specific_source_dependency
929+
else used_dependencies[0]
930+
).with_constraint(constraint)
931+
new_dep.marker = used_marker_intersection
932+
new_dependencies.append(new_dep)
933+
934+
# In order to reduce the number of overrides we merge duplicate
935+
# dependencies by constraint again. After overlapping markers were
936+
# resolved, there might be new dependencies with the same constraint.
937+
return self._merge_dependencies_by_constraint(new_dependencies)

tests/installation/fixtures/with-duplicate-dependencies.test

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ files = []
88

99
[package.dependencies]
1010
B = [
11-
{version = "^1.0", markers = "python_version < \"4.0\""},
12-
{version = "^2.0", markers = "python_version >= \"4.0\""},
11+
{version = ">=1.0,<2.0", markers = "python_version < \"4.0\""},
12+
{version = ">=2.0,<3.0", markers = "python_version >= \"4.0\""},
1313
]
1414

1515
[[package]]

tests/installation/fixtures/with-multiple-updates.test

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ files = []
99
[package.dependencies]
1010
B = ">=1.0.1"
1111
C = [
12-
{version = "^1.0", markers = "python_version >= \"2.7\" and python_version < \"2.8\""},
13-
{version = "^2.0", markers = "python_version >= \"3.4\" and python_version < \"4.0\""},
12+
{version = ">=1.0,<2.0", markers = "python_version >= \"2.7\" and python_version < \"2.8\""},
13+
{version = ">=2.0,<3.0", markers = "python_version >= \"3.4\" and python_version < \"4.0\""},
1414
]
1515

1616
[[package]]

0 commit comments

Comments
 (0)