From 076aa123d8fc23f804578dff9f1a835b29e1f050 Mon Sep 17 00:00:00 2001 From: David Hotham Date: Sat, 6 Jun 2026 19:09:25 +0100 Subject: [PATCH 1/4] Fix VersionRange.intersect broadening for local-version other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VersionRange.intersect(Version) has a special case introduced in #579 to handle '>=X+local ∩ public_X' by broadening to '[X+local, next_patch(X))', because per PEP 440 a public-version Version constraint '==X' matches all local-tagged variants of X. The broadening must not fire when 'other' is itself a local-tagged version: '==X+local' matches only the literal point X+local, never a sibling local-tagged variant. For an exclusive lower bound '>X+local ∩ ==X+local' the broadening wrongly returned the non-empty range '(X+local, next_patch(X))' instead of EmptyConstraint, which downstream caused Poetry's solver to derive contradictory punctured-range terms and crash in partial_solution.satisfier with a '[BUG] ... is not satisfied' RuntimeError. Skip the broadening when 'other.is_local()'; whether the literal point falls in the range is then decided entirely by the existing 'self.allows(other)' check above. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/constraints/version/version_range.py | 11 ++- .../constraints/version/test_version_range.py | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/poetry/core/constraints/version/version_range.py b/src/poetry/core/constraints/version/version_range.py index 6666331b2..cdf82b2f1 100644 --- a/src/poetry/core/constraints/version/version_range.py +++ b/src/poetry/core/constraints/version/version_range.py @@ -205,7 +205,16 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint: return other # `>=1.2.3+local` intersects `1.2.3` to return `>=1.2.3+local,<1.2.4`. - if self.min is not None and self.min.is_local() and other.allows(self.min): + # Skip when ``other`` is itself a local version: for an exclusive + # lower bound like ``>1.2.3+local`` intersected with the excluded + # point ``1.2.3+local``, broadening would wrongly return a + # non-empty range instead of the correct empty constraint. + if ( + self.min is not None + and self.min.is_local() + and not other.is_local() + and other.allows(self.min) + ): upper = other.stable.next_patch() return _range_or_empty( min=self.min, diff --git a/tests/constraints/version/test_version_range.py b/tests/constraints/version/test_version_range.py index dd167f206..82ffdd968 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -735,6 +735,82 @@ def test_difference_returns_empty_constraint_not_empty_range() -> None: assert isinstance(result, EmptyConstraint) +def test_intersect_with_local_version_other_does_not_broaden_exclusive_min() -> None: + """Regression test: ``>0.21.0+cpu,<0.22.0 ∩ ==0.21.0+cpu`` must be + empty. Previously returned ``>0.21.0+cpu,<0.21.1`` because the + ``>=X+local ∩ public_X`` broadening fired for local ``other`` too. + """ + excluded_point = Version.parse("0.21.0+cpu") + upper = Version.parse("0.22.0") + + exclusive = VersionRange( + excluded_point, upper, include_min=False, include_max=False + ) + assert isinstance(exclusive.intersect(excluded_point), EmptyConstraint) + + # Inclusive-lower case still returns the literally-equal point. + inclusive = VersionRange( + excluded_point, upper, include_min=True, include_max=False + ) + assert inclusive.intersect(excluded_point) == excluded_point + + # Original motivating case (``>=X+local ∩ public X``) still broadens. + public = Version.parse("0.21.0") + range_ge_local = VersionRange( + excluded_point, Version.parse("1.0"), include_min=True, include_max=False + ) + assert range_ge_local.intersect(public) == VersionRange( + excluded_point, + public.next_patch(), + include_min=True, + include_max=False, + ) + + +@pytest.mark.parametrize( + ("min_local", "other_local"), + [ + # other lex-orders after min: in range → return other. + ("a", "b"), + ("cpu", "cu124"), + ("cpu", "cpu1"), + # other lex-orders before min: out of range → empty. + ("b", "a"), + ("cu124", "cpu"), + # literal equal: handled by self.allows(other) on line 204 for + # inclusive; falls through to the special case for exclusive. + ("cpu", "cpu"), + ], +) +def test_intersect_with_two_local_versions( + min_local: str, other_local: str +) -> None: + """Cross-local intersection: ``self.min`` and ``other`` both carry + local segments. ``==X+other`` matches only the literal point + ``X+other``, so the result is just the point if it falls in the + range and empty otherwise — never a broadened range.""" + self_min = Version.parse(f"1.2.3+{min_local}") + other = Version.parse(f"1.2.3+{other_local}") + upper = Version.parse("2.0") + + for include_min in (True, False): + rng = VersionRange(self_min, upper, include_min=include_min) + expected = other if rng.allows(other) else EmptyConstraint() + assert rng.intersect(other) == expected, ( + f"include_min={include_min}: {rng} ∩ =={other} should be {expected}" + ) + + +def test_intersect_punctured_range_with_excluded_point_is_empty() -> None: + """A punctured ``VersionUnion`` (``>=A,!=V,=0.21.0,!=0.21.0+cpu,<0.22.0") + excluded_point = parse_constraint("==0.21.0+cpu") + assert punctured.intersect(excluded_point).is_empty() + assert excluded_point.intersect(punctured).is_empty() + + def test_parsed_strict_max_excludes_dev_releases_of_stable() -> None: """PEP 440: `` Date: Sat, 6 Jun 2026 18:12:08 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/constraints/version/test_version_range.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/constraints/version/test_version_range.py b/tests/constraints/version/test_version_range.py index 82ffdd968..202dbb946 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -749,9 +749,7 @@ def test_intersect_with_local_version_other_does_not_broaden_exclusive_min() -> assert isinstance(exclusive.intersect(excluded_point), EmptyConstraint) # Inclusive-lower case still returns the literally-equal point. - inclusive = VersionRange( - excluded_point, upper, include_min=True, include_max=False - ) + inclusive = VersionRange(excluded_point, upper, include_min=True, include_max=False) assert inclusive.intersect(excluded_point) == excluded_point # Original motivating case (``>=X+local ∩ public X``) still broadens. @@ -782,9 +780,7 @@ def test_intersect_with_local_version_other_does_not_broaden_exclusive_min() -> ("cpu", "cpu"), ], ) -def test_intersect_with_two_local_versions( - min_local: str, other_local: str -) -> None: +def test_intersect_with_two_local_versions(min_local: str, other_local: str) -> None: """Cross-local intersection: ``self.min`` and ``other`` both carry local segments. ``==X+other`` matches only the literal point ``X+other``, so the result is just the point if it falls in the From c179881e29b5285d6ff1e9659feb20cc97881a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:21:44 +0200 Subject: [PATCH 3/4] use parametrize instead of loop --- tests/constraints/version/test_version_range.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/constraints/version/test_version_range.py b/tests/constraints/version/test_version_range.py index 202dbb946..32730f738 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -780,7 +780,10 @@ def test_intersect_with_local_version_other_does_not_broaden_exclusive_min() -> ("cpu", "cpu"), ], ) -def test_intersect_with_two_local_versions(min_local: str, other_local: str) -> None: +@pytest.mark.parametrize("include_min", [True, False]) +def test_intersect_with_two_local_versions( + min_local: str, other_local: str, include_min: bool +) -> None: """Cross-local intersection: ``self.min`` and ``other`` both carry local segments. ``==X+other`` matches only the literal point ``X+other``, so the result is just the point if it falls in the @@ -789,12 +792,11 @@ def test_intersect_with_two_local_versions(min_local: str, other_local: str) -> other = Version.parse(f"1.2.3+{other_local}") upper = Version.parse("2.0") - for include_min in (True, False): - rng = VersionRange(self_min, upper, include_min=include_min) - expected = other if rng.allows(other) else EmptyConstraint() - assert rng.intersect(other) == expected, ( - f"include_min={include_min}: {rng} ∩ =={other} should be {expected}" - ) + rng = VersionRange(self_min, upper, include_min=include_min) + expected = other if rng.allows(other) else EmptyConstraint() + assert rng.intersect(other) == expected, ( + f"include_min={include_min}: {rng} ∩ =={other} should be {expected}" + ) def test_intersect_punctured_range_with_excluded_point_is_empty() -> None: From c068d01d2634eac1a9cded43f9038b7827c6f1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:28:06 +0200 Subject: [PATCH 4/4] check symmetry --- tests/constraints/version/test_version_range.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/constraints/version/test_version_range.py b/tests/constraints/version/test_version_range.py index 32730f738..841c53a0f 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -747,22 +747,26 @@ def test_intersect_with_local_version_other_does_not_broaden_exclusive_min() -> excluded_point, upper, include_min=False, include_max=False ) assert isinstance(exclusive.intersect(excluded_point), EmptyConstraint) + assert isinstance(excluded_point.intersect(exclusive), EmptyConstraint) # Inclusive-lower case still returns the literally-equal point. inclusive = VersionRange(excluded_point, upper, include_min=True, include_max=False) assert inclusive.intersect(excluded_point) == excluded_point + assert excluded_point.intersect(inclusive) == excluded_point # Original motivating case (``>=X+local ∩ public X``) still broadens. public = Version.parse("0.21.0") range_ge_local = VersionRange( excluded_point, Version.parse("1.0"), include_min=True, include_max=False ) - assert range_ge_local.intersect(public) == VersionRange( + broadened_range = VersionRange( excluded_point, public.next_patch(), include_min=True, include_max=False, ) + assert range_ge_local.intersect(public) == broadened_range + assert public.intersect(range_ge_local) == broadened_range @pytest.mark.parametrize( @@ -794,9 +798,8 @@ def test_intersect_with_two_local_versions( rng = VersionRange(self_min, upper, include_min=include_min) expected = other if rng.allows(other) else EmptyConstraint() - assert rng.intersect(other) == expected, ( - f"include_min={include_min}: {rng} ∩ =={other} should be {expected}" - ) + assert rng.intersect(other) == expected + assert other.intersect(rng) == expected def test_intersect_punctured_range_with_excluded_point_is_empty() -> None: