Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 6 additions & 18 deletions src/poetry/core/constraints/version/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions src/poetry/core/constraints/version/version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Comment thread
radoering marked this conversation as resolved.
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

Expand Down
98 changes: 98 additions & 0 deletions tests/constraints/version/test_version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,<X+local union ==X`` broadens the upper bound to
``X.next_patch()`` (exclusive). ``==X`` matches every ``X+local``
variant by PEP 440 release-equality, so the union must cover them.
"""
range_below = VersionRange(
Version.parse("0.21.0"),
Version.parse("0.21.0+cpu"),
include_min=True,
include_max=False,
)
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_below.union(public) == expected
Comment thread
radoering marked this conversation as resolved.
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:
"""``>X+local,<Y union ==X`` extends the lower bound down to ``X``
(inclusive). ``==X`` matches the literal point ``X`` and every
``X+local`` variant, including the excluded ``X+local`` lower bound.
"""
range_above = VersionRange(
Version.parse("0.21.0+cpu"),
Version.parse("0.22.0"),
include_min=False,
include_max=False,
)
public = Version.parse("0.21.0")
expected = VersionRange(
Version.parse("0.21.0"),
Version.parse("0.22.0"),
include_min=True,
include_max=False,
)
assert range_above.union(public) == expected
assert public.union(range_above) == 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: ``<V`` for stable V MUST NOT allow pre-/dev-releases of V.
The parser canonicalizes to ``<V.dev0`` so ``allows`` reports correctly."""
Expand Down
Loading