Add unified JSON metadata validation via msgspec (#3285)#4063
Conversation
Introduce zarr.core.json_parse.parse_json, a single type-annotation-driven validator that consolidates the scattered per-field parse_* helpers. Handles primitives, Literal, unions/Optional, fixed and variadic tuples, Sequence/list (coerced to tuple), Mapping/dict, and TypedDict, with a bool-vs-int safe primitive check. Adds tests/test_json_parse.py (94 tests) and a changelog fragment. No existing call sites migrated yet; this is the proof-of-direction module. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r-developers#3285) Pilot migration delegating three representative helpers to the unified parse_json validator. Public signatures and return types are unchanged. parse_zarr_format re-wraps parse_json's ValueError/TypeError as MetadataValidationError with the original message to preserve observable behavior. parse_json is imported function-locally to avoid circular imports. Focused suites green: test_common/test_config/test_metadata/test_json_parse = 430 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ns (zarr-developers#3285) Apply ruff check/format to satisfy the Lint CI hook. Keep typing.Union/Optional spellings in tests (with noqa) to cover that origin path alongside X | Y. Rework _parse_typeddict to derive required/optional from get_type_hints(include_extras=True) + __total__ instead of __required_keys__, so class-syntax NotRequired is detected even when 'from __future__ import annotations' stringizes the hints (as in zarr's metadata modules). test_json_parse + test_common + test_metadata = 393 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lopers#3285) get_origin(hint) is Required/NotRequired tripped mypy's comparison-overlap and unreachable checks; typing origin as Any keeps the runtime check while satisfying mypy. Full 'uv run --frozen mypy' is clean (190 files); ruff check/format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delegate the literal/type check of parse_indexing_order, parse_node_type, parse_node_type_array, parse_separator, two parse_zarr_format variants (group, v2), and parse_name to the unified parse_json. Public signatures and return types unchanged; each preserves its original exception type and message (wrapping parse_json's ValueError/TypeError into MetadataValidationError / NodeTypeValidationError / the original ValueError/TypeError where tests assert on them). parse_json imported function-locally to avoid circular imports. Focused suites + full mypy green (1196 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s#3285) Delegate the int/bool type check of parse_checksum, parse_clevel, parse_blocksize, parse_typesize, parse_gzip_level, and parse_zstd_level to parse_json, keeping each helper's range/bound check and exact error messages. Original exception types are preserved by wrapping parse_json's ValueError/TypeError. Note: parse_json rejects bool where the old isinstance(data, int) accepted it; verified no caller/test passes a bool to these, so this is a deliberate, more-correct strictening. parse_json imported function-locally. Codec suite + full mypy green (794 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4063 +/- ##
==========================================
+ Coverage 93.62% 93.73% +0.11%
==========================================
Files 90 91 +1
Lines 11936 11960 +24
==========================================
+ Hits 11175 11211 +36
+ Misses 761 749 -12
🚀 New features to boost your workflow:
|
|
Hi @d-v-b, I put up a draft so you can see where I'm heading before I take it further. So far it adds On the de-dup side, I've moved 16 of the scattered Before I migrate the rest, a few things I'd like your read on:
I'm holding off on the One heads up on CI: the failing Test jobs are a pre-existing collection error in Let me know what you think on the four questions and I'll keep going. |
|
@vup903, if we are willing to add |
|
nearly all of the types we need are now defined in zarr-metadata, so we should investigate if msgspec can handle all of those types correctly. zarr-metadata itself uses pydantic for its test suite, and I think i ended up choosing that after tryng msgspec and running into some issues, but I don't recall what they were. Maybe something to do with tuples? If its helpful, we could widen the JSON collection types in zarr-metadata to |
|
Thanks @chuckwondo, that's a good idea. I spent some time poking at The good news is that most of the categories work out of the box, including the one you were worried about, @d-v-b:
The two things that don't work are both load-bearing for the real
So my read is that msgspec handles the leaf/primitive/tuple cases really well, but it can't currently validate the two zarr-specific constructs (
Happy to prototype whichever direction you prefer. What's your instinct here? |
these are both pretty important features! it's too bad that msgspec doesn't support them. but i don't think we need to reach 100% with just 1 tool. I would be interested in seeing how far we could get with msgspec, even if it doesn't get us all the way. The metric here should be increasing code simplicity (removing code) + catching bugs. |
…#3285) Delete the bespoke parse_json runtime type checker and route JSON metadata validation through msgspec.convert, which handles the type coercions zarr needs (Literal membership, int/bool strictness, list-to-tuple). A small hand-written fallback (validate_json_value) covers the recursive JSON values msgspec cannot build a schema for, and adds an explicit nesting-depth limit. The registry/dtype/numcodec parsers stay hand-written since they need runtime lookups. Also fixes a latent generator-exhaustion bug in parse_storage_transformers, adds msgspec as a dependency (pinned in the min_deps env), and preserves the exception types and messages every migrated helper raised. Net ~555 lines removed. Tests, mypy and ruff all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed a full version of this. Everything msgspec can handle goes through It comes out to around 555 fewer lines, and it shook out a couple of latent bugs Have a look whenever you get a chance, and let me know if you'd rather tweak the |
| try: | ||
| return cast('Literal["array"]', convert(data, Literal["array"])) | ||
| except (ValueError, TypeError) as exc: | ||
| msg = f"Invalid value for 'node_type'. Expected 'array'. Got '{data}'." | ||
| raise NodeTypeValidationError(msg) from exc |
There was a problem hiding this comment.
I feel like this pattern: try cast(convert(...)), handle exception with error should be encapsulated in a reusable routine
There was a problem hiding this comment.
we probably dont want that reusable routine to know the field name being validated. So we probably want to core validation routine to emit ValueError("Expected instance of $TYPE, got $data), and the caller should re-raise with a separate exception that raises ValueError("Failed to parse input for $FIELD) from the type-specific exception
There was a problem hiding this comment.
Good idea, thanks. I moved it into a small parse_field helper like you
suggested: convert stays field-agnostic and just raises ValueError("Expected instance of {type}, got {data}"), and parse_field adds the field context
(Failed to parse input for {field}) with whatever exception type the caller
needs. Pushed it.
…lopers#3285) Address review feedback: factor the repeated convert-then-re-raise pattern out of each per-field parser. convert now raises a field-agnostic ValueError("Expected instance of TYPE, got DATA"); the new parse_field wraps it and re-raises with field context ("Failed to parse input for FIELD") using the caller's chosen exception type, chaining the original error. Every per-field parser collapses to a one-liner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Marked this ready for review since the msgspec migration is complete and the |
Summary
This PR consolidates zarr's scattered per-field JSON metadata validation
(
parse_*helpers, ~42 of them acrosssrc/zarr) for #3285.JSON type coercion now goes through
msgspec.convert(Literalmembership,int/boolstrictness, list-to-tuple,TypedDictwithNotRequired), whichcovers most of what the old helpers did by hand. A small fallback
(
zarr.core.json_parse.validate_json_value) handles the two things msgspeccan't: the recursive
JSON/JSONValuetype alias (msgspec rejects it atschema-build time) and PEP 728
extra_items=extension fields (msgspecsilently drops them). The registry/dtype/numcodec-bound parsers (
parse_codecs,parse_chunk_grid,parse_filters,dtype.from_json_scalar, …) stayhand-written since they need runtime registry lookups msgspec can't do.
zarr.core.json_parse.parse_field(data, type_, field, error=...)wrapsconvertand adds field context to the error message, so every migratedper-field parser is now a one-liner.
What changed
src/zarr/core/json_parse.py— rewritten aroundmsgspec.convert:convert(field-agnostic type coercion),parse_field(adds field context tothe error, using the caller's exception type),
validate_json_value(recursive JSON validation with an explicit nesting-depth limit).
parse_field/convert):parse_name,parse_order,parse_bool(common.py),parse_indexing_order(
config.py),parse_separator(chunk_key_encodings.py),parse_zarr_format/
parse_node_type(group.py),parse_zarr_format(metadata/v2.py),parse_zarr_format/parse_node_type_array(metadata/v3.py),parse_typesize/parse_clevel/parse_blocksize(codecs/blosc.py),parse_gzip_level(codecs/gzip.py),parse_zstd_level/parse_checksum(
codecs/zstd.py). Every migrated helper keeps its original exception typeand message.
ArrayV3Metadata.from_dictvalidatesattributesthroughvalidate_json_value.parse_storage_transformersused to calllen(tuple(data))andthen return the original
data, which silently exhausted a one-shot iterableand could return a non-tuple typed as one. Now materializes once into a tuple.
pyproject.toml— addedmsgspec>=0.19as a dependency (and pinned in themin_depstest env).tests/test_json_parse.py— tests forconvert,parse_field,validate_json_value(including the nesting-depth limit), and a regressiontest for the
parse_storage_transformersfix.Net effect
json_parse.pygoes from 291 to ~90 lines;test_json_parse.pyfrom 479 to~130. Net ~550 lines removed across the change. Two latent bugs caught: no
bound on JSON nesting depth (stack-exhaustion risk on adversarial
attributes),and the
parse_storage_transformersiterator-exhaustion bug above.Testing
test_json_parse,test_metadata,test_common,test_config,test_codecs,test_group) plus thetest_array/
test_apiintegration suites: all passing.uv run --frozen mypy: clean (189 source files).ruff check/ruff format --check: clean.History
This started as a hand-written
parse_jsonruntime type checker (the originaldraft). @chuckwondo suggested
msgspeccould replace most of that logic, and@d-v-b asked whether it could handle the real
zarr-metadatatypes; a spikeconfirmed msgspec covers the JSON-shaped cases but not
JSONValueorextra_items=. @d-v-b's guidance was not to require 100% coverage from onetool, and to optimize for less code + catching bugs — this PR is the resulting
full migration, plus a follow-up from his review to encapsulate the
convert-then-re-raise pattern intoparse_field.Closes #3285.