From f5c6b648381c842223a3ea4ae28c4ea35458c7d3 Mon Sep 17 00:00:00 2001 From: Arun Babu Neelicattu Date: Fri, 2 Apr 2021 17:36:19 +0200 Subject: [PATCH] pep440: support upper bound post/local release comparisons (#157) * tests: add coverage for poetry.core.version.pep440 * pep440: allow release tuples * pep440: support post/local release comparisons This change ensures that post and local releases are taken into consideration when checking if version range allows a post release local build release at upper and lower bounds. The following conditions now hold for upper bound checks. - `<=3.0.0` allows `3.0.0+local.1`, `3.0.0-1` - `<=3.0.0+local.1` disallows `3.0.0+local.2`, allows `3.0.0-1` - `<=3.0.0-1` allows `3.0.0+local.1`, `3.0.0` Lower bound checks require no modification and works due to the implicit version comparison of `poetry.core.pep440.PEP440Version`. --- poetry/core/semver/version_range.py | 14 ++- poetry/core/version/pep440/version.py | 24 ++++ tests/semver/test_version_range.py | 140 +++++++++++++++++++++- tests/version/test_version_pep440.py | 164 ++++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 tests/version/test_version_pep440.py diff --git a/poetry/core/semver/version_range.py b/poetry/core/semver/version_range.py index 6b77e58d1..e7860d95f 100644 --- a/poetry/core/semver/version_range.py +++ b/poetry/core/semver/version_range.py @@ -74,10 +74,20 @@ def allows(self, other: "Version") -> bool: return False if self.full_max is not None: - if other > self.full_max: + _this, _other = self.full_max, other + + 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 == self.full_max: + if not self._include_max and _other == _this: return False return True diff --git a/poetry/core/version/pep440/version.py b/poetry/core/version/pep440/version.py index e84a87a72..d46a93822 100644 --- a/poetry/core/version/pep440/version.py +++ b/poetry/core/version/pep440/version.py @@ -36,6 +36,9 @@ def __post_init__(self): if self.local is not None and not isinstance(self.local, tuple): object.__setattr__(self, "local", (self.local,)) + if isinstance(self.release, tuple): + object.__setattr__(self, "release", Release(*self.release)) + # we do this here to handle both None and tomlkit string values object.__setattr__( self, "text", self.to_string() if not self.text else str(self.text) @@ -137,6 +140,9 @@ def is_postrelease(self) -> bool: def is_devrelease(self) -> bool: return self.dev is not None + def is_local(self) -> bool: + return self.local is not None + def is_no_suffix_release(self) -> bool: return not (self.pre or self.post or self.dev) @@ -200,3 +206,21 @@ def first_prerelease(self) -> "PEP440Version": return self.__class__( epoch=self.epoch, release=self.release, pre=ReleaseTag(RELEASE_PHASE_ALPHA) ) + + def replace(self, **kwargs): + return self.__class__( + **{ + **{ + k: getattr(self, k) + for k in self.__dataclass_fields__.keys() + if k not in ("_compare_key", "text") + }, # setup defaults with current values, excluding compare keys and text + **kwargs, # keys to replace + } + ) + + def without_local(self) -> "PEP440Version": + return self.replace(local=None) + + def without_postrelease(self) -> "PEP440Version": + return self.replace(post=None) diff --git a/tests/semver/test_version_range.py b/tests/semver/test_version_range.py index e6e21e34c..d2363d5b4 100644 --- a/tests/semver/test_version_range.py +++ b/tests/semver/test_version_range.py @@ -75,7 +75,135 @@ def v300b1(): return Version.parse("3.0.0b1") -def test_allows_all(v003, v010, v080, v114, v123, v124, v140, v200, v234, v250, v300): +@pytest.mark.parametrize( + "base,other", + [ + pytest.param(Version.parse("3.0.0"), Version.parse("3.0.0-1"), id="post"), + pytest.param( + Version.parse("3.0.0"), Version.parse("3.0.0+local.1"), id="local" + ), + ], +) +def test_allows_post_releases_with_max(base, other): + range = VersionRange(max=base, include_max=True) + assert range.allows(other) + + +@pytest.mark.parametrize( + "base,other", + [ + pytest.param(Version.parse("3.0.0"), Version.parse("3.0.0-1"), id="post"), + pytest.param( + Version.parse("3.0.0"), Version.parse("3.0.0+local.1"), id="local" + ), + ], +) +def test_allows_post_releases_with_min(base, other): + range = VersionRange(min=base, include_min=True) + assert range.allows(other) + + +def test_allows_post_releases_with_post_and_local_min(): + one = Version.parse("3.0.0+local.1") + two = Version.parse("3.0.0-1") + three = Version.parse("3.0.0-1+local.1") + four = Version.parse("3.0.0+local.2") + + assert VersionRange(min=one, include_min=True).allows(two) + assert VersionRange(min=one, include_min=True).allows(three) + assert VersionRange(min=one, include_min=True).allows(four) + + assert not VersionRange(min=two, include_min=True).allows(one) + assert VersionRange(min=two, include_min=True).allows(three) + assert not VersionRange(min=two, include_min=True).allows(four) + + assert not VersionRange(min=three, include_min=True).allows(one) + assert not VersionRange(min=three, include_min=True).allows(two) + assert not VersionRange(min=three, include_min=True).allows(four) + + assert not VersionRange(min=four, include_min=True).allows(one) + assert VersionRange(min=four, include_min=True).allows(two) + assert VersionRange(min=four, include_min=True).allows(three) + + +def test_allows_post_releases_with_post_and_local_max(): + one = Version.parse("3.0.0+local.1") + two = Version.parse("3.0.0-1") + three = Version.parse("3.0.0-1+local.1") + four = Version.parse("3.0.0+local.2") + + assert VersionRange(max=one, include_max=True).allows(two) + assert VersionRange(max=one, include_max=True).allows(three) + assert not VersionRange(max=one, include_max=True).allows(four) + + assert VersionRange(max=two, include_max=True).allows(one) + assert VersionRange(max=two, include_max=True).allows(three) + assert VersionRange(max=two, include_max=True).allows(four) + + assert VersionRange(max=three, include_max=True).allows(one) + assert VersionRange(max=three, include_max=True).allows(two) + assert VersionRange(max=three, include_max=True).allows(four) + + assert VersionRange(max=four, include_max=True).allows(one) + assert VersionRange(max=four, include_max=True).allows(two) + assert VersionRange(max=four, include_max=True).allows(three) + + +@pytest.mark.parametrize( + "base,one,two", + [ + pytest.param( + Version.parse("3.0.0"), + Version.parse("3.0.0-1"), + Version.parse("3.0.0-2"), + id="post", + ), + pytest.param( + Version.parse("3.0.0"), + Version.parse("3.0.0+local.1"), + Version.parse("3.0.0+local.2"), + id="local", + ), + ], +) +def test_allows_post_releases_explicit_with_max(base, one, two): + range = VersionRange(max=one, include_max=True) + assert range.allows(base) + assert not range.allows(two) + + range = VersionRange(max=two, include_max=True) + assert range.allows(base) + assert range.allows(one) + + +@pytest.mark.parametrize( + "base,one,two", + [ + pytest.param( + Version.parse("3.0.0"), + Version.parse("3.0.0-1"), + Version.parse("3.0.0-2"), + id="post", + ), + pytest.param( + Version.parse("3.0.0"), + Version.parse("3.0.0+local.1"), + Version.parse("3.0.0+local.2"), + id="local", + ), + ], +) +def test_allows_post_releases_explicit_with_min(base, one, two): + range = VersionRange(min=one, include_min=True) + assert not range.allows(base) + assert range.allows(two) + + range = VersionRange(min=two, include_min=True) + assert not range.allows(base) + assert not range.allows(one) + + +def test_allows_all(v123, v124, v140, v250, v300): assert VersionRange(v123, v250).allows_all(EmptyConstraint()) range = VersionRange(v123, v250, include_max=True) @@ -84,7 +212,8 @@ def test_allows_all(v003, v010, v080, v114, v123, v124, v140, v200, v234, v250, assert range.allows_all(v250) assert not range.allows_all(v300) - # with no min + +def test_allows_all_with_no_min(v080, v140, v250, v300): range = VersionRange(max=v250) assert range.allows_all(VersionRange(v080, v140)) assert not range.allows_all(VersionRange(v080, v300)) @@ -93,7 +222,8 @@ def test_allows_all(v003, v010, v080, v114, v123, v124, v140, v200, v234, v250, assert range.allows_all(range) assert not range.allows_all(VersionRange()) - # with no max + +def test_allows_all_with_no_max(v003, v010, v080, v140): range = VersionRange(min=v010) assert range.allows_all(VersionRange(v080, v140)) assert not range.allows_all(VersionRange(v003, v140)) @@ -102,6 +232,8 @@ def test_allows_all(v003, v010, v080, v114, v123, v124, v140, v200, v234, v250, assert range.allows_all(range) assert not range.allows_all(VersionRange()) + +def test_allows_all_bordering_range_not_more_inclusive(v010, v250): # Allows bordering range that is not more inclusive exclusive = VersionRange(v010, v250) inclusive = VersionRange(v010, v250, True, True) @@ -110,6 +242,8 @@ def test_allows_all(v003, v010, v080, v114, v123, v124, v140, v200, v234, v250, assert not exclusive.allows_all(inclusive) assert exclusive.allows_all(exclusive) + +def test_allows_all_contained_unions(v010, v114, v123, v124, v140, v200, v234): # Allows unions that are completely contained range = VersionRange(v114, v200) assert range.allows_all(VersionRange(v123, v124).union(v140)) diff --git a/tests/version/test_version_pep440.py b/tests/version/test_version_pep440.py new file mode 100644 index 000000000..11b873dac --- /dev/null +++ b/tests/version/test_version_pep440.py @@ -0,0 +1,164 @@ +import pytest + +from poetry.core.version.exceptions import InvalidVersion +from poetry.core.version.pep440 import PEP440Version +from poetry.core.version.pep440 import Release +from poetry.core.version.pep440 import ReleaseTag +from poetry.core.version.pep440.segments import RELEASE_PHASES +from poetry.core.version.pep440.segments import RELEASE_PHASES_SHORT + + +@pytest.mark.parametrize( + "parts,result", + [ + ((1,), Release(1)), + ((1, 2), Release(1, 2)), + ((1, 2, 3), Release(1, 2, 3)), + ((1, 2, 3, 4), Release(1, 2, 3, 4)), + ((1, 2, 3, 4, 5, 6), Release(1, 2, 3, (4, 5, 6))), + ], +) +def test_pep440_release_segment_from_parts(parts, result): + assert Release.from_parts(*parts) == result + + +@pytest.mark.parametrize( + "parts,result", + [ + (("a",), ReleaseTag("alpha", 0)), + (("a", 1), ReleaseTag("alpha", 1)), + (("b",), ReleaseTag("beta", 0)), + (("b", 1), ReleaseTag("beta", 1)), + (("pre",), ReleaseTag("preview", 0)), + (("pre", 1), ReleaseTag("preview", 1)), + (("c",), ReleaseTag("rc", 0)), + (("c", 1), ReleaseTag("rc", 1)), + (("r",), ReleaseTag("rev", 0)), + (("r", 1), ReleaseTag("rev", 1)), + ], +) +def test_pep440_release_tag_normalisation(parts, result): + tag = ReleaseTag(*parts) + assert tag == result + assert tag.to_string() == result.to_string() + assert tag.to_string(short=True) == result.to_string(short=True) + + +@pytest.mark.parametrize( + "parts,result", + [ + (("a",), ReleaseTag("beta")), + (("b",), ReleaseTag("rc")), + (("post",), None), + (("rc",), None), + (("rev",), None), + (("dev",), None), + ], +) +def test_pep440_release_tag_next_phase(parts, result): + assert ReleaseTag(*parts).next_phase() == result + + +@pytest.mark.parametrize( + "phase", list({*RELEASE_PHASES.keys(), *RELEASE_PHASES_SHORT.keys()}) +) +def test_pep440_release_tag_next(phase): + tag = ReleaseTag(phase=phase).next() + assert tag.phase == ReleaseTag.expand(phase) + assert tag.number == 1 + + +@pytest.mark.parametrize( + "text,result", + [ + ("1", PEP440Version(release=Release.from_parts(1))), + ("1.2.3", PEP440Version(release=Release.from_parts(1, 2, 3))), + ( + "1.2.3-1", + PEP440Version( + release=Release.from_parts(1, 2, 3), post=ReleaseTag("post", 1) + ), + ), + ( + "1.2.3.dev1", + PEP440Version( + release=Release.from_parts(1, 2, 3), dev=ReleaseTag("dev", 1) + ), + ), + ( + "1.2.3-1.dev1", + PEP440Version( + release=Release.from_parts(1, 2, 3), + post=ReleaseTag("post", 1), + dev=ReleaseTag("dev", 1), + ), + ), + ( + "1.2.3+local", + PEP440Version(release=Release.from_parts(1, 2, 3), local="local"), + ), + ( + "1.2.3+local.1", + PEP440Version(release=Release.from_parts(1, 2, 3), local=("local", 1)), + ), + ( + "1.2.3+local1", + PEP440Version(release=Release.from_parts(1, 2, 3), local="local1"), + ), + ("1.2.3+1", PEP440Version(release=Release.from_parts(1, 2, 3), local=1)), + ( + "1.2.3a1", + PEP440Version( + release=Release.from_parts(1, 2, 3), pre=ReleaseTag("alpha", 1) + ), + ), + ( + "1.2.3.a1", + PEP440Version( + release=Release.from_parts(1, 2, 3), pre=ReleaseTag("alpha", 1) + ), + ), + ( + "1.2.3alpha1", + PEP440Version( + release=Release.from_parts(1, 2, 3), pre=ReleaseTag("alpha", 1) + ), + ), + ( + "1.2.3b1", + PEP440Version( + release=Release.from_parts(1, 2, 3), pre=ReleaseTag("beta", 1) + ), + ), + ( + "1.2.3.b1", + PEP440Version( + release=Release.from_parts(1, 2, 3), pre=ReleaseTag("beta", 1) + ), + ), + ( + "1.2.3beta1", + PEP440Version( + release=Release.from_parts(1, 2, 3), pre=ReleaseTag("beta", 1) + ), + ), + ( + "1.2.3rc1", + PEP440Version(release=Release.from_parts(1, 2, 3), pre=ReleaseTag("rc", 1)), + ), + ( + "1.2.3.rc1", + PEP440Version(release=Release.from_parts(1, 2, 3), pre=ReleaseTag("rc", 1)), + ), + ], +) +def test_pep440_parse_text(text, result): + assert PEP440Version.parse(text) == result + + +@pytest.mark.parametrize( + "text", ["1.2.3.dev1-1" "example-1" "1.2.3-random1" "1.2.3-1-1"] +) +def test_pep440_parse_text_invalid_versions(text): + with pytest.raises(InvalidVersion): + assert PEP440Version.parse(text)