Skip to content

Commit 3f00091

Browse files
authored
tests: add a pickle check (#1174)
* tests: add pickle check job Signed-off-by: Henry Schreiner <henryfs@princeton.edu> Assisted-by: OpenCode:Kimi-K2.6 * docs: mention stable pickle Assisted-by: OpenCode:Kimi-K2.6 Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * tests: ensure version number is correct Assisted-by: OpenCode:Kimi-K2.6 Signed-off-by: Henry Schreiner <henryfs@princeton.edu> --------- Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
1 parent 48a8a06 commit 3f00091

10 files changed

Lines changed: 325 additions & 2 deletions

File tree

.github/workflows/test.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,23 @@ jobs:
9494
MATRIX_PROJECT: ${{ matrix.project }}
9595
run: pipx run nox -s "downstream(project='$MATRIX_PROJECT')"
9696

97+
pickle:
98+
name: Pickle test
99+
runs-on: ubuntu-latest
100+
steps:
101+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
102+
with:
103+
persist-credentials: false
104+
105+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
106+
name: Install Python 3.14
107+
with:
108+
python-version: "3.14"
109+
cache: "pip"
110+
111+
- name: Run nox
112+
run: pipx run nox -s test_pickle
113+
97114
pass:
98115
name: All pass
99116
if: always()

docs/requirements.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ Reference
7373
This class abstracts handling the details of a requirement for a project.
7474
Each requirement will be parsed according to the specification.
7575

76+
Instances are safe to serialize with :mod:`pickle`. They use a stable
77+
format so the same pickle can be loaded in future packaging releases.
78+
79+
.. versionchanged:: 26.2
80+
81+
Added a stable pickle format. Pickles created with packaging 26.2+ can
82+
be unpickled with future releases. Backward compatibility with pickles
83+
from packaging < 26.2 is supported but may be removed in a future
84+
release.
85+
7686
:param str requirement: The string representation of a requirement.
7787
:raises InvalidRequirement: If the given ``requirement`` is not parseable,
7888
then this exception will be raised.

noxfile.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,40 @@ def update_licenses(session: nox.Session) -> None:
336336
session.run("python", "tasks/licenses.py")
337337

338338

339+
@nox.session(default=False)
340+
@nox.parametrize("version", ["21.0", "24.0", "25.0", "26.0", "26.1"])
341+
def test_pickle(session: nox.Session, version: str) -> None:
342+
"""
343+
Make sure pickles written by an older packaging release can be read
344+
by the current code.
345+
"""
346+
tmp_dir = Path(session.create_tmp())
347+
pickle_file = tmp_dir / f"packaging_{version}_pickles.pkl"
348+
349+
# Step 1: install the old release so the generator pickles objects in
350+
# the format that version serialises.
351+
session.install(f"packaging=={version}")
352+
session.run(
353+
"python",
354+
"tasks/pickle_compat.py",
355+
"write",
356+
version,
357+
str(tmp_dir),
358+
)
359+
360+
# Step 2: install the current (in-tree) packaging so we can verify
361+
# backward compatibility of the load path.
362+
session.install("-e.")
363+
session.run(
364+
"python",
365+
"tasks/pickle_compat.py",
366+
"verify",
367+
"--version",
368+
version,
369+
str(pickle_file),
370+
)
371+
372+
339373
# -----------------------------------------------------------------------------
340374
# Helpers
341375
# -----------------------------------------------------------------------------

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,9 @@ flake8-unused-arguments.ignore-variadic-names = true
158158

159159
[tool.ruff.lint.per-file-ignores]
160160
"tests/test_*.py" = ["PYI024", "PLR", "SIM201", "T20", "S301"]
161-
"tasks/check.py" = ["UP032", "T20"]
162-
"tasks/check_frozen_revs.py" = ["T20", "ANN401"]
161+
"tasks/*.py" = ["T20"]
162+
"tasks/check.py" = ["UP032"]
163+
"tasks/check_frozen_revs.py" = ["ANN401"]
163164
"tests/test_requirements.py" = ["UP032"]
164165
"src/packaging/_musllinux.py" = ["T20"]
165166
"docs/conf.py" = ["INP001", "S", "A001"]

src/packaging/markers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,16 @@ class Marker:
322322
323323
:param marker: The string representation of a marker expression.
324324
:raises InvalidMarker: If ``marker`` cannot be parsed.
325+
326+
Instances are safe to serialize with :mod:`pickle`. They use a stable
327+
format so the same pickle can be loaded in future packaging releases.
328+
329+
.. versionchanged:: 26.2
330+
331+
Added a stable pickle format. Pickles created with packaging 26.2+ can
332+
be unpickled with future releases. Backward compatibility with pickles
333+
from packaging < 26.2 is supported but may be removed in a future
334+
release.
325335
"""
326336

327337
__slots__ = ("_markers",)

src/packaging/requirements.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ class Requirement:
3333
Parse a given requirement string into its parts, such as name, specifier,
3434
URL, and extras. Raises InvalidRequirement on a badly-formed requirement
3535
string.
36+
37+
Instances are safe to serialize with :mod:`pickle`. They use a stable
38+
format so the same pickle can be loaded in future packaging releases.
39+
40+
.. versionchanged:: 26.2
41+
42+
Added a stable pickle format. Pickles created with packaging 26.2+ can
43+
be unpickled with future releases. Backward compatibility with pickles
44+
from packaging < 26.2 is supported but may be removed in a future
45+
release.
3646
"""
3747

3848
# TODO: Can we test whether something is contained within a requirement?

src/packaging/specifiers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,16 @@ class Specifier(BaseSpecifier):
443443
It is generally not required to instantiate this manually. You should instead
444444
prefer to work with :class:`SpecifierSet` instead, which can parse
445445
comma-separated version specifiers (which is what package metadata contains).
446+
447+
Instances are safe to serialize with :mod:`pickle`. They use a stable
448+
format so the same pickle can be loaded in future packaging releases.
449+
450+
.. versionchanged:: 26.2
451+
452+
Added a stable pickle format. Pickles created with packaging 26.2+ can
453+
be unpickled with future releases. Backward compatibility with pickles
454+
from packaging < 26.2 is supported but may be removed in a future
455+
release.
446456
"""
447457

448458
__slots__ = (
@@ -1326,6 +1336,18 @@ class SpecifierSet(BaseSpecifier):
13261336
13271337
It can be passed a single specifier (``>=3.0``), a comma-separated list of
13281338
specifiers (``>=3.0,!=3.1``), or no specifier at all.
1339+
1340+
Instances are safe to serialize with :mod:`pickle`. They use a stable
1341+
format so the same pickle can be loaded in future packaging
1342+
releases.
1343+
1344+
.. versionchanged:: 26.2
1345+
1346+
Added a stable pickle format. Pickles created with
1347+
packaging 26.2+ can be unpickled with future releases.
1348+
Backward compatibility with pickles from
1349+
packaging < 26.2 is supported but may be removed in a future
1350+
release.
13291351
"""
13301352

13311353
__slots__ = (

src/packaging/tags.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ class Tag:
9191
9292
Instances are considered immutable and thus are hashable. Equality checking
9393
is also supported.
94+
95+
Instances are safe to serialize with :mod:`pickle`. They use a stable
96+
format so the same pickle can be loaded in future packaging releases.
97+
98+
.. versionchanged:: 26.2
99+
100+
Added a stable pickle format. Pickles created with packaging 26.2+ can
101+
be unpickled with future releases. Backward compatibility with pickles
102+
from packaging < 26.2 is supported but may be removed in a future
103+
release.
94104
"""
95105

96106
__slots__ = ["_abi", "_hash", "_interpreter", "_platform"]

src/packaging/version.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,16 @@ class Version(_BaseVersion):
358358
359359
:class:`Version` is immutable; use :meth:`__replace__` to change
360360
part of a version.
361+
362+
Instances are safe to serialize with :mod:`pickle`. They use a stable
363+
format so the same pickle can be loaded in future packaging releases.
364+
365+
.. versionchanged:: 26.2
366+
367+
Added a stable pickle format. Pickles created with packaging 26.2+ can
368+
be unpickled with future releases. Backward compatibility with pickles
369+
from packaging < 26.2 is supported but may be removed in a future
370+
release.
361371
"""
362372

363373
__slots__ = (

tasks/pickle_compat.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# This file is dual licensed under the terms of the Apache License, Version
2+
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
3+
# for complete details.
4+
5+
"""Generate or verify pickle files with packaging objects for cross-version testing.
6+
7+
Usage:
8+
python tasks/pickle_compat.py write <version> <output_dir>
9+
python tasks/pickle_compat.py verify [--version <version>] <pickle_file>
10+
11+
The ``write`` command generates a pickle file using the *currently installed*
12+
release of ``packaging``. ``<version>`` is recorded as metadata in the file
13+
(e.g. ``25.0``) so that ``verify`` can report what release created the pickle.
14+
15+
The ``verify`` command loads a pickle and checks that every object:
16+
17+
* has the expected type,
18+
* compares equal to a freshly constructed counterpart, and
19+
* round-trips through ``pickle.dumps`` / ``pickle.loads``.
20+
21+
When ``--version`` is supplied, ``verify`` also checks that the pickle was
22+
generated with that release of ``packaging``.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import argparse
28+
import importlib.metadata
29+
import pathlib
30+
import pickle
31+
import sys
32+
from typing import Any
33+
34+
from packaging.markers import Marker
35+
from packaging.specifiers import Specifier, SpecifierSet
36+
from packaging.tags import Tag
37+
from packaging.version import Version
38+
39+
_OBJECTS = {
40+
"version": [
41+
Version("1.2.3"),
42+
Version("1!2.3.4a5.post6.dev7+zzz"),
43+
Version("0.1.0"),
44+
Version("2.0a1"),
45+
Version("1.0.post1"),
46+
Version("1.0.dev3"),
47+
],
48+
"marker": [
49+
Marker('python_version >= "3.8"'),
50+
Marker('os_name == "posix" and python_version >= "3.9"'),
51+
Marker('extra == "test"'),
52+
],
53+
"specifier": [
54+
Specifier(">=1.0.0"),
55+
Specifier("~=2.3"),
56+
Specifier("==1.2.*"),
57+
Specifier("!=2.0.0"),
58+
Specifier("<3.0"),
59+
Specifier(">1.0"),
60+
Specifier("<=2.0"),
61+
],
62+
"specifierset": [
63+
SpecifierSet(">=1.0.0,<2.0.0"),
64+
SpecifierSet("~=1.0,!=1.0.1"),
65+
SpecifierSet(">=3.0"),
66+
],
67+
"tag": [
68+
Tag("cp39", "cp39", "linux_x86_64"),
69+
Tag("py3", "none", "any"),
70+
Tag("cp310", "abi3", "manylinux_2_17_x86_64"),
71+
],
72+
}
73+
74+
_TYPE_CHECKS: dict[str, type[object]] = {
75+
"version": Version,
76+
"marker": Marker,
77+
"specifier": Specifier,
78+
"specifierset": SpecifierSet,
79+
"tag": Tag,
80+
}
81+
82+
83+
def write(version: str, output_dir: pathlib.Path) -> pathlib.Path:
84+
"""Pickle a representative set of objects and write them to disk."""
85+
installed = importlib.metadata.version("packaging")
86+
if version != installed:
87+
raise SystemExit(
88+
f"Requested packaging=={version} but the installed version is {installed}"
89+
)
90+
91+
output_dir.mkdir(parents=True, exist_ok=True)
92+
path = output_dir / f"packaging_{version}_pickles.pkl"
93+
94+
with open(path, "wb") as f:
95+
pickle.dump({"generated_with": version, "objects": _OBJECTS}, f)
96+
97+
return path
98+
99+
100+
def verify(path: pathlib.Path, expected_version: str | None = None) -> int:
101+
"""Load a pickle file and verify its contents.
102+
103+
Returns 0 on success, 1 on failure.
104+
"""
105+
with open(path, "rb") as f:
106+
data: dict[str, Any] = pickle.load(f) # noqa: S301
107+
108+
generated_with = data["generated_with"]
109+
objects: dict[str, list[Any]] = data["objects"]
110+
print(f"Verifying pickles generated with packaging=={generated_with}")
111+
112+
if expected_version is not None and generated_with != expected_version:
113+
print(
114+
f"FAIL: generated_with ({generated_with}) != expected version "
115+
f"({expected_version})",
116+
file=sys.stderr,
117+
)
118+
return 1
119+
120+
for kind, expected_cls in _TYPE_CHECKS.items():
121+
loaded_list = objects[kind]
122+
expected_list = _OBJECTS[kind]
123+
124+
if len(loaded_list) != len(expected_list): # type: ignore[arg-type]
125+
print(
126+
f"FAIL: {kind} list length mismatch "
127+
f"({len(loaded_list)} vs {len(expected_list)})", # type: ignore[arg-type]
128+
file=sys.stderr,
129+
)
130+
return 1
131+
132+
for i, (loaded, expected) in enumerate(
133+
zip(loaded_list, expected_list) # type: ignore[call-overload]
134+
):
135+
if type(loaded) is not expected_cls:
136+
print(
137+
f"FAIL: {kind}[{i}] is {type(loaded).__name__}, "
138+
f"expected {expected_cls.__name__}",
139+
file=sys.stderr,
140+
)
141+
return 1
142+
143+
if loaded != expected:
144+
print(
145+
f"FAIL: {kind}[{i}] {loaded!r} != {expected!r}",
146+
file=sys.stderr,
147+
)
148+
return 1
149+
150+
reloaded = pickle.loads(pickle.dumps(loaded)) # noqa: S301
151+
if reloaded != expected:
152+
print(
153+
f"FAIL: {kind}[{i}] does not round-trip correctly",
154+
file=sys.stderr,
155+
)
156+
return 1
157+
158+
print("All pickle verifications passed!")
159+
return 0
160+
161+
162+
def main() -> int:
163+
parser = argparse.ArgumentParser(description=__doc__)
164+
subparsers = parser.add_subparsers(dest="command", required=True)
165+
166+
write_parser = subparsers.add_parser("write", help="Generate a pickle file")
167+
write_parser.add_argument(
168+
"version", help="packaging version generating the pickles"
169+
)
170+
write_parser.add_argument(
171+
"output_dir",
172+
type=pathlib.Path,
173+
default=pathlib.Path("."),
174+
nargs="?",
175+
)
176+
177+
verify_parser = subparsers.add_parser("verify", help="Verify a pickle file")
178+
verify_parser.add_argument(
179+
"pickle_file", type=pathlib.Path, help="path to the pickle file"
180+
)
181+
verify_parser.add_argument(
182+
"--version", dest="expected_version", help="expected packaging version"
183+
)
184+
185+
args = parser.parse_args()
186+
187+
if args.command == "write":
188+
path = write(args.version, args.output_dir)
189+
print(f"Wrote {path}")
190+
return 0
191+
192+
if args.command == "verify":
193+
return verify(args.pickle_file, args.expected_version)
194+
195+
return 1
196+
197+
198+
if __name__ == "__main__":
199+
sys.exit(main())

0 commit comments

Comments
 (0)