From a110f50cc6e1bf9f2bfb137d4b240b5b1c45c811 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 16 Sep 2026 14:38:12 +0200 Subject: [PATCH 01/10] perf(api): read metadata once when zarr.open falls back to a group `zarr.open` looks for an array first and opens a group when it doesn't find one. Both steps read `zarr.json` and `.zattrs`, so a format-detecting open of a group made seven requests where five would do, a cost paid on every call against a remote store. Split the array lookup into `_probe_array_metadata`, which reports "no array here" as a value instead of an exception and hands back the documents it read. `zarr.open` passes those to `AsyncGroup.open`, which then reads only the keys it is still missing. `get_array_metadata` keeps its signature and its exceptions, and becomes a thin wrapper over the probe. Assisted-by: ClaudeCode:claude-opus-5 --- src/zarr/api/asynchronous.py | 36 +++++++--- src/zarr/api/synchronous.py | 9 +++ src/zarr/core/array.py | 135 +++++++++++++++++++++++++---------- src/zarr/core/group.py | 39 +++++++--- tests/test_api.py | 113 ++++++++++++++++++++++++++++- 5 files changed, 271 insertions(+), 61 deletions(-) diff --git a/src/zarr/api/asynchronous.py b/src/zarr/api/asynchronous.py index 1fc10cdd1e..582d21cbae 100644 --- a/src/zarr/api/asynchronous.py +++ b/src/zarr/api/asynchronous.py @@ -15,9 +15,10 @@ Array, AsyncArray, CompressorLike, + _MetadataDocs, + _probe_array_metadata, create_array, from_array, - get_array_metadata, ) from zarr.core.array_spec import ArrayConfigLike, parse_array_config from zarr.core.buffer import NDArrayLike @@ -392,20 +393,27 @@ async def open( # TODO: the mode check below seems wrong! if "shape" not in kwargs and mode in {"a", "r", "r+", "w"}: - try: - metadata_dict = await get_array_metadata(store_path, zarr_format=zarr_format) + probe = await _probe_array_metadata(store_path, zarr_format=zarr_format) + if probe.is_array: # TODO: remove this cast when we fix typing for array metadata dicts - _metadata_dict = cast("ArrayMetadataDict", metadata_dict) - # for v2, the above would already have raised an exception if not an array + _metadata_dict = cast("ArrayMetadataDict", probe.metadata) zarr_format = _metadata_dict["zarr_format"] is_v3_array = zarr_format == 3 and _metadata_dict.get("node_type") == "array" if is_v3_array or zarr_format == 2: return AsyncArray( store_path=store_path, metadata=_metadata_dict, config=kwargs.get("config") ) - except (FileNotFoundError, NodeTypeValidationError): - pass - return await open_group(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs) + # There is no array here, so open a group instead. The probe already read + # `zarr.json` and `.zattrs`, two of the four keys the group open reads, so + # hand those over rather than pay for them twice. That only holds when the + # format still has to be detected; an explicit format reads a smaller set. + return await open_group( + store=store_path, + zarr_format=zarr_format, + mode=mode, + _pre_fetched_metadata=probe.docs if zarr_format is None else None, + **kwargs, + ) try: return await open_array(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs) @@ -790,6 +798,7 @@ async def open_group( meta_array: Any | None = None, # not used attributes: dict[str, JSON] | None = None, use_consolidated: bool | str | None = None, + _pre_fetched_metadata: _MetadataDocs | None = None, ) -> AsyncGroup: """Open a group using file-mode-like semantics. @@ -840,6 +849,12 @@ async def open_group( Zarr format 2 allowed configuring the key storing the consolidated metadata (`.zmetadata` by default). Specify the custom key as `use_consolidated` to load consolidated metadata from a non-default key. + _pre_fetched_metadata : _MetadataDocs or None, default None + Private. The `zarr.json` and `.zattrs` documents for this path, already + read by the caller, to use instead of reading them again. Only consulted + when `zarr_format` is None and the group is opened rather than created. + [`zarr.api.asynchronous.open`][zarr.api.asynchronous.open] passes what it + read while looking for an array before falling back to opening a group. Returns ------- @@ -863,7 +878,10 @@ async def open_group( try: if mode in _READ_MODES: return await AsyncGroup.open( - store_path, zarr_format=zarr_format, use_consolidated=use_consolidated + store_path, + zarr_format=zarr_format, + use_consolidated=use_consolidated, + _pre_fetched_metadata=_pre_fetched_metadata, ) except (KeyError, FileNotFoundError): pass diff --git a/src/zarr/api/synchronous.py b/src/zarr/api/synchronous.py index 6975f6d953..f56a232b25 100644 --- a/src/zarr/api/synchronous.py +++ b/src/zarr/api/synchronous.py @@ -26,6 +26,7 @@ FiltersLike, SerializerLike, ShardsLike, + _MetadataDocs, ) from zarr.core.array_spec import ArrayConfigLike from zarr.core.buffer import NDArrayLike, NDArrayLikeOrScalar @@ -492,6 +493,7 @@ def open_group( meta_array: Any | None = None, # not used in async api attributes: dict[str, JSON] | None = None, use_consolidated: bool | str | None = None, + _pre_fetched_metadata: _MetadataDocs | None = None, ) -> Group: """Open a group using file-mode-like semantics. @@ -542,6 +544,12 @@ def open_group( Zarr format 2 allowed configuring the key storing the consolidated metadata (`.zmetadata` by default). Specify the custom key as `use_consolidated` to load consolidated metadata from a non-default key. + _pre_fetched_metadata : _MetadataDocs or None, default None + Private. The `zarr.json` and `.zattrs` documents for this path, already + read by the caller, to use instead of reading them again. Only consulted + when `zarr_format` is None and the group is opened rather than created. + [`zarr.api.asynchronous.open`][zarr.api.asynchronous.open] passes what it + read while looking for an array before falling back to opening a group. Returns ------- @@ -562,6 +570,7 @@ def open_group( meta_array=meta_array, attributes=attributes, use_consolidated=use_consolidated, + _pre_fetched_metadata=_pre_fetched_metadata, ) ) ) diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index 5a8d6bf57e..871d708982 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -269,55 +269,121 @@ def create_codec_pipeline(metadata: ArrayMetadata, *, store: Store | None = None raise TypeError # pragma: no cover -async def get_array_metadata( - store_path: StorePath, zarr_format: ZarrFormat | None = 3 -) -> dict[str, JSON]: +@dataclass(frozen=True, kw_only=True) +class _MetadataDocs: + """Metadata documents read from a store, so a second reader can skip re-reading them. + + Each attribute holds the document found at that key, or None when the key held + nothing. An instance only carries the keys its producer actually read, so a + consumer has to know which of them to expect. + """ + + zarr_json: Buffer | None = None + zarray: Buffer | None = None + zattrs: Buffer | None = None + + +@dataclass(frozen=True, kw_only=True) +class _ArrayProbe: + """What a search for array metadata at a path turned up. + + `metadata` is the metadata document found there, or None when the path holds + no array metadata document at all. A `zarr.json` document is reported as + found without checking that its `node_type` is `array`; `is_array` applies + that check, and `from_zarr_json` lets a caller raise about it instead. + + `docs` holds the documents the probe read, for a caller that goes on to open + a group at the same path: `zarr.json` and `.zattrs` are keys the group open + would otherwise read a second time. + """ + + metadata: dict[str, JSON] | None = None + from_zarr_json: bool = False + docs: _MetadataDocs = field(default_factory=_MetadataDocs) + + @property + def is_array(self) -> bool: + """Whether the metadata found describes an array rather than a group.""" + if self.metadata is None: + return False + return not self.from_zarr_json or self.metadata.get("node_type") == "array" + + +async def _fetch_metadata_docs( + store_path: StorePath, zarr_format: ZarrFormat | None +) -> _MetadataDocs: + """Read the documents that could describe an array at `store_path`. + + Which keys are read depends on `zarr_format`: `.zarray` and `.zattrs` for 2, + `zarr.json` for 3, and all three when it is None and the format has to be + detected. + """ if zarr_format == 2: zarray_bytes, zattrs_bytes = await gather( (store_path / ZARRAY_JSON).get(prototype=cpu_buffer_prototype), (store_path / ZATTRS_JSON).get(prototype=cpu_buffer_prototype), ) - if zarray_bytes is None: - msg = ( - "A Zarr V2 array metadata document was not found in store " - f"{store_path.store!r} at path {store_path.path!r}." - ) - raise ArrayNotFoundError(msg) - return _array_metadata_dict_v2(zarray_bytes, zattrs_bytes) + return _MetadataDocs(zarray=zarray_bytes, zattrs=zattrs_bytes) elif zarr_format == 3: zarr_json_bytes = await (store_path / ZARR_JSON).get(prototype=cpu_buffer_prototype) - if zarr_json_bytes is None: - msg = ( - "A Zarr V3 array metadata document was not found in store " - f"{store_path.store!r} at path {store_path.path!r}." - ) - raise ArrayNotFoundError(msg) - return _array_metadata_dict_v3(zarr_json_bytes) + return _MetadataDocs(zarr_json=zarr_json_bytes) elif zarr_format is None: zarr_json_bytes, zarray_bytes, zattrs_bytes = await gather( (store_path / ZARR_JSON).get(prototype=cpu_buffer_prototype), (store_path / ZARRAY_JSON).get(prototype=cpu_buffer_prototype), (store_path / ZATTRS_JSON).get(prototype=cpu_buffer_prototype), ) - if zarr_json_bytes is not None and zarray_bytes is not None: - # warn and favor v3 - msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store_path}. Zarr v3 will be used." - warnings.warn(msg, category=ZarrUserWarning, stacklevel=1) - # favor v3 when both are present - if zarr_json_bytes is not None: - return _array_metadata_dict_v3(zarr_json_bytes) - if zarray_bytes is not None: - return _array_metadata_dict_v2(zarray_bytes, zattrs_bytes) - msg = ( - f"Neither Zarr V3 nor Zarr V2 array metadata documents " - f"were found in store {store_path.store!r} at path {store_path.path!r}." - ) - raise ArrayNotFoundError(msg) + return _MetadataDocs(zarr_json=zarr_json_bytes, zarray=zarray_bytes, zattrs=zattrs_bytes) else: msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." # type: ignore[unreachable] raise MetadataValidationError(msg) +async def _probe_array_metadata( + store_path: StorePath, zarr_format: ZarrFormat | None = 3 +) -> _ArrayProbe: + """Look for array metadata at `store_path`, reporting a miss instead of raising. + + This is [`get_array_metadata`][zarr.core.array.get_array_metadata] without + the exceptions for "there is no array here", so that a caller which has + something else to try can do so without paying for the same reads twice. An + invalid `zarr_format` still raises. + """ + docs = await _fetch_metadata_docs(store_path, zarr_format) + if docs.zarr_json is not None and docs.zarray is not None: + # warn and favor v3 + msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store_path}. Zarr v3 will be used." + warnings.warn(msg, category=ZarrUserWarning, stacklevel=1) + if docs.zarr_json is not None: + return _ArrayProbe( + metadata=buffer_to_json_object(docs.zarr_json), from_zarr_json=True, docs=docs + ) + if docs.zarray is not None: + return _ArrayProbe(metadata=_array_metadata_dict_v2(docs.zarray, docs.zattrs), docs=docs) + return _ArrayProbe(docs=docs) + + +async def get_array_metadata( + store_path: StorePath, zarr_format: ZarrFormat | None = 3 +) -> dict[str, JSON]: + probe = await _probe_array_metadata(store_path, zarr_format=zarr_format) + if probe.metadata is None: + if zarr_format is None: + msg = ( + f"Neither Zarr V3 nor Zarr V2 array metadata documents " + f"were found in store {store_path.store!r} at path {store_path.path!r}." + ) + else: + msg = ( + f"A Zarr V{zarr_format} array metadata document was not found in store " + f"{store_path.store!r} at path {store_path.path!r}." + ) + raise ArrayNotFoundError(msg) + if probe.from_zarr_json: + parse_node_type_array(probe.metadata.get("node_type")) + return probe.metadata + + def _array_metadata_dict_v2(zarray_bytes: Buffer, zattrs_bytes: Buffer | None) -> dict[str, JSON]: """Combine a `.zarray` document and an optional `.zattrs` document into one metadata dict.""" metadata_dict: dict[str, JSON] = buffer_to_json_object(zarray_bytes) @@ -327,13 +393,6 @@ def _array_metadata_dict_v2(zarray_bytes: Buffer, zattrs_bytes: Buffer | None) - return metadata_dict -def _array_metadata_dict_v3(zarr_json_bytes: Buffer) -> dict[str, JSON]: - """Parse a `zarr.json` document, checking that it describes an array.""" - metadata_dict: dict[str, JSON] = buffer_to_json_object(zarr_json_bytes) - parse_node_type_array(metadata_dict.get("node_type")) - return metadata_dict - - async def _prepare_overwrite( store_path: StorePath, *, zarr_format: ZarrFormat, overwrite: bool ) -> None: diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index d734e6b7cd..d67af0588d 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -25,6 +25,7 @@ FiltersLike, SerializerLike, ShardsLike, + _MetadataDocs, _parse_deprecated_compressor, create_array, ) @@ -499,6 +500,7 @@ async def open( store: StoreLike, zarr_format: ZarrFormat | None = 3, use_consolidated: bool | str | None = None, + _pre_fetched_metadata: _MetadataDocs | None = None, ) -> AsyncGroup: """Open a new AsyncGroup @@ -523,6 +525,13 @@ async def open( Zarr format 2 allowed configuring the key storing the consolidated metadata (``.zmetadata`` by default). Specify the custom key as ``use_consolidated`` to load consolidated metadata from a non-default key. + _pre_fetched_metadata : _MetadataDocs or None, default None + Private. The ``zarr.json`` and ``.zattrs`` documents for this path, + already read by the caller, to use instead of reading them again. Only + consulted when ``zarr_format`` is None, so the caller must have read + both keys unconditionally; a `zarr_format=None` array probe does. + `zarr.api.asynchronous.open` passes what it read while looking for an + array before falling back to opening a group. """ store_path = await make_store_path(store) if not store_path.store.supports_consolidated_metadata: @@ -563,17 +572,25 @@ async def open( if zarr_json_bytes is None: raise FileNotFoundError(store_path) elif zarr_format is None: - ( - zarr_json_bytes, - zgroup_bytes, - zattrs_bytes, - maybe_consolidated_metadata_bytes, - ) = await asyncio.gather( - (store_path / ZARR_JSON).get(), - (store_path / ZGROUP_JSON).get(), - (store_path / ZATTRS_JSON).get(), - (store_path / str(consolidated_key)).get(), - ) + if _pre_fetched_metadata is None: + ( + zarr_json_bytes, + zgroup_bytes, + zattrs_bytes, + maybe_consolidated_metadata_bytes, + ) = await asyncio.gather( + (store_path / ZARR_JSON).get(), + (store_path / ZGROUP_JSON).get(), + (store_path / ZATTRS_JSON).get(), + (store_path / str(consolidated_key)).get(), + ) + else: + zarr_json_bytes = _pre_fetched_metadata.zarr_json + zattrs_bytes = _pre_fetched_metadata.zattrs + zgroup_bytes, maybe_consolidated_metadata_bytes = await asyncio.gather( + (store_path / ZGROUP_JSON).get(), + (store_path / str(consolidated_key)).get(), + ) if zarr_json_bytes is not None and zgroup_bytes is not None: # warn and favor v3 msg = f"Both zarr.json (Zarr format 3) and .zgroup (Zarr format 2) metadata objects exist at {store_path}. Zarr format 3 will be used." diff --git a/tests/test_api.py b/tests/test_api.py index 45d0c0dee4..8a6476a723 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,6 @@ from __future__ import annotations +import collections import inspect import re from typing import TYPE_CHECKING, Any @@ -13,9 +14,11 @@ if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path + from typing import Self - from zarr.abc.store import Store - from zarr.core.common import JSON, MemoryOrder, ZarrFormat + from zarr.abc.store import ByteRequest + from zarr.core.buffer import Buffer, BufferPrototype + from zarr.core.common import JSON, AccessModeLiteral, MemoryOrder, ZarrFormat from zarr.types import AnyArray import contextlib @@ -30,6 +33,7 @@ import zarr.api.synchronous import zarr.core.group from zarr import Array, Group +from zarr.abc.store import Store from zarr.api.synchronous import ( create, create_array, @@ -42,15 +46,17 @@ save_array, save_group, ) -from zarr.core.buffer import NDArrayLike +from zarr.core.buffer import NDArrayLike, default_buffer_prototype from zarr.errors import ( ArrayNotFoundError, MetadataValidationError, + NodeTypeValidationError, ZarrDeprecationWarning, ZarrUserWarning, ) from zarr.storage import MemoryStore from zarr.storage._utils import normalize_path +from zarr.storage._wrapper import WrapperStore from zarr.testing.utils import gpu_test @@ -1377,6 +1383,107 @@ async def test_open_falls_back_to_open_group_async(zarr_format: ZarrFormat) -> N assert group.attrs == {"key": "value"} +class _CountingStore(WrapperStore[Store]): + """A store that records the key of every `get` it forwards.""" + + get_counts: collections.Counter[str] + + def __init__(self, store: Store) -> None: + super().__init__(store) + self.get_counts = collections.Counter() + + def _with_store(self, store: Store) -> Self: + # `_with_store` is how a store is re-made read-only, so the copy has to + # keep counting into the same tally. + new = type(self)(store) + new.get_counts = self.get_counts + return new + + async def get( + self, + key: str, + prototype: BufferPrototype, + byte_range: ByteRequest | None = None, + ) -> Buffer | None: + self.get_counts[key] += 1 + return await self._store.get(key, prototype, byte_range) + + +@pytest.mark.filterwarnings("ignore:Consolidated metadata") +@pytest.mark.parametrize( + ("zarr_format", "use_consolidated"), + [(2, False), (2, True), (2, "custom"), (3, False), (3, True)], +) +@pytest.mark.parametrize("mode", ["r", "r+", "a"]) +@pytest.mark.parametrize("path", ["", "parent/child"]) +async def test_open_group_fallback_reads_each_key_once( + zarr_format: ZarrFormat, use_consolidated: bool | str, mode: AccessModeLiteral, path: str +) -> None: + """`open` falling back to a group reads no key twice, and opens the same group as before. + + The array probe and the group open read an overlapping set of keys, so the + probe hands over what it read. That has to leave the resulting group -- its + format, path, attributes, read-only-ness and consolidated metadata -- + exactly as it was when both read the store independently. + """ + store = _CountingStore(MemoryStore()) + await zarr.api.asynchronous.open_group( + store, path=path, attributes={"key": "value"}, zarr_format=zarr_format + ) + if use_consolidated: + await zarr.api.asynchronous.consolidate_metadata(store, path=path) + if isinstance(use_consolidated, str): + # move the consolidated document to the non-default key + prefix = f"{path}/" if path else "" + metadata = await store.get(prefix + ".zmetadata", default_buffer_prototype()) + assert metadata is not None + await store.set(prefix + use_consolidated, metadata) + await store.delete(prefix + ".zmetadata") + + store.get_counts.clear() + group = await zarr.api.asynchronous.open( + store=store, path=path, mode=mode, use_consolidated=use_consolidated + ) + assert isinstance(group, zarr.core.group.AsyncGroup) + assert group.metadata.zarr_format == zarr_format + assert group.path == path + assert group.attrs == {"key": "value"} + assert group.store.read_only == (mode == "r") + assert (group.metadata.consolidated_metadata is not None) == bool(use_consolidated) + assert [key for key, count in store.get_counts.items() if count > 1] == [] + + +@pytest.mark.parametrize("zarr_format", [2, 3]) +async def test_open_array_does_not_fall_back(zarr_format: ZarrFormat) -> None: + """`open` on an array still returns the array rather than falling back to a group.""" + store = MemoryStore() + await zarr.api.asynchronous.create_array( + store, shape=(10,), dtype="uint8", zarr_format=zarr_format, attributes={"k": "v"} + ) + arr = await zarr.api.asynchronous.open(store=store) + assert isinstance(arr, AsyncArray) + assert arr.metadata.zarr_format == zarr_format + assert arr.attrs == {"k": "v"} + + +async def test_open_array_probe_invalid_zarr_format_raises() -> None: + """An invalid `zarr_format` is a bad request, not a missing array, so it still raises.""" + store = MemoryStore() + with pytest.raises( + MetadataValidationError, + match="Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '3.0'.", + ): + await zarr.api.asynchronous.open(store=store, zarr_format="3.0") # type: ignore[arg-type] + + +async def test_async_array_open_on_group_raises_node_type() -> None: + """Opening a v3 group as an array still reports the node_type mismatch.""" + store = MemoryStore() + await zarr.api.asynchronous.open_group(store, zarr_format=3) + with pytest.raises(NodeTypeValidationError, match="node_type"): + await AsyncArray.open(store, zarr_format=3) + + @pytest.mark.parametrize("mode", ["r", "r+", "w", "a"]) def test_open_modes_creates_group(tmp_path: Path, mode: str) -> None: # https://github.com/zarr-developers/zarr-python/issues/2490 From aa760ceae280f6fa9fd82b44c5cc5e2f6237a9ad Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 16 Sep 2026 14:39:05 +0200 Subject: [PATCH 02/10] docs: add changelog fragment for #4366 Assisted-by: ClaudeCode:claude-opus-5 --- changes/4366.misc.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/4366.misc.md diff --git a/changes/4366.misc.md b/changes/4366.misc.md new file mode 100644 index 0000000000..9008ad5017 --- /dev/null +++ b/changes/4366.misc.md @@ -0,0 +1 @@ +`zarr.open` no longer reads `zarr.json` and `.zattrs` twice when it falls back from looking for an array to opening a group. From 14663af86365a68ea31530e76e089a057f87a441 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 16 Sep 2026 14:50:00 +0200 Subject: [PATCH 03/10] refactor(array): derive from_zarr_json instead of storing it `zarr.json` wins whenever it is present, so whether the probe's metadata came from it is a fact about `docs`, not a second field that has to agree with `docs`. Also name the fetcher for what it reads: the array document set, never `.zgroup`. Assisted-by: ClaudeCode:claude-fable-5-1 --- src/zarr/core/array.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index 871d708982..b3814aa7ab 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -298,9 +298,13 @@ class _ArrayProbe: """ metadata: dict[str, JSON] | None = None - from_zarr_json: bool = False docs: _MetadataDocs = field(default_factory=_MetadataDocs) + @property + def from_zarr_json(self) -> bool: + """Whether `metadata` came from `zarr.json`, which wins whenever it is present.""" + return self.docs.zarr_json is not None + @property def is_array(self) -> bool: """Whether the metadata found describes an array rather than a group.""" @@ -309,7 +313,7 @@ def is_array(self) -> bool: return not self.from_zarr_json or self.metadata.get("node_type") == "array" -async def _fetch_metadata_docs( +async def _fetch_array_metadata_docs( store_path: StorePath, zarr_format: ZarrFormat | None ) -> _MetadataDocs: """Read the documents that could describe an array at `store_path`. @@ -349,15 +353,13 @@ async def _probe_array_metadata( something else to try can do so without paying for the same reads twice. An invalid `zarr_format` still raises. """ - docs = await _fetch_metadata_docs(store_path, zarr_format) + docs = await _fetch_array_metadata_docs(store_path, zarr_format) if docs.zarr_json is not None and docs.zarray is not None: # warn and favor v3 msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store_path}. Zarr v3 will be used." warnings.warn(msg, category=ZarrUserWarning, stacklevel=1) if docs.zarr_json is not None: - return _ArrayProbe( - metadata=buffer_to_json_object(docs.zarr_json), from_zarr_json=True, docs=docs - ) + return _ArrayProbe(metadata=buffer_to_json_object(docs.zarr_json), docs=docs) if docs.zarray is not None: return _ArrayProbe(metadata=_array_metadata_dict_v2(docs.zarray, docs.zattrs), docs=docs) return _ArrayProbe(docs=docs) From 358672bf902303e5f9b8c3667ad6c3bcece1ec17 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 16 Sep 2026 15:11:11 +0200 Subject: [PATCH 04/10] refactor(array): make _MetadataDocs a TypedDict so presence means "read" A dataclass field set to None could mean the key was read and held nothing, or was never read at all, so the group open could only reuse documents under a guard at the call site. With NotRequired keys, presence is the signal: the group open takes whatever was actually read, and zarr.open passes the probe's documents along unconditionally. Assisted-by: ClaudeCode:claude-fable-5-1 --- src/zarr/api/asynchronous.py | 14 ++++++-------- src/zarr/api/synchronous.py | 6 +++--- src/zarr/core/array.py | 31 +++++++++++++++++-------------- src/zarr/core/group.py | 29 +++++++++++++++-------------- 4 files changed, 41 insertions(+), 39 deletions(-) diff --git a/src/zarr/api/asynchronous.py b/src/zarr/api/asynchronous.py index 582d21cbae..171cb655d8 100644 --- a/src/zarr/api/asynchronous.py +++ b/src/zarr/api/asynchronous.py @@ -403,15 +403,13 @@ async def open( return AsyncArray( store_path=store_path, metadata=_metadata_dict, config=kwargs.get("config") ) - # There is no array here, so open a group instead. The probe already read - # `zarr.json` and `.zattrs`, two of the four keys the group open reads, so - # hand those over rather than pay for them twice. That only holds when the - # format still has to be detected; an explicit format reads a smaller set. + # There is no array here, so open a group instead, handing over what the + # probe already read so the group open doesn't pay for the same keys twice. return await open_group( store=store_path, zarr_format=zarr_format, mode=mode, - _pre_fetched_metadata=probe.docs if zarr_format is None else None, + _pre_fetched_metadata=probe.docs, **kwargs, ) @@ -850,9 +848,9 @@ async def open_group( (`.zmetadata` by default). Specify the custom key as `use_consolidated` to load consolidated metadata from a non-default key. _pre_fetched_metadata : _MetadataDocs or None, default None - Private. The `zarr.json` and `.zattrs` documents for this path, already - read by the caller, to use instead of reading them again. Only consulted - when `zarr_format` is None and the group is opened rather than created. + Private. Metadata documents for this path that the caller already read, + to use instead of reading them again. Only consulted when `zarr_format` + is None and the group is opened rather than created. [`zarr.api.asynchronous.open`][zarr.api.asynchronous.open] passes what it read while looking for an array before falling back to opening a group. diff --git a/src/zarr/api/synchronous.py b/src/zarr/api/synchronous.py index f56a232b25..ac30da5518 100644 --- a/src/zarr/api/synchronous.py +++ b/src/zarr/api/synchronous.py @@ -545,9 +545,9 @@ def open_group( (`.zmetadata` by default). Specify the custom key as `use_consolidated` to load consolidated metadata from a non-default key. _pre_fetched_metadata : _MetadataDocs or None, default None - Private. The `zarr.json` and `.zattrs` documents for this path, already - read by the caller, to use instead of reading them again. Only consulted - when `zarr_format` is None and the group is opened rather than created. + Private. Metadata documents for this path that the caller already read, + to use instead of reading them again. Only consulted when `zarr_format` + is None and the group is opened rather than created. [`zarr.api.asynchronous.open`][zarr.api.asynchronous.open] passes what it read while looking for an array before falling back to opening a group. diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index b3814aa7ab..7516338ef9 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -12,6 +12,7 @@ TYPE_CHECKING, Any, Literal, + NotRequired, TypedDict, cast, overload, @@ -269,18 +270,17 @@ def create_codec_pipeline(metadata: ArrayMetadata, *, store: Store | None = None raise TypeError # pragma: no cover -@dataclass(frozen=True, kw_only=True) -class _MetadataDocs: +class _MetadataDocs(TypedDict): """Metadata documents read from a store, so a second reader can skip re-reading them. - Each attribute holds the document found at that key, or None when the key held - nothing. An instance only carries the keys its producer actually read, so a - consumer has to know which of them to expect. + A key is present only if that document was read; its value is None when the + store held nothing there. That lets a consumer tell "not read" from "absent" + and reuse exactly what was read. """ - zarr_json: Buffer | None = None - zarray: Buffer | None = None - zattrs: Buffer | None = None + zarr_json: NotRequired[Buffer | None] + zarray: NotRequired[Buffer | None] + zattrs: NotRequired[Buffer | None] @dataclass(frozen=True, kw_only=True) @@ -303,7 +303,7 @@ class _ArrayProbe: @property def from_zarr_json(self) -> bool: """Whether `metadata` came from `zarr.json`, which wins whenever it is present.""" - return self.docs.zarr_json is not None + return self.docs.get("zarr_json") is not None @property def is_array(self) -> bool: @@ -354,14 +354,17 @@ async def _probe_array_metadata( invalid `zarr_format` still raises. """ docs = await _fetch_array_metadata_docs(store_path, zarr_format) - if docs.zarr_json is not None and docs.zarray is not None: + zarr_json_bytes = docs.get("zarr_json") + zarray_bytes = docs.get("zarray") + if zarr_json_bytes is not None and zarray_bytes is not None: # warn and favor v3 msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store_path}. Zarr v3 will be used." warnings.warn(msg, category=ZarrUserWarning, stacklevel=1) - if docs.zarr_json is not None: - return _ArrayProbe(metadata=buffer_to_json_object(docs.zarr_json), docs=docs) - if docs.zarray is not None: - return _ArrayProbe(metadata=_array_metadata_dict_v2(docs.zarray, docs.zattrs), docs=docs) + if zarr_json_bytes is not None: + return _ArrayProbe(metadata=buffer_to_json_object(zarr_json_bytes), docs=docs) + if zarray_bytes is not None: + metadata = _array_metadata_dict_v2(zarray_bytes, docs.get("zattrs")) + return _ArrayProbe(metadata=metadata, docs=docs) return _ArrayProbe(docs=docs) diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index d67af0588d..ab5adff061 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -526,12 +526,11 @@ async def open( (``.zmetadata`` by default). Specify the custom key as ``use_consolidated`` to load consolidated metadata from a non-default key. _pre_fetched_metadata : _MetadataDocs or None, default None - Private. The ``zarr.json`` and ``.zattrs`` documents for this path, - already read by the caller, to use instead of reading them again. Only - consulted when ``zarr_format`` is None, so the caller must have read - both keys unconditionally; a `zarr_format=None` array probe does. - `zarr.api.asynchronous.open` passes what it read while looking for an - array before falling back to opening a group. + Private. Metadata documents for this path that the caller already + read, to use instead of reading them again. Only the ``zarr.json`` + and ``.zattrs`` entries are consulted, and only when ``zarr_format`` + is None. `zarr.api.asynchronous.open` passes what it read while + looking for an array before falling back to opening a group. """ store_path = await make_store_path(store) if not store_path.store.supports_consolidated_metadata: @@ -572,7 +571,16 @@ async def open( if zarr_json_bytes is None: raise FileNotFoundError(store_path) elif zarr_format is None: - if _pre_fetched_metadata is None: + pre_fetched = _pre_fetched_metadata or {} + if "zarr_json" in pre_fetched and "zattrs" in pre_fetched: + # the caller already read these; only read what is still missing + zarr_json_bytes = pre_fetched["zarr_json"] + zattrs_bytes = pre_fetched["zattrs"] + zgroup_bytes, maybe_consolidated_metadata_bytes = await asyncio.gather( + (store_path / ZGROUP_JSON).get(), + (store_path / str(consolidated_key)).get(), + ) + else: ( zarr_json_bytes, zgroup_bytes, @@ -584,13 +592,6 @@ async def open( (store_path / ZATTRS_JSON).get(), (store_path / str(consolidated_key)).get(), ) - else: - zarr_json_bytes = _pre_fetched_metadata.zarr_json - zattrs_bytes = _pre_fetched_metadata.zattrs - zgroup_bytes, maybe_consolidated_metadata_bytes = await asyncio.gather( - (store_path / ZGROUP_JSON).get(), - (store_path / str(consolidated_key)).get(), - ) if zarr_json_bytes is not None and zgroup_bytes is not None: # warn and favor v3 msg = f"Both zarr.json (Zarr format 3) and .zgroup (Zarr format 2) metadata objects exist at {store_path}. Zarr format 3 will be used." From e3a89c95adb0cce63f9d5619cd41dd28df709178 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 16 Sep 2026 15:16:09 +0200 Subject: [PATCH 05/10] refactor(array): report what the probe found as a node_type literal The probe learns one of three things: nothing is here, an array is, or a Zarr format 3 group is. Say that with `node_type: Literal["array", "group"] | None` instead of deriving `is_array` and `from_zarr_json` from the documents after the fact. That makes the classification explicit. A `.zarray` is an array. A `zarr.json` is whatever its `node_type` says, with a missing `node_type` read as a group, which is the leniency `GroupMetadata.from_dict` already grants and so what `zarr.open` already did. Any other value is now rejected by the probe with `NodeTypeValidationError`, the same error the group open raised for it after the fallback, only earlier and with a clearer message. Assisted-by: ClaudeCode:claude-fable-5-1 --- src/zarr/api/asynchronous.py | 2 +- src/zarr/core/array.py | 37 ++++++++++++++++++------------------ tests/test_api.py | 21 +++++++++++++++++++- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/zarr/api/asynchronous.py b/src/zarr/api/asynchronous.py index 171cb655d8..51a30dd6f9 100644 --- a/src/zarr/api/asynchronous.py +++ b/src/zarr/api/asynchronous.py @@ -394,7 +394,7 @@ async def open( # TODO: the mode check below seems wrong! if "shape" not in kwargs and mode in {"a", "r", "r+", "w"}: probe = await _probe_array_metadata(store_path, zarr_format=zarr_format) - if probe.is_array: + if probe.node_type == "array": # TODO: remove this cast when we fix typing for array metadata dicts _metadata_dict = cast("ArrayMetadataDict", probe.metadata) zarr_format = _metadata_dict["zarr_format"] diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index 7516338ef9..392645062f 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -138,6 +138,7 @@ ArrayNotFoundError, ChunkNotFoundError, MetadataValidationError, + NodeTypeValidationError, ZarrDeprecationWarning, ZarrUserWarning, ) @@ -287,31 +288,21 @@ class _MetadataDocs(TypedDict): class _ArrayProbe: """What a search for array metadata at a path turned up. - `metadata` is the metadata document found there, or None when the path holds - no array metadata document at all. A `zarr.json` document is reported as - found without checking that its `node_type` is `array`; `is_array` applies - that check, and `from_zarr_json` lets a caller raise about it instead. + `node_type` is what the metadata document found there describes: an array, a + group, or None when there is no such document. A `.zarray` always means + an array. A `zarr.json` means whatever its `node_type` says, with a missing + `node_type` read as a group, the same leniency `GroupMetadata.from_dict` + applies. `metadata` is the document itself, or None when `node_type` is. `docs` holds the documents the probe read, for a caller that goes on to open a group at the same path: `zarr.json` and `.zattrs` are keys the group open would otherwise read a second time. """ + node_type: Literal["array", "group"] | None = None metadata: dict[str, JSON] | None = None docs: _MetadataDocs = field(default_factory=_MetadataDocs) - @property - def from_zarr_json(self) -> bool: - """Whether `metadata` came from `zarr.json`, which wins whenever it is present.""" - return self.docs.get("zarr_json") is not None - - @property - def is_array(self) -> bool: - """Whether the metadata found describes an array rather than a group.""" - if self.metadata is None: - return False - return not self.from_zarr_json or self.metadata.get("node_type") == "array" - async def _fetch_array_metadata_docs( store_path: StorePath, zarr_format: ZarrFormat | None @@ -361,10 +352,17 @@ async def _probe_array_metadata( msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store_path}. Zarr v3 will be used." warnings.warn(msg, category=ZarrUserWarning, stacklevel=1) if zarr_json_bytes is not None: - return _ArrayProbe(metadata=buffer_to_json_object(zarr_json_bytes), docs=docs) + metadata = buffer_to_json_object(zarr_json_bytes) + node_type = metadata.get("node_type") + if node_type == "array": + return _ArrayProbe(node_type="array", metadata=metadata, docs=docs) + if node_type in ("group", None): + return _ArrayProbe(node_type="group", metadata=metadata, docs=docs) + msg = f"Invalid value for 'node_type'. Expected 'array' or 'group'. Got {node_type!r}." + raise NodeTypeValidationError(msg) if zarray_bytes is not None: metadata = _array_metadata_dict_v2(zarray_bytes, docs.get("zattrs")) - return _ArrayProbe(metadata=metadata, docs=docs) + return _ArrayProbe(node_type="array", metadata=metadata, docs=docs) return _ArrayProbe(docs=docs) @@ -384,7 +382,8 @@ async def get_array_metadata( f"{store_path.store!r} at path {store_path.path!r}." ) raise ArrayNotFoundError(msg) - if probe.from_zarr_json: + if probe.node_type != "array": + # raise the same error the document's own `node_type` value would produce parse_node_type_array(probe.metadata.get("node_type")) return probe.metadata diff --git a/tests/test_api.py b/tests/test_api.py index 8a6476a723..b14f658e74 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -46,7 +46,7 @@ save_array, save_group, ) -from zarr.core.buffer import NDArrayLike, default_buffer_prototype +from zarr.core.buffer import NDArrayLike, cpu, default_buffer_prototype from zarr.errors import ( ArrayNotFoundError, MetadataValidationError, @@ -1476,6 +1476,25 @@ async def test_open_array_probe_invalid_zarr_format_raises() -> None: await zarr.api.asynchronous.open(store=store, zarr_format="3.0") # type: ignore[arg-type] +async def test_open_zarr_json_without_node_type_is_a_group() -> None: + """A `zarr.json` with no `node_type` opens as a group, as `GroupMetadata.from_dict` allows.""" + store = MemoryStore() + await store.set( + "zarr.json", cpu.Buffer.from_bytes(b'{"zarr_format": 3, "attributes": {"k": "v"}}') + ) + group = await zarr.api.asynchronous.open(store=store, mode="r") + assert isinstance(group, zarr.core.group.AsyncGroup) + assert group.attrs == {"k": "v"} + + +async def test_open_zarr_json_with_invalid_node_type_raises() -> None: + """A `zarr.json` whose `node_type` is neither array nor group is an error, not a fallback.""" + store = MemoryStore() + await store.set("zarr.json", cpu.Buffer.from_bytes(b'{"zarr_format": 3, "node_type": "foo"}')) + with pytest.raises(NodeTypeValidationError, match="Expected 'array' or 'group'. Got 'foo'"): + await zarr.api.asynchronous.open(store=store, mode="r") + + async def test_async_array_open_on_group_raises_node_type() -> None: """Opening a v3 group as an array still reports the node_type mismatch.""" store = MemoryStore() From 0ddfcfd8470cdcfba756b98e1b59387c44028412 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 16 Sep 2026 15:21:21 +0200 Subject: [PATCH 06/10] refactor(array): model the reads as state and interpret them with plain functions Drop the `_ArrayProbe` class. `_fetch_array_metadata_docs` returns the `_MetadataDocs` TypedDict and `_array_metadata_from_docs` interprets it, raising exactly what `get_array_metadata` raised before; that function is now their composition. `zarr.open` reads the docs once ahead of its existing try/except, which is otherwise unchanged, and passes them to the group open on the fallback. Pin the two odd-document behaviors `zarr.open` already had: a zarr.json without a node_type opens as a group, and one with an unknown node_type ends in GroupNotFoundError. Assisted-by: ClaudeCode:claude-fable-5-1 --- src/zarr/api/asynchronous.py | 19 +++++--- src/zarr/core/array.py | 88 ++++++++++++------------------------ tests/test_api.py | 9 ++-- 3 files changed, 47 insertions(+), 69 deletions(-) diff --git a/src/zarr/api/asynchronous.py b/src/zarr/api/asynchronous.py index 51a30dd6f9..2b3097fbe6 100644 --- a/src/zarr/api/asynchronous.py +++ b/src/zarr/api/asynchronous.py @@ -15,8 +15,9 @@ Array, AsyncArray, CompressorLike, + _array_metadata_from_docs, + _fetch_array_metadata_docs, _MetadataDocs, - _probe_array_metadata, create_array, from_array, ) @@ -393,23 +394,27 @@ async def open( # TODO: the mode check below seems wrong! if "shape" not in kwargs and mode in {"a", "r", "r+", "w"}: - probe = await _probe_array_metadata(store_path, zarr_format=zarr_format) - if probe.node_type == "array": + # Read the array metadata documents once: if they turn out not to describe + # an array, the group open below reuses them instead of reading them again. + docs = await _fetch_array_metadata_docs(store_path, zarr_format=zarr_format) + try: + metadata_dict = _array_metadata_from_docs(docs, store_path, zarr_format) # TODO: remove this cast when we fix typing for array metadata dicts - _metadata_dict = cast("ArrayMetadataDict", probe.metadata) + _metadata_dict = cast("ArrayMetadataDict", metadata_dict) + # for v2, the above would already have raised an exception if not an array zarr_format = _metadata_dict["zarr_format"] is_v3_array = zarr_format == 3 and _metadata_dict.get("node_type") == "array" if is_v3_array or zarr_format == 2: return AsyncArray( store_path=store_path, metadata=_metadata_dict, config=kwargs.get("config") ) - # There is no array here, so open a group instead, handing over what the - # probe already read so the group open doesn't pay for the same keys twice. + except (FileNotFoundError, NodeTypeValidationError): + pass return await open_group( store=store_path, zarr_format=zarr_format, mode=mode, - _pre_fetched_metadata=probe.docs, + _pre_fetched_metadata=docs, **kwargs, ) diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index 392645062f..8d149edeeb 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -138,7 +138,6 @@ ArrayNotFoundError, ChunkNotFoundError, MetadataValidationError, - NodeTypeValidationError, ZarrDeprecationWarning, ZarrUserWarning, ) @@ -284,26 +283,6 @@ class _MetadataDocs(TypedDict): zattrs: NotRequired[Buffer | None] -@dataclass(frozen=True, kw_only=True) -class _ArrayProbe: - """What a search for array metadata at a path turned up. - - `node_type` is what the metadata document found there describes: an array, a - group, or None when there is no such document. A `.zarray` always means - an array. A `zarr.json` means whatever its `node_type` says, with a missing - `node_type` read as a group, the same leniency `GroupMetadata.from_dict` - applies. `metadata` is the document itself, or None when `node_type` is. - - `docs` holds the documents the probe read, for a caller that goes on to open - a group at the same path: `zarr.json` and `.zattrs` are keys the group open - would otherwise read a second time. - """ - - node_type: Literal["array", "group"] | None = None - metadata: dict[str, JSON] | None = None - docs: _MetadataDocs = field(default_factory=_MetadataDocs) - - async def _fetch_array_metadata_docs( store_path: StorePath, zarr_format: ZarrFormat | None ) -> _MetadataDocs: @@ -334,17 +313,16 @@ async def _fetch_array_metadata_docs( raise MetadataValidationError(msg) -async def _probe_array_metadata( - store_path: StorePath, zarr_format: ZarrFormat | None = 3 -) -> _ArrayProbe: - """Look for array metadata at `store_path`, reporting a miss instead of raising. +def _array_metadata_from_docs( + docs: _MetadataDocs, store_path: StorePath, zarr_format: ZarrFormat | None +) -> dict[str, JSON]: + """Interpret `docs`, as read by `_fetch_array_metadata_docs`, as array metadata. - This is [`get_array_metadata`][zarr.core.array.get_array_metadata] without - the exceptions for "there is no array here", so that a caller which has - something else to try can do so without paying for the same reads twice. An - invalid `zarr_format` still raises. + `zarr.json` wins when both formats are present. Raises `ArrayNotFoundError` + when there is no array metadata document and `NodeTypeValidationError` when + the `zarr.json` found describes something other than an array. `store_path` + and `zarr_format` only shape the error messages. """ - docs = await _fetch_array_metadata_docs(store_path, zarr_format) zarr_json_bytes = docs.get("zarr_json") zarray_bytes = docs.get("zarray") if zarr_json_bytes is not None and zarray_bytes is not None: @@ -352,40 +330,27 @@ async def _probe_array_metadata( msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store_path}. Zarr v3 will be used." warnings.warn(msg, category=ZarrUserWarning, stacklevel=1) if zarr_json_bytes is not None: - metadata = buffer_to_json_object(zarr_json_bytes) - node_type = metadata.get("node_type") - if node_type == "array": - return _ArrayProbe(node_type="array", metadata=metadata, docs=docs) - if node_type in ("group", None): - return _ArrayProbe(node_type="group", metadata=metadata, docs=docs) - msg = f"Invalid value for 'node_type'. Expected 'array' or 'group'. Got {node_type!r}." - raise NodeTypeValidationError(msg) + return _array_metadata_dict_v3(zarr_json_bytes) if zarray_bytes is not None: - metadata = _array_metadata_dict_v2(zarray_bytes, docs.get("zattrs")) - return _ArrayProbe(node_type="array", metadata=metadata, docs=docs) - return _ArrayProbe(docs=docs) + return _array_metadata_dict_v2(zarray_bytes, docs.get("zattrs")) + if zarr_format is None: + msg = ( + f"Neither Zarr V3 nor Zarr V2 array metadata documents " + f"were found in store {store_path.store!r} at path {store_path.path!r}." + ) + else: + msg = ( + f"A Zarr V{zarr_format} array metadata document was not found in store " + f"{store_path.store!r} at path {store_path.path!r}." + ) + raise ArrayNotFoundError(msg) async def get_array_metadata( store_path: StorePath, zarr_format: ZarrFormat | None = 3 ) -> dict[str, JSON]: - probe = await _probe_array_metadata(store_path, zarr_format=zarr_format) - if probe.metadata is None: - if zarr_format is None: - msg = ( - f"Neither Zarr V3 nor Zarr V2 array metadata documents " - f"were found in store {store_path.store!r} at path {store_path.path!r}." - ) - else: - msg = ( - f"A Zarr V{zarr_format} array metadata document was not found in store " - f"{store_path.store!r} at path {store_path.path!r}." - ) - raise ArrayNotFoundError(msg) - if probe.node_type != "array": - # raise the same error the document's own `node_type` value would produce - parse_node_type_array(probe.metadata.get("node_type")) - return probe.metadata + docs = await _fetch_array_metadata_docs(store_path, zarr_format=zarr_format) + return _array_metadata_from_docs(docs, store_path, zarr_format) def _array_metadata_dict_v2(zarray_bytes: Buffer, zattrs_bytes: Buffer | None) -> dict[str, JSON]: @@ -397,6 +362,13 @@ def _array_metadata_dict_v2(zarray_bytes: Buffer, zattrs_bytes: Buffer | None) - return metadata_dict +def _array_metadata_dict_v3(zarr_json_bytes: Buffer) -> dict[str, JSON]: + """Parse a `zarr.json` document, checking that it describes an array.""" + metadata_dict: dict[str, JSON] = buffer_to_json_object(zarr_json_bytes) + parse_node_type_array(metadata_dict.get("node_type")) + return metadata_dict + + async def _prepare_overwrite( store_path: StorePath, *, zarr_format: ZarrFormat, overwrite: bool ) -> None: diff --git a/tests/test_api.py b/tests/test_api.py index b14f658e74..1240af2bec 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -49,6 +49,7 @@ from zarr.core.buffer import NDArrayLike, cpu, default_buffer_prototype from zarr.errors import ( ArrayNotFoundError, + GroupNotFoundError, MetadataValidationError, NodeTypeValidationError, ZarrDeprecationWarning, @@ -1466,7 +1467,7 @@ async def test_open_array_does_not_fall_back(zarr_format: ZarrFormat) -> None: assert arr.attrs == {"k": "v"} -async def test_open_array_probe_invalid_zarr_format_raises() -> None: +async def test_open_invalid_zarr_format_raises() -> None: """An invalid `zarr_format` is a bad request, not a missing array, so it still raises.""" store = MemoryStore() with pytest.raises( @@ -1487,11 +1488,11 @@ async def test_open_zarr_json_without_node_type_is_a_group() -> None: assert group.attrs == {"k": "v"} -async def test_open_zarr_json_with_invalid_node_type_raises() -> None: - """A `zarr.json` whose `node_type` is neither array nor group is an error, not a fallback.""" +async def test_open_zarr_json_with_invalid_node_type_is_not_a_group() -> None: + """A `zarr.json` whose `node_type` is neither array nor group does not open as a group.""" store = MemoryStore() await store.set("zarr.json", cpu.Buffer.from_bytes(b'{"zarr_format": 3, "node_type": "foo"}')) - with pytest.raises(NodeTypeValidationError, match="Expected 'array' or 'group'. Got 'foo'"): + with pytest.raises(GroupNotFoundError): await zarr.api.asynchronous.open(store=store, mode="r") From 387c1923f6f9a7b3b92c781118d1561b6d678dcd Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 16 Sep 2026 15:28:03 +0200 Subject: [PATCH 07/10] perf(group): read only what the found format needs after a pre-fetch With zarr.json already in hand, the format-detecting branch of AsyncGroup.open knows the format before it reads anything else. A format 3 group needs nothing more, so it now costs no reads at all on top of the array lookup; a format 2 group reads .zgroup, plus the consolidated document only when use_consolidated is not False, which is the rule the explicit format 2 branch already followed. The same rule now applies to the un-pre-fetched path, which used to read .zmetadata and then discard it. zarr.open on a group: format 3 goes from 5 reads to 3, format 2 with use_consolidated=False from 5 to 4. The one thing given up is the warning for a store holding both zarr.json and .zgroup on the zarr.open path, since .zgroup is no longer read when zarr.json is present; open_group with zarr_format=None still emits it. Assisted-by: ClaudeCode:claude-fable-5-1 --- changes/4366.misc.md | 2 +- src/zarr/core/group.py | 39 ++++++++++++++++++++++----------------- tests/test_api.py | 35 ++++++++++++++++++++++++----------- 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/changes/4366.misc.md b/changes/4366.misc.md index 9008ad5017..cea4a8428c 100644 --- a/changes/4366.misc.md +++ b/changes/4366.misc.md @@ -1 +1 @@ -`zarr.open` no longer reads `zarr.json` and `.zattrs` twice when it falls back from looking for an array to opening a group. +`zarr.open` no longer reads `zarr.json` and `.zattrs` twice when it falls back from looking for an array to opening a group, and the group open then reads only what the format it found needs: nothing more for Zarr format 3, and no consolidated-metadata document when `use_consolidated=False`.\n \ No newline at end of file diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index ab5adff061..55c24eb3de 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -529,8 +529,10 @@ async def open( Private. Metadata documents for this path that the caller already read, to use instead of reading them again. Only the ``zarr.json`` and ``.zattrs`` entries are consulted, and only when ``zarr_format`` - is None. `zarr.api.asynchronous.open` passes what it read while - looking for an array before falling back to opening a group. + is None; a ``zarr.json`` in hand also settles the format before any + further read, so a format 3 group costs no reads at all here. + `zarr.api.asynchronous.open` passes what it read while looking for + an array before falling back to opening a group. """ store_path = await make_store_path(store) if not store_path.store.supports_consolidated_metadata: @@ -571,27 +573,30 @@ async def open( if zarr_json_bytes is None: raise FileNotFoundError(store_path) elif zarr_format is None: + # A consolidated document is only worth reading if it might be used. + want_consolidated = use_consolidated or use_consolidated is None pre_fetched = _pre_fetched_metadata or {} if "zarr_json" in pre_fetched and "zattrs" in pre_fetched: - # the caller already read these; only read what is still missing + # The caller already read these, and they settle the format before + # anything else is read: a zarr.json means format 3, which has no + # use for .zgroup or a consolidated document. zarr_json_bytes = pre_fetched["zarr_json"] zattrs_bytes = pre_fetched["zattrs"] - zgroup_bytes, maybe_consolidated_metadata_bytes = await asyncio.gather( - (store_path / ZGROUP_JSON).get(), - (store_path / str(consolidated_key)).get(), - ) + zgroup_bytes = maybe_consolidated_metadata_bytes = None + if zarr_json_bytes is None: + paths = [store_path / ZGROUP_JSON] + if want_consolidated: + paths.append(store_path / consolidated_key) + zgroup_bytes, *rest = await asyncio.gather(*[path.get() for path in paths]) + maybe_consolidated_metadata_bytes = rest[0] if rest else None else: - ( - zarr_json_bytes, - zgroup_bytes, - zattrs_bytes, - maybe_consolidated_metadata_bytes, - ) = await asyncio.gather( - (store_path / ZARR_JSON).get(), - (store_path / ZGROUP_JSON).get(), - (store_path / ZATTRS_JSON).get(), - (store_path / str(consolidated_key)).get(), + paths = [store_path / ZARR_JSON, store_path / ZGROUP_JSON, store_path / ZATTRS_JSON] + if want_consolidated: + paths.append(store_path / consolidated_key) + zarr_json_bytes, zgroup_bytes, zattrs_bytes, *rest = await asyncio.gather( + *[path.get() for path in paths] ) + maybe_consolidated_metadata_bytes = rest[0] if rest else None if zarr_json_bytes is not None and zgroup_bytes is not None: # warn and favor v3 msg = f"Both zarr.json (Zarr format 3) and .zgroup (Zarr format 2) metadata objects exist at {store_path}. Zarr format 3 will be used." diff --git a/tests/test_api.py b/tests/test_api.py index 1240af2bec..f0822e852e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1413,21 +1413,27 @@ async def get( @pytest.mark.filterwarnings("ignore:Consolidated metadata") @pytest.mark.parametrize( ("zarr_format", "use_consolidated"), - [(2, False), (2, True), (2, "custom"), (3, False), (3, True)], + [(2, None), (2, False), (2, True), (2, "custom"), (3, None), (3, False), (3, True)], ) @pytest.mark.parametrize("mode", ["r", "r+", "a"]) @pytest.mark.parametrize("path", ["", "parent/child"]) -async def test_open_group_fallback_reads_each_key_once( - zarr_format: ZarrFormat, use_consolidated: bool | str, mode: AccessModeLiteral, path: str +async def test_open_group_fallback_reads_only_what_it_needs( + zarr_format: ZarrFormat, + use_consolidated: bool | str | None, + mode: AccessModeLiteral, + path: str, ) -> None: - """`open` falling back to a group reads no key twice, and opens the same group as before. - - The array probe and the group open read an overlapping set of keys, so the - probe hands over what it read. That has to leave the resulting group -- its - format, path, attributes, read-only-ness and consolidated metadata -- - exactly as it was when both read the store independently. + """`open` falling back to a group reads each key at most once, and only the keys it needs. + + The array lookup reads `zarr.json`, `.zarray` and `.zattrs` and hands them to + the group open, which then knows the format: a format 3 group needs nothing + more, a format 2 group needs `.zgroup` plus the consolidated document when + that might be used. The resulting group -- its format, path, attributes, + read-only-ness and consolidated metadata -- has to be exactly what it was when + both read the store independently. """ store = _CountingStore(MemoryStore()) + prefix = f"{path}/" if path else "" await zarr.api.asynchronous.open_group( store, path=path, attributes={"key": "value"}, zarr_format=zarr_format ) @@ -1435,7 +1441,6 @@ async def test_open_group_fallback_reads_each_key_once( await zarr.api.asynchronous.consolidate_metadata(store, path=path) if isinstance(use_consolidated, str): # move the consolidated document to the non-default key - prefix = f"{path}/" if path else "" metadata = await store.get(prefix + ".zmetadata", default_buffer_prototype()) assert metadata is not None await store.set(prefix + use_consolidated, metadata) @@ -1451,7 +1456,15 @@ async def test_open_group_fallback_reads_each_key_once( assert group.attrs == {"key": "value"} assert group.store.read_only == (mode == "r") assert (group.metadata.consolidated_metadata is not None) == bool(use_consolidated) - assert [key for key, count in store.get_counts.items() if count > 1] == [] + + expected_keys = {"zarr.json", ".zarray", ".zattrs"} + if zarr_format == 2: + expected_keys.add(".zgroup") + if use_consolidated is not False: + expected_keys.add( + ".zmetadata" if isinstance(use_consolidated, bool | None) else use_consolidated + ) + assert store.get_counts == {prefix + key: 1 for key in expected_keys} @pytest.mark.parametrize("zarr_format", [2, 3]) From 821cc214739e2c282cb87742fce7d6033d879ee0 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 16 Sep 2026 15:47:27 +0200 Subject: [PATCH 08/10] perf(api): give zarr.open a Zarr format 3 path and a format 2 fallback zarr.open used to look for an array and, failing that, open a group, and the two steps read overlapping keys: seven requests for a group, five of them for a format 3 group whose single zarr.json already held everything. Now zarr.open builds the node itself from what it reads. _open_v3 reads zarr.json once; its node_type says array or group and the document holds everything needed to open either. Only if there is no zarr.json does _open_v2 read .zarray, .zgroup, .zattrs and, when it might be used, the consolidated document, in one concurrent round. The existing open_group fallback still handles create-or-raise, and the mode="w" quirk is kept: an existing array is returned, an existing group is overwritten. The pieces zarr.open needs come out of AsyncGroup.open without changing it: _resolve_use_consolidated settles use_consolidated against the store, the format 2 missing/discard check moves into _from_bytes_v2 so it mirrors _from_bytes_v3, and _from_dict_v3 builds a group from a parsed document. No signature that users see changes. Reads via zarr.open: a format 3 array or group costs one; a format 2 group five (four with use_consolidated=False), as before; a format 2 array five where it cost three, plus one round trip, the price of being the fallback. When both zarr.json and .zarray exist at a path, zarr.open takes the format 3 node without reading the other and no longer warns; open_array still does. zarr.open(mode="w", use_consolidated=...) now runs the consolidated metadata checks before overwriting instead of ignoring the request. Assisted-by: ClaudeCode:claude-fable-5-1 --- changes/4366.misc.md | 2 +- src/zarr/api/asynchronous.py | 122 ++++++++++++++++++++++++----------- src/zarr/api/synchronous.py | 9 --- src/zarr/core/array.py | 93 +++++++++----------------- src/zarr/core/group.py | 122 +++++++++++++++++------------------ tests/test_api.py | 82 +++++++++++++---------- 6 files changed, 224 insertions(+), 206 deletions(-) diff --git a/changes/4366.misc.md b/changes/4366.misc.md index cea4a8428c..5a95a417e9 100644 --- a/changes/4366.misc.md +++ b/changes/4366.misc.md @@ -1 +1 @@ -`zarr.open` no longer reads `zarr.json` and `.zattrs` twice when it falls back from looking for an array to opening a group, and the group open then reads only what the format it found needs: nothing more for Zarr format 3, and no consolidated-metadata document when `use_consolidated=False`.\n \ No newline at end of file +`zarr.open` now opens a Zarr format 3 array or group with a single read of `zarr.json`, and a format 2 node with one concurrent round of reads, instead of first looking for an array and then opening a group with overlapping reads (five requests for a format 3 group, seven for a format 2 one). When both `zarr.json` and `.zarray` exist at a path, `zarr.open` takes the format 3 node without reading the other and no longer warns about the pair; `open_array` still does.\n \ No newline at end of file diff --git a/src/zarr/api/asynchronous.py b/src/zarr/api/asynchronous.py index 2b3097fbe6..0d1d76366e 100644 --- a/src/zarr/api/asynchronous.py +++ b/src/zarr/api/asynchronous.py @@ -10,21 +10,26 @@ from typing_extensions import deprecated from zarr.abc.store import Store +from zarr.core._json import buffer_to_json_object from zarr.core.array import ( DEFAULT_FILL_VALUE, Array, AsyncArray, CompressorLike, - _array_metadata_from_docs, - _fetch_array_metadata_docs, - _MetadataDocs, + _array_metadata_dict_v2, create_array, from_array, ) from zarr.core.array_spec import ArrayConfigLike, parse_array_config from zarr.core.buffer import NDArrayLike +from zarr.core.buffer.cpu import buffer_prototype as cpu_buffer_prototype from zarr.core.common import ( JSON, + ZARR_JSON, + ZARRAY_JSON, + ZATTRS_JSON, + ZGROUP_JSON, + ZMETADATA_V2_JSON, AccessModeLiteral, DimensionNamesLike, MemoryOrder, @@ -37,12 +42,14 @@ AsyncGroup, ConsolidatedMetadata, GroupMetadata, + _resolve_use_consolidated, create_hierarchy, ) from zarr.core.metadata import ArrayMetadataDict, ArrayV2Metadata from zarr.errors import ( ArrayNotFoundError, GroupNotFoundError, + MetadataValidationError, NodeTypeValidationError, ZarrDeprecationWarning, ZarrRuntimeWarning, @@ -394,29 +401,27 @@ async def open( # TODO: the mode check below seems wrong! if "shape" not in kwargs and mode in {"a", "r", "r+", "w"}: - # Read the array metadata documents once: if they turn out not to describe - # an array, the group open below reuses them instead of reading them again. - docs = await _fetch_array_metadata_docs(store_path, zarr_format=zarr_format) - try: - metadata_dict = _array_metadata_from_docs(docs, store_path, zarr_format) - # TODO: remove this cast when we fix typing for array metadata dicts - _metadata_dict = cast("ArrayMetadataDict", metadata_dict) - # for v2, the above would already have raised an exception if not an array - zarr_format = _metadata_dict["zarr_format"] - is_v3_array = zarr_format == 3 and _metadata_dict.get("node_type") == "array" - if is_v3_array or zarr_format == 2: - return AsyncArray( - store_path=store_path, metadata=_metadata_dict, config=kwargs.get("config") - ) - except (FileNotFoundError, NodeTypeValidationError): - pass - return await open_group( - store=store_path, - zarr_format=zarr_format, - mode=mode, - _pre_fetched_metadata=docs, - **kwargs, + if zarr_format not in (2, 3, None): + msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." + raise MetadataValidationError(msg) + # Zarr format 3 first: its one document says whether the node is an array + # or a group and holds everything needed to open it. Only when there is no + # zarr.json are the format 2 documents read, so a path holding both formats + # opens as format 3 without a second look. + use_consolidated = _resolve_use_consolidated( + store_path.store, kwargs.get("use_consolidated") ) + config = kwargs.get("config") + node = None + if zarr_format != 2: + node = await _open_v3(store_path, use_consolidated=use_consolidated, config=config) + if node is None and zarr_format != 3: + node = await _open_v2(store_path, use_consolidated=use_consolidated, config=config) + # An existing array is returned whatever the mode; an existing group only in + # a read mode, since "w" means overwrite and that is open_group's business. + if isinstance(node, AsyncArray) or (node is not None and mode in _READ_MODES): + return node + return await open_group(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs) try: return await open_array(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs) @@ -427,6 +432,61 @@ async def open( return await open_group(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs) +async def _open_v3( + store_path: StorePath, *, use_consolidated: bool | str | None, config: ArrayConfigLike | None +) -> AnyAsyncArray | AsyncGroup | None: + """Open the Zarr format 3 node at `store_path`, or return None if there is none. + + One read: `zarr.json` says whether the node is an array or a group and holds + everything needed to open it. + """ + zarr_json_bytes = await (store_path / ZARR_JSON).get(prototype=cpu_buffer_prototype) + if zarr_json_bytes is None: + return None + metadata = buffer_to_json_object(zarr_json_bytes) + if metadata.get("node_type") == "array": + # TODO: remove this cast when we fix typing for array metadata dicts + return AsyncArray( + store_path=store_path, metadata=cast("ArrayMetadataDict", metadata), config=config + ) + # anything else is a group, or fails to be one in GroupMetadata.from_dict + return AsyncGroup._from_dict_v3(store_path, metadata, use_consolidated=use_consolidated) + + +async def _open_v2( + store_path: StorePath, *, use_consolidated: bool | str | None, config: ArrayConfigLike | None +) -> AnyAsyncArray | AsyncGroup | None: + """Open the Zarr format 2 node at `store_path`, or return None if there is none. + + One concurrent read of `.zarray`, `.zgroup`, `.zattrs` and, when it might be + used, the consolidated metadata document. `.zarray` makes the node an array + and `.zgroup` a group, the array winning if both are present. + """ + consolidated_key = use_consolidated if isinstance(use_consolidated, str) else ZMETADATA_V2_JSON + keys = [ZARRAY_JSON, ZGROUP_JSON, ZATTRS_JSON] + if use_consolidated or use_consolidated is None: + keys.append(consolidated_key) + zarray_bytes, zgroup_bytes, zattrs_bytes, *rest = await asyncio.gather( + *((store_path / key).get(prototype=cpu_buffer_prototype) for key in keys) + ) + if zarray_bytes is not None: + metadata = _array_metadata_dict_v2(zarray_bytes, zattrs_bytes) + # TODO: remove this cast when we fix typing for array metadata dicts + return AsyncArray( + store_path=store_path, metadata=cast("ArrayMetadataDict", metadata), config=config + ) + if zgroup_bytes is None: + return None + return AsyncGroup._from_bytes_v2( + store_path, + zgroup_bytes, + zattrs_bytes, + rest[0] if rest else None, + use_consolidated=use_consolidated, + consolidated_key=consolidated_key, + ) + + async def open_consolidated( *args: Any, use_consolidated: Literal[True] = True, **kwargs: Any ) -> AsyncGroup: @@ -801,7 +861,6 @@ async def open_group( meta_array: Any | None = None, # not used attributes: dict[str, JSON] | None = None, use_consolidated: bool | str | None = None, - _pre_fetched_metadata: _MetadataDocs | None = None, ) -> AsyncGroup: """Open a group using file-mode-like semantics. @@ -852,12 +911,6 @@ async def open_group( Zarr format 2 allowed configuring the key storing the consolidated metadata (`.zmetadata` by default). Specify the custom key as `use_consolidated` to load consolidated metadata from a non-default key. - _pre_fetched_metadata : _MetadataDocs or None, default None - Private. Metadata documents for this path that the caller already read, - to use instead of reading them again. Only consulted when `zarr_format` - is None and the group is opened rather than created. - [`zarr.api.asynchronous.open`][zarr.api.asynchronous.open] passes what it - read while looking for an array before falling back to opening a group. Returns ------- @@ -881,10 +934,7 @@ async def open_group( try: if mode in _READ_MODES: return await AsyncGroup.open( - store_path, - zarr_format=zarr_format, - use_consolidated=use_consolidated, - _pre_fetched_metadata=_pre_fetched_metadata, + store_path, zarr_format=zarr_format, use_consolidated=use_consolidated ) except (KeyError, FileNotFoundError): pass diff --git a/src/zarr/api/synchronous.py b/src/zarr/api/synchronous.py index ac30da5518..6975f6d953 100644 --- a/src/zarr/api/synchronous.py +++ b/src/zarr/api/synchronous.py @@ -26,7 +26,6 @@ FiltersLike, SerializerLike, ShardsLike, - _MetadataDocs, ) from zarr.core.array_spec import ArrayConfigLike from zarr.core.buffer import NDArrayLike, NDArrayLikeOrScalar @@ -493,7 +492,6 @@ def open_group( meta_array: Any | None = None, # not used in async api attributes: dict[str, JSON] | None = None, use_consolidated: bool | str | None = None, - _pre_fetched_metadata: _MetadataDocs | None = None, ) -> Group: """Open a group using file-mode-like semantics. @@ -544,12 +542,6 @@ def open_group( Zarr format 2 allowed configuring the key storing the consolidated metadata (`.zmetadata` by default). Specify the custom key as `use_consolidated` to load consolidated metadata from a non-default key. - _pre_fetched_metadata : _MetadataDocs or None, default None - Private. Metadata documents for this path that the caller already read, - to use instead of reading them again. Only consulted when `zarr_format` - is None and the group is opened rather than created. - [`zarr.api.asynchronous.open`][zarr.api.asynchronous.open] passes what it - read while looking for an array before falling back to opening a group. Returns ------- @@ -570,7 +562,6 @@ def open_group( meta_array=meta_array, attributes=attributes, use_consolidated=use_consolidated, - _pre_fetched_metadata=_pre_fetched_metadata, ) ) ) diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index 8d149edeeb..5a8d6bf57e 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -12,7 +12,6 @@ TYPE_CHECKING, Any, Literal, - NotRequired, TypedDict, cast, overload, @@ -270,87 +269,53 @@ def create_codec_pipeline(metadata: ArrayMetadata, *, store: Store | None = None raise TypeError # pragma: no cover -class _MetadataDocs(TypedDict): - """Metadata documents read from a store, so a second reader can skip re-reading them. - - A key is present only if that document was read; its value is None when the - store held nothing there. That lets a consumer tell "not read" from "absent" - and reuse exactly what was read. - """ - - zarr_json: NotRequired[Buffer | None] - zarray: NotRequired[Buffer | None] - zattrs: NotRequired[Buffer | None] - - -async def _fetch_array_metadata_docs( - store_path: StorePath, zarr_format: ZarrFormat | None -) -> _MetadataDocs: - """Read the documents that could describe an array at `store_path`. - - Which keys are read depends on `zarr_format`: `.zarray` and `.zattrs` for 2, - `zarr.json` for 3, and all three when it is None and the format has to be - detected. - """ +async def get_array_metadata( + store_path: StorePath, zarr_format: ZarrFormat | None = 3 +) -> dict[str, JSON]: if zarr_format == 2: zarray_bytes, zattrs_bytes = await gather( (store_path / ZARRAY_JSON).get(prototype=cpu_buffer_prototype), (store_path / ZATTRS_JSON).get(prototype=cpu_buffer_prototype), ) - return _MetadataDocs(zarray=zarray_bytes, zattrs=zattrs_bytes) + if zarray_bytes is None: + msg = ( + "A Zarr V2 array metadata document was not found in store " + f"{store_path.store!r} at path {store_path.path!r}." + ) + raise ArrayNotFoundError(msg) + return _array_metadata_dict_v2(zarray_bytes, zattrs_bytes) elif zarr_format == 3: zarr_json_bytes = await (store_path / ZARR_JSON).get(prototype=cpu_buffer_prototype) - return _MetadataDocs(zarr_json=zarr_json_bytes) + if zarr_json_bytes is None: + msg = ( + "A Zarr V3 array metadata document was not found in store " + f"{store_path.store!r} at path {store_path.path!r}." + ) + raise ArrayNotFoundError(msg) + return _array_metadata_dict_v3(zarr_json_bytes) elif zarr_format is None: zarr_json_bytes, zarray_bytes, zattrs_bytes = await gather( (store_path / ZARR_JSON).get(prototype=cpu_buffer_prototype), (store_path / ZARRAY_JSON).get(prototype=cpu_buffer_prototype), (store_path / ZATTRS_JSON).get(prototype=cpu_buffer_prototype), ) - return _MetadataDocs(zarr_json=zarr_json_bytes, zarray=zarray_bytes, zattrs=zattrs_bytes) - else: - msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." # type: ignore[unreachable] - raise MetadataValidationError(msg) - - -def _array_metadata_from_docs( - docs: _MetadataDocs, store_path: StorePath, zarr_format: ZarrFormat | None -) -> dict[str, JSON]: - """Interpret `docs`, as read by `_fetch_array_metadata_docs`, as array metadata. - - `zarr.json` wins when both formats are present. Raises `ArrayNotFoundError` - when there is no array metadata document and `NodeTypeValidationError` when - the `zarr.json` found describes something other than an array. `store_path` - and `zarr_format` only shape the error messages. - """ - zarr_json_bytes = docs.get("zarr_json") - zarray_bytes = docs.get("zarray") - if zarr_json_bytes is not None and zarray_bytes is not None: - # warn and favor v3 - msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store_path}. Zarr v3 will be used." - warnings.warn(msg, category=ZarrUserWarning, stacklevel=1) - if zarr_json_bytes is not None: - return _array_metadata_dict_v3(zarr_json_bytes) - if zarray_bytes is not None: - return _array_metadata_dict_v2(zarray_bytes, docs.get("zattrs")) - if zarr_format is None: + if zarr_json_bytes is not None and zarray_bytes is not None: + # warn and favor v3 + msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store_path}. Zarr v3 will be used." + warnings.warn(msg, category=ZarrUserWarning, stacklevel=1) + # favor v3 when both are present + if zarr_json_bytes is not None: + return _array_metadata_dict_v3(zarr_json_bytes) + if zarray_bytes is not None: + return _array_metadata_dict_v2(zarray_bytes, zattrs_bytes) msg = ( f"Neither Zarr V3 nor Zarr V2 array metadata documents " f"were found in store {store_path.store!r} at path {store_path.path!r}." ) + raise ArrayNotFoundError(msg) else: - msg = ( - f"A Zarr V{zarr_format} array metadata document was not found in store " - f"{store_path.store!r} at path {store_path.path!r}." - ) - raise ArrayNotFoundError(msg) - - -async def get_array_metadata( - store_path: StorePath, zarr_format: ZarrFormat | None = 3 -) -> dict[str, JSON]: - docs = await _fetch_array_metadata_docs(store_path, zarr_format=zarr_format) - return _array_metadata_from_docs(docs, store_path, zarr_format) + msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." # type: ignore[unreachable] + raise MetadataValidationError(msg) def _array_metadata_dict_v2(zarray_bytes: Buffer, zattrs_bytes: Buffer | None) -> dict[str, JSON]: diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index 55c24eb3de..9ab8823279 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -25,7 +25,6 @@ FiltersLike, SerializerLike, ShardsLike, - _MetadataDocs, _parse_deprecated_compressor, create_array, ) @@ -86,6 +85,23 @@ logger = logging.getLogger("zarr.group") +def _resolve_use_consolidated( + store: Store, use_consolidated: bool | str | None +) -> bool | str | None: + """Settle `use_consolidated` against what `store` supports. + + A store that can't hold consolidated metadata makes the answer False, unless + consolidated metadata was explicitly asked for, which is an error. + """ + if store.supports_consolidated_metadata: + return use_consolidated + if use_consolidated: + raise ValueError( + f"The Zarr store in use ({type(store).__name__}) doesn't support consolidated metadata." + ) + return False + + def parse_zarr_format(data: Any) -> ZarrFormat: """Parse the zarr_format field from metadata.""" return cast("ZarrFormat", parse_field(data, Literal[2, 3], "zarr_format")) @@ -500,7 +516,6 @@ async def open( store: StoreLike, zarr_format: ZarrFormat | None = 3, use_consolidated: bool | str | None = None, - _pre_fetched_metadata: _MetadataDocs | None = None, ) -> AsyncGroup: """Open a new AsyncGroup @@ -525,26 +540,9 @@ async def open( Zarr format 2 allowed configuring the key storing the consolidated metadata (``.zmetadata`` by default). Specify the custom key as ``use_consolidated`` to load consolidated metadata from a non-default key. - _pre_fetched_metadata : _MetadataDocs or None, default None - Private. Metadata documents for this path that the caller already - read, to use instead of reading them again. Only the ``zarr.json`` - and ``.zattrs`` entries are consulted, and only when ``zarr_format`` - is None; a ``zarr.json`` in hand also settles the format before any - further read, so a format 3 group costs no reads at all here. - `zarr.api.asynchronous.open` passes what it read while looking for - an array before falling back to opening a group. """ store_path = await make_store_path(store) - if not store_path.store.supports_consolidated_metadata: - # Fail if consolidated metadata was requested but the Store doesn't support it - if use_consolidated: - store_name = type(store_path.store).__name__ - raise ValueError( - f"The Zarr store in use ({store_name}) doesn't support consolidated metadata." - ) - - # if use_consolidated was None (optional), the Store dictates it doesn't want consolidation - use_consolidated = False + use_consolidated = _resolve_use_consolidated(store_path.store, use_consolidated) consolidated_key = ZMETADATA_V2_JSON @@ -573,30 +571,17 @@ async def open( if zarr_json_bytes is None: raise FileNotFoundError(store_path) elif zarr_format is None: - # A consolidated document is only worth reading if it might be used. - want_consolidated = use_consolidated or use_consolidated is None - pre_fetched = _pre_fetched_metadata or {} - if "zarr_json" in pre_fetched and "zattrs" in pre_fetched: - # The caller already read these, and they settle the format before - # anything else is read: a zarr.json means format 3, which has no - # use for .zgroup or a consolidated document. - zarr_json_bytes = pre_fetched["zarr_json"] - zattrs_bytes = pre_fetched["zattrs"] - zgroup_bytes = maybe_consolidated_metadata_bytes = None - if zarr_json_bytes is None: - paths = [store_path / ZGROUP_JSON] - if want_consolidated: - paths.append(store_path / consolidated_key) - zgroup_bytes, *rest = await asyncio.gather(*[path.get() for path in paths]) - maybe_consolidated_metadata_bytes = rest[0] if rest else None - else: - paths = [store_path / ZARR_JSON, store_path / ZGROUP_JSON, store_path / ZATTRS_JSON] - if want_consolidated: - paths.append(store_path / consolidated_key) - zarr_json_bytes, zgroup_bytes, zattrs_bytes, *rest = await asyncio.gather( - *[path.get() for path in paths] - ) - maybe_consolidated_metadata_bytes = rest[0] if rest else None + ( + zarr_json_bytes, + zgroup_bytes, + zattrs_bytes, + maybe_consolidated_metadata_bytes, + ) = await asyncio.gather( + (store_path / ZARR_JSON).get(), + (store_path / ZGROUP_JSON).get(), + (store_path / ZATTRS_JSON).get(), + (store_path / str(consolidated_key)).get(), + ) if zarr_json_bytes is not None and zgroup_bytes is not None: # warn and favor v3 msg = f"Both zarr.json (Zarr format 3) and .zgroup (Zarr format 2) metadata objects exist at {store_path}. Zarr format 3 will be used." @@ -617,30 +602,20 @@ async def open( if zarr_format == 2: if zgroup_bytes is None: raise FileNotFoundError(store_path) - - if use_consolidated and maybe_consolidated_metadata_bytes is None: - # the user requested consolidated metadata, but it was missing - raise ValueError(consolidated_key) - - elif use_consolidated is False: - # the user explicitly opted out of consolidated_metadata. - # Discard anything we might have read. - maybe_consolidated_metadata_bytes = None - return cls._from_bytes_v2( - store_path, zgroup_bytes, zattrs_bytes, maybe_consolidated_metadata_bytes + store_path, + zgroup_bytes, + zattrs_bytes, + maybe_consolidated_metadata_bytes, + use_consolidated=use_consolidated, + consolidated_key=consolidated_key, ) else: # V3 groups are comprised of a zarr.json object if zarr_json_bytes is None: raise FileNotFoundError(store_path) - if not isinstance(use_consolidated, bool | None): - raise TypeError("use_consolidated must be a bool or None for Zarr format 3.") - return cls._from_bytes_v3( - store_path, - zarr_json_bytes, - use_consolidated=use_consolidated, + store_path, zarr_json_bytes, use_consolidated=use_consolidated ) @classmethod @@ -650,7 +625,18 @@ def _from_bytes_v2( zgroup_bytes: Buffer, zattrs_bytes: Buffer | None, consolidated_metadata_bytes: Buffer | None, + *, + use_consolidated: bool | str | None = None, + consolidated_key: str = ZMETADATA_V2_JSON, ) -> AsyncGroup: + if use_consolidated and consolidated_metadata_bytes is None: + # the user requested consolidated metadata, but it was missing + raise ValueError(consolidated_key) + elif use_consolidated is False: + # the user explicitly opted out of consolidated_metadata. + # Discard anything we might have read. + consolidated_metadata_bytes = None + # V2 groups are comprised of a .zgroup and .zattrs objects zgroup = buffer_to_json_object(zgroup_bytes) zattrs = buffer_to_json_object(zattrs_bytes) if zattrs_bytes is not None else {} @@ -691,9 +677,21 @@ def _from_bytes_v3( cls, store_path: StorePath, zarr_json_bytes: Buffer, - use_consolidated: bool | None, + use_consolidated: bool | str | None, ) -> AsyncGroup: group_metadata = buffer_to_json_object(zarr_json_bytes) + return cls._from_dict_v3(store_path, group_metadata, use_consolidated=use_consolidated) + + @classmethod + def _from_dict_v3( + cls, + store_path: StorePath, + group_metadata: dict[str, JSON], + use_consolidated: bool | str | None, + ) -> AsyncGroup: + """Build the group from an already-parsed `zarr.json` document.""" + if not isinstance(use_consolidated, bool | None): + raise TypeError("use_consolidated must be a bool or None for Zarr format 3.") if use_consolidated and group_metadata.get("consolidated_metadata") is None: msg = f"Consolidated metadata requested with 'use_consolidated=True' but not found in '{store_path.path}'." raise ValueError(msg) diff --git a/tests/test_api.py b/tests/test_api.py index f0822e852e..7e0d9b4c45 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -367,9 +367,13 @@ def test_array_open_array_not_found_sync() -> None: def test_v2_and_v3_exist_at_same_path(store: Store) -> None: zarr.create_array(store, shape=(10,), dtype="uint8", zarr_format=3) zarr.create_array(store, shape=(10,), dtype="uint8", zarr_format=2) + # `open` reads only zarr.json and takes the format 3 node without a second look + node = zarr.open(store=store) + assert node.metadata.zarr_format == 3 + # `open_array` looks at both and says so msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store}. Zarr v3 will be used." with pytest.warns(ZarrUserWarning, match=re.escape(msg)): - zarr.open(store=store) + zarr.open_array(store=store) @pytest.mark.parametrize("store", ["memory"], indirect=True) @@ -1411,55 +1415,65 @@ async def get( @pytest.mark.filterwarnings("ignore:Consolidated metadata") +@pytest.mark.parametrize("node", ["array", "group"]) @pytest.mark.parametrize( ("zarr_format", "use_consolidated"), [(2, None), (2, False), (2, True), (2, "custom"), (3, None), (3, False), (3, True)], ) @pytest.mark.parametrize("mode", ["r", "r+", "a"]) @pytest.mark.parametrize("path", ["", "parent/child"]) -async def test_open_group_fallback_reads_only_what_it_needs( +async def test_open_reads_only_what_the_node_needs( + node: Literal["array", "group"], zarr_format: ZarrFormat, use_consolidated: bool | str | None, mode: AccessModeLiteral, path: str, ) -> None: - """`open` falling back to a group reads each key at most once, and only the keys it needs. - - The array lookup reads `zarr.json`, `.zarray` and `.zattrs` and hands them to - the group open, which then knows the format: a format 3 group needs nothing - more, a format 2 group needs `.zgroup` plus the consolidated document when - that might be used. The resulting group -- its format, path, attributes, - read-only-ness and consolidated metadata -- has to be exactly what it was when - both read the store independently. + """`open` reads a format 3 node with one request and a format 2 node with one round of them. + + Whatever it finds has to be what `open_array` or `open_group` would have + returned: the same format, path, attributes and read-only-ness, and for a + group the same consolidated metadata. """ store = _CountingStore(MemoryStore()) prefix = f"{path}/" if path else "" - await zarr.api.asynchronous.open_group( - store, path=path, attributes={"key": "value"}, zarr_format=zarr_format - ) - if use_consolidated: - await zarr.api.asynchronous.consolidate_metadata(store, path=path) - if isinstance(use_consolidated, str): - # move the consolidated document to the non-default key - metadata = await store.get(prefix + ".zmetadata", default_buffer_prototype()) - assert metadata is not None - await store.set(prefix + use_consolidated, metadata) - await store.delete(prefix + ".zmetadata") + if node == "array": + await zarr.api.asynchronous.create_array( + store, + name=path or None, + shape=(3,), + dtype="uint8", + attributes={"key": "value"}, + zarr_format=zarr_format, + ) + else: + await zarr.api.asynchronous.open_group( + store, path=path, attributes={"key": "value"}, zarr_format=zarr_format + ) + if use_consolidated: + await zarr.api.asynchronous.consolidate_metadata(store, path=path) + if isinstance(use_consolidated, str): + # move the consolidated document to the non-default key + metadata = await store.get(prefix + ".zmetadata", default_buffer_prototype()) + assert metadata is not None + await store.set(prefix + use_consolidated, metadata) + await store.delete(prefix + ".zmetadata") store.get_counts.clear() - group = await zarr.api.asynchronous.open( + result = await zarr.api.asynchronous.open( store=store, path=path, mode=mode, use_consolidated=use_consolidated ) - assert isinstance(group, zarr.core.group.AsyncGroup) - assert group.metadata.zarr_format == zarr_format - assert group.path == path - assert group.attrs == {"key": "value"} - assert group.store.read_only == (mode == "r") - assert (group.metadata.consolidated_metadata is not None) == bool(use_consolidated) - - expected_keys = {"zarr.json", ".zarray", ".zattrs"} + assert isinstance(result, AsyncArray if node == "array" else zarr.core.group.AsyncGroup) + assert result.metadata.zarr_format == zarr_format + assert result.path == path + assert result.attrs == {"key": "value"} + assert result.store.read_only == (mode == "r") + if isinstance(result, zarr.core.group.AsyncGroup): + assert (result.metadata.consolidated_metadata is not None) == bool(use_consolidated) + + expected_keys = {"zarr.json"} if zarr_format == 2: - expected_keys.add(".zgroup") + expected_keys |= {".zarray", ".zgroup", ".zattrs"} if use_consolidated is not False: expected_keys.add( ".zmetadata" if isinstance(use_consolidated, bool | None) else use_consolidated @@ -1501,11 +1515,11 @@ async def test_open_zarr_json_without_node_type_is_a_group() -> None: assert group.attrs == {"k": "v"} -async def test_open_zarr_json_with_invalid_node_type_is_not_a_group() -> None: - """A `zarr.json` whose `node_type` is neither array nor group does not open as a group.""" +async def test_open_zarr_json_with_invalid_node_type_raises() -> None: + """A `zarr.json` whose `node_type` is neither array nor group is an error, as on `main`.""" store = MemoryStore() await store.set("zarr.json", cpu.Buffer.from_bytes(b'{"zarr_format": 3, "node_type": "foo"}')) - with pytest.raises(GroupNotFoundError): + with pytest.raises(GroupNotFoundError, match="is not 'group'"): await zarr.api.asynchronous.open(store=store, mode="r") From 3498a9717fadd33e7aef4ef4a5d83dd6695d3445 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 16 Sep 2026 16:09:44 +0200 Subject: [PATCH 09/10] refactor(api): build zarr.open, open_array and open_group on one metadata reader Three read implementations existed: get_array_metadata for arrays, AsyncGroup.open for groups, and the _read_metadata_v2/v3 helpers behind AsyncGroup.getitem for either kind with an explicit format. Each fused reading with interpreting, the first two each detected the format on their own, and zarr.open, with no primitive to build on, was trial-and-error over the specific openers with exceptions carrying "found something else." Now read_node_metadata reads a node's documents once and returns parsed metadata whichever kind and format the node is, trying Zarr format 3 first when the format is not given; _open_node adds the use_consolidated policy and builds the node; and open, open_array, open_group, AsyncArray.open, AsyncGroup.open and get_node are mode policy and a kind filter over that. AsyncGroup._from_bytes_v2/v3 and _from_dict_v3 are gone, and _build_node takes the array config. open has a written contract, replacing the "mode check seems wrong" TODO: with shape it behaves as open_array; otherwise the reading modes open whatever node is there, 'a' creating a group when there is none, and the creating modes create a group, 'w' replacing whatever is there. One node_type policy: a format 3 document's node_type must be array or group, else NodeTypeValidationError; finding the wrong kind is ContainsArrayError or ContainsGroupError, never "not found." Behavior changes, each pinned by a test: open(mode="w") without shape replaces an existing array with a group instead of returning it; open(shape=...) on a group raises ContainsGroupError instead of opening it; open(mode="r") on nothing raises NodeNotFoundError; a missing or unknown node_type raises everywhere (a missing one used to open as a group); wrong kind raises Contains*Error in every format and mode; open_array(mode="w") on a group replaces it; open_array applies config to an existing array; a path holding both formats opens as format 3 without a warning; requesting missing format 2 consolidated metadata raises with the format 3 message. Reads via zarr.open: a format 3 node costs one; a format 2 group five (four with use_consolidated=False); a format 2 array five where it cost three, the price of being the fallback. Assisted-by: ClaudeCode:claude-fable-5-1 --- changes/4366.misc.md | 2 +- src/zarr/api/asynchronous.py | 191 ++++------ src/zarr/api/synchronous.py | 11 +- src/zarr/core/array.py | 101 ++--- src/zarr/core/group.py | 463 ++++++++++------------- src/zarr/core/sync_group.py | 6 +- tests/test_api.py | 114 +++++- tests/test_api/test_asynchronous.py | 11 +- tests/test_group.py | 8 +- tests/test_metadata/test_consolidated.py | 16 +- 10 files changed, 418 insertions(+), 505 deletions(-) diff --git a/changes/4366.misc.md b/changes/4366.misc.md index 5a95a417e9..951dcd69ea 100644 --- a/changes/4366.misc.md +++ b/changes/4366.misc.md @@ -1 +1 @@ -`zarr.open` now opens a Zarr format 3 array or group with a single read of `zarr.json`, and a format 2 node with one concurrent round of reads, instead of first looking for an array and then opening a group with overlapping reads (five requests for a format 3 group, seven for a format 2 one). When both `zarr.json` and `.zarray` exist at a path, `zarr.open` takes the format 3 node without reading the other and no longer warns about the pair; `open_array` still does.\n \ No newline at end of file +`zarr.open`, `open_array`, `open_group`, `AsyncArray.open` and `AsyncGroup.open` are now built on one metadata reader, `zarr.core.group.read_node_metadata`, which reads a node's documents once and returns the parsed metadata whichever kind and format the node is; with `zarr_format=None` it tries Zarr format 3 first and reads the format 2 documents only when there is no `zarr.json`. Opening a Zarr format 3 node costs a single read where `zarr.open` used to make up to seven. `zarr.open` now has a stated contract: with `shape` it behaves as `open_array`; otherwise the reading modes open whatever node is there ('a' creating a group when there is none) and the creating modes create a group ('w' replacing whatever is there). Consequences: `zarr.open(mode="w")` replaces an existing array with a group instead of returning it; `zarr.open(shape=...)` on a group raises `ContainsGroupError` instead of opening the group; `zarr.open(mode="r")` on nothing raises `NodeNotFoundError`; opening an array as a group raises `ContainsArrayError` and a group as an array `ContainsGroupError` in every format and mode; a Zarr format 3 document whose `node_type` is missing or unknown raises `NodeTypeValidationError` everywhere; and a path holding both formats is read as format 3 without a warning. diff --git a/src/zarr/api/asynchronous.py b/src/zarr/api/asynchronous.py index 0d1d76366e..f29a891c8e 100644 --- a/src/zarr/api/asynchronous.py +++ b/src/zarr/api/asynchronous.py @@ -3,33 +3,25 @@ import asyncio import dataclasses import warnings -from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict import numpy as np import numpy.typing as npt from typing_extensions import deprecated from zarr.abc.store import Store -from zarr.core._json import buffer_to_json_object from zarr.core.array import ( DEFAULT_FILL_VALUE, Array, AsyncArray, CompressorLike, - _array_metadata_dict_v2, create_array, from_array, ) from zarr.core.array_spec import ArrayConfigLike, parse_array_config from zarr.core.buffer import NDArrayLike -from zarr.core.buffer.cpu import buffer_prototype as cpu_buffer_prototype from zarr.core.common import ( JSON, - ZARR_JSON, - ZARRAY_JSON, - ZATTRS_JSON, - ZGROUP_JSON, - ZMETADATA_V2_JSON, AccessModeLiteral, DimensionNamesLike, MemoryOrder, @@ -42,15 +34,16 @@ AsyncGroup, ConsolidatedMetadata, GroupMetadata, - _resolve_use_consolidated, + _open_node, create_hierarchy, ) -from zarr.core.metadata import ArrayMetadataDict, ArrayV2Metadata +from zarr.core.metadata import ArrayV2Metadata from zarr.errors import ( ArrayNotFoundError, + ContainsArrayError, + ContainsGroupError, GroupNotFoundError, - MetadataValidationError, - NodeTypeValidationError, + NodeNotFoundError, ZarrDeprecationWarning, ZarrRuntimeWarning, ZarrUserWarning, @@ -365,7 +358,8 @@ async def open( (fail if exists). If the store is read-only, the default is 'r'; otherwise, it is 'a'. zarr_format : {2, 3, None}, optional - The zarr format to use when saving. + The Zarr format of the node. None opens whichever format is found, + trying Zarr format 3 first, and creates the default format. path : str or None, optional The path within the store to open. storage_options : dict @@ -386,6 +380,15 @@ async def open( Notes ----- + What `open` opens or creates follows two rules. If `shape` is given, the + call describes an array and behaves as + [`open_array`][zarr.api.asynchronous.open_array] with the same arguments. + Otherwise, in the modes that read ('r', 'r+' and 'a'), the node at `path` is + opened whichever kind it is; when there is none, 'r' and 'r+' raise + [`NodeNotFoundError`][zarr.errors.NodeNotFoundError] and 'a' creates a + group. The modes that only create ('w' and 'w-') create a group, 'w' + replacing whatever is at `path` and 'w-' failing if anything is. + `open` returns a lazy [`Array`][zarr.Array] or [`Group`][zarr.Group] backed by the store, so data is read and written incrementally. Use [`load`][zarr.load] instead when you want the data eagerly read into an in-memory array (a @@ -399,92 +402,23 @@ async def open( mode = "a" store_path = await make_store_path(store, mode=mode, path=path, storage_options=storage_options) - # TODO: the mode check below seems wrong! - if "shape" not in kwargs and mode in {"a", "r", "r+", "w"}: - if zarr_format not in (2, 3, None): - msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." - raise MetadataValidationError(msg) - # Zarr format 3 first: its one document says whether the node is an array - # or a group and holds everything needed to open it. Only when there is no - # zarr.json are the format 2 documents read, so a path holding both formats - # opens as format 3 without a second look. - use_consolidated = _resolve_use_consolidated( - store_path.store, kwargs.get("use_consolidated") - ) - config = kwargs.get("config") - node = None - if zarr_format != 2: - node = await _open_v3(store_path, use_consolidated=use_consolidated, config=config) - if node is None and zarr_format != 3: - node = await _open_v2(store_path, use_consolidated=use_consolidated, config=config) - # An existing array is returned whatever the mode; an existing group only in - # a read mode, since "w" means overwrite and that is open_group's business. - if isinstance(node, AsyncArray) or (node is not None and mode in _READ_MODES): - return node - return await open_group(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs) - - try: + if "shape" in kwargs: + # the call describes an array return await open_array(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs) - except (KeyError, NodeTypeValidationError): - # KeyError for a missing key - # NodeTypeValidationError for failing to parse node metadata as an array when it's - # actually a group - return await open_group(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs) - - -async def _open_v3( - store_path: StorePath, *, use_consolidated: bool | str | None, config: ArrayConfigLike | None -) -> AnyAsyncArray | AsyncGroup | None: - """Open the Zarr format 3 node at `store_path`, or return None if there is none. - - One read: `zarr.json` says whether the node is an array or a group and holds - everything needed to open it. - """ - zarr_json_bytes = await (store_path / ZARR_JSON).get(prototype=cpu_buffer_prototype) - if zarr_json_bytes is None: - return None - metadata = buffer_to_json_object(zarr_json_bytes) - if metadata.get("node_type") == "array": - # TODO: remove this cast when we fix typing for array metadata dicts - return AsyncArray( - store_path=store_path, metadata=cast("ArrayMetadataDict", metadata), config=config - ) - # anything else is a group, or fails to be one in GroupMetadata.from_dict - return AsyncGroup._from_dict_v3(store_path, metadata, use_consolidated=use_consolidated) - - -async def _open_v2( - store_path: StorePath, *, use_consolidated: bool | str | None, config: ArrayConfigLike | None -) -> AnyAsyncArray | AsyncGroup | None: - """Open the Zarr format 2 node at `store_path`, or return None if there is none. - - One concurrent read of `.zarray`, `.zgroup`, `.zattrs` and, when it might be - used, the consolidated metadata document. `.zarray` makes the node an array - and `.zgroup` a group, the array winning if both are present. - """ - consolidated_key = use_consolidated if isinstance(use_consolidated, str) else ZMETADATA_V2_JSON - keys = [ZARRAY_JSON, ZGROUP_JSON, ZATTRS_JSON] - if use_consolidated or use_consolidated is None: - keys.append(consolidated_key) - zarray_bytes, zgroup_bytes, zattrs_bytes, *rest = await asyncio.gather( - *((store_path / key).get(prototype=cpu_buffer_prototype) for key in keys) - ) - if zarray_bytes is not None: - metadata = _array_metadata_dict_v2(zarray_bytes, zattrs_bytes) - # TODO: remove this cast when we fix typing for array metadata dicts - return AsyncArray( - store_path=store_path, metadata=cast("ArrayMetadataDict", metadata), config=config + if mode in _READ_MODES: + node = await _open_node( + store_path, + zarr_format=zarr_format, + use_consolidated=kwargs.get("use_consolidated"), + config=kwargs.get("config"), ) - if zgroup_bytes is None: - return None - return AsyncGroup._from_bytes_v2( - store_path, - zgroup_bytes, - zattrs_bytes, - rest[0] if rest else None, - use_consolidated=use_consolidated, - consolidated_key=consolidated_key, - ) + if node is not None: + return node + if mode != "a": + msg = f"No array or group found in store {store_path.store} at path {store_path.path!r}" + raise NodeNotFoundError(msg) + # nothing to open, or a mode that only creates: make a group + return await open_group(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs) async def open_consolidated( @@ -928,24 +862,21 @@ async def open_group( ) store_path = await make_store_path(store, mode=mode, storage_options=storage_options, path=path) - if attributes is None: - attributes = {} - - try: - if mode in _READ_MODES: - return await AsyncGroup.open( - store_path, zarr_format=zarr_format, use_consolidated=use_consolidated - ) - except (KeyError, FileNotFoundError): - pass + if mode in _READ_MODES: + node = await _open_node( + store_path, zarr_format=zarr_format, use_consolidated=use_consolidated + ) + if isinstance(node, AsyncGroup): + return node + if node is not None: + msg = f"An array exists in store {store_path.store} at path {store_path.path}." + raise ContainsArrayError(msg) if mode in _CREATE_MODES: - overwrite = _infer_overwrite(mode) - _zarr_format = zarr_format or _default_zarr_format() return await AsyncGroup.from_store( store_path, - zarr_format=_zarr_format, - overwrite=overwrite, - attributes=attributes, + zarr_format=zarr_format or _default_zarr_format(), + overwrite=_infer_overwrite(mode), + attributes=attributes or {}, ) msg = f"No group found in store {store!r} at path {store_path.path!r}" raise GroupNotFoundError(msg) @@ -1337,20 +1268,26 @@ async def open_array( if "write_empty_chunks" in kwargs: _warn_write_empty_chunks_kwarg() - try: - return await AsyncArray.open(store_path, zarr_format=zarr_format) - except FileNotFoundError as err: - if not store_path.read_only and mode in _CREATE_MODES: - overwrite = _infer_overwrite(mode) - _zarr_format = zarr_format or _default_zarr_format() - return await create( - store=store_path, - zarr_format=_zarr_format, - overwrite=overwrite, - **kwargs, - ) - msg = f"No array found in store {store_path.store} at path {store_path.path}" - raise ArrayNotFoundError(msg) from err + if mode not in _OVERWRITE_MODES: + # Whatever is here is what the caller gets, unless it is a group. An array + # has no consolidated metadata to read. + node = await _open_node( + store_path, zarr_format=zarr_format, use_consolidated=False, config=kwargs.get("config") + ) + if isinstance(node, AsyncArray): + return node + if node is not None: + msg = f"A group exists in store {store_path.store} at path {store_path.path}." + raise ContainsGroupError(msg) + if not store_path.read_only and mode in _CREATE_MODES: + return await create( + store=store_path, + zarr_format=zarr_format or _default_zarr_format(), + overwrite=_infer_overwrite(mode), + **kwargs, + ) + msg = f"No array found in store {store_path.store} at path {store_path.path}" + raise ArrayNotFoundError(msg) async def open_like(a: ArrayLike, path: str, **kwargs: Any) -> AnyAsyncArray: diff --git a/src/zarr/api/synchronous.py b/src/zarr/api/synchronous.py index 6975f6d953..595345deac 100644 --- a/src/zarr/api/synchronous.py +++ b/src/zarr/api/synchronous.py @@ -203,7 +203,8 @@ def open( (fail if exists). If the store is read-only, the default is 'r'; otherwise, it is 'a'. zarr_format : {2, 3, None}, optional - The zarr format to use when saving. + The Zarr format of the node. None opens whichever format is found, + trying Zarr format 3 first, and creates the default format. path : str or None, optional The path within the store to open. storage_options : dict @@ -224,6 +225,14 @@ def open( Notes ----- + What `open` opens or creates follows two rules. If `shape` is given, the + call describes an array and behaves as [`open_array`][zarr.open_array] with + the same arguments. Otherwise, in the modes that read ('r', 'r+' and 'a'), + the node at `path` is opened whichever kind it is; when there is none, 'r' + and 'r+' raise [`NodeNotFoundError`][zarr.errors.NodeNotFoundError] and 'a' + creates a group. The modes that only create ('w' and 'w-') create a group, + 'w' replacing whatever is at `path` and 'w-' failing if anything is. + `open` returns a lazy [`Array`][zarr.Array] or [`Group`][zarr.Group] backed by the store, so data is read and written incrementally. Use [`load`][zarr.load] instead when you want the data eagerly read into an in-memory array (a diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index 5a8d6bf57e..480c526681 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -3,7 +3,6 @@ import copy import math import warnings -from asyncio import gather from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field, replace from itertools import starmap @@ -29,7 +28,6 @@ from zarr.codecs.vlen_utf8 import VLenBytesCodec, VLenUTF8Codec from zarr.codecs.zstd import ZstdCodec from zarr.core._info import ArrayInfo -from zarr.core._json import buffer_to_json_object from zarr.core.array_spec import ArrayConfig, ArrayConfigLike, ArraySpec, parse_array_config from zarr.core.attributes import Attributes from zarr.core.buffer import ( @@ -39,7 +37,6 @@ NDBuffer, default_buffer_prototype, ) -from zarr.core.buffer.cpu import buffer_prototype as cpu_buffer_prototype from zarr.core.chunk_grids import ( SHARDED_INNER_CHUNK_MAX_BYTES, ChunkGrid, @@ -59,9 +56,6 @@ ) from zarr.core.common import ( JSON, - ZARR_JSON, - ZARRAY_JSON, - ZATTRS_JSON, ChunksLike, DimensionNamesLike, MemoryOrder, @@ -130,13 +124,12 @@ RectilinearChunkGridMetadata, RegularChunkGridMetadata, create_chunk_grid_metadata, - parse_node_type_array, ) from zarr.core.sync import sync from zarr.errors import ( ArrayNotFoundError, ChunkNotFoundError, - MetadataValidationError, + ContainsGroupError, ZarrDeprecationWarning, ZarrUserWarning, ) @@ -158,7 +151,6 @@ from zarr.abc.codec import CodecPipeline from zarr.abc.store import Store from zarr.codecs.sharding import IndexLocation, ShardingCodec - from zarr.core.buffer import Buffer from zarr.core.dtype.wrapper import TBaseDType, TBaseScalar from zarr.storage import StoreLike from zarr.types import AnyArray, AnyAsyncArray, ArrayV2, ArrayV3, AsyncArrayV2, AsyncArrayV3 @@ -269,69 +261,36 @@ def create_codec_pipeline(metadata: ArrayMetadata, *, store: Store | None = None raise TypeError # pragma: no cover -async def get_array_metadata( - store_path: StorePath, zarr_format: ZarrFormat | None = 3 -) -> dict[str, JSON]: - if zarr_format == 2: - zarray_bytes, zattrs_bytes = await gather( - (store_path / ZARRAY_JSON).get(prototype=cpu_buffer_prototype), - (store_path / ZATTRS_JSON).get(prototype=cpu_buffer_prototype), - ) - if zarray_bytes is None: - msg = ( - "A Zarr V2 array metadata document was not found in store " - f"{store_path.store!r} at path {store_path.path!r}." - ) - raise ArrayNotFoundError(msg) - return _array_metadata_dict_v2(zarray_bytes, zattrs_bytes) - elif zarr_format == 3: - zarr_json_bytes = await (store_path / ZARR_JSON).get(prototype=cpu_buffer_prototype) - if zarr_json_bytes is None: - msg = ( - "A Zarr V3 array metadata document was not found in store " - f"{store_path.store!r} at path {store_path.path!r}." - ) - raise ArrayNotFoundError(msg) - return _array_metadata_dict_v3(zarr_json_bytes) - elif zarr_format is None: - zarr_json_bytes, zarray_bytes, zattrs_bytes = await gather( - (store_path / ZARR_JSON).get(prototype=cpu_buffer_prototype), - (store_path / ZARRAY_JSON).get(prototype=cpu_buffer_prototype), - (store_path / ZATTRS_JSON).get(prototype=cpu_buffer_prototype), - ) - if zarr_json_bytes is not None and zarray_bytes is not None: - # warn and favor v3 - msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store_path}. Zarr v3 will be used." - warnings.warn(msg, category=ZarrUserWarning, stacklevel=1) - # favor v3 when both are present - if zarr_json_bytes is not None: - return _array_metadata_dict_v3(zarr_json_bytes) - if zarray_bytes is not None: - return _array_metadata_dict_v2(zarray_bytes, zattrs_bytes) - msg = ( - f"Neither Zarr V3 nor Zarr V2 array metadata documents " - f"were found in store {store_path.store!r} at path {store_path.path!r}." - ) - raise ArrayNotFoundError(msg) - else: - msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." # type: ignore[unreachable] - raise MetadataValidationError(msg) +async def _read_array_metadata( + store_path: StorePath, zarr_format: ZarrFormat | None +) -> ArrayMetadata: + """The array metadata at `store_path`. + Raises `ArrayNotFoundError` if there is no node there and + `ContainsGroupError` if the node is a group. + """ + # group.py imports this module, so the shared reader is imported here + from zarr.core.group import GroupMetadata, read_node_metadata -def _array_metadata_dict_v2(zarray_bytes: Buffer, zattrs_bytes: Buffer | None) -> dict[str, JSON]: - """Combine a `.zarray` document and an optional `.zattrs` document into one metadata dict.""" - metadata_dict: dict[str, JSON] = buffer_to_json_object(zarray_bytes) - metadata_dict["attributes"] = ( - buffer_to_json_object(zattrs_bytes) if zattrs_bytes is not None else {} - ) - return metadata_dict + metadata = await read_node_metadata(store_path.store, store_path.path, zarr_format) + if metadata is None: + msg = f"No array found in store {store_path.store} at path {store_path.path!r}" + raise ArrayNotFoundError(msg) + if isinstance(metadata, GroupMetadata): + msg = f"A group exists in store {store_path.store} at path {store_path.path}." + raise ContainsGroupError(msg) + return metadata -def _array_metadata_dict_v3(zarr_json_bytes: Buffer) -> dict[str, JSON]: - """Parse a `zarr.json` document, checking that it describes an array.""" - metadata_dict: dict[str, JSON] = buffer_to_json_object(zarr_json_bytes) - parse_node_type_array(metadata_dict.get("node_type")) - return metadata_dict +async def get_array_metadata( + store_path: StorePath, zarr_format: ZarrFormat | None = 3 +) -> dict[str, JSON]: + """The array metadata document at `store_path`, as a dict. + + Prefer `AsyncArray.open`, which builds the array from the parsed metadata + directly; this is kept for callers that want the document. + """ + return (await _read_array_metadata(store_path, zarr_format)).to_dict() async def _prepare_overwrite( @@ -813,10 +772,8 @@ async def example(): ``` """ store_path = await make_store_path(store) - metadata_dict = await get_array_metadata(store_path, zarr_format=zarr_format) - # TODO: remove this cast when we have better type hints - _metadata_dict = cast("ArrayMetadataJSON_V3", metadata_dict) - return cls(store_path=store_path, metadata=_metadata_dict) + metadata = await _read_array_metadata(store_path, zarr_format) + return cls(store_path=store_path, metadata=metadata) @property def store(self) -> Store: diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index 9ab8823279..8f3fd69fc2 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -102,6 +102,66 @@ def _resolve_use_consolidated( return False +def _v2_consolidated_key(use_consolidated: bool | str | None) -> str | None: + """The Zarr format 2 consolidated-metadata key to read for `use_consolidated`, or None for none.""" + if use_consolidated is False: + return None + if isinstance(use_consolidated, str): + return use_consolidated + return ZMETADATA_V2_JSON + + +def _apply_use_consolidated( + metadata: GroupMetadata, use_consolidated: bool | str | None, *, store_path: StorePath +) -> GroupMetadata: + """Enforce the caller's `use_consolidated` on freshly read group metadata. + + True (or a format 2 key) requires consolidated metadata to be present; False + drops whatever is present; None keeps whatever is present. + """ + if metadata.zarr_format == 3 and not isinstance(use_consolidated, bool | None): + raise TypeError("use_consolidated must be a bool or None for Zarr format 3.") + if use_consolidated and metadata.consolidated_metadata is None: + msg = ( + f"Consolidated metadata requested with 'use_consolidated={use_consolidated!r}' " + f"but not found in '{store_path.path}'." + ) + raise ValueError(msg) + if use_consolidated is False and metadata.consolidated_metadata is not None: + return replace(metadata, consolidated_metadata=None) + return metadata + + +async def _open_node( + store_path: StorePath, + *, + zarr_format: ZarrFormat | None, + use_consolidated: bool | str | None, + config: ArrayConfigLike | None = None, +) -> AnyAsyncArray | AsyncGroup | None: + """The node at `store_path`, whichever kind it is, or None if there is none. + + This is what the opening functions are built from: it reads the node's + metadata once, applies `use_consolidated` if the node is a group, and + builds the node. It applies no mode policy and raises nothing for a missing + node; the callers decide what those mean. + """ + use_consolidated = _resolve_use_consolidated(store_path.store, use_consolidated) + metadata = await read_node_metadata( + store_path.store, + store_path.path, + zarr_format, + consolidated_key=_v2_consolidated_key(use_consolidated), + ) + if metadata is None: + return None + if isinstance(metadata, GroupMetadata): + metadata = _apply_use_consolidated(metadata, use_consolidated, store_path=store_path) + return _build_node( + store=store_path.store, path=store_path.path, metadata=metadata, config=config + ) + + def parse_zarr_format(data: Any) -> ZarrFormat: """Parse the zarr_format field from metadata.""" return cast("ZarrFormat", parse_field(data, Literal[2, 3], "zarr_format")) @@ -542,165 +602,16 @@ async def open( to load consolidated metadata from a non-default key. """ store_path = await make_store_path(store) - use_consolidated = _resolve_use_consolidated(store_path.store, use_consolidated) - - consolidated_key = ZMETADATA_V2_JSON - - if (zarr_format == 2 or zarr_format is None) and isinstance(use_consolidated, str): - consolidated_key = use_consolidated - - if zarr_format == 2: - paths = [store_path / ZGROUP_JSON, store_path / ZATTRS_JSON] - if use_consolidated or use_consolidated is None: - paths.append(store_path / consolidated_key) - - zgroup_bytes, zattrs_bytes, *rest = await asyncio.gather( - *[path.get() for path in paths] - ) - if zgroup_bytes is None: - raise FileNotFoundError(store_path) - - if use_consolidated or use_consolidated is None: - maybe_consolidated_metadata_bytes = rest[0] - - else: - maybe_consolidated_metadata_bytes = None - - elif zarr_format == 3: - zarr_json_bytes = await (store_path / ZARR_JSON).get() - if zarr_json_bytes is None: - raise FileNotFoundError(store_path) - elif zarr_format is None: - ( - zarr_json_bytes, - zgroup_bytes, - zattrs_bytes, - maybe_consolidated_metadata_bytes, - ) = await asyncio.gather( - (store_path / ZARR_JSON).get(), - (store_path / ZGROUP_JSON).get(), - (store_path / ZATTRS_JSON).get(), - (store_path / str(consolidated_key)).get(), - ) - if zarr_json_bytes is not None and zgroup_bytes is not None: - # warn and favor v3 - msg = f"Both zarr.json (Zarr format 3) and .zgroup (Zarr format 2) metadata objects exist at {store_path}. Zarr format 3 will be used." - warnings.warn(msg, category=ZarrUserWarning, stacklevel=1) - if zarr_json_bytes is None and zgroup_bytes is None: - raise FileNotFoundError( - f"could not find zarr.json or .zgroup objects in {store_path}" - ) - # set zarr_format based on which keys were found - if zarr_json_bytes is not None: - zarr_format = 3 - else: - zarr_format = 2 - else: - msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." # type: ignore[unreachable] - raise MetadataValidationError(msg) - - if zarr_format == 2: - if zgroup_bytes is None: - raise FileNotFoundError(store_path) - return cls._from_bytes_v2( - store_path, - zgroup_bytes, - zattrs_bytes, - maybe_consolidated_metadata_bytes, - use_consolidated=use_consolidated, - consolidated_key=consolidated_key, - ) - else: - # V3 groups are comprised of a zarr.json object - if zarr_json_bytes is None: - raise FileNotFoundError(store_path) - return cls._from_bytes_v3( - store_path, zarr_json_bytes, use_consolidated=use_consolidated - ) - - @classmethod - def _from_bytes_v2( - cls, - store_path: StorePath, - zgroup_bytes: Buffer, - zattrs_bytes: Buffer | None, - consolidated_metadata_bytes: Buffer | None, - *, - use_consolidated: bool | str | None = None, - consolidated_key: str = ZMETADATA_V2_JSON, - ) -> AsyncGroup: - if use_consolidated and consolidated_metadata_bytes is None: - # the user requested consolidated metadata, but it was missing - raise ValueError(consolidated_key) - elif use_consolidated is False: - # the user explicitly opted out of consolidated_metadata. - # Discard anything we might have read. - consolidated_metadata_bytes = None - - # V2 groups are comprised of a .zgroup and .zattrs objects - zgroup = buffer_to_json_object(zgroup_bytes) - zattrs = buffer_to_json_object(zattrs_bytes) if zattrs_bytes is not None else {} - group_metadata: dict[str, Any] = {**zgroup, "attributes": zattrs} - - if consolidated_metadata_bytes is not None: - v2_consolidated_doc = buffer_to_json_object(consolidated_metadata_bytes) - v2_consolidated_metadata = cast("dict[str, Any]", v2_consolidated_doc["metadata"]) - # We already read zattrs and zgroup. Should we ignore these? - v2_consolidated_metadata.pop(".zattrs", None) - v2_consolidated_metadata.pop(".zgroup", None) - - consolidated_metadata: defaultdict[str, dict[str, Any]] = defaultdict(dict) - - # keys like air/.zarray, air/.zattrs - for k, v in v2_consolidated_metadata.items(): - path, kind = k.rsplit("/.", 1) - - if kind == "zarray": - consolidated_metadata[path].update(v) - elif kind == "zattrs": - consolidated_metadata[path]["attributes"] = v - elif kind == "zgroup": - consolidated_metadata[path].update(v) - else: - raise ValueError(f"Invalid file type '{kind}' at path '{path}") - - group_metadata["consolidated_metadata"] = { - "metadata": dict(consolidated_metadata), - "kind": "inline", - "must_understand": False, - } - - return cls.from_dict(store_path, group_metadata) - - @classmethod - def _from_bytes_v3( - cls, - store_path: StorePath, - zarr_json_bytes: Buffer, - use_consolidated: bool | str | None, - ) -> AsyncGroup: - group_metadata = buffer_to_json_object(zarr_json_bytes) - return cls._from_dict_v3(store_path, group_metadata, use_consolidated=use_consolidated) - - @classmethod - def _from_dict_v3( - cls, - store_path: StorePath, - group_metadata: dict[str, JSON], - use_consolidated: bool | str | None, - ) -> AsyncGroup: - """Build the group from an already-parsed `zarr.json` document.""" - if not isinstance(use_consolidated, bool | None): - raise TypeError("use_consolidated must be a bool or None for Zarr format 3.") - if use_consolidated and group_metadata.get("consolidated_metadata") is None: - msg = f"Consolidated metadata requested with 'use_consolidated=True' but not found in '{store_path.path}'." - raise ValueError(msg) - - elif use_consolidated is False: - # Drop consolidated metadata if it's there. - group_metadata.pop("consolidated_metadata", None) - - return cls.from_dict(store_path, group_metadata) + node = await _open_node( + store_path, zarr_format=zarr_format, use_consolidated=use_consolidated + ) + if node is None: + msg = f"No group found in store {store_path.store} at path {store_path.path!r}" + raise GroupNotFoundError(msg) + if not isinstance(node, AsyncGroup): + msg = f"An array exists in store {store_path.store} at path {store_path.path}." + raise ContainsArrayError(msg) + return node @classmethod def from_dict( @@ -708,13 +619,10 @@ def from_dict( store_path: StorePath, data: dict[str, Any], ) -> AsyncGroup: - node_type = data.pop("node_type", None) - if node_type == "array": + if data.get("node_type") == "array": msg = f"An array already exists in store {store_path.store} at path {store_path.path}." raise ContainsArrayError(msg) - elif node_type not in ("group", None): - msg = f"Node type in metadata ({node_type}) is not 'group'" - raise GroupNotFoundError(msg) + # any other wrong node_type is GroupMetadata.from_dict's to reject return cls( metadata=GroupMetadata.from_dict(data), store_path=store_path, @@ -3514,53 +3422,114 @@ async def _iter_members_deep( yield key, node -async def _read_metadata_v3(store: Store, path: str) -> ArrayV3Metadata | GroupMetadata: - """ - Given a store_path, return ArrayV3Metadata or GroupMetadata defined by the metadata - document stored at store_path.path / zarr.json. If no such document is found, raise a - FileNotFoundError. +@overload +async def read_node_metadata( + store: Store, path: str, zarr_format: Literal[3], *, consolidated_key: str | None = None +) -> ArrayV3Metadata | GroupMetadata | None: ... + + +@overload +async def read_node_metadata( + store: Store, path: str, zarr_format: Literal[2], *, consolidated_key: str | None = None +) -> ArrayV2Metadata | GroupMetadata | None: ... + + +@overload +async def read_node_metadata( + store: Store, path: str, zarr_format: ZarrFormat | None, *, consolidated_key: str | None = None +) -> ArrayV2Metadata | ArrayV3Metadata | GroupMetadata | None: ... + + +async def read_node_metadata( + store: Store, path: str, zarr_format: ZarrFormat | None, *, consolidated_key: str | None = None +) -> ArrayV2Metadata | ArrayV3Metadata | GroupMetadata | None: + """Read the metadata of the node at `path`, whichever kind and format it is. + + This is the one place a node's metadata documents are read. With + `zarr_format` None, Zarr format 3 is tried first, and format 2 only if there + is no `zarr.json`; a path holding both formats is read as format 3 without a + second look. A format 3 node is its `zarr.json`, whose `node_type` must say + `array` or `group`. A format 2 node is `.zarray` or `.zgroup` (the array + winning if both exist) plus `.zattrs`, all read at once along with + `consolidated_key`, which names a consolidated-metadata document to attach + to a group; None reads none. Returns None if no node is found. """ - zarr_json_bytes = await store.get( - _join_paths([path, ZARR_JSON]), prototype=default_buffer_prototype() + if zarr_format not in (2, 3, None): + msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." + raise MetadataValidationError(msg) + if zarr_format != 2: + zarr_json_bytes = await store.get( + _join_paths([path, ZARR_JSON]), prototype=default_buffer_prototype() + ) + if zarr_json_bytes is not None: + return _build_metadata_v3(buffer_to_json_object(zarr_json_bytes)) + if zarr_format == 3: + return None + + keys = [ZARRAY_JSON, ZGROUP_JSON, ZATTRS_JSON] + if consolidated_key is not None: + keys.append(consolidated_key) + zarray_bytes, zgroup_bytes, zattrs_bytes, *rest = await asyncio.gather( + *(store.get(_join_paths([path, key]), prototype=default_buffer_prototype()) for key in keys) ) - if zarr_json_bytes is None: - raise FileNotFoundError(path) - return _build_metadata_v3(buffer_to_json_object(zarr_json_bytes)) + zattrs: dict[str, JSON] = {} if zattrs_bytes is None else buffer_to_json_object(zattrs_bytes) + if zarray_bytes is not None: + return _build_metadata_v2(buffer_to_json_object(zarray_bytes), zattrs) + if zgroup_bytes is None: + return None + metadata = _build_metadata_v2(buffer_to_json_object(zgroup_bytes), zattrs) + consolidated_bytes = rest[0] if rest else None + if consolidated_bytes is not None and isinstance(metadata, GroupMetadata): + consolidated = _consolidated_metadata_from_v2_doc(buffer_to_json_object(consolidated_bytes)) + metadata = replace(metadata, consolidated_metadata=consolidated) + return metadata -async def _read_metadata_v2(store: Store, path: str) -> ArrayV2Metadata | GroupMetadata: - """ - Given a store_path, return ArrayV2Metadata or GroupMetadata defined by the metadata - document stored at store_path.path / (.zgroup | .zarray). If no such document is found, - raise a FileNotFoundError. +def _consolidated_metadata_from_v2_doc(doc: dict[str, JSON]) -> ConsolidatedMetadata: + """Turn a Zarr format 2 consolidated-metadata document into `ConsolidatedMetadata`. + + The format 2 document is flat, keyed like `air/.zarray` and `air/.zattrs`; + those become one metadata dict per path. The root's own `.zgroup` and + `.zattrs` are dropped, since they were read directly. """ - # TODO: consider first fetching array metadata, and only fetching group metadata when we don't - # find an array - zarray_bytes, zgroup_bytes, zattrs_bytes = await asyncio.gather( - store.get(_join_paths([path, ZARRAY_JSON]), prototype=default_buffer_prototype()), - store.get(_join_paths([path, ZGROUP_JSON]), prototype=default_buffer_prototype()), - store.get(_join_paths([path, ZATTRS_JSON]), prototype=default_buffer_prototype()), + v2_metadata = doc.get("metadata") + if not isinstance(v2_metadata, dict): + msg = f"A consolidated metadata document needs a 'metadata' object. Got {doc!r}." + raise MetadataValidationError(msg) + v2_metadata = cast("dict[str, dict[str, Any]]", dict(v2_metadata)) + v2_metadata.pop(".zattrs", None) + v2_metadata.pop(".zgroup", None) + + consolidated_metadata: defaultdict[str, dict[str, Any]] = defaultdict(dict) + for k, v in v2_metadata.items(): + path, kind = k.rsplit("/.", 1) + if kind == "zarray": + consolidated_metadata[path].update(v) + elif kind == "zattrs": + consolidated_metadata[path]["attributes"] = v + elif kind == "zgroup": + consolidated_metadata[path].update(v) + else: + raise ValueError(f"Invalid file type '{kind}' at path '{path}") + return ConsolidatedMetadata.from_dict( + {"metadata": dict(consolidated_metadata), "kind": "inline", "must_understand": False} ) - zattrs: dict[str, JSON] - if zattrs_bytes is None: - zattrs = {} - else: - zattrs = buffer_to_json_object(zattrs_bytes) - # TODO: decide how to handle finding both array and group metadata. The spec does not seem to - # consider this situation. A practical approach would be to ignore that combination, and only - # return the array metadata. - if zarray_bytes is not None: - zmeta = buffer_to_json_object(zarray_bytes) - else: - if zgroup_bytes is None: - # neither .zarray or .zgroup were found results in KeyError - raise FileNotFoundError(path) - else: - zmeta = buffer_to_json_object(zgroup_bytes) +async def _read_metadata_v3(store: Store, path: str) -> ArrayV3Metadata | GroupMetadata: + """The format 3 node metadata at `path`, raising FileNotFoundError if there is none.""" + metadata = await read_node_metadata(store, path, 3) + if metadata is None: + raise FileNotFoundError(path) + return metadata - return _build_metadata_v2(zmeta, zattrs) + +async def _read_metadata_v2(store: Store, path: str) -> ArrayV2Metadata | GroupMetadata: + """The format 2 node metadata at `path`, raising FileNotFoundError if there is none.""" + metadata = await read_node_metadata(store, path, 2) + if metadata is None: + raise FileNotFoundError(path) + return metadata async def _read_group_metadata_v2(store: Store, path: str) -> GroupMetadata: @@ -3597,16 +3566,17 @@ def _build_metadata_v3(zarr_json: dict[str, JSON]) -> ArrayV3Metadata | GroupMet """ if "node_type" not in zarr_json: msg = "Required key 'node_type' is missing from the provided metadata document." - raise MetadataValidationError(msg) + raise NodeTypeValidationError(msg) match zarr_json: case {"node_type": "array"}: return ArrayV3Metadata.from_dict(zarr_json) case {"node_type": "group"}: return GroupMetadata.from_dict(zarr_json) + case {"node_type": node_type}: + msg = f"Invalid value for 'node_type'. Expected 'array' or 'group'. Got {node_type!r}." + raise NodeTypeValidationError(msg) case _: # pragma: no cover - raise ValueError( - "invalid value for `node_type` key in metadata document" - ) # pragma: no cover + raise AssertionError("unreachable") # pragma: no cover def _build_metadata_v2( @@ -3623,72 +3593,47 @@ def _build_metadata_v2( @overload -def _build_node(*, store: Store, path: str, metadata: ArrayV2Metadata) -> AsyncArrayV2: ... +def _build_node( + *, store: Store, path: str, metadata: ArrayV2Metadata, config: ArrayConfigLike | None = None +) -> AsyncArrayV2: ... @overload -def _build_node(*, store: Store, path: str, metadata: ArrayV3Metadata) -> AsyncArrayV3: ... +def _build_node( + *, store: Store, path: str, metadata: ArrayV3Metadata, config: ArrayConfigLike | None = None +) -> AsyncArrayV3: ... @overload -def _build_node(*, store: Store, path: str, metadata: GroupMetadata) -> AsyncGroup: ... +def _build_node( + *, store: Store, path: str, metadata: GroupMetadata, config: ArrayConfigLike | None = None +) -> AsyncGroup: ... def _build_node( - *, store: Store, path: str, metadata: ArrayV3Metadata | ArrayV2Metadata | GroupMetadata + *, + store: Store, + path: str, + metadata: ArrayV3Metadata | ArrayV2Metadata | GroupMetadata, + config: ArrayConfigLike | None = None, ) -> AnyAsyncArray | AsyncGroup: """ - Take a metadata object and return a node (AsyncArray or AsyncGroup). + Take a metadata object and return a node (AsyncArray or AsyncGroup). `config` + applies to an array and is ignored for a group. """ store_path = StorePath(store=store, path=path) match metadata: case ArrayV2Metadata() | ArrayV3Metadata(): - return AsyncArray(metadata, store_path=store_path) + return AsyncArray(metadata, store_path=store_path, config=config) case GroupMetadata(): return AsyncGroup(metadata, store_path=store_path) case _: # pragma: no cover raise ValueError(f"Unexpected metadata type: {type(metadata)}") # pragma: no cover -async def _get_node_v2(store: Store, path: str) -> AsyncArrayV2 | AsyncGroup: - """ - Read a Zarr v2 AsyncArray or AsyncGroup from a path in a Store. - - Parameters - ---------- - store : Store - The store-like object to read from. - path : str - The path to the node to read. - - Returns - ------- - AsyncArray | AsyncGroup - """ - metadata = await _read_metadata_v2(store=store, path=path) - return _build_node(store=store, path=path, metadata=metadata) - - -async def _get_node_v3(store: Store, path: str) -> AsyncArrayV3 | AsyncGroup: - """ - Read a Zarr v3 AsyncArray or AsyncGroup from a path in a Store. - - Parameters - ---------- - store : Store - The store-like object to read from. - path : str - The path to the node to read. - - Returns - ------- - AsyncArray | AsyncGroup - """ - metadata = await _read_metadata_v3(store=store, path=path) - return _build_node(store=store, path=path, metadata=metadata) - - -async def get_node(store: Store, path: str, zarr_format: ZarrFormat) -> AnyAsyncArray | AsyncGroup: +async def get_node( + store: Store, path: str, zarr_format: ZarrFormat | None +) -> AnyAsyncArray | AsyncGroup: """ Get an AsyncArray or AsyncGroup from a path in a Store. @@ -3698,21 +3643,17 @@ async def get_node(store: Store, path: str, zarr_format: ZarrFormat) -> AnyAsync The store-like object to read from. path : str The path to the node to read. - zarr_format : {2, 3} - The zarr format of the node to read. + zarr_format : {2, 3, None} + The zarr format of the node to read, or None to detect it. Returns ------- AsyncArray | AsyncGroup """ - - match zarr_format: - case 2: - return await _get_node_v2(store=store, path=path) - case 3: - return await _get_node_v3(store=store, path=path) - case _: # pragma: no cover - raise ValueError(f"Unexpected zarr format: {zarr_format}") # pragma: no cover + metadata = await read_node_metadata(store, path, zarr_format) + if metadata is None: + raise FileNotFoundError(path) + return _build_node(store=store, path=path, metadata=metadata) async def _set_return_key( diff --git a/src/zarr/core/sync_group.py b/src/zarr/core/sync_group.py index 8af514e938..c33f65c246 100644 --- a/src/zarr/core/sync_group.py +++ b/src/zarr/core/sync_group.py @@ -142,7 +142,7 @@ def create_rooted_hierarchy( return _parse_async_node(async_node) -def get_node(store: Store, path: str, zarr_format: ZarrFormat) -> AnyArray | Group: +def get_node(store: Store, path: str, zarr_format: ZarrFormat | None) -> AnyArray | Group: """ Get an Array or Group from a path in a Store. @@ -152,8 +152,8 @@ def get_node(store: Store, path: str, zarr_format: ZarrFormat) -> AnyArray | Gro The store-like object to read from. path : str The path to the node to read. - zarr_format : {2, 3} - The zarr format of the node to read. + zarr_format : {2, 3, None} + The zarr format of the node to read, or None to detect it. Returns ------- diff --git a/tests/test_api.py b/tests/test_api.py index 7e0d9b4c45..51aac652a8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,7 +2,6 @@ import collections import inspect -import re from typing import TYPE_CHECKING, Any import zarr.codecs @@ -49,8 +48,10 @@ from zarr.core.buffer import NDArrayLike, cpu, default_buffer_prototype from zarr.errors import ( ArrayNotFoundError, - GroupNotFoundError, + ContainsArrayError, + ContainsGroupError, MetadataValidationError, + NodeNotFoundError, NodeTypeValidationError, ZarrDeprecationWarning, ZarrUserWarning, @@ -367,13 +368,9 @@ def test_array_open_array_not_found_sync() -> None: def test_v2_and_v3_exist_at_same_path(store: Store) -> None: zarr.create_array(store, shape=(10,), dtype="uint8", zarr_format=3) zarr.create_array(store, shape=(10,), dtype="uint8", zarr_format=2) - # `open` reads only zarr.json and takes the format 3 node without a second look - node = zarr.open(store=store) - assert node.metadata.zarr_format == 3 - # `open_array` looks at both and says so - msg = f"Both zarr.json (Zarr format 3) and .zarray (Zarr format 2) metadata objects exist at {store}. Zarr v3 will be used." - with pytest.warns(ZarrUserWarning, match=re.escape(msg)): - zarr.open_array(store=store) + # only zarr.json is read, and the format 3 node is taken without a second look + assert zarr.open(store=store).metadata.zarr_format == 3 + assert zarr.open_array(store=store).metadata.zarr_format == 3 @pytest.mark.parametrize("store", ["memory"], indirect=True) @@ -1504,31 +1501,106 @@ async def test_open_invalid_zarr_format_raises() -> None: await zarr.api.asynchronous.open(store=store, zarr_format="3.0") # type: ignore[arg-type] -async def test_open_zarr_json_without_node_type_is_a_group() -> None: - """A `zarr.json` with no `node_type` opens as a group, as `GroupMetadata.from_dict` allows.""" +async def test_open_zarr_json_without_node_type_raises() -> None: + """A format 3 document must say what it is: no `node_type` is a validation error.""" store = MemoryStore() await store.set( "zarr.json", cpu.Buffer.from_bytes(b'{"zarr_format": 3, "attributes": {"k": "v"}}') ) - group = await zarr.api.asynchronous.open(store=store, mode="r") - assert isinstance(group, zarr.core.group.AsyncGroup) - assert group.attrs == {"k": "v"} + with pytest.raises(NodeTypeValidationError, match="Required key 'node_type' is missing"): + await zarr.api.asynchronous.open(store=store, mode="r") async def test_open_zarr_json_with_invalid_node_type_raises() -> None: - """A `zarr.json` whose `node_type` is neither array nor group is an error, as on `main`.""" + """A `node_type` that is neither array nor group is a validation error, not a missing node.""" store = MemoryStore() await store.set("zarr.json", cpu.Buffer.from_bytes(b'{"zarr_format": 3, "node_type": "foo"}')) - with pytest.raises(GroupNotFoundError, match="is not 'group'"): + with pytest.raises(NodeTypeValidationError, match="Expected 'array' or 'group'. Got 'foo'"): await zarr.api.asynchronous.open(store=store, mode="r") -async def test_async_array_open_on_group_raises_node_type() -> None: - """Opening a v3 group as an array still reports the node_type mismatch.""" +@pytest.mark.parametrize("zarr_format", [2, 3]) +async def test_async_array_open_on_group_raises_contains_group(zarr_format: ZarrFormat) -> None: + """Opening a group as an array says a group is there, rather than that nothing is.""" + store = MemoryStore() + await zarr.api.asynchronous.open_group(store, zarr_format=zarr_format) + with pytest.raises(ContainsGroupError, match="A group exists in store"): + await AsyncArray.open(store, zarr_format=zarr_format) + + +@pytest.mark.parametrize("zarr_format", [2, 3]) +async def test_async_group_open_on_array_raises_contains_array(zarr_format: ZarrFormat) -> None: + """Opening an array as a group says an array is there, rather than that nothing is.""" + store = MemoryStore() + await zarr.api.asynchronous.create_array( + store, shape=(3,), dtype="uint8", zarr_format=zarr_format + ) + with pytest.raises(ContainsArrayError, match="An array exists in store"): + await zarr.core.group.AsyncGroup.open(store, zarr_format=zarr_format) + + +@pytest.mark.parametrize("mode", ["r", "a"]) +def test_open_group_on_array_raises_contains_array(mode: AccessModeLiteral) -> None: + store = MemoryStore() + zarr.create_array(store, shape=(3,), dtype="uint8") + with pytest.raises(ContainsArrayError, match="An array exists in store"): + zarr.open_group(store, mode=mode) + + +@pytest.mark.parametrize("mode", ["r", "a"]) +def test_open_array_on_group_raises_contains_group(mode: AccessModeLiteral) -> None: + store = MemoryStore() + zarr.create_group(store) + with pytest.raises(ContainsGroupError, match="A group exists in store"): + zarr.open_array(store, mode=mode) + + +def _expected_open_outcome( + existing: str, mode: str, shape: tuple[int, ...] | None +) -> type[Array[Any] | Group | Exception]: + """The contract of `open`, as a table: what comes back for what is there, the mode, and `shape`.""" + if mode == "w-" and existing != "nothing": + return FileExistsError + if mode == "w": + return Array if shape else Group + if existing == "nothing": + if mode in ("r", "r+"): + return ArrayNotFoundError if shape else NodeNotFoundError + return Array if shape else Group + if shape: + return Array if existing == "array" else ContainsGroupError + return Array if existing == "array" else Group + + +@pytest.mark.parametrize("existing", ["nothing", "array", "group"]) +@pytest.mark.parametrize("mode", ["r", "r+", "a", "w", "w-"]) +@pytest.mark.parametrize("shape", [None, (3,)], ids=["no shape", "shape"]) +def test_open_mode_contract( + existing: str, mode: AccessModeLiteral, shape: tuple[int, ...] | None +) -> None: + """`open` follows its two rules for every mode, whatever is at the path, with and without `shape`. + + With `shape` the call describes an array. Without it, the reading modes open + whatever node is there and 'a' creates a group when there is none; the + creating modes make a group, 'w' over whatever is there and 'w-' only over + nothing. An opened node keeps its attributes; a created one has none. + """ store = MemoryStore() - await zarr.api.asynchronous.open_group(store, zarr_format=3) - with pytest.raises(NodeTypeValidationError, match="node_type"): - await AsyncArray.open(store, zarr_format=3) + if existing == "array": + zarr.create_array(store, shape=(3,), dtype="uint8", attributes={"old": True}) + elif existing == "group": + zarr.create_group(store, attributes={"old": True}) + kwargs: dict[str, Any] = {} if shape is None else {"shape": shape, "dtype": "uint8"} + expected = _expected_open_outcome(existing, mode, shape) + + if issubclass(expected, Exception): + with pytest.raises(expected): + zarr.open(store=store, mode=mode, **kwargs) + return + node = zarr.open(store=store, mode=mode, **kwargs) + assert isinstance(node, expected) + opened = existing != "nothing" and mode in ("r", "r+", "a") + assert node.attrs.get("old") is (True if opened else None) @pytest.mark.parametrize("mode", ["r", "r+", "w", "a"]) diff --git a/tests/test_api/test_asynchronous.py b/tests/test_api/test_asynchronous.py index 6ebec36bbd..8ef9ad0588 100644 --- a/tests/test_api/test_asynchronous.py +++ b/tests/test_api/test_asynchronous.py @@ -11,6 +11,7 @@ from zarr.api.asynchronous import _get_shape_chunks, _like_args, group, open from zarr.core.buffer.core import default_buffer_prototype from zarr.core.group import AsyncGroup +from zarr.errors import ContainsGroupError if TYPE_CHECKING: from pathlib import Path @@ -96,19 +97,17 @@ def test_like_args( assert _like_args(observed) == expected -async def test_open_no_array() -> None: +async def test_open_with_shape_on_group_raises() -> None: """ - Test that zarr.api.asynchronous.open attempts to open a group when no array is found, but shape was specified in kwargs. - This behavior makes no sense but we should still test it. + With `shape` given, `open` describes an array, so a group at the path is an error rather than + something to fall back to. """ store = { "zarr.json": default_buffer_prototype().buffer.from_bytes( json.dumps({"zarr_format": 3, "node_type": "group"}).encode("utf-8") ) } - with pytest.raises( - TypeError, match=r"open_group\(\) got an unexpected keyword argument 'shape'" - ): + with pytest.raises(ContainsGroupError, match="A group exists in store"): await open(store=store, shape=(1,)) diff --git a/tests/test_group.py b/tests/test_group.py index 31fbd138cd..5297276a7a 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1547,6 +1547,7 @@ def test_open_mutable_mapping_sync(): async def test_open_ambiguous_node(): + """A path holding both formats opens as Zarr format 3, without reading or warning about the other.""" zarr_json_bytes = default_buffer_prototype().buffer.from_bytes( json.dumps({"zarr_format": 3, "node_type": "group"}).encode("utf-8") ) @@ -1554,11 +1555,8 @@ async def test_open_ambiguous_node(): json.dumps({"zarr_format": 2}).encode("utf-8") ) store: dict[str, Buffer] = {"zarr.json": zarr_json_bytes, ".zgroup": zgroup_bytes} - with pytest.warns( - ZarrUserWarning, - match=r"Both zarr\.json \(Zarr format 3\) and \.zgroup \(Zarr format 2\) metadata objects exist at", - ): - await AsyncGroup.open(store, zarr_format=None) + group = await AsyncGroup.open(store, zarr_format=None) + assert group.metadata.zarr_format == 3 class TestConsolidated: diff --git a/tests/test_metadata/test_consolidated.py b/tests/test_metadata/test_consolidated.py index cd0fd92d74..8f884a0383 100644 --- a/tests/test_metadata/test_consolidated.py +++ b/tests/test_metadata/test_consolidated.py @@ -597,16 +597,16 @@ async def test_open_consolidated_raises_async(self, zarr_format: ZarrFormat) -> async def v2_consolidated_metadata_empty_dataset( self, memory_store: zarr.storage.MemoryStore ) -> AsyncGroup: - zgroup_bytes = cpu.Buffer.from_bytes(json.dumps({"zarr_format": 2}).encode()) - zmetadata_bytes = cpu.Buffer.from_bytes( - b'{"metadata":{".zgroup":{"zarr_format":2}},"zarr_consolidated_format":1}' + await memory_store.set( + ".zgroup", cpu.Buffer.from_bytes(json.dumps({"zarr_format": 2}).encode()) ) - return AsyncGroup._from_bytes_v2( - StorePath(memory_store, path=""), - zgroup_bytes, - zattrs_bytes=None, - consolidated_metadata_bytes=zmetadata_bytes, + await memory_store.set( + ".zmetadata", + cpu.Buffer.from_bytes( + b'{"metadata":{".zgroup":{"zarr_format":2}},"zarr_consolidated_format":1}' + ), ) + return await AsyncGroup.open(memory_store, zarr_format=2, use_consolidated=True) async def test_consolidated_metadata_backwards_compatibility( self, v2_consolidated_metadata_empty_dataset: AsyncGroup From 80243e9eaa47ea0fbbeb05a9b5f93ad528eccbda Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 16 Sep 2026 16:18:26 +0200 Subject: [PATCH 10/10] refactor(group): compose read_node_metadata from per-format readers read_v3_metadata and read_v2_metadata are the reusable pieces: each reads one format's documents and returns metadata or None. read_node_metadata is their composition, trying format 3 first when the format is not given, rather than inlining both and hanging the per-format wrappers off itself. Assisted-by: ClaudeCode:claude-fable-5-1 --- changes/4366.misc.md | 2 +- src/zarr/core/group.py | 66 +++++++++++++++++++++++++++--------------- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/changes/4366.misc.md b/changes/4366.misc.md index 951dcd69ea..b04feda153 100644 --- a/changes/4366.misc.md +++ b/changes/4366.misc.md @@ -1 +1 @@ -`zarr.open`, `open_array`, `open_group`, `AsyncArray.open` and `AsyncGroup.open` are now built on one metadata reader, `zarr.core.group.read_node_metadata`, which reads a node's documents once and returns the parsed metadata whichever kind and format the node is; with `zarr_format=None` it tries Zarr format 3 first and reads the format 2 documents only when there is no `zarr.json`. Opening a Zarr format 3 node costs a single read where `zarr.open` used to make up to seven. `zarr.open` now has a stated contract: with `shape` it behaves as `open_array`; otherwise the reading modes open whatever node is there ('a' creating a group when there is none) and the creating modes create a group ('w' replacing whatever is there). Consequences: `zarr.open(mode="w")` replaces an existing array with a group instead of returning it; `zarr.open(shape=...)` on a group raises `ContainsGroupError` instead of opening the group; `zarr.open(mode="r")` on nothing raises `NodeNotFoundError`; opening an array as a group raises `ContainsArrayError` and a group as an array `ContainsGroupError` in every format and mode; a Zarr format 3 document whose `node_type` is missing or unknown raises `NodeTypeValidationError` everywhere; and a path holding both formats is read as format 3 without a warning. +`zarr.open`, `open_array`, `open_group`, `AsyncArray.open` and `AsyncGroup.open` are now built on one metadata reader, `zarr.core.group.read_node_metadata`, composed of a per-format `read_v3_metadata` and `read_v2_metadata`; each reads a node's documents once and returns the parsed metadata whichever kind the node is, and with `zarr_format=None` format 3 is tried first and the format 2 documents are read only when there is no `zarr.json`. Opening a Zarr format 3 node costs a single read where `zarr.open` used to make up to seven. `zarr.open` now has a stated contract: with `shape` it behaves as `open_array`; otherwise the reading modes open whatever node is there ('a' creating a group when there is none) and the creating modes create a group ('w' replacing whatever is there). Consequences: `zarr.open(mode="w")` replaces an existing array with a group instead of returning it; `zarr.open(shape=...)` on a group raises `ContainsGroupError` instead of opening the group; `zarr.open(mode="r")` on nothing raises `NodeNotFoundError`; opening an array as a group raises `ContainsArrayError` and a group as an array `ContainsGroupError` in every format and mode; a Zarr format 3 document whose `node_type` is missing or unknown raises `NodeTypeValidationError` everywhere; and a path holding both formats is read as format 3 without a warning. diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index 8f3fd69fc2..46a5555cc7 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -3445,27 +3445,47 @@ async def read_node_metadata( ) -> ArrayV2Metadata | ArrayV3Metadata | GroupMetadata | None: """Read the metadata of the node at `path`, whichever kind and format it is. - This is the one place a node's metadata documents are read. With - `zarr_format` None, Zarr format 3 is tried first, and format 2 only if there - is no `zarr.json`; a path holding both formats is read as format 3 without a - second look. A format 3 node is its `zarr.json`, whose `node_type` must say - `array` or `group`. A format 2 node is `.zarray` or `.zgroup` (the array - winning if both exist) plus `.zattrs`, all read at once along with - `consolidated_key`, which names a consolidated-metadata document to attach - to a group; None reads none. Returns None if no node is found. + With `zarr_format` None, Zarr format 3 is tried first and format 2 only if + there is no `zarr.json`, so a path holding both formats is read as format 3 + without a second look. `consolidated_key` is passed to the format 2 reader. + Returns None if no node is found. """ - if zarr_format not in (2, 3, None): - msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." - raise MetadataValidationError(msg) - if zarr_format != 2: - zarr_json_bytes = await store.get( - _join_paths([path, ZARR_JSON]), prototype=default_buffer_prototype() - ) - if zarr_json_bytes is not None: - return _build_metadata_v3(buffer_to_json_object(zarr_json_bytes)) - if zarr_format == 3: - return None + if zarr_format == 3: + return await read_v3_metadata(store, path) + if zarr_format == 2: + return await read_v2_metadata(store, path, consolidated_key=consolidated_key) + if zarr_format is None: + metadata = await read_v3_metadata(store, path) + if metadata is None: + return await read_v2_metadata(store, path, consolidated_key=consolidated_key) + return metadata + msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." # type: ignore[unreachable] + raise MetadataValidationError(msg) + +async def read_v3_metadata(store: Store, path: str) -> ArrayV3Metadata | GroupMetadata | None: + """Read the Zarr format 3 node metadata at `path`, or None if there is no `zarr.json`. + + One read: the document's `node_type` says whether it is an array or a group. + """ + zarr_json_bytes = await store.get( + _join_paths([path, ZARR_JSON]), prototype=default_buffer_prototype() + ) + if zarr_json_bytes is None: + return None + return _build_metadata_v3(buffer_to_json_object(zarr_json_bytes)) + + +async def read_v2_metadata( + store: Store, path: str, *, consolidated_key: str | None = None +) -> ArrayV2Metadata | GroupMetadata | None: + """Read the Zarr format 2 node metadata at `path`, or None if there is neither `.zarray` nor `.zgroup`. + + One concurrent read of `.zarray`, `.zgroup`, `.zattrs` and, if given, the + consolidated-metadata document at `consolidated_key`. `.zarray` makes the + node an array and `.zgroup` a group, the array winning if both exist; a + consolidated document is attached to a group's metadata. + """ keys = [ZARRAY_JSON, ZGROUP_JSON, ZATTRS_JSON] if consolidated_key is not None: keys.append(consolidated_key) @@ -3517,16 +3537,16 @@ def _consolidated_metadata_from_v2_doc(doc: dict[str, JSON]) -> ConsolidatedMeta async def _read_metadata_v3(store: Store, path: str) -> ArrayV3Metadata | GroupMetadata: - """The format 3 node metadata at `path`, raising FileNotFoundError if there is none.""" - metadata = await read_node_metadata(store, path, 3) + """`read_v3_metadata`, raising FileNotFoundError instead of returning None.""" + metadata = await read_v3_metadata(store, path) if metadata is None: raise FileNotFoundError(path) return metadata async def _read_metadata_v2(store: Store, path: str) -> ArrayV2Metadata | GroupMetadata: - """The format 2 node metadata at `path`, raising FileNotFoundError if there is none.""" - metadata = await read_node_metadata(store, path, 2) + """`read_v2_metadata`, raising FileNotFoundError instead of returning None.""" + metadata = await read_v2_metadata(store, path) if metadata is None: raise FileNotFoundError(path) return metadata