From 353dc3c89ab281c35ac43284ba13450342d24798 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 13 Apr 2024 11:47:02 -0400 Subject: [PATCH 1/2] In canonicalize_version, re-use Version.__str__. --- src/packaging/utils.py | 41 ++++++++-------------------------------- src/packaging/version.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index d33da5bb..e3653eb2 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -8,7 +8,7 @@ from typing import NewType, Tuple, Union, cast from .tags import Tag, parse_tag -from .version import InvalidVersion, Version +from .version import InvalidVersion, Version, _TrimmedRelease BuildTag = Union[Tuple[()], Tuple[int, str]] NormalizedName = NewType("NormalizedName", str) @@ -58,8 +58,12 @@ def canonicalize_version( version: 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. + Return a canonical form of a version as a string. + + Per PEP 625, versions may have multiple canonical forms, differing + only by trailing zeros. + + By default, zeros are stripped from the release. """ if isinstance(version, str): try: @@ -70,36 +74,7 @@ def canonicalize_version( else: parsed = version - parts = [] - - # Epoch - if parsed.epoch != 0: - parts.append(f"{parsed.epoch}!") - - # Release segment - 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: - parts.append("".join(str(x) for x in parsed.pre)) - - # Post-release - if parsed.post is not None: - parts.append(f".post{parsed.post}") - - # Development release - if parsed.dev is not None: - parts.append(f".dev{parsed.dev}") - - # Local version segment - if parsed.local is not None: - parts.append(f"+{parsed.local}") - - return "".join(parts) + return str(_TrimmedRelease(str(parsed)) if strip_trailing_zero else parsed) def parse_wheel_filename( diff --git a/src/packaging/version.py b/src/packaging/version.py index 46bc2613..5c6c5f85 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -451,6 +451,23 @@ def micro(self) -> int: return self.release[2] if len(self.release) >= 3 else 0 +class _TrimmedRelease(Version): + @property + def release(self) -> tuple[int, ...]: + """ + Release segment without any trailing zeros. + + >>> _TrimmedRelease('1.0.0').release + (1,) + >>> _TrimmedRelease('0.0').release + (0,) + """ + rel = super().release + nonzeros = (index for index, val in enumerate(rel) if val) + last_nonzero = max(nonzeros, default=0) + return rel[: last_nonzero + 1] + + def _parse_letter_version( letter: str | None, number: str | bytes | SupportsInt | None ) -> tuple[str, int] | None: From f95bceea9096e25a10cb0f7181233ed61d50c9f2 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 13 Apr 2024 12:00:22 -0400 Subject: [PATCH 2/2] Utilize singledispatch to separate concerns in canonicalize_version. --- src/packaging/utils.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/packaging/utils.py b/src/packaging/utils.py index e3653eb2..92e562ce 100644 --- a/src/packaging/utils.py +++ b/src/packaging/utils.py @@ -4,6 +4,7 @@ from __future__ import annotations +import functools import re from typing import NewType, Tuple, Union, cast @@ -54,27 +55,40 @@ def is_normalized_name(name: str) -> bool: return _normalized_regex.match(name) is not None +@functools.singledispatch def canonicalize_version( version: Version | str, *, strip_trailing_zero: bool = True ) -> str: """ Return a canonical form of a version as a string. + >>> canonicalize_version('1.0.1') + '1.0.1' + Per PEP 625, versions may have multiple canonical forms, differing only by trailing zeros. - By default, zeros are stripped from the release. + >>> canonicalize_version('1.0.0') + '1' + >>> canonicalize_version('1.0.0', strip_trailing_zero=False) + '1.0.0' + + Invalid versions are returned unaltered. + + >>> canonicalize_version('foo bar baz') + 'foo bar baz' """ - if isinstance(version, str): - try: - parsed = Version(version) - except InvalidVersion: - # Legacy versions cannot be normalized - return version - else: - parsed = version + return str(_TrimmedRelease(str(version)) if strip_trailing_zero else version) + - return str(_TrimmedRelease(str(parsed)) if strip_trailing_zero else parsed) +@canonicalize_version.register +def _(version: str, *, strip_trailing_zero: bool = True) -> str: + try: + parsed = Version(version) + except InvalidVersion: + # Legacy versions cannot be normalized + return version + return canonicalize_version(parsed, strip_trailing_zero=strip_trailing_zero) def parse_wheel_filename(