Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 113 additions & 1 deletion src/packaging/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@

import re
import sys
from typing import Any, Callable, SupportsInt, Tuple, Union
import typing
from typing import Any, Callable, Literal, SupportsInt, Tuple, TypedDict, Union

from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType

if typing.TYPE_CHECKING:
from typing_extensions import Self, Unpack

__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "parse"]

LocalType = Tuple[Union[int, str], ...]
Expand All @@ -35,6 +39,15 @@
VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool]


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:
"""Parse the given version string.

Expand Down Expand Up @@ -164,6 +177,72 @@ 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
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
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)


class Version(_BaseVersion):
"""This class abstracts handling of a project's versions.

Expand Down Expand Up @@ -227,6 +306,39 @@ def __init__(self, version: str) -> None:
# 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:
Expand Down
220 changes: 219 additions & 1 deletion tests/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,32 @@

import itertools
import operator
import sys
import typing

import pretend
import pytest

from packaging.version import InvalidVersion, Version, parse
from 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: ...

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)
Expand Down Expand Up @@ -775,3 +791,205 @@ 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")
Loading