Skip to content

feat(zarr-metadata): check, JSON against a TypedDict as the typing spec reads it - #4432

Merged
d-v-b merged 9 commits into
zarr-developers:mainfrom
d-v-b:feat/zarr-metadata-typed-json-check
Sep 26, 2026
Merged

d-v-b merged 9 commits into
zarr-developers:mainfrom
d-v-b:feat/zarr-metadata-typed-json-check

Conversation

@d-v-b

@d-v-b d-v-b commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

🤖 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 is zarr_metadata.model's.

from zarr_metadata import ZarrV3ArrayMetadataJSON
from zarr_metadata.typed_json import check

document, problems = check(json.loads(raw), ZarrV3ArrayMetadataJSON)

A TypedDict, read as the typing spec defines it, whatever the runtime's attributes say:

  • A key is required by its own Required/NotRequired, or else by the total of the class that declared it, read off the evaluated annotations. __required_keys__ cannot see a qualifier written as a string, which under from __future__ import annotations is every one.
  • closed, extra_items and 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 kind unknown_key and left out, and the value still comes back.
  • Each annotation is evaluated in the module of the class that wrote it.
  • 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_hints reads what a subclass inherited in the subclass's module, so a base's x: Foo reads as the subclass's Foo, 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.
  • 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 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.
  • 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.
  • Where this is ahead of every library surveyed, tests pin it: inherited openness, qualifiers under PEP 563 and under Annotated and ReadOnly, recursive aliases.
  • Generic TypedDicts are refused: no library surveyed substitutes type arguments through bases correctly, and a refusal beats a silent pass.
  • check(None, SomeTypedDict) refuses JSON's null as not an object, rather than answering no value and no problem.

JSON and its problems move below the model, from zarr_metadata.model._validation into zarr_metadata._json: the problem types, MetadataValidationError, refine_json and refine_user_data, validate_json, is_json, parse_json and arrays_to_tuples. The checker reports with them, and the model is one of the checker's users, so they sit below both. zarr_metadata.model exports every name it did.

Every TypedDict the package declares compiles, and a test says so. The v2 dtype alias 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.TypedDict and typing_extensions.TypedDict, evaluated and postponed, and bases in other modules holding names their subclasses' modules lack or mean otherwise. test_typed_json_properties holds 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. hypothesis joins the package's test dependencies for these.

Review question: does check read 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: false refused 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

d-v-b and others added 8 commits September 26, 2026 22:00
…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
@github-actions github-actions Bot added needs release notes Automatically applied to PRs which haven't added release notes zarr-metadata Specific to the zarr-metadata sub-package labels Sep 26, 2026
@read-the-docs-community

read-the-docs-community Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 zarr-metadata | 🛠️ Build #34779175 | 📁 Comparing d564657 against latest (cad2561)

  🔍 Preview build  

3 files changed
+ api/typed_json/index.html
± api/index.html
± api/model/index.html

@d-v-b
d-v-b marked this pull request as ready for review September 26, 2026 21:22
@d-v-b
d-v-b merged commit c383b22 into zarr-developers:main Sep 26, 2026
40 checks passed
@d-v-b
d-v-b deleted the feat/zarr-metadata-typed-json-check branch September 27, 2026 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs release notes Automatically applied to PRs which haven't added release notes zarr-metadata Specific to the zarr-metadata sub-package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant