diff --git a/packages/zarr-metadata/changes/312.feature.md b/packages/zarr-metadata/changes/312.feature.md
new file mode 100644
index 0000000000..8dc3b6688a
--- /dev/null
+++ b/packages/zarr-metadata/changes/312.feature.md
@@ -0,0 +1,8 @@
+Added `zarr_metadata.msgspec`, an optional msgspec integration module: field
+types over the core metadata models plus a `dec_hook` (and a `make_dec_hook`
+composer for applications with hooks of their own) that route raw documents
+through the models' strict `from_json` parser, so the models can be used as
+field types in `msgspec.Struct` classes and with `msgspec.json.decode` /
+`msgspec.convert`. Invalid documents surface as `msgspec.ValidationError`
+with the loc-annotated problem messages. msgspec stays an optional
+dependency of `zarr-metadata`; the module requires msgspec 0.19 or newer.
diff --git a/packages/zarr-metadata/docs/api/index.md b/packages/zarr-metadata/docs/api/index.md
index 5e230c7aa2..c6ab7169c9 100644
--- a/packages/zarr-metadata/docs/api/index.md
+++ b/packages/zarr-metadata/docs/api/index.md
@@ -10,6 +10,8 @@ The package is organized to mirror the structure of the Zarr specifications:
structural validators, loc-aware parsers, and the `UNSET` sentinel
- [`zarr_metadata.pydantic`](pydantic.md) — optional Pydantic field types
over the models
+- [`zarr_metadata.msgspec`](msgspec.md) — optional msgspec field types and
+ decode hook over the models
- [`zarr_metadata.v2`](v2.md) — `TypedDict` shapes for Zarr v2 documents
(`.zarray`, `.zgroup`, `.zattrs`, `.zmetadata`)
- [`zarr_metadata.v3`](v3/index.md) — `TypedDict` shapes for Zarr v3
diff --git a/packages/zarr-metadata/docs/api/msgspec.md b/packages/zarr-metadata/docs/api/msgspec.md
new file mode 100644
index 0000000000..ff6aae5783
--- /dev/null
+++ b/packages/zarr-metadata/docs/api/msgspec.md
@@ -0,0 +1,5 @@
+---
+title: msgspec
+---
+
+::: zarr_metadata.msgspec
diff --git a/packages/zarr-metadata/docs/index.md b/packages/zarr-metadata/docs/index.md
index 58e4f290c6..04a184c025 100644
--- a/packages/zarr-metadata/docs/index.md
+++ b/packages/zarr-metadata/docs/index.md
@@ -35,6 +35,9 @@ closely model the content of the Zarr specifications, such as:
- **Optional Pydantic integration** ([`zarr_metadata.pydantic`](api/pydantic.md),
requires Pydantic 2.13 or newer): each model as a Pydantic field type that
validates raw documents through the same strict parser.
+- **Optional msgspec integration** ([`zarr_metadata.msgspec`](api/msgspec.md),
+ requires msgspec 0.19 or newer): field types and a decode hook that route
+ raw documents through the same strict parser.
## What this is for
@@ -67,6 +70,20 @@ A bare `TypeAdapter` over a public document `TypedDict` is a coercive shape
adapter, not a Zarr conformance validator; it may coerce values or discard
members that the strict model parser rejects.
+The optional msgspec integration does the same through msgspec's decode hook:
+
+```python
+import msgspec
+import zarr_metadata.msgspec as zmm
+
+metadata = msgspec.convert(raw, zmm.ZarrV3ArrayMetadata, dec_hook=zmm.dec_hook)
+encoded = metadata.to_key_value()["zarr.json"]
+```
+
+Serialization stays explicit (`to_json` / `to_key_value`): msgspec encodes
+dataclasses natively, so no hook can make `msgspec.json.encode` emit the
+canonical document — see [`zarr_metadata.msgspec`](api/msgspec.md).
+
## Validation boundary
The model validators enforce the declared document structure and a small set
diff --git a/packages/zarr-metadata/mkdocs.yml b/packages/zarr-metadata/mkdocs.yml
index 18e1fc8c35..77a4fe6349 100644
--- a/packages/zarr-metadata/mkdocs.yml
+++ b/packages/zarr-metadata/mkdocs.yml
@@ -17,6 +17,7 @@ nav:
- api/index.md
- ' zarr_metadata.model': api/model.md
- ' zarr_metadata.pydantic': api/pydantic.md
+ - ' zarr_metadata.msgspec': api/msgspec.md
- ' zarr_metadata.v2': api/v2.md
- ' zarr_metadata.v3':
- api/v3/index.md
diff --git a/packages/zarr-metadata/pyproject.toml b/packages/zarr-metadata/pyproject.toml
index a58d3579a1..e4a93eac53 100644
--- a/packages/zarr-metadata/pyproject.toml
+++ b/packages/zarr-metadata/pyproject.toml
@@ -47,7 +47,9 @@ Changelog = "https://github.com/zarr-developers/zarr-python/blob/main/packages/z
Documentation = "https://zarr-metadata.readthedocs.io/"
[dependency-groups]
-test = ["pytest", "pydantic>=2.13", "jsonschema"]
+# The msgspec floor matches the requirement of the zarr package, the
+# integration's motivating consumer.
+test = ["pytest", "pydantic>=2.13", "jsonschema", "msgspec>=0.19"]
docs = [
# Pins match the zarr-python docs environment in the repo-root
# pyproject.toml so the two sites render with the same toolchain.
diff --git a/packages/zarr-metadata/src/zarr_metadata/msgspec.py b/packages/zarr-metadata/src/zarr_metadata/msgspec.py
new file mode 100644
index 0000000000..8161a29fe2
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/msgspec.py
@@ -0,0 +1,220 @@
+"""Optional msgspec integration: field types and a decode hook over the core models.
+
+Importing this module requires msgspec; the core package deliberately does
+not depend on it, so this module is never imported by `zarr_metadata` itself.
+
+msgspec's extension point is a decode hook consulted only for annotated
+types msgspec does not support natively — and the core models are
+dataclasses, a kind msgspec supports natively, so annotating a field with a
+core model class directly would engage msgspec's own field-by-field
+dataclass coercion and bypass `from_json` (the single source of truth for
+structural validation and normalization). Each name this module exports is
+therefore a runtime marker class that msgspec treats as a custom type,
+forcing every value for the field through this module's `dec_hook`. Each
+marker registers its core model class as a virtual subclass, so the values
+`dec_hook` produces satisfy msgspec's type check while the instances ARE
+the core classes — they interoperate freely with non-msgspec code
+(equality, isinstance, nesting). Static type checkers see each field type
+as its core model class, so `manifest.metadata` below is a
+`zarr_metadata.model.ZarrV3ArrayMetadata`. That alias holds for
+annotations only: at runtime the exported names are empty markers with
+none of the core classes' methods, so construct, parse, and serialize
+through `zarr_metadata.model` — `zmm.ZarrV3ArrayMetadata.from_json(...)`
+type-checks but fails.
+
+`dec_hook` routes a raw document through `from_json` and passes an existing
+model instance through unchanged. `MetadataValidationError` subclasses
+`ValueError`, so a failed parse surfaces as a `msgspec.ValidationError`
+carrying the loc-annotated problem messages, with msgspec appending the
+path of the failing field (``- at `$.metadata` ``).
+
+Usage:
+
+ import msgspec
+
+ import zarr_metadata.msgspec as zmm
+
+ class ArrayManifest(msgspec.Struct):
+ path: str
+ metadata: zmm.ZarrV3ArrayMetadata
+
+ manifest = msgspec.json.decode(data, type=ArrayManifest, dec_hook=zmm.dec_hook)
+
+The same hook serves `msgspec.convert` — e.g.
+`msgspec.convert(doc, zmm.ZarrV3ArrayMetadata, dec_hook=zmm.dec_hook)` —
+and a prebuilt `msgspec.json.Decoder`. An application that already has a
+decode hook of its own composes via `make_dec_hook(wrapped=...)`.
+
+Three msgspec limits shape what this module can offer:
+
+- Serialization cannot be delegated: msgspec's encoders are value-driven
+ and consult their `enc_hook` only for objects msgspec cannot encode
+ natively, which a dataclass never is, so no hook can route a model
+ instance through `to_json`. Encoding a model directly either raises (the
+ `UNSET` sentinel is unencodable) or silently emits a raw field dump that
+ is NOT the canonical document (no shorthand collapse, no omit-empty
+ conventions). Serialize explicitly: put `model.to_json()` — the
+ canonical document — wherever msgspec-encodable output is needed.
+- Unions may contain at most one hook-handled type, so a field cannot be
+ typed as, say, array-or-group metadata. Decode such a field as an
+ untyped mapping and dispatch on its content.
+- JSON Schema generation (`msgspec.json.schema`) rejects custom types
+ unless the caller supplies a `schema_hook`, so a Struct using these
+ field types cannot produce a schema out of the box. When a JSON Schema
+ for the document forms is what you need, `zarr_metadata.pydantic` is
+ the schema-capable integration.
+"""
+
+from __future__ import annotations
+
+import abc
+from typing import TYPE_CHECKING, Any, Final
+
+# The import is unused by name: it makes the module fail fast where msgspec
+# is absent (everything exported here is inert without it), mirroring how
+# `zarr_metadata.pydantic` fails at import when pydantic is absent.
+import msgspec # noqa: F401 # pyright: ignore[reportUnusedImport]
+
+from zarr_metadata import model as _model
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ # For static type checkers the field types ARE the core model classes.
+ ZarrV3ArrayMetadata = _model.ZarrV3ArrayMetadata
+ ZarrV2ArrayMetadata = _model.ZarrV2ArrayMetadata
+ ZarrV3GroupMetadata = _model.ZarrV3GroupMetadata
+ ZarrV2GroupMetadata = _model.ZarrV2GroupMetadata
+ ZarrV3ConsolidatedMetadata = _model.ZarrV3ConsolidatedMetadata
+ ZarrV2ConsolidatedMetadata = _model.ZarrV2ConsolidatedMetadata
+ ZarrV3MetadataField = _model.ZarrV3NamedConfig
+else:
+ # At runtime each field type is a marker: an empty ABC msgspec treats as
+ # a custom type (so `dec_hook` is consulted) with the core model class
+ # registered as a virtual subclass (so the core instances `dec_hook`
+ # returns satisfy msgspec's isinstance check on hook results). The
+ # registrations are derived from `_DECODERS` below, keeping one table
+ # that pairs each marker with its core class.
+
+ class _FieldType(abc.ABC): # noqa: B024
+ """Base of the runtime field-type markers; never instantiated."""
+
+ __slots__ = ()
+
+ def __new__(cls) -> None:
+ raise TypeError(
+ f"{cls.__name__} is a field-type marker for msgspec annotations only; "
+ "construct instances via the corresponding zarr_metadata.model class"
+ )
+
+ class ZarrV3ArrayMetadata(_FieldType):
+ """Field type for a v3 array metadata document (`zarr.json` content)."""
+
+ class ZarrV2ArrayMetadata(_FieldType):
+ """Field type for a v2 array metadata document (merged `.zarray` + `.zattrs` form)."""
+
+ class ZarrV3GroupMetadata(_FieldType):
+ """Field type for a v3 group metadata document (`zarr.json` content)."""
+
+ class ZarrV2GroupMetadata(_FieldType):
+ """Field type for a v2 group metadata document (merged `.zgroup` + `.zattrs` form)."""
+
+ class ZarrV3ConsolidatedMetadata(_FieldType):
+ """Field type for v3 inline consolidated metadata."""
+
+ class ZarrV2ConsolidatedMetadata(_FieldType):
+ """Field type for a v2 `.zmetadata` document."""
+
+ class ZarrV3MetadataField(_FieldType):
+ """Field type for one normalized v3 metadata extension envelope."""
+
+
+_DECODERS: Final[dict[type, tuple[type, Callable[[object], object]]]] = {
+ ZarrV3ArrayMetadata: (_model.ZarrV3ArrayMetadata, _model.ZarrV3ArrayMetadata.from_json),
+ ZarrV2ArrayMetadata: (_model.ZarrV2ArrayMetadata, _model.ZarrV2ArrayMetadata.from_json),
+ ZarrV3GroupMetadata: (_model.ZarrV3GroupMetadata, _model.ZarrV3GroupMetadata.from_json),
+ ZarrV2GroupMetadata: (_model.ZarrV2GroupMetadata, _model.ZarrV2GroupMetadata.from_json),
+ ZarrV3ConsolidatedMetadata: (
+ _model.ZarrV3ConsolidatedMetadata,
+ _model.ZarrV3ConsolidatedMetadata.from_json,
+ ),
+ ZarrV2ConsolidatedMetadata: (
+ _model.ZarrV2ConsolidatedMetadata,
+ _model.ZarrV2ConsolidatedMetadata.from_json,
+ ),
+ ZarrV3MetadataField: (_model.ZarrV3NamedConfig, _model.ZarrV3NamedConfig.from_json),
+}
+"""Marker class -> (pass-through core class, document parser)."""
+
+if not TYPE_CHECKING:
+ for _marker, (_core_cls, _) in _DECODERS.items():
+ _marker.register(_core_cls)
+
+
+def _lookup(type: type) -> tuple[type, Callable[[object], object]] | None:
+ """Return the decode entry for `type`, or None for types not covered here.
+
+ msgspec can hand a hook parametrized annotation objects, which may be
+ unhashable; those are never this module's markers, so a failed hash is
+ an ordinary miss rather than an error.
+ """
+ try:
+ return _DECODERS.get(type)
+ except TypeError:
+ return None
+
+
+def _decode(entry: tuple[type, Callable[[object], object]], obj: Any) -> Any:
+ core_cls, parse = entry
+ if isinstance(obj, core_cls):
+ return obj
+ return parse(obj)
+
+
+def dec_hook(type: type, obj: Any) -> Any:
+ """Decode `obj` for a field annotated with one of this module's field types.
+
+ Pass as `dec_hook=` to `msgspec.json.decode`, `msgspec.convert`, or a
+ `msgspec.json.Decoder`. An existing core model instance passes through
+ unchanged; anything else is parsed by the core model's `from_json`. A
+ type this module does not cover raises `NotImplementedError`, msgspec's
+ convention for "still unsupported"; to keep decoding custom types of
+ your own alongside these, chain your hook with `make_dec_hook`.
+ """
+ entry = _lookup(type)
+ if entry is None:
+ raise NotImplementedError(f"Objects of type {type} are not supported")
+ return _decode(entry, obj)
+
+
+def make_dec_hook(wrapped: Callable[[type, Any], Any] | None = None) -> Callable[[type, Any], Any]:
+ """Return a decode hook that also delegates unknown types to `wrapped`.
+
+ The returned hook handles this module's field types exactly like
+ `dec_hook` and hands every other type to `wrapped`, so an application's
+ existing custom-type decoding keeps working alongside the model field
+ types. With no `wrapped` hook this returns `dec_hook` itself.
+ """
+ if wrapped is None:
+ return dec_hook
+
+ def hook(type: type, obj: Any) -> Any:
+ entry = _lookup(type)
+ if entry is None:
+ return wrapped(type, obj)
+ return _decode(entry, obj)
+
+ return hook
+
+
+__all__ = [
+ "ZarrV2ArrayMetadata",
+ "ZarrV2ConsolidatedMetadata",
+ "ZarrV2GroupMetadata",
+ "ZarrV3ArrayMetadata",
+ "ZarrV3ConsolidatedMetadata",
+ "ZarrV3GroupMetadata",
+ "ZarrV3MetadataField",
+ "dec_hook",
+ "make_dec_hook",
+]
diff --git a/packages/zarr-metadata/tests/model/_cases.py b/packages/zarr-metadata/tests/model/_cases.py
index 15faa65539..2c55f7439d 100644
--- a/packages/zarr-metadata/tests/model/_cases.py
+++ b/packages/zarr-metadata/tests/model/_cases.py
@@ -6,9 +6,28 @@
import pytest
+from zarr_metadata.model import ZarrV2ArrayMetadata, ZarrV3ArrayMetadata
+
if TYPE_CHECKING:
from contextlib import AbstractContextManager
+# Canonical documents shared by the integration test modules
+# (test_pydantic_module.py, test_msgspec_module.py), so a model change that
+# alters a canonical document is corrected in one place.
+V3_ARRAY_DOC = dict(ZarrV3ArrayMetadata.create_default(shape=(4,)).to_json())
+V2_ARRAY_DOC = dict(ZarrV2ArrayMetadata.create_default(shape=(4,), chunks=(2,)).to_json())
+V3_GROUP_DOC = {"zarr_format": 3, "node_type": "group", "attributes": {"a": 1}}
+V2_GROUP_DOC = {"zarr_format": 2, "attributes": {"a": 1}}
+V3_CONSOLIDATED_DOC = {
+ "kind": "inline",
+ "must_understand": False,
+ "metadata": {"a": dict(V3_ARRAY_DOC)},
+}
+V2_CONSOLIDATED_DOC = {
+ "zarr_consolidated_format": 1,
+ "metadata": {".zgroup": {"zarr_format": 2}},
+}
+
TIn = TypeVar("TIn")
TOut = TypeVar("TOut")
diff --git a/packages/zarr-metadata/tests/model/test_msgspec_module.py b/packages/zarr-metadata/tests/model/test_msgspec_module.py
new file mode 100644
index 0000000000..e89dbf2b96
--- /dev/null
+++ b/packages/zarr-metadata/tests/model/test_msgspec_module.py
@@ -0,0 +1,241 @@
+"""Tests for `zarr_metadata.msgspec`, the optional msgspec integration module.
+
+Unlike pydantic, msgspec consults its extension hooks only for types it does
+not support natively — and it supports dataclasses natively, so the module's
+field types are runtime markers rather than the core model classes (see the
+module docstring). Instances are still the CORE model classes (no parallel
+hierarchy), so decoded values interoperate freely with non-msgspec code.
+"""
+
+import msgspec
+import msgspec.json
+import pytest
+
+import zarr_metadata.msgspec as zmm
+from tests.model._cases import (
+ V2_ARRAY_DOC,
+ V2_CONSOLIDATED_DOC,
+ V2_GROUP_DOC,
+ V3_ARRAY_DOC,
+ V3_CONSOLIDATED_DOC,
+ V3_GROUP_DOC,
+)
+from zarr_metadata.model import (
+ ZarrV2ArrayMetadata,
+ ZarrV2ConsolidatedMetadata,
+ ZarrV2GroupMetadata,
+ ZarrV3ArrayMetadata,
+ ZarrV3ConsolidatedMetadata,
+ ZarrV3GroupMetadata,
+ ZarrV3NamedConfig,
+)
+
+FIELD_CASES = [
+ pytest.param(zmm.ZarrV3ArrayMetadata, ZarrV3ArrayMetadata, V3_ARRAY_DOC, id="array-v3"),
+ pytest.param(zmm.ZarrV2ArrayMetadata, ZarrV2ArrayMetadata, V2_ARRAY_DOC, id="array-v2"),
+ pytest.param(zmm.ZarrV3GroupMetadata, ZarrV3GroupMetadata, V3_GROUP_DOC, id="group-v3"),
+ pytest.param(zmm.ZarrV2GroupMetadata, ZarrV2GroupMetadata, V2_GROUP_DOC, id="group-v2"),
+ pytest.param(
+ zmm.ZarrV3ConsolidatedMetadata,
+ ZarrV3ConsolidatedMetadata,
+ V3_CONSOLIDATED_DOC,
+ id="consolidated-v3",
+ ),
+ pytest.param(
+ zmm.ZarrV2ConsolidatedMetadata,
+ ZarrV2ConsolidatedMetadata,
+ V2_CONSOLIDATED_DOC,
+ id="consolidated-v2",
+ ),
+ pytest.param(zmm.ZarrV3MetadataField, ZarrV3NamedConfig, {"name": "bytes"}, id="field-v3"),
+]
+
+
+@pytest.mark.parametrize(("field_type", "model_cls", "doc"), FIELD_CASES)
+def test_field_type_decodes_and_passes_through(
+ field_type: object, model_cls: type, doc: dict[str, object]
+) -> None:
+ """Each field type parses its raw document into the CORE model class via
+ from_json, passes existing instances through unchanged, and works for
+ `msgspec.convert` and `msgspec.json.decode` alike."""
+ model = msgspec.convert(doc, field_type, dec_hook=zmm.dec_hook)
+ assert type(model) is model_cls
+ assert msgspec.convert(model, field_type, dec_hook=zmm.dec_hook) is model
+ decoded = msgspec.json.decode(msgspec.json.encode(doc), type=field_type, dec_hook=zmm.dec_hook)
+ assert decoded == model
+
+
+def test_registry_is_consistent() -> None:
+ """One decode table drives everything: it covers exactly the exported
+ field types, each parser is its core class's from_json, and each core
+ class is registered as a virtual subclass of its marker (so instances
+ dec_hook returns satisfy msgspec's check on hook results)."""
+ field_types = {getattr(zmm, name) for name in zmm.__all__ if name[0] == "Z"}
+ assert field_types == set(zmm._DECODERS)
+ for marker, (core_cls, parse) in zmm._DECODERS.items():
+ assert parse == core_cls.from_json
+ assert issubclass(core_cls, marker)
+
+
+def test_struct_field_round_trip() -> None:
+ """A Struct field decodes a raw document through from_json (the library's
+ normalization applies), and re-encoding the canonical document emitted by
+ to_json revalidates to an equal manifest — serialization routes through
+ to_json explicitly because msgspec's encoders cannot delegate dataclasses
+ (see the module docstring)."""
+
+ class ArrayManifest(msgspec.Struct):
+ path: str
+ metadata: zmm.ZarrV3ArrayMetadata
+
+ data = msgspec.json.encode({"path": "a/b", "metadata": V3_ARRAY_DOC})
+ manifest = msgspec.json.decode(data, type=ArrayManifest, dec_hook=zmm.dec_hook)
+ assert isinstance(manifest.metadata, ZarrV3ArrayMetadata)
+ assert manifest.metadata.shape == (4,)
+
+ out = msgspec.json.encode({"path": manifest.path, "metadata": manifest.metadata.to_json()})
+ assert msgspec.json.decode(out, type=ArrayManifest, dec_hook=zmm.dec_hook) == manifest
+
+
+def test_wrapped_hook_composes() -> None:
+ """make_dec_hook keeps an application's own custom-type decoding working
+ alongside the model field types — including custom types whose annotation
+ objects are unhashable — and with no wrapped hook it is dec_hook."""
+
+ class Fraction:
+ def __init__(self, value: float) -> None:
+ self.value = value
+
+ def consumer_hook(type: type, obj: object) -> object:
+ if type is Fraction:
+ return Fraction(obj)
+ raise NotImplementedError(type)
+
+ class Manifest(msgspec.Struct):
+ scale: Fraction
+ metadata: zmm.ZarrV3GroupMetadata
+
+ hook = zmm.make_dec_hook(consumer_hook)
+ manifest = msgspec.convert({"scale": 0.5, "metadata": V3_GROUP_DOC}, Manifest, dec_hook=hook)
+ assert isinstance(manifest.scale, Fraction)
+ assert manifest.scale.value == 0.5
+ assert type(manifest.metadata) is ZarrV3GroupMetadata
+
+ # An unhashable annotation object is an ordinary miss, delegated onward.
+ class Unhashable:
+ __hash__ = None # type: ignore[assignment]
+
+ unhashable = Unhashable()
+ seen: list[object] = []
+
+ def recording_hook(type: type, obj: object) -> object:
+ seen.append(type)
+ return obj
+
+ assert zmm.make_dec_hook(recording_hook)(unhashable, 1) == 1
+ assert seen == [unhashable]
+
+ assert zmm.make_dec_hook() is zmm.dec_hook
+
+
+def test_decoding_is_stateless_and_normalizing() -> None:
+ """Decoding the same document twice yields equal but independent model
+ instances, and from_json's normalization converts JSON arrays to tuples,
+ so mutating a list in the source document cannot reach a decoded model.
+ (from_json copies mappings shallowly; only to_json guarantees a document
+ that shares no mutable state.)"""
+ doc = {"zarr_format": 3, "node_type": "group", "attributes": {"a": [1]}}
+ first = msgspec.convert(doc, zmm.ZarrV3GroupMetadata, dec_hook=zmm.dec_hook)
+ second = msgspec.convert(doc, zmm.ZarrV3GroupMetadata, dec_hook=zmm.dec_hook)
+ assert first == second
+ assert first is not second
+ doc["attributes"]["a"].append(2)
+ assert first.attributes == {"a": (1,)}
+
+
+def test_invalid_document_surfaces_problems_in_validation_error() -> None:
+ """A defective document fails as msgspec.ValidationError carrying the
+ loc-annotated problem messages from MetadataValidationError, plus
+ msgspec's own path annotation for the failing field."""
+
+ class ArrayManifest(msgspec.Struct):
+ metadata: zmm.ZarrV3ArrayMetadata
+
+ doc = dict(V3_ARRAY_DOC)
+ del doc["chunk_key_encoding"]
+ with pytest.raises(
+ msgspec.ValidationError, match=r"chunk_key_encoding: missing required key.*\$\.metadata"
+ ):
+ msgspec.convert({"metadata": doc}, ArrayManifest, dec_hook=zmm.dec_hook)
+
+
+def test_dec_hook_rejects_uncovered_types() -> None:
+ """A type the module does not cover raises NotImplementedError, msgspec's
+ convention for a still-unsupported custom type — including annotation
+ objects that are not hashable."""
+ with pytest.raises(NotImplementedError, match="int"):
+ zmm.dec_hook(int, 1)
+ with pytest.raises(NotImplementedError, match="not supported"):
+ zmm.dec_hook([], 1)
+
+
+def test_field_type_markers_cannot_be_instantiated() -> None:
+ """The exported names are for annotations only: instantiating a runtime
+ marker raises instead of minting a hollow object that would pass
+ msgspec's result check while having none of the core class's fields."""
+ with pytest.raises(TypeError, match="field-type marker"):
+ zmm.ZarrV3ArrayMetadata()
+
+
+def test_core_class_annotations_bypass_dec_hook() -> None:
+ """Why the field types are markers: msgspec supports dataclasses natively
+ and never consults dec_hook for them, so annotating with a core model
+ class would engage msgspec's field-by-field coercion instead of from_json
+ (today it fails to resolve the models' TYPE_CHECKING-only annotations).
+ If this test starts failing because decoding succeeded or the hook was
+ called, msgspec's dataclass handling changed — revisit the markers."""
+ calls: list[type] = []
+
+ def hook(type: type, obj: object) -> object:
+ calls.append(type)
+ raise NotImplementedError(type)
+
+ with pytest.raises((NameError, TypeError)):
+ msgspec.convert(V3_ARRAY_DOC, ZarrV3ArrayMetadata, dec_hook=hook)
+ assert calls == []
+
+
+def test_native_encode_cannot_emit_canonical_documents() -> None:
+ """Why serialization must route through to_json explicitly: msgspec's
+ encoders never consult enc_hook for dataclasses, so a model instance
+ either fails to encode (the UNSET sentinel is unencodable) or encodes as
+ a raw field dump that is not the canonical document. If either half
+ starts failing, msgspec grew an encode override point — revisit the
+ module's serialization guidance."""
+ with pytest.raises(TypeError, match="unsupported"):
+ msgspec.json.encode(ZarrV3ArrayMetadata.from_json(V3_ARRAY_DOC))
+
+ config = ZarrV3NamedConfig.from_json({"name": "bytes"})
+ assert msgspec.json.decode(msgspec.json.encode(config)) != config.to_json()
+
+
+def test_core_package_does_not_import_msgspec() -> None:
+ """Importing zarr_metadata (in a fresh interpreter) must not import
+ msgspec: the integration is opt-in via zarr_metadata.msgspec."""
+ import subprocess
+ import sys
+
+ code = "import sys, zarr_metadata; assert 'msgspec' not in sys.modules, 'leaked'"
+ subprocess.run([sys.executable, "-c", code], check=True)
+
+
+def test_module_requires_msgspec() -> None:
+ """Importing the integration module without msgspec fails fast at import
+ (its exports are inert without msgspec), mirroring zarr_metadata.pydantic."""
+ import subprocess
+ import sys
+
+ code = "import sys; sys.modules['msgspec'] = None; import zarr_metadata.msgspec"
+ proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=False)
+ assert proc.returncode != 0
+ assert "msgspec" in proc.stderr
diff --git a/packages/zarr-metadata/tests/model/test_pydantic_module.py b/packages/zarr-metadata/tests/model/test_pydantic_module.py
index d15b3f118c..be9165c448 100644
--- a/packages/zarr-metadata/tests/model/test_pydantic_module.py
+++ b/packages/zarr-metadata/tests/model/test_pydantic_module.py
@@ -13,6 +13,14 @@
from pydantic import BaseModel, TypeAdapter, ValidationError
import zarr_metadata.pydantic as zmp
+from tests.model._cases import (
+ V2_ARRAY_DOC,
+ V2_CONSOLIDATED_DOC,
+ V2_GROUP_DOC,
+ V3_ARRAY_DOC,
+ V3_CONSOLIDATED_DOC,
+ V3_GROUP_DOC,
+)
from zarr_metadata.model import (
ZarrV2ArrayMetadata,
ZarrV2ConsolidatedMetadata,
@@ -23,20 +31,6 @@
ZarrV3NamedConfig,
)
-V3_ARRAY_DOC = dict(ZarrV3ArrayMetadata.create_default(shape=(4,)).to_json())
-V2_ARRAY_DOC = dict(ZarrV2ArrayMetadata.create_default(shape=(4,), chunks=(2,)).to_json())
-V3_GROUP_DOC = {"zarr_format": 3, "node_type": "group", "attributes": {"a": 1}}
-V2_GROUP_DOC = {"zarr_format": 2, "attributes": {"a": 1}}
-V3_CONSOLIDATED_DOC = {
- "kind": "inline",
- "must_understand": False,
- "metadata": {"a": dict(V3_ARRAY_DOC)},
-}
-V2_CONSOLIDATED_DOC = {
- "zarr_consolidated_format": 1,
- "metadata": {".zgroup": {"zarr_format": 2}},
-}
-
FIELD_CASES = [
pytest.param(zmp.ZarrV3ArrayMetadata, ZarrV3ArrayMetadata, V3_ARRAY_DOC, id="array-v3"),
pytest.param(zmp.ZarrV2ArrayMetadata, ZarrV2ArrayMetadata, V2_ARRAY_DOC, id="array-v2"),