diff --git a/src/poetry/puzzle/provider.py b/src/poetry/puzzle/provider.py
index 0effa9598a8..9c7457f7e7c 100644
--- a/src/poetry/puzzle/provider.py
+++ b/src/poetry/puzzle/provider.py
@@ -59,6 +59,18 @@
logger = logging.getLogger(__name__)
+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)
+ super().__init__(
+ f"Incompatible constraints in requirements of {package}:\n{constraints}"
+ )
+
+
class Indicator(ProgressIndicator):
CONTEXT: str | None = None
@@ -740,55 +752,7 @@ def fmt_warning(d: Dependency) -> str:
f"Different requirements found for {warnings}."
)
- # 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)
- any_markers_dependencies = [d for d in deps if d.marker.is_any()]
- other_markers_dependencies = [d for d in deps if not d.marker.is_any()]
-
- 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 any_markers_dependencies:
- for dep_any in any_markers_dependencies:
- dep_any.marker = inverted_marker
- for dep_other in other_markers_dependencies:
- dep_other.constraint = dep_other.constraint.intersect(
- dep_any.constraint
- )
- elif not inverted_marker.is_empty() and self._python_constraint.allows_any(
- get_python_constraint_from_marker(inverted_marker)
- ):
- # 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:
- # - 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 = deps[0].with_constraint(EmptyConstraint())
- inverted_marker_dep.marker = inverted_marker
- deps.append(inverted_marker_dep)
+ deps = self._handle_any_marker_dependencies(package, deps)
overrides = []
overrides_marker_intersection: BaseMarker = AnyMarker()
@@ -1021,3 +985,73 @@ def _merge_dependencies_by_marker(
)
deps.append(_deps[0].with_constraint(new_constraint))
return deps
+
+ def _handle_any_marker_dependencies(
+ 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).
+ """
+ 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)
+ )
+ 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:
+ # - 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
diff --git a/tests/puzzle/test_solver.py b/tests/puzzle/test_solver.py
index d55ea5b2915..04f2dd70b35 100644
--- a/tests/puzzle/test_solver.py
+++ b/tests/puzzle/test_solver.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import re
+
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
@@ -19,6 +21,7 @@
from poetry.packages import DependencyPackage
from poetry.puzzle import Solver
from poetry.puzzle.exceptions import SolverProblemError
+from poetry.puzzle.provider import IncompatibleConstraintsError
from poetry.repositories.repository import Repository
from poetry.repositories.repository_pool import RepositoryPool
from poetry.utils.env import MockEnv
@@ -1480,6 +1483,164 @@ def test_solver_duplicate_dependencies_different_constraints_merge_no_markers(
)
+def test_solver_duplicate_dependencies_different_constraints_conflict(
+ solver: Solver, repo: Repository, package: ProjectPackage
+) -> None:
+ package.add_dependency(Factory.create_dependency("A", ">=1.1"))
+ package.add_dependency(
+ Factory.create_dependency("A", {"version": "<1.1", "python": "3.10"})
+ )
+
+ repo.add_package(get_package("A", "1.0"))
+ repo.add_package(get_package("A", "1.1"))
+ repo.add_package(get_package("A", "1.2"))
+
+ expectation = (
+ "Incompatible constraints in requirements of root (1.0):\n"
+ 'A (<1.1) ; python_version == "3.10"\n'
+ "A (>=1.1)"
+ )
+ with pytest.raises(IncompatibleConstraintsError, match=re.escape(expectation)):
+ solver.solve()
+
+
+def test_solver_duplicate_dependencies_different_constraints_discard_no_markers1(
+ solver: Solver, repo: Repository, package: ProjectPackage
+) -> None:
+ """
+ Initial dependencies:
+ A (>=1.0)
+ A (<1.2) ; python >= 3.10
+ A (<1.1) ; python < 3.10
+
+ Merged dependencies:
+ A (>=1.0) ;
+ A (>=1.0,<1.2) ; python >= 3.10
+ A (>=1.0,<1.1) ; python < 3.10
+
+ The dependency with an empty marker has to be ignored.
+ """
+ package.add_dependency(Factory.create_dependency("A", ">=1.0"))
+ package.add_dependency(
+ Factory.create_dependency("A", {"version": "<1.2", "python": ">=3.10"})
+ )
+ package.add_dependency(
+ Factory.create_dependency("A", {"version": "<1.1", "python": "<3.10"})
+ )
+ package.add_dependency(Factory.create_dependency("B", "*"))
+
+ package_a10 = get_package("A", "1.0")
+ package_a11 = get_package("A", "1.1")
+ package_a12 = get_package("A", "1.2")
+ package_b = get_package("B", "1.0")
+ package_b.add_dependency(Factory.create_dependency("A", "*"))
+
+ repo.add_package(package_a10)
+ repo.add_package(package_a11)
+ repo.add_package(package_a12)
+ repo.add_package(package_b)
+
+ transaction = solver.solve()
+
+ check_solver_result(
+ transaction,
+ [
+ # only a10 and a11, not a12
+ {"job": "install", "package": package_a10},
+ {"job": "install", "package": package_a11},
+ {"job": "install", "package": package_b},
+ ],
+ )
+
+
+def test_solver_duplicate_dependencies_different_constraints_discard_no_markers2(
+ solver: Solver, repo: Repository, package: ProjectPackage
+) -> None:
+ """
+ Initial dependencies:
+ A (>=1.0)
+ A (<1.2) ; python == 3.10
+
+ Merged dependencies:
+ A (>=1.0) ; python != 3.10
+ A (>=1.0,<1.2) ; python == 3.10
+
+ The first dependency has to be ignored
+ because it is not compatible with the project's python constraint.
+ """
+ set_package_python_versions(solver.provider, "~3.10")
+ package.add_dependency(Factory.create_dependency("A", ">=1.0"))
+ package.add_dependency(
+ Factory.create_dependency("A", {"version": "<1.2", "python": "3.10"})
+ )
+ package.add_dependency(Factory.create_dependency("B", "*"))
+
+ package_a10 = get_package("A", "1.0")
+ package_a11 = get_package("A", "1.1")
+ package_a12 = get_package("A", "1.2")
+ package_b = get_package("B", "1.0")
+ package_b.add_dependency(Factory.create_dependency("A", "*"))
+
+ repo.add_package(package_a10)
+ repo.add_package(package_a11)
+ repo.add_package(package_a12)
+ repo.add_package(package_b)
+
+ transaction = solver.solve()
+
+ check_solver_result(
+ transaction,
+ [
+ {"job": "install", "package": package_a11}, # only a11, not a12
+ {"job": "install", "package": package_b},
+ ],
+ )
+
+
+def test_solver_duplicate_dependencies_different_constraints_discard_no_markers3(
+ solver: Solver, repo: Repository, package: ProjectPackage
+) -> None:
+ """
+ Initial dependencies:
+ A (>=1.0)
+ A (<1.2) ; python == 3.10
+
+ Merged dependencies:
+ A (>=1.0) ; python != 3.10
+ A (>=1.0,<1.2) ; python == 3.10
+
+ The first dependency has to be ignored
+ because it is not compatible with the current environment.
+ """
+ package.add_dependency(Factory.create_dependency("A", ">=1.0"))
+ package.add_dependency(
+ Factory.create_dependency("A", {"version": "<1.2", "python": "3.10"})
+ )
+ package.add_dependency(Factory.create_dependency("B", "*"))
+
+ package_a10 = get_package("A", "1.0")
+ package_a11 = get_package("A", "1.1")
+ package_a12 = get_package("A", "1.2")
+ package_b = get_package("B", "1.0")
+ package_b.add_dependency(Factory.create_dependency("A", "*"))
+
+ repo.add_package(package_a10)
+ repo.add_package(package_a11)
+ repo.add_package(package_a12)
+ repo.add_package(package_b)
+
+ with solver.use_environment(MockEnv((3, 10, 0))):
+ transaction = solver.solve()
+
+ check_solver_result(
+ transaction,
+ [
+ {"job": "install", "package": package_a11}, # only a11, not a12
+ {"job": "install", "package": package_b},
+ ],
+ )
+
+
def test_solver_duplicate_dependencies_ignore_overrides_with_empty_marker_intersection(
solver: Solver, repo: Repository, package: ProjectPackage
):