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
28 changes: 21 additions & 7 deletions src/packaging/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from importlib.machinery import EXTENSION_SUFFIXES
from typing import (
TYPE_CHECKING,
Any,
Iterable,
Iterator,
Sequence,
Expand Down Expand Up @@ -158,12 +157,27 @@ def __str__(self) -> str:
def __repr__(self) -> str:
return f"<{self} @ {id(self)}>"

def __setstate__(self, state: tuple[None, dict[str, Any]]) -> None:
# The cached _hash is wrong when unpickling.
_, slots = state
for k, v in slots.items():
setattr(self, k, v)
self._hash = hash((self._interpreter, self._abi, self._platform))
def __getstate__(self) -> tuple[str, str, str]:
# Return state as a 3-item tuple: (interpreter, abi, platform).
# Cache member _hash is excluded and will be recomputed.
return (self._interpreter, self._abi, self._platform)

def __setstate__(self, state: object) -> None:
if isinstance(state, tuple):
if len(state) == 3 and all(isinstance(s, str) for s in state):
# New format (26.2+): (interpreter, abi, platform)
self._interpreter, self._abi, self._platform = state
self._hash = hash((self._interpreter, self._abi, self._platform))
return
if len(state) == 2 and isinstance(state[1], dict):
# Old format (packaging <= 26.1, __slots__): (None, {slot: value}).
_, slots = state
self._interpreter = slots["_interpreter"]
self._abi = slots["_abi"]
self._platform = slots["_platform"]
self._hash = hash((self._interpreter, self._abi, self._platform))
return
raise TypeError(f"Cannot restore Tag from {state!r}")


def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
Expand Down
94 changes: 94 additions & 0 deletions tests/test_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,100 @@ def test_pickle() -> None:
assert pickle.loads(pickle.dumps(tag)) == tag


@pytest.mark.parametrize(
("interpreter", "abi", "platform"),
[
("py3", "none", "any"),
("cp39", "cp39", "linux_x86_64"),
("cp312", "cp312", "win_amd64"),
("pp310", "pypy310_pp73", "manylinux_2_17_x86_64"),
],
)
def test_pickle_tag_roundtrip(interpreter: str, abi: str, platform: str) -> None:
# Make sure equality, str(), and hash() work between a pickle/unpickle round trip.
t = tags.Tag(interpreter, abi, platform)
loaded = pickle.loads(pickle.dumps(t))
assert loaded == t
assert str(loaded) == str(t)
assert hash(loaded) == hash(t)


def test_pickle_tag_setstate_rejects_invalid_state() -> None:
# Cover the TypeError branches in __setstate__ for invalid input.
t = tags.Tag.__new__(tags.Tag)
with pytest.raises(TypeError, match="Cannot restore Tag"):
t.__setstate__(12345)
with pytest.raises(TypeError, match="Cannot restore Tag"):
t.__setstate__((1, 2, 3)) # Wrong types, not all strings


# Pickle bytes generated with packaging==26.1, Python 3.13.1, pickle protocol 2.
# Format: __slots__ (no __getstate__), state is (None, {slot: value}). The
# _hash slot contains a pre-computed integer that must be discarded on load.
_PACKAGING_26_1_PICKLE_TAG_CP39 = (
b"\x80\x02cpackaging.tags\nTag\nq\x00)\x81q\x01N}q\x02(X\x04\x00\x00"
b"\x00_abiq\x03X\x04\x00\x00\x00cp39q\x04X\x05\x00\x00\x00_hashq\x05"
b"\x8a\x08)\xb1\xe8\x9d\x90\xf8tFX\x0c\x00\x00\x00_interpreterq\x06X"
b"\x04\x00\x00\x00cp39q\x07X\t\x00\x00\x00_platformq\x08X\x0c\x00\x00"
b"\x00linux_x86_64q\tu\x86q\nb."
)


# Pickle bytes generated with packaging==26.0, Python 3.13.1, pickle protocol 2.
# Format: __slots__ (no __getstate__), state is (None, {slot: value}).
_PACKAGING_26_0_PICKLE_TAG_CP39 = (
b"\x80\x02cpackaging.tags\nTag\nq\x00)\x81q\x01N}q\x02(X\x04\x00\x00"
b"\x00_abiq\x03X\x04\x00\x00\x00cp39q\x04X\x05\x00\x00\x00_hashq\x05"
b"\x8a\x08\xc1\xdb\xa0\xe5]7z\x87X\x0c\x00\x00\x00_interpreterq\x06X"
b"\x04\x00\x00\x00cp39q\x07X\t\x00\x00\x00_platformq\x08X\x0c\x00\x00"
b"\x00linux_x86_64q\tu\x86q\nb."
)


# Pickle bytes generated with packaging==25.0, Python 3.13.1, pickle protocol 2.
# Format: plain __dict__ (no __slots__).
_PACKAGING_25_0_PICKLE_TAG_CP39 = (
b"\x80\x02cpackaging.tags\nTag\nq\x00)\x81q\x01N}q\x02(X\x04\x00\x00"
b"\x00_abiq\x03X\x04\x00\x00\x00cp39q\x04X\x05\x00\x00\x00_hashq\x05"
b"\x8a\x08\xa2\x088\xe6j:\xcaSX\x0c\x00\x00\x00_interpreterq\x06X"
b"\x04\x00\x00\x00cp39q\x07X\t\x00\x00\x00_platformq\x08X\x0c\x00\x00"
b"\x00linux_x86_64q\tu\x86q\nb."
)


def test_pickle_tag_old_format_loads() -> None:
# Verify that Tag pickles created with packaging <= 26.1 (__slots__,
# no __getstate__) can be loaded and produce correct Tag objects.
t = pickle.loads(_PACKAGING_26_1_PICKLE_TAG_CP39)
assert isinstance(t, tags.Tag)
assert str(t) == "cp39-cp39-linux_x86_64"
assert t == tags.Tag("cp39", "cp39", "linux_x86_64")
assert t.interpreter == "cp39"
assert t.abi == "cp39"
assert t.platform == "linux_x86_64"
assert t._hash == hash(("cp39", "cp39", "linux_x86_64"))


def test_pickle_tag_26_0_format_loads() -> None:
# Verify that Tag pickles created with packaging 26.0 (__slots__,
# no __getstate__) can be loaded and produce correct Tag objects.
t = pickle.loads(_PACKAGING_26_0_PICKLE_TAG_CP39)
assert isinstance(t, tags.Tag)
assert str(t) == "cp39-cp39-linux_x86_64"
assert t == tags.Tag("cp39", "cp39", "linux_x86_64")
assert t._hash == hash(("cp39", "cp39", "linux_x86_64"))


def test_pickle_tag_25_0_format_loads() -> None:
# Verify that Tag pickles created with packaging 25.0 (plain __dict__)
# can be loaded and produce correct Tag objects.
t = pickle.loads(_PACKAGING_25_0_PICKLE_TAG_CP39)
assert isinstance(t, tags.Tag)
assert str(t) == "cp39-cp39-linux_x86_64"
assert t == tags.Tag("cp39", "cp39", "linux_x86_64")
assert t._hash == hash(("cp39", "cp39", "linux_x86_64"))


@pytest.mark.parametrize(
("supported", "things", "expected"),
[
Expand Down
Loading