Skip to content

Commit 64d1169

Browse files
dimblebyCopilot
andcommitted
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' or '>X+local,<Y') must be broadened to include those variants in the union. Apply the broadening symmetrically in VersionRange.union(Version), handling both bounds in a single pass, and delegate Version.union(non Version) to the other operand so the rules apply regardless of operand order. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e3998b0 commit 64d1169

3 files changed

Lines changed: 156 additions & 18 deletions

File tree

src/poetry/core/constraints/version/version.py

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -109,28 +109,16 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint:
109109
return other.intersect(self)
110110

111111
def union(self, other: VersionConstraint) -> VersionConstraint:
112-
from poetry.core.constraints.version.version_range import VersionRange
112+
if not isinstance(other, Version):
113+
# Delegate to the other operand's ``union`` so the broadening
114+
# rules for local-tagged bounds are applied symmetrically
115+
# whether the union is written as ``range.union(point)`` or
116+
# ``point.union(range)``.
117+
return other.union(self)
113118

114119
if other.allows(self):
115120
return other
116121

117-
if isinstance(other, VersionRangeConstraint):
118-
if self.allows(other.min):
119-
return VersionRange(
120-
other.min,
121-
other.max,
122-
include_min=True,
123-
include_max=other.include_max,
124-
)
125-
126-
if self.allows(other.max):
127-
return VersionRange(
128-
other.min,
129-
other.max,
130-
include_min=other.include_min,
131-
include_max=True,
132-
)
133-
134122
return VersionUnion.of(self, other)
135123

136124
def difference(self, other: VersionConstraint) -> Version | EmptyConstraint:

src/poetry/core/constraints/version/version_range.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,39 @@ def union(self, other: VersionConstraint) -> VersionConstraint:
260260
from poetry.core.constraints.version.version import Version
261261

262262
if isinstance(other, Version):
263+
# If ``other`` is a public version that covers any local-tagged
264+
# variants at our bounds (by PEP 440 release-equality), the
265+
# union absorbs those local variants and we can broaden the
266+
# bound(s). Handle both bounds at once so e.g. a range that is
267+
# bracketed by two local variants of ``other`` collapses to a
268+
# single release-spanning range.
269+
new_min: Version | None = self._min
270+
new_include_min = self._include_min
271+
new_max: Version | None = self._max
272+
new_include_max = self._include_max
273+
broadened = False
274+
if not other.is_local():
275+
if (
276+
self._min is not None
277+
and self._min.is_local()
278+
and other.allows(self._min)
279+
):
280+
new_min = other
281+
new_include_min = True
282+
broadened = True
283+
if (
284+
self._max is not None
285+
and self._max.is_local()
286+
and other.allows(self._max)
287+
):
288+
new_max = other.stable.next_patch()
289+
new_include_max = False
290+
broadened = True
291+
if broadened:
292+
return _range_or_empty(
293+
new_min, new_max, new_include_min, new_include_max
294+
)
295+
263296
if self.allows(other):
264297
return self
265298

tests/constraints/version/test_version_range.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,123 @@ def test_difference_returns_empty_constraint_not_empty_range() -> None:
735735
assert isinstance(result, EmptyConstraint)
736736

737737

738+
def test_union_upper_bound_local_with_public_extends_to_next_patch() -> None:
739+
"""``>=X,<X+local ∪ ==X`` broadens the upper bound to
740+
``X.next_patch()`` (exclusive). ``==X`` matches every ``X+local``
741+
variant by PEP 440 release-equality, so the union must cover them.
742+
"""
743+
range_below = VersionRange(
744+
Version.parse("0.21.0"),
745+
Version.parse("0.21.0+cpu"),
746+
include_min=True,
747+
include_max=False,
748+
)
749+
public = Version.parse("0.21.0")
750+
expected = VersionRange(
751+
Version.parse("0.21.0"),
752+
Version.parse("0.21.1"),
753+
include_min=True,
754+
include_max=False,
755+
)
756+
assert range_below.union(public) == expected
757+
758+
759+
def test_union_lower_bound_local_with_public_extends_to_public() -> None:
760+
"""``>X+local,<Y ∪ ==X`` extends the lower bound down to ``X``
761+
(inclusive). ``==X`` matches the literal point ``X`` and every
762+
``X+local`` variant, including the excluded ``X+local`` lower bound.
763+
"""
764+
range_above = VersionRange(
765+
Version.parse("0.21.0+cpu"),
766+
Version.parse("0.22.0"),
767+
include_min=False,
768+
include_max=False,
769+
)
770+
public = Version.parse("0.21.0")
771+
expected = VersionRange(
772+
Version.parse("0.21.0"),
773+
Version.parse("0.22.0"),
774+
include_min=True,
775+
include_max=False,
776+
)
777+
assert range_above.union(public) == expected
778+
779+
780+
@pytest.mark.parametrize(
781+
(
782+
"min",
783+
"include_min",
784+
"max",
785+
"include_max",
786+
"expected_min",
787+
"expected_max",
788+
),
789+
[
790+
# Upper bound is a local of the public version.
791+
("0.21.0", True, "0.21.0+cpu", False, "0.21.0", "0.21.1"),
792+
# Lower bound is a local of the public version.
793+
("0.21.0+cpu", False, "0.22.0", False, "0.21.0", "0.22.0"),
794+
],
795+
)
796+
def test_union_with_public_version_is_symmetric(
797+
min: str,
798+
include_min: bool,
799+
max: str,
800+
include_max: bool,
801+
expected_min: str,
802+
expected_max: str,
803+
) -> None:
804+
"""``range ∪ ==X`` and ``==X ∪ range`` produce the same result.
805+
The broadening must apply regardless of operand order.
806+
"""
807+
rng = VersionRange(
808+
Version.parse(min),
809+
Version.parse(max),
810+
include_min=include_min,
811+
include_max=include_max,
812+
)
813+
public = Version.parse("0.21.0")
814+
expected = VersionRange(
815+
Version.parse(expected_min),
816+
Version.parse(expected_max),
817+
include_min=True,
818+
include_max=False,
819+
)
820+
assert rng.union(public) == expected
821+
assert public.union(rng) == expected
822+
823+
824+
def test_union_punctured_versionunion_with_public_collapses_puncture() -> None:
825+
"""End-to-end on a punctured ``VersionUnion``: ``==X`` fills the
826+
interior puncture at ``X+local``, collapsing the two-range union
827+
back to a single unpunctured range.
828+
"""
829+
punctured = parse_constraint(">=0.21.0,!=0.21.0+cpu,<0.22.0")
830+
public = parse_constraint("==0.21.0")
831+
plain = parse_constraint(">=0.21.0,<0.22.0")
832+
assert punctured.union(public) == plain
833+
assert public.union(punctured) == plain
834+
835+
836+
def test_union_range_with_local_other_does_not_broaden() -> None:
837+
"""Negative control: a *local* ``==X+other`` matches only itself
838+
literally, never sibling local-tagged versions. Unioning it with a
839+
range that excludes a different ``X+local`` must preserve the
840+
puncture rather than broaden.
841+
"""
842+
range_below = VersionRange(
843+
Version.parse("0.21.0"),
844+
Version.parse("0.21.0+cpu"),
845+
include_min=True,
846+
include_max=False,
847+
)
848+
other_local = Version.parse("0.21.0+other")
849+
result = range_below.union(other_local)
850+
assert result.allows(Version.parse("0.21.0"))
851+
assert result.allows(other_local)
852+
assert not result.allows(Version.parse("0.21.0+cpu"))
853+
854+
738855
def test_parsed_strict_max_excludes_dev_releases_of_stable() -> None:
739856
"""PEP 440: ``<V`` for stable V MUST NOT allow pre-/dev-releases of V.
740857
The parser canonicalizes to ``<V.dev0`` so ``allows`` reports correctly."""

0 commit comments

Comments
 (0)