From 4bc3738ab22339a5047cef72098da5d1f1752c6e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Randy=20D=C3=B6ring?=
<30527984+radoering@users.noreply.github.com>
Date: Mon, 26 Dec 2022 14:23:36 +0100
Subject: [PATCH] solver: full support for duplicate dependencies with
overlapping markers
---
src/poetry/puzzle/provider.py | 283 ++++++++----------
.../fixtures/with-duplicate-dependencies.test | 4 +-
.../fixtures/with-multiple-updates.test | 4 +-
.../version_solver/test_unsolvable.py | 25 +-
tests/puzzle/test_provider.py | 132 +++++---
tests/puzzle/test_solver.py | 171 +++++++++--
6 files changed, 394 insertions(+), 225 deletions(-)
diff --git a/src/poetry/puzzle/provider.py b/src/poetry/puzzle/provider.py
index 69302a94acb..4c595e2c2fe 100644
--- a/src/poetry/puzzle/provider.py
+++ b/src/poetry/puzzle/provider.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import functools
+import itertools
import logging
import os
import re
@@ -18,10 +19,11 @@
from cleo.ui.progress_indicator import ProgressIndicator
from poetry.core.constraints.version import EmptyConstraint
from poetry.core.constraints.version import Version
+from poetry.core.constraints.version import VersionRange
from poetry.core.packages.utils.utils import get_python_constraint_from_marker
from poetry.core.version.markers import AnyMarker
-from poetry.core.version.markers import EmptyMarker
-from poetry.core.version.markers import MarkerUnion
+from poetry.core.version.markers import intersection as marker_intersection
+from poetry.core.version.markers import union as marker_union
from poetry.inspection.info import PackageInfo
from poetry.inspection.info import PackageInfoError
@@ -66,10 +68,22 @@ class IncompatibleConstraintsError(Exception):
Exception when there are duplicate dependencies with incompatible constraints.
"""
- def __init__(self, package: Package, *dependencies: Dependency) -> None:
- constraints = "\n".join(dep.to_pep_508() for dep in dependencies)
+ def __init__(
+ self, package: Package, *dependencies: Dependency, with_sources: bool = False
+ ) -> None:
+ constraints = []
+ for dep in dependencies:
+ constraint = dep.to_pep_508()
+ if dep.is_direct_origin():
+ # add version info because issue might be a version conflict
+ # with a version constraint
+ constraint += f" ({dep.constraint})"
+ if with_sources and dep.source_name:
+ constraint += f" ; source={dep.source_name}"
+ constraints.append(constraint)
super().__init__(
- f"Incompatible constraints in requirements of {package}:\n{constraints}"
+ f"Incompatible constraints in requirements of {package}:\n"
+ + "\n".join(constraints)
)
@@ -687,55 +701,15 @@ def complete_package(
self.debug(f"Duplicate dependencies for {dep_name}")
- # Group dependencies for merging.
- # We must not merge dependencies from different sources!
- dep_groups = self._group_by_source(deps)
- deps = []
- for group in dep_groups:
- # In order to reduce the number of overrides we merge duplicate
- # dependencies by constraint. For instance, if we have:
- # - foo (>=2.0) ; python_version >= "3.6" and python_version < "3.7"
- # - foo (>=2.0) ; python_version >= "3.7"
- # we can avoid two overrides by merging them to:
- # - foo (>=2.0) ; python_version >= "3.6"
- # However, if we want to merge dependencies by constraint we have to
- # merge dependencies by markers first in order to avoid unnecessary
- # solver failures. For instance, if we have:
- # - foo (>=2.0) ; python_version >= "3.6" and python_version < "3.7"
- # - foo (>=2.0) ; python_version >= "3.7"
- # - foo (<2.1) ; python_version >= "3.7"
- # we must not merge the first two constraints but the last two:
- # - foo (>=2.0) ; python_version >= "3.6" and python_version < "3.7"
- # - foo (>=2.0,<2.1) ; python_version >= "3.7"
- deps += self._merge_dependencies_by_constraint(
- self._merge_dependencies_by_marker(group)
- )
+ # For dependency resolution, markers of duplicate dependencies must be
+ # mutually exclusive.
+ deps = self._resolve_overlapping_markers(package, deps)
+
if len(deps) == 1:
self.debug(f"Merging requirements for {deps[0]!s}")
dependencies.append(deps[0])
continue
- # We leave dependencies as-is if they have the same
- # python/platform constraints.
- # That way the resolver will pickup the conflict
- # and display a proper error.
- seen = set()
- for dep in deps:
- pep_508_dep = dep.to_pep_508(False)
- if ";" not in pep_508_dep:
- _requirements = ""
- else:
- _requirements = pep_508_dep.split(";")[1].strip()
-
- if _requirements not in seen:
- seen.add(_requirements)
-
- if len(deps) != len(seen):
- for dep in deps:
- dependencies.append(dep)
-
- continue
-
# At this point, we raise an exception that will
# tell the solver to make new resolutions with specific overrides.
#
@@ -761,8 +735,6 @@ def fmt_warning(d: Dependency) -> str:
f"Different requirements found for {warnings}."
)
- deps = self._handle_any_marker_dependencies(package, deps)
-
overrides = []
overrides_marker_intersection: BaseMarker = AnyMarker()
for dep_overrides in self._overrides.values():
@@ -787,18 +759,18 @@ def fmt_warning(d: Dependency) -> str:
clean_dependencies = []
for dep in dependencies:
if not dependency.transitive_marker.without_extras().is_any():
- marker_intersection = (
+ transitive_marker_intersection = (
dependency.transitive_marker.without_extras().intersect(
dep.marker.without_extras()
)
)
- if marker_intersection.is_empty():
+ if transitive_marker_intersection.is_empty():
# The dependency is not needed, since the markers specified
# for the current package selection are not compatible with
# the markers for the current dependency, so we skip it
continue
- dep.transitive_marker = marker_intersection
+ dep.transitive_marker = transitive_marker_intersection
if not dependency.python_constraint.is_any():
python_constraint_intersection = dep.python_constraint.intersect(
@@ -942,118 +914,121 @@ def _merge_dependencies_by_constraint(
"""
Merge dependencies with the same constraint
by building a union of their markers.
- """
- by_constraint: dict[VersionConstraint, list[Dependency]] = defaultdict(list)
- for dep in dependencies:
- by_constraint[dep.constraint].append(dep)
- for constraint, _deps in by_constraint.items():
- new_markers = [dep.marker for dep in _deps]
- dep = _deps[0]
-
- # Union with EmptyMarker is to make sure we get the benefit of marker
- # simplifications.
- dep.marker = MarkerUnion(*new_markers).union(EmptyMarker())
- by_constraint[constraint] = [dep]
-
- return [value[0] for value in by_constraint.values()]
-
- def _merge_dependencies_by_marker(
- self, dependencies: Iterable[Dependency]
- ) -> list[Dependency]:
+ For instance, if we have:
+ - foo (>=2.0) ; python_version >= "3.6" and python_version < "3.7"
+ - foo (>=2.0) ; python_version >= "3.7"
+ we can avoid two overrides by merging them to:
+ - foo (>=2.0) ; python_version >= "3.6"
"""
- Merge dependencies with the same marker
- by building the intersection of their constraints.
+ dep_groups = self._group_by_source(dependencies)
+ merged_dependencies = []
+ for group in dep_groups:
+ by_constraint: dict[VersionConstraint, list[Dependency]] = defaultdict(list)
+ for dep in group:
+ by_constraint[dep.constraint].append(dep)
+ for constraint, deps in by_constraint.items():
+ new_markers = [dep.marker for dep in deps]
+ dep = deps[0]
+ dep.marker = marker_union(*new_markers)
+ by_constraint[constraint] = [dep]
+
+ merged_dependencies += [value[0] for value in by_constraint.values()]
+
+ return merged_dependencies
+
+ def _is_relevant_marker(self, marker: BaseMarker) -> bool:
"""
- by_marker: dict[BaseMarker, list[Dependency]] = defaultdict(list)
- for dep in dependencies:
- by_marker[dep.marker].append(dep)
- deps = []
- for _deps in by_marker.values():
- if len(_deps) == 1:
- deps.extend(_deps)
- else:
- new_constraint = _deps[0].constraint
- for dep in _deps[1:]:
- new_constraint = new_constraint.intersect(dep.constraint)
- if new_constraint.is_empty():
- # leave dependencies as-is so the resolver will pickup
- # the conflict and display a proper error.
- deps.extend(_deps)
- else:
- self.debug(
- f"Merging constraints for {_deps[0].name} for"
- f" marker {_deps[0].marker}"
- )
- deps.append(_deps[0].with_constraint(new_constraint))
- return deps
+ A marker is relevant if
+ - it is not empty
+ - allowed by the project's python constraint
+ - allowed by the environment (only during installation)
+ """
+ return (
+ not marker.is_empty()
+ and self._python_constraint.allows_any(
+ get_python_constraint_from_marker(marker)
+ )
+ and (not self._env or marker.validate(self._env.marker_env))
+ )
- def _handle_any_marker_dependencies(
+ def _resolve_overlapping_markers(
self, package: Package, dependencies: list[Dependency]
) -> list[Dependency]:
"""
- We need to check if one of the duplicate dependencies
- has no markers. If there is one, we need to change its
- environment markers to the inverse of the union of the
- other dependencies markers.
- For instance, if we have the following dependencies:
- - ipython
- - ipython (1.2.4) ; implementation_name == "pypy"
-
- the marker for `ipython` will become `implementation_name != "pypy"`.
-
- Further, we have to merge the constraints of the requirements
- without markers into the constraints of the requirements with markers.
- for instance, if we have the following dependencies:
- - foo (>= 1.2)
- - foo (!= 1.2.1) ; python == 3.10
-
- the constraint for the second entry will become (!= 1.2.1, >= 1.2).
+ Convert duplicate dependencies with potentially overlapping markers
+ into duplicate dependencies with mutually exclusive markers.
+
+ Therefore, the intersections of all combinations of markers and inverted markers
+ have to be calculated. If such an intersection is relevant (not empty, etc.),
+ the intersection of all constraints, whose markers were not inverted is built
+ and a new dependency with the calculated version constraint and marker is added.
+ (The marker of such a dependency does not overlap with the marker
+ of any other new dependency.)
"""
- any_markers_dependencies = [d for d in dependencies if d.marker.is_any()]
- other_markers_dependencies = [d for d in dependencies if not d.marker.is_any()]
-
- if any_markers_dependencies:
- for dep_other in other_markers_dependencies:
- new_constraint = dep_other.constraint
- for dep_any in any_markers_dependencies:
- new_constraint = new_constraint.intersect(dep_any.constraint)
- if new_constraint.is_empty():
- raise IncompatibleConstraintsError(
- package, dep_other, *any_markers_dependencies
- )
- dep_other.constraint = new_constraint
-
- marker = other_markers_dependencies[0].marker
- for other_dep in other_markers_dependencies[1:]:
- marker = marker.union(other_dep.marker)
- inverted_marker = marker.invert()
-
- if (
- not inverted_marker.is_empty()
- and self._python_constraint.allows_any(
- get_python_constraint_from_marker(inverted_marker)
+ # In order to reduce the number of intersections,
+ # we merge duplicate dependencies by constraint.
+ dependencies = self._merge_dependencies_by_constraint(dependencies)
+
+ new_dependencies = []
+ for uses in itertools.product([True, False], repeat=len(dependencies)):
+ # intersection of markers
+ used_marker_intersection = marker_intersection(
+ *(
+ dep.marker if use else dep.marker.invert()
+ for use, dep in zip(uses, dependencies)
+ )
)
- and (not self._env or inverted_marker.validate(self._env.marker_env))
- ):
- if any_markers_dependencies:
- for dep_any in any_markers_dependencies:
- dep_any.marker = inverted_marker
- else:
- # If there is no any marker dependency
- # and the inverted marker is not empty,
- # a dependency with the inverted union of all markers is required
- # in order to not miss other dependencies later, for instance:
+ if not self._is_relevant_marker(used_marker_intersection):
+ continue
+
+ # intersection of constraints
+ constraint: VersionConstraint = VersionRange()
+ specific_source_dependency = None
+ used_dependencies = list(itertools.compress(dependencies, uses))
+ for dep in used_dependencies:
+ if dep.is_direct_origin() or dep.source_name:
+ # if direct origin or specific source:
+ # conflict if specific source already set and not the same
+ if specific_source_dependency and (
+ not dep.is_same_source_as(specific_source_dependency)
+ or dep.source_name != specific_source_dependency.source_name
+ ):
+ raise IncompatibleConstraintsError(
+ package, dep, specific_source_dependency, with_sources=True
+ )
+ specific_source_dependency = dep
+ constraint = constraint.intersect(dep.constraint)
+ if constraint.is_empty():
+ # conflict in overlapping area
+ raise IncompatibleConstraintsError(package, *used_dependencies)
+
+ if set(uses) == {False}:
+ # This is an edge case where the dependency is not required
+ # for the resulting marker. However, we have to consider it anyway
+ # in order to not miss other dependencies later, for instance:
# - foo (1.0) ; python == 3.7
# - foo (2.0) ; python == 3.8
# - bar (2.0) ; python == 3.8
# - bar (3.0) ; python == 3.9
- #
# the last dependency would be missed without this,
# because the intersection with both foo dependencies is empty.
- inverted_marker_dep = dependencies[0].with_constraint(EmptyConstraint())
- inverted_marker_dep.marker = inverted_marker
- dependencies.append(inverted_marker_dep)
- else:
- dependencies = other_markers_dependencies
- return dependencies
+
+ # Set constraint to empty to mark dependency as "not required".
+ constraint = EmptyConstraint()
+ used_dependencies = dependencies
+
+ # build new dependency with intersected constraint and marker
+ # (and correct source)
+ new_dep = (
+ specific_source_dependency
+ if specific_source_dependency
+ else used_dependencies[0]
+ ).with_constraint(constraint)
+ new_dep.marker = used_marker_intersection
+ new_dependencies.append(new_dep)
+
+ # In order to reduce the number of overrides we merge duplicate
+ # 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)
diff --git a/tests/installation/fixtures/with-duplicate-dependencies.test b/tests/installation/fixtures/with-duplicate-dependencies.test
index c2b16990725..153abbfe48d 100644
--- a/tests/installation/fixtures/with-duplicate-dependencies.test
+++ b/tests/installation/fixtures/with-duplicate-dependencies.test
@@ -9,8 +9,8 @@ files = []
[package.dependencies]
B = [
- {version = "^1.0", markers = "python_version < \"4.0\""},
- {version = "^2.0", markers = "python_version >= \"4.0\""},
+ {version = ">=1.0,<2.0", markers = "python_version < \"4.0\""},
+ {version = ">=2.0,<3.0", markers = "python_version >= \"4.0\""},
]
[[package]]
diff --git a/tests/installation/fixtures/with-multiple-updates.test b/tests/installation/fixtures/with-multiple-updates.test
index 929322c608d..b7ae88b9ba3 100644
--- a/tests/installation/fixtures/with-multiple-updates.test
+++ b/tests/installation/fixtures/with-multiple-updates.test
@@ -10,8 +10,8 @@ files = []
[package.dependencies]
B = ">=1.0.1"
C = [
- {version = "^1.0", markers = "python_version >= \"2.7\" and python_version < \"2.8\""},
- {version = "^2.0", markers = "python_version >= \"3.4\" and python_version < \"4.0\""},
+ {version = ">=1.0,<2.0", markers = "python_version >= \"2.7\" and python_version < \"2.8\""},
+ {version = ">=2.0,<3.0", markers = "python_version >= \"3.4\" and python_version < \"4.0\""},
]
[[package]]
diff --git a/tests/mixology/version_solver/test_unsolvable.py b/tests/mixology/version_solver/test_unsolvable.py
index 75ff37da9cb..4e3f6e5336f 100644
--- a/tests/mixology/version_solver/test_unsolvable.py
+++ b/tests/mixology/version_solver/test_unsolvable.py
@@ -3,7 +3,10 @@
from pathlib import Path
from typing import TYPE_CHECKING
+import pytest
+
from poetry.factory import Factory
+from poetry.puzzle.provider import IncompatibleConstraintsError
from tests.mixology.helpers import add_to_repo
from tests.mixology.helpers import check_solver_result
@@ -88,9 +91,14 @@ def test_disjoint_root_constraints(
add_to_repo(repo, "foo", "2.0.0")
error = """\
-Because myapp depends on both foo (1.0.0) and foo (2.0.0), version solving failed."""
+Incompatible constraints in requirements of myapp (0.0.0):
+foo (==1.0.0)
+foo (==2.0.0)"""
- check_solver_result(root, provider, error=error)
+ with pytest.raises(IncompatibleConstraintsError) as e:
+ check_solver_result(root, provider, error=error)
+
+ assert str(e.value) == error
def test_disjoint_root_constraints_path_dependencies(
@@ -104,12 +112,15 @@ def test_disjoint_root_constraints_path_dependencies(
dependency2 = Factory.create_dependency("demo", {"path": project_dir / "demo_two"})
root.add_dependency(dependency2)
- error = (
- f"Because myapp depends on both {str(dependency1).replace('*', '1.2.3')} "
- f"and {str(dependency2).replace('*', '1.2.3')}, version solving failed."
- )
+ error = f"""\
+Incompatible constraints in requirements of myapp (0.0.0):
+demo @ file:///{project_dir.as_posix()}/demo_two (1.2.3)
+demo @ file:///{project_dir.as_posix()}/demo_one (1.2.3)"""
- check_solver_result(root, provider, error=error)
+ with pytest.raises(IncompatibleConstraintsError) as e:
+ check_solver_result(root, provider, error=error)
+
+ assert str(e.value) == error
def test_no_valid_solution(root: ProjectPackage, provider: Provider, repo: Repository):
diff --git a/tests/puzzle/test_provider.py b/tests/puzzle/test_provider.py
index 5186af99713..93ed74e17ba 100644
--- a/tests/puzzle/test_provider.py
+++ b/tests/puzzle/test_provider.py
@@ -17,6 +17,7 @@
from poetry.factory import Factory
from poetry.inspection.info import PackageInfo
from poetry.packages import DependencyPackage
+from poetry.puzzle.provider import IncompatibleConstraintsError
from poetry.puzzle.provider import Provider
from poetry.repositories.repository import Repository
from poetry.repositories.repository_pool import RepositoryPool
@@ -577,6 +578,31 @@ def test_search_for_file_wheel_with_extras(
}
+def test_complete_package_merges_same_source_and_no_source(
+ provider: Provider, root: ProjectPackage
+) -> None:
+ foo_no_source_1 = get_dependency("foo", ">=1")
+ foo_source_1 = get_dependency("foo", "!=1.1.*")
+ foo_source_1.source_name = "source"
+ foo_source_2 = get_dependency("foo", "!=1.2.*")
+ foo_source_2.source_name = "source"
+ foo_no_source_2 = get_dependency("foo", "<2")
+
+ root.add_dependency(foo_no_source_1)
+ root.add_dependency(foo_source_1)
+ root.add_dependency(foo_source_2)
+ root.add_dependency(foo_no_source_2)
+
+ complete_package = provider.complete_package(
+ DependencyPackage(root.to_dependency(), root)
+ )
+
+ requires = complete_package.package.all_requires
+ assert len(requires) == 1
+ assert requires[0].source_name == "source"
+ assert str(requires[0].constraint) == ">=1,<1.1.0 || >=1.3.0,<2"
+
+
def test_complete_package_does_not_merge_different_source_names(
provider: Provider, root: ProjectPackage
) -> None:
@@ -588,19 +614,39 @@ def test_complete_package_does_not_merge_different_source_names(
root.add_dependency(foo_source_1)
root.add_dependency(foo_source_2)
+ with pytest.raises(IncompatibleConstraintsError) as e:
+ provider.complete_package(DependencyPackage(root.to_dependency(), root))
+
+ expected = """\
+Incompatible constraints in requirements of root (1.2.3):
+foo ; source=source_2
+foo ; source=source_1"""
+
+ assert str(e.value) == expected
+
+
+def test_complete_package_merges_same_source_type_and_no_source(
+ provider: Provider, root: ProjectPackage, fixture_dir: FixtureDirGetter
+) -> None:
+ project_dir = fixture_dir("with_conditional_path_deps")
+ path = (project_dir / "demo_one").as_posix()
+
+ root.add_dependency(Factory.create_dependency("demo", ">=1.0"))
+ root.add_dependency(Factory.create_dependency("demo", {"path": path}))
+ root.add_dependency(Factory.create_dependency("demo", {"path": path})) # duplicate
+ root.add_dependency(Factory.create_dependency("demo", "<2.0"))
+
complete_package = provider.complete_package(
DependencyPackage(root.to_dependency(), root)
)
requires = complete_package.package.all_requires
- assert len(requires) == 2
- assert {requires[0].source_name, requires[1].source_name} == {
- "source_1",
- "source_2",
- }
+ assert len(requires) == 1
+ assert requires[0].source_url == path
+ assert str(requires[0].constraint) == "1.2.3"
-def test_complete_package_preserves_source_type(
+def test_complete_package_does_not_merge_different_source_types(
provider: Provider, root: ProjectPackage, fixture_dir: FixtureDirGetter
) -> None:
project_dir = fixture_dir("with_conditional_path_deps")
@@ -608,19 +654,40 @@ def test_complete_package_preserves_source_type(
path = (project_dir / folder).as_posix()
root.add_dependency(Factory.create_dependency("demo", {"path": path}))
- complete_package = provider.complete_package(
- DependencyPackage(root.to_dependency(), root)
- )
+ with pytest.raises(IncompatibleConstraintsError) as e:
+ provider.complete_package(DependencyPackage(root.to_dependency(), root))
- requires = complete_package.package.all_requires
- assert len(requires) == 2
- assert {requires[0].source_url, requires[1].source_url} == {
- project_dir.joinpath("demo_one").as_posix(),
- project_dir.joinpath("demo_two").as_posix(),
- }
+ expected = f"""\
+Incompatible constraints in requirements of root (1.2.3):
+demo @ file:///{project_dir.as_posix()}/demo_two (1.2.3)
+demo @ file:///{project_dir.as_posix()}/demo_one (1.2.3)"""
+
+ assert str(e.value) == expected
+
+
+def test_complete_package_does_not_merge_different_source_type_and_name(
+ provider: Provider, root: ProjectPackage, fixture_dir: FixtureDirGetter
+) -> None:
+ project_dir = fixture_dir("with_conditional_path_deps")
+ path = (project_dir / "demo_one").as_posix()
+
+ dep_with_source_name = Factory.create_dependency("demo", ">=1.0")
+ dep_with_source_name.source_name = "source"
+ root.add_dependency(dep_with_source_name)
+ root.add_dependency(Factory.create_dependency("demo", {"path": path}))
+ with pytest.raises(IncompatibleConstraintsError) as e:
+ provider.complete_package(DependencyPackage(root.to_dependency(), root))
-def test_complete_package_preserves_source_type_with_subdirectories(
+ expected = f"""\
+Incompatible constraints in requirements of root (1.2.3):
+demo @ file:///{project_dir.as_posix()}/demo_one (1.2.3)
+demo (>=1.0) ; source=source"""
+
+ assert str(e.value) == expected
+
+
+def test_complete_package_does_not_merge_different_subdirectories(
provider: Provider, root: ProjectPackage
) -> None:
dependency_one = Factory.create_dependency(
@@ -637,34 +704,19 @@ def test_complete_package_preserves_source_type_with_subdirectories(
"subdirectory": "one-copy",
},
)
- dependency_two = Factory.create_dependency(
- "two",
- {"git": "https://github.com/demo/subdirectories.git", "subdirectory": "two"},
- )
- root.add_dependency(
- Factory.create_dependency(
- "one",
- {
- "git": "https://github.com/demo/subdirectories.git",
- "subdirectory": "one",
- },
- )
- )
+ root.add_dependency(dependency_one)
root.add_dependency(dependency_one_copy)
- root.add_dependency(dependency_two)
- complete_package = provider.complete_package(
- DependencyPackage(root.to_dependency(), root)
- )
+ with pytest.raises(IncompatibleConstraintsError) as e:
+ provider.complete_package(DependencyPackage(root.to_dependency(), root))
- requires = complete_package.package.all_requires
- assert len(requires) == 3
- assert {r.to_pep_508() for r in requires} == {
- dependency_one.to_pep_508(),
- dependency_one_copy.to_pep_508(),
- dependency_two.to_pep_508(),
- }
+ expected = """\
+Incompatible constraints in requirements of root (1.2.3):
+one @ git+https://github.com/demo/subdirectories.git#subdirectory=one-copy (1.0.0)
+one @ git+https://github.com/demo/subdirectories.git#subdirectory=one (1.0.0)"""
+
+ assert str(e.value) == expected
@pytest.mark.parametrize("source_name", [None, "repo"])
diff --git a/tests/puzzle/test_solver.py b/tests/puzzle/test_solver.py
index 0018da79da5..944907f5197 100644
--- a/tests/puzzle/test_solver.py
+++ b/tests/puzzle/test_solver.py
@@ -1362,13 +1362,13 @@ def test_solver_duplicate_dependencies_different_constraints_same_requirements(
repo.add_package(package_b10)
repo.add_package(package_b20)
- with pytest.raises(SolverProblemError) as e:
+ with pytest.raises(IncompatibleConstraintsError) as e:
solver.solve()
expected = """\
-Because a (1.0) depends on both B (^1.0) and B (^2.0), a is forbidden.
-So, because no versions of a match !=1.0
- and root depends on A (*), version solving failed."""
+Incompatible constraints in requirements of a (1.0):
+B (>=1.0,<2.0)
+B (>=2.0,<3.0)"""
assert str(e.value) == expected
@@ -1411,7 +1411,7 @@ def test_solver_duplicate_dependencies_different_constraints_merge_by_marker(
@pytest.mark.parametrize("git_first", [False, True])
-def test_solver_duplicate_dependencies_different_sources_types_are_preserved(
+def test_solver_duplicate_dependencies_different_sources_direct_origin_preserved(
solver: Solver, repo: Repository, package: ProjectPackage, git_first: bool
):
pendulum = get_package("pendulum", "2.0.3")
@@ -1458,20 +1458,13 @@ def test_solver_duplicate_dependencies_different_sources_types_are_preserved(
DependencyPackage(package.to_dependency(), package)
)
- assert len(complete_package.package.all_requires) == 2
+ assert len(complete_package.package.all_requires) == 1
+ dep = complete_package.package.all_requires[0]
- if git_first:
- git, pypi = complete_package.package.all_requires
- else:
- pypi, git = complete_package.package.all_requires
-
- assert isinstance(pypi, Dependency)
- assert pypi == dependency_pypi
-
- assert isinstance(git, VCSDependency)
- assert git.constraint
- assert git.constraint != dependency_git.constraint
- assert (git.name, git.source_type, git.source_url, git.source_reference) == (
+ assert isinstance(dep, VCSDependency)
+ assert dep.constraint
+ assert dep.constraint == demo.version
+ assert (dep.name, dep.source_type, dep.source_url, dep.source_reference) == (
dependency_git.name,
dependency_git.source_type,
dependency_git.source_url,
@@ -1536,8 +1529,8 @@ def test_solver_duplicate_dependencies_different_constraints_conflict(
expectation = (
"Incompatible constraints in requirements of root (1.0):\n"
- 'A (<1.1) ; python_version == "3.10"\n'
- "A (>=1.1)"
+ "A (>=1.1)\n"
+ 'A (<1.1) ; python_version == "3.10"'
)
with pytest.raises(IncompatibleConstraintsError, match=re.escape(expectation)):
solver.solve()
@@ -1835,6 +1828,144 @@ def test_solver_duplicate_dependencies_sub_dependencies(
)
+def test_solver_duplicate_dependencies_with_overlapping_markers_simple(
+ solver: Solver, repo: Repository, package: ProjectPackage
+) -> None:
+ package.add_dependency(get_dependency("b", "1.0"))
+
+ package_b = get_package("b", "1.0")
+ dep_strings = [
+ "a (>=1.0)",
+ "a (>=1.1) ; python_version >= '3.7'",
+ "a (<2.0) ; python_version < '3.8'",
+ "a (!=1.2) ; python_version == '3.7'",
+ ]
+ deps = [Dependency.create_from_pep_508(dep) for dep in dep_strings]
+ for dep in deps:
+ package_b.add_dependency(dep)
+
+ package_a09 = get_package("a", "0.9")
+ package_a10 = get_package("a", "1.0")
+ package_a11 = get_package("a", "1.1")
+ package_a12 = get_package("a", "1.2")
+ package_a20 = get_package("a", "2.0")
+
+ package_a11.python_versions = ">=3.7"
+ package_a12.python_versions = ">=3.7"
+ package_a20.python_versions = ">=3.7"
+
+ repo.add_package(package_a09)
+ repo.add_package(package_a10)
+ repo.add_package(package_a11)
+ repo.add_package(package_a12)
+ repo.add_package(package_a20)
+ repo.add_package(package_b)
+
+ transaction = solver.solve()
+ ops = check_solver_result(
+ transaction,
+ [
+ {"job": "install", "package": package_a10},
+ {"job": "install", "package": package_a11},
+ {"job": "install", "package": package_a20},
+ {"job": "install", "package": package_b},
+ ],
+ )
+ package_b_requires = {dep.to_pep_508() for dep in ops[-1].package.requires}
+ assert package_b_requires == {
+ 'a (>=1.0,<2.0) ; python_version < "3.7"',
+ 'a (>=1.1,!=1.2,<2.0) ; python_version == "3.7"',
+ 'a (>=1.1) ; python_version >= "3.8"',
+ }
+
+
+def test_solver_duplicate_dependencies_with_overlapping_markers_complex(
+ solver: Solver, repo: Repository, package: ProjectPackage
+) -> None:
+ """
+ Dependencies with overlapping markers from
+ https://pypi.org/project/opencv-python/4.6.0.66/
+ """
+ package.add_dependency(get_dependency("opencv", "4.6.0.66"))
+
+ opencv_package = get_package("opencv", "4.6.0.66")
+ dep_strings = [
+ "numpy (>=1.13.3) ; python_version < '3.7'",
+ "numpy (>=1.21.2) ; python_version >= '3.10'",
+ (
+ "numpy (>=1.21.2) ; python_version >= '3.6' "
+ "and platform_system == 'Darwin' and platform_machine == 'arm64'"
+ ),
+ (
+ "numpy (>=1.19.3) ; python_version >= '3.6' "
+ "and platform_system == 'Linux' and platform_machine == 'aarch64'"
+ ),
+ "numpy (>=1.14.5) ; python_version >= '3.7'",
+ "numpy (>=1.17.3) ; python_version >= '3.8'",
+ "numpy (>=1.19.3) ; python_version >= '3.9'",
+ ]
+ deps = [Dependency.create_from_pep_508(dep) for dep in dep_strings]
+ for dep in deps:
+ opencv_package.add_dependency(dep)
+
+ for version in {"1.13.3", "1.21.2", "1.19.3", "1.14.5", "1.17.3"}:
+ repo.add_package(get_package("numpy", version))
+ repo.add_package(opencv_package)
+
+ transaction = solver.solve()
+ ops = check_solver_result(
+ transaction,
+ [
+ {"job": "install", "package": get_package("numpy", "1.21.2")},
+ {"job": "install", "package": opencv_package},
+ ],
+ )
+ opencv_requires = {dep.to_pep_508() for dep in ops[-1].package.requires}
+ assert opencv_requires == {
+ (
+ "numpy (>=1.21.2) ;"
+ ' platform_system == "Darwin" and platform_machine == "arm64"'
+ ' and python_version >= "3.6" or python_version >= "3.10"'
+ ),
+ (
+ 'numpy (>=1.19.3) ; python_version >= "3.9" and python_version < "3.10"'
+ ' and platform_system != "Darwin" or platform_system == "Linux"'
+ ' and platform_machine == "aarch64" and python_version < "3.10"'
+ ' and python_version >= "3.6" or python_version >= "3.9"'
+ ' and python_version < "3.10" and platform_machine != "arm64"'
+ ),
+ (
+ 'numpy (>=1.13.3) ; python_version < "3.6" or python_version < "3.7"'
+ ' and platform_system != "Darwin" and platform_system != "Linux"'
+ ' or python_version < "3.7" and platform_system != "Darwin"'
+ ' and platform_machine != "aarch64" or python_version < "3.7"'
+ ' and platform_machine != "arm64" and platform_system != "Linux"'
+ ' or python_version < "3.7" and platform_machine != "arm64"'
+ ' and platform_machine != "aarch64"'
+ ),
+ (
+ 'numpy (>=1.17.3) ; python_version >= "3.8" and python_version < "3.9"'
+ ' and platform_system != "Darwin" and platform_system != "Linux"'
+ ' or python_version >= "3.8" and python_version < "3.9"'
+ ' and platform_system != "Darwin" and platform_machine != "aarch64"'
+ ' or python_version >= "3.8" and python_version < "3.9"'
+ ' and platform_machine != "arm64" and platform_system != "Linux"'
+ ' or python_version >= "3.8" and python_version < "3.9"'
+ ' and platform_machine != "arm64" and platform_machine != "aarch64"'
+ ),
+ (
+ 'numpy (>=1.14.5) ; python_version >= "3.7" and python_version < "3.8"'
+ ' and platform_system != "Darwin" and platform_system != "Linux"'
+ ' or python_version >= "3.7" and python_version < "3.8"'
+ ' and platform_system != "Darwin" and platform_machine != "aarch64"'
+ ' or python_version >= "3.7" and python_version < "3.8"'
+ ' and platform_machine != "arm64" and platform_system != "Linux"'
+ ' or python_version >= "3.7" and python_version < "3.8"'
+ ' and platform_machine != "arm64" and platform_machine != "aarch64"'
+ ),
+ }
+
+
def test_duplicate_path_dependencies(solver: Solver, package: ProjectPackage) -> None:
set_package_python_versions(solver.provider, "^3.7")
fixtures = Path(__file__).parent.parent / "fixtures"