From 8014de78a0c3aa42a42945b75a84ed098aa98164 Mon Sep 17 00:00:00 2001 From: David Hotham Date: Sat, 6 Jun 2026 21:46:17 +0100 Subject: [PATCH] fix(solver): avoid spurious empty-constraint conflict with duplicates across extras When duplicate dependencies on the same package had different extras (e.g. the package appeared in multiple project.optional-dependencies entries and was also required with an extra by a dependency group), _resolve_overlapping_markers synthesised a 'not required' empty-constraint dependency for the leftover marker space of each subgroup. That synthetic dep conflicted with the sibling subgroup, causing 'depends on both X (*) and X ()' resolution failures. Make the leftover-marker-space placeholder opt-in via a new `cover_leftover_marker_space` flag, and only enable it for the single-complete-name caller that actually needs it. Fixes #10447 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/poetry/puzzle/provider.py | 19 ++++++++++++++++-- tests/puzzle/test_solver.py | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/poetry/puzzle/provider.py b/src/poetry/puzzle/provider.py index 527b8493fdc..0b668d1af39 100644 --- a/src/poetry/puzzle/provider.py +++ b/src/poetry/puzzle/provider.py @@ -645,9 +645,18 @@ def complete_package( active_extras = ( self._active_root_extras if package.is_root() else found_extras ) - deps = self._resolve_overlapping_markers(package, deps, active_extras) + deps = self._resolve_overlapping_markers( + package, + deps, + active_extras, + cover_leftover_marker_space=True, + ) else: - # There are duplicates with different extras. + # Resolve overlaps within each subgroup of duplicates that share + # the same extras. The leftover marker space (where no dep in a + # subgroup is required) is covered by the sibling subgroups, so + # _resolve_overlapping_markers must not synthesise an + # empty-constraint dep for it (see #10447). for complete_dep_name, deps_by_extra in duplicates_by_extras.items(): if len(deps_by_extra) > 1: duplicates_by_extras[complete_dep_name] = ( @@ -952,6 +961,8 @@ def _resolve_overlapping_markers( package: Package, dependencies: list[Dependency], active_extras: Collection[NormalizedName] | None, + *, + cover_leftover_marker_space: bool = False, ) -> list[Dependency]: """ Convert duplicate dependencies with potentially overlapping markers @@ -1009,6 +1020,10 @@ def _resolve_overlapping_markers( raise IncompatibleConstraintsError(package, *used_dependencies) if not any(uses): + if not cover_leftover_marker_space: + # Caller is responsible for the leftover marker space. + continue + # 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: diff --git a/tests/puzzle/test_solver.py b/tests/puzzle/test_solver.py index 8a30dde71fc..a677143538b 100644 --- a/tests/puzzle/test_solver.py +++ b/tests/puzzle/test_solver.py @@ -5247,6 +5247,42 @@ def test_solver_resolves_duplicate_dependencies_with_restricted_extras( ) +def test_solver_resolves_optional_dependencies_and_group_with_extras( + package: ProjectPackage, + pool: RepositoryPool, + repo: Repository, + io: NullIO, +) -> None: + """Regression test for https://github.com/python-poetry/poetry/issues/10447.""" + dep_alchemy = get_dependency("A", "*", optional=True) + dep_alchemy._in_extras = [canonicalize_name("alchemy")] + dep_databases = get_dependency("A", "*", optional=True) + dep_databases._in_extras = [canonicalize_name("databases")] + package.extras = { + canonicalize_name("alchemy"): [dep_alchemy], + canonicalize_name("databases"): [dep_databases], + } + package.add_dependency(dep_alchemy) + package.add_dependency(dep_databases) + package.add_dependency( + Factory.create_dependency( + "A", {"version": "*", "extras": ["mypy"]}, groups=["test"] + ) + ) + + package_a = get_package("A", "1.0") + package_a.extras = {canonicalize_name("mypy"): []} + repo.add_package(package_a) + + solver = Solver(package, pool, [], [], io) + transaction = solver.solve() + + check_solver_result( + transaction, + [{"job": "install", "package": package_a}], + ) + + def test_solver_logs_age_filtered_versions_on_failure( mocker: MockerFixture, pool: RepositoryPool, solver: Solver ) -> None: