diff --git a/CHANGES.md b/CHANGES.md index 34f094f..c4be06e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,10 @@ ## Unreleased - Maintenance: Added `py.typed` marker file, signalling typing support +- packaging.version: Updated to packaging v26.0 +- packaging.version: Restored backward-compatibility with `distutils.version` +- packaging.version: Removed PEP 570 compatibility to support EOL Pythons +- packaging.version: Compatibility adjustments for Python 3.6 and 3.7 ## 2025-02-11 v0.3.1 - Fixed packaging diff --git a/README.md b/README.md index 5d58a34..f7aaded 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ without anything else. It also includes the original `distutils.version` implementation, for those who need it going forward. +To retain backward compatibility with earlier versions of Python, +syntax features like [PEP 570] have been removed from the original code. ## Rationale @@ -120,3 +122,4 @@ excellent development tooling. [verlib2]: https://pypi.org/project/verlib2/ [PEP 386]: https://peps.python.org/pep-0386/ [PEP 440]: https://peps.python.org/pep-0440/ +[PEP 570]: https://peps.python.org/pep-0570/ diff --git a/pyproject.toml b/pyproject.toml index 73a22ff..dcd2886 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dynamic = [ ] dependencies = [ "importlib-metadata; python_version<'3.8'", + "typing-extensions; python_version<'3.8'", ] optional-dependencies.develop = [ "poethepoet<1", diff --git a/tests/test_version_packaging.py b/tests/test_version_packaging.py index 78abbc7..943936b 100644 --- a/tests/test_version_packaging.py +++ b/tests/test_version_packaging.py @@ -2,20 +2,41 @@ # 2.0, and the BSD License. See the LICENSE file in the root of this repository # for complete details. + import itertools import operator +import sys +import typing import pretend import pytest -from verlib2.packaging.version import InvalidVersion, Version, parse +from verlib2.packaging.version import InvalidVersion, Version, _VersionReplace, parse + +if typing.TYPE_CHECKING: + from collections.abc import Callable + + from typing_extensions import Self, Unpack + +if sys.version_info >= (3, 13): + from copy import replace +else: + T = typing.TypeVar("T") + class SupportsReplace(typing.Protocol): + def __replace__(self, **kwargs: Unpack[_VersionReplace]) -> Self: ... -def test_parse(): + S = typing.TypeVar("S", bound="SupportsReplace") + + def replace(item: S, **kwargs: Unpack[_VersionReplace]) -> S: + return item.__replace__(**kwargs) + + +def test_parse() -> None: assert isinstance(parse("1.0"), Version) -def test_parse_raises(): +def test_parse_raises() -> None: with pytest.raises(InvalidVersion): parse("lolwat") @@ -27,24 +48,69 @@ def test_parse_raises(): "1.0a1", "1.0a2.dev456", "1.0a12.dev456", + "1.0a12", + "1.0b1.dev456", + "1.0b2", + "1.0b2.post345.dev456", + "1.0b2.post345", + "1.0b2-346", + "1.0c1.dev456", + "1.0c1", "1.0rc2", "1.0c3", "1.0", "1.0.post456.dev34", + "1.0.post456", + "1.1.dev1", + "1.2+123abc", + "1.2+123abc456", + "1.2+abc", + "1.2+abc123", + "1.2+abc123def", + "1.2+1234.abc", + "1.2+123456", + "1.2.r32+123456", "1.2.rev33+123456", # Explicit epoch of 1 "1!1.0.dev456", "1!1.0a1", "1!1.0a2.dev456", + "1!1.0a12.dev456", + "1!1.0a12", + "1!1.0b1.dev456", + "1!1.0b2", + "1!1.0b2.post345.dev456", + "1!1.0b2.post345", + "1!1.0b2-346", + "1!1.0c1.dev456", + "1!1.0c1", + "1!1.0rc2", + "1!1.0c3", + "1!1.0", + "1!1.0.post456.dev34", + "1!1.0.post456", + "1!1.1.dev1", + "1!1.2+123abc", + "1!1.2+123abc456", + "1!1.2+abc", + "1!1.2+abc123", + "1!1.2+abc123def", + "1!1.2+1234.abc", + "1!1.2+123456", + "1!1.2.r32+123456", "1!1.2.rev33+123456", ] class TestVersion: @pytest.mark.parametrize("version", VERSIONS) - def test_valid_versions(self, version): + def test_valid_versions(self, version: str) -> None: Version(version) + def test_match_args(self) -> None: + assert Version.__match_args__ == ("_str",) + assert Version("1.2")._str == "1.2" + @pytest.mark.parametrize( "version", [ @@ -58,7 +124,7 @@ def test_valid_versions(self, version): "1.0+1+1", ], ) - def test_invalid_versions(self, version): + def test_invalid_versions(self, version: str) -> None: with pytest.raises(InvalidVersion): Version(version) @@ -69,13 +135,11 @@ def test_invalid_versions(self, version): ("1.0dev", "1.0.dev0"), ("1.0.dev", "1.0.dev0"), ("1.0dev1", "1.0.dev1"), - ("1.0dev", "1.0.dev0"), ("1.0-dev", "1.0.dev0"), ("1.0-dev1", "1.0.dev1"), ("1.0DEV", "1.0.dev0"), ("1.0.DEV", "1.0.dev0"), ("1.0DEV1", "1.0.dev1"), - ("1.0DEV", "1.0.dev0"), ("1.0.DEV1", "1.0.dev1"), ("1.0-DEV", "1.0.dev0"), ("1.0-DEV1", "1.0.dev1"), @@ -146,13 +210,11 @@ def test_invalid_versions(self, version): ("1.0post", "1.0.post0"), ("1.0.post", "1.0.post0"), ("1.0post1", "1.0.post1"), - ("1.0post", "1.0.post0"), ("1.0-post", "1.0.post0"), ("1.0-post1", "1.0.post1"), ("1.0POST", "1.0.post0"), ("1.0.POST", "1.0.post0"), ("1.0POST1", "1.0.post1"), - ("1.0POST", "1.0.post0"), ("1.0r", "1.0.post0"), ("1.0rev", "1.0.post0"), ("1.0.POST1", "1.0.post1"), @@ -180,7 +242,7 @@ def test_invalid_versions(self, version): (" v1.0\t\n", "1.0"), ], ) - def test_normalized_versions(self, version, normalized): + def test_normalized_versions(self, version: str, normalized: str) -> None: assert str(Version(version)) == normalized @pytest.mark.parametrize( @@ -235,15 +297,15 @@ def test_normalized_versions(self, version, normalized): ("7!1.1.dev1", "7!1.1.dev1"), ], ) - def test_version_str_repr(self, version, expected): + def test_version_str_repr(self, version: str, expected: str) -> None: assert str(Version(version)) == expected assert repr(Version(version)) == f"" - def test_version_rc_and_c_equals(self): + def test_version_rc_and_c_equals(self) -> None: assert Version("1.0rc1") == Version("1.0c1") @pytest.mark.parametrize("version", VERSIONS) - def test_version_hash(self, version): + def test_version_hash(self, version: str) -> None: assert hash(Version(version)) == hash(Version(version)) @pytest.mark.parametrize( @@ -280,7 +342,7 @@ def test_version_hash(self, version): ("1!1.0.post5+deadbeef", "1!1.0.post5"), ], ) - def test_version_public(self, version, public): + def test_version_public(self, version: str, public: str) -> None: assert Version(version).public == public @pytest.mark.parametrize( @@ -317,7 +379,7 @@ def test_version_public(self, version, public): ("1!1.0.post5+deadbeef", "1!1.0"), ], ) - def test_version_base_version(self, version, base_version): + def test_version_base_version(self, version: str, base_version: str) -> None: assert Version(version).base_version == base_version @pytest.mark.parametrize( @@ -354,7 +416,7 @@ def test_version_base_version(self, version, base_version): ("1!1.0.post5+deadbeef", 1), ], ) - def test_version_epoch(self, version, epoch): + def test_version_epoch(self, version: str, epoch: int) -> None: assert Version(version).epoch == epoch @pytest.mark.parametrize( @@ -391,7 +453,7 @@ def test_version_epoch(self, version, epoch): ("1!1.0.post5+deadbeef", (1, 0)), ], ) - def test_version_release(self, version, release): + def test_version_release(self, version: str, release: tuple[int, int]) -> None: assert Version(version).release == release @pytest.mark.parametrize( @@ -428,7 +490,7 @@ def test_version_release(self, version, release): ("1!1.0.post5+deadbeef", "deadbeef"), ], ) - def test_version_local(self, version, local): + def test_version_local(self, version: str, local: str | None) -> None: assert Version(version).local == local @pytest.mark.parametrize( @@ -465,7 +527,7 @@ def test_version_local(self, version, local): ("1!1.0.post5+deadbeef", None), ], ) - def test_version_pre(self, version, pre): + def test_version_pre(self, version: str, pre: None | tuple[str, int]) -> None: assert Version(version).pre == pre @pytest.mark.parametrize( @@ -495,7 +557,7 @@ def test_version_pre(self, version, pre): ("1.0.post1+dev", False), ], ) - def test_version_is_prerelease(self, version, expected): + def test_version_is_prerelease(self, version: str, expected: bool) -> None: assert Version(version).is_prerelease is expected @pytest.mark.parametrize( @@ -532,7 +594,7 @@ def test_version_is_prerelease(self, version, expected): ("1!1.0.post5+deadbeef", None), ], ) - def test_version_dev(self, version, dev): + def test_version_dev(self, version: str, dev: int | None) -> None: assert Version(version).dev == dev @pytest.mark.parametrize( @@ -569,7 +631,7 @@ def test_version_dev(self, version, dev): ("1!1.0.post5+deadbeef", False), ], ) - def test_version_is_devrelease(self, version, expected): + def test_version_is_devrelease(self, version: str, expected: bool) -> None: assert Version(version).is_devrelease is expected @pytest.mark.parametrize( @@ -606,7 +668,7 @@ def test_version_is_devrelease(self, version, expected): ("1!1.0.post5+deadbeef", 5), ], ) - def test_version_post(self, version, post): + def test_version_post(self, version: str, post: int | None) -> None: assert Version(version).post == post @pytest.mark.parametrize( @@ -619,16 +681,16 @@ def test_version_post(self, version, post): ("1.0.post1", True), ], ) - def test_version_is_postrelease(self, version, expected): + def test_version_is_postrelease(self, version: str, expected: bool) -> None: assert Version(version).is_postrelease is expected @pytest.mark.parametrize( ("left", "right", "op"), # Below we'll generate every possible combination of VERSIONS that # should be True for the given operator - itertools.chain( + itertools.chain.from_iterable( # Verify that the less than (<) operator works correctly - *[ + [ [(x, y, operator.lt) for y in VERSIONS[i + 1 :]] for i, x in enumerate(VERSIONS) ] @@ -661,16 +723,18 @@ def test_version_is_postrelease(self, version, expected): ] ), ) - def test_comparison_true(self, left, right, op): + def test_comparison_true( + self, left: str, right: str, op: Callable[[Version, Version], bool] + ) -> None: assert op(Version(left), Version(right)) @pytest.mark.parametrize( ("left", "right", "op"), # Below we'll generate every possible combination of VERSIONS that # should be False for the given operator - itertools.chain( + itertools.chain.from_iterable( # Verify that the less than (<) operator works correctly - *[ + [ [(x, y, operator.lt) for y in VERSIONS[: i + 1]] for i, x in enumerate(VERSIONS) ] @@ -703,28 +767,266 @@ def test_comparison_true(self, left, right, op): ] ), ) - def test_comparison_false(self, left, right, op): + def test_comparison_false( + self, left: str, right: str, op: Callable[[Version, Version], bool] + ) -> None: assert not op(Version(left), Version(right)) @pytest.mark.parametrize("op", ["lt", "le", "eq", "ge", "gt", "ne"]) - def test_dunder_op_returns_notimplemented(self, op): + def test_dunder_op_returns_notimplemented(self, op: str) -> None: method = getattr(Version, f"__{op}__") assert method(Version("1"), 1) is NotImplemented @pytest.mark.parametrize(("op", "expected"), [("eq", False), ("ne", True)]) - def test_compare_other(self, op, expected): - other = pretend.stub(**{f"__{op}__": lambda other: NotImplemented}) + def test_compare_other(self, op: str, expected: bool) -> None: + other = pretend.stub(**{f"__{op}__": lambda _: NotImplemented}) assert getattr(operator, op)(Version("1"), other) is expected - def test_major_version(self): + def test_major_version(self) -> None: assert Version("2.1.0").major == 2 - def test_minor_version(self): + def test_minor_version(self) -> None: assert Version("2.1.0").minor == 1 assert Version("2").minor == 0 - def test_micro_version(self): + def test_micro_version(self) -> None: assert Version("2.1.3").micro == 3 assert Version("2.1").micro == 0 assert Version("2").micro == 0 + + # Tests for replace() method + def test_replace_no_args(self) -> None: + """replace() with no arguments should return an equivalent version""" + v = Version("1.2.3a1.post2.dev3+local") + v_replaced = replace(v) + assert v == v_replaced + assert str(v) == str(v_replaced) + + def test_replace_epoch(self) -> None: + v = Version("1.2.3") + assert str(replace(v, epoch=2)) == "2!1.2.3" + assert replace(v, epoch=0).epoch == 0 + + v_with_epoch = Version("1!1.2.3") + assert str(replace(v_with_epoch, epoch=2)) == "2!1.2.3" + assert str(replace(v_with_epoch, epoch=None)) == "1.2.3" + + def test_replace_release_tuple(self) -> None: + v = Version("1.2.3") + assert str(replace(v, release=(2, 0, 0))) == "2.0.0" + assert str(replace(v, release=(1,))) == "1" + assert str(replace(v, release=(1, 2, 3, 4, 5))) == "1.2.3.4.5" + + def test_replace_release_none(self) -> None: + v = Version("1.2.3") + assert str(replace(v, release=None)) == "0" + + def test_replace_pre_alpha(self) -> None: + v = Version("1.2.3") + assert str(replace(v, pre=("a", 1))) == "1.2.3a1" + assert str(replace(v, pre=("a", 0))) == "1.2.3a0" + + def test_replace_pre_alpha_none(self) -> None: + v = Version("1.2.3a1") + assert str(replace(v, pre=None)) == "1.2.3" + + def test_replace_pre_beta(self) -> None: + v = Version("1.2.3") + assert str(replace(v, pre=("b", 1))) == "1.2.3b1" + assert str(replace(v, pre=("b", 0))) == "1.2.3b0" + + def test_replace_pre_beta_none(self) -> None: + v = Version("1.2.3b1") + assert str(replace(v, pre=None)) == "1.2.3" + + def test_replace_pre_rc(self) -> None: + v = Version("1.2.3") + assert str(replace(v, pre=("rc", 1))) == "1.2.3rc1" + assert str(replace(v, pre=("rc", 0))) == "1.2.3rc0" + + def test_replace_pre_rc_none(self) -> None: + v = Version("1.2.3rc1") + assert str(replace(v, pre=None)) == "1.2.3" + + def test_replace_post(self) -> None: + v = Version("1.2.3") + assert str(replace(v, post=1)) == "1.2.3.post1" + assert str(replace(v, post=0)) == "1.2.3.post0" + + def test_replace_post_none(self) -> None: + v = Version("1.2.3.post1") + assert str(replace(v, post=None)) == "1.2.3" + + def test_replace_dev(self) -> None: + v = Version("1.2.3") + assert str(replace(v, dev=1)) == "1.2.3.dev1" + assert str(replace(v, dev=0)) == "1.2.3.dev0" + + def test_replace_dev_none(self) -> None: + v = Version("1.2.3.dev1") + assert str(replace(v, dev=None)) == "1.2.3" + + def test_replace_local_string(self) -> None: + v = Version("1.2.3") + assert str(replace(v, local="abc")) == "1.2.3+abc" + assert str(replace(v, local="abc.123")) == "1.2.3+abc.123" + assert str(replace(v, local="abc-123")) == "1.2.3+abc.123" + + def test_replace_local_none(self) -> None: + v = Version("1.2.3+local") + assert str(replace(v, local=None)) == "1.2.3" + + def test_replace_multiple_components(self) -> None: + v = Version("1.2.3") + assert str(replace(v, pre=("a", 1), post=1)) == "1.2.3a1.post1" + assert str(replace(v, release=(2, 0, 0), pre=("b", 2), dev=1)) == "2.0.0b2.dev1" + assert str(replace(v, epoch=1, release=(3, 0), local="abc")) == "1!3.0+abc" + + def test_replace_clear_all_optional(self) -> None: + v = Version("1!1.2.3a1.post2.dev3+local") + cleared = replace(v, epoch=None, pre=None, post=None, dev=None, local=None) + assert str(cleared) == "1.2.3" + + def test_replace_preserves_comparison(self) -> None: + v1 = Version("1.2.3") + v2 = Version("1.2.4") + + v1_new = replace(v1, release=(1, 2, 4)) + assert v1_new == v2 + assert v1 < v2 + assert v1_new >= v2 + + def test_replace_preserves_hash(self) -> None: + v1 = Version("1.2.3") + v2 = replace(v1, release=(1, 2, 3)) + assert hash(v1) == hash(v2) + + v3 = replace(v1, release=(2, 0, 0)) + assert hash(v1) != hash(v3) + + def test_replace_returns_same_instance_when_unchanged(self) -> None: + """replace() returns the exact same object when no components change""" + v = Version("1.2.3a1.post2.dev3+local") + assert replace(v) is v + assert replace(v, epoch=0) is v + assert replace(v, release=(1, 2, 3)) is v + assert replace(v, pre=("a", 1)) is v + assert replace(v, post=2) is v + assert replace(v, dev=3) is v + assert replace(v, local="local") is v + + def test_replace_change_pre_type(self) -> None: + """Can change from one pre-release type to another""" + v = Version("1.2.3a1") + assert str(replace(v, pre=("b", 2))) == "1.2.3b2" + assert str(replace(v, pre=("rc", 1))) == "1.2.3rc1" + + v2 = Version("1.2.3rc5") + assert str(replace(v2, pre=("a", 0))) == "1.2.3a0" + + def test_replace_invalid_epoch_type(self) -> None: + v = Version("1.2.3") + with pytest.raises(InvalidVersion, match="epoch must be non-negative"): + replace(v, epoch="1") # type: ignore[arg-type] + + def test_replace_invalid_post_type(self) -> None: + v = Version("1.2.3") + with pytest.raises(InvalidVersion, match="post must be non-negative"): + replace(v, post="1") # type: ignore[arg-type] + + def test_replace_invalid_dev_type(self) -> None: + v = Version("1.2.3") + with pytest.raises(InvalidVersion, match="dev must be non-negative"): + replace(v, dev="1") # type: ignore[arg-type] + + def test_replace_invalid_epoch_negative(self) -> None: + v = Version("1.2.3") + with pytest.raises(InvalidVersion, match="epoch must be non-negative"): + replace(v, epoch=-1) + + def test_replace_invalid_release_empty(self) -> None: + v = Version("1.2.3") + with pytest.raises(InvalidVersion, match="release must be a non-empty tuple"): + replace(v, release=()) + + def test_replace_invalid_release_tuple_content(self) -> None: + v = Version("1.2.3") + with pytest.raises( + InvalidVersion, match="release must be a non-empty tuple of non-negative" + ): + replace(v, release=(1, -2, 3)) + + def test_replace_invalid_pre_negative(self) -> None: + v = Version("1.2.3") + with pytest.raises(InvalidVersion, match="pre must be a tuple"): + replace(v, pre=("a", -1)) + + def test_replace_invalid_pre_type(self) -> None: + v = Version("1.2.3") + with pytest.raises(InvalidVersion, match="pre must be a tuple"): + replace(v, pre=("x", 1)) # type: ignore[arg-type] + + def test_replace_invalid_pre_format(self) -> None: + v = Version("1.2.3") + with pytest.raises(InvalidVersion, match="pre must be a tuple"): + replace(v, pre="a1") # type: ignore[arg-type] + with pytest.raises(InvalidVersion, match="pre must be a tuple"): + replace(v, pre=("a",)) # type: ignore[arg-type] + with pytest.raises(InvalidVersion, match="pre must be a tuple"): + replace(v, pre=("a", 1, 2)) # type: ignore[arg-type] + + def test_replace_invalid_post_negative(self) -> None: + v = Version("1.2.3") + with pytest.raises(InvalidVersion, match="post must be non-negative"): + replace(v, post=-1) + + def test_replace_invalid_dev_negative(self) -> None: + v = Version("1.2.3") + with pytest.raises(InvalidVersion, match="dev must be non-negative"): + replace(v, dev=-1) + + def test_replace_invalid_local_string(self) -> None: + v = Version("1.2.3") + with pytest.raises( + InvalidVersion, match="local must be a valid version string" + ): + replace(v, local="abc+123") + with pytest.raises( + InvalidVersion, match="local must be a valid version string" + ): + replace(v, local="+abc") + + +# Taken from hatchling 1.28 +def reset_version_parts(version: Version, **kwargs: typing.Any) -> None: # noqa: ANN401 + # https://github.com/pypa/packaging/blob/20.9/packaging/version.py#L301-L310 + internal_version = version._version + parts: dict[str, typing.Any] = {} + ordered_part_names = ("epoch", "release", "pre", "post", "dev", "local") + + reset = False + for part_name in ordered_part_names: + if reset: + parts[part_name] = kwargs.get(part_name) + elif part_name in kwargs: + parts[part_name] = kwargs[part_name] + reset = True + else: + parts[part_name] = getattr(internal_version, part_name) + + version._version = type(internal_version)(**parts) + + +# These will be deprecated in 26.1, and removed in the future +def test_deprecated__version() -> None: + v = Version("1.2.3") + with pytest.warns(DeprecationWarning, match="is private"): + assert v._version.release == (1, 2, 3) + + +def test_hatchling_usage__version() -> None: + v = Version("2.3.4") + with pytest.warns(DeprecationWarning, match="is private"): + reset_version_parts(v, post=("post", 1)) + assert v == Version("2.3.4.post1") diff --git a/verlib2/packaging/version.py b/verlib2/packaging/version.py index 18c045b..29b1d20 100644 --- a/verlib2/packaging/version.py +++ b/verlib2/packaging/version.py @@ -7,13 +7,64 @@ from packaging.version import parse, Version """ -import itertools import re -from typing import Any, Callable, NamedTuple, Optional, SupportsInt, Tuple, Union +import sys +import typing +from typing import ( + Any, + Callable, + NamedTuple, + SupportsInt, + Tuple, + TypedDict, + Union, +) + +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType -__all__ = ["VERSION_PATTERN", "parse", "Version", "InvalidVersion"] +if typing.TYPE_CHECKING: + from typing_extensions import Self, Unpack + +if sys.version_info >= (3, 13): # pragma: no cover + from warnings import deprecated as _deprecated +elif typing.TYPE_CHECKING: + from typing_extensions import deprecated as _deprecated +else: # pragma: no cover + import functools + import warnings + + def _deprecated(message: str) -> object: + def decorator(func: object) -> object: + @functools.wraps(func) + def wrapper(*args: object, **kwargs: object) -> object: + warnings.warn( + message, + category=DeprecationWarning, + stacklevel=2, + ) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +_LETTER_NORMALIZATION = { + "alpha": "a", + "beta": "b", + "c": "rc", + "pre": "rc", + "preview": "rc", + "rev": "post", + "r": "post", +} + +__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "parse"] LocalType = Tuple[Union[int, str], ...] @@ -33,13 +84,13 @@ VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool] -class _Version(NamedTuple): - epoch: int - release: Tuple[int, ...] - dev: Optional[Tuple[str, int]] - pre: Optional[Tuple[str, int]] - post: Optional[Tuple[str, int]] - local: Optional[LocalType] +class _VersionReplace(TypedDict, total=False): + epoch: int | None + release: tuple[int, ...] | None + pre: tuple[Literal["a", "b", "rc"], int] | None + post: int | None + dev: int | None + local: str | None def parse(version: str) -> "Version": @@ -65,7 +116,15 @@ class InvalidVersion(ValueError): class _BaseVersion: - _key: Tuple[Any, ...] + __slots__ = () + + # This can also be a normal member (see the packaging_legacy package); + # we are just requiring it to be readable. Actually defining a property + # has runtime effect on subclasses, so it's typing only. + if typing.TYPE_CHECKING: + + @property + def _key(self) -> tuple[Any, ...]: ... def __hash__(self) -> int: return hash(self._key) @@ -112,38 +171,56 @@ def __ne__(self, other: object) -> bool: # Deliberately not anchored to the start and end of the string, to make it # easier for 3rd party code to reuse + +# Note that ++ doesn't behave identically on CPython and PyPy, so not using it here _VERSION_PATTERN = r""" - v? + v?+ # optional leading v (?: - (?:(?P[0-9]+)!)? # epoch - (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?:(?P[0-9]+)!)?+ # epoch + (?P[0-9]+(?:\.[0-9]+)*+) # release segment (?P
                                          # pre-release
-            [-_\.]?
+            [._-]?+
             (?Palpha|a|beta|b|preview|pre|c|rc)
-            [-_\.]?
+            [._-]?+
             (?P[0-9]+)?
-        )?
+        )?+
         (?P                                         # post release
             (?:-(?P[0-9]+))
             |
             (?:
-                [-_\.]?
+                [._-]?
                 (?Ppost|rev|r)
-                [-_\.]?
+                [._-]?
                 (?P[0-9]+)?
             )
-        )?
+        )?+
         (?P                                          # dev release
-            [-_\.]?
+            [._-]?+
             (?Pdev)
-            [-_\.]?
+            [._-]?+
             (?P[0-9]+)?
-        )?
+        )?+
     )
-    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+    (?:\+
+        (?P                                        # local version
+            [a-z0-9]+
+            (?:[._-][a-z0-9]+)*+
+        )
+    )?+
 """
 
-VERSION_PATTERN = _VERSION_PATTERN
+_VERSION_PATTERN_OLD = _VERSION_PATTERN.replace("*+", "*").replace("?+", "?")
+
+# Possessive qualifiers were added in Python 3.11.
+# CPython 3.11.0-3.11.4 had a bug: https://github.com/python/cpython/pull/107795
+# Older PyPy also had a bug.
+VERSION_PATTERN = (
+    _VERSION_PATTERN_OLD
+    if (sys.implementation.name == "cpython" and sys.version_info < (3, 11, 5))
+    or (sys.implementation.name == "pypy" and sys.version_info < (3, 11, 13))
+    or sys.version_info < (3, 11)
+    else _VERSION_PATTERN
+)
 """
 A string containing the regular expression used to match a valid version.
 
@@ -156,6 +233,82 @@ def __ne__(self, other: object) -> bool:
 """
 
 
+# Validation pattern for local version in replace()
+_LOCAL_PATTERN = re.compile(r"[a-z0-9]+(?:[._-][a-z0-9]+)*", re.IGNORECASE)
+
+
+def _validate_epoch(value: object) -> int:
+    epoch = value or 0
+    if isinstance(epoch, int) and epoch >= 0:
+        return epoch
+    msg = f"epoch must be non-negative integer, got {epoch}"
+    raise InvalidVersion(msg)
+
+
+def _validate_release(value: object) -> tuple[int, ...]:
+    release = (0,) if value is None else value
+    if (
+        isinstance(release, tuple)
+        and len(release) > 0
+        and all(isinstance(i, int) and i >= 0 for i in release)
+    ):
+        return release  # ty: ignore[invalid-return-type]
+    msg = f"release must be a non-empty tuple of non-negative integers, got {release}"
+    raise InvalidVersion(msg)
+
+
+def _validate_pre(value: object) -> tuple[Literal["a", "b", "rc"], int] | None:
+    if value is None:
+        return value
+    if (
+        isinstance(value, tuple)
+        and len(value) == 2
+        and value[0] in ("a", "b", "rc")
+        and isinstance(value[1], int)
+        and value[1] >= 0
+    ):
+        return value  # ty: ignore[invalid-return-type]
+    msg = f"pre must be a tuple of ('a'|'b'|'rc', non-negative int), got {value}"
+    raise InvalidVersion(msg)
+
+
+def _validate_post(value: object) -> tuple[Literal["post"], int] | None:
+    if value is None:
+        return value
+    if isinstance(value, int) and value >= 0:
+        return ("post", value)
+    msg = f"post must be non-negative integer, got {value}"
+    raise InvalidVersion(msg)
+
+
+def _validate_dev(value: object) -> tuple[Literal["dev"], int] | None:
+    if value is None:
+        return value
+    if isinstance(value, int) and value >= 0:
+        return ("dev", value)
+    msg = f"dev must be non-negative integer, got {value}"
+    raise InvalidVersion(msg)
+
+
+def _validate_local(value: object) -> LocalType | None:
+    if value is None:
+        return value
+    if isinstance(value, str) and _LOCAL_PATTERN.fullmatch(value):
+        return _parse_local_version(value)
+    msg = f"local must be a valid version string, got {value!r}"
+    raise InvalidVersion(msg)
+
+
+# Backward compatibility for internals before 26.0. Do not use.
+class _Version(NamedTuple):
+    epoch: int
+    release: tuple[int, ...]
+    dev: tuple[str, int] | None
+    pre: tuple[str, int] | None
+    post: tuple[str, int] | None
+    local: LocalType | None
+
+
 class Version(_BaseVersion):
     """This class abstracts handling of a project's versions.
 
@@ -180,8 +333,19 @@ class Version(_BaseVersion):
     True
     """
 
-    _regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
-    _key: CmpKey
+    __slots__ = ("_dev", "_epoch", "_key_cache", "_local", "_post", "_pre", "_release")
+    __match_args__ = ("_str",)
+
+    _regex = re.compile(r"\s*" + VERSION_PATTERN + r"\s*", re.VERBOSE | re.IGNORECASE)
+
+    _epoch: int
+    _release: tuple[int, ...]
+    _dev: tuple[str, int] | None
+    _pre: tuple[str, int] | None
+    _post: tuple[str, int] | None
+    _local: LocalType | None
+
+    _key_cache: CmpKey | None
 
     def __init__(self, version: str) -> None:
         """Initialize a Version object.
@@ -193,34 +357,86 @@ def __init__(self, version: str) -> None:
             If the ``version`` does not conform to PEP 440 in any way then this
             exception will be raised.
         """
-
         # Validate the version and parse it into pieces
-        match = self._regex.search(version)
+        match = self._regex.fullmatch(version)
         if not match:
-            raise InvalidVersion(f"Invalid version: '{version}'")
-
-        # Store the parsed out pieces of the version
-        self._version = _Version(
-            epoch=int(match.group("epoch")) if match.group("epoch") else 0,
-            release=tuple(int(i) for i in match.group("release").split(".")),
-            pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
-            post=_parse_letter_version(
-                match.group("post_l"), match.group("post_n1") or match.group("post_n2")
-            ),
-            dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
-            local=_parse_local_version(match.group("local")),
+            raise InvalidVersion(f"Invalid version: {version!r}")
+        self._epoch = int(match.group("epoch")) if match.group("epoch") else 0
+        self._release = tuple(map(int, match.group("release").split(".")))
+        self._pre = _parse_letter_version(match.group("pre_l"), match.group("pre_n"))
+        self._post = _parse_letter_version(
+            match.group("post_l"), match.group("post_n1") or match.group("post_n2")
+        )
+        self._dev = _parse_letter_version(match.group("dev_l"), match.group("dev_n"))
+        self._local = _parse_local_version(match.group("local"))
+
+        # Key which will be used for sorting
+        self._key_cache = None
+
+    def __replace__(self, **kwargs: Unpack[_VersionReplace]) -> Self:
+        epoch = _validate_epoch(kwargs["epoch"]) if "epoch" in kwargs else self._epoch
+        release = (
+            _validate_release(kwargs["release"])
+            if "release" in kwargs
+            else self._release
         )
+        pre = _validate_pre(kwargs["pre"]) if "pre" in kwargs else self._pre
+        post = _validate_post(kwargs["post"]) if "post" in kwargs else self._post
+        dev = _validate_dev(kwargs["dev"]) if "dev" in kwargs else self._dev
+        local = _validate_local(kwargs["local"]) if "local" in kwargs else self._local
+
+        if (
+            epoch == self._epoch
+            and release == self._release
+            and pre == self._pre
+            and post == self._post
+            and dev == self._dev
+            and local == self._local
+        ):
+            return self
+
+        new_version = self.__class__.__new__(self.__class__)
+        new_version._key_cache = None
+        new_version._epoch = epoch
+        new_version._release = release
+        new_version._pre = pre
+        new_version._post = post
+        new_version._dev = dev
+        new_version._local = local
+
+        return new_version
+
+    @property
+    def _key(self) -> CmpKey:
+        if self._key_cache is None:
+            self._key_cache = _cmpkey(
+                self._epoch,
+                self._release,
+                self._pre,
+                self._post,
+                self._dev,
+                self._local,
+            )
+        return self._key_cache
 
-        # Generate a key which will be used for sorting
-        self._key = _cmpkey(
-            self._version.epoch,
-            self._version.release,
-            self._version.pre,
-            self._version.post,
-            self._version.dev,
-            self._version.local,
+    @property
+    @_deprecated("Version._version is private and will be removed soon")
+    def _version(self) -> _Version:
+        return _Version(
+            self._epoch, self._release, self._dev, self._pre, self._post, self._local
         )
 
+    @_version.setter
+    @_deprecated("Version._version is private and will be removed soon")
+    def _version(self, value: _Version) -> None:
+        self._epoch = value.epoch
+        self._release = value.release
+        self._dev = value.dev
+        self._pre = value.pre
+        self._post = value.post
+        self._local = value.local
+        self._key_cache = None
+
     def __repr__(self) -> str:
         """A representation of the Version that shows all internal state.
 
@@ -230,37 +446,40 @@ def __repr__(self) -> str:
         return f""
 
     def __str__(self) -> str:
-        """A string representation of the version that can be rounded-tripped.
+        """A string representation of the version that can be round-tripped.
 
         >>> str(Version("1.0a5"))
         '1.0a5'
         """
-        parts = []
+        # This is a hot function, so not calling self.base_version
+        version = ".".join(map(str, self.release))
 
         # Epoch
-        if self.epoch != 0:
-            parts.append(f"{self.epoch}!")
-
-        # Release segment
-        parts.append(".".join(str(x) for x in self.release))
+        if self.epoch:
+            version = f"{self.epoch}!{version}"
 
         # Pre-release
         if self.pre is not None:
-            parts.append("".join(str(x) for x in self.pre))
+            version += "".join(map(str, self.pre))
 
         # Post-release
         if self.post is not None:
-            parts.append(f".post{self.post}")
+            version += f".post{self.post}"
 
         # Development release
         if self.dev is not None:
-            parts.append(f".dev{self.dev}")
+            version += f".dev{self.dev}"
 
         # Local version segment
         if self.local is not None:
-            parts.append(f"+{self.local}")
+            version += f"+{self.local}"
 
-        return "".join(parts)
+        return version
+
+    @property
+    def _str(self) -> str:
+        """Internal property for match_args"""
+        return str(self)
 
     @property
     def epoch(self) -> int:
@@ -271,10 +490,10 @@ def epoch(self) -> int:
         >>> Version("1!2.0.0").epoch
         1
         """
-        return self._version.epoch
+        return self._epoch
 
     @property
-    def release(self) -> Tuple[int, ...]:
+    def release(self) -> tuple[int, ...]:
         """The components of the "release" segment of the version.
 
         >>> Version("1.2.3").release
@@ -287,17 +506,17 @@ def release(self) -> Tuple[int, ...]:
         Includes trailing zeroes but not the epoch or any pre-release / development /
         post-release suffixes.
         """
-        return self._version.release
+        return self._release
 
     @property
-    def version(self) -> Tuple[int, ...]:
+    def version(self) -> tuple[int, ...]:
         """
         Return version tuple for backward-compatibility with `distutils.version`.
         """
         return self.release
 
     @property
-    def pre(self) -> Optional[Tuple[str, int]]:
+    def pre(self) -> tuple[str, int] | None:
         """The pre-release segment of the version.
 
         >>> print(Version("1.2.3").pre)
@@ -309,10 +528,10 @@ def pre(self) -> Optional[Tuple[str, int]]:
         >>> Version("1.2.3rc1").pre
         ('rc', 1)
         """
-        return self._version.pre
+        return self._pre
 
     @property
-    def post(self) -> Optional[int]:
+    def post(self) -> int | None:
         """The post-release number of the version.
 
         >>> print(Version("1.2.3").post)
@@ -320,10 +539,10 @@ def post(self) -> Optional[int]:
         >>> Version("1.2.3.post1").post
         1
         """
-        return self._version.post[1] if self._version.post else None
+        return self._post[1] if self._post else None
 
     @property
-    def dev(self) -> Optional[int]:
+    def dev(self) -> int | None:
         """The development number of the version.
 
         >>> print(Version("1.2.3").dev)
@@ -331,10 +550,10 @@ def dev(self) -> Optional[int]:
         >>> Version("1.2.3.dev1").dev
         1
         """
-        return self._version.dev[1] if self._version.dev else None
+        return self._dev[1] if self._dev else None
 
     @property
-    def local(self) -> Optional[str]:
+    def local(self) -> str | None:
         """The local version segment of the version.
 
         >>> print(Version("1.2.3").local)
@@ -342,8 +561,8 @@ def local(self) -> Optional[str]:
         >>> Version("1.2.3+abc").local
         'abc'
         """
-        if self._version.local:
-            return ".".join(str(x) for x in self._version.local)
+        if self._local:
+            return ".".join(str(x) for x in self._local)
         else:
             return None
 
@@ -355,8 +574,8 @@ def public(self) -> str:
         '1.2.3'
         >>> Version("1.2.3+abc").public
         '1.2.3'
-        >>> Version("1.2.3+abc.dev1").public
-        '1.2.3'
+        >>> Version("1!1.2.3dev1+abc").public
+        '1!1.2.3.dev1'
         """
         return str(self).split("+", 1)[0]
 
@@ -368,22 +587,14 @@ def base_version(self) -> str:
         '1.2.3'
         >>> Version("1.2.3+abc").base_version
         '1.2.3'
-        >>> Version("1!1.2.3+abc.dev1").base_version
+        >>> Version("1!1.2.3dev1+abc").base_version
         '1!1.2.3'
 
         The "base version" is the public version of the project without any pre or post
         release markers.
         """
-        parts = []
-
-        # Epoch
-        if self.epoch != 0:
-            parts.append(f"{self.epoch}!")
-
-        # Release segment
-        parts.append(".".join(str(x) for x in self.release))
-
-        return "".join(parts)
+        release_segment = ".".join(map(str, self.release))
+        return f"{self.epoch}!{release_segment}" if self.epoch else release_segment
 
     @property
     def is_prerelease(self) -> bool:
@@ -456,37 +667,60 @@ def micro(self) -> int:
         return self.release[2] if len(self.release) >= 3 else 0
 
 
+class _TrimmedRelease(Version):
+    __slots__ = ()
+
+    def __init__(self, version: str | Version) -> None:
+        if isinstance(version, Version):
+            self._epoch = version._epoch
+            self._release = version._release
+            self._dev = version._dev
+            self._pre = version._pre
+            self._post = version._post
+            self._local = version._local
+            self._key_cache = version._key_cache
+            return
+        super().__init__(version)  # pragma: no cover
+
+    @property
+    def release(self) -> tuple[int, ...]:
+        """
+        Release segment without any trailing zeros.
+
+        >>> _TrimmedRelease('1.0.0').release
+        (1,)
+        >>> _TrimmedRelease('0.0').release
+        (0,)
+        """
+        # This leaves one 0.
+        rel = super().release
+        len_release = len(rel)
+        i = len_release
+        while i > 1 and rel[i - 1] == 0:
+            i -= 1
+        return rel if i == len_release else rel[:i]
+
+
 def _parse_letter_version(
-    letter: Optional[str], number: Union[str, bytes, SupportsInt, None]
-) -> Optional[Tuple[str, int]]:
+    letter: str | None, number: str | bytes | SupportsInt | None
+) -> tuple[str, int] | None:
     if letter:
-        # We consider there to be an implicit 0 in a pre-release if there is
-        # not a numeral associated with it.
-        if number is None:
-            number = 0
-
         # We normalize any letters to their lower case form
         letter = letter.lower()
 
         # We consider some words to be alternate spellings of other words and
         # in those cases we want to normalize the spellings to our preferred
         # spelling.
-        if letter == "alpha":
-            letter = "a"
-        elif letter == "beta":
-            letter = "b"
-        elif letter in ["c", "pre", "preview"]:
-            letter = "rc"
-        elif letter in ["rev", "r"]:
-            letter = "post"
-
-        return letter, int(number)
+        letter = _LETTER_NORMALIZATION.get(letter, letter)
+
+        # We consider there to be an implicit 0 in a pre-release if there is
+        # not a numeral associated with it.
+        return letter, int(number or 0)
+
     if number:
         # We assume if we are given a number, but we are not given a letter
         # then this is using the implicit post release syntax (e.g. 1.0-1)
-        letter = "post"
-
-        return letter, int(number)
+        return "post", int(number)
 
     return None
 
@@ -494,7 +728,7 @@ def _parse_letter_version(
 _local_version_separators = re.compile(r"[\._-]")
 
 
-def _parse_local_version(local: Optional[str]) -> Optional[LocalType]:
+def _parse_local_version(local: str | None) -> LocalType | None:
     """
     Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
     """
@@ -508,20 +742,19 @@ def _parse_local_version(local: Optional[str]) -> Optional[LocalType]:
 
 def _cmpkey(
     epoch: int,
-    release: Tuple[int, ...],
-    pre: Optional[Tuple[str, int]],
-    post: Optional[Tuple[str, int]],
-    dev: Optional[Tuple[str, int]],
-    local: Optional[LocalType],
+    release: tuple[int, ...],
+    pre: tuple[str, int] | None,
+    post: tuple[str, int] | None,
+    dev: tuple[str, int] | None,
+    local: LocalType | None,
 ) -> CmpKey:
     # When we compare a release version, we want to compare it with all of the
-    # trailing zeros removed. So we'll use a reverse the list, drop all the now
-    # leading zeros until we come to something non zero, then take the rest
-    # re-reverse it back into the correct order and make it a tuple and use
-    # that for our sorting key.
-    _release = tuple(
-        reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release))))
-    )
+    # trailing zeros removed. We will use this for our sorting key.
+    len_release = len(release)
+    i = len_release
+    while i and release[i - 1] == 0:
+        i -= 1
+    _release = release if i == len_release else release[:i]
 
     # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
     # We'll do this by abusing the pre segment, but we _only_ want to do this