Skip to content

fix(dtype): resolve struct fields with the registry that resolves the struct - #4387

Merged
d-v-b merged 15 commits into
zarr-developers:mainfrom
d-v-b:feat/dtype-json-resolver
Sep 24, 2026
Merged

d-v-b merged 15 commits into
zarr-developers:mainfrom
d-v-b:feat/dtype-json-resolver

Conversation

@d-v-b

@d-v-b d-v-b commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

When we read a JSON value from the data_type field in metadata, we resolve the JSON value to a ZDType class, construct the class, or error. We model this operation as f(JSON value) -> ZDtype, but it's more like f(JSON value, registry of data type classes) -> ZDType, because the operation depends on both the actual JSON value AND the set of ZDType classes available.

This PR makes that explicit. Instead of magically conjuring (read: using the global config) a data type registry inside parsing routines, we pass the registry to the parsing routine explicitly via a Context object. We also pass the relevant zarr format and the current location of the parser in the data type JSON, which is relevant for nested dtypes like struct and ensures that an error can point to where exactly the problematic dtype was.

claude wrote this, and I was absolutely delighted when the need for this came up because I plan to extend this pattern to codecs, which will be a great improvement.

@ilan-gold @maxrjones this doesn't add public API but I would like to in later PRs: I think adding Context to our group / array constructors is a great way to keep our internal logic isolated from the mutable global config. The config would be used to create the default context, but consumers don't see the global config. they just see a piece of data.

🤖 AI text below 🤖

Follows #4385 (merged): its review asked for dtype resolution to take a resolver, to support recursive dtypes. Independent of #4386. It also renames the changes/351.bugfix.md fragment #4385 merged under its fork pull request number to 4385.bugfix.md.

Problem

JSON → data type resolution always ended in the module-global data_type_registry. A DataTypeRegistry other than the default only took effect at the top level. A structured data type resolved its fields with the global registry no matter which registry resolved the struct. get_data_type_from_json, parse_dtype and the metadata parsers had no way to take a registry at all.

Change

Two things are kept apart, as in zarr-metadata (Context passed as context=, with the location threaded separately):

  • zarr.registry.Context: which extensions a read may resolve, grouped by kind. It's a value, the same for a whole read, and chosen by the caller. For now it holds data_types. Codecs, chunk key encodings and chunk grids can be added later as fields with defaults, without changing any signature. Context.default() refers to the default registries themselves, so it sees later registrations.
  • Resolver (zarr.core.context): a context positioned in a document. It holds the context, the Zarr format, and the location in the document. A composite resolves each contained extension with resolver.at(*keys).resolve_data_type(json). A codec read that meets a data type, like cast_value will once codecs join, hands the same context to a data type read through the same object. The Context / Resolver pair mirrors referencing.Registry / referencing.Resolver in python-jsonschema.
  • Locations are JSON locations, reported as JSON Pointers. This stays unambiguous across kinds, e.g. No Zarr data type found that matches 'nope' at /configuration/fields/1/data_type. A Zarr V2 data type is located by its name as written in the document, so metadata parsing can later prefix /dtype.
  • ZDType._from_json_resolved(data, *, resolver): the one way a registry creates a data type. Its default ignores the resolver and calls from_json(data, zarr_format=resolver.zarr_format), so every existing data type, including ones that override from_json, is created exactly as before. A data type that contains other data types overrides it to resolve them with the resolver.
  • Structured / Struct override _from_json_resolved, and their from_json takes context=. A field whose data type matches nothing raises NestedDataTypeValidationError (in zarr.errors, a DataTypeValidationError). The containing data type decides that a failure is nested, and the registry re-raises it rather than trying other data types. This restores a specific message for a bad struct field, which fix(dtype): accept any byte order for byte-order-irrelevant v2 dtype names #4385 had reduced to a mismatch of the whole struct.
  • context= keyword (default: Context.default()) on get_data_type_from_json, get_data_type_from_native_dtype, parse_dtype and parse_data_type.
  • Entry-point data types now load (separate commit). zarr.registry collected them into DataTypeRegistry._lazy_load_list, but nothing in src/ called _lazy_load(). get, match_dtype and match_json now load them first, as the other registries do.

Precedents

Newer parsing libraries agree on passing the decoding context down explicitly, rather than resolving nested types through a global registry:

  • pydantic v2: every custom type's __get_pydantic_core_schema__(source, handler) receives handler, and container types use it for their inner types.
  • Rust serde: every type's Deserialize::deserialize(deserializer) receives the deserializer.
  • cattrs: hooks are registered on a Converter instance, and hook factories can receive the converter.
  • python-jsonschema: it deprecated the implicit RefResolver in favour of a referencing.Registry passed as registry=, with a Resolver positioned in the document. That's the model for Context / Resolver here.
  • zarr-metadata: resolve(data, context), with the context passed to every entity's coerce.

Where they differ is whether types that contain nothing receive the context too. Protocols designed from scratch (pydantic, serde, zarr-metadata) give it to every type, and leaves ignore it. Protocols retrofitted onto an existing API split it off: serde's separate DeserializeSeed trait, cattrs checking how many arguments a factory takes, aeson's FromJSON1. This PR takes the from-scratch shape while staying compatible: the hook is on every type, and its default delegates to the existing from_json.

Why not a keyword on ZDType.from_json

That was the first version. It broke on our own entry-point test data type (tests/package_with_entrypoint), which overrides from_json(cls, data, *, zarr_format). With the registry passing an extra keyword to every class, a single registered data type like that raises TypeError and takes down all JSON resolution. A separate hook on every data type, whose default delegates to from_json, avoids that, and the registry needs no type check to decide which data types get the resolver. test_resolve_data_type_overriding_from_json pins this.

Why the resolver carries a location

The location only serves diagnostics: it changes nothing about which data type is resolved. It lets a failure deep in DataTypeRegistry._match_json, which does not know it is resolving a struct field, say where in the document it happened, e.g. No Zarr data type found that matches 'nope' at /configuration/fields/1/data_type.

Tracking the location is standard; libraries get it in one of two ways:

  • Passed down with each call: aeson's Parser carries a JSONPath that composites extend with <?>, zod's parse context carries a path, and zarr-metadata passes loc alongside the context. The location is complete when the error is raised.
  • Built up as errors propagate: pydantic-core adds outer location segments to errors as they bubble up (hence ValidationError's loc tuples), and python-jsonschema's descend() prepends each child's path segment before re-raising.

This PR passes it down. Building it up would mean each composite catches, prepends its segment, and re-raises. The exception would then either carry the location as structured data or have its message text rewritten at every level. Neither is how zarr-python errors should work; structured problem locations belong to zarr-metadata's problem reports. Passing it down keeps the message complete at the raise, and a composite's only job is resolver.at(...). It also matches zarr-metadata's loc.

Costs:

  • The location is why Resolver is its own type; without it, a resolver would be a context plus the Zarr format.
  • A Zarr V2 data type is located by its name as written in the document, not by the {"name": ..., "object_codec_id": ...} form zarr-python reads it in, so metadata parsing can prefix /dtype.
  • Data types in consolidated metadata are not yet prefixed with /consolidated_metadata/metadata/<name> (see feat: open arrays and groups with a Context #4388).

Not in this PR

  • Plumbing context= through metadata parsing, the array and group classes, and open_array / open_group / open / open_consolidated. That's the next PR, which builds on this one.
  • Found while testing: a Zarr V2 structured data type nested in another doesn't round trip. to_json writes [['outer', [['x', '|i1']]]], which the v2 structured shape check then rejects. This predates this PR.

Tests

  • One test covering both zarr formats and every route into resolution (registry.match_json, get_data_type_from_json(context=), parse_dtype(context=), Struct.from_json(context=)): a struct whose field data type exists only in a local registry resolves that field from the local registry.
  • Error cases:
    • without a context, that field isn't found in the default registry (NestedDataTypeValidationError at /configuration/fields/0/data_type);
    • a local registry lacking a field's data type rejects the struct, even though the default registry has that data type;
    • parse_dtype with a native dtype only searches the given registry;
    • a missing field data type is reported with its JSON Pointer: /1/1 for v2, and two levels deep in v3.
  • A data type that overrides from_json works as a struct field when resolved through a registry.
  • test_entrypoint_dtype no longer calls _lazy_load() by hand, so it now checks automatic loading. The fixture that clears pending entry points also clears the data type registry's list.

🤖 Generated with Claude Code

…s their container

A structured data type resolved its field data types with the default data
type registry, whatever registry resolved the structured data type itself,
so a DataTypeRegistry other than the default could not supply field data
types.

- DTypeResolver (exported from zarr.dtype) is the signature of
  DataTypeRegistry.match_json.
- HasNestedDTypes is a mix-in for data types that contain data types. A
  registry creates them with the _from_json_nested hook, passing its own
  match_json as the resolver. Other data types are created with from_json as
  before, so data types that override from_json keep working.
- Structured and Struct are HasNestedDTypes, and their from_json takes an
  optional resolver (the default registry when omitted).
- get_data_type_from_json, get_data_type_from_native_dtype, parse_dtype and
  parse_data_type take an optional registry (the default registry when
  omitted).

Assisted-by: ClaudeCode:claude-opus-5-5
zarr.registry collects data type entry points into
DataTypeRegistry._lazy_load_list, but nothing in src called _lazy_load, so
data types registered through the "zarr.data_type" entry point group never
loaded. get, match_dtype and match_json now load them first, as the other
registries do in their getters. _lazy_load imports each entry point once
instead of twice.

The test fixture that clears pending entry points now clears the data type
registry's too, since _collect_entrypoints does not return it, and the
entry point test no longer loads them by hand.

Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Replaces the DTypeResolver callable with a context object, as mature
decoders do (Jackson's DeserializationContext, Gson's Gson instance,
PyYAML's loader), so it can grow without changing the hook's signature.

- DTypeContext (exported from zarr.dtype) holds the registry, the Zarr
  format and the path of the data type within the data types that contain
  it. Composites resolve each contained data type with
  context.child(name).resolve(json). The Zarr format is part of the context
  because contained data types always share their container's format.
- HasNestedDTypes._from_json_nested takes the context.
- Structured.from_json / Struct.from_json take registry= like the other
  entry points, instead of a resolver.
- NestedDataTypeValidationError (a DataTypeValidationError) reports a
  contained data type that matches nothing, with its path. The registry
  re-raises it instead of trying other data types, since the JSON has the
  shape of the containing data type. This restores a specific error for an
  invalid struct field (e.g. "... matches {'name': '|i9', ...} at path
  ['a']"), which #351 had reduced to a mismatch of the whole struct.

Assisted-by: ClaudeCode:claude-opus-5-5
…state

DTypeContext mixed two things: which extensions a read may resolve (the
same for a whole read, chosen by the caller) and where the read has got to
(changing at every nested step). That does not extend to other kinds of
extension: a codec read that meets a data type (cast_value) must hand the
same extensions to a data type read.

- zarr.registry.Context is the set of extensions, by kind. It holds only
  data_types for now; codecs, chunk key encodings and chunk grids can be
  added as fields with defaults. Context.default() refers to the default
  registries. This mirrors zarr-metadata's Context, passed as context=.
- Reading (zarr.core.context) is the state of a read: the context, the Zarr
  format and the location in the document. Composites read contained
  extensions with reading.at(*keys).resolve_data_type(json).
- The location is the JSON location in the document, reported as a JSON
  Pointer, so it stays unambiguous once codecs nest data types. A Zarr V2
  data type is located by its name as written in the document.
- The containing data type decides that a failure is nested: Structured
  raises NestedDataTypeValidationError for a field that matches nothing,
  since a non-empty location no longer means "inside another data type"
  once metadata parsing starts reads at /data_type.
- The dtype entry points and Structured.from_json take context= instead of
  registry=.

Assisted-by: ClaudeCode:claude-opus-5-5
… request

It was merged under its fork pull request number (351), which towncrier
would link to the wrong pull request.

Assisted-by: ClaudeCode:claude-opus-5-5
@codecov

codecov Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.33%. Comparing base (9abad7f) to head (55a8175).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4387      +/-   ##
==========================================
+ Coverage   94.31%   94.33%   +0.02%     
==========================================
  Files          92       93       +1     
  Lines       13069    13127      +58     
==========================================
+ Hits        12326    12384      +58     
  Misses        743      743              
Files with missing lines Coverage Δ
src/zarr/core/context.py 100.00% <100.00%> (ø)
src/zarr/core/dtype/npy/structured.py 94.35% <100.00%> (+0.60%) ⬆️
src/zarr/core/dtype/registry.py 95.89% <100.00%> (+0.97%) ⬆️
src/zarr/core/dtype/wrapper.py 97.91% <100.00%> (+0.13%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

A Resolver is a Context positioned in a document; it resolves the
extensions there. The pair mirrors referencing.Registry and
referencing.Resolver in python-jsonschema. "Reading" read as a verb.

Assisted-by: ClaudeCode:claude-opus-5-5
ZDType._from_json_resolved(data, *, resolver) is the one way a registry
creates a data type. Its default ignores the resolver and calls from_json in
the resolver's Zarr format, so data types that override from_json keep
working; a data type that contains others (Structured, Struct) overrides it
to resolve them. This replaces the HasNestedDTypes mix-in and the registry's
issubclass branch: every type receives the context and leaves ignore it, as
in pydantic's __get_pydantic_core_schema__(source, handler), serde's
Deserialize, and zarr-metadata's resolve(data, context).

Assisted-by: ClaudeCode:claude-opus-5-5
@d-v-b
d-v-b marked this pull request as ready for review September 23, 2026 17:51
Context, the context= keywords, and the nested data type error were public
API without a caller that needs them, and their shape is expected to change
once codecs join the context or zarr-python reads metadata through
zarr-metadata.

- zarr.registry no longer exports Context; it stays in zarr.core.context.
- get_data_type_from_json, get_data_type_from_native_dtype, parse_dtype,
  parse_data_type and Structured.from_json are unchanged from main.
  Structured's _from_json_v2/_v3 resolve fields with the default registry;
  a DataTypeRegistry resolves them with itself through _from_json_resolved.
- The nested error is _NestedDataTypeValidationError in the dtype registry
  module, a DataTypeValidationError, and no longer in zarr.errors.

What remains user-visible is bug fixes: a structured data type read by a
DataTypeRegistry resolves its fields with that registry, a bad field is
reported with its JSON location, and entry point data types load.

Assisted-by: ClaudeCode:claude-opus-5-5
@d-v-b d-v-b changed the title feat(dtype): read nested data types in a Context, with their JSON location fix(dtype): resolve struct fields with the registry that resolves the struct Sep 23, 2026
@d-v-b
d-v-b merged commit 5c37f97 into zarr-developers:main Sep 24, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant