diff --git a/src/poetry/puzzle/provider.py b/src/poetry/puzzle/provider.py index ea1eb8d9559..77d4dee2b53 100644 --- a/src/poetry/puzzle/provider.py +++ b/src/poetry/puzzle/provider.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import logging import re import time @@ -12,10 +13,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.mixology.incompatibility import Incompatibility from poetry.mixology.incompatibility_cause import DependencyCause @@ -59,10 +61,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) ) @@ -590,55 +604,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. # @@ -664,8 +638,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(): @@ -690,18 +662,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( @@ -845,118 +817,119 @@ 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 deps in by_constraint.values(): + new_markers = [dep.marker for dep in deps] + dep = deps[0] + dep.marker = marker_union(*new_markers) + merged_dependencies.append(dep) + + 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 not any(uses): + # 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 2c3030a34e5..3c88e7c18f8 100644 --- a/tests/installation/fixtures/with-duplicate-dependencies.test +++ b/tests/installation/fixtures/with-duplicate-dependencies.test @@ -8,8 +8,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 76bc65a2a5b..4c6e401ad97 100644 --- a/tests/installation/fixtures/with-multiple-updates.test +++ b/tests/installation/fixtures/with-multiple-updates.test @@ -9,8 +9,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 92cc5a98515..ce5f6456a28 100644 --- a/tests/mixology/version_solver/test_unsolvable.py +++ b/tests/mixology/version_solver/test_unsolvable.py @@ -2,7 +2,10 @@ 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( @@ -106,12 +114,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 @ {project_dir.as_uri()}/demo_two (1.2.3) +demo @ {project_dir.as_uri()}/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( diff --git a/tests/puzzle/test_provider.py b/tests/puzzle/test_provider.py index eec324a2b4a..38504c412cc 100644 --- a/tests/puzzle/test_provider.py +++ b/tests/puzzle/test_provider.py @@ -19,6 +19,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 @@ -584,6 +585,36 @@ 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) in { + ">=1,<1.1 || >=1.3,<2", + ">=1,<1.1.dev0 || >=1.3.dev0,<2", + ">=1,<1.1.0 || >=1.3.0,<2", + ">=1,<1.1.0.dev0 || >=1.3.0.dev0,<2", + } + + def test_complete_package_does_not_merge_different_source_names( provider: Provider, root: ProjectPackage ) -> None: @@ -595,19 +626,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") @@ -615,19 +666,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 @ {project_dir.as_uri()}/demo_two (1.2.3) +demo @ {project_dir.as_uri()}/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})) -def test_complete_package_preserves_source_type_with_subdirectories( + with pytest.raises(IncompatibleConstraintsError) as e: + provider.complete_package(DependencyPackage(root.to_dependency(), root)) + + expected = f"""\ +Incompatible constraints in requirements of root (1.2.3): +demo @ {project_dir.as_uri()}/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( @@ -644,34 +716,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 eb179218e1f..9adbc867752 100644 --- a/tests/puzzle/test_solver.py +++ b/tests/puzzle/test_solver.py @@ -1406,13 +1406,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 @@ -1455,7 +1455,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 ) -> None: pendulum = get_package("pendulum", "2.0.3") @@ -1504,19 +1504,12 @@ 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 != dependency_git.constraint - assert (git.name, git.source_type, git.source_url, git.source_reference) == ( + assert isinstance(dep, VCSDependency) + 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, @@ -1581,8 +1574,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() @@ -1880,6 +1873,131 @@ 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.17.3) ; python_version >= "3.8" and python_version < "3.9"' + ' and (platform_system != "Darwin" or platform_machine != "arm64")' + ' and (platform_system != "Linux" or platform_machine != "aarch64")' + ), + ( + 'numpy (>=1.14.5) ; python_version >= "3.7" and python_version < "3.8"' + ' and (platform_system != "Darwin" or platform_machine != "arm64")' + ' and (platform_system != "Linux" or platform_machine != "aarch64")' + ), + ( + 'numpy (>=1.13.3) ; python_version < "3.7"' + ' and (python_version < "3.6" or platform_system != "Darwin"' + ' or platform_machine != "arm64") and (python_version < "3.6"' + ' or platform_system != "Linux" or platform_machine != "aarch64")' + ), + } + + def test_duplicate_path_dependencies( solver: Solver, package: ProjectPackage, fixture_dir: FixtureDirGetter ) -> None: