From aa87fc05f361ea992ec224991aa1f268bbd7f1c5 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Thu, 3 Sep 2026 20:50:50 +0200 Subject: [PATCH 1/4] fix(zarr-metadata)!: make entity types assignable to metadata fields; validators return tuples `ZarrV3NamedConfigJSON.name` and `.configuration` are `ReadOnly` and the envelope is `closed`, so the concrete codec / chunk-grid / chunk-key-encoding / data-type TypedDicts are assignable to the fields they describe. Every concrete `*Object` / `*Configuration` is `closed` and object forms declare `must_understand: NotRequired[bool]`. Every `validate_*` in `zarr_metadata.model` returns `tuple[ValidationProblem, ...]`, `MetadataValidationError.problems` is a tuple, and `load_store_json` returns `object` rather than `Any`. `ANN401` is enforced package-wide. `ZarrV2ConsolidatedMetadataJSON.zarr_consolidated_format` is `Literal[1]`. Split from d-v-b/zarr-python#296 (part 1 of 3). Assisted-by: ClaudeCode:claude-fable-5-1 --- packages/zarr-metadata/changes/296.bugfix.md | 25 +++ packages/zarr-metadata/changes/296.misc.1.md | 5 + packages/zarr-metadata/changes/296.misc.md | 6 + packages/zarr-metadata/pyproject.toml | 8 + .../src/zarr_metadata/_common.py | 52 +++--- .../src/zarr_metadata/model/_array.py | 16 +- .../src/zarr_metadata/model/_group.py | 8 +- .../src/zarr_metadata/model/_validation.py | 160 ++++++++++-------- .../src/zarr_metadata/pydantic.py | 25 +-- .../src/zarr_metadata/v2/consolidated.py | 2 +- .../v3/chunk_grid/rectilinear.py | 7 +- .../zarr_metadata/v3/chunk_grid/regular.py | 7 +- .../v3/chunk_key_encoding/default.py | 5 +- .../zarr_metadata/v3/chunk_key_encoding/v2.py | 5 +- .../src/zarr_metadata/v3/codec/blosc.py | 5 +- .../src/zarr_metadata/v3/codec/bytes.py | 5 +- .../src/zarr_metadata/v3/codec/cast_value.py | 7 +- .../src/zarr_metadata/v3/codec/crc32c.py | 3 +- .../src/zarr_metadata/v3/codec/gzip.py | 7 +- .../zarr_metadata/v3/codec/scale_offset.py | 5 +- .../v3/codec/sharding_indexed.py | 5 +- .../src/zarr_metadata/v3/codec/transpose.py | 7 +- .../src/zarr_metadata/v3/codec/zstd.py | 5 +- .../src/zarr_metadata/v3/consolidated.py | 2 +- .../v3/data_type/numpy_datetime64.py | 7 +- .../v3/data_type/numpy_timedelta64.py | 7 +- .../src/zarr_metadata/v3/data_type/struct.py | 9 +- .../zarr-metadata/tests/model/test_array.py | 40 ++--- .../zarr-metadata/tests/model/test_group.py | 4 +- .../tests/test_partial_equivalence.py | 4 +- 30 files changed, 265 insertions(+), 188 deletions(-) create mode 100644 packages/zarr-metadata/changes/296.bugfix.md create mode 100644 packages/zarr-metadata/changes/296.misc.1.md create mode 100644 packages/zarr-metadata/changes/296.misc.md diff --git a/packages/zarr-metadata/changes/296.bugfix.md b/packages/zarr-metadata/changes/296.bugfix.md new file mode 100644 index 0000000000..cbe3a1a7ed --- /dev/null +++ b/packages/zarr-metadata/changes/296.bugfix.md @@ -0,0 +1,25 @@ +Concrete v3 entity types are now assignable to the fields they describe. +Previously, none of the package's canonical codec / chunk-grid / +chunk-key-encoding / data-type types (e.g. `BloscCodecMetadata`, +`RegularChunkGridMetadata`) satisfied `ZarrV3MetadataFieldJSON`, so a +type checker rejected putting them into the very fields they document +(`codecs`, `chunk_grid`, `data_type`, ...). Three changes fix this: + +- `ZarrV3NamedConfigJSON.name` and `.configuration` are now `ReadOnly` + (PEP 705), making them covariant so concrete `name: Literal[...]` and + required-`configuration` shapes are accepted. +- `ZarrV3NamedConfigJSON` is now `closed` (PEP 728): the spec's + named-configuration envelope has exactly `name` / `configuration` / + `must_understand`, and closing the type also makes it usable as a + `JSONValue` (needed for e.g. the `sharding_indexed` inner `codecs`). +- Every concrete `*Object` / `*Configuration` TypedDict is now `closed`, + and object forms declare `must_understand: NotRequired[bool]` (any v3 + metadata field may carry the extension member). + +**Soft-breaking** for type-checking consumers: dicts with keys beyond the +declared shape no longer satisfy the closed types, and `name` / +`configuration` can no longer be mutated through `ZarrV3NamedConfigJSON`. +Both were previously accepted by type checkers but produced documents +outside the spec's shapes. `zarr_metadata.pydantic` serializers now +declare their return schema via the pydantic-facing shadow types, so +pydantic schema generation stays warning-free. diff --git a/packages/zarr-metadata/changes/296.misc.1.md b/packages/zarr-metadata/changes/296.misc.1.md new file mode 100644 index 0000000000..5f5eb1f238 --- /dev/null +++ b/packages/zarr-metadata/changes/296.misc.1.md @@ -0,0 +1,5 @@ +`ZarrV2ConsolidatedMetadataJSON.zarr_consolidated_format` is typed +`Literal[1]` rather than `int`. Format 1 is the only defined `.zmetadata` +format and the runtime validator already rejected anything else, so the +type now carries the constraint the validator enforces instead of +contradicting it. diff --git a/packages/zarr-metadata/changes/296.misc.md b/packages/zarr-metadata/changes/296.misc.md new file mode 100644 index 0000000000..ed9167064f --- /dev/null +++ b/packages/zarr-metadata/changes/296.misc.md @@ -0,0 +1,6 @@ +**Breaking:** every `validate_*` function in `zarr_metadata.model` now +returns `tuple[ValidationProblem, ...]` instead of `list[ValidationProblem]`, +and `MetadataValidationError.problems` is a tuple. Iteration and indexing are +unchanged; callers that mutate reports must first copy them with +`list(problems)`. `MetadataValidationError` still accepts any problem +sequence. diff --git a/packages/zarr-metadata/pyproject.toml b/packages/zarr-metadata/pyproject.toml index 8900f96dc5..6e7b0e4f52 100644 --- a/packages/zarr-metadata/pyproject.toml +++ b/packages/zarr-metadata/pyproject.toml @@ -95,6 +95,14 @@ include = [ extend = "../../pyproject.toml" target-version = "py311" +[tool.ruff.lint] +# `Any` defeats the point of a package whose product is precise types, so it +# is banned in annotations here rather than merely discouraged. Use `object` +# for "any value" (the caller must narrow) and the document TypedDicts where +# the shape is known; a genuinely dynamic annotation needs an explicit noqa +# saying why. +extend-select = ["ANN401"] + [tool.pytest.ini_options] minversion = "7" testpaths = ["tests"] diff --git a/packages/zarr-metadata/src/zarr_metadata/_common.py b/packages/zarr-metadata/src/zarr_metadata/_common.py index 08c143107f..0751d56563 100644 --- a/packages/zarr-metadata/src/zarr_metadata/_common.py +++ b/packages/zarr-metadata/src/zarr_metadata/_common.py @@ -6,14 +6,21 @@ `zarr_metadata.v3.data_type`. """ -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from typing import NotRequired -from typing_extensions import TypeAliasType, TypedDict +from typing_extensions import ReadOnly, TypeAliasType, TypedDict JSONValue = TypeAliasType( "JSONValue", - int | float | bool | str | Sequence["JSONValue"] | Mapping[str, "JSONValue"] | None, + int + | float + | bool + | str + | list["JSONValue"] + | tuple["JSONValue", ...] + | Mapping[str, "JSONValue"] + | None, ) """A recursive type alias for JSON-encodable values. @@ -21,30 +28,37 @@ self-reference is a named recursion point that pydantic can resolve when building a `TypeAdapter`; a bare recursive `TypeAlias` raises `PydanticUserError`/`RecursionError` at validation time. - -The array arm is the covariant `Sequence` rather than the invariant -`list["JSONValue"] | tuple["JSONValue", ...]`, so values typed with a -*narrower* element type still count as JSON values: a `list[str]` field on a -TypedDict is assignable to `JSONValue` under `Sequence` but not under -`list[JSONValue]` (`list` is invariant in its element type, and pyright's -diagnostic for that failure suggests exactly this change). This is what lets -downstream TypedDicts give their fields precise types (`Sequence[str]`, -`list[int]`, ...) while remaining assignable to `Mapping[str, JSONValue]`. -The type-level cost, accepted deliberately: `Sequence` says nothing about the -concrete container, and it admits `str`/`bytes` (`str` was already a union -arm); runtime code narrowing a JSON array must exclude `str`/`bytes`/ -`bytearray` regardless of how this alias is spelled. """ -class ZarrV3NamedConfigJSON(TypedDict): +class ZarrV3NamedConfigJSON(TypedDict, closed=True): """ Externally-tagged union member for a metadata field. The optional `configuration` mapping holds arbitrary JSON-encodable values. `must_understand` is implicitly true when absent. + + `name` and `configuration` are `ReadOnly` (PEP 705) so that concrete + entity types — `BloscCodecObject`, `RegularChunkGridObject`, and the + rest — are assignable to this type, and therefore to + `ZarrV3MetadataFieldJSON`. Without `ReadOnly` both items are invariant, + so a concrete `name: Literal["blosc"]` does not satisfy `name: str`, and + a required `configuration` does not satisfy a `NotRequired` one. That + made the package's own codec types unusable in the very fields they + describe (`codecs`, `data_type`, `chunk_grid`, ...), and made + `TypeIs`-based codec classification impossible to declare, since `TypeIs` + requires the narrowed type to be assignable to the input type. + + `must_understand` stays writable: nothing needs to narrow it, and + keeping it mutable lets writers set it on an already-constructed field. + + The type is `closed` (PEP 728): the spec's named-configuration envelope + has exactly these three members, and closing it is also what makes this + type — and every concrete entity type embedding it, e.g. the + `sharding_indexed` configuration's inner `codecs` list — assignable to + `Mapping[str, JSONValue]` (i.e. usable as a `JSONValue`). """ - name: str - configuration: NotRequired[Mapping[str, JSONValue]] + name: ReadOnly[str] + configuration: NotRequired[ReadOnly[Mapping[str, JSONValue]]] must_understand: NotRequired[bool] diff --git a/packages/zarr-metadata/src/zarr_metadata/model/_array.py b/packages/zarr-metadata/src/zarr_metadata/model/_array.py index db708c0152..26a69a304a 100644 --- a/packages/zarr-metadata/src/zarr_metadata/model/_array.py +++ b/packages/zarr-metadata/src/zarr_metadata/model/_array.py @@ -61,10 +61,14 @@ class ZarrV3NamedConfig: def to_json(self) -> ZarrV3MetadataFieldJSON: if not self.configuration and self.must_understand: return self.name - out: ZarrV3NamedConfigJSON = {"name": self.name} - if self.configuration: - # to_json output shares no mutable state with the model. - out["configuration"] = copy.deepcopy(self.configuration) + # `configuration` is ReadOnly, so it is set in the literal rather than + # assigned afterwards. to_json output shares no mutable state with the + # model. + out: ZarrV3NamedConfigJSON = ( + {"name": self.name, "configuration": copy.deepcopy(self.configuration)} + if self.configuration + else {"name": self.name} + ) if not self.must_understand: out["must_understand"] = False return out @@ -465,7 +469,7 @@ def from_json(cls, data: object) -> ZarrV2ArrayMetadata: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ArrayMetadata: - zarray_raw = cast("object", load_store_json(mapping, ZARR_V2_ARRAY_METADATA_STORE_KEY)) + zarray_raw = load_store_json(mapping, ZARR_V2_ARRAY_METADATA_STORE_KEY) if not isinstance(zarray_raw, Mapping): return cls.from_json(zarray_raw) zarray = cast("Mapping[str, object]", zarray_raw) @@ -480,7 +484,7 @@ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ArrayMetadata: ] ) if ZARR_V2_ATTRIBUTES_STORE_KEY in mapping: - zattrs = cast("object", load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY)) + zattrs = load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY) return cls.from_json({**zarray, "attributes": zattrs}) return cls.from_json(zarray) diff --git a/packages/zarr-metadata/src/zarr_metadata/model/_group.py b/packages/zarr-metadata/src/zarr_metadata/model/_group.py index bb2e14da1b..5519e6fbb9 100644 --- a/packages/zarr-metadata/src/zarr_metadata/model/_group.py +++ b/packages/zarr-metadata/src/zarr_metadata/model/_group.py @@ -226,7 +226,7 @@ def to_json(self) -> ZarrV3ConsolidatedMetadataJSON: def from_json(cls, data: object) -> ZarrV3ConsolidatedMetadata: normalized = arrays_to_tuples(data) problems = validate_consolidated_metadata_v3(normalized) - if problems: + if len(problems) != 0: raise MetadataValidationError(problems) env = cast("Mapping[str, object]", normalized) entries: dict[str, ZarrV3ArrayMetadata | ZarrV3GroupMetadata] = {} @@ -315,7 +315,7 @@ def from_json(cls, data: object) -> ZarrV2GroupMetadata: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2GroupMetadata: - zgroup_raw = cast("object", load_store_json(mapping, ZARR_V2_GROUP_METADATA_STORE_KEY)) + zgroup_raw = load_store_json(mapping, ZARR_V2_GROUP_METADATA_STORE_KEY) if not isinstance(zgroup_raw, Mapping): return cls.from_json(zgroup_raw) zgroup = cast("Mapping[str, object]", zgroup_raw) @@ -330,7 +330,7 @@ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2GroupMetadata: ] ) if ZARR_V2_ATTRIBUTES_STORE_KEY in mapping: - zattrs = cast("object", load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY)) + zattrs = load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY) return cls.from_json({**zgroup, "attributes": zattrs}) return cls.from_json(zgroup) @@ -417,7 +417,7 @@ def from_json(cls, data: object) -> ZarrV2ConsolidatedMetadata: ) for problem in validate_json(value) ) - if problems: + if len(problems) != 0: raise MetadataValidationError(problems) entries_tupled = cast( "dict[str, JSONValue]", diff --git a/packages/zarr-metadata/src/zarr_metadata/model/_validation.py b/packages/zarr-metadata/src/zarr_metadata/model/_validation.py index d1cd74b719..f927851e4c 100644 --- a/packages/zarr-metadata/src/zarr_metadata/model/_validation.py +++ b/packages/zarr-metadata/src/zarr_metadata/model/_validation.py @@ -17,7 +17,7 @@ import math from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Any, Final, Literal, NoReturn, cast +from typing import Final, Literal, NoReturn, cast from typing_extensions import TypeIs @@ -62,27 +62,33 @@ def __str__(self) -> str: class MetadataValidationError(ValueError): """Raised when a value fails structural metadata validation. - Carries every problem found (not just the first) in `.problems`. + Carries every problem found (not just the first) in `.problems`, as an + immutable tuple: a raised error is a finished report, and a caller + inspecting it must not be able to edit the record. """ - def __init__(self, problems: list[ValidationProblem]) -> None: - self.problems = problems - super().__init__("\n".join(str(problem) for problem in problems)) + problems: tuple[ValidationProblem, ...] + def __init__(self, problems: Sequence[ValidationProblem]) -> None: + self.problems = tuple(problems) + super().__init__("\n".join(str(problem) for problem in self.problems)) -def _prefix(loc_head: str | int, problems: list[ValidationProblem]) -> list[ValidationProblem]: + +def _prefix( + loc_head: str | int, problems: Sequence[ValidationProblem] +) -> tuple[ValidationProblem, ...]: """Prepend `loc_head` to the `loc` of every problem (for nested validators).""" - return [ValidationProblem((loc_head, *p.loc), p.message, p.kind) for p in problems] + return tuple(ValidationProblem((loc_head, *p.loc), p.message, p.kind) for p in problems) -def validate_json(value: object) -> list[ValidationProblem]: +def validate_json(value: object) -> tuple[ValidationProblem, ...]: """Return every reason `value` is not JSON-serializable (recursively).""" if isinstance(value, float): if math.isfinite(value): - return [] - return [ValidationProblem((), f"non-finite float {value!r} is not JSON", "invalid_value")] + return () + return (ValidationProblem((), f"non-finite float {value!r} is not JSON", "invalid_value"),) if isinstance(value, (str, int, bool)) or value is None: - return [] + return () problems: list[ValidationProblem] = [] if isinstance(value, Mapping): for key, item in cast("Mapping[object, object]", value).items(): @@ -92,12 +98,12 @@ def validate_json(value: object) -> list[ValidationProblem]: ) continue problems.extend(_prefix(key, validate_json(item))) - return problems + return tuple(problems) if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)): for index, item in enumerate(cast("Sequence[object]", value)): problems.extend(_prefix(index, validate_json(item))) - return problems - return [ValidationProblem((), f"not a JSON-serializable value: {value!r}", "invalid_type")] + return tuple(problems) + return (ValidationProblem((), f"not a JSON-serializable value: {value!r}", "invalid_type"),) def _is_canonical_json(value: object) -> TypeIs[JSONValue]: @@ -126,7 +132,7 @@ def parse_json(value: object) -> JSONValue: """Return a canonical `JSONValue`, or raise `MetadataValidationError`.""" normalized = arrays_to_tuples(value) problems = validate_json(normalized) - if problems: + if len(problems) != 0: raise MetadataValidationError(problems) return cast(JSONValue, normalized) @@ -177,17 +183,19 @@ def parse_json(value: object) -> JSONValue: ) -def _missing_keys(required: frozenset[str], doc: Mapping[str, object]) -> list[ValidationProblem]: +def _missing_keys( + required: frozenset[str], doc: Mapping[str, object] +) -> tuple[ValidationProblem, ...]: """One `missing_key` problem per required key absent from `doc`.""" - return [ + return tuple( ValidationProblem((key,), "missing required key", "missing_key") for key in sorted(required - doc.keys()) - ] + ) def _unexpected_keys( allowed: frozenset[str], doc: Mapping[object, object] -) -> list[ValidationProblem]: +) -> tuple[ValidationProblem, ...]: """One problem per member outside a closed document's declared shape.""" problems: list[ValidationProblem] = [] for key in doc: @@ -199,18 +207,18 @@ def _unexpected_keys( problems.append( ValidationProblem((key,), "unexpected document member", "invalid_value") ) - return problems + return tuple(problems) def _check_literal( doc: Mapping[str, object], key: str, expected: object -) -> list[ValidationProblem]: +) -> tuple[ValidationProblem, ...]: """One `invalid_value` problem if `doc[key]` is present but not `expected`.""" if key in doc and (type(doc[key]) is not type(expected) or doc[key] != expected): - return [ - ValidationProblem((key,), f"expected {expected!r}, got {doc[key]!r}", "invalid_value") - ] - return [] + return ( + ValidationProblem((key,), f"expected {expected!r}, got {doc[key]!r}", "invalid_value"), + ) + return () def _validate_extension_fields_v3( @@ -218,7 +226,7 @@ def _validate_extension_fields_v3( standard_keys: frozenset[str], *, additional_reserved_keys: frozenset[str] = frozenset(), -) -> list[ValidationProblem]: +) -> tuple[ValidationProblem, ...]: """Validate v3 top-level key types and unknown-field JSON payloads.""" problems: list[ValidationProblem] = [] reserved_keys = standard_keys | additional_reserved_keys @@ -231,27 +239,27 @@ def _validate_extension_fields_v3( if key in reserved_keys: continue problems.extend(_prefix(key, validate_json(value))) - return problems + return tuple(problems) def validate_metadata_field_v3( value: object, *, allow_must_understand_false: bool = True -) -> list[ValidationProblem]: +) -> tuple[ValidationProblem, ...]: """Return every reason `value` is not a v3 metadata field. A metadata field is a bare name string or a mapping containing `name` and optional `configuration` and `must_understand` members. """ if isinstance(value, str): - return [] + return () if not isinstance(value, Mapping): - return [ + return ( ValidationProblem( (), "expected a metadata field (string or extension object)", "invalid_type", - ) - ] + ), + ) field = cast("Mapping[object, object]", value) problems: list[ValidationProblem] = [] allowed_keys = frozenset({"name", "configuration", "must_understand"}) @@ -293,7 +301,7 @@ def validate_metadata_field_v3( "invalid_value", ) ) - return problems + return tuple(problems) def is_metadata_field_v3(value: object) -> TypeIs[ZarrV3MetadataFieldJSON]: @@ -310,7 +318,7 @@ def parse_metadata_field_v3(value: object) -> ZarrV3MetadataFieldJSON: """Return `value` narrowed to `ZarrV3MetadataFieldJSON`, or raise `MetadataValidationError`.""" normalized = arrays_to_tuples(value) problems = validate_metadata_field_v3(normalized) - if problems: + if len(problems) != 0: raise MetadataValidationError(problems) return cast(ZarrV3MetadataFieldJSON, normalized) @@ -331,19 +339,19 @@ def _is_int_sequence(value: object) -> bool: ) -def _validate_dim_sequence(doc: Mapping[str, object], key: str) -> list[ValidationProblem]: +def _validate_dim_sequence(doc: Mapping[str, object], key: str) -> tuple[ValidationProblem, ...]: """Validate a dimension sequence (`shape` / `chunks`) if present in `doc`. Dimension lengths are non-negative integers. """ if key not in doc: - return [] + return () value = doc[key] if not _is_int_sequence(value): - return [ValidationProblem((key,), "expected a sequence of int", "invalid_type")] + return (ValidationProblem((key,), "expected a sequence of int", "invalid_type"),) if any(item < 0 for item in cast("Sequence[int]", value)): - return [ValidationProblem((key,), "expected non-negative integers", "invalid_value")] - return [] + return (ValidationProblem((key,), "expected non-negative integers", "invalid_value"),) + return () def _is_dtype_v2(value: object) -> bool: @@ -447,18 +455,18 @@ def _is_codec_v2(value: object) -> bool: ) -def _validate_codec_v2(value: object) -> list[ValidationProblem]: +def _validate_codec_v2(value: object) -> tuple[ValidationProblem, ...]: """Validate a v2 codec's required shape and JSON-valued configuration.""" if not _is_codec_v2(value): - return [ + return ( ValidationProblem( (), "expected a codec configuration with a string 'id'", "invalid_type" - ) - ] + ), + ) return validate_json(value) -def _validate_attributes(value: object) -> list[ValidationProblem]: +def _validate_attributes(value: object) -> tuple[ValidationProblem, ...]: """Validate an `attributes` value: a mapping with string keys. Returns a problem at `("attributes",)` if it is not, else `[]`. Shared by the @@ -470,27 +478,27 @@ def _validate_attributes(value: object) -> list[ValidationProblem]: if not isinstance(value, Mapping) or not all( isinstance(k, str) for k in cast("Mapping[object, object]", value) ): - return [ + return ( ValidationProblem( ("attributes",), "expected a mapping with string keys", "invalid_type" - ) - ] + ), + ) problems: list[ValidationProblem] = [] for key, item in cast("Mapping[str, object]", value).items(): problems.extend(_prefix("attributes", _prefix(key, validate_json(item)))) - return problems + return tuple(problems) -def validate_array_metadata_v3(value: object) -> list[ValidationProblem]: +def validate_array_metadata_v3(value: object) -> tuple[ValidationProblem, ...]: """Return every reason `value` is not a structurally-valid v3 array doc. Checks structure, not domain validity. Unknown top-level keys are allowed (they map to `extra_fields`). """ if not isinstance(value, Mapping): - return [ValidationProblem((), "expected a mapping", "invalid_type")] + return (ValidationProblem((), "expected a mapping", "invalid_type"),) doc = cast("Mapping[str, object]", value) - problems: list[ValidationProblem] = _missing_keys(ARRAY_METADATA_REQUIRED_KEYS_V3, doc) + problems: list[ValidationProblem] = list(_missing_keys(ARRAY_METADATA_REQUIRED_KEYS_V3, doc)) problems.extend( _validate_extension_fields_v3( cast("Mapping[object, object]", value), ARRAY_METADATA_STANDARD_KEYS_V3 @@ -552,7 +560,7 @@ def validate_array_metadata_v3(value: object) -> list[ValidationProblem]: "invalid_value", ) ) - return problems + return tuple(problems) def is_array_metadata_v3(value: object) -> TypeIs[ZarrV3ArrayMetadataJSON]: @@ -568,12 +576,12 @@ def parse_array_metadata_v3(value: object) -> ZarrV3ArrayMetadataJSON: """Return `value` as `ZarrV3ArrayMetadataJSON`, or raise `MetadataValidationError`.""" normalized = arrays_to_tuples(value) problems = validate_array_metadata_v3(normalized) - if problems: + if len(problems) != 0: raise MetadataValidationError(problems) return cast("ZarrV3ArrayMetadataJSON", normalized) -def validate_array_metadata_v2(value: object) -> list[ValidationProblem]: +def validate_array_metadata_v2(value: object) -> tuple[ValidationProblem, ...]: """Return every reason `value` is not a structurally-valid v2 array doc. Checks structure, not domain validity: `dtype` must be a string or field @@ -582,22 +590,22 @@ def validate_array_metadata_v2(value: object) -> list[ValidationProblem]: codec configurations (mappings with a string `id`). """ if not isinstance(value, Mapping): - return [ValidationProblem((), "expected a mapping", "invalid_type")] + return (ValidationProblem((), "expected a mapping", "invalid_type"),) doc = cast("Mapping[str, object]", value) # Unlike the group document ("Other keys MUST NOT be present", # https://github.com/zarr-developers/zarr-specs/blob/fc7dd9c9beb5a50b87f9b08b00bf50fc0048482f/docs/v2/v2.0.rst#L313), the v2 array document is open: other keys "SHOULD NOT be # present within the metadata object and SHOULD be ignored by # implementations" (https://github.com/zarr-developers/zarr-specs/blob/fc7dd9c9beb5a50b87f9b08b00bf50fc0048482f/docs/v2/v2.0.rst#L91-L92), so members outside # ARRAY_METADATA_STANDARD_KEYS_V2 are not problems. - problems: list[ValidationProblem] = _missing_keys(ARRAY_METADATA_REQUIRED_KEYS_V2, doc) + problems: list[ValidationProblem] = list(_missing_keys(ARRAY_METADATA_REQUIRED_KEYS_V2, doc)) problems.extend(_check_literal(doc, "zarr_format", 2)) shape_problems = _validate_dim_sequence(doc, "shape") chunks_problems = _validate_dim_sequence(doc, "chunks") problems.extend(shape_problems) problems.extend(chunks_problems) if ( - not shape_problems - and not chunks_problems + len(shape_problems) == 0 + and len(chunks_problems) == 0 and _is_int_sequence(doc.get("shape")) and _is_int_sequence(doc.get("chunks")) ): @@ -660,7 +668,7 @@ def validate_array_metadata_v2(value: object) -> list[ValidationProblem]: problems.extend(_prefix("fill_value", validate_json(doc["fill_value"]))) if "attributes" in doc: problems.extend(_validate_attributes(doc["attributes"])) - return problems + return tuple(problems) def is_array_metadata_v2(value: object) -> TypeIs[ZarrV2ArrayMetadataJSON]: @@ -676,12 +684,12 @@ def parse_array_metadata_v2(value: object) -> ZarrV2ArrayMetadataJSON: """Return `value` as `ZarrV2ArrayMetadataJSON`, or raise `MetadataValidationError`.""" normalized = arrays_to_tuples(value) problems = validate_array_metadata_v2(normalized) - if problems: + if len(problems) != 0: raise MetadataValidationError(problems) return cast("ZarrV2ArrayMetadataJSON", normalized) -def validate_consolidated_metadata_v3(value: object) -> list[ValidationProblem]: +def validate_consolidated_metadata_v3(value: object) -> tuple[ValidationProblem, ...]: """Return every reason `value` is not a valid inline consolidated envelope. Locs are value-relative (the caller prefixes with `consolidated_metadata` @@ -690,7 +698,7 @@ def validate_consolidated_metadata_v3(value: object) -> list[ValidationProblem]: `ZarrV3ConsolidatedMetadata.from_json` accepts. """ if not isinstance(value, Mapping): - return [ValidationProblem((), "expected a mapping", "invalid_type")] + return (ValidationProblem((), "expected a mapping", "invalid_type"),) env = cast("Mapping[str, object]", value) problems: list[ValidationProblem] = [ ValidationProblem((key,), "missing required key", "missing_key") @@ -737,10 +745,10 @@ def validate_consolidated_metadata_v3(value: object) -> list[ValidationProblem]: "invalid_value", ) ) - return problems + return tuple(problems) -def validate_group_metadata_v3(value: object) -> list[ValidationProblem]: +def validate_group_metadata_v3(value: object) -> tuple[ValidationProblem, ...]: """Return every reason `value` is not a structurally-valid v3 group doc. Checks structure, not domain validity. Unknown top-level keys are allowed @@ -749,9 +757,9 @@ def validate_group_metadata_v3(value: object) -> list[ValidationProblem]: `validate_consolidated_metadata_v3`. """ if not isinstance(value, Mapping): - return [ValidationProblem((), "expected a mapping", "invalid_type")] + return (ValidationProblem((), "expected a mapping", "invalid_type"),) doc = cast("Mapping[str, object]", value) - problems: list[ValidationProblem] = _missing_keys(GROUP_METADATA_REQUIRED_KEYS_V3, doc) + problems: list[ValidationProblem] = list(_missing_keys(GROUP_METADATA_REQUIRED_KEYS_V3, doc)) problems.extend( _validate_extension_fields_v3( cast("Mapping[object, object]", value), @@ -773,7 +781,7 @@ def validate_group_metadata_v3(value: object) -> list[ValidationProblem]: validate_consolidated_metadata_v3(doc["consolidated_metadata"]), ) ) - return problems + return tuple(problems) def is_group_metadata_v3(value: object) -> TypeIs[ZarrV3GroupMetadataJSON]: @@ -785,28 +793,28 @@ def parse_group_metadata_v3(value: object) -> ZarrV3GroupMetadataJSON: """Return `value` narrowed to `ZarrV3GroupMetadataJSON`, or raise `MetadataValidationError`.""" normalized = arrays_to_tuples(value) problems = validate_group_metadata_v3(normalized) - if problems: + if len(problems) != 0: raise MetadataValidationError(problems) return cast(ZarrV3GroupMetadataJSON, normalized) -def validate_group_metadata_v2(value: object) -> list[ValidationProblem]: +def validate_group_metadata_v2(value: object) -> tuple[ValidationProblem, ...]: """Return every reason `value` is not a structurally-valid v2 group doc. Validates the in-memory merged form: the `.zgroup` fields plus an optional `attributes` mapping folded in from `.zattrs`. """ if not isinstance(value, Mapping): - return [ValidationProblem((), "expected a mapping", "invalid_type")] + return (ValidationProblem((), "expected a mapping", "invalid_type"),) doc = cast("Mapping[str, object]", value) - problems: list[ValidationProblem] = _missing_keys(GROUP_METADATA_REQUIRED_KEYS_V2, doc) + problems: list[ValidationProblem] = list(_missing_keys(GROUP_METADATA_REQUIRED_KEYS_V2, doc)) problems.extend( _unexpected_keys(GROUP_METADATA_STANDARD_KEYS_V2, cast("Mapping[object, object]", value)) ) problems.extend(_check_literal(doc, "zarr_format", 2)) if "attributes" in doc: problems.extend(_validate_attributes(doc["attributes"])) - return problems + return tuple(problems) def is_group_metadata_v2(value: object) -> TypeIs[ZarrV2GroupMetadataJSON]: @@ -818,7 +826,7 @@ def parse_group_metadata_v2(value: object) -> ZarrV2GroupMetadataJSON: """Return `value` narrowed to `ZarrV2GroupMetadataJSON`, or raise `MetadataValidationError`.""" normalized = arrays_to_tuples(value) problems = validate_group_metadata_v2(normalized) - if problems: + if len(problems) != 0: raise MetadataValidationError(problems) return cast(ZarrV2GroupMetadataJSON, normalized) @@ -828,9 +836,13 @@ def _reject_json_constant(constant: str) -> NoReturn: raise ValueError(f"non-standard JSON constant {constant!r}") -def load_store_json(mapping: Mapping[str, bytes], key: str) -> Any: +def load_store_json(mapping: Mapping[str, bytes], key: str) -> object: """Decode the JSON document stored at `key` in `mapping`. + Returns `object`, not `Any`: what a store holds is unknown until a + validator says otherwise, and `Any` would let unchecked values flow + into typed positions silently. Narrow the result with a `parse_*`. + Every ingestion failure surfaces as `MetadataValidationError`: a missing store key is a `missing_key` problem and undecodable bytes are an `invalid_json` problem, rather than leaking `KeyError` / diff --git a/packages/zarr-metadata/src/zarr_metadata/pydantic.py b/packages/zarr-metadata/src/zarr_metadata/pydantic.py index 8584efa570..5b0c9e5b57 100644 --- a/packages/zarr-metadata/src/zarr_metadata/pydantic.py +++ b/packages/zarr-metadata/src/zarr_metadata/pydantic.py @@ -54,17 +54,6 @@ class ArrayManifest(BaseModel): from zarr_metadata._pydantic_schema import ( ZarrV3MetadataFieldJSON as _ZarrV3MetadataFieldSchema, ) -from zarr_metadata.v2.array import ZarrV2ArrayMetadataJSON as _ZarrV2ArrayMetadataJSON -from zarr_metadata.v2.consolidated import ( - ZarrV2ConsolidatedMetadataJSON as _ZarrV2ConsolidatedMetadataJSON, -) -from zarr_metadata.v2.group import ZarrV2GroupMetadataJSON as _ZarrV2GroupMetadataJSON -from zarr_metadata.v3._common import ZarrV3MetadataFieldJSON as _ZarrV3MetadataFieldJSON -from zarr_metadata.v3.array import ZarrV3ArrayMetadataJSON as _ZarrV3ArrayMetadataJSON -from zarr_metadata.v3.consolidated import ( - ZarrV3ConsolidatedMetadataJSON as _ZarrV3ConsolidatedMetadataJSON, -) -from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON as _ZarrV3GroupMetadataJSON if TYPE_CHECKING: from collections.abc import Callable @@ -89,7 +78,7 @@ def coerce(value: object) -> _M: _coerce_to(_model.ZarrV3ArrayMetadata, _model.ZarrV3ArrayMetadata.from_json), json_schema_input_type=_ZarrV3ArrayMetadataSchema, ), - PlainSerializer(_model.ZarrV3ArrayMetadata.to_json, return_type=_ZarrV3ArrayMetadataJSON), + PlainSerializer(_model.ZarrV3ArrayMetadata.to_json, return_type=_ZarrV3ArrayMetadataSchema), ] """Field type for a v3 array metadata document (`zarr.json` content).""" @@ -99,7 +88,7 @@ def coerce(value: object) -> _M: _coerce_to(_model.ZarrV2ArrayMetadata, _model.ZarrV2ArrayMetadata.from_json), json_schema_input_type=_ZarrV2ArrayMetadataSchema, ), - PlainSerializer(_model.ZarrV2ArrayMetadata.to_json, return_type=_ZarrV2ArrayMetadataJSON), + PlainSerializer(_model.ZarrV2ArrayMetadata.to_json, return_type=_ZarrV2ArrayMetadataSchema), ] """Field type for a v2 array metadata document (merged `.zarray` + `.zattrs` form).""" @@ -109,7 +98,7 @@ def coerce(value: object) -> _M: _coerce_to(_model.ZarrV3GroupMetadata, _model.ZarrV3GroupMetadata.from_json), json_schema_input_type=_ZarrV3GroupMetadataSchema, ), - PlainSerializer(_model.ZarrV3GroupMetadata.to_json, return_type=_ZarrV3GroupMetadataJSON), + PlainSerializer(_model.ZarrV3GroupMetadata.to_json, return_type=_ZarrV3GroupMetadataSchema), ] """Field type for a v3 group metadata document (`zarr.json` content).""" @@ -119,7 +108,7 @@ def coerce(value: object) -> _M: _coerce_to(_model.ZarrV2GroupMetadata, _model.ZarrV2GroupMetadata.from_json), json_schema_input_type=_ZarrV2GroupMetadataSchema, ), - PlainSerializer(_model.ZarrV2GroupMetadata.to_json, return_type=_ZarrV2GroupMetadataJSON), + PlainSerializer(_model.ZarrV2GroupMetadata.to_json, return_type=_ZarrV2GroupMetadataSchema), ] """Field type for a v2 group metadata document (merged `.zgroup` + `.zattrs` form).""" @@ -134,7 +123,7 @@ def coerce(value: object) -> _M: ), PlainSerializer( _model.ZarrV3ConsolidatedMetadata.to_json, - return_type=_ZarrV3ConsolidatedMetadataJSON, + return_type=_ZarrV3ConsolidatedMetadataSchema, ), ] """Field type for v3 inline consolidated metadata.""" @@ -150,7 +139,7 @@ def coerce(value: object) -> _M: ), PlainSerializer( _model.ZarrV2ConsolidatedMetadata.to_json, - return_type=_ZarrV2ConsolidatedMetadataJSON, + return_type=_ZarrV2ConsolidatedMetadataSchema, ), ] """Field type for a v2 `.zmetadata` document.""" @@ -161,7 +150,7 @@ def coerce(value: object) -> _M: _coerce_to(_model.ZarrV3NamedConfig, _model.ZarrV3NamedConfig.from_json), json_schema_input_type=_ZarrV3MetadataFieldSchema, ), - PlainSerializer(_model.ZarrV3NamedConfig.to_json, return_type=_ZarrV3MetadataFieldJSON), + PlainSerializer(_model.ZarrV3NamedConfig.to_json, return_type=_ZarrV3MetadataFieldSchema), ] """Field type for one normalized v3 metadata extension envelope.""" diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py b/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py index 999c9131da..a6b8e4a58e 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py +++ b/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py @@ -34,7 +34,7 @@ class ZarrV2ConsolidatedMetadataJSON(TypedDict): `key.endswith(".zarray")` etc. """ - zarr_consolidated_format: int + zarr_consolidated_format: Literal[1] metadata: Mapping[str, ZarrV2ZArrayJSON | ZarrV2ZGroupJSON | ZarrV2ZAttrsJSON] diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/rectilinear.py b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/rectilinear.py index 480f5538fa..78c38b702a 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/rectilinear.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/rectilinear.py @@ -4,7 +4,7 @@ See https://github.com/zarr-developers/zarr-extensions/blob/4da7b37a84f76e660902f6d3de3eaef0e0febae6/chunk-grids/rectilinear/README.md """ -from typing import Final, Literal +from typing import Final, Literal, NotRequired from typing_extensions import TypedDict @@ -23,18 +23,19 @@ """ -class RectilinearChunkGridConfiguration(TypedDict): +class RectilinearChunkGridConfiguration(TypedDict, closed=True): """Configuration for the rectilinear chunk grid.""" kind: Literal["inline"] chunk_shapes: tuple[RectilinearDimSpec, ...] -class RectilinearChunkGridObject(TypedDict): +class RectilinearChunkGridObject(TypedDict, closed=True): """Rectilinear chunk grid metadata in object form.""" name: RectilinearChunkGridName configuration: RectilinearChunkGridConfiguration + must_understand: NotRequired[bool] RectilinearChunkGridMetadata = RectilinearChunkGridObject diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/regular.py b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/regular.py index a0b33688c8..bdcd9e06c5 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/regular.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/regular.py @@ -4,7 +4,7 @@ See https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html#regular-grids """ -from typing import Final, Literal +from typing import Final, Literal, NotRequired from typing_extensions import TypedDict @@ -15,17 +15,18 @@ """Literal type of the `name` field of the regular chunk grid.""" -class RegularChunkGridConfiguration(TypedDict): +class RegularChunkGridConfiguration(TypedDict, closed=True): """Configuration for the regular chunk grid.""" chunk_shape: tuple[int, ...] -class RegularChunkGridObject(TypedDict): +class RegularChunkGridObject(TypedDict, closed=True): """Regular chunk grid metadata in object form.""" name: RegularChunkGridName configuration: RegularChunkGridConfiguration + must_understand: NotRequired[bool] RegularChunkGridMetadata = RegularChunkGridObject diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/default.py b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/default.py index 39c5385ce4..10a7c0cb55 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/default.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/default.py @@ -27,7 +27,7 @@ """Tuple of permitted values for the `separator` field of the default chunk key encoding.""" -class DefaultChunkKeyEncodingConfiguration(TypedDict): +class DefaultChunkKeyEncodingConfiguration(TypedDict, closed=True): """Configuration for the default chunk key encoding. `separator` is optional and defaults to `"/"` per spec. @@ -37,11 +37,12 @@ class DefaultChunkKeyEncodingConfiguration(TypedDict): separator: NotRequired[DefaultChunkKeyEncodingSeparator] -class DefaultChunkKeyEncodingObject(TypedDict): +class DefaultChunkKeyEncodingObject(TypedDict, closed=True): """Default chunk key encoding metadata in object form.""" name: DefaultChunkKeyEncodingName configuration: NotRequired[DefaultChunkKeyEncodingConfiguration] + must_understand: NotRequired[bool] DefaultChunkKeyEncodingMetadata = DefaultChunkKeyEncodingObject | DefaultChunkKeyEncodingName diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/v2.py b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/v2.py index 3face04f91..63cdf26783 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/v2.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/v2.py @@ -33,7 +33,7 @@ """Tuple of permitted values for the `separator` field of the v2 chunk key encoding.""" -class V2ChunkKeyEncodingConfiguration(TypedDict): +class V2ChunkKeyEncodingConfiguration(TypedDict, closed=True): """Configuration for the v2 chunk key encoding. `separator` is optional and defaults to `"."` per spec. @@ -43,11 +43,12 @@ class V2ChunkKeyEncodingConfiguration(TypedDict): separator: NotRequired[V2ChunkKeyEncodingSeparator] -class V2ChunkKeyEncodingObject(TypedDict): +class V2ChunkKeyEncodingObject(TypedDict, closed=True): """v2-compatibility chunk key encoding metadata in object form.""" name: V2ChunkKeyEncodingName configuration: NotRequired[V2ChunkKeyEncodingConfiguration] + must_understand: NotRequired[bool] V2ChunkKeyEncodingMetadata = V2ChunkKeyEncodingObject | V2ChunkKeyEncodingName diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/blosc.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/blosc.py index 4c3631a032..3387a7c285 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/codec/blosc.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/blosc.py @@ -27,7 +27,7 @@ """Tuple of permitted values for the `cname` field of the `blosc` codec.""" -class BloscCodecConfiguration(TypedDict): +class BloscCodecConfiguration(TypedDict, closed=True): """Configuration for the Zarr v3 `blosc` codec.""" cname: BloscCName @@ -37,11 +37,12 @@ class BloscCodecConfiguration(TypedDict): typesize: NotRequired[int] -class BloscCodecObject(TypedDict): +class BloscCodecObject(TypedDict, closed=True): """`blosc` codec metadata in object form.""" name: BloscCodecName configuration: BloscCodecConfiguration + must_understand: NotRequired[bool] BloscCodecMetadata = BloscCodecObject diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/bytes.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/bytes.py index 43b165f9e6..4feb6b8c1c 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/codec/bytes.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/bytes.py @@ -21,7 +21,7 @@ """Tuple of permitted values for the `endian` field of the `bytes` codec.""" -class BytesCodecConfiguration(TypedDict): +class BytesCodecConfiguration(TypedDict, closed=True): """ Configuration for the Zarr v3 `bytes` codec. @@ -31,7 +31,7 @@ class BytesCodecConfiguration(TypedDict): endian: NotRequired[Endianness] -class BytesCodecObject(TypedDict): +class BytesCodecObject(TypedDict, closed=True): """`bytes` codec metadata in object form. `configuration` is itself optional — when no configuration fields are @@ -42,6 +42,7 @@ class BytesCodecObject(TypedDict): name: BytesCodecName configuration: NotRequired[BytesCodecConfiguration] + must_understand: NotRequired[bool] BytesCodecMetadata = BytesCodecObject | BytesCodecName diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/cast_value.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/cast_value.py index d1b878c95c..656a509ed7 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/codec/cast_value.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/cast_value.py @@ -55,14 +55,14 @@ """ -class ScalarMap(TypedDict): +class ScalarMap(TypedDict, closed=True): """Optional encode/decode scalar overrides for the cast_value codec.""" encode: NotRequired[tuple[ScalarMapEntry, ...]] decode: NotRequired[tuple[ScalarMapEntry, ...]] -class CastValueCodecConfiguration(TypedDict): +class CastValueCodecConfiguration(TypedDict, closed=True): """ Configuration for the Zarr v3 `cast_value` codec. @@ -77,11 +77,12 @@ class CastValueCodecConfiguration(TypedDict): scalar_map: NotRequired[ScalarMap] -class CastValueCodecObject(TypedDict): +class CastValueCodecObject(TypedDict, closed=True): """`cast_value` codec metadata in object form.""" name: CastValueCodecName configuration: CastValueCodecConfiguration + must_understand: NotRequired[bool] CastValueCodecMetadata = CastValueCodecObject diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/crc32c.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/crc32c.py index aa72fcae5a..05661d0b59 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/codec/crc32c.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/crc32c.py @@ -22,7 +22,7 @@ class Empty(TypedDict, closed=True): """An empty mapping""" -class Crc32cCodecObject(TypedDict): +class Crc32cCodecObject(TypedDict, closed=True): """`crc32c` codec metadata in object form. Per spec the codec has no configuration fields. `configuration` is @@ -32,6 +32,7 @@ class Crc32cCodecObject(TypedDict): name: Crc32cCodecName configuration: NotRequired[Empty] + must_understand: NotRequired[bool] Crc32cCodecMetadata = Crc32cCodecObject | Crc32cCodecName diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/gzip.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/gzip.py index 9a9647263c..d516088f99 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/codec/gzip.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/gzip.py @@ -4,7 +4,7 @@ See https://zarr-specs.readthedocs.io/en/latest/v3/codecs/gzip/index.html """ -from typing import Final, Literal +from typing import Final, Literal, NotRequired from typing_extensions import TypedDict @@ -15,7 +15,7 @@ """Literal type of the `name` field of the `gzip` codec.""" -class GzipCodecConfiguration(TypedDict): +class GzipCodecConfiguration(TypedDict, closed=True): """ Configuration for the Zarr v3 `gzip` codec. @@ -32,11 +32,12 @@ class GzipCodecConfiguration(TypedDict): level: int -class GzipCodecObject(TypedDict): +class GzipCodecObject(TypedDict, closed=True): """`gzip` codec metadata in object form.""" name: GzipCodecName configuration: GzipCodecConfiguration + must_understand: NotRequired[bool] GzipCodecMetadata = GzipCodecObject diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/scale_offset.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/scale_offset.py index abf11b4211..344a4c435c 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/codec/scale_offset.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/scale_offset.py @@ -17,7 +17,7 @@ """Literal type of the `name` field of the `scale_offset` codec.""" -class ScaleOffsetCodecConfiguration(TypedDict): +class ScaleOffsetCodecConfiguration(TypedDict, closed=True): """ Configuration for the Zarr v3 `scale_offset` codec. @@ -32,7 +32,7 @@ class ScaleOffsetCodecConfiguration(TypedDict): scale: NotRequired[JSONValue] -class ScaleOffsetCodecObject(TypedDict): +class ScaleOffsetCodecObject(TypedDict, closed=True): """`scale_offset` codec metadata in object form. `configuration` is itself optional per spec — when both `offset` and @@ -43,6 +43,7 @@ class ScaleOffsetCodecObject(TypedDict): name: ScaleOffsetCodecName configuration: NotRequired[ScaleOffsetCodecConfiguration] + must_understand: NotRequired[bool] ScaleOffsetCodecMetadata = ScaleOffsetCodecObject | ScaleOffsetCodecName diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/sharding_indexed.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/sharding_indexed.py index ac40e78b7b..0ce466b5cf 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/codec/sharding_indexed.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/sharding_indexed.py @@ -23,7 +23,7 @@ """Tuple of permitted values for the `index_location` field of the `sharding_indexed` codec.""" -class ShardingIndexedCodecConfiguration(TypedDict): +class ShardingIndexedCodecConfiguration(TypedDict, closed=True): """ Configuration for the Zarr v3 `sharding_indexed` codec. @@ -47,11 +47,12 @@ class ShardingIndexedCodecConfiguration(TypedDict): index_location: NotRequired[ShardingIndexLocation] -class ShardingIndexedCodecObject(TypedDict): +class ShardingIndexedCodecObject(TypedDict, closed=True): """`sharding_indexed` codec metadata in object form.""" name: ShardingIndexedCodecName configuration: ShardingIndexedCodecConfiguration + must_understand: NotRequired[bool] ShardingIndexedCodecMetadata = ShardingIndexedCodecObject diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/transpose.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/transpose.py index 41d9bcdc6d..8024174605 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/codec/transpose.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/transpose.py @@ -4,7 +4,7 @@ See https://zarr-specs.readthedocs.io/en/latest/v3/codecs/transpose/index.html """ -from typing import Final, Literal +from typing import Final, Literal, NotRequired from typing_extensions import TypedDict @@ -15,7 +15,7 @@ """Literal type of the `name` field of the `transpose` codec.""" -class TransposeCodecConfiguration(TypedDict): +class TransposeCodecConfiguration(TypedDict, closed=True): """ Configuration for the Zarr v3 `transpose` codec. @@ -26,11 +26,12 @@ class TransposeCodecConfiguration(TypedDict): order: tuple[int, ...] -class TransposeCodecObject(TypedDict): +class TransposeCodecObject(TypedDict, closed=True): """`transpose` codec metadata in object form.""" name: TransposeCodecName configuration: TransposeCodecConfiguration + must_understand: NotRequired[bool] TransposeCodecMetadata = TransposeCodecObject diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/zstd.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/zstd.py index 9fdf177b4c..b7ee3f5685 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/codec/zstd.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/zstd.py @@ -17,7 +17,7 @@ """Literal type of the `name` field of the `zstd` codec.""" -class ZstdCodecConfiguration(TypedDict): +class ZstdCodecConfiguration(TypedDict, closed=True): """ Configuration for the Zarr v3 `zstd` codec. @@ -30,11 +30,12 @@ class ZstdCodecConfiguration(TypedDict): checksum: NotRequired[bool] -class ZstdCodecObject(TypedDict): +class ZstdCodecObject(TypedDict, closed=True): """`zstd` codec metadata in object form.""" name: ZstdCodecName configuration: ZstdCodecConfiguration + must_understand: NotRequired[bool] ZstdCodecMetadata = ZstdCodecObject diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py b/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py index ba7c9aec0d..274af507b7 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py @@ -20,7 +20,7 @@ from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON -class ZarrV3ConsolidatedMetadataJSON(TypedDict): +class ZarrV3ConsolidatedMetadataJSON(TypedDict, closed=True): """ Inline consolidated metadata embedded in a v3 group. diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_datetime64.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_datetime64.py index bed264e96e..4f9a6415c5 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_datetime64.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_datetime64.py @@ -4,7 +4,7 @@ See https://github.com/zarr-developers/zarr-extensions/blob/4da7b37a84f76e660902f6d3de3eaef0e0febae6/data-types/numpy.datetime64/README.md """ -from typing import Final, Literal +from typing import Final, Literal, NotRequired from typing_extensions import ReadOnly, TypedDict @@ -20,7 +20,7 @@ """Time unit codes used by numpy.datetime64.""" -class NumpyDatetime64Configuration(TypedDict): +class NumpyDatetime64Configuration(TypedDict, closed=True): """ Configuration for the `numpy.datetime64` data type. @@ -36,11 +36,12 @@ class NumpyDatetime64Configuration(TypedDict): scale_factor: ReadOnly[int] -class NumpyDatetime64(TypedDict): +class NumpyDatetime64(TypedDict, closed=True): """`numpy.datetime64` data type metadata.""" name: NumpyDatetime64DataTypeName configuration: NumpyDatetime64Configuration + must_understand: NotRequired[bool] NumpyDatetime64FillValue = int | Literal["NaT"] diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_timedelta64.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_timedelta64.py index ceb31d8c40..a1c4fef772 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_timedelta64.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_timedelta64.py @@ -4,7 +4,7 @@ See https://github.com/zarr-developers/zarr-extensions/blob/4da7b37a84f76e660902f6d3de3eaef0e0febae6/data-types/numpy.timedelta64/README.md """ -from typing import Final, Literal +from typing import Final, Literal, NotRequired from typing_extensions import ReadOnly, TypedDict @@ -39,7 +39,7 @@ """Runtime tuple of the permitted `numpy.timedelta64`/`numpy.datetime64` unit strings.""" -class NumpyTimedelta64Configuration(TypedDict): +class NumpyTimedelta64Configuration(TypedDict, closed=True): """ Configuration for the `numpy.timedelta64` data type. @@ -55,11 +55,12 @@ class NumpyTimedelta64Configuration(TypedDict): scale_factor: ReadOnly[int] -class NumpyTimedelta64(TypedDict): +class NumpyTimedelta64(TypedDict, closed=True): """`numpy.timedelta64` data type metadata.""" name: NumpyTimedelta64DataTypeName configuration: NumpyTimedelta64Configuration + must_understand: NotRequired[bool] NumpyTimedelta64FillValue = int | Literal["NaT"] diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/struct.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/struct.py index 5795c927f5..87a437951d 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/struct.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/struct.py @@ -5,7 +5,7 @@ """ from collections.abc import Mapping -from typing import Final, Literal +from typing import Final, Literal, NotRequired from typing_extensions import ReadOnly, TypedDict @@ -19,7 +19,7 @@ """Literal type of the `name` field of the `struct` data type.""" -class StructField(TypedDict): +class StructField(TypedDict, closed=True): """ A single field entry inside a structured dtype. @@ -36,17 +36,18 @@ class StructField(TypedDict): data_type: ReadOnly[ZarrV3MetadataFieldJSON] -class StructConfiguration(TypedDict): +class StructConfiguration(TypedDict, closed=True): """Configuration for the `struct` data type.""" fields: ReadOnly[tuple[StructField, ...]] -class Struct(TypedDict): +class Struct(TypedDict, closed=True): """`struct` data type metadata.""" name: StructDataTypeName configuration: StructConfiguration + must_understand: NotRequired[bool] StructFillValue = Mapping[str, JSONValue] diff --git a/packages/zarr-metadata/tests/model/test_array.py b/packages/zarr-metadata/tests/model/test_array.py index c30f457618..e3668fa205 100644 --- a/packages/zarr-metadata/tests/model/test_array.py +++ b/packages/zarr-metadata/tests/model/test_array.py @@ -385,7 +385,7 @@ def test_v3_create_default_is_valid_empty_array() -> None: assert m.attributes == {} assert m.extra_fields == {} # the default document is structurally valid and round-trips - assert validate_array_metadata_v3(m.to_json()) == [] + assert validate_array_metadata_v3(m.to_json()) == () assert ZarrV3ArrayMetadata.from_json(m.to_json()) == m @@ -407,7 +407,7 @@ def test_v2_create_default_is_valid_empty_array() -> None: assert m.compressor is None assert m.filters is None assert m.attributes is UNSET - assert validate_array_metadata_v2(m.to_json()) == [] + assert validate_array_metadata_v2(m.to_json()) == () assert ZarrV2ArrayMetadata.from_json(m.to_json()) == m @@ -760,7 +760,7 @@ def test_v3_parser_accepts_bare_string_data_type() -> None: @pytest.mark.parametrize("name", ["bytes", "ANY string", "urn:example:codec"]) def test_metadata_field_accepts_any_string_name(name: str) -> None: """The structural layer checks the name type, not syntax or registration.""" - assert validate_metadata_field_v3({"name": name}) == [] + assert validate_metadata_field_v3({"name": name}) == () @pytest.mark.parametrize("value", [0, 1, "false", None]) @@ -783,7 +783,7 @@ def test_optional_extension_points_allow_must_understand_false(field: str) -> No """Codecs and storage transformers may be explicitly ignorable.""" doc: dict[str, object] = dict(ZarrV3ArrayMetadata.create_default().to_json()) doc[field] = ({"name": "optional", "must_understand": False},) - assert validate_array_metadata_v3(doc) == [] + assert validate_array_metadata_v3(doc) == () @pytest.mark.parametrize("field", ["data_type", "chunk_grid", "chunk_key_encoding"]) @@ -936,7 +936,7 @@ def test_is_json(case: Expect[object, frozenset[tuple[str | int, ...]]]) -> None def test_validate_json(case: Expect[object, frozenset[tuple[str | int, ...]]]) -> None: """validate_json reports the problems (and their locs) for a value.""" problems = validate_json(case.input) - assert (problems == []) is (case.output == frozenset()) + assert (problems == ()) is (case.output == frozenset()) assert {p.loc for p in problems} >= case.output @@ -1027,7 +1027,7 @@ def test_validate_metadata_field_v3( ) -> None: """validate_metadata_field_v3 reports the problems for a metadata-field value.""" problems = validate_metadata_field_v3(case.input) - assert (problems == []) is (case.output == frozenset()) + assert (problems == ()) is (case.output == frozenset()) assert {p.loc for p in problems} >= case.output @@ -1178,7 +1178,7 @@ def test_array_metadata_guards( valid = case.output == frozenset() assert is_fn(doc) is valid problems = validate_fn(doc) - assert (problems == []) is valid + assert (problems == ()) is valid assert {p.loc for p in problems} >= case.output if valid: assert parse_fn(doc) is doc @@ -1258,7 +1258,7 @@ def test_metadata_validation_error_holds_problems() -> None: ), ] err = MetadataValidationError(problems) - assert err.problems == problems + assert err.problems == tuple(problems) assert "shape: missing required key" in str(err) assert "data_type: expected a metadata field" in str(err) @@ -1267,9 +1267,9 @@ def test_prefix_prepends_loc_head() -> None: """_prefix prepends a loc head to each problem's loc.""" problems = [ValidationProblem(loc=("name",), message="expected str", kind="invalid_type")] prefixed = _prefix(0, problems) - assert prefixed == [ - ValidationProblem(loc=(0, "name"), message="expected str", kind="invalid_type") - ] + assert prefixed == ( + ValidationProblem(loc=(0, "name"), message="expected str", kind="invalid_type"), + ) # --- Stricter v2/v3 field validation and error kinds ------------------------- @@ -1286,7 +1286,7 @@ def test_v2_structured_dtype_records_accepted() -> None: """A structured v2 dtype (field records, optionally nested/shaped) validates.""" dtype = (("a", " None: @@ -1444,9 +1444,9 @@ def test_missing_key_kind_is_machine_readable() -> None: doc = dict(ZarrV3ArrayMetadata.create_default().to_json()) del doc["chunk_key_encoding"] problems = validate_array_metadata_v3(doc) - assert problems == [ - ValidationProblem(("chunk_key_encoding",), "missing required key", "missing_key") - ] + assert problems == ( + ValidationProblem(("chunk_key_encoding",), "missing required key", "missing_key"), + ) # --- Unified error channels --------------------------------------------------- @@ -1479,9 +1479,9 @@ def test_from_key_value_missing_key_kind() -> None: """A missing store key surfaces as a missing_key problem at the store-key loc.""" with pytest.raises(MetadataValidationError) as exc_info: ZarrV2ArrayMetadata.from_key_value({}) - assert exc_info.value.problems == [ - ValidationProblem((".zarray",), "missing store key", "missing_key") - ] + assert exc_info.value.problems == ( + ValidationProblem((".zarray",), "missing store key", "missing_key"), + ) def test_extra_fields_overlap_raises_metadata_error() -> None: @@ -1616,8 +1616,8 @@ def test_array_parsers_normalize_json_lists_before_narrowing() -> None: v3_raw = json.loads(json.dumps(ZarrV3ArrayMetadata.create_default(shape=(2,)).to_json())) v2_raw = json.loads(json.dumps(ZarrV2ArrayMetadata.create_default(shape=(2,)).to_json())) - assert validate_array_metadata_v3(v3_raw) == [] - assert validate_array_metadata_v2(v2_raw) == [] + assert validate_array_metadata_v3(v3_raw) == () + assert validate_array_metadata_v2(v2_raw) == () assert not is_array_metadata_v3(v3_raw) assert not is_array_metadata_v2(v2_raw) diff --git a/packages/zarr-metadata/tests/model/test_group.py b/packages/zarr-metadata/tests/model/test_group.py index d280091ba6..b0e451b0a0 100644 --- a/packages/zarr-metadata/tests/model/test_group.py +++ b/packages/zarr-metadata/tests/model/test_group.py @@ -474,7 +474,7 @@ def test_group_v3_valid_consolidated_passes_validator() -> None: "metadata": {"a": child, "g": {"zarr_format": 3, "node_type": "group"}}, }, } - assert validate_group_metadata_v3(doc) == [] + assert validate_group_metadata_v3(doc) == () def test_v3_consolidated_rejects_unknown_envelope_member() -> None: @@ -522,7 +522,7 @@ def test_group_v3_null_consolidated_metadata_repaired_to_absence() -> None: it is read as absence (UNSET) and never written back — the round-trip deliberately repairs the document rather than preserving the bug.""" null_doc = {"zarr_format": 3, "node_type": "group", "consolidated_metadata": None} - assert validate_group_metadata_v3(null_doc) == [] + assert validate_group_metadata_v3(null_doc) == () model = ZarrV3GroupMetadata.from_json(null_doc) assert model.consolidated_metadata is UNSET assert "consolidated_metadata" not in model.to_json() diff --git a/packages/zarr-metadata/tests/test_partial_equivalence.py b/packages/zarr-metadata/tests/test_partial_equivalence.py index 33492b2356..b364fce091 100644 --- a/packages/zarr-metadata/tests/test_partial_equivalence.py +++ b/packages/zarr-metadata/tests/test_partial_equivalence.py @@ -10,8 +10,6 @@ from __future__ import annotations -from typing import Any - import pytest from zarr_metadata.v2.array import ZarrV2ArrayMetadataJSON, ZarrV2ArrayMetadataJSONPartial @@ -29,7 +27,7 @@ @pytest.mark.parametrize(("full", "partial"), PAIRS, ids=lambda p: p.__name__) -def test_partial_matches_full(full: Any, partial: Any) -> None: +def test_partial_matches_full(full: type, partial: type) -> None: """Partial TypedDict has identical fields and extra_items, only total differs.""" assert full.__annotations__ == partial.__annotations__, ( f"{partial.__name__} fields drifted from {full.__name__}: " From 6eddf35398ab00f96927a684b348cdf102f08b83 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 4 Sep 2026 09:44:52 +0200 Subject: [PATCH 2/4] chore(zarr-metadata): number changelog fragments for #317 Assisted-by: ClaudeCode:claude-fable-5-1 --- packages/zarr-metadata/changes/{296.bugfix.md => 317.bugfix.md} | 0 packages/zarr-metadata/changes/{296.misc.1.md => 317.misc.1.md} | 0 packages/zarr-metadata/changes/{296.misc.md => 317.misc.md} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename packages/zarr-metadata/changes/{296.bugfix.md => 317.bugfix.md} (100%) rename packages/zarr-metadata/changes/{296.misc.1.md => 317.misc.1.md} (100%) rename packages/zarr-metadata/changes/{296.misc.md => 317.misc.md} (100%) diff --git a/packages/zarr-metadata/changes/296.bugfix.md b/packages/zarr-metadata/changes/317.bugfix.md similarity index 100% rename from packages/zarr-metadata/changes/296.bugfix.md rename to packages/zarr-metadata/changes/317.bugfix.md diff --git a/packages/zarr-metadata/changes/296.misc.1.md b/packages/zarr-metadata/changes/317.misc.1.md similarity index 100% rename from packages/zarr-metadata/changes/296.misc.1.md rename to packages/zarr-metadata/changes/317.misc.1.md diff --git a/packages/zarr-metadata/changes/296.misc.md b/packages/zarr-metadata/changes/317.misc.md similarity index 100% rename from packages/zarr-metadata/changes/296.misc.md rename to packages/zarr-metadata/changes/317.misc.md From 06972c287089ead258f467d4ae9bb3bbbe95ec71 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Sat, 19 Sep 2026 18:53:03 +0200 Subject: [PATCH 3/4] test(zarr-metadata): expect tuples from validate_array_metadata_v2 in #4365's tests Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 --- packages/zarr-metadata/tests/model/test_array.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/zarr-metadata/tests/model/test_array.py b/packages/zarr-metadata/tests/model/test_array.py index e3668fa205..69a3823b6f 100644 --- a/packages/zarr-metadata/tests/model/test_array.py +++ b/packages/zarr-metadata/tests/model/test_array.py @@ -1345,7 +1345,7 @@ def test_v2_filters_may_be_empty() -> None: doc = dict(ZarrV2ArrayMetadata.create_default().to_json()) doc["filters"] = () - assert validate_array_metadata_v2(doc) == [] + assert validate_array_metadata_v2(doc) == () assert ZarrV2ArrayMetadata.from_key_value({".zarray": json.dumps(doc).encode()}).filters == () @@ -1399,7 +1399,7 @@ def test_array_v2_ignores_unknown_document_member() -> None: """ doc = dict(ZarrV2ArrayMetadata.create_default().to_json()) | {"unexpected": 1} - assert validate_array_metadata_v2(doc) == [] + assert validate_array_metadata_v2(doc) == () assert "unexpected" not in ZarrV2ArrayMetadata.from_json(doc).to_json() From 86052270870514f81562e8b678faec7dbdc6af56 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Sat, 19 Sep 2026 18:55:09 +0200 Subject: [PATCH 4/4] chore(zarr-metadata): number changelog fragments for #4378 Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 --- packages/zarr-metadata/changes/{317.bugfix.md => 4378.bugfix.md} | 0 packages/zarr-metadata/changes/{317.misc.1.md => 4378.misc.1.md} | 0 packages/zarr-metadata/changes/{317.misc.md => 4378.misc.md} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename packages/zarr-metadata/changes/{317.bugfix.md => 4378.bugfix.md} (100%) rename packages/zarr-metadata/changes/{317.misc.1.md => 4378.misc.1.md} (100%) rename packages/zarr-metadata/changes/{317.misc.md => 4378.misc.md} (100%) diff --git a/packages/zarr-metadata/changes/317.bugfix.md b/packages/zarr-metadata/changes/4378.bugfix.md similarity index 100% rename from packages/zarr-metadata/changes/317.bugfix.md rename to packages/zarr-metadata/changes/4378.bugfix.md diff --git a/packages/zarr-metadata/changes/317.misc.1.md b/packages/zarr-metadata/changes/4378.misc.1.md similarity index 100% rename from packages/zarr-metadata/changes/317.misc.1.md rename to packages/zarr-metadata/changes/4378.misc.1.md diff --git a/packages/zarr-metadata/changes/317.misc.md b/packages/zarr-metadata/changes/4378.misc.md similarity index 100% rename from packages/zarr-metadata/changes/317.misc.md rename to packages/zarr-metadata/changes/4378.misc.md