From 7feeda5b290b8c141b28aa5e56a0a41d6ea832ec Mon Sep 17 00:00:00 2001 From: David Hotham Date: Sat, 6 Jun 2026 19:36:45 +0100 Subject: [PATCH 1/2] Fix union(range, public_version) to fill local-variant punctures The constraint algebra treated '==X' as the literal point [X, X] when computing 'range.union(public_X)', returning the range unchanged whenever the range already allowed X. But per PEP 440 release-equality '==X' also matches every local-tagged variant 'X+local', so a range whose bound is such a variant (e.g. '>=X,X+local, --- .../core/constraints/version/version.py | 24 +--- .../core/constraints/version/version_range.py | 33 +++++ .../constraints/version/test_version_range.py | 117 ++++++++++++++++++ 3 files changed, 156 insertions(+), 18 deletions(-) diff --git a/src/poetry/core/constraints/version/version.py b/src/poetry/core/constraints/version/version.py index 2a60d832e..6bcd13205 100644 --- a/src/poetry/core/constraints/version/version.py +++ b/src/poetry/core/constraints/version/version.py @@ -109,28 +109,16 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint: return other.intersect(self) def union(self, other: VersionConstraint) -> VersionConstraint: - from poetry.core.constraints.version.version_range import VersionRange + if not isinstance(other, Version): + # Delegate to the other operand's ``union`` so the broadening + # rules for local-tagged bounds are applied symmetrically + # whether the union is written as ``range.union(point)`` or + # ``point.union(range)``. + return other.union(self) if other.allows(self): return other - if isinstance(other, VersionRangeConstraint): - if self.allows(other.min): - return VersionRange( - other.min, - other.max, - include_min=True, - include_max=other.include_max, - ) - - if self.allows(other.max): - return VersionRange( - other.min, - other.max, - include_min=other.include_min, - include_max=True, - ) - return VersionUnion.of(self, other) def difference(self, other: VersionConstraint) -> Version | EmptyConstraint: diff --git a/src/poetry/core/constraints/version/version_range.py b/src/poetry/core/constraints/version/version_range.py index cdf82b2f1..04a44a2ea 100644 --- a/src/poetry/core/constraints/version/version_range.py +++ b/src/poetry/core/constraints/version/version_range.py @@ -269,6 +269,39 @@ def union(self, other: VersionConstraint) -> VersionConstraint: from poetry.core.constraints.version.version import Version if isinstance(other, Version): + # If ``other`` is a public version that covers any local-tagged + # variants at our bounds (by PEP 440 release-equality), the + # union absorbs those local variants and we can broaden the + # bound(s). Handle both bounds at once so e.g. a range that is + # bracketed by two local variants of ``other`` collapses to a + # single release-spanning range. + new_min: Version | None = self._min + new_include_min = self._include_min + new_max: Version | None = self._max + new_include_max = self._include_max + broadened = False + if not other.is_local(): + if ( + self._min is not None + and self._min.is_local() + and other.allows(self._min) + ): + new_min = other + new_include_min = True + broadened = True + if ( + self._max is not None + and self._max.is_local() + and other.allows(self._max) + ): + new_max = other.stable.next_patch() + new_include_max = False + broadened = True + if broadened: + return _range_or_empty( + new_min, new_max, new_include_min, new_include_max + ) + if self.allows(other): return self diff --git a/tests/constraints/version/test_version_range.py b/tests/constraints/version/test_version_range.py index 841c53a0f..6a54a3bdc 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -812,6 +812,123 @@ def test_intersect_punctured_range_with_excluded_point_is_empty() -> None: assert excluded_point.intersect(punctured).is_empty() +def test_union_upper_bound_local_with_public_extends_to_next_patch() -> None: + """``>=X, None: + """``>X+local, None: + """``range union ==X`` and ``==X union range`` produce the same result. + The broadening must apply regardless of operand order. + """ + rng = VersionRange( + Version.parse(min), + Version.parse(max), + include_min=include_min, + include_max=include_max, + ) + public = Version.parse("0.21.0") + expected = VersionRange( + Version.parse(expected_min), + Version.parse(expected_max), + include_min=True, + include_max=False, + ) + assert rng.union(public) == expected + assert public.union(rng) == expected + + +def test_union_punctured_versionunion_with_public_collapses_puncture() -> None: + """End-to-end on a punctured ``VersionUnion``: ``==X`` fills the + interior puncture at ``X+local``, collapsing the two-range union + back to a single unpunctured range. + """ + punctured = parse_constraint(">=0.21.0,!=0.21.0+cpu,<0.22.0") + public = parse_constraint("==0.21.0") + plain = parse_constraint(">=0.21.0,<0.22.0") + assert punctured.union(public) == plain + assert public.union(punctured) == plain + + +def test_union_range_with_local_other_does_not_broaden() -> None: + """Negative control: a *local* ``==X+other`` matches only itself + literally, never sibling local-tagged versions. Unioning it with a + range that excludes a different ``X+local`` must preserve the + puncture rather than broaden. + """ + range_below = VersionRange( + Version.parse("0.21.0"), + Version.parse("0.21.0+cpu"), + include_min=True, + include_max=False, + ) + other_local = Version.parse("0.21.0+other") + result = range_below.union(other_local) + assert result.allows(Version.parse("0.21.0")) + assert result.allows(other_local) + assert not result.allows(Version.parse("0.21.0+cpu")) + + def test_parsed_strict_max_excludes_dev_releases_of_stable() -> None: """PEP 440: `` Date: Wed, 10 Jun 2026 17:40:02 +0200 Subject: [PATCH 2/2] - add comments - remove mostly duplicated test and test for symmetry in other tests instead - add test with both local bounds --- .../core/constraints/version/version_range.py | 6 ++ .../constraints/version/test_version_range.py | 69 +++++++------------ 2 files changed, 31 insertions(+), 44 deletions(-) diff --git a/src/poetry/core/constraints/version/version_range.py b/src/poetry/core/constraints/version/version_range.py index 04a44a2ea..d399942fe 100644 --- a/src/poetry/core/constraints/version/version_range.py +++ b/src/poetry/core/constraints/version/version_range.py @@ -215,6 +215,9 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint: and not other.is_local() and other.allows(self.min) ): + # Strictly speaking, next_patch() is not quite correct + # because you cannot specify the upper bound. It only + # works for versions with a precision of three or less. upper = other.stable.next_patch() return _range_or_empty( min=self.min, @@ -294,6 +297,9 @@ def union(self, other: VersionConstraint) -> VersionConstraint: and self._max.is_local() and other.allows(self._max) ): + # Strictly speaking, next_patch() is not quite correct + # because you cannot specify the upper bound. It only + # works for versions with a precision of three or less. new_max = other.stable.next_patch() new_include_max = False broadened = True diff --git a/tests/constraints/version/test_version_range.py b/tests/constraints/version/test_version_range.py index 6a54a3bdc..83c8742f4 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -831,6 +831,30 @@ def test_union_upper_bound_local_with_public_extends_to_next_patch() -> None: include_max=False, ) assert range_below.union(public) == expected + assert public.union(range_below) == expected + + +def test_union_local_bounds_with_public_collapses_to_next_patch() -> None: + """``>=X+local1,<=X+local2 union ==X`` collapses to ``[X, X.next_patch())``. + Both bounds are local variants of the same public version ``X``. + Since ``==X`` matches all of them by PEP 440 release-equality, the + union must cover the entire public range up to ``X.next_patch()``. + """ + range_locals = VersionRange( + Version.parse("0.21.0+cpu"), + Version.parse("0.21.0+gpu"), + include_min=True, + include_max=True, + ) + public = Version.parse("0.21.0") + expected = VersionRange( + Version.parse("0.21.0"), + Version.parse("0.21.1"), + include_min=True, + include_max=False, + ) + assert range_locals.union(public) == expected + assert public.union(range_locals) == expected def test_union_lower_bound_local_with_public_extends_to_public() -> None: @@ -852,50 +876,7 @@ def test_union_lower_bound_local_with_public_extends_to_public() -> None: include_max=False, ) assert range_above.union(public) == expected - - -@pytest.mark.parametrize( - ( - "min", - "include_min", - "max", - "include_max", - "expected_min", - "expected_max", - ), - [ - # Upper bound is a local of the public version. - ("0.21.0", True, "0.21.0+cpu", False, "0.21.0", "0.21.1"), - # Lower bound is a local of the public version. - ("0.21.0+cpu", False, "0.22.0", False, "0.21.0", "0.22.0"), - ], -) -def test_union_with_public_version_is_symmetric( - min: str, - include_min: bool, - max: str, - include_max: bool, - expected_min: str, - expected_max: str, -) -> None: - """``range union ==X`` and ``==X union range`` produce the same result. - The broadening must apply regardless of operand order. - """ - rng = VersionRange( - Version.parse(min), - Version.parse(max), - include_min=include_min, - include_max=include_max, - ) - public = Version.parse("0.21.0") - expected = VersionRange( - Version.parse(expected_min), - Version.parse(expected_max), - include_min=True, - include_max=False, - ) - assert rng.union(public) == expected - assert public.union(rng) == expected + assert public.union(range_above) == expected def test_union_punctured_versionunion_with_public_collapses_puncture() -> None: