From e99b37eae68da4016d8d015452a3c031e5d75e5d Mon Sep 17 00:00:00 2001 From: kasium <15907922+kasium@users.noreply.github.com> Date: Sat, 15 Jan 2022 08:59:02 +0100 Subject: [PATCH] Fix compatible version specifier incorrectly strip trailing '0' (#493) Co-authored-by: Tzu-ping Chung Co-authored-by: Pradyun Gedam Co-authored-by: Brett Cannon --- packaging/specifiers.py | 6 +++++- packaging/utils.py | 11 ++++++++--- tests/test_specifiers.py | 11 +++++++++++ tests/test_utils.py | 5 +++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packaging/specifiers.py b/packaging/specifiers.py index 0e218a6f..6e5d4d59 100644 --- a/packaging/specifiers.py +++ b/packaging/specifiers.py @@ -119,7 +119,11 @@ def __str__(self) -> str: @property def _canonical_spec(self) -> Tuple[str, str]: - return self._spec[0], canonicalize_version(self._spec[1]) + canonical_version = canonicalize_version( + self._spec[1], + strip_trailing_zero=(self._spec[0] != "~="), + ) + return self._spec[0], canonical_version def __hash__(self) -> int: return hash(self._canonical_spec) diff --git a/packaging/utils.py b/packaging/utils.py index bab11b80..33c613b7 100644 --- a/packaging/utils.py +++ b/packaging/utils.py @@ -35,7 +35,9 @@ def canonicalize_name(name: str) -> NormalizedName: return cast(NormalizedName, value) -def canonicalize_version(version: Union[Version, str]) -> str: +def canonicalize_version( + version: Union[Version, str], *, strip_trailing_zero: bool = True +) -> str: """ This is very similar to Version.__str__, but has one subtle difference with the way it handles the release segment. @@ -56,8 +58,11 @@ def canonicalize_version(version: Union[Version, str]) -> str: parts.append(f"{parsed.epoch}!") # Release segment - # NB: This strips trailing '.0's to normalize - parts.append(re.sub(r"(\.0)+$", "", ".".join(str(x) for x in parsed.release))) + release_segment = ".".join(str(x) for x in parsed.release) + if strip_trailing_zero: + # NB: This strips trailing '.0's to normalize + release_segment = re.sub(r"(\.0)+$", "", release_segment) + parts.append(release_segment) # Pre-release if parsed.pre is not None: diff --git a/tests/test_specifiers.py b/tests/test_specifiers.py index ca21fa1d..41a3f829 100644 --- a/tests/test_specifiers.py +++ b/tests/test_specifiers.py @@ -630,6 +630,12 @@ def test_iteration(self, spec, expected_items): items = {str(item) for item in spec} assert items == set(expected_items) + def test_specifier_equal_for_compatible_operator(self): + assert Specifier("~=1.18.0") != Specifier("~=1.18") + + def test_specifier_hash_for_compatible_operator(self): + assert hash(Specifier("~=1.18.0")) != hash(Specifier("~=1.18")) + class TestLegacySpecifier: def test_legacy_specifier_is_deprecated(self): @@ -996,3 +1002,8 @@ def test_comparison_non_specifier(self): ) def test_comparison_ignores_local(self, version, specifier, expected): assert (Version(version) in SpecifierSet(specifier)) == expected + + def test_contains_with_compatible_operator(self): + combination = SpecifierSet("~=1.18.0") & SpecifierSet("~=1.18") + assert "1.19.5" not in combination + assert "1.18.0" in combination diff --git a/tests/test_utils.py b/tests/test_utils.py index be52d670..84a8b38b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -56,6 +56,11 @@ def test_canonicalize_version(version, expected): assert canonicalize_version(version) == expected +@pytest.mark.parametrize(("version"), ["1.4.0", "1.0"]) +def test_canonicalize_version_no_strip_trailing_zero(version): + assert canonicalize_version(version, strip_trailing_zero=False) == version + + @pytest.mark.parametrize( ("filename", "name", "version", "build", "tags"), [