Repository navigation
fix(dtype): resolve struct fields with the registry that resolves the struct - #4387
Merged
Merged
Conversation
…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
Assisted-by: ClaudeCode:claude-opus-5-5
Codecov Report✅ All modified and coverable lines are covered by tests. 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
🚀 New features to boost your workflow:
|
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
When we read a JSON value from the
data_typefield in metadata, we resolve the JSON value to aZDTypeclass, construct the class, or error. We model this operation asf(JSON value) -> ZDtype, but it's more likef(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
Contextobject. 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 likestructand 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
Contextto 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.mdfragment #4385 merged under its fork pull request number to4385.bugfix.md.Problem
JSON → data type resolution always ended in the module-global
data_type_registry. ADataTypeRegistryother 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_dtypeand the metadata parsers had no way to take a registry at all.Change
Two things are kept apart, as in zarr-metadata (
Contextpassed ascontext=, 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 holdsdata_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 withresolver.at(*keys).resolve_data_type(json). A codec read that meets a data type, likecast_valuewill once codecs join, hands the same context to a data type read through the same object. TheContext/Resolverpair mirrorsreferencing.Registry/referencing.Resolverin python-jsonschema.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 callsfrom_json(data, zarr_format=resolver.zarr_format), so every existing data type, including ones that overridefrom_json, is created exactly as before. A data type that contains other data types overrides it to resolve them with the resolver.Structured/Structoverride_from_json_resolved, and theirfrom_jsontakescontext=. A field whose data type matches nothing raisesNestedDataTypeValidationError(inzarr.errors, aDataTypeValidationError). 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()) onget_data_type_from_json,get_data_type_from_native_dtype,parse_dtypeandparse_data_type.zarr.registrycollected them intoDataTypeRegistry._lazy_load_list, but nothing insrc/called_lazy_load().get,match_dtypeandmatch_jsonnow 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:
__get_pydantic_core_schema__(source, handler)receiveshandler, and container types use it for their inner types.Deserialize::deserialize(deserializer)receives the deserializer.Converterinstance, and hook factories can receive the converter.RefResolverin favour of areferencing.Registrypassed asregistry=, with aResolverpositioned in the document. That's the model forContext/Resolverhere.resolve(data, context), with the context passed to every entity'scoerce.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
DeserializeSeedtrait, cattrs checking how many arguments a factory takes, aeson'sFromJSON1. This PR takes the from-scratch shape while staying compatible: the hook is on every type, and its default delegates to the existingfrom_json.Why not a keyword on
ZDType.from_jsonThat was the first version. It broke on our own entry-point test data type (
tests/package_with_entrypoint), which overridesfrom_json(cls, data, *, zarr_format). With the registry passing an extra keyword to every class, a single registered data type like that raisesTypeErrorand takes down all JSON resolution. A separate hook on every data type, whose default delegates tofrom_json, avoids that, and the registry needs no type check to decide which data types get the resolver.test_resolve_data_type_overriding_from_jsonpins 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:
Parsercarries aJSONPaththat composites extend with<?>, zod's parse context carries apath, and zarr-metadata passeslocalongside the context. The location is complete when the error is raised.ValidationError'sloctuples), and python-jsonschema'sdescend()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'sloc.Costs:
Resolveris its own type; without it, a resolver would be a context plus the Zarr format.{"name": ..., "object_codec_id": ...}form zarr-python reads it in, so metadata parsing can prefix/dtype./consolidated_metadata/metadata/<name>(see feat: open arrays and groups with a Context #4388).Not in this PR
context=through metadata parsing, the array and group classes, andopen_array/open_group/open/open_consolidated. That's the next PR, which builds on this one.to_jsonwrites[['outer', [['x', '|i1']]]], which the v2 structured shape check then rejects. This predates this PR.Tests
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.NestedDataTypeValidationErrorat/configuration/fields/0/data_type);parse_dtypewith a native dtype only searches the given registry;/1/1for v2, and two levels deep in v3.from_jsonworks as a struct field when resolved through a registry.test_entrypoint_dtypeno 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