Skip to content

Commit

Permalink
fix and simplify handling of single wildcard range exclusion (e.g. "!…
Browse files Browse the repository at this point in the history
…=1.2.*")
  • Loading branch information
radoering committed Apr 30, 2023
1 parent 34cd3b2 commit 1ff92e4
Show file tree
Hide file tree
Showing 9 changed files with 290 additions and 236 deletions.
7 changes: 5 additions & 2 deletions src/poetry/core/constraints/version/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,14 @@ def _make_x_constraint_range(
raise RuntimeError("version is neither stable, nor pre-release nor dev-release")

_min = version
_max = _next

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

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

if invert:
return VersionRange().difference(result)
Expand Down
61 changes: 61 additions & 0 deletions src/poetry/core/constraints/version/version_constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,64 @@ def __hash__(self) -> int:

def __eq__(self, other: object) -> bool:
raise NotImplementedError


def _is_wildcard_candidate(
min_: Version, max_: Version, *, inverted: bool = False
) -> bool:
if (
min_.is_local()
or max_.is_local()
or min_.is_prerelease()
or max_.is_prerelease()
or min_.is_postrelease() is not max_.is_postrelease()
or min_.first_devrelease() != min_
or (max_.is_devrelease() and max_.first_devrelease() != max_)
):
return False

first = max_ if inverted else min_
second = min_ if inverted else max_

parts_first = list(first.parts)
parts_second = list(second.parts)

# remove trailing zeros from second
while parts_second and parts_second[-1] == 0:
del parts_second[-1]

# fill up first with zeros
parts_first += [0] * (len(parts_second) - len(parts_first))

# all exceeding parts of first must be zero
if set(parts_first[len(parts_second) :]) not in [set(), {0}]:
return False

parts_first = parts_first[: len(parts_second)]

if first.is_postrelease():
assert first.post is not None
return parts_first == parts_second and first.post.next() == second.post

return (
parts_first[:-1] == parts_second[:-1]
and parts_first[-1] + 1 == parts_second[-1]
)


def _single_wildcard_range_string(first: Version, second: Version) -> str:
if first.is_postrelease():
base_version = str(first.without_devrelease())

else:
parts = list(second.parts)

# remove trailing zeros from max
while parts and parts[-1] == 0:
del parts[-1]

parts[-1] = parts[-1] - 1

base_version = ".".join(str(part) for part in parts)

return f"{base_version}.*"
47 changes: 8 additions & 39 deletions src/poetry/core/constraints/version/version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
from typing import TYPE_CHECKING

from poetry.core.constraints.version.empty_constraint import EmptyConstraint
from poetry.core.constraints.version.version_constraint import _is_wildcard_candidate
from poetry.core.constraints.version.version_constraint import (
_single_wildcard_range_string,
)
from poetry.core.constraints.version.version_range_constraint import (
VersionRangeConstraint,
)
Expand Down Expand Up @@ -339,23 +343,9 @@ def _single_wildcard_range_string(self) -> str:
if not self.is_single_wildcard_range():
raise ValueError("Not a valid wildcard range")

if self.min.is_postrelease():
assert self.min is not None
base_version = str(self.min.without_devrelease())

else:
assert self.max is not None
parts = list(self.max.parts)

# remove trailing zeros from max
while parts and parts[-1] == 0:
del parts[-1]

parts[-1] = parts[-1] - 1

base_version = ".".join(str(part) for part in parts)

return f"=={base_version}.*"
assert self.min is not None
assert self.max is not None
return f"=={_single_wildcard_range_string(self.min, self.max)}"

def is_single_wildcard_range(self) -> bool:
# e.g.
Expand All @@ -367,31 +357,10 @@ def is_single_wildcard_range(self) -> bool:
or self.max is None
or not self.include_min
or self.include_max
or self.min.is_local()
or self.max.is_local()
or self.max.is_prerelease()
or self.min.is_postrelease() is not self.max.is_postrelease()
or self.min.first_devrelease() != self.min
or (self.max.is_devrelease() and self.max.first_devrelease() != self.max)
):
return False

parts_min = list(self.min.parts)
parts_max = list(self.max.parts)

# remove trailing zeros from max
while parts_max and parts_max[-1] == 0:
del parts_max[-1]

# fill up min with zeros
parts_min += [0] * (len(parts_max) - len(parts_min))

if set(parts_min[len(parts_max) :]) not in [set(), {0}]:
return False
parts_min = parts_min[: len(parts_max)]
if self.min.is_postrelease():
return parts_min == parts_max and self.min.post.next() == self.max.post
return parts_min[:-1] == parts_max[:-1] and parts_min[-1] + 1 == parts_max[-1]
return _is_wildcard_candidate(self.min, self.max)

def __eq__(self, other: object) -> bool:
if not isinstance(other, VersionRangeConstraint):
Expand Down
163 changes: 16 additions & 147 deletions src/poetry/core/constraints/version/version_union.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

from poetry.core.constraints.version.empty_constraint import EmptyConstraint
from poetry.core.constraints.version.version_constraint import VersionConstraint
from poetry.core.constraints.version.version_constraint import _is_wildcard_candidate
from poetry.core.constraints.version.version_constraint import (
_single_wildcard_range_string,
)
from poetry.core.constraints.version.version_range_constraint import (
VersionRangeConstraint,
)
Expand Down Expand Up @@ -256,168 +260,33 @@ def _exclude_single_wildcard_range_string(self) -> str:
if not self.excludes_single_wildcard_range():
raise ValueError("Not a valid wildcard range")

# we assume here that since it is a single exclusion range
# that it is one of "< 2.0.0 || >= 2.1.0" or ">= 2.1.0 || < 2.0.0"
# and the one with the max is the first part
idx_order = (0, 1) if self._ranges[0].max else (1, 0)
one = self._ranges[idx_order[0]].max
assert one is not None
two = self._ranges[idx_order[1]].min
assert two is not None

# versions can have both semver and non semver parts
parts_one = [
one.major,
one.minor or 0,
one.patch or 0,
*list(one.non_semver_parts or []),
]
parts_two = [
two.major,
two.minor or 0,
two.patch or 0,
*list(two.non_semver_parts or []),
]

# we assume here that a wildcard range implies that the part following the
# first part that is different in the second range is the wildcard, this means
# that multiple wildcards are not supported right now.
parts = []

for idx, part in enumerate(parts_one):
parts.append(str(part))
if parts_two[idx] != part:
# since this part is different the next one is the wildcard
# for example, "< 2.0.0 || >= 2.1.0" gets us a wildcard range
# 2.0.*
parts.append("*")
break
else:
# we should not ever get here, however it is likely that poorly
# constructed metadata exists
raise ValueError("Not a valid wildcard range")

return f"!={'.'.join(parts)}"

@staticmethod
def _excludes_single_wildcard_range_check_is_valid_range(
one: VersionRangeConstraint, two: VersionRangeConstraint
) -> bool:
"""
Helper method to determine if two versions define a single wildcard range.
In cases where !=2.0.* was parsed by us, the union is of the range
<2.0.0 || >=2.1.0. In user defined ranges, precision might be different.
For example, a union <2.0 || >= 2.1.0 is still !=2.0.*. In order to
handle these cases we make sure that if precisions do not match, extra
checks are performed to validate that the constraint is a valid single
wildcard range.
"""
one = self._ranges[idx_order[0]]
two = self._ranges[idx_order[1]]

assert one.max is not None
assert two.min is not None

_max = one.max
_min = two.min

if _max.is_devrelease() and _max.dev is not None and _max.dev.number == 0:
# handle <2.0.0.dev0 || >= 2.1.0
_max = _max.without_devrelease()

if _min.is_devrelease():
assert _min.dev is not None

if _min.dev.number != 0:
# if both are dev releases, they should both have dev0
return False
_min = _min.without_devrelease()

max_precision = max(_max.precision, _min.precision)

if max_precision <= 3:
# In cases where both versions have a precision less than 3,
# we can make use of the next major/minor/patch versions.
return _min in {
_max.next_major(),
_max.next_minor(),
_max.next_patch(),
}
else:
# When there are non-semver parts in one of the versions, we need to
# ensure we use zero padded version and in addition to next major/minor/
# patch versions, also check each next release for the extra parts.
from_parts = _max.__class__.from_parts

_extras: list[list[int]] = []
_versions: list[Version] = []

for _version in (_max, _min):
_extra = list(_version.non_semver_parts or [])

while len(_extra) < (max_precision - 3):
# pad zeros for extra parts to ensure precisions are equal
_extra.append(0)

# create a new release with unspecified parts padded with zeros
_padded_version: Version = from_parts(
major=_version.major,
minor=_version.minor or 0,
patch=_version.patch or 0,
extra=tuple(_extra),
)

_extras.append(_extra)
_versions.append(_padded_version)

_extra_one = _extras[0]
_padded_version_one = _versions[0]
_padded_version_two = _versions[1]

_check_versions = {
_padded_version_one.next_major(),
_padded_version_one.next_minor(),
_padded_version_one.next_patch(),
}

# for each non-semver (extra) part, bump a version
for idx, val in enumerate(_extra_one):
_extra = [
*_extra_one[: idx - 1],
(val + 1),
*_extra_one[idx + 1 :],
]
_check_versions.add(
from_parts(
_padded_version_one.major,
_padded_version_one.minor,
_padded_version_one.patch,
tuple(_extra),
)
)

return _padded_version_two in _check_versions
return f"!={_single_wildcard_range_string(one.max, two.min)}"

def excludes_single_wildcard_range(self) -> bool:
from poetry.core.constraints.version.version_range import VersionRange

if len(self._ranges) != 2:
return False

idx_order = (0, 1) if self._ranges[0].max else (1, 0)
one = self._ranges[idx_order[0]]
two = self._ranges[idx_order[1]]

is_range_exclusion = (
one.max and not one.include_max and two.min and two.include_min
)

if not is_range_exclusion:
return False

if not self._excludes_single_wildcard_range_check_is_valid_range(one, two):
if (
one.max is None
or one.include_max
or one.min is not None
or two.min is None
or not two.include_min
or two.max is not None
):
return False

return isinstance(VersionRange().difference(self), VersionRange)
return _is_wildcard_candidate(two.min, one.max, inverted=True)

def excludes_single_version(self) -> bool:
from poetry.core.constraints.version.version import Version
Expand Down
Loading

0 comments on commit 1ff92e4

Please sign in to comment.