diff --git a/src/poetry/core/constraints/version/version_range.py b/src/poetry/core/constraints/version/version_range.py index 6ed41369c..9db7d12dd 100644 --- a/src/poetry/core/constraints/version/version_range.py +++ b/src/poetry/core/constraints/version/version_range.py @@ -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 @@ -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 @@ -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: diff --git a/src/poetry/core/version/pep440/segments.py b/src/poetry/core/version/pep440/segments.py index b606b6a57..1c6aaa9ef 100644 --- a/src/poetry/core/version/pep440/segments.py +++ b/src/poetry/core/version/pep440/segments.py @@ -3,6 +3,7 @@ import dataclasses from typing import Optional +from typing import Sequence from typing import Tuple from typing import Union @@ -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 diff --git a/src/poetry/core/version/pep440/version.py b/src/poetry/core/version/pep440/version.py index adacb3742..17a9fe3d9 100644 --- a/src/poetry/core/version/pep440/version.py +++ b/src/poetry/core/version/pep440/version.py @@ -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 @@ -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 diff --git a/tests/constraints/version/test_version_range.py b/tests/constraints/version/test_version_range.py index ecb5e444e..51508a50b 100644 --- a/tests/constraints/version/test_version_range.py +++ b/tests/constraints/version/test_version_range.py @@ -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 diff --git a/tests/packages/test_dependency.py b/tests/packages/test_dependency.py index ddfa000e1..3ea6cbb8d 100644 --- a/tests/packages/test_dependency.py +++ b/tests/packages/test_dependency.py @@ -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.*)"), diff --git a/tests/version/pep440/test_segments.py b/tests/version/pep440/test_segments.py index 3051ad255..6a991f05a 100644 --- a/tests/version/pep440/test_segments.py +++ b/tests/version/pep440/test_segments.py @@ -51,8 +51,9 @@ 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))) @@ -60,7 +61,9 @@ 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)))