Skip to content

Commit 0dc98bb

Browse files
d-v-bclaude
andcommitted
fix(zarr-metadata): require typing_extensions>=4.16 so UNSET pickles by reference
Models hold the UNSET sentinel as field values (dimension_names, attributes), so any object graph containing a model must survive pickling and deep-copying. A sentinel's contract is identity — state-based pickling would produce impostor objects that fail every `is UNSET` check — which is why typing_extensions <= 4.15 refused to pickle sentinels at all. typing_extensions 4.16 implements Sentinel.__reduce__ as pickling by reference (a lookup of the sentinel's name on its defining module), the same mechanism enum members use, so the singleton identity survives the round trip. Bump the floor and pin the behavior with tests: identity across pickle/copy/deepcopy, models holding UNSET round-tripping, and a guard that a non-importable sentinel still fails loudly rather than pickling by state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 04f777d commit 0dc98bb

3 files changed

Lines changed: 67 additions & 1 deletion

File tree

packages/zarr-metadata/pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ classifiers = [
3232
]
3333
keywords = ["zarr"]
3434
dependencies = [
35-
"typing_extensions>=4.14",
35+
# >=4.16: first release where `Sentinel` pickles by reference
36+
# (`__reduce__` returns the sentinel's name), so `UNSET` — and any model
37+
# holding it — can cross process boundaries and be deep-copied with its
38+
# singleton identity intact. 4.15 and earlier refuse to pickle sentinels.
39+
"typing_extensions>=4.16",
3640
]
3741

3842
[project.urls]

packages/zarr-metadata/src/zarr_metadata/model/_sentinel.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@
88
99
Check with identity: `if model.dimension_names is UNSET: ...`.
1010
11+
Because the contract is identity, the sentinel must never be reconstructed
12+
from state: pickling and copying work by *reference* (typing_extensions >=
13+
4.16 implements `Sentinel.__reduce__` as a lookup of the sentinel's name on
14+
its defining module), so `pickle.loads(pickle.dumps(UNSET)) is UNSET` holds
15+
across process boundaries, and models holding `UNSET` pickle and deep-copy
16+
freely. Earlier typing_extensions releases refused to pickle sentinels
17+
outright — hence the `>=4.16` floor in this package's dependencies.
18+
1119
Checker support (PEP 661 is Final; stdlib `sentinel` arrives in Python
1220
3.15): ty types this spelling exactly, including `is`/`is not` narrowing.
1321
Pyright supports it but a regression (1.1.405+, tracked as
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Tests for the pickling and copying behavior of the `UNSET` sentinel.
2+
3+
The sentinel's contract is identity, so it must never be reconstructed from
4+
state. typing_extensions >= 4.16 pickles sentinels by reference (a lookup of
5+
the sentinel's name on its defining module), which preserves the singleton
6+
across process boundaries; these tests pin that behavior, since models hold
7+
`UNSET` as field values and must survive pickling and deep-copying.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import copy
13+
import pickle
14+
15+
import pytest
16+
from typing_extensions import Sentinel
17+
18+
from zarr_metadata.model import UNSET, ArrayMetadataModelV3
19+
20+
21+
def test_unset_pickle_round_trip_preserves_identity() -> None:
22+
restored = pickle.loads(pickle.dumps(UNSET))
23+
assert restored is UNSET
24+
25+
26+
def test_unset_copy_preserves_identity() -> None:
27+
assert copy.copy(UNSET) is UNSET
28+
assert copy.deepcopy(UNSET) is UNSET
29+
30+
31+
def test_model_holding_unset_pickles() -> None:
32+
model = ArrayMetadataModelV3.create_default(shape=(4,))
33+
assert model.dimension_names is UNSET
34+
restored = pickle.loads(pickle.dumps(model))
35+
assert restored == model
36+
assert restored.dimension_names is UNSET
37+
38+
39+
def test_model_holding_unset_deepcopies() -> None:
40+
model = ArrayMetadataModelV3.create_default(shape=(4,))
41+
duplicate = copy.deepcopy(model)
42+
assert duplicate == model
43+
assert duplicate.dimension_names is UNSET
44+
45+
46+
def test_non_importable_sentinel_fails_to_pickle() -> None:
47+
"""Sentinels pickle by reference, never by state. A sentinel that is not
48+
an importable attribute of its module has no reference to pickle, so
49+
dumping it must fail loudly — a successful dump here would mean the
50+
implementation regressed to state-based pickling, which would produce
51+
identity-breaking impostor objects on the receiving side."""
52+
local_sentinel = Sentinel("local_sentinel")
53+
with pytest.raises((pickle.PicklingError, TypeError)):
54+
pickle.dumps(local_sentinel)

0 commit comments

Comments
 (0)