zarr-metadata¶
Basic tools for modelling Zarr metadata, with minimal dependencies.
zarr-metadata is developed in the
zarr-python repository
and released independently of zarr itself. Install it with:
Who needs this¶
This library supports software that interacts with Zarr metadata documents.
What this is¶
This is not a full Zarr implementation. It's a collection of data structures and routines that closely model the content of the Zarr specifications, such as:
- Typed JSON shapes (
zarr_metadata.v2andzarr_metadata.v3):TypedDictdefinitions andLiteralaliases for the JSON documents specified by the Zarr v2 and Zarr v3 specifications, plus types for zarr-extensions and a few widely-used-but-unspecified entities (e.g. consolidated metadata). - Document models (
zarr_metadata.model): immutable models of whole metadata documents, each one a document and the scope it was read in, with readers that report every problem at its location, and store-key (de)serialization. A document produced byto_jsonshares no mutable state with the model that produced it. - Definitions and scopes (
zarr_metadata.definition, with each format's kinds inzarr_metadata.v3.definitionandzarr_metadata.v2.definition): each extension's configuration as aTypedDictwith its rules, and the scope that decides which definitions read a document.zarr_metadata.v3andzarr_metadata.v2export every definition and scope of their format, such asBYTES_CODECandCORE_AND_EXTENSIONS. - JSON type checking (
zarr_metadata.typed_json):checkchecks a JSON value against anyTypedDictand reports every problem with its location. - Problems (
zarr_metadata.problems): the problem values every reader returns, and the error that carries them. - JSON Schema (
zarr_metadata.json_schema): the JSON Schema of aTypedDict, a v3 field, or a wholezarr.json. - Optional Pydantic integration (
zarr_metadata.pydantic, requires Pydantic 2.13 or newer): each model as a Pydantic field type that validates raw documents through the same strict parser.
What this is for¶
-
Static Type Checking The public
TypedDictdefinitions describe the static JSON shape of Zarr metadata. -
Dynamic JSON validation For strict, location-aware validation of JSON loaded from disk, use the model parser:
import json
from zarr_metadata.model import ZarrV3ArrayMetadata
with open("zarr.json", "rb") as f:
raw = json.load(f)
metadata = ZarrV3ArrayMetadata.from_json(raw)
The optional Pydantic integration delegates raw input to the same strict parser and returns the same normalized model class:
from pydantic import TypeAdapter
import zarr_metadata.pydantic as zmp
adapter: TypeAdapter[zmp.ZarrV3ArrayMetadataField] = TypeAdapter(zmp.ZarrV3ArrayMetadataField)
metadata = adapter.validate_python(raw)
encoded = metadata.to_key_value()["zarr.json"]
A field annotated Annotated[ZarrV3ArrayMetadata, zmp.InScope(CONTEXT)]
is read in CONTEXT, whatever the validation context holds, and a model
instance given to it is read again in CONTEXT.
Note that a bare TypeAdapter over a public TypedDict is a coercive shape
adapter, not a Zarr conformance validator; it may coerce values or discard
members that the strict model parser rejects. This is better suited for handling input provided
from Python objects (e.g., user-authored dicts) than JSON.
Validation boundary¶
The readers and model constructors enforce the declared document structure and a small set
of context-free consistency rules, e.g.:
- fixed format literals,
- finite JSON numbers outside attributes and the members nothing in scope reads
- non-negative dimensions
- one dimension_names entry per array dimension.
In a v3 document, validation also checks each extension point: the data
type, the chunk grid, the chunk key encoding, each codec, and each storage
transformer. Each one is checked by the definition that claims its name in
a scope. The scope is CORE_AND_EXTENSIONS unless a context is passed.
If the definition rejects a configuration, the reader rejects it too,
and a configuration key the definition does not declare is reported as
unknown_key. If no definition in the scope claims a name, the reader
does not check that extension point, and the consumer decides whether to
support it.
Making the context and explicit part of metadata parsing allows different libraries, which might support different sets of Zarr features, to formally represent exactly the codecs, data types, etc they support when parsing metadata.
Some checks relate one part of a v3 document to another:
- The fill value is checked against the data type the document names, by that data type's definition.
- The chunk grid is checked against the shape, by the grid's definition.
- The codecs are checked as a pipeline. In order, each codec's definition checks the codec against the chunk it receives. This includes a shard's inner codecs and index codecs.
The readers do no arithmetic on values. For example, they do not check
whether a fill value survives a cast_value round trip.
Some choices that the spec text leaves open, or answers in two ways:
attributesmay holdNaN,Infinityand-Infinity. The spec does not interpret attributes, and zarr-python and xarray write these numbers there (for example, a CF_FillValue). The models read them, andto_key_valuewrites them back as bare tokens, which a strict JSON parser rejects. So may a member the spec does not define when no definition in scope claims it: nothing reads it, so it is kept as written.check, fromzarr_metadata.typed_json, rejects a non-finite number anywhere, including in attributes.- A reader walks at most 256 levels of nesting. A value nested deeper
is an
invalid_valueproblem at the first level past the limit, wherever it is. Every reader, writer and comparison uses one stack frame per level, andcopy.deepcopy(andpicklebefore Python 3.12) uses two. A document at the limit therefore uses about half of the interpreter's default recursion limit, and the rest is left for the caller. consolidated_metadata: nullis a problem. A zarr-python 3.0.x bug wrote it. The spec requires an object, and the package accepts only an object. A reader of those stores removes the key before reading.- An extension name must follow the spec's naming rule,
^[a-z][a-z0-9-_.]+$, or be a URI, which earlier versions of the spec required. Any other name is rejected before any definition is consulted, so""and"foo/bar"are problems, not unknown extensions. must_understand: falseis rejected at every extension point, including codecs and storage transformers, although the core spec names only the data type, chunk grid and chunk key encoding. A reader that skips a codec reads wrong bytes, just as a reader that skips a data type reads wrong values. On an unknown top-level member,must_understandkeeps its meaning: a reader can skip that member.- A v2 datetime or timedelta dtype must have a unit, as in
<M8[ns], because the v2 spec requires the unit in brackets. zarr-python 3 writes<M8or<m8for a NumPydatetime64ortimedelta64with no unit, and the package refuses those dtypes. A codec's dtype parameter, such as thedtypeof adeltafilter, may leave out the unit, because numcodecs writes it that way and runs it. - A v2 codec's dtype parameter is checked against the core v2 data
types, whatever scope the array is read in, because numcodecs reads
the parameter with NumPy. So a scope that widens a data type, for
example to read
<f16, does not widen what a codec's dtype parameter may be.
A v3 regular chunk grid's chunk lengths must be at least 1, even along a
dimension of length 0. The regular grid spec says "Chunk sizes must be
greater than zero". The core spec's wording, "non-zero when the
corresponding dimensions of the arrays have non-zero length", requires
less, but does not explicitly allow anything more. So a document with a
chunk length of 0, which zarr-python 3.0 and 3.1 wrote for an empty
dimension, is rejected.
The readers are strict, so a document that a known writer bug made
invalid has problems. The repair readers in zarr_metadata.model
(read_repaired_node_metadata_v3, read_repaired_node_metadata_v2 and
read_repaired_consolidated_metadata_v2) undo these bugs first, then
read the result strictly, and return each change they made as a Repair:
- A chunk length of 0 in a v3
regularchunk grid, along an empty dimension, which zarr-python 3.0 and 3.1 wrote, is written as 1. consolidated_metadata: nullon a v3 group, which zarr-python 3.0 and 3.1 wrote, is removed.- The
consolidated_metadatamember that zarr-python 3 writes into each.zgroupentry of a.zmetadatabelow the root is removed. - A non-finite v2 fill value that zarr-python 3.0.0 to 3.0.6 wrote as a
bare
NaN,Infinityor-Infinityis written as the string the v2 spec uses, such as"NaN". - A non-finite float or complex fill value in a v3 group's consolidated
metadata, which zarr-python 3.0.0 to 3.0.6 wrote as a bare token, is
written as the string the v3 spec uses, such as
"NaN".
The repair readers do not undo these known writer bugs, so a document with one of them is refused:
- zarr-python 3.0.7 to 3.0.10 wrote the fill value of an
SorVarray in a.zmetadataentry as a JSON array of byte values. The array's own.zarrayfile holds the base64 string that the package reads. - zarr-python 3.0.7 and 3.0.8 wrote the fill value of a v2 datetime array as an ISO 8601 string, not as a count of ticks.
- zarr-python 3.0 and 3.1 wrote a chunk length of 0 in a v3
regularchunk grid along a dimension that is not empty, for an explicit chunk length of 0. - Some releases of zarr-python from 3.0 to 3.2 wrote a chunk length of
truein a v3regularchunk grid forchunks=True, and some releases of 3.0 and 3.1 wrotefalseforchunks=False.
read_array_metadata_v3 reads a document once and returns everything the
read found:
- each field as the scope read it: an
AcceptedFieldwhen a definition claims its name, anUnclaimedFieldwhen none does, or aRefusedField, together with the field's location and the kind it was read as; - each codec, with the chunk it receives;
- every problem;
- the model, when there are no problems.
from_json returns that model, or raises the problems. A consumer can
apply its own policy by walking the fields, without reading anything
twice. with_problems pairs each field with its problems: those located
in the field and in the fields it contains. This matches how zod's
flattenError groups issues. canonical_of returns the simplest
spelling of a field that has no problems, without reading it again. For
example, the code below finds the fields that go beyond the core spec (a
field with no name is already a problem), and the simplest spelling of
each field:
from zarr_metadata.model import read_array_metadata_v3
from zarr_metadata.definition import canonical_of, with_problems
from zarr_metadata.v3.definition import CORE
reading = read_array_metadata_v3(raw)
beyond_core = [
loc
for loc, field in reading.fields()
if field.name is not None and CORE.claimant(field.read_as, field.name) is None
]
simplest = {
loc: canonical_of(field, problems)
for loc, field, problems in with_problems(reading.fields(), reading.problems)
}
metadata = reading.metadata # None when reading.problems is not empty
read_group_metadata_v3 reads a group the same way. It checks the
envelope of the group's consolidated_metadata, and does not read the
documents the member lists (see below). read_node_metadata_v3 reads
a zarr.json of either kind as the node type its node_type names, the
same way a discriminated union uses its tag. A document whose node_type
is neither reads as ZarrV3UnknownNodeMetadataReading, with the problems.
Only its zarr_format is read, so a document of another format is
reported as not v3. node_metadata_from_json_v3 and
node_metadata_from_key_value_v3 build the model of either kind, the same
way the models' own from_json and from_key_value build one.
Consolidated metadata holds the hierarchy below its group, with the group
as the root. The document of the node at /a/b is stored under the key
a/b. The documents and the group must form a tree: only groups have
child nodes, and the parent of every node is present.
ZarrV3ConsolidatedMetadata checks this when it reads the documents. NodeName and
NodePath, in zarr_metadata.v3, are the types of strings that satisfy
the spec's rules for node names and paths. They are modeled on the zarrs
types with the same names. validate_node_name_v3, is_node_name_v3 and
parse_node_name_v3, and the matching node_path functions, check a
string against those rules.
A top-level member that the spec does not define is an extra field. A
scope can hold a definition for one, by its key, in the
array_extra_fields or group_extra_fields slot of ZarrV3Context. A
member whose key a definition claims is read by that definition, like any
other field. Any other member is kept as written, as an UnclaimedField.
The scope is what a reader recognizes, so the model's
must_understand_fields lists each unclaimed member that does not say
"must_understand": false: the spec requires a reader to fail to open a
node with any such member. It covers members only. A data type, chunk
grid, chunk key encoding, codec or storage transformer that nothing in
scope claims is an UnclaimedField too, also when it sits inside another
field, such as a codec inside a shard. A model's unclaimed_fields lists
every field nothing in scope claims, at any depth, by its location, and
leaves out the members that waive must_understand: a reader can open
the node when it is empty. CORE holds
CONSOLIDATED_METADATA_EXTRA_FIELD, which reads a group's
consolidated_metadata as the envelope the spec fixes. A group's
consolidated() reads the documents that member lists, in the group's
own scope.
A v3 model consists of its document and the scope it was read in.
ZarrV3ArrayMetadata(document, context=None) reads the document in the
scope (CORE_AND_EXTENSIONS when none is given) and raises
MetadataValidationError with every problem, so an invalid model is never
built. to_json returns the document as it was written, and
to_key_value writes that same document to store bytes.
Every typed member is a view of that read: each field as the scope read
it (an AcceptedField or an UnclaimedField), and shape, attributes
and the other members as the read refined them. These views are read-only
at every level: a list in the document is shown as a tuple, and an object
as a read-only mapping. to_json returns plain containers.
To change a model, call update. It replaces members of the document with
the given JSON members and reads the result in the model's own scope, so
you do not pass the scope again. A member the spec defines is given by its
name, and any other member in extra_fields, a mapping from its key to its
value, or to UNSET to remove it. A name update does not take raises
TypeError, so a misspelled member is never written. with_context reads the document in
another scope. refined_in also reads the document in another scope, but
only one that claims what this scope left unclaimed and contradicts
nothing; otherwise it raises ScopeConflictError.
A group does not read the documents its consolidated_metadata lists.
Consolidated metadata is a convention of the reference implementation,
not core metadata, so a group holds the member as written, after it
checks the envelope that the core spec fixes. A reader can use the
member as a source of child documents in place of the store: read one
document with read_node_metadata_v3 when that node is opened, or read
all of them with ZarrV3ConsolidatedMetadata, or with
read_consolidated_metadata_v3, which returns problems instead of
raising them. A document with a problem then fails only when it is read.
Pass context=group.context to read the documents in the group's own
scope; with no context, they are read in CORE_AND_EXTENSIONS.
To write consolidated metadata, build a ZarrV3ConsolidatedMetadata and
pass it to the group's update. The group reads the listed models in its
own scope, refuses one that its scope reads differently, and writes the
document. The model reads every document strictly, and also accepts node
models as entries. Such an entry is accepted when its claims refine into
the model's scope, and refused at its path otherwise. To consolidate
children that were read in several scopes, consolidate them in the
union of those scopes. Every reader takes context=None for the
default scope, CORE_AND_EXTENSIONS.
Two models are equal when they represent the same document, even if each
is spelled differently. Values the package interprets (each field, and the
fill value against its data type) are compared by their canonical
spelling, as returned by canonical_of and canonical_fill_value. For
example, "NaN" and "0x7fc00000" are the same float32 fill value,
0.0 and -0.0 are two different fill values, and a v3 blosc codec
under noshuffle, which ignores typesize, is the same codec with or
without one. A v2
document's dtype, codecs and fill value are compared the same way: <b1
and |b1 are one dtype, in dtype and in a codec's dtype parameter
alike, and a v2 codec that leaves out a parameter is
the same as one that writes the numcodecs default, so
{"id": "zstd", "level": 0} equals
{"id": "zstd", "level": 0, "checksum": false}. A v2 blosc typesize
is kept whatever the shuffle, unlike in v3: v2 codecs are defined by
numcodecs, which writes it into each chunk's header, so two v2 blosc
codecs that differ only in it encode different bytes, though they decode
the same data. The v3 spec defines its blosc codec, and says noshuffle
ignores typesize. A v2 node with no .zattrs equals one with an
empty .zattrs, as a v3 document with and without an empty
attributes are equal. Values the package does
not interpret (attributes, extra fields, and the configuration of a field
that nothing in the scope claims) are compared as JSON text. JSON text
distinguishes true from 1 and -0.0 from 0.0, and treats NaN as
equal to itself. Equal models have equal hashes, but may write different
documents, because to_json writes each document as it was given. A
model holds nothing that can be changed in place.
node_metadata_json_schema_v3 writes the rules the readers apply as a
JSON Schema (draft 2020-12). Use it in an editor that checks a zarr.json
while you write it, or with a validator in another language. Each
extension point is described as a field as its scope reads it: a claimed
configuration follows its definition's TypedDict, including bounds, and a
name that nothing in the scope claims may have any configuration. The fill
value must be a valid fill value for the data type the document names.
field_json_schema_v3(kind, *, context=None) writes the schema of one field, and
json_schema writes the schema of any TypedDict as check reads it. All
three are in zarr_metadata.json_schema. A schema describes each member on its own, not the rules that
relate members to each other, so a document the schema accepts may still
have a problem. Every JSON document the readers accept, the schema also
accepts. A schema validator reads JSON as a parser produces it, with
arrays as lists. A model's to_json returns tuples, which a Python
validator does not accept as arrays.
import json
from zarr_metadata.json_schema import node_metadata_json_schema_v3
with open("zarr.schema.json", "w") as f:
json.dump(node_metadata_json_schema_v3(), f, indent=2)
Scope¶
At minimum, this library supports what Zarr-Python needs: the complete
Zarr v2 and v3 specs, consolidated metadata, and a subset of the metadata
defined in zarr-extensions. We are generally open to contributions that
add types, models, or validation for Zarr metadata with a
published spec.
Runtime array behavior is out of scope: nothing here encodes or decodes
chunks, resolves codec or data type names to implementations, or performs
store I/O. The models begin and end at the metadata documents themselves —
from_key_value / to_key_value map documents to store keys and bytes,
and everything past that belongs to consumer libraries. The "no encoding / decoding" rule does constrain
how extensively we can validate certain codec combinations. For example, we cannot check that the cast_value
codec bijectively maps scalars.