Skip to content

Commit 4bed32d

Browse files
authored
fix: make Specifier / SpecifierSet pickle-safe (#1168)
* fix: Specifier pickling Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * tests: generate and test older Packaging version tuples Assisted-by: Copilot:claude-sonnet-4.6 Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * tests: get one more branch covered Assisted-by: OpenCode:Kimi-K2.5 Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * chore: drop unused code Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * tests: make sure cache is cleared with setstate Assisted-by: Copilot:claude-sonnet-4.6 Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * fix: specifiers store as tuple Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * fix: protect against malformed pickle Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * Add validation for prereleases in specifiers --------- Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
1 parent 963118e commit 4bed32d

2 files changed

Lines changed: 473 additions & 1 deletion

File tree

src/packaging/specifiers.py

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,28 @@
1515
import functools
1616
import itertools
1717
import re
18+
import sys
1819
import typing
19-
from typing import Any, Callable, Final, Iterable, Iterator, Sequence, TypeVar, Union
20+
from typing import (
21+
TYPE_CHECKING,
22+
Any,
23+
Callable,
24+
Final,
25+
Iterable,
26+
Iterator,
27+
Sequence,
28+
TypeVar,
29+
Union,
30+
)
2031

2132
from .utils import canonicalize_version
2233
from .version import InvalidVersion, Version
2334

35+
if sys.version_info >= (3, 10):
36+
from typing import TypeGuard # pragma: no cover
37+
elif TYPE_CHECKING:
38+
from typing_extensions import TypeGuard
39+
2440
__all__ = [
2541
"BaseSpecifier",
2642
"InvalidSpecifier",
@@ -33,6 +49,19 @@ def __dir__() -> list[str]:
3349
return __all__
3450

3551

52+
def _validate_spec(spec: object, /) -> TypeGuard[tuple[str, str]]:
53+
return (
54+
isinstance(spec, tuple)
55+
and len(spec) == 2
56+
and isinstance(spec[0], str)
57+
and isinstance(spec[1], str)
58+
)
59+
60+
61+
def _validate_pre(pre: object, /) -> TypeGuard[bool | None]:
62+
return pre is None or isinstance(pre, bool)
63+
64+
3665
T = TypeVar("T")
3766
UnparsedVersion = Union[Version, str]
3867
UnparsedVersionVar = TypeVar("UnparsedVersionVar", bound=UnparsedVersion)
@@ -722,6 +751,46 @@ def prereleases(self) -> bool | None:
722751
def prereleases(self, value: bool | None) -> None:
723752
self._prereleases = value
724753

754+
def __getstate__(self) -> tuple[tuple[str, str], bool | None]:
755+
# Return state as a 2-item tuple for compactness:
756+
# ((operator, version), prereleases)
757+
# Cache members are excluded and will be recomputed on demand.
758+
return (self._spec, self._prereleases)
759+
760+
def __setstate__(self, state: object) -> None:
761+
# Always discard cached values - they will be recomputed on demand.
762+
self._spec_version = None
763+
self._wildcard_split = None
764+
self._ranges = None
765+
766+
if isinstance(state, tuple):
767+
if len(state) == 2:
768+
# New format (26.2+): ((operator, version), prereleases)
769+
spec, prereleases = state
770+
if _validate_spec(spec) and _validate_pre(prereleases):
771+
self._spec = spec
772+
self._prereleases = prereleases
773+
return
774+
if len(state) == 2 and isinstance(state[1], dict):
775+
# Format (packaging 26.0-26.1): (None, {slot: value}).
776+
_, slot_dict = state
777+
spec = slot_dict.get("_spec")
778+
prereleases = slot_dict.get("_prereleases", "invalid")
779+
if _validate_spec(spec) and _validate_pre(prereleases):
780+
self._spec = spec
781+
self._prereleases = prereleases
782+
return
783+
if isinstance(state, dict):
784+
# Old format (packaging <= 25.x, no __slots__): state is a plain dict.
785+
spec = state.get("_spec")
786+
prereleases = state.get("_prereleases", "invalid")
787+
if _validate_spec(spec) and _validate_pre(prereleases):
788+
self._spec = spec
789+
self._prereleases = prereleases
790+
return
791+
792+
raise TypeError(f"Cannot restore Specifier from {state!r}")
793+
725794
@property
726795
def operator(self) -> str:
727796
"""The operator of this specifier.
@@ -1347,6 +1416,69 @@ def prereleases(self, value: bool | None) -> None:
13471416
self._prereleases = value
13481417
self._is_unsatisfiable = None
13491418

1419+
def __getstate__(self) -> tuple[tuple[Specifier, ...], bool | None]:
1420+
# Return state as a 2-item tuple for compactness:
1421+
# (specs, prereleases)
1422+
# Cache members are excluded and will be recomputed on demand.
1423+
return (self._specs, self._prereleases)
1424+
1425+
def __setstate__(self, state: object) -> None:
1426+
# Always discard cached values - they will be recomputed on demand.
1427+
self._resolved_ops = None
1428+
self._is_unsatisfiable = None
1429+
1430+
if isinstance(state, tuple):
1431+
if len(state) == 2:
1432+
# New format (26.2+): (specs, prereleases)
1433+
specs, prereleases = state
1434+
if (
1435+
isinstance(specs, tuple)
1436+
and all(isinstance(s, Specifier) for s in specs)
1437+
and _validate_pre(prereleases)
1438+
):
1439+
self._specs = specs
1440+
self._prereleases = prereleases
1441+
self._canonicalized = len(specs) <= 1
1442+
self._has_arbitrary = any("===" in str(s) for s in specs)
1443+
return
1444+
if len(state) == 2 and isinstance(state[1], dict):
1445+
# Format (packaging 26.0-26.1): (None, {slot: value}).
1446+
_, slot_dict = state
1447+
specs = slot_dict.get("_specs", ())
1448+
prereleases = slot_dict.get("_prereleases")
1449+
# Convert frozenset to tuple (26.0 stored as frozenset)
1450+
if isinstance(specs, frozenset):
1451+
specs = tuple(sorted(specs, key=str))
1452+
if (
1453+
isinstance(specs, tuple)
1454+
and all(isinstance(s, Specifier) for s in specs)
1455+
and _validate_pre(prereleases)
1456+
):
1457+
self._specs = specs
1458+
self._prereleases = prereleases
1459+
self._canonicalized = len(self._specs) <= 1
1460+
self._has_arbitrary = any("===" in str(s) for s in self._specs)
1461+
return
1462+
if isinstance(state, dict):
1463+
# Old format (packaging <= 25.x, no __slots__): state is a plain dict.
1464+
specs = state.get("_specs", ())
1465+
prereleases = state.get("_prereleases")
1466+
# Convert frozenset to tuple (26.0 stored as frozenset)
1467+
if isinstance(specs, frozenset):
1468+
specs = tuple(sorted(specs, key=str))
1469+
if (
1470+
isinstance(specs, tuple)
1471+
and all(isinstance(s, Specifier) for s in specs)
1472+
and _validate_pre(prereleases)
1473+
):
1474+
self._specs = specs
1475+
self._prereleases = prereleases
1476+
self._canonicalized = len(self._specs) <= 1
1477+
self._has_arbitrary = any("===" in str(s) for s in self._specs)
1478+
return
1479+
1480+
raise TypeError(f"Cannot restore SpecifierSet from {state!r}")
1481+
13501482
def __repr__(self) -> str:
13511483
"""A representation of the specifier set that shows all internal state.
13521484

0 commit comments

Comments
 (0)