Skip to content

Commit f1fba41

Browse files
committed
feat(zarr-metadata): adopt the PEP 661 sentinel for UNSET
d-v-b's call: PEP 661 is Final, ty already types the sentinel spelling exactly, mypy support is in review (python/mypy#21647) and treated as imminent, and pyright has a known-good version — so use the standard sentinel today rather than carrying the enum stopgap. - UNSET is now typing_extensions.Sentinel("UNSET"), used directly in type expressions (tuple[str | None, ...] | UNSET); the UnsetType companion enum is gone from the API. - typing_extensions floor bumped to 4.14 (where Sentinel arrived). - CI pins pyright==1.1.404, the last version before the class-attribute sentinel regression (microsoft/pyright#11115); pyproject documents the same pin for local runs. 0 errors on the pin; ty checks the sentinel fields clean (its 2 remaining diagnostics are its incomplete PEP 728 extra_items write support, unrelated). - Known short-term cost, accepted deliberately: mypy-checked consumers need cast/type-ignore at narrowing sites until mypy#21647 merges, and contributors' Pylance may show phantom Unknowns until the pyright fix ships. Recorded in _sentinel.py and the changelog. - The pydantic native-introspection test reverts to documenting that introspection is unsupported (pydantic 2.13 cannot schema a Sentinel); the delegation patterns are unaffected. Assisted-by: ClaudeCode:claude-fable-5
1 parent 800546a commit f1fba41

10 files changed

Lines changed: 71 additions & 98 deletions

File tree

.github/workflows/zarr-metadata.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ jobs:
8282
- name: Sync test dependency group
8383
run: uv sync --group test --python 3.11
8484
- name: Run pyright
85-
run: uv run --group test --with pyright pyright src
85+
# Pinned to the last version that types PEP 661 sentinels in class
86+
# attributes correctly; 1.1.405+ regressed (microsoft/pyright#11115).
87+
# Unpin when the fix lands.
88+
run: uv run --group test --with 'pyright==1.1.404' pyright src
8689

8790
zarr-metadata-complete:
8891
name: zarr-metadata complete

packages/zarr-metadata/changes/210.feature.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,14 @@ convention's default `"."` (the model previously normalized absence to `"/"`,
4747
which would misaddress the chunks of real-world default-separator arrays).
4848
The value is never null: absent, `"."`, or `"/"` are the only spellings.
4949

50-
Optional document keys use the `UNSET` sentinel, never `None`: in a model,
51-
`None` always corresponds to a JSON `null` in the document (a v2
52-
`compressor`, an unnamed dimension inside `dimension_names`), and `UNSET`
53-
always means the key is absent. This keeps semantically distinct spellings
50+
Optional document keys use `UNSET` — a PEP 661 sentinel
51+
(`typing_extensions.Sentinel`), usable directly in type expressions — never
52+
`None`: in a model, `None` always corresponds to a JSON `null` in the
53+
document (a v2 `compressor`, an unnamed dimension inside `dimension_names`),
54+
and `UNSET` always means the key is absent. Checker note: ty types the
55+
sentinel exactly; pyright needs `<= 1.1.404` until microsoft/pyright#11115
56+
is fixed (this package's CI pins it); mypy users need a `cast` or
57+
`type: ignore` at narrowing sites until python/mypy#21647 merges. This keeps semantically distinct spellings
5458
distinct — an absent `dimension_names` ("there are no dimension names") and
5559
an explicit `[null, null]` ("every dimension has a name, which is null") are
5660
different documents and round-trip as such. The `consolidated_metadata: null`

packages/zarr-metadata/pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ classifiers = [
3232
]
3333
keywords = ["zarr"]
3434
dependencies = [
35-
"typing_extensions>=4.13",
35+
"typing_extensions>=4.14",
3636
]
3737

3838
[project.urls]
@@ -82,6 +82,9 @@ checks = [
8282
"PR06",
8383
]
8484

85+
# CI pins pyright==1.1.404: later versions regress PEP 661 sentinel typing in
86+
# class attributes (microsoft/pyright#11115), which zarr_metadata.model._sentinel
87+
# relies on. Use the same pin locally; unpin when the fix lands.
8588
[tool.pyright]
8689
include = ["src"]
8790
enableExperimentalFeatures = true

packages/zarr-metadata/src/zarr_metadata/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
MetadataValidationError,
1818
NamedConfigModelV3,
1919
ProblemKind,
20-
UnsetType,
2120
ValidationProblem,
2221
)
2322
from zarr_metadata.v2.array import (
@@ -356,7 +355,6 @@
356355
"Uint32FillValue",
357356
"Uint64DataTypeName",
358357
"Uint64FillValue",
359-
"UnsetType",
360358
"V2ChunkKeyEncodingMetadata",
361359
"V2ChunkKeyEncodingName",
362360
"V2ChunkKeyEncodingSeparator",

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
GroupMetadataModelV3,
3535
GroupMetadataModelV3Partial,
3636
)
37-
from zarr_metadata.model._sentinel import UNSET, UnsetType
37+
from zarr_metadata.model._sentinel import UNSET
3838
from zarr_metadata.model._validation import (
3939
ARRAY_METADATA_OPTIONAL_KEYS_V3,
4040
ARRAY_METADATA_REQUIRED_KEYS_V2,
@@ -98,7 +98,6 @@
9898
"MetadataValidationError",
9999
"NamedConfigModelV3",
100100
"ProblemKind",
101-
"UnsetType",
102101
"ValidationProblem",
103102
"is_array_metadata_v2",
104103
"is_array_metadata_v3",

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from typing_extensions import TypedDict, Unpack
1212

13-
from zarr_metadata.model._sentinel import UNSET, UnsetType
13+
from zarr_metadata.model._sentinel import UNSET
1414
from zarr_metadata.model._validation import (
1515
ARRAY_METADATA_STANDARD_KEYS_V3,
1616
MetadataValidationError,
@@ -134,7 +134,7 @@ class ArrayMetadataModelV3Partial(TypedDict, total=False):
134134
chunk_grid: MetadataFieldModelV3
135135
codecs: tuple[MetadataFieldModelV3, ...]
136136
chunk_key_encoding: MetadataFieldModelV3
137-
dimension_names: tuple[str | None, ...] | UnsetType
137+
dimension_names: tuple[str | None, ...] | UNSET
138138
attributes: dict[str, JSONValue]
139139
storage_transformers: tuple[MetadataFieldModelV3, ...]
140140
extra_fields: dict[str, ExtensionFieldV3]
@@ -160,7 +160,7 @@ class ArrayMetadataModelV3:
160160
chunk_grid: MetadataFieldModelV3
161161
codecs: tuple[MetadataFieldModelV3, ...]
162162
chunk_key_encoding: MetadataFieldModelV3
163-
dimension_names: tuple[str | None, ...] | UnsetType
163+
dimension_names: tuple[str | None, ...] | UNSET
164164
attributes: dict[str, JSONValue]
165165
storage_transformers: tuple[MetadataFieldModelV3, ...]
166166
extra_fields: dict[str, ExtensionFieldV3]

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
ArrayMetadataModelV3,
1616
must_understand_subset,
1717
)
18-
from zarr_metadata.model._sentinel import UNSET, UnsetType
18+
from zarr_metadata.model._sentinel import UNSET
1919
from zarr_metadata.model._validation import (
2020
GROUP_METADATA_STANDARD_KEYS_V3,
2121
MetadataValidationError,
@@ -64,7 +64,7 @@ class GroupMetadataModelV3Partial(TypedDict, total=False):
6464
"""
6565

6666
attributes: dict[str, JSONValue]
67-
consolidated_metadata: ConsolidatedMetadataModelV3 | UnsetType
67+
consolidated_metadata: ConsolidatedMetadataModelV3 | UNSET
6868
extra_fields: dict[str, ExtensionFieldV3]
6969

7070

@@ -81,7 +81,7 @@ class GroupMetadataModelV3:
8181
zarr_format: Literal[3] = field(default=3, init=False)
8282
node_type: Literal["group"] = field(default="group", init=False)
8383
attributes: dict[str, JSONValue]
84-
consolidated_metadata: ConsolidatedMetadataModelV3 | UnsetType
84+
consolidated_metadata: ConsolidatedMetadataModelV3 | UNSET
8585
extra_fields: dict[str, ExtensionFieldV3]
8686

8787
def __post_init__(self) -> None:
@@ -147,7 +147,7 @@ def from_json(cls, data: object) -> GroupMetadataModelV3:
147147
# Cast to object: the TypedDict's extra_items type does not admit null,
148148
# but wild documents (historical zarr-python) contain it.
149149
consolidated_raw = cast("object", parsed.get(CONSOLIDATED_METADATA_KEY_V3, UNSET))
150-
consolidated: ConsolidatedMetadataModelV3 | UnsetType
150+
consolidated: ConsolidatedMetadataModelV3 | UNSET
151151
if consolidated_raw is UNSET or consolidated_raw is None:
152152
# consolidated_metadata: null was written by a historical
153153
# zarr-python bug; it gets no model representation. It is read as

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

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,27 @@
33
The models observe one invariant: `None` in a model always corresponds to a
44
JSON `null` in the document (a v2 `compressor`/`filters` value, an unnamed
55
dimension inside `dimension_names`), and `UNSET` always means the document
6-
key is absent. The two are never interchangeable, so a model value can never leak
7-
into a document as a spelling the writer did not intend.
6+
key is absent. The two are never interchangeable, so a model value can never
7+
leak into a document as a spelling the writer did not intend.
88
99
Check with identity: `if model.dimension_names is UNSET: ...`.
1010
11-
Implementation note: `typing_extensions.Sentinel` (PEP 661, Final as of
12-
2026-04-23, stdlib in Python 3.15) is the intended spelling, but two
13-
independent checker gaps block it for now. Pyright: a confirmed regression
14-
(1.1.405 through at least 1.1.411; worked in <= 1.1.404,
15-
https://github.com/microsoft/pyright/issues/11115) degrades a Sentinel to
16-
`Unknown` when read from any class-body attribute annotation. Mypy (2.1.0):
17-
has not yet implemented PEP 661 — a sentinel in type position is a hard
18-
`[valid-type]` error, so downstream mypy users (zarr-python itself) would
19-
see these fields as `Any`. Pinning a working pyright in this package's CI
20-
would fix neither contributors' IDEs nor downstream checkers reading the
21-
py.typed annotations. For calibration: ty (0.0.56)
22-
already types the Sentinel spelling perfectly — exact unions and
23-
`is`/`is not` narrowing in dataclass fields — so the standard is landing;
24-
pyright and mypy are the laggards. The single-member enum gives the same
25-
identity semantics with exact `Literal` narrowing on every checker; switch
26-
to `Sentinel` once the pyright regression is fixed and mypy support lands.
11+
Checker support (PEP 661 is Final; stdlib `sentinel` arrives in Python
12+
3.15): ty types this spelling exactly, including `is`/`is not` narrowing.
13+
Pyright supports it but a regression (1.1.405+, tracked as
14+
https://github.com/microsoft/pyright/issues/11115) degrades class-attribute
15+
reads to `Unknown`, so this package pins pyright to the last good version
16+
until the fix lands. Mypy support is in review
17+
(https://github.com/python/mypy/pull/21647); until it merges, mypy-checked
18+
consumers of these fields need a `cast` or `type: ignore` at narrowing
19+
sites. This is a deliberate short-term cost: the sentinel is the standard,
20+
and the checkers are converging on it.
2721
"""
2822

2923
from __future__ import annotations
3024

31-
from enum import Enum
32-
from typing import Final, Literal
25+
from typing_extensions import Sentinel
3326

34-
35-
class UnsetType(Enum):
36-
"""The type of `UNSET`; use in annotations as `T | UnsetType`."""
37-
38-
UNSET = "UNSET"
39-
40-
def __repr__(self) -> str:
41-
return "UNSET"
42-
43-
44-
UNSET: Final[Literal[UnsetType.UNSET]] = UnsetType.UNSET
45-
"""Marks a metadata-document key as absent. Test with `is UNSET`."""
27+
UNSET = Sentinel("UNSET")
28+
"""Marks a metadata-document key as absent (PEP 661 sentinel; usable directly
29+
in type expressions, e.g. `tuple[str, ...] | UNSET`). Test with `is UNSET`."""

packages/zarr-metadata/tests/model/test_pydantic.py

Lines changed: 31 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
ConfigDict,
3535
InstanceOf,
3636
PlainSerializer,
37+
PydanticSchemaGenerationError,
38+
PydanticUserError,
3739
TypeAdapter,
3840
ValidationError,
3941
model_validator,
@@ -142,59 +144,40 @@ def test_type_adapter_standalone() -> None:
142144
# --- the road not taken: native dataclass introspection ----------------------
143145

144146

145-
def test_native_dataclass_introspection_is_possible_but_diverges() -> None:
146-
"""Pydantic CAN introspect the model dataclass after a namespace rebuild,
147-
but that path validates the model shape, not the document: it rejects the
148-
document form, coerces booleans into dimensions, and skips the library's
149-
cross-field checks. This test documents why the delegation pattern above
150-
is the recommended integration."""
147+
def test_native_dataclass_introspection_is_not_supported() -> None:
148+
"""Pydantic cannot field-introspect the model dataclasses: the UNSET
149+
sentinel (PEP 661, typing_extensions.Sentinel) in the optional-field
150+
annotations has no pydantic schema (as of pydantic 2.13), so even the
151+
rebuild-with-namespace recipe fails. Introspection was already the wrong
152+
tool before the sentinel existed — it validated the model shape rather
153+
than the document, and its lax coercion re-opened validator holes (e.g.
154+
shape=[True, -5] coerced to (1, -5)) — so the delegation patterns above
155+
are the only supported integrations. If this test ever fails because
156+
pydantic learned to handle sentinels, revisit whether the introspection
157+
path needs its divergences documented again."""
151158
from zarr_metadata._common import JSONValue
152-
from zarr_metadata.model import UNSET, UnsetType
159+
from zarr_metadata.model import UNSET
153160
from zarr_metadata.v3._common import MetadataV3
154161
from zarr_metadata.v3.array import ArrayMetadataV3, ExtensionFieldV3
155162

156-
adapter = TypeAdapter(ArrayMetadataModelV3)
157-
adapter.rebuild(
158-
force=True,
159-
_types_namespace={
160-
"JSONValue": JSONValue,
161-
"ExtensionFieldV3": ExtensionFieldV3,
162-
"MetadataV3": MetadataV3,
163-
"ArrayMetadataV3": ArrayMetadataV3,
164-
"NamedConfigModelV3": NamedConfigModelV3,
165-
"MetadataFieldModelV3": NamedConfigModelV3,
166-
"UnsetType": UnsetType,
167-
},
168-
)
169-
model_shaped = {
170-
"shape": [10],
171-
"fill_value": 0,
172-
"data_type": {"name": "uint8", "configuration": {}},
173-
"chunk_grid": {"name": "regular", "configuration": {"chunk_shape": [5]}},
174-
"codecs": [{"name": "bytes", "configuration": {}}],
175-
"chunk_key_encoding": {"name": "default", "configuration": {}},
176-
"dimension_names": UNSET,
177-
"attributes": {},
178-
"storage_transformers": [],
179-
"extra_fields": {},
180-
}
181-
# model-shaped data validates, nested named configs and all
182-
model = adapter.validate_python(model_shaped)
183-
assert isinstance(model.data_type, NamedConfigModelV3)
184-
185-
# divergence 1: the DOCUMENT form is rejected — no from_json normalization
186-
with pytest.raises(ValidationError):
187-
adapter.validate_python(model_shaped | {"data_type": "uint8"})
188-
189-
# divergence 2: lax coercion re-opens holes the library validators close
190-
coerced = adapter.validate_python(model_shaped | {"shape": [True, -5]})
191-
assert coerced.shape == (1, -5) # from_json would reject both entries
192-
193-
# __post_init__ invariants DO still run under pydantic construction
194-
with pytest.raises(ValidationError, match="Extra fields"):
195-
adapter.validate_python(
196-
model_shaped | {"extra_fields": {"shape": {"must_understand": False}}}
163+
def build_and_use() -> None:
164+
adapter = TypeAdapter(ArrayMetadataModelV3)
165+
adapter.rebuild(
166+
force=True,
167+
_types_namespace={
168+
"JSONValue": JSONValue,
169+
"ExtensionFieldV3": ExtensionFieldV3,
170+
"MetadataV3": MetadataV3,
171+
"ArrayMetadataV3": ArrayMetadataV3,
172+
"NamedConfigModelV3": NamedConfigModelV3,
173+
"MetadataFieldModelV3": NamedConfigModelV3,
174+
"UNSET": UNSET,
175+
},
197176
)
177+
adapter.validate_python({})
178+
179+
with pytest.raises((AttributeError, PydanticSchemaGenerationError, PydanticUserError)):
180+
build_and_use()
198181

199182

200183
# --- a first-class pydantic model, engine-backed (the pydantic-zarr pattern) --

packages/zarr-metadata/tests/test_public_api.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ def _group_rank(s: str) -> int:
5656
"MetadataValidationError",
5757
"ProblemKind",
5858
"UNSET",
59-
"UnsetType",
6059
# v2 data-type encoding union
6160
"DataTypeMetadataV2",
6261
# Category B — codec canonical unions

0 commit comments

Comments
 (0)