Skip to content

Commit 015732f

Browse files
d-v-bdependabot[bot]claude
authored
feat(zarr-metadata): Zarr metadata model layer (#4119)
* chore(deps): bump the actions group across 1 directory with 8 updates (#176) Bumps the actions group with 8 updates in the / directory: | Package | From | To | | --- | --- | --- | | [prefix-dev/setup-pixi](https://github.com/prefix-dev/setup-pixi) | `0.9.5` | `0.9.6` | | [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.0` | `6.0.1` | | [github/issue-metrics](https://github.com/github/issue-metrics) | `4.2.2` | `4.2.7` | | [j178/prek-action](https://github.com/j178/prek-action) | `2.0.3` | `2.0.4` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.0` | `7.0.1` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `7.0.0` | `8.0.1` | | [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) | `1.13.0` | `1.14.0` | | [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.5.3` | `0.5.6` | Updates `prefix-dev/setup-pixi` from 0.9.5 to 0.9.6 - [Release notes](https://github.com/prefix-dev/setup-pixi/releases) - [Commits](prefix-dev/setup-pixi@1b2de7f...5185adf) Updates `codecov/codecov-action` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](codecov/codecov-action@57e3a13...e79a696) Updates `github/issue-metrics` from 4.2.2 to 4.2.7 - [Release notes](https://github.com/github/issue-metrics/releases) - [Commits](github-community-projects/issue-metrics@c9e9838...1e38d5e) Updates `j178/prek-action` from 2.0.3 to 2.0.4 - [Release notes](https://github.com/j178/prek-action/releases) - [Commits](j178/prek-action@6ad8027...bdca6f1) Updates `actions/upload-artifact` from 7.0.0 to 7.0.1 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](actions/upload-artifact@v7...043fb46) Updates `actions/download-artifact` from 7.0.0 to 8.0.1 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](actions/download-artifact@v7...3e5f45b) Updates `pypa/gh-action-pypi-publish` from 1.13.0 to 1.14.0 - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](pypa/gh-action-pypi-publish@v1.13.0...cef2210) Updates `zizmorcore/zizmor-action` from 0.5.3 to 0.5.6 - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](zizmorcore/zizmor-action@b1d7e1f...5f14fd0) --- updated-dependencies: - dependency-name: prefix-dev/setup-pixi dependency-version: 0.9.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: codecov/codecov-action dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: github/issue-metrics dependency-version: 4.2.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: j178/prek-action dependency-version: 2.0.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.14.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: zizmorcore/zizmor-action dependency-version: 0.5.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(zarr-metadata): add model._validation — structural validators Assisted-by: ClaudeCode:claude-fable-5 * feat(zarr-metadata): add model._array — array metadata models Assisted-by: ClaudeCode:claude-fable-5 * test(zarr-metadata): port array model test suite Assisted-by: ClaudeCode:claude-fable-5 * feat(zarr-metadata): add group metadata models Assisted-by: ClaudeCode:claude-fable-5 * test(zarr-metadata): add consolidated metadata model tests Assisted-by: ClaudeCode:claude-fable-5 * feat(zarr-metadata): export model layer from package front door Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-metadata): changelog entry for the model layer Assisted-by: ClaudeCode:claude-fable-5 * feat(zarr-metadata): harden model validation and error reporting Findings from an API-ergonomics exercise (a fresh agent consuming defective metadata documents): - ValidationProblem gains a machine-readable kind (missing_key / invalid_type / invalid_value / invalid_json), ending message string-matching in consumers. - The v2 array validator now enforces what its types declare (dtype, order, compressor, filters, dimension_separator), and all four document validators check the fixed zarr_format / node_type literals. - All ingestion failures surface as MetadataValidationError: missing store keys and undecodable bytes in from_key_value (previously KeyError / JSONDecodeError) and constructor invariants (previously bare ValueError). - ZarrMetadataV3 is renamed NamedConfigModelV3: it models a name + configuration pair, and the old name read as a whole-document type. - Discoverability: the validate_*/is_*/parse_* contract is documented on zarr_metadata.model itself; update() documents that it does not re-validate; the v2 to_json/to_key_value attributes split is documented on both. Assisted-by: ClaudeCode:claude-fable-5 * feat(zarr-metadata): annotate metadata fields with role alias MetadataFieldModelV3 Model fields and consumer signatures should convey the logical meaning of the type (a metadata-document field), not the form it takes when JSON-serialized (a named configuration). MetadataFieldModelV3 is today exactly NamedConfigModelV3; if a future spec revision adds a field form that cannot normalize to name + configuration, the alias widens to a union and annotation sites do not move. Mirrors the raw-layer split between NamedConfigV3 (shape) and MetadataV3 (field union). Assisted-by: ClaudeCode:claude-fable-5 * test(zarr-metadata): assert required-key coverage via the typed constant test_v3_to_json_includes_required_fields hand-enumerated keys with chained asserts, restating what ARRAY_METADATA_REQUIRED_KEYS_V3 already defines. Now: one coverage assert driven by the constant (tracks the TypedDict automatically) and one whole-document equality for the values. Assisted-by: ClaudeCode:claude-fable-5 * test(zarr-metadata): single whole-document comparison for v3 to_json The subset assert against ARRAY_METADATA_REQUIRED_KEYS_V3 was redundant: equality with a literal that spells out the full document already covers every required key. One dict, one assert. Assisted-by: ClaudeCode:claude-fable-5 * fix(zarr-metadata): close validation holes found by adversarial review Invalid documents that previously passed validation: - shape/chunks containing JSON booleans (bool is an int subclass in Python but not an integer in a metadata document) or negative values - dimension_names whose length does not match shape - attributes and configuration values that are not JSON-serializable — now checked recursively like fill_value, so an int-keyed dict cannot be silently rewritten by json.dumps on round-trip and a set() cannot escape as a TypeError from to_key_value - consolidated_metadata envelopes: the group validator now deep-validates the envelope and its entries via the shared validate_consolidated_metadata_v3, which ConsolidatedMetadataModelV3 .from_json also uses, so is_group_metadata_v3 never vouches for a document the model constructor would reject Three pre-existing test fixtures paired dimension_names=('x',) with the default scalar shape () and were themselves spec-invalid; they now use a matching 1-d shape. Deliberately unchanged, pending a design decision: unknown extension fields with must_understand: true still pass (which layer owns the spec's refusal duty), and empty v2 dtype records / empty codec names still pass (domain territory). Assisted-by: ClaudeCode:claude-fable-5 * feat(zarr-metadata): expose must_understand_fields on the v3 models The v3 core spec: 'An implementation MUST fail to open Zarr groups or arrays if any metadata fields are present which (a) the implementation does not recognize and (b) are not explicitly set to "must_understand": false' — and fields are implicitly must-understand unless waived. The model layer cannot discharge this itself: recognition is reader-specific (consolidated_metadata is itself an extension field one reader understands and another does not), and a document carrying a must-understand extension is still a valid document. So the models partition by obligation: must_understand_fields is the subset of extra_fields not explicitly waived, and a compliant reader fails to open when must_understand_fields.keys() - recognized is non-empty. The design spec pins that duty on the part-2 resolve layer, matching what zarr-python's parse_extra_fields enforces today. Assisted-by: ClaudeCode:claude-fable-5 * test(zarr-metadata): executable example of pydantic integration Delegate wholesale rather than letting pydantic introspect the dataclass: InstanceOf (is-instance core schema) + BeforeValidator(from_json) + PlainSerializer(to_json, return_type=dict). Field-by-field validation is impossible anyway (the models' annotation-only imports live behind TYPE_CHECKING, so pydantic raises class-not-fully-defined) and would diverge from the library's structural validation via coercion if it weren't. MetadataValidationError subclasses ValueError, so failed parses surface as pydantic ValidationError with the loc-annotated messages. pydantic is already in the package's test dependency group. Assisted-by: ClaudeCode:claude-fable-5 * test(zarr-metadata): document pydantic's native dataclass path and why not Correcting the previous commit's too-strong claim: pydantic CAN introspect the model dataclass — TypeAdapter(...).rebuild() with the TYPE_CHECKING-only names supplied as _types_namespace resolves the schema, and __post_init__ invariants still run. A new test exercises that path and pins why it is not the recommended integration: it validates the model shape, not the document (bare-string data_type rejected — no from_json normalization), and pydantic's lax coercion silently re-opens holes the library validators close (shape=[True, -5] coerces to (1, -5); a wrong dimension_names count passes). Assisted-by: ClaudeCode:claude-fable-5 * test(zarr-metadata): engine-backed pydantic BaseModel example (pydantic-zarr pattern) For consumers that want a first-class BaseModel — JSON schema generation and generics for typed attributes, as in pydantic-zarr's ArraySpec — the example adds a third pattern: pydantic-native fields as the user-facing surface, with the library as the engine. A mode='before' validator canonicalizes every input via from_json(...).to_json(), so structural validation and normalization run before pydantic parses fields (the [True, -5] coercion divergence cannot occur), and to_metadata_model / to_document bridge both ways through the document form. One translation noted at the bridge: the document spells 'no dimension names' as key absence, the pydantic side as None. Assisted-by: ClaudeCode:claude-fable-5 * test(zarr-metadata): pin that a null dimension_names field is invalid Spec: 'If specified, must be an array of strings or null objects... If dimension_names is not specified, all dimensions are unnamed.' The null object is a permitted element (an unnamed dimension), never the field value; key absence is the only spelling of 'not specified'. Pins the validator's existing rejection so it is not later 'fixed' to accept null-as-absence, and documents that in-memory None maps to key absence on serialization. Assisted-by: ClaudeCode:claude-fable-5 * feat(zarr-metadata): optional pydantic integration as zarr_metadata.pydantic Gamed out three shapes with prototypes before choosing: - dunders on the core classes (works, verified pydantic 2.0-2.13, but puts a framework protocol in the dependency-free layer); - pydantic-aware SUBCLASSES in a namespace (rejected on empirical failures: identity split breaks equality, core instances are rejected by subclass-typed fields, and nested construction produces core-class children unless every cross-reference is overridden); - Annotated field types over the CORE classes in an opt-in module (chosen): instances are the core classes so interop is free, pydantic imports eagerly at the module (loud failure when absent), core stays framework-free, and pydantic-protocol risk is quarantined to one clearly-labeled module. The module exports one field type per model. Validation delegates to from_json (structural validation and normalization cannot be bypassed by pydantic coercion), instances pass through, serialization emits the canonical document, and WithJsonSchema describes the accepted document form so model_json_schema works. Tests cover all seven field types, core-instance interop, error quality, JSON schema, roundtrip, and that importing zarr_metadata does not import pydantic. Assisted-by: ClaudeCode:claude-fable-5 * fix(zarr-metadata): create_default derives the chunk grid from shape create_default(shape=(100, 100)) silently kept the scalar default's 0-d chunk grid (chunk_shape: ()), producing a structurally-valid but semantically inconsistent document — a footgun for every test fixture built on it. When shape is overridden and the grid is not, the default is now one regular chunk covering the array (v3 chunk_shape == shape, v2 chunks == shape); an explicit chunk_grid/chunks override still wins. update() stays a dumb dataclasses.replace, per its documented contract. One existing whole-document test literal carried exactly this inconsistency (shape (10,) with chunk_shape ()) and was updated. Assisted-by: ClaudeCode:claude-fable-5 * test(zarr-metadata): pin zero-length-dimension case of the derived chunk grid The spec's constraint is conditional ('non-zero when the corresponding dimensions of the arrays have non-zero length'), so chunk_shape == shape is sound for every shape, including empty dimensions. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-metadata): document that create_default's derivation is one-way Overriding shape without a grid derives the grid; the reverse does not hold. A user-supplied chunk_grid is an extension point taken verbatim — deriving shape from it would require interpreting grid configurations, which the model layer never does and cannot do for unrecognized grid names. Pinned by test so the asymmetry reads as a decision, not an oversight; the v2 model documents the same one-way rule for chunks for cross-version consistency. Assisted-by: ClaudeCode:claude-fable-5 * refactor(zarr-metadata): eliminate every type-ignore comment Audited all 24 (15 src, 9 tests); each was either obsolete, replaceable by a sound cast, or avoidable by better-typed code: - Two fill_value arg-type ignores were factually obsolete: their justifying comment said 'fill_value: object in upstream TypedDict', but 0.3.0 narrowed it to JSONValue. - Eight pre-existing call-arg/reportInvalidTypeForm ignores on the PEP 728 TypedDicts and the recursive JSONValue alias were mypy-dialect suppressions that the checker of record (pyright strict with enableExperimentalFeatures) never needed; mypy has never checked this package. - The two extra_fields comprehensions are a genuine checker limitation (a key filter cannot narrow a PEP 728 TypedDict's item-value union), now expressed as casts whose comments state the soundness claim instead of suppressing the diagnostic. - pydantic.py's generic coercer factory takes the parse callable explicitly instead of calling from_json through type[_M]. - NamedConfigModelV3.from_json casts the validated configuration (sound since configuration values are now deep-validated as JSON). - Tests: _build_v2/_build_v3 gained real Unpack[...Partial] signatures; raw-document pydantic inputs go through model_validate (the idiomatic entry point for untyped data) instead of ignoring constructor signatures; the frozen-dataclass test uses setattr for its intentional runtime error. src and tests/model now carry zero type-ignore comments. Assisted-by: ClaudeCode:claude-fable-5 * fix(zarr-metadata): absent v2 dimension_separator means '.', not '/' roborev job 426 (branch review) found that ArrayMetadataModelV2 normalized an ABSENT dimension_separator key to '/', inherited verbatim from the zng prototype. The v2 convention's default is '.': a consumer deriving chunk keys from the model against a real-world v2 array written with the default separator would have looked for '0/0' instead of '0.0'. No test caught it because every fixture started from create_default(), which always carries an explicit separator. Absence is normalized to an explicit '.' -- a semantics-preserving spelling normalization consistent with the model's existing canonical forms (bare-string metadata fields, missing configuration). The field is deliberately NOT modeled as Optional: the document grammar has no null spelling for this key, and a None in the model invites writing 'dimension_separator': null into documents. Pinned by three tests, including explicit-null rejection. Assisted-by: ClaudeCode:claude-fable-5 * feat(zarr-metadata): UNSET sentinel for absent optional document keys Establishes the models' None/absence invariant: None in a model always corresponds to a JSON null in the document (a v2 compressor/filters value, an unnamed dimension inside dimension_names), and UNSET always means the key is absent. The two are never interchangeable. Applied to the two fields that used None as an absence marker: dimension_names (ArrayMetadataModelV3) and consolidated_metadata (GroupMetadataModelV3). For dimension_names this also preserves a semantic distinction d-v-b identified: an absent field ("there are no dimension names") and an explicit all-null array ("every dimension has a name, which is null") are different documents; both spellings now round-trip faithfully and compare unequal. Normalizing absence to the all-null form was considered and rejected: the spellings' interpretations coincide but interpretation-equivalence is the resolve layer's business, and collapsing document-level distinctions on that basis is the layer violation this package exists to avoid. Verified that current zarr-python never writes "consolidated_metadata": null (GroupMetadata.to_dict pops the key), so None there was purely an absence marker, not a document spelling. UnsetType is a single-member enum (identity-checkable, repr "UNSET", deliberately truthy so `if not x` cannot silently treat it as absent); UNSET and UnsetType are exported from zarr_metadata.model and the package front door. Assisted-by: ClaudeCode:claude-fable-5 * fix(zarr-metadata): accept and preserve the wild consolidated_metadata null Historical zarr-python versions wrote "consolidated_metadata": null into group documents for groups without consolidated metadata, so real stores contain the spelling; the validator was rejecting those documents ("expected a mapping"). Per the None/UNSET invariant, the field is now honestly three-state: UNSET (key absent), None (the document's literal null, preserved on round-trip), or a ConsolidatedMetadataModelV3. Interpreting null as absence is the consumer's call, not a document rewrite by this layer. Also records an implementation constraint on the sentinel itself: typing_extensions.Sentinel (PEP 661) is the intended spelling, but pyright 1.1.411 degrades a Sentinel to Unknown in dataclass FIELD annotations (function signatures work), verified by probe both with and without enableExperimentalFeatures. Using it would reintroduce suppressions at every use site under the strict gate, so UNSET stays a single-member enum, with the Sentinel switch documented in _sentinel.py for when pyright catches up. Assisted-by: ClaudeCode:claude-fable-5 * fix(zarr-metadata): repair consolidated_metadata null to absence, not preserve it d-v-b: the bugged spelling should not be preserved or honored. The three-state field reverts to two states (model | UNSET): a document carrying "consolidated_metadata": null — written by a historical zarr-python bug — remains readable (the validator accepts it so real stores open), but the spelling gets no model representation: it is read as absence and never written back. This is the one deliberate exception to faithful round-tripping, pinned as such: from_json(null_doc) equals from_json(absent_doc), and to_json omits the key. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-metadata): pin the Sentinel blocker to pyright regression #11115 Investigated: the Unknown-degradation of typing_extensions.Sentinel is a confirmed upstream pyright regression, not by-design. Introduced in 1.1.405 (verified: 1.1.404 is clean on the same probe, 1.1.411 fails), affects reads of any class-body attribute annotation (dataclass or plain class), does not affect function signatures or module variables, and Final on the sentinel does not help. Tracked as microsoft/pyright#11115 (open, bug+regression); #11467 closed as its duplicate. The enum sentinel stays until the fix lands. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-metadata): sentinel switch is blocked by mypy too, not just pyright Pinning a working pyright (<= 1.1.404) in CI was considered and does not suffice: the pin controls one of four checker surfaces. Contributor IDEs (Pylance bundles current pyright) and downstream consumers' pyright read the py.typed inline annotations with their own versions, and decisively, mypy 2.1.0 has no PEP 661 support at all — a sentinel in type position is a hard [valid-type] error, which would degrade these fields to Any for mypy consumers, including zarr-python itself. The enum is currently the only spelling with exact types on every surface; switch when pyright#11115 is fixed AND mypy implements PEP 661. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-metadata): PEP 661 is Final (Python 3.15), not a draft Corrects the sentinel implementation note: PEP 661 was accepted 2026-04-23 and ships as stdlib sentinel in Python 3.15. The two checker gaps blocking the Sentinel spelling (pyright regression #11115, mypy not yet implementing the PEP) are therefore temporary gaps against a Final standard, and the enum is a stopgap with a defined end state. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-metadata): ty fully supports typed sentinels; pyright/mypy are the laggards ty 0.0.56 types the Sentinel spelling perfectly in dataclass fields: exact T | UNSET unions, both-direction is/is-not narrowing, and wrong-typed constructor arguments rejected (verified with reveal_type, so it is real inference, not silent Any). The checker matrix for sentinel-in-type-position is therefore ty full / pyright regressed (#11115) / mypy not implemented — recorded so the switch decision has current calibration. Assisted-by: ClaudeCode:claude-fable-5 * 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 * fix(zarr-metadata): .zattrs presence is part of the store, not an artifact Resolves the last flagged round-trip question from the initial port: to_key_value on the v2 models always emitted a .zattrs key, so a store that never had one gained a file on round-trip. Per d-v-b's ruling, attributes on ArrayMetadataModelV2/GroupMetadataModelV2 is now `dict[str, JSONValue] | UNSET`: UNSET means no .zattrs file (and no attributes key in the merged document form) and emits nothing, while any dict — including an explicit empty {} — means the file exists and is emitted. The two spellings stay distinct through round-trips, per the None/UNSET invariant; create_default defaults to UNSET (a fresh minimal node has no .zattrs). Assisted-by: ClaudeCode:claude-fable-5 * fix(zarr-metadata): require typing_extensions>=4.16 so UNSET pickles by reference Models hold the UNSET sentinel as field values (dimension_names, attributes), so any object graph containing a model must survive pickling and deep-copying. A sentinel's contract is identity — state-based pickling would produce impostor objects that fail every `is UNSET` check — which is why typing_extensions <= 4.15 refused to pickle sentinels at all. typing_extensions 4.16 implements Sentinel.__reduce__ as pickling by reference (a lookup of the sentinel's name on its defining module), the same mechanism enum members use, so the singleton identity survives the round trip. Bump the floor and pin the behavior with tests: identity across pickle/copy/deepcopy, models holding UNSET round-tripping, and a guard that a non-importable sentinel still fails loudly rather than pickling by state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(zarr-metadata): format-version-first naming; dataclasses primary, JSON suffix for documents Applies the naming decisions from the PR discussion: ZarrV2/ZarrV3 moves to the front of every type name so a format version cannot be misread as a class revision, and the model dataclasses take the bare entity names (ZarrV3ArrayMetadata, ZarrV3GroupMetadata, ZarrV3ConsolidatedMetadata, ZarrV3NamedConfig, role alias ZarrV3MetadataField) while the TypedDict document forms carry a JSON suffix (ZarrV3ArrayMetadataJSON, ..., ZarrV3MetadataFieldJSON, ZarrV3NamedConfigJSON). The zarr_metadata.pydantic field types take the bare entity names, matching the model classes they validate into; the module now references the model module qualified to keep those names free. Raw-layer names released in 0.3.0 are renamed without aliases (pre-1.0), documented in changes/4119.removal.md. Validation problem messages name documents in plain English instead of type names. snake_case function names (validate_array_metadata_v3, ...) and SCREAMING_SNAKE constants are deliberately untouched: the revision ambiguity the rename fixes does not arise for them, and renaming them is a separate decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(zarr-metadata): harden model validation Assisted-by: Codex:gpt-5 * docs(zarr-metadata): define v3 conformance boundary Assisted-by: Codex:gpt-5 * fix(zarr-metadata): preserve v3 extension obligations Assisted-by: Codex:gpt-5 * fix(zarr-metadata): enforce v3 core extension rules Assisted-by: Codex:gpt-5 * fix(zarr-metadata): align v3 additional field types Assisted-by: Codex:gpt-5 * chore(zarr-metadata): finalize v3 model conformance Assisted-by: Codex:gpt-5 * docs: remove llm docs * chore(zarr-metadata): correct changelog PR number Assisted-by: Codex:gpt-5 * fix(metadata): enforce canonical document boundaries Assisted-by: Codex:GPT-5 * docs(metadata): record review fix design Assisted-by: Codex:gpt-5 * fix(metadata): align validation and schemas Assisted-by: Codex:gpt-5 * fix(metadata): tighten validation boundaries Assisted-by: Codex:gpt-5 * fix(metadata): repair package CI Assisted-by: Codex:gpt-5 * chore: remove stray design notes from docs Assisted-by: ClaudeCode:claude-fable-5 * fix(zarr-metadata): deep-copy mutable state in to_json output to_json previously returned documents holding direct references to the model's internal dicts (attributes, named-config configurations, extra fields, v2 compressor/filters, consolidated entries), so mutating a serialized document silently mutated the frozen model. Every value that can hold a mutable container is now deep-copied on the way out, with parametrized tests proving mutation independence for all seven models. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-metadata): describe the widened package scope The README tagline, intro, and scope section (and the PyPI description) still presented the package as type definitions only. Restructure them around the two layers plus optional integration, extend the contribution scope to models and structural validation, and state the runtime-behavior boundary explicitly. Assisted-by: ClaudeCode:claude-fable-5 * style(zarr-metadata): move None to the end of the JSONValue union The unpinned ruff in CI now enforces RUF036. Assisted-by: ClaudeCode:claude-fable-5 * feat(zarr-metadata): export store-key aliases and type to_key_value with them The six store-key Literal aliases were private and unused: only reachable via underscore modules, and absent from every signature. Export them from zarr_metadata.model beside their constants (matching the package's name/constant pairing everywhere else), and key each to_key_value return mapping by them so the store keys a model can emit are visible in its signature. from_key_value keeps Mapping[str, bytes] input on purpose — it accepts whole store mappings. A pair test guards export and value drift, and the removal note now states the version-placement and JSON-suffix conventions explicitly. Assisted-by: ClaudeCode:claude-fable-5 * refactor(zarr-metadata): make every public type name parse against a naming grammar Three grammars now cover the public surface, enforced by a conformance test that walks every public module's __all__: - core document/model names: ZarrV{2,3} + entity + optional role suffix (JSON / JSONPartial / Partial / StoreKey) - extension-entity names: registered entity + exactly one role suffix (CodecMetadata, DataTypeName, FillValue, ...) - a closed standalone-vocabulary allowlist for role-less scalar and diagnostic types, with a staleness guard The three .z-file document types were the only names that fit no grammar and are renamed: ZArrayMetadata -> ZarrV2ZArrayJSON, ZGroupMetadata -> ZarrV2ZGroupJSON, ZAttrsMetadata -> ZarrV2ZAttrsJSON. The leading V2 of V2ChunkKeyEncodingMetadata is that encoding's registered entity name, not a format version; its module docstring now says so. Assisted-by: ClaudeCode:claude-fable-5 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cb93ef8 commit 015732f

47 files changed

Lines changed: 5940 additions & 299 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.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/README.md

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,86 @@
11
# zarr-metadata
22

3-
Python type definitions for Zarr v2 and v3 metadata.
3+
Python types, models, and validators for Zarr v2 and v3 metadata.
44

55
## What this is
66

7-
A typed-data package: `TypedDict` definitions and `Literal` aliases for the
8-
JSON shapes specified by the [Zarr v2](https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html)
9-
and [Zarr v3](https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html)
10-
specifications, plus types for [`zarr-extensions`](https://github.com/zarr-developers/zarr-extensions/)
11-
and a few widely-used-but-unspecified entities (e.g. consolidated metadata).
7+
Two layers and an optional integration:
8+
9+
- **Typed JSON shapes**: `TypedDict` definitions and `Literal` aliases for the
10+
JSON documents specified by the [Zarr v2](https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html)
11+
and [Zarr v3](https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html)
12+
specifications, plus types for [`zarr-extensions`](https://github.com/zarr-developers/zarr-extensions/)
13+
and a few widely-used-but-unspecified entities (e.g. consolidated metadata).
14+
- **Document models** (`zarr_metadata.model`): canonical frozen-dataclass
15+
models of whole metadata documents, with structural validators, loc-aware
16+
parsers, and store-key (de)serialization. A document produced by `to_json`
17+
shares no mutable state with the model that produced it.
18+
- **Optional Pydantic integration** (`zarr_metadata.pydantic`, requires
19+
Pydantic 2.13 or newer): each model as a Pydantic field type that validates
20+
raw documents through the same strict parser.
1221

1322
## What this is for
1423

15-
These types describe the JSON shape of Zarr metadata. They are
16-
intended for libraries that **read, write, validate, or transform**
17-
Zarr metadata. Pair them with a runtime validator like
18-
[pydantic](https://docs.pydantic.dev/) to check JSON loaded from disk:
24+
The public `TypedDict` definitions describe the static JSON shape of Zarr
25+
metadata. For strict, loc-aware validation of JSON loaded from disk, use the
26+
model parser:
1927

2028
```python
2129
import json
22-
from pydantic import TypeAdapter
23-
from zarr_metadata.v3.array import ArrayMetadataV3
30+
from zarr_metadata.model import ZarrV3ArrayMetadata
2431

2532
with open("zarr.json", "rb") as f:
2633
raw = json.load(f)
2734

28-
metadata = TypeAdapter(ArrayMetadataV3).validate_python(raw)
35+
metadata = ZarrV3ArrayMetadata.from_json(raw)
36+
```
37+
38+
The optional Pydantic integration delegates raw input to the same strict
39+
parser and returns the same normalized model class:
40+
41+
```python
42+
from pydantic import TypeAdapter
43+
import zarr_metadata.pydantic as zmp
44+
45+
metadata = TypeAdapter(zmp.ZarrV3ArrayMetadata).validate_python(raw)
46+
encoded = metadata.to_key_value()["zarr.json"]
2947
```
3048

31-
## What this is *not*
49+
A bare `TypeAdapter` over a public document `TypedDict` is a coercive shape
50+
adapter, not a Zarr conformance validator; it may coerce values or discard
51+
members that the strict model parser rejects.
52+
53+
## Validation boundary
3254

33-
- Not a parser or builder. There are no `make_array_metadata(...)` factories —
34-
that surface belongs to consumer libraries.
35-
- Not a runtime validator on its own. Pair with `pydantic`, `msgspec`, or
36-
similar to enforce shapes at decode time.
55+
The model validators enforce the declared document structure and a small set
56+
of context-free consistency rules, including fixed format literals, finite
57+
JSON numbers, non-negative dimensions, non-empty v3 codec pipelines, and one
58+
`dimension_names` entry per array dimension. They do not interpret extension
59+
names or configurations, resolve codec pipelines, or decide whether a data
60+
type, chunk grid, codec, or storage transformer is supported. Those decisions
61+
belong to consumer implementations.
3762

38-
Even with a runtime validator, these types only describe **structural**
39-
shape — they will not flag *semantically* invalid metadata, like a 3D v3
40-
array whose `dimension_names` has 4 entries instead of 3. That's a job
41-
for downstream validator routines.
63+
The Pydantic integration's generated JSON Schemas express independently
64+
checkable document structure and field constraints, but they are not a
65+
replacement for runtime model validation. Standard JSON Schema treats a
66+
mathematically integral number such as `1.0` as an integer, while the runtime
67+
boundary requires Python `int` values, and it cannot express arbitrary
68+
same-length relations such as `dimension_names` versus `shape` or v2 `chunks`
69+
versus `shape`. Consumers should run the model parser after schema validation.
4270

4371
## Scope
4472

4573
At minimum, this library supports what Zarr-Python needs: the complete
4674
Zarr v2 and v3 specs, consolidated metadata, and a subset of the metadata
4775
defined in `zarr-extensions`. We are generally open to contributions that
48-
add types for Zarr metadata with a published spec.
76+
add types, models, or structural validation for Zarr metadata with a
77+
published spec.
78+
79+
Runtime array behavior is out of scope: nothing here encodes or decodes
80+
chunks, resolves codec or data type names to implementations, or performs
81+
store I/O. The models begin and end at the metadata documents themselves —
82+
`from_key_value` / `to_key_value` map documents to store keys and bytes,
83+
and everything past that belongs to consumer libraries.
4984

5085
## Releasing
5186

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
Added `zarr_metadata.model`: frozen-dataclass models (`ZarrV2ArrayMetadata`,
2+
`ZarrV3ArrayMetadata`, `ZarrV2GroupMetadata`, `ZarrV3GroupMetadata`,
3+
`ZarrV2ConsolidatedMetadata`, `ZarrV3ConsolidatedMetadata`, `ZarrV3NamedConfig`)
4+
that are canonical, semantically lossless representations of Zarr metadata
5+
documents, plus structural validators (`validate_*` / `is_*` / `parse_*`).
6+
Every v3 extension point (data type, chunk grid, chunk key encoding, codecs,
7+
storage transformers) is held as `ZarrV3NamedConfig`: a name, configuration,
8+
and `must_understand` obligation; nothing is interpreted. On the wire, an
9+
empty configuration with the default obligation uses the spec's plain-string
10+
shorthand. Model fields are annotated with the role alias
11+
`ZarrV3MetadataField` (today exactly `ZarrV3NamedConfig`), so annotations
12+
convey the logical meaning and stay put if the spec adds another field form.
13+
14+
Validation is strict about what the types declare: v2 `dtype` / `order` /
15+
`compressor` / `filters` / `dimension_separator` shapes and the fixed
16+
`zarr_format` / `node_type` literals are all enforced. Every
17+
`ValidationProblem` carries a machine-readable `kind`
18+
(`missing_key` / `invalid_type` / `invalid_value` / `invalid_json`) so
19+
consumers can dispatch on the failure mode without matching message strings,
20+
and every ingestion failure — including missing store keys and undecodable
21+
bytes in `from_key_value` — surfaces as `MetadataValidationError`. An
22+
adversarial review added further structural checks: JSON booleans are not
23+
accepted as dimension lengths, dimensions are non-negative,
24+
`dimension_names` must have one entry per dimension of `shape`, `attributes`
25+
and `configuration` values are JSON-checked recursively (like `fill_value`),
26+
non-finite floats and non-standard JSON constants are rejected, abstract
27+
mappings and sequences normalize to encoder-safe canonical containers,
28+
v2 `shape` and `chunks` must have the same rank, non-null v2 filter pipelines
29+
contain at least one filter, document `TypeIs` guards only narrow values that
30+
already use the declared canonical containers,
31+
and the inline consolidated-metadata envelope and entries are deep-validated
32+
so the group validator's verdict always agrees with the model constructor.
33+
34+
The v3 models expose `must_understand_fields`: the subset of `extra_fields`
35+
not explicitly waived with `must_understand: false` (fields are implicitly
36+
must-understand per the spec). Readers discharge the spec's fail-to-open
37+
duty by subtracting the extension names they recognize; the model only
38+
partitions by obligation, since recognition is reader-specific.
39+
40+
Optional pydantic integration ships as `zarr_metadata.pydantic` (importing it
41+
requires pydantic 2.13 or newer; the core package does not depend on it): one
42+
`Annotated`
43+
field type per model, validating raw documents through `from_json`, passing
44+
core-model instances through unchanged, serializing via `to_json`, and
45+
publishing JSON Schemas derived from private constrained document types that
46+
mirror the independently expressible runtime rules. Cross-field cardinality
47+
relations still require runtime validation. The instances are the core model
48+
classes, so values interoperate freely with non-pydantic code.
49+
50+
`create_default` keeps its output self-consistent: overriding `shape` without
51+
a chunk grid derives one regular chunk covering the array (v3
52+
`chunk_shape == shape`; v2 `chunks == shape`) instead of silently keeping the
53+
scalar default's 0-d grid.
54+
55+
A v2 `.zarray` that omits `dimension_separator` is interpreted with the v2
56+
convention's default `"."` (the model previously normalized absence to `"/"`,
57+
which would misaddress the chunks of real-world default-separator arrays).
58+
The value is never null: absent, `"."`, or `"/"` are the only spellings.
59+
60+
Optional document keys use `UNSET` — a PEP 661 sentinel
61+
(`typing_extensions.Sentinel`), usable directly in type expressions — never
62+
`None`: in a model, `None` always corresponds to a JSON `null` in the
63+
document (a v2 `compressor`, an unnamed dimension inside `dimension_names`),
64+
and `UNSET` always means the key is absent. Checker note: ty types the
65+
sentinel exactly; pyright needs `<= 1.1.404` until microsoft/pyright#11115
66+
is fixed (this package's CI pins it); mypy users need a `cast` or
67+
`type: ignore` at narrowing sites until python/mypy#21647 merges. This keeps semantically distinct spellings
68+
distinct — an absent `dimension_names` ("there are no dimension names") and
69+
an explicit `[null, null]` ("every dimension has a name, which is null") are
70+
different documents and round-trip as such. The `consolidated_metadata: null`
71+
written by a historical zarr-python bug is the one deliberate exception to
72+
faithful round-tripping: those stores remain readable, but the bug spelling
73+
is repaired to absence on read and never written back.
74+
75+
The v2 models treat the `.zattrs` file's presence as part of the store:
76+
`attributes` is `UNSET` when no `.zattrs` file exists (and `to_key_value`
77+
emits none), while an explicit empty `.zattrs` is `{}` and round-trips as a
78+
file. Previously `to_key_value` always emitted `.zattrs`, silently adding a
79+
file to stores that never had one.
80+
81+
The store-key `Literal` aliases (`ZarrV2ArrayMetadataStoreKey`,
82+
`ZarrV2AttributesStoreKey`, ...) are exported from `zarr_metadata.model`
83+
alongside their constants, and each `to_key_value` return type is keyed by
84+
them, so the set of store keys a model can emit is visible in its signature.
85+
`from_key_value` deliberately keeps `Mapping[str, bytes]` input: it accepts
86+
any string-keyed store mapping and ignores unrelated keys.
87+
88+
`to_json` returns a document that shares no mutable state with the model:
89+
every value that can hold a mutable container (attributes, configurations,
90+
extra fields, v2 codec configurations, fill values, consolidated entries) is
91+
deep-copied on the way out, so editing a serialized document can never
92+
silently mutate the frozen model that produced it.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
The document (TypedDict) types are renamed to put the format version at the
2+
front of the name and to mark the JSON-document form with a `JSON` suffix,
3+
so a format version can never be misread as a class revision and the bare
4+
entity names are reserved for the `zarr_metadata.model` dataclasses:
5+
6+
- `ArrayMetadataV2``ZarrV2ArrayMetadataJSON` (and `...Partial` accordingly)
7+
- `ArrayMetadataV3``ZarrV3ArrayMetadataJSON` (and `...Partial` accordingly)
8+
- `GroupMetadataV2``ZarrV2GroupMetadataJSON` (and `...Partial` accordingly)
9+
- `GroupMetadataV3``ZarrV3GroupMetadataJSON` (and `...Partial` accordingly)
10+
- `ConsolidatedMetadataV2``ZarrV2ConsolidatedMetadataJSON`
11+
- `ConsolidatedMetadataV3``ZarrV3ConsolidatedMetadataJSON`
12+
- `NamedConfigV3``ZarrV3NamedConfigJSON`
13+
- `MetadataV3``ZarrV3MetadataFieldJSON` (the union of the bare-name and
14+
named-configuration spellings of one metadata field)
15+
- `ExtensionFieldV3``ZarrV3ExtensionField`
16+
- `CodecMetadataV2``ZarrV2CodecMetadata`
17+
- `DataTypeMetadataV2``ZarrV2DataTypeMetadata`
18+
- `ArrayOrderV2``ZarrV2ArrayOrder`
19+
- `ArrayDimensionSeparatorV2``ZarrV2ArrayDimensionSeparator`
20+
- `ZArrayMetadata``ZarrV2ZArrayJSON` (the strict on-disk `.zarray` document)
21+
- `ZGroupMetadata``ZarrV2ZGroupJSON` (the strict on-disk `.zgroup` document)
22+
- `ZAttrsMetadata``ZarrV2ZAttrsJSON` (the `.zattrs` document)
23+
24+
The old names are removed, not aliased. The `zarr_metadata.pydantic` field
25+
types take the bare entity names (`ZarrV3ArrayMetadata`, ...), matching the
26+
model classes they validate into.
27+
28+
The conventions, stated once for future additions: CamelCase type names put
29+
the format version first (`ZarrV2ArrayMetadataJSON`,
30+
`ZarrV3ArrayMetadataStoreKey`), while SCREAMING_SNAKE constants and
31+
snake_case functions put it last (`ARRAY_METADATA_STORE_KEY_V2`,
32+
`validate_array_metadata_v3`). The `JSON` suffix marks a raw-document type
33+
whose bare name is taken by (or reserved for) a `zarr_metadata.model`
34+
dataclass; raw field-level types the models hold verbatim
35+
(`ZarrV2CodecMetadata`, `ZarrV3ExtensionField`) keep their bare names.
36+
Extension-entity types put the registered entity name first and end in
37+
exactly one role suffix (`BloscCodecMetadata`, `Uint8DataTypeName`) — the
38+
`V2` in `V2ChunkKeyEncodingMetadata` is that encoding's entity name, not a
39+
format version, which is always spelled `ZarrV2`/`ZarrV3`. Every public
40+
type name is checked against this grammar by
41+
`tests/test_public_api.py::test_public_type_names_comply_with_naming_grammar`.

packages/zarr-metadata/pyproject.toml

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
55
[project]
66
name = "zarr-metadata"
77
dynamic = ["version"]
8-
description = "Spec-defined metadata types for Zarr v2 and v3."
8+
description = "Spec-defined metadata types, models, and validators for Zarr v2 and v3."
99
readme = "README.md"
1010
requires-python = ">=3.11"
1111
license = "MIT"
@@ -32,7 +32,11 @@ classifiers = [
3232
]
3333
keywords = ["zarr"]
3434
dependencies = [
35-
"typing_extensions>=4.13",
35+
# >=4.16: first release where `Sentinel` pickles by reference
36+
# (`__reduce__` returns the sentinel's name), so `UNSET` — and any model
37+
# holding it — can cross process boundaries and be deep-copied with its
38+
# singleton identity intact. 4.15 and earlier refuse to pickle sentinels.
39+
"typing_extensions>=4.16",
3640
]
3741

3842
[project.urls]
@@ -43,7 +47,7 @@ Changelog = "https://github.com/zarr-developers/zarr-python/blob/main/packages/z
4347
Documentation = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-metadata/README.md"
4448

4549
[dependency-groups]
46-
test = ["pytest", "pydantic>=2"]
50+
test = ["pytest", "pydantic>=2.13", "jsonschema"]
4751

4852
[tool.hatch.version]
4953
source = "vcs"
@@ -67,9 +71,9 @@ xfail_strict = true
6771
addopts = ["-ra", "--strict-config", "--strict-markers"]
6872
filterwarnings = [
6973
"error",
70-
# pydantic warns about ReadOnly TypedDict items not being enforced at runtime.
71-
# That's expected here — we rely on type-checker enforcement, not pydantic mutation guards.
72-
"ignore::UserWarning:pydantic._internal._generate_schema",
74+
# Pydantic validates these public immutable-shape TypedDicts correctly but
75+
# cannot enforce the type checker's ReadOnly mutation restriction.
76+
"ignore:Items? .* using the `ReadOnly` qualifier.*:UserWarning:pydantic._internal._generate_schema",
7377
]
7478

7579
[tool.numpydoc_validation]
@@ -82,6 +86,9 @@ checks = [
8286
"PR06",
8387
]
8488

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

0 commit comments

Comments
 (0)