feat(zarr-metadata): check, JSON against a TypedDict as the typing spec reads it - #4432
Merged
d-v-b merged 9 commits intoSep 26, 2026
Merged
Conversation
…nnotations The second of the stack, and a module on its own: `v3/_typed_json` compiles, once per annotation, a parser for each shape JSON takes -- `int`, `float`, `bool`, `str`, `JSONValue`, a `Literal`, `tuple[T, ...]` and `tuple[T1, T2]`, a union, a TypedDict, a record dataclass, `Mapping[str, V]`, a `NewType`, and `| UNSET` for a member a document may leave out -- and the writer that is its inverse. A parser is `(value, loc, state) -> (typed value, problems)`, generic in a state it hands through untouched, so a caller's leaf can read its own shapes at any depth: the entity layer's leaf, in the next PR, reads a field that holds another entity in a scope. The module knows nothing of entities. Read once per class, cached: `field_hints` resolves a dataclass's annotations where each class was defined, under PEP 649 as before it, and skips class variables, which `declared_class_vars` reports so a base can owe one. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…defines it The definition layer makes a configuration's TypedDict the one declaration of its JSON, so the checker has to read a TypedDict the way the typing spec does, and it did not: - Whether a key is required came from `NotRequired` wrappers alone, so `total=False` was ignored, and callers that asked `__required_keys__` instead got a different answer: the runtime works that out before the annotations are evaluated, and a qualifier written as a string, as `from __future__ import annotations` writes every one, is invisible to it. `typeddict_keys` now reads `Required` and `NotRequired` off the evaluated annotations and falls back on the declaring class's `total` for a key with neither. - Every TypedDict was treated as closed. `closed`, `extra_items` and openness are now read as the spec says, inherited from the bases when a class says nothing, which the runtime does not record; bases that disagree are a `TypeError`. - The typed value kept a closed TypedDict's unknown keys, so a value typed as the TypedDict held keys the type says cannot exist. It is now a new dict of the keys the type admits, in the order they came. - `None` is JSON null, a type alias reads as what it stands for, and a TypedDict or alias that holds itself compiles instead of recursing without end. An annotation that does not resolve is a `TypeError` naming its TypedDict at any depth, not a `NameError` from inside. Parsers are pure, `(value, loc) -> (typed value, problems)`. The state argument was for the entity layer's scoped reads; the definition layer finds nested fields in the typed value instead, so the branch of a union that did not match leaves nothing behind. The record dataclasses, the writers, `| UNSET` members, `field_hints` and `declared_class_vars` were the entity layer's too, and have no caller in this stack. `test_typed_json_properties` holds the checker to a reference predicate worked out from a drawn spec -- TypedDicts with bases, per-class `total`, qualifiers in any order, every kind of openness, classes that hold themselves -- built with evaluated and with postponed annotations, over JSON values of capped depth that conform, nearly conform, or are drawn at large. Each of the bugs above, put back, fails it. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The checker was private, so a layer meant to stand alone gave a caller nothing to call until the definition layer. `zarr_metadata.typed_json` is its door: `check(value, SomeTypedDict)` type-checks a JSON value against any TypedDict -- the package's own document and configuration types, or a caller's -- and gives back a value of it, or None, with every problem located; `typeddict_keys` says what a TypedDict declares. The module moves out of `v3`, since nothing in it is version-specific. Every TypedDict the package declares now compiles, and a test says so: the v2 `dtype` alias names itself through a string, which is now resolved in the alias's module. What other runtime checkers do, and what their test suites catch, was surveyed first -- pydantic, typing-inspection, typeguard, cattrs, msgspec, trycast, typedload, beartype, typing_extensions and CPython -- and taken in: - Each annotation is evaluated in the module of the class that wrote it. `get_type_hints` reads what a subclass inherited in the subclass's module, so a base's `x: Foo` read as the subclass's `Foo`, a silent wrong answer, and a string nested in a base's annotation failed to resolve at all (CPython #90531). pydantic and typeguard get the first right; none of them the second. - A union of TypedDicts tagged by a `Literal` key is read by the branch the tag names, so a gzip with a bad level is reported as a gzip; an untagged union is reported by its closest branch; and of clean TypedDict branches, the one declaring the most of the value's keys reads it. - A key both `Required` and `NotRequired`, and runtime keys that are not the annotated keys, are a `TypeError`. - A recursive alias is named in a message rather than expanded. - Tests: every declaration under both `typing.TypedDict` and `typing_extensions.TypedDict`; bases in other modules holding names their subclasses' modules lack or mean otherwise; redeclared and narrowed keys, diamonds, quoted qualifiers, keys that are not names. The property suite now builds each class in a module of its own, mixes postponed and evaluated classes, quotes names by hand, redeclares read-only keys, notes a failing example's source, and asserts it still reaches each of these. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first tests that import it are the checker's, so it joins the test group here. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing raises `MetadataValidationError` from a rule; a caller that collects problems extends with the tuple `problem()` returns. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`check(None, SomeTypedDict)` came back `(None, ())`: no value and no problem. `refine_json` gives JSON's `null` as None with no problem, and `check` read a None refinement as "not JSON, and its problems say why". It now asks the problems whether the value is JSON, so `null` is checked like any other value, and refused as not an object. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The checker imported `ValidationProblem`, `ProblemKind`, `refine_json` and `is_json` from the model, so the layer that stands alone depended on the one it feeds. They move, with the rest of what reads JSON and reports on it, into `zarr_metadata._json`: the problem types, `MetadataValidationError`, `refine_json` and `refine_user_data`, `validate_json`, `is_json`, `parse_json` and `arrays_to_tuples`. `zarr_metadata.model` exports every name it did; the checker, and the model's own modules, import from where the names now live. Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
Documentation build overview
|
d-v-b
marked this pull request as ready for review
September 26, 2026 21:22
This was referenced Sep 27, 2026
This was referenced Sep 27, 2026
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.
🤖 AI text below 🤖
zarr_metadata.typed_json.check(value, SomeTypedDict)type-checks a JSON value against any TypedDict (the package's own document and configuration types, or a caller's) and gives back a value of it, or None, with every problem located. It stands on its own: nothing it imports iszarr_metadata.model's.A TypedDict, read as the typing spec defines it, whatever the runtime's attributes say:
Required/NotRequired, or else by thetotalof the class that declared it, read off the evaluated annotations.__required_keys__cannot see a qualifier written as a string, which underfrom __future__ import annotationsis every one.closed,extra_itemsand openness come from the class, or from its bases when it says neither, which the runtime does not record. A key a closed TypedDict does not declare is reported as the new problem kindunknown_keyand left out, and the value still comes back.typeddict_keys(T)says what a TypedDict declares.Surveyed first. How pydantic, typing-inspection, typeguard, cattrs, msgspec, trycast, typedload and beartype check TypedDicts, and what their test suites and typing_extensions' and CPython's catch. What that decided here:
get_type_hintsreads what a subclass inherited in the subclass's module, so a base'sx: Fooreads as the subclass'sFoo, a silent wrong answer, and a string nested in a base's annotation fails to resolve (CPython #90531). The checker evaluates each class's own keys in its own module. pydantic and typeguard get the first case right; none of the libraries surveyed gets the second.Literalkey is read by the branch the tag names, so a gzip with a bad level is reported as a gzip rather than as four blosc problems. An untagged union is reported by its closest branch, and of clean TypedDict branches the one declaring the most of the value's keys reads it.RequiredandNotRequired, and runtime keys that are not the annotated keys, are aTypeError. A recursive alias is named in a message rather than expanded.AnnotatedandReadOnly, recursive aliases.check(None, SomeTypedDict)refuses JSON'snullas not an object, rather than answering no value and no problem.JSON and its problems move below the model, from
zarr_metadata.model._validationintozarr_metadata._json: the problem types,MetadataValidationError,refine_jsonandrefine_user_data,validate_json,is_json,parse_jsonandarrays_to_tuples. The checker reports with them, and the model is one of the checker's users, so they sit below both.zarr_metadata.modelexports every name it did.Every TypedDict the package declares compiles, and a test says so. The v2
dtypealias names itself through a string, which is now resolved in the alias's module.Tests. Unit tests per shape and per TypedDict rule, with every declaration read under both
typing.TypedDictandtyping_extensions.TypedDict, evaluated and postponed, and bases in other modules holding names their subclasses' modules lack or mean otherwise.test_typed_json_propertiesholds the checker to a reference predicate worked out from a drawn spec: each class is built in a module of its own, postponed and evaluated classes are mixed, names are quoted by hand, and read-only keys are redeclared. Values are JSON of capped depth that conform, nearly conform, or are drawn at large. Reach tests keep the strategy drawing each of those cases, and putting back any of the bugs found while writing the checker fails the suite.hypothesisjoins the package's test dependencies for these.Review question: does
checkread each shape, and each TypedDict, as the typing spec does, and is it useful to someone holding nothing but JSON and the package's types?Where this goes. This is the first of a stack that reads a document's metadata fields (codecs, data types, chunk grids and the rest) against their definitions, with the checker compiling each definition's configuration TypedDict. The later layers are open on my fork for review first:
must_understand: falserefused at every extension point (d-v-b#353), metadata fields read against their definitions (d-v-b#350, with its core reviewable alone in d-v-b#349), and the field in a document (d-v-b#355). This PR stands without them.🤖 Generated with Claude Code