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
49 changes: 42 additions & 7 deletions src/poetry/core/constraints/version/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@
from poetry.core.constraints.version.version_constraint import VersionConstraint


def _canonical_strict_max(
version: Version, *, is_marker_constraint: bool = False
) -> Version:
"""Canonicalize an exclusive ``<V`` upper bound.

Per PEP 440, ``<V`` for stable V MUST NOT allow any pre-/dev-release of V,
so the effective max is ``V.dev0``. Applied at parse-time so that user
intent (``<V``) is captured in the data structure; internal range
arithmetic preserves raw maxes so that, e.g., ``(>1, <3) - {2}`` allows
``2.dev0`` in the interior fragment ``(1, 2)``.

In a marker-constraint context (``python_version`` etc.) the observable
values are always concrete release strings, so canonicalization would be
a semantic no-op; we skip it there to keep marker rendering stable.
"""
if is_marker_constraint or version.is_unstable():
return version
return version.first_devrelease()


@functools.cache
def parse_constraint(constraints: str) -> VersionConstraint:
return _parse_constraint(constraints=constraints)
Expand Down Expand Up @@ -94,6 +114,9 @@ def parse_single_constraint(
from poetry.core.constraints.version.version_range import VersionRange
from poetry.core.constraints.version.version_union import VersionUnion

def canon_max(v: Version) -> Version:
return _canonical_strict_max(v, is_marker_constraint=is_marker_constraint)

m = re.match(r"(?i)^v?[xX*](\.[xX*])*$", constraint)
if m:
return VersionRange()
Expand All @@ -112,7 +135,7 @@ def parse_single_constraint(
if version.release.precision == 1:
high = version.stable.next_major()

return VersionRange(version, high, include_min=True)
return VersionRange(version, canon_max(high), include_min=True)

# PEP 440 Tilde range (~=)
m = TILDE_PEP440_CONSTRAINT.match(constraint)
Expand All @@ -129,7 +152,7 @@ def parse_single_constraint(
else:
high = version.stable.next_minor()

return VersionRange(version, high, include_min=True)
return VersionRange(version, canon_max(high), include_min=True)

# Caret range
m = CARET_CONSTRAINT.match(constraint)
Expand All @@ -141,7 +164,9 @@ def parse_single_constraint(
f"Could not parse version constraint: {constraint}"
) from e

return VersionRange(version, version.next_breaking(), include_min=True)
return VersionRange(
version, canon_max(version.next_breaking()), include_min=True
)

# X Range
m = X_CONSTRAINT.match(constraint)
Expand Down Expand Up @@ -174,7 +199,7 @@ def parse_single_constraint(
) from e

if op == "<":
return VersionRange(max=version)
return VersionRange(max=canon_max(version))
if op == "<=":
return VersionRange(max=version, include_max=True)
if op == ">":
Expand All @@ -190,7 +215,13 @@ def parse_single_constraint(
)

if op == "!=":
return VersionUnion(VersionRange(max=version), VersionRange(min=version))
# PEP 440 strict equality: ``!=V`` excludes exactly ``V``, *not* its
# prereleases (unlike the ordered ``<V`` comparison). We therefore
# leave the upper piece raw; ``2.0.dev1`` correctly matches ``!=2``.
return VersionUnion(
VersionRange(max=version),
VersionRange(min=version),
)

return version

Expand All @@ -212,15 +243,19 @@ def parse_single_constraint(
) from e

if op == "<":
return VersionRange(max=version)
return VersionRange(max=canon_max(version))
if op == "<=":
return VersionRange(max=version, include_max=True)
if op == ">":
return VersionRange(min=version)
if op == ">=":
return VersionRange(min=version, include_min=True)
if op == "!=":
return VersionUnion(VersionRange(max=version), VersionRange(min=version))
# See PEP 440 ``!=V`` note above (basic constraint branch).
return VersionUnion(
VersionRange(max=version),
VersionRange(min=version),
)
return version

raise ParseConstraintError(f"Could not parse version constraint: {constraint}")
Expand Down
89 changes: 77 additions & 12 deletions src/poetry/core/constraints/version/version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,41 @@
from poetry.core.constraints.version.version_constraint import VersionConstraint


def _is_canonical_strict_max(version: Version) -> bool:
"""True if ``version`` is the dev0 of a stable release — i.e., the
canonical exclusive upper bound that the parser produces for a
user-typed ``<V`` (with V stable). See ``parser._canonical_strict_max``.
"""
if version.dev is None or version.dev.number != 0:
return False
return not version.without_devrelease().is_unstable()


def _display_max_text(max_: Version, include_max: bool) -> str:
"""Render an exclusive ``<V.dev0`` (canonical-strict-max) back as ``<V``
so user-facing strings match what the user typed."""
if not include_max and _is_canonical_strict_max(max_):
return max_.without_devrelease().text
return max_.text


def _range_or_empty(
min: Version | None = None,
max: Version | None = None,
include_min: bool = False,
include_max: bool = False,
) -> VersionConstraint:
"""Construct a ``VersionRange``, returning ``EmptyConstraint`` if the
constructed range is empty. ``VersionRange.__init__`` cannot return a
different type, so a tail-side normalisation is required for arithmetic
operations whose result may be empty (e.g.
``VersionRange(V, V, True, False)`` is empty)."""
range_ = VersionRange(min, max, include_min, include_max)
if range_.is_empty():
return EmptyConstraint()
return range_


class VersionRange(VersionRangeConstraint):
def __init__(
self,
Expand Down Expand Up @@ -50,7 +85,16 @@ def include_max(self) -> bool:
return self._include_max

def is_empty(self) -> bool:
return False
# A bounded range is non-empty only when its min is strictly below
# its max, or when both bounds coincide and are inclusive (the
# single-point range ``[V, V]``). Coincident-bound non-inclusive
# ranges and inverted ranges (min > max) are empty. Previously
# this unconditionally returned False.
if self._min is None or self._max is None:
return False
if self._min == self._max:
return not (self._include_min and self._include_max)
return self._min > self._max

def is_any(self) -> bool:
return self._min is None and self._max is None
Expand Down Expand Up @@ -89,7 +133,7 @@ def allows(self, other: Version) -> bool:
return False

if self.max is not None:
_this, _other = self.allowed_max, other
_this, _other = self.max, other

assert _this is not None

Expand Down Expand Up @@ -150,7 +194,7 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint:
from poetry.core.constraints.version.version import Version

if other.is_empty():
return other
return EmptyConstraint()

if isinstance(other, VersionUnion):
return other.intersect(self)
Expand All @@ -163,7 +207,7 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint:
# `>=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):
upper = other.stable.next_patch()
return VersionRange(
return _range_or_empty(
min=self.min,
max=upper,
include_min=self.include_min,
Expand Down Expand Up @@ -208,7 +252,7 @@ def intersect(self, other: VersionConstraint) -> VersionConstraint:
return intersect_min

# If we got here, there is an actual range.
return VersionRange(
return _range_or_empty(
intersect_min, intersect_max, intersect_include_min, intersect_include_max
)

Expand Down Expand Up @@ -278,17 +322,17 @@ def difference(self, other: VersionConstraint) -> VersionConstraint:
if not self.include_min:
return self

return VersionRange(self.min, self.max, False, self.include_max)
return _range_or_empty(self.min, self.max, False, self.include_max)

if other == self.max:
if not self.include_max:
return self

return VersionRange(self.min, self.max, self.include_min, False)
return _range_or_empty(self.min, self.max, self.include_min, False)

return VersionUnion.of(
VersionRange(self.min, other, self.include_min, False),
VersionRange(other, self.max, False, self.include_max),
_range_or_empty(self.min, other, self.include_min, False),
_range_or_empty(other, self.max, False, self.include_max),
)
elif isinstance(other, VersionRangeConstraint):
if not self.allows_any(other):
Expand All @@ -300,7 +344,7 @@ def difference(self, other: VersionConstraint) -> VersionConstraint:
elif self.min == other.min:
before = self.min
else:
before = VersionRange(
before = _range_or_empty(
self.min, other.min, self.include_min, not other.include_min
)

Expand All @@ -310,7 +354,7 @@ def difference(self, other: VersionConstraint) -> VersionConstraint:
elif self.max == other.max:
after = self.max
else:
after = VersionRange(
after = _range_or_empty(
other.max, self.max, not other.include_max, self.include_max
)

Expand Down Expand Up @@ -441,6 +485,27 @@ def _compare_max(self, other: VersionRangeConstraint) -> int:

return 0

def __repr__(self) -> str:
text = ""

if self.min is not None:
text += ">=" if self.include_min else ">"
text += self.min.text

if self.max is not None:
if self.min is not None:
text += ","

op = "<=" if self.include_max else "<"
# In contrast to __str__, we want to display the actual max so that
# we can distinguish between `< 1` and `< 1.dev0` in test output.
text += f"{op}{self.max}"

if self.min is None and self.max is None:
return "*"

return f"<{self.__class__.__name__} {text}>"

def __str__(self) -> str:
with suppress(ValueError):
return self._single_wildcard_range_string
Expand All @@ -456,7 +521,7 @@ def __str__(self) -> str:
text += ","

op = "<=" if self.include_max else "<"
text += f"{op}{self.max.text}"
text += f"{op}{_display_max_text(self.max, self.include_max)}"

if self.min is None and self.max is None:
return "*"
Expand Down
22 changes: 2 additions & 20 deletions src/poetry/core/constraints/version/version_range_constraint.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

from abc import abstractmethod
from functools import cached_property
from typing import TYPE_CHECKING

from poetry.core.constraints.version.version_constraint import VersionConstraint
Expand Down Expand Up @@ -45,23 +44,6 @@ def allowed_min(self) -> Version | None:
# the callers of allowed_min.
return self.min

@cached_property
def allowed_max(self) -> Version | None:
if self.max is None:
return None

if self.include_max or self.max.is_unstable():
return self.max

if self.min == self.max and (self.include_min or self.include_max):
# this is an equality range
return self.max

# The exclusive ordered comparison <V MUST NOT allow a pre-release
# of the specified version unless the specified version is itself a pre-release.
# https://peps.python.org/pep-0440/#exclusive-ordered-comparison
return self.max.first_devrelease()

def has_upper_bound(self) -> bool:
return self.max is not None

Expand All @@ -83,7 +65,7 @@ def allows_lower(self, other: VersionRangeConstraint) -> bool:
return self.include_min and not other.include_min

def allows_higher(self, other: VersionRangeConstraint) -> bool:
_this, _other = self.allowed_max, other.allowed_max
_this, _other = self.max, other.max

if _this is None:
return _other is not None
Expand All @@ -100,7 +82,7 @@ def allows_higher(self, other: VersionRangeConstraint) -> bool:
return self.include_max and not other.include_max

def is_strictly_lower(self, other: VersionRangeConstraint) -> bool:
_this, _other = self.allowed_max, other.allowed_min
_this, _other = self.max, other.allowed_min

if _this is None or _other is None:
return False
Expand Down
Loading
Loading