Skip to content

Commit

Permalink
improve handling of single wildcard ranges (e.g. "==1.2.*")
Browse files Browse the repository at this point in the history
  • Loading branch information
radoering committed Apr 30, 2023
1 parent 8bbe7a2 commit 34cd3b2
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 5 deletions.
62 changes: 62 additions & 0 deletions src/poetry/core/constraints/version/version_range.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from contextlib import suppress
from typing import TYPE_CHECKING

from poetry.core.constraints.version.empty_constraint import EmptyConstraint
Expand Down Expand Up @@ -334,6 +335,64 @@ def difference(self, other: VersionConstraint) -> VersionConstraint:
def flatten(self) -> list[VersionRangeConstraint]:
return [self]

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}.*"

def is_single_wildcard_range(self) -> bool:
# e.g.
# - "1.*" equals ">=1.0.dev0, <2" (equivalent to ">=1.0.dev0, <2.0.dev0")
# - "1.0.*" equals ">=1.0.dev0, <1.1"
# - "1.2.*" equals ">=1.2.dev0, <1.3"
if (
self.min is None
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]

def __eq__(self, other: object) -> bool:
if not isinstance(other, VersionRangeConstraint):
return False
Expand Down Expand Up @@ -390,6 +449,9 @@ def _compare_max(self, other: VersionRangeConstraint) -> int:
return 0

def __str__(self) -> str:
with suppress(ValueError):
return self._single_wildcard_range_string()

text = ""

if self.min is not None:
Expand Down
8 changes: 8 additions & 0 deletions src/poetry/core/version/pep440/segments.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import dataclasses

from typing import Optional
from typing import Sequence
from typing import Tuple
from typing import Union

Expand Down Expand Up @@ -68,6 +69,13 @@ def from_parts(cls, *parts: int) -> Release:
extra=parts[3:],
)

def to_parts(self) -> Sequence[int]:
return tuple(
part
for part in [self.major, self.minor, self.patch, *self.extra]
if part is not None
)

def to_string(self) -> str:
return self.text

Expand Down
8 changes: 6 additions & 2 deletions src/poetry/core/version/pep440/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from typing import TYPE_CHECKING
from typing import Any
from typing import Sequence
from typing import TypeVar

from poetry.core.version.pep440.segments import RELEASE_PHASE_ID_ALPHA
Expand Down Expand Up @@ -139,10 +140,13 @@ def patch(self) -> int | None:
return self.release.patch

@property
def non_semver_parts(self) -> tuple[int, ...]:
assert isinstance(self.release.extra, tuple)
def non_semver_parts(self) -> Sequence[int]:
return self.release.extra

@property
def parts(self) -> Sequence[int]:
return self.release.to_parts()

def to_string(self, short: bool = False) -> str:
if short:
import warnings
Expand Down
91 changes: 91 additions & 0 deletions tests/constraints/version/test_version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,3 +655,94 @@ def test_specifiers(version: str, spec: str, expected: bool) -> None:
# Test that the version instance form works
# assert version not in spec
assert not constraint.allows(v)


@pytest.mark.parametrize(
("include_min", "include_max", "expected"),
[
(True, False, True),
(False, False, False),
(False, True, False),
(True, True, False),
],
)
def test_is_single_wildcard_range_include_min_include_max(
include_min: bool, include_max: bool, expected: bool
) -> None:
version_range = VersionRange(
Version.parse("1.2.dev0"), Version.parse("1.3"), include_min, include_max
)
assert version_range.is_single_wildcard_range() is expected


@pytest.mark.parametrize(
("min", "max", "expected"),
[
# simple wildcard ranges
("1.2.dev0", "1.3", True),
("1.2.dev0", "1.3.dev0", True),
("1.dev0", "2", True),
("1.2.3.4.5.dev0", "1.2.3.4.6", True),
# simple non wilcard ranges
(None, "1.3", False),
("1.2.dev0", None, False),
(None, None, False),
("1.2a0", "1.3", False),
("1.2.post0", "1.3", False),
("1.2.dev0+local", "1.3", False),
("1.2", "1.3", False),
("1.2.dev1", "1.3", False),
("1.2.dev0", "1.3.post0.dev0", False),
("1.2.dev0", "1.3a0.dev0", False),
("1.2.dev0", "1.3.dev0+local", False),
("1.2.dev0", "1.3.dev1", False),
# more complicated ranges
("1.dev0", "1.0.0.1", True),
("1.2.dev0", "1.3.0.0", True),
("1.2.dev0", "1.3.0.0.dev0", True),
("1.2.0.dev0", "1.3", True),
("1.2.1.dev0", "1.3.0.0", False),
("1.2.dev0", "1.4", False),
("1.2.dev0", "2.3", False),
# post releases
("2.0.post1.dev0", "2.0.post2", True),
("2.0.post1.dev0", "2.0.post2.dev0", True),
("2.0.post1.dev1", "2.0.post2", False),
("2.0.post1.dev0", "2.0.post2.dev1", False),
("2.0.post1.dev0", "2.0.post3", False),
("2.0.post1.dev0", "2.0.post1", False),
],
)
def test_is_single_wildcard_range(
min: str | None, max: str | None, expected: bool
) -> None:
version_range = VersionRange(
Version.parse(min) if min else None,
Version.parse(max) if max else None,
include_min=True,
)
assert version_range.is_single_wildcard_range() is expected


@pytest.mark.parametrize(
("version", "expected"),
[
# simple ranges
("*", "*"),
(">1.2", ">1.2"),
(">=1.2", ">=1.2"),
("<1.3", "<1.3"),
("<=1.3", "<=1.3"),
(">=1.2,<1.3", ">=1.2,<1.3"),
# wildcard ranges
("1.*", "==1.*"),
("1.0.*", "==1.0.*"),
("1.2.*", "==1.2.*"),
("1.2.3.4.5.*", "==1.2.3.4.5.*"),
("2.0.post1.*", "==2.0.post1.*"),
("2.1.post0.*", "==2.1.post0.*"),
(">=1.dev0,<2", "==1.*"),
],
)
def test_str(version: str, expected: str) -> None:
assert str(parse_constraint(version)) == expected
17 changes: 16 additions & 1 deletion tests/packages/test_dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,22 @@ def test_complete_name() -> None:
["x"],
"A[x] (>=1.6.5,!=1.8.0,<3.1.0)",
),
# test single version range exclusions
# test single version range (wildcard)
("A", "==2.*", None, "A (==2.*)"),
("A", "==2.0.*", None, "A (==2.0.*)"),
("A", "==0.0.*", None, "A (==0.0.*)"),
("A", "==0.1.*", None, "A (==0.1.*)"),
("A", "==0.*", None, "A (==0.*)"),
("A", ">=1.0.dev0,<2", None, "A (==1.*)"),
("A", ">=1.dev0,<2", None, "A (==1.*)"),
("A", ">=1.0.dev1,<2", None, "A (>=1.0.dev1,<2)"),
("A", ">=1.1.dev0,<2", None, "A (>=1.1.dev0,<2)"),
("A", ">=1.0.dev0,<2.0.dev0", None, "A (==1.*)"),
("A", ">=1.0.dev0,<2.0.dev1", None, "A (>=1.0.dev0,<2.0.dev1)"),
("A", ">=1,<2", None, "A (>=1,<2)"),
("A", ">=1.0.dev0,<1.1", None, "A (==1.0.*)"),
("A", ">=1.0.0.0.dev0,<1.1.0.0.0", None, "A (==1.0.*)"),
# test single version range (wildcard) exclusions
("A", ">=1.8,!=2.0.*", None, "A (>=1.8,!=2.0.*)"),
("A", "!=0.0.*", None, "A (!=0.0.*)"),
("A", "!=0.1.*", None, "A (!=0.1.*)"),
Expand Down
7 changes: 5 additions & 2 deletions tests/version/pep440/test_segments.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,19 @@ def test_release_equal_zero_padding(precision1: int, precision2: int) -> None:
((1, 2, 3, 4, 5, 6), Release(1, 2, 3, (4, 5, 6))),
],
)
def test_release_from_parts(parts: tuple[int, ...], result: Release) -> None:
def test_release_from_parts_to_parts(parts: tuple[int, ...], result: Release) -> None:
assert Release.from_parts(*parts) == result
assert result.to_parts() == parts


@pytest.mark.parametrize("precision", list(range(1, 6)))
def test_release_precision(precision: int) -> None:
"""
Semantically identical releases might have a different precision, e.g. 1 vs. 1.0
"""
assert Release.from_parts(1, *[0] * (precision - 1)).precision == precision
release = Release.from_parts(1, *[0] * (precision - 1))
assert release.precision == precision
assert len(release.to_parts()) == precision


@pytest.mark.parametrize("precision", list(range(1, 6)))
Expand Down

0 comments on commit 34cd3b2

Please sign in to comment.