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..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, @@ -269,6 +272,42 @@ 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) + ): + # 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 + 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..83c8742f4 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -812,6 +812,104 @@ 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+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: + """``>X+local, 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: ``