Skip to content
Open
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
42 changes: 39 additions & 3 deletions src/packaging/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@ class Version(_BaseVersion):
be unpickled with future releases. Backward compatibility with pickles
from packaging < 26.2 is supported but may be removed in a future
release.

.. versionchanged:: 26.4

Added :attr:`raw_version`, included in newly created pickles.
"""

__slots__ = (
Expand All @@ -377,6 +381,7 @@ class Version(_BaseVersion):
"_local",
"_post",
"_pre",
"_raw_version",
"_release",
)
__match_args__ = ("_str",)
Expand All @@ -396,6 +401,7 @@ class Version(_BaseVersion):
_pre: tuple[Literal["a", "b", "rc"], int] | None
_post: tuple[Literal["post"], int] | None
_local: LocalType | None
_raw_version: str | None

_hash_cache: int | None
_key_cache: CmpKey | None
Expand All @@ -415,6 +421,8 @@ def __init__(self, version: str) -> None:
except TypeError:
raise InvalidVersion(f"Invalid version: {version!r}") from None

self._raw_version = version

if is_simple:
try:
self._release = tuple(map(int, version.split(".")))
Expand Down Expand Up @@ -503,6 +511,7 @@ def from_parts(
new_version._post = _post
new_version._dev = _dev
new_version._local = _local
new_version._raw_version = None

return new_version

Expand Down Expand Up @@ -571,6 +580,7 @@ def __replace__(
new_version._post = _post
new_version._dev = _dev
new_version._local = _local
new_version._raw_version = None

return new_version

Expand Down Expand Up @@ -778,9 +788,10 @@ def __getstate__(
tuple[str, int] | None,
tuple[str, int] | None,
LocalType | None,
str | None,
]:
# Return state as a 6-item tuple for compactness:
# (epoch, release, pre, post, dev, local)
# Return state as a compact tuple:
# (epoch, release, pre, post, dev, local, raw_version)
# Cache members are excluded and will be recomputed on demand
return (
self._epoch,
Expand All @@ -789,6 +800,7 @@ def __getstate__(
self._post,
self._dev,
self._local,
self._raw_version,
)

def __setstate__(self, state: object) -> None:
Expand All @@ -797,10 +809,22 @@ def __setstate__(self, state: object) -> None:
# and will be recomputed on demand from the core fields above.
self._key_cache = None
self._hash_cache = None
self._raw_version = None

if isinstance(state, tuple):
if len(state) == 7:
(
self._epoch,
self._release,
self._pre,
self._post,
self._dev,
self._local,
self._raw_version,
) = state
return
if len(state) == 6:
# New format (26.2+): (epoch, release, pre, post, dev, local)
# Legacy stable format (26.2): (epoch, release, pre, post, dev, local)
(
self._epoch,
self._release,
Expand Down Expand Up @@ -852,6 +876,7 @@ def _version(self, value: _Version) -> None:
self._pre = value.pre
self._post = value.post
self._local = value.local
self._raw_version = None
self._key_cache = None
self._hash_cache = None

Expand Down Expand Up @@ -910,6 +935,16 @@ def epoch(self) -> int:
"""
return self._epoch

@property
def raw_version(self) -> str | None:
"""The original string passed to the constructor, if available.

Versions created with :meth:`from_parts`, changed with
:meth:`__replace__`, or loaded from an older pickle have no original
input string and return ``None``.
"""
return self._raw_version

@property
def release(self) -> tuple[int, ...]:
"""The components of the "release" segment of the version.
Expand Down Expand Up @@ -1102,6 +1137,7 @@ def __init__(self, version: str | Version) -> None:
self._pre = version._pre
self._post = version._post
self._local = version._local
self._raw_version = version._raw_version
self._key_cache = version._key_cache
self._hash_cache = version._hash_cache
return
Expand Down
26 changes: 25 additions & 1 deletion tests/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -1300,11 +1300,32 @@ def test_from_parts_rejects_non_str_pre_letter() -> None:
],
)
def test_pickle_roundtrip(version: str) -> None:
# Make sure equality and str() work between a pickle/unpickle round trip.
# Make sure equality, str(), and the original input survive a pickle round trip.
v = Version(version)
loaded = pickle.loads(pickle.dumps(v))
assert loaded == v
assert str(loaded) == str(v)
assert loaded.raw_version == v.raw_version


def test_raw_version_preserves_constructor_input_without_affecting_comparison() -> None:
padded = Version(" v1.02.0 ")
normalized = Version("1.2")

assert str(padded) == "1.2.0"
assert padded.raw_version == " v1.02.0 "
assert normalized.raw_version == "1.2"
assert padded == normalized
assert hash(padded) == hash(normalized)


def test_raw_version_is_unavailable_for_derived_versions() -> None:
original = Version("1.2.0")

assert Version.from_parts(release=(1, 2, 0)).raw_version is None
assert original.__replace__(release=(1, 3)).raw_version is None
assert original.__replace__() is original
assert original.__replace__().raw_version == "1.2.0"


# Pickle bytes generated with packaging==25.0, Python 3.13.1, pickle protocol 2.
Expand Down Expand Up @@ -1339,6 +1360,7 @@ def test_pickle_old_format_loads() -> None:
assert v == Version("1.2.3")
assert v < Version("2.0")
assert v > Version("1.2.2")
assert v.raw_version is None

v2 = pickle.loads(_PACKAGING_25_0_PICKLE_V2_0A1)
assert isinstance(v2, Version)
Expand Down Expand Up @@ -1382,6 +1404,7 @@ def test_pickle_26_0_slots_format_loads() -> None:
assert v == Version("1.2.3")
assert v < Version("2.0")
assert v > Version("1.2.2")
assert v.raw_version is None


# Pickle bytes generated with packaging 26.2+ (6-tuple __getstate__ format),
Expand All @@ -1402,6 +1425,7 @@ def test_pickle_26_2_tuple_getstate_loads() -> None:
assert str(v) == "1!2.3.4a5.post6.dev7+zzz"
assert v == Version("1!2.3.4a5.post6.dev7+zzz")
assert v.epoch == 1
assert v.raw_version is None
assert v.release == (2, 3, 4)
assert v.pre == ("a", 5)
assert v.post == 6
Expand Down
Loading