Skip to content

Commit

Permalink
pypa/packaging compliance (especially wildcard constraints)
Browse files Browse the repository at this point in the history
Co-authored-by: Randy Döring <30527984+radoering@users.noreply.github.com>
  • Loading branch information
abn and radoering committed Apr 30, 2023
1 parent f314393 commit 8bbe7a2
Show file tree
Hide file tree
Showing 19 changed files with 517 additions and 132 deletions.
2 changes: 2 additions & 0 deletions src/poetry/core/constraints/version/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from poetry.core.constraints.version.empty_constraint import EmptyConstraint
from poetry.core.constraints.version.parser import parse_constraint
from poetry.core.constraints.version.parser import parse_marker_version_constraint
from poetry.core.constraints.version.util import constraint_regions
from poetry.core.constraints.version.version import Version
from poetry.core.constraints.version.version_constraint import VersionConstraint
Expand All @@ -21,4 +22,5 @@
"VersionUnion",
"constraint_regions",
"parse_constraint",
"parse_marker_version_constraint",
)
89 changes: 69 additions & 20 deletions src/poetry/core/constraints/version/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,22 @@


if TYPE_CHECKING:
from poetry.core.constraints.version.version import Version
from poetry.core.constraints.version.version_constraint import VersionConstraint


@functools.lru_cache(maxsize=None)
def parse_constraint(constraints: str) -> VersionConstraint:
return _parse_constraint(constraints=constraints)


def parse_marker_version_constraint(constraints: str) -> VersionConstraint:
return _parse_constraint(constraints=constraints, is_marker_constraint=True)


def _parse_constraint(
constraints: str, *, is_marker_constraint: bool = False
) -> VersionConstraint:
if constraints == "*":
from poetry.core.constraints.version.version_range import VersionRange

Expand All @@ -33,9 +44,17 @@ def parse_constraint(constraints: str) -> VersionConstraint:

if len(and_constraints) > 1:
for constraint in and_constraints:
constraint_objects.append(parse_single_constraint(constraint))
constraint_objects.append(
parse_single_constraint(
constraint, is_marker_constraint=is_marker_constraint
)
)
else:
constraint_objects.append(parse_single_constraint(and_constraints[0]))
constraint_objects.append(
parse_single_constraint(
and_constraints[0], is_marker_constraint=is_marker_constraint
)
)

if len(constraint_objects) == 1:
constraint = constraint_objects[0]
Expand All @@ -54,7 +73,9 @@ def parse_constraint(constraints: str) -> VersionConstraint:
return VersionUnion.of(*or_groups)


def parse_single_constraint(constraint: str) -> VersionConstraint:
def parse_single_constraint(
constraint: str, *, is_marker_constraint: bool = False
) -> VersionConstraint:
from poetry.core.constraints.version.patterns import BASIC_CONSTRAINT
from poetry.core.constraints.version.patterns import CARET_CONSTRAINT
from poetry.core.constraints.version.patterns import TILDE_CONSTRAINT
Expand Down Expand Up @@ -117,25 +138,15 @@ def parse_single_constraint(constraint: str) -> VersionConstraint:
m = X_CONSTRAINT.match(constraint)
if m:
op = m.group("op")
major = int(m.group(2))
minor = m.group(3)

if minor is not None:
version = Version.from_parts(major, int(minor), 0)
result: VersionConstraint = VersionRange(
version, version.next_minor(), include_min=True
try:
return _make_x_constraint_range(
version=Version.parse(m.group("version")),
invert=op == "!=",
is_marker_constraint=is_marker_constraint,
)
elif major == 0:
result = VersionRange(max=Version.from_parts(1, 0, 0))
else:
version = Version.from_parts(major, 0, 0)

result = VersionRange(version, version.next_major(), include_min=True)

if op == "!=":
result = VersionRange().difference(result)

return result
except ValueError:
raise ValueError(f"Could not parse version constraint: {constraint}")

# Basic comparator
m = BASIC_CONSTRAINT.match(constraint)
Expand All @@ -161,8 +172,46 @@ def parse_single_constraint(constraint: str) -> VersionConstraint:
return VersionRange(min=version)
if op == ">=":
return VersionRange(min=version, include_min=True)

if m.group("wildcard") is not None:
return _make_x_constraint_range(
version=version,
invert=op == "!=",
is_marker_constraint=is_marker_constraint,
)

if op == "!=":
return VersionUnion(VersionRange(max=version), VersionRange(min=version))

return version

raise ParseConstraintError(f"Could not parse version constraint: {constraint}")


def _make_x_constraint_range(
version: Version, *, invert: bool = False, is_marker_constraint: bool = False
) -> VersionConstraint:
from poetry.core.constraints.version.version_range import VersionRange

if version.is_postrelease():
_next = version.next_postrelease()
elif version.is_stable():
_next = version.next_stable()
elif version.is_prerelease():
_next = version.next_prerelease()
elif version.is_devrelease():
_next = version.next_devrelease()
else:
raise RuntimeError("version is neither stable, nor pre-release nor dev-release")

_min = version

if not is_marker_constraint and not _next.is_unstable():
_min = _min.first_devrelease()

result = VersionRange(_min, _next, include_min=True)

if invert:
return VersionRange().difference(result)

return result
4 changes: 2 additions & 2 deletions src/poetry/core/constraints/version/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
rf"^~=\s*(?P<version>{VERSION_PATTERN})$", re.VERBOSE | re.IGNORECASE
)
X_CONSTRAINT = re.compile(
r"^(?P<op>!=|==)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.[xX*])+$"
r"^(?P<op>!=|==)?\s*v?(?P<version>(\d+)(?:\.(\d+))?(?:\.(\d+))?)(?:\.[xX*])+$"
)

# note that we also allow technically incorrect version patterns with astrix (eg: 3.5.*)
# as this is supported by pip and appears in metadata within python packages
BASIC_CONSTRAINT = re.compile(
rf"^(?P<op><>|!=|>=?|<=?|==?)?\s*(?P<version>{VERSION_PATTERN}|dev)(\.\*)?$",
rf"^(?P<op><>|!=|>=?|<=?|==?)?\s*(?P<version>{VERSION_PATTERN}|dev)(?P<wildcard>\.\*)?$",
re.VERBOSE | re.IGNORECASE,
)
53 changes: 26 additions & 27 deletions src/poetry/core/constraints/version/version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,9 @@ def __init__(
max: Version | None = None,
include_min: bool = False,
include_max: bool = False,
always_include_max_prerelease: bool = False,
) -> None:
full_max = max
if (
not always_include_max_prerelease
and not include_max
and full_max is not None
and full_max.is_stable()
and not full_max.is_postrelease()
and (min is None or min.is_stable() or min.release != full_max.release)
):
full_max = full_max.first_prerelease()

self._min = min
self._max = max
self._full_max = full_max
self._min = min
self._include_min = include_min
self._include_max = include_max

Expand All @@ -48,10 +35,6 @@ def min(self) -> Version | None:
def max(self) -> Version | None:
return self._max

@property
def full_max(self) -> Version | None:
return self._full_max

@property
def include_min(self) -> bool:
return self._include_min
Expand All @@ -71,27 +54,43 @@ def is_simple(self) -> bool:

def allows(self, other: Version) -> bool:
if self._min is not None:
if other < self._min:
_this, _other = self.allowed_min, other

assert _this is not None

if not _this.is_postrelease() and _other.is_postrelease():
# The exclusive ordered comparison >V MUST NOT allow a post-release
# of the given version unless V itself is a post release.
# https://peps.python.org/pep-0440/#exclusive-ordered-comparison
# e.g. "2.0.post1" does not match ">2"
_other = _other.without_postrelease()

if not _this.is_local() and _other.is_local():
# The exclusive ordered comparison >V MUST NOT match
# a local version of the specified version.
# https://peps.python.org/pep-0440/#exclusive-ordered-comparison
# e.g. "2.0+local.version" does not match ">2"
_other = other.without_local()

if _other < _this:
return False

if not self._include_min and other == self._min:
if not self._include_min and (_other == self._min or _other == _this):
return False

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

assert _this is not None

if not _this.is_local() and _other.is_local():
# allow weak equality to allow `3.0.0+local.1` for `<=3.0.0`
_other = _other.without_local()

if not _this.is_postrelease() and _other.is_postrelease():
# allow weak equality to allow `3.0.0-1` for `<=3.0.0`
_other = _other.without_postrelease()

if _other > _this:
return False

if not self._include_max and _other == _this:
if not self._include_max and (_other == self._max or _other == _this):
return False

return True
Expand Down
70 changes: 52 additions & 18 deletions src/poetry/core/constraints/version/version_range_constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ def min(self) -> Version | None:
def max(self) -> Version | None:
raise NotImplementedError

@property
@abstractmethod
def full_max(self) -> Version | None:
raise NotImplementedError

@property
@abstractmethod
def include_min(self) -> bool:
Expand All @@ -36,44 +31,83 @@ def include_min(self) -> bool:
def include_max(self) -> bool:
raise NotImplementedError

def allows_lower(self, other: VersionRangeConstraint) -> bool:
@property
def allowed_min(self) -> Version | None:
if self.min is None:
return other.min is not None
return None

# That is a bit inaccurate because
# 1) The exclusive ordered comparison >V MUST NOT allow a post-release
# of the given version unless V itself is a post release.
# 2) The exclusive ordered comparison >V MUST NOT match
# a local version of the specified version.
# https://peps.python.org/pep-0440/#exclusive-ordered-comparison
# However, there is no specific min greater than the greatest post release
# or greatest local version identifier. These cases have to be handled by
# the callers of allowed_min.
return self.min

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

if other.min is 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 allows_lower(self, other: VersionRangeConstraint) -> bool:
_this, _other = self.allowed_min, other.allowed_min

if _this is None:
return _other is not None

if _other is None:
return False

if self.min < other.min:
if _this < _other:
return True

if self.min > other.min:
if _this > _other:
return False

return self.include_min and not other.include_min

def allows_higher(self, other: VersionRangeConstraint) -> bool:
if self.full_max is None:
return other.max is not None
_this, _other = self.allowed_max, other.allowed_max

if other.full_max is None:
if _this is None:
return _other is not None

if _other is None:
return False

if self.full_max < other.full_max:
if _this < _other:
return False

if self.full_max > other.full_max:
if _this > _other:
return True

return self.include_max and not other.include_max

def is_strictly_lower(self, other: VersionRangeConstraint) -> bool:
if self.full_max is None or other.min is None:
_this, _other = self.allowed_max, other.allowed_min

if _this is None or _other is None:
return False

if self.full_max < other.min:
if _this < _other:
return True

if self.full_max > other.min:
if _this > _other:
return False

return not (self.include_max and other.include_min)
Expand Down
Loading

0 comments on commit 8bbe7a2

Please sign in to comment.