Skip to content

Commit 9677081

Browse files
anishmehta24d-v-b
andauthored
fix: accept must_understand=false extra fields in v3 group metadata (#4391)
* fix: accept must_understand=false extra fields in v3 group metadata Closes #3523 * fix(group): match array extra_fields validation and refuse v2 extras Share the extra-field checks between array and group metadata: `parse_extra_fields` takes the reserved keys, and the new `extract_extra_fields` splits and validates extra keys in `from_dict`. `GroupMetadata` now rejects extra fields that collide with its own keys, refuses extra fields on Zarr format 2 groups, and no longer passes a v2 `.zgroup` key named `extra_fields` to its constructor. Rename the changelog fragment to the PR number. Assisted-by: ClaudeCode:claude-opus-5-5 * fix(metadata): reject non-conforming extra fields in v3 metadata constructors `parse_extra_fields` now also checks that every extra field is an object with `"must_understand": false`, so `ArrayV3Metadata` and `GroupMetadata` can no longer be constructed with extra fields that their own `from_dict` would reject. `extract_extra_fields` delegates to it, leaving one place that validates extra fields. The Zarr format 2 group refusal is checked first so it keeps its own message. Assisted-by: ClaudeCode:claude-opus-5-5 * refactor(metadata): inline the extra-field split into from_dict `extract_extra_fields` filtered the reserved keys and then called `parse_extra_fields`, whose collision check could never fire from that path. Both `from_dict`s now do the filter inline and call `parse_extra_fields` directly, so one function owns the extra-field contract. Also tighten the changelog fragment, the `GROUP_METADATA_KEYS` docstring, and the stale repr comments. Assisted-by: ClaudeCode:claude-fable-5-1 --------- Co-authored-by: Davis Vann Bennett <davis.v.bennett@gmail.com>
1 parent c79f736 commit 9677081

6 files changed

Lines changed: 172 additions & 47 deletions

File tree

‎changes/4391.bugfix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Zarr format 3 group metadata now accepts extra fields that are objects with `"must_understand": false`, as the specification requires, and writes them back unchanged; previously opening such a group raised `TypeError`. Extra fields of any other shape raise `MetadataValidationError`, both when reading a metadata document and when constructing `GroupMetadata` or `ArrayV3Metadata` directly. `GroupMetadata` exposes these fields as `extra_fields`, like `ArrayV3Metadata`.

‎src/zarr/core/group.py‎

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import unicodedata
66
import warnings
77
from collections import defaultdict
8-
from dataclasses import asdict, dataclass, field, fields, replace
8+
from dataclasses import asdict, dataclass, field, replace
99
from itertools import accumulate
10-
from typing import TYPE_CHECKING, Literal, assert_never, cast, overload
10+
from typing import TYPE_CHECKING, Final, Literal, assert_never, cast, overload
1111

1212
import numpy as np
1313

@@ -50,6 +50,7 @@
5050
from zarr.core.json_parse import parse_field
5151
from zarr.core.metadata import ArrayV2Metadata, ArrayV3Metadata
5252
from zarr.core.metadata.io import save_metadata
53+
from zarr.core.metadata.v3 import AllowedExtraField, parse_extra_fields
5354
from zarr.core.sync import SyncMixin, sync
5455
from zarr.errors import (
5556
ArrayNotFoundError,
@@ -312,9 +313,9 @@ def flattened_metadata(self) -> dict[str, ArrayV2Metadata | ArrayV3Metadata | Gr
312313
"group-1": GroupMetadata(),
313314
}
314315
)
315-
# {'group-0': GroupMetadata(attributes={}, zarr_format=3, consolidated_metadata=None, node_type='group'),
316-
# 'group-0/group-0-0': GroupMetadata(attributes={}, zarr_format=3, consolidated_metadata=None, node_type='group'),
317-
# 'group-1': GroupMetadata(attributes={}, zarr_format=3, consolidated_metadata=None, node_type='group')}
316+
# {'group-0': GroupMetadata(attributes={}, zarr_format=3, consolidated_metadata=None, node_type='group', extra_fields={}),
317+
# 'group-0/group-0-0': GroupMetadata(attributes={}, zarr_format=3, consolidated_metadata=None, node_type='group', extra_fields={}),
318+
# 'group-1': GroupMetadata(attributes={}, zarr_format=3, consolidated_metadata=None, node_type='group', extra_fields={})}
318319
```
319320
"""
320321
metadata = {}
@@ -346,6 +347,18 @@ def flatten(
346347
return metadata
347348

348349

350+
GROUP_METADATA_KEYS: Final[set[str]] = {
351+
"zarr_format",
352+
"node_type",
353+
"attributes",
354+
"consolidated_metadata",
355+
}
356+
"""
357+
The names of the fields of the group metadata document. `consolidated_metadata` is not in the
358+
Zarr V3 spec; Zarr-Python writes it.
359+
"""
360+
361+
349362
@dataclass(frozen=True)
350363
class GroupMetadata(Metadata):
351364
"""
@@ -356,6 +369,7 @@ class GroupMetadata(Metadata):
356369
zarr_format: ZarrFormat = 3
357370
consolidated_metadata: ConsolidatedMetadata | None = None
358371
node_type: Literal["group"] = field(default="group", init=False)
372+
extra_fields: dict[str, AllowedExtraField] = field(default_factory=dict)
359373

360374
def to_buffer_dict(self, prototype: BufferPrototype) -> dict[str, Buffer]:
361375
indent = config.get("json_indent")
@@ -407,13 +421,22 @@ def __init__(
407421
attributes: dict[str, Any] | None = None,
408422
zarr_format: ZarrFormat = 3,
409423
consolidated_metadata: ConsolidatedMetadata | None = None,
424+
extra_fields: Mapping[str, AllowedExtraField] | None = None,
410425
) -> None:
411426
attributes_parsed = parse_attributes(attributes)
412427
zarr_format_parsed = parse_zarr_format(zarr_format)
428+
if zarr_format_parsed == 2 and extra_fields:
429+
raise ValueError(
430+
"Invalid extra fields. Zarr format 2 group metadata does not support extra fields."
431+
)
432+
extra_fields_parsed = parse_extra_fields(
433+
extra_fields, reserved_keys=GROUP_METADATA_KEYS, node_type="group"
434+
)
413435

414436
object.__setattr__(self, "attributes", attributes_parsed)
415437
object.__setattr__(self, "zarr_format", zarr_format_parsed)
416438
object.__setattr__(self, "consolidated_metadata", consolidated_metadata)
439+
object.__setattr__(self, "extra_fields", extra_fields_parsed)
417440

418441
@classmethod
419442
def from_dict(cls, data: dict[str, Any]) -> GroupMetadata:
@@ -428,17 +451,22 @@ def from_dict(cls, data: dict[str, Any]) -> GroupMetadata:
428451
data["consolidated_metadata"] = ConsolidatedMetadata.from_dict(consolidated_metadata)
429452

430453
zarr_format = data.get("zarr_format")
431-
if zarr_format == 2 or zarr_format is None:
432-
# zarr v2 allowed arbitrary keys here.
433-
# We don't want the GroupMetadata constructor to fail just because someone put an
434-
# extra key in the metadata.
435-
expected = {x.name for x in fields(cls)}
436-
data = {k: v for k, v in data.items() if k in expected}
437-
438-
return cls(**data)
454+
if zarr_format == 3:
455+
# Zarr v3 allows an extra key only if it is an object with "must_understand": false.
456+
extra_fields = parse_extra_fields(
457+
{k: v for k, v in data.items() if k not in GROUP_METADATA_KEYS},
458+
reserved_keys=GROUP_METADATA_KEYS,
459+
node_type="group",
460+
)
461+
else:
462+
# Zarr v2 allowed arbitrary keys; they are dropped rather than kept.
463+
extra_fields = {}
464+
data = {k: v for k, v in data.items() if k in GROUP_METADATA_KEYS}
465+
return cls(**data, extra_fields=extra_fields)
439466

440467
def to_dict(self) -> dict[str, Any]:
441468
result = asdict(replace(self, consolidated_metadata=None))
469+
result.update(result.pop("extra_fields"))
442470
if self.consolidated_metadata is not None:
443471
result["consolidated_metadata"] = self.consolidated_metadata.to_dict()
444472
else:

‎src/zarr/core/metadata/v3.py‎

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
from collections.abc import Iterable, Mapping, Sequence
5+
from collections.abc import Set as AbstractSet
56
from dataclasses import dataclass, field, replace
67
from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypeGuard, cast
78

@@ -24,6 +25,7 @@
2425
DimensionNamesLike,
2526
NamedConfig,
2627
NamedRequiredConfig,
28+
NodeType,
2729
compress_rle,
2830
expand_rle,
2931
parse_named_configuration,
@@ -155,8 +157,8 @@ def parse_storage_transformers(data: object) -> tuple[dict[str, JSON], ...]:
155157

156158
class AllowedExtraField(TypedDict, extra_items=JSON): # type: ignore[call-arg]
157159
"""
158-
This class models allowed extra fields in array metadata.
159-
They must have ``must_understand`` set to ``False``, and may contain
160+
This class models allowed extra fields in array or group metadata.
161+
They must have `must_understand` set to `False`, and may contain
160162
arbitrary additional JSON data.
161163
"""
162164

@@ -172,22 +174,45 @@ def check_allowed_extra_field(data: object) -> TypeGuard[AllowedExtraField]:
172174

173175

174176
def parse_extra_fields(
175-
data: Mapping[str, AllowedExtraField] | None,
177+
data: Mapping[str, object] | None,
178+
*,
179+
reserved_keys: AbstractSet[str],
180+
node_type: NodeType,
176181
) -> dict[str, AllowedExtraField]:
182+
"""
183+
Check the extra fields of a Zarr V3 metadata document.
184+
185+
Raises `ValueError` if a key collides with a key in `reserved_keys`, and
186+
`MetadataValidationError` if a value is not an object with `"must_understand": false`.
187+
"""
177188
if data is None:
178189
return {}
179-
else:
180-
conflict_keys = ARRAY_METADATA_KEYS & set(data.keys())
181-
if len(conflict_keys) > 0:
182-
msg = (
183-
"Invalid extra fields. "
184-
"The following keys: "
185-
f"{sorted(conflict_keys)} "
186-
"are invalid because they collide with keys reserved for use by the "
187-
"array metadata document."
188-
)
189-
raise ValueError(msg)
190-
return dict(data)
190+
conflict_keys = reserved_keys & set(data.keys())
191+
if len(conflict_keys) > 0:
192+
msg = (
193+
"Invalid extra fields. "
194+
"The following keys: "
195+
f"{sorted(conflict_keys)} "
196+
"are invalid because they collide with keys reserved for use by the "
197+
f"{node_type} metadata document."
198+
)
199+
raise ValueError(msg)
200+
allowed_extra_fields: dict[str, AllowedExtraField] = {}
201+
invalid_extra_fields: list[str] = []
202+
for key, val in data.items():
203+
if check_allowed_extra_field(val):
204+
allowed_extra_fields[key] = val
205+
else:
206+
invalid_extra_fields.append(key)
207+
if len(invalid_extra_fields) > 0:
208+
msg = (
209+
f"Got Zarr V3 {node_type} metadata with the following disallowed extra "
210+
f"fields: {sorted(invalid_extra_fields)}. "
211+
'Extra fields are not allowed unless they are an object with a "must_understand" '
212+
"key which is assigned the value `false`."
213+
)
214+
raise MetadataValidationError(msg)
215+
return allowed_extra_fields
191216

192217

193218
# JSON type for a single dimension's rectilinear spec:
@@ -518,7 +543,9 @@ def __init__(
518543
attributes_parsed = parse_attributes(attributes)
519544
codecs_parsed_partial = parse_codecs(codecs)
520545
storage_transformers_parsed = parse_storage_transformers(storage_transformers)
521-
extra_fields_parsed = parse_extra_fields(extra_fields)
546+
extra_fields_parsed = parse_extra_fields(
547+
extra_fields, reserved_keys=ARRAY_METADATA_KEYS, node_type="array"
548+
)
522549
array_spec = ArraySpec(
523550
shape=shape_parsed,
524551
dtype=data_type,
@@ -652,23 +679,11 @@ def from_dict(cls, data: dict[str, JSON]) -> Self:
652679
raise TypeError(f"Invalid fill_value: {fill!r}") from e
653680

654681
# check if there are extra keys
655-
extra_keys = set(_data.keys()) - ARRAY_METADATA_KEYS
656-
allowed_extra_fields: dict[str, AllowedExtraField] = {}
657-
invalid_extra_fields = {}
658-
for key in extra_keys:
659-
val = _data[key]
660-
if check_allowed_extra_field(val):
661-
allowed_extra_fields[key] = val
662-
else:
663-
invalid_extra_fields[key] = val
664-
if len(invalid_extra_fields) > 0:
665-
msg = (
666-
"Got a Zarr V3 metadata document with the following disallowed extra fields:"
667-
f"{sorted(invalid_extra_fields.keys())}."
668-
'Extra fields are not allowed unless they are a dict with a "must_understand" key'
669-
"which is assigned the value `False`."
670-
)
671-
raise MetadataValidationError(msg)
682+
allowed_extra_fields = parse_extra_fields(
683+
{k: v for k, v in _data.items() if k not in ARRAY_METADATA_KEYS},
684+
reserved_keys=ARRAY_METADATA_KEYS,
685+
node_type="array",
686+
)
672687
# TODO: replace this with a real type check!
673688
_data_typed = cast(ArrayMetadataJSON_V3, _data)
674689

‎src/zarr/core/sync_group.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def create_hierarchy(
103103
nodes = {'a': GroupMetadata(attributes={'name': 'leaf'})}
104104
nodes_created = dict(create_hierarchy(store=store, nodes=nodes))
105105
print(nodes)
106-
# {'a': GroupMetadata(attributes={'name': 'leaf'}, zarr_format=3, consolidated_metadata=None, node_type='group')}
106+
# {'a': GroupMetadata(attributes={'name': 'leaf'}, zarr_format=3, consolidated_metadata=None, node_type='group', extra_fields={})}
107107
```
108108
"""
109109
coro = create_hierarchy_async(store=store, nodes=nodes, overwrite=overwrite)

‎tests/test_group.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from zarr.core.dtype.common import unpack_dtype_json
3030
from zarr.core.dtype.npy.int import UInt8
3131
from zarr.core.group import (
32+
GROUP_METADATA_KEYS,
3233
ConsolidatedMetadata,
3334
GroupMetadata,
3435
ImplicitGroupMarker,
@@ -1790,12 +1791,75 @@ def test_from_dict_extra_fields(self):
17901791
data = {
17911792
"attributes": {"key": "value"},
17921793
"_nczarr_superblock": {"version": "2.0.0"},
1794+
# a key named like the `extra_fields` parameter is dropped, not passed to __init__
1795+
"extra_fields": {"my_extension": {"must_understand": False}},
17931796
"zarr_format": 2,
17941797
}
17951798
result = GroupMetadata.from_dict(data)
17961799
expected = GroupMetadata(attributes={"key": "value"}, zarr_format=2)
17971800
assert result == expected
17981801

1802+
@pytest.mark.parametrize(
1803+
"extra_fields",
1804+
[
1805+
{},
1806+
{"my_extension": {"must_understand": False, "version": 1}},
1807+
{"a": {"must_understand": False}, "b": {"must_understand": False, "data": [1, 2]}},
1808+
{"extra_fields": {"must_understand": False}},
1809+
],
1810+
)
1811+
def test_v3_extra_fields_round_trip(self, extra_fields: dict[str, Any]) -> None:
1812+
# https://github.com/zarr-developers/zarr-python/issues/3523
1813+
data = {"zarr_format": 3, "node_type": "group", "attributes": {}, **extra_fields}
1814+
expected = GroupMetadata(extra_fields=extra_fields)
1815+
result = GroupMetadata.from_dict(data)
1816+
assert result == expected
1817+
assert result.extra_fields == extra_fields
1818+
assert result.to_dict() == data
1819+
1820+
@pytest.mark.parametrize("value", [{"must_understand": True}, {}, "not an object", 1])
1821+
def test_from_dict_v3_disallowed_extra_fields(self, value: object) -> None:
1822+
data = {"zarr_format": 3, "node_type": "group", "my_extension": value}
1823+
with pytest.raises(MetadataValidationError, match="my_extension"):
1824+
GroupMetadata.from_dict(data)
1825+
1826+
@pytest.mark.parametrize("value", [{"must_understand": True}, {}, "not an object", 1])
1827+
def test_init_disallowed_extra_fields(self, value: object) -> None:
1828+
with pytest.raises(MetadataValidationError, match="my_extension"):
1829+
GroupMetadata(extra_fields={"my_extension": value}) # type: ignore[dict-item]
1830+
1831+
@pytest.mark.parametrize("key", sorted(GROUP_METADATA_KEYS))
1832+
def test_init_extra_fields_collision(self, key: str) -> None:
1833+
extra_fields: dict[str, Any] = {key: {"must_understand": False}}
1834+
with pytest.raises(ValueError, match="collide with keys reserved"):
1835+
GroupMetadata(extra_fields=extra_fields)
1836+
1837+
def test_init_extra_fields_v2(self) -> None:
1838+
with pytest.raises(ValueError, match="Zarr format 2 group metadata does not support"):
1839+
GroupMetadata(zarr_format=2, extra_fields={"my_extension": {"must_understand": False}})
1840+
1841+
@pytest.mark.filterwarnings("ignore:Consolidated metadata is:zarr.errors.ZarrUserWarning")
1842+
def test_extra_fields_survive_rewrite(self) -> None:
1843+
extension = {"must_understand": False}
1844+
doc = {"zarr_format": 3, "node_type": "group", "attributes": {}, "ext": extension}
1845+
store = MemoryStore()
1846+
for key in ("zarr.json", "child/zarr.json"):
1847+
buffer = default_buffer_prototype().buffer.from_bytes(json.dumps(doc).encode())
1848+
sync(store.set(key, buffer))
1849+
1850+
group = zarr.open_group(store=store, mode="r+")
1851+
group["child"].attrs["key"] = "value"
1852+
zarr.consolidate_metadata(store)
1853+
1854+
written = json.loads(
1855+
sync(store.get("child/zarr.json", prototype=default_buffer_prototype())).to_bytes()
1856+
)
1857+
assert written == {**doc, "attributes": {"key": "value"}}
1858+
consolidated = zarr.open_consolidated(store)
1859+
assert consolidated.metadata.extra_fields == {"ext": extension}
1860+
assert consolidated["child"].metadata.extra_fields == {"ext": extension}
1861+
assert consolidated["child"].attrs["key"] == "value"
1862+
17991863

18001864
class TestInfo:
18011865
def test_info(self):

‎tests/test_metadata/test_v3.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,23 @@ def test_array_metadata_extra_fields_rejected(case: ExpectFail[dict[str, Any]])
312312
ArrayV3Metadata.from_dict(case.input)
313313

314314

315+
@pytest.mark.parametrize("value", [{"must_understand": True}, {}, 42])
316+
def test_init_extra_fields_disallowed(value: object) -> None:
317+
"""Extra field values that are not objects with `must_understand: false` are rejected."""
318+
with pytest.raises(MetadataValidationError, match="disallowed extra fields"):
319+
ArrayV3Metadata(
320+
shape=(10,),
321+
data_type=UInt8(),
322+
chunk_grid={"name": "regular", "configuration": {"chunk_shape": (10,)}},
323+
chunk_key_encoding={"name": "default", "configuration": {"separator": "/"}},
324+
fill_value=0,
325+
codecs=({"name": "bytes", "configuration": {"endian": "little"}},),
326+
attributes={},
327+
dimension_names=None,
328+
extra_fields={"my_ext": value}, # type: ignore[dict-item]
329+
)
330+
331+
315332
def test_init_extra_fields_collision() -> None:
316333
"""Extra field keys that collide with reserved metadata field names are rejected."""
317334
extra_fields: dict[str, object] = {"shape": (10,), "data_type": "uint8"}

0 commit comments

Comments
 (0)