Repository navigation
Bugs turned up while creating the zarr-metadata type checker #4453
Description
Here's a list of bugs I found while working on the zarr-metadata type checker (see #4447). I had claude write this up.
🤖 AI text below 🤖
These were found by differential testing of zarr-python against the zarr-metadata validators (d-v-b#370–#382), and every item was re-run against zarr 3.4.1.dev50+gf3bfd68a6 (main as of 2026-09-29) with numpy 2.5.3. Spec references are to zarr-specs at fc7dd9c9. Grouped by consequence, worst first. Repros use this array document as the base and replace one member:
ARRAY = {
"zarr_format": 3, "node_type": "array", "shape": [1], "data_type": "int8",
"chunk_grid": {"name": "regular", "configuration": {"chunk_shape": [1]}},
"chunk_key_encoding": {"name": "default"}, "fill_value": 0,
"codecs": [{"name": "bytes", "configuration": {"endian": "little"}}],
}
GROUP = {"zarr_format": 3, "node_type": "group", "attributes": {}}1. Reserved node names are written, and one of them makes the hierarchy unreadable
Node names must not start with __ and must not be zarr.json (core L818–L837).
root.create_group("__a")succeeds, writes__a/zarr.json, androot.members()lists__a.root.create_group("a/zarr.json")succeeds and writesa/zarr.json/zarr.json— a directory namedzarr.json.root.members()is then[]:ahas no document of its own, so it and everything under it is invisible.root.create_group("zarr.json")on aLocalStorefails withFileExistsError: [Errno 17] File exists: '.../zarr.json'(the directory cannot be created over the root's document) and leaves a strayzarr.<uuid>.partialfile behind; on aMemoryStoreit writeszarr.json/zarr.json. The name is not refused before writing starts.
2. Consolidated metadata: invalid listings are accepted, and one bad entry hides the whole group
ConsolidatedMetadata.from_dict and GroupMetadata.from_dict accept metadata entries keyed "", ".", "..", "zarr.json", "__a", and entries that are v2 documents ({"zarr_format": 2, "attributes": {}}); Group.members() then lists '' and '__a' as children. Keys are node paths, made of node names (core L211–L229).
- A child listed without its parent (
{"a/b": GROUP}only):from_dictraisesKeyError: 'a', andzarr.open_group(store)on a store holding that document raisesGroupNotFoundError: No group found in store ... at path ''— the root group itself becomes unopenable because of one listing entry. - A node listed below an array (
{"arr": ARRAY, "arr/child": GROUP}): accepted byfrom_dictand byopen_group;members()(andmembers(max_depth=None)) raisesKeyError: 'arr/child'. "Group nodes may have children but array nodes may not" (core L177–L181).
3. Float fill values: equality conflates distinct values, and NaN payloads are lost on a round trip
ArrayV3Metadata.from_dict({**ARRAY, "data_type": "float32", "fill_value": -0.0}) == ...fill_value: 0.0isTrue, though the two are written back as-0.0and0.0: two documents that differ compare equal.fill_value: "0x7fc00001"(a float32 NaN with payload 1) is read, written back as"NaN", and compares equal to a plain NaN. The spec writes a NaN other than the canonical one as its hex bits (data-types L63–L79).
4. Fill-value handling at the edges
- An out-of-range integer fill leaks numpy's exception:
fill_value: 300forint8→OverflowError: Python integer 300 out of bounds for int8(-129likewise), where a validation error is expected. - The byte-array form of a
bytesfill value is refused:fill_value: [65]→TypeError: Invalid type: [65]. Expected a string.forbytesandvariable_length_bytes(... Expected a base64-encoded string.fornull_terminated_bytes); only the base64 form is taken. The spec allows an array of byte values (data-types L97–L99). - The
r*raw-bits data types are not supported:data_type: "r16"→ValueError: No Zarr data type found that matches 'r16'.
5. Documents accepted that the spec rules out
Each is accepted by ArrayV3Metadata.from_dict (and so by open_array) without any error, with the base document above and the one member replaced:
| member | value | why it is invalid |
|---|---|---|
shape |
[true] |
a boolean is not an integer |
dimension_names |
null |
an array, with a string or null per dimension |
attributes |
[] |
an object |
storage_transformers |
null |
an array |
codecs |
[gzip, bytes] |
one array→bytes codec, with bytes→bytes codecs only after it (core L974–L983, L1048–L1050) |
sharding index_codecs |
[] |
the index needs an array→bytes codec (sharding L129–L138) |
sharding index_codecs |
[{"name": "bytes"}] |
endian is required for the uint64 index (bytes L64–L69) |
codecs |
[vlen-utf8] on int8 |
a string codec on an integer type |
crc32c |
{"configuration": {"bogus": 1}} |
crc32c takes no configuration members (crc32c L63–L66; unknown members, core L1562–L1564) |
zstd |
level: -131073 |
one below zstd's minimum level, -131072 |
fill_value (bool) |
null, "true", 1 |
a boolean; null is silently read as false |
fill_value (float32) |
"nan", "inf", true |
a number, or one of "NaN", "Infinity", "-Infinity", or hex bits (data-types L63–L79, L88–L91) |
fill_value (structured {a: int8, b: int8}) |
{"a": 1} / {"a": 1, "b": 2, "c": 3} |
read as {"a": 1, "b": 0} / {"a": 1, "b": 2}: a missing field defaults silently, an extra one is dropped |
Context
The zarr-metadata package on the d-v-b/zarr-python fork (PRs #370–#382, to be proposed upstream as a single PR) refuses each of these with a located problem and the spec citation above; the adversarial-review pass that produced this list is described in d-v-b#382. Happy to split any group into its own issue.
Activity