Skip to content

Commit 753bd51

Browse files
authored
Merge branch 'main' into codex/justfile-verbs
2 parents ea82101 + ae41d6e commit 753bd51

30 files changed

Lines changed: 267 additions & 190 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
Concrete v3 entity types are now assignable to the fields they describe.
2+
Previously, none of the package's canonical codec / chunk-grid /
3+
chunk-key-encoding / data-type types (e.g. `BloscCodecMetadata`,
4+
`RegularChunkGridMetadata`) satisfied `ZarrV3MetadataFieldJSON`, so a
5+
type checker rejected putting them into the very fields they document
6+
(`codecs`, `chunk_grid`, `data_type`, ...). Three changes fix this:
7+
8+
- `ZarrV3NamedConfigJSON.name` and `.configuration` are now `ReadOnly`
9+
(PEP 705), making them covariant so concrete `name: Literal[...]` and
10+
required-`configuration` shapes are accepted.
11+
- `ZarrV3NamedConfigJSON` is now `closed` (PEP 728): the spec's
12+
named-configuration envelope has exactly `name` / `configuration` /
13+
`must_understand`, and closing the type also makes it usable as a
14+
`JSONValue` (needed for e.g. the `sharding_indexed` inner `codecs`).
15+
- Every concrete `*Object` / `*Configuration` TypedDict is now `closed`,
16+
and object forms declare `must_understand: NotRequired[bool]` (any v3
17+
metadata field may carry the extension member).
18+
19+
**Soft-breaking** for type-checking consumers: dicts with keys beyond the
20+
declared shape no longer satisfy the closed types, and `name` /
21+
`configuration` can no longer be mutated through `ZarrV3NamedConfigJSON`.
22+
Both were previously accepted by type checkers but produced documents
23+
outside the spec's shapes. `zarr_metadata.pydantic` serializers now
24+
declare their return schema via the pydantic-facing shadow types, so
25+
pydantic schema generation stays warning-free.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
`ZarrV2ConsolidatedMetadataJSON.zarr_consolidated_format` is typed
2+
`Literal[1]` rather than `int`. Format 1 is the only defined `.zmetadata`
3+
format and the runtime validator already rejected anything else, so the
4+
type now carries the constraint the validator enforces instead of
5+
contradicting it.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
**Breaking:** every `validate_*` function in `zarr_metadata.model` now
2+
returns `tuple[ValidationProblem, ...]` instead of `list[ValidationProblem]`,
3+
and `MetadataValidationError.problems` is a tuple. Iteration and indexing are
4+
unchanged; callers that mutate reports must first copy them with
5+
`list(problems)`. `MetadataValidationError` still accepts any problem
6+
sequence.

‎packages/zarr-metadata/pyproject.toml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ include = [
9595
extend = "../../pyproject.toml"
9696
target-version = "py311"
9797

98+
[tool.ruff.lint]
99+
# `Any` defeats the point of a package whose product is precise types, so it
100+
# is banned in annotations here rather than merely discouraged. Use `object`
101+
# for "any value" (the caller must narrow) and the document TypedDicts where
102+
# the shape is known; a genuinely dynamic annotation needs an explicit noqa
103+
# saying why.
104+
extend-select = ["ANN401"]
105+
98106
[tool.pytest.ini_options]
99107
minversion = "7"
100108
testpaths = ["tests"]

‎packages/zarr-metadata/src/zarr_metadata/_common.py‎

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,45 +6,59 @@
66
`zarr_metadata.v3.data_type`.
77
"""
88

9-
from collections.abc import Mapping, Sequence
9+
from collections.abc import Mapping
1010
from typing import NotRequired
1111

12-
from typing_extensions import TypeAliasType, TypedDict
12+
from typing_extensions import ReadOnly, TypeAliasType, TypedDict
1313

1414
JSONValue = TypeAliasType(
1515
"JSONValue",
16-
int | float | bool | str | Sequence["JSONValue"] | Mapping[str, "JSONValue"] | None,
16+
int
17+
| float
18+
| bool
19+
| str
20+
| list["JSONValue"]
21+
| tuple["JSONValue", ...]
22+
| Mapping[str, "JSONValue"]
23+
| None,
1724
)
1825
"""A recursive type alias for JSON-encodable values.
1926
2027
Defined via `TypeAliasType` (rather than a plain `TypeAlias`) so the
2128
self-reference is a named recursion point that pydantic can resolve when
2229
building a `TypeAdapter`; a bare recursive `TypeAlias` raises
2330
`PydanticUserError`/`RecursionError` at validation time.
24-
25-
The array arm is the covariant `Sequence` rather than the invariant
26-
`list["JSONValue"] | tuple["JSONValue", ...]`, so values typed with a
27-
*narrower* element type still count as JSON values: a `list[str]` field on a
28-
TypedDict is assignable to `JSONValue` under `Sequence` but not under
29-
`list[JSONValue]` (`list` is invariant in its element type, and pyright's
30-
diagnostic for that failure suggests exactly this change). This is what lets
31-
downstream TypedDicts give their fields precise types (`Sequence[str]`,
32-
`list[int]`, ...) while remaining assignable to `Mapping[str, JSONValue]`.
33-
The type-level cost, accepted deliberately: `Sequence` says nothing about the
34-
concrete container, and it admits `str`/`bytes` (`str` was already a union
35-
arm); runtime code narrowing a JSON array must exclude `str`/`bytes`/
36-
`bytearray` regardless of how this alias is spelled.
3731
"""
3832

3933

40-
class ZarrV3NamedConfigJSON(TypedDict):
34+
class ZarrV3NamedConfigJSON(TypedDict, closed=True):
4135
"""
4236
Externally-tagged union member for a metadata field.
4337
4438
The optional `configuration` mapping holds arbitrary JSON-encodable
4539
values. `must_understand` is implicitly true when absent.
40+
41+
`name` and `configuration` are `ReadOnly` (PEP 705) so that concrete
42+
entity types — `BloscCodecObject`, `RegularChunkGridObject`, and the
43+
rest — are assignable to this type, and therefore to
44+
`ZarrV3MetadataFieldJSON`. Without `ReadOnly` both items are invariant,
45+
so a concrete `name: Literal["blosc"]` does not satisfy `name: str`, and
46+
a required `configuration` does not satisfy a `NotRequired` one. That
47+
made the package's own codec types unusable in the very fields they
48+
describe (`codecs`, `data_type`, `chunk_grid`, ...), and made
49+
`TypeIs`-based codec classification impossible to declare, since `TypeIs`
50+
requires the narrowed type to be assignable to the input type.
51+
52+
`must_understand` stays writable: nothing needs to narrow it, and
53+
keeping it mutable lets writers set it on an already-constructed field.
54+
55+
The type is `closed` (PEP 728): the spec's named-configuration envelope
56+
has exactly these three members, and closing it is also what makes this
57+
type — and every concrete entity type embedding it, e.g. the
58+
`sharding_indexed` configuration's inner `codecs` list — assignable to
59+
`Mapping[str, JSONValue]` (i.e. usable as a `JSONValue`).
4660
"""
4761

48-
name: str
49-
configuration: NotRequired[Mapping[str, JSONValue]]
62+
name: ReadOnly[str]
63+
configuration: NotRequired[ReadOnly[Mapping[str, JSONValue]]]
5064
must_understand: NotRequired[bool]

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,14 @@ class ZarrV3NamedConfig:
6161
def to_json(self) -> ZarrV3MetadataFieldJSON:
6262
if not self.configuration and self.must_understand:
6363
return self.name
64-
out: ZarrV3NamedConfigJSON = {"name": self.name}
65-
if self.configuration:
66-
# to_json output shares no mutable state with the model.
67-
out["configuration"] = copy.deepcopy(self.configuration)
64+
# `configuration` is ReadOnly, so it is set in the literal rather than
65+
# assigned afterwards. to_json output shares no mutable state with the
66+
# model.
67+
out: ZarrV3NamedConfigJSON = (
68+
{"name": self.name, "configuration": copy.deepcopy(self.configuration)}
69+
if self.configuration
70+
else {"name": self.name}
71+
)
6872
if not self.must_understand:
6973
out["must_understand"] = False
7074
return out
@@ -465,7 +469,7 @@ def from_json(cls, data: object) -> ZarrV2ArrayMetadata:
465469

466470
@classmethod
467471
def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ArrayMetadata:
468-
zarray_raw = cast("object", load_store_json(mapping, ZARR_V2_ARRAY_METADATA_STORE_KEY))
472+
zarray_raw = load_store_json(mapping, ZARR_V2_ARRAY_METADATA_STORE_KEY)
469473
if not isinstance(zarray_raw, Mapping):
470474
return cls.from_json(zarray_raw)
471475
zarray = cast("Mapping[str, object]", zarray_raw)
@@ -480,7 +484,7 @@ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ArrayMetadata:
480484
]
481485
)
482486
if ZARR_V2_ATTRIBUTES_STORE_KEY in mapping:
483-
zattrs = cast("object", load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY))
487+
zattrs = load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY)
484488
return cls.from_json({**zarray, "attributes": zattrs})
485489
return cls.from_json(zarray)
486490

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def to_json(self) -> ZarrV3ConsolidatedMetadataJSON:
226226
def from_json(cls, data: object) -> ZarrV3ConsolidatedMetadata:
227227
normalized = arrays_to_tuples(data)
228228
problems = validate_consolidated_metadata_v3(normalized)
229-
if problems:
229+
if len(problems) != 0:
230230
raise MetadataValidationError(problems)
231231
env = cast("Mapping[str, object]", normalized)
232232
entries: dict[str, ZarrV3ArrayMetadata | ZarrV3GroupMetadata] = {}
@@ -315,7 +315,7 @@ def from_json(cls, data: object) -> ZarrV2GroupMetadata:
315315

316316
@classmethod
317317
def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2GroupMetadata:
318-
zgroup_raw = cast("object", load_store_json(mapping, ZARR_V2_GROUP_METADATA_STORE_KEY))
318+
zgroup_raw = load_store_json(mapping, ZARR_V2_GROUP_METADATA_STORE_KEY)
319319
if not isinstance(zgroup_raw, Mapping):
320320
return cls.from_json(zgroup_raw)
321321
zgroup = cast("Mapping[str, object]", zgroup_raw)
@@ -330,7 +330,7 @@ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2GroupMetadata:
330330
]
331331
)
332332
if ZARR_V2_ATTRIBUTES_STORE_KEY in mapping:
333-
zattrs = cast("object", load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY))
333+
zattrs = load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY)
334334
return cls.from_json({**zgroup, "attributes": zattrs})
335335
return cls.from_json(zgroup)
336336

@@ -417,7 +417,7 @@ def from_json(cls, data: object) -> ZarrV2ConsolidatedMetadata:
417417
)
418418
for problem in validate_json(value)
419419
)
420-
if problems:
420+
if len(problems) != 0:
421421
raise MetadataValidationError(problems)
422422
entries_tupled = cast(
423423
"dict[str, JSONValue]",

0 commit comments

Comments
 (0)