From 5c468b668466b5e656075efaa7cb15dad167dd0d Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Tue, 16 Jul 2024 23:06:37 +0200 Subject: [PATCH 01/14] add deprecated Group.array method, and start filling in group unit tests --- src/zarr/group.py | 234 ++++++++++++++++++++++++++++++++++++++++- tests/v3/test_group.py | 107 ++++++++++++++++++- 2 files changed, 336 insertions(+), 5 deletions(-) diff --git a/src/zarr/group.py b/src/zarr/group.py index e6e2ac183..875c1d7ec 100644 --- a/src/zarr/group.py +++ b/src/zarr/group.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Literal, cast, overload import numpy.typing as npt +from typing_extensions import deprecated from zarr.abc.codec import Codec from zarr.abc.metadata import Metadata @@ -346,6 +347,51 @@ async def create_array( # runtime exists_ok: bool = False, ) -> AsyncArray: + """ + Create a zarr array within this AsyncGroup. + This method lightly wraps AsyncArray.create. + + Parameters + ---------- + + path: str + The name of the array. + shape: tuple[int, ...] + The shape of the array. + dtype: np.DtypeLike = float64 + The data type of the array. + chunk_shape: tuple[int, ...] | None = None + The shape of the chunks of the array. V3 only. + chunk_key_encoding: ChunkKeyEncoding + | tuple[Literal["default"], Literal[".", "/"]] + | tuple[Literal["v2"], Literal[".", "/"]] + | None = None + A specification of how the chunk keys are represented in storage. + codecs: Iterable[Codec | dict[str, JSON]] | None = None + An iterable of Codec or dict serializations thereof. The elements of + this collection specify the transformation from array values to stored bytes. + dimension_names: Iterable[str] | None = None + The names of the dimensions of the array. V3 only. + chunks: ChunkCoords | None = None + The shape of the chunks of the array. V2 only. + dimension_separator: Literal[".", "/"] | None = None + The delimiter used for the chunk keys. + order: Literal["C", "F"] | None = None + The memory order of the array. + filters: list[dict[str, JSON]] | None = None + Filters for the array. + compressor: dict[str, JSON] | None = None + The compressor for the array. + exists_ok: bool = False + If True, a pre-existing array or group at the path of this array will + be overwritten. If False, the presence of a pre-existing array or group is + an error. + + Returns + ------- + + AsyncArray + """ return await AsyncArray.create( self.store_path / path, shape=shape, @@ -616,8 +662,98 @@ def tree(self, expand: bool = False, level: int | None = None) -> Any: def create_group(self, name: str, **kwargs: Any) -> Group: return Group(self._sync(self._async_group.create_group(name, **kwargs))) - def create_array(self, name: str, **kwargs: Any) -> Array: - return Array(self._sync(self._async_group.create_array(name, **kwargs))) + def create_array( + self, + name: str, + shape: ChunkCoords, + dtype: npt.DTypeLike = "float64", + fill_value: Any | None = None, + attributes: dict[str, JSON] | None = None, + # v3 only + chunk_shape: ChunkCoords | None = None, + chunk_key_encoding: ( + ChunkKeyEncoding + | tuple[Literal["default"], Literal[".", "/"]] + | tuple[Literal["v2"], Literal[".", "/"]] + | None + ) = None, + codecs: Iterable[Codec | dict[str, JSON]] | None = None, + dimension_names: Iterable[str] | None = None, + # v2 only + chunks: ChunkCoords | None = None, + dimension_separator: Literal[".", "/"] | None = None, + order: Literal["C", "F"] | None = None, + filters: list[dict[str, JSON]] | None = None, + compressor: dict[str, JSON] | None = None, + # runtime + exists_ok: bool = False, + ) -> Array: + """ + Create a zarr array within this AsyncGroup. + This method lightly wraps AsyncArray.create. + + Parameters + ---------- + + name: str + The name of the array. + shape: tuple[int, ...] + The shape of the array. + dtype: np.DtypeLike = float64 + The data type of the array. + chunk_shape: tuple[int, ...] | None = None + The shape of the chunks of the array. V3 only. + chunk_key_encoding: ChunkKeyEncoding + | tuple[Literal["default"], Literal[".", "/"]] + | tuple[Literal["v2"], Literal[".", "/"]] + | None = None + A specification of how the chunk keys are represented in storage. + codecs: Iterable[Codec | dict[str, JSON]] | None = None + An iterable of Codec or dict serializations thereof. The elements of + this collection specify the transformation from array values to stored bytes. + dimension_names: Iterable[str] | None = None + The names of the dimensions of the array. V3 only. + chunks: ChunkCoords | None = None + The shape of the chunks of the array. V2 only. + dimension_separator: Literal[".", "/"] | None = None + The delimiter used for the chunk keys. + order: Literal["C", "F"] | None = None + The memory order of the array. + filters: list[dict[str, JSON]] | None = None + Filters for the array. + compressor: dict[str, JSON] | None = None + The compressor for the array. + exists_ok: bool = False + If True, a pre-existing array or group at the path of this array will + be overwritten. If False, the presence of a pre-existing array or group is + an error. + + Returns + ------- + + Array + """ + return Array( + self._sync( + self._async_group.create_array( + name, + shape, + dtype, + fill_value, + attributes, + chunk_shape, + chunk_key_encoding, + codecs, + dimension_names, + chunks, + dimension_separator, + order, + filters, + compressor, + exists_ok, + ) + ) + ) def empty(self, **kwargs: Any) -> Array: return Array(self._sync(self._async_group.empty(**kwargs))) @@ -645,3 +781,97 @@ def full_like(self, prototype: AsyncArray, **kwargs: Any) -> Array: def move(self, source: str, dest: str) -> None: return self._sync(self._async_group.move(source, dest)) + + @deprecated("Use Group.create_array instead.") + def array( + self, + name: str, + shape: ChunkCoords, + dtype: npt.DTypeLike = "float64", + fill_value: Any | None = None, + attributes: dict[str, JSON] | None = None, + # v3 only + chunk_shape: ChunkCoords | None = None, + chunk_key_encoding: ( + ChunkKeyEncoding + | tuple[Literal["default"], Literal[".", "/"]] + | tuple[Literal["v2"], Literal[".", "/"]] + | None + ) = None, + codecs: Iterable[Codec | dict[str, JSON]] | None = None, + dimension_names: Iterable[str] | None = None, + # v2 only + chunks: ChunkCoords | None = None, + dimension_separator: Literal[".", "/"] | None = None, + order: Literal["C", "F"] | None = None, + filters: list[dict[str, JSON]] | None = None, + compressor: dict[str, JSON] | None = None, + # runtime + exists_ok: bool = False, + ) -> Array: + """ + Create a zarr array within this AsyncGroup. + This method lightly wraps AsyncArray.create. + + Parameters + ---------- + + name: str + The name of the array. + shape: tuple[int, ...] + The shape of the array. + dtype: np.DtypeLike = float64 + The data type of the array. + chunk_shape: tuple[int, ...] | None = None + The shape of the chunks of the array. V3 only. + chunk_key_encoding: ChunkKeyEncoding + | tuple[Literal["default"], Literal[".", "/"]] + | tuple[Literal["v2"], Literal[".", "/"]] + | None = None + A specification of how the chunk keys are represented in storage. + codecs: Iterable[Codec | dict[str, JSON]] | None = None + An iterable of Codec or dict serializations thereof. The elements of + this collection specify the transformation from array values to stored bytes. + dimension_names: Iterable[str] | None = None + The names of the dimensions of the array. V3 only. + chunks: ChunkCoords | None = None + The shape of the chunks of the array. V2 only. + dimension_separator: Literal[".", "/"] | None = None + The delimiter used for the chunk keys. + order: Literal["C", "F"] | None = None + The memory order of the array. + filters: list[dict[str, JSON]] | None = None + Filters for the array. + compressor: dict[str, JSON] | None = None + The compressor for the array. + exists_ok: bool = False + If True, a pre-existing array or group at the path of this array will + be overwritten. If False, the presence of a pre-existing array or group is + an error. + + Returns + ------- + + Array + """ + return Array( + self._sync( + self._async_group.create_array( + name, + shape, + dtype, + fill_value, + attributes, + chunk_shape, + chunk_key_encoding, + codecs, + dimension_names, + chunks, + dimension_separator, + order, + filters, + compressor, + exists_ok, + ) + ) + ) diff --git a/tests/v3/test_group.py b/tests/v3/test_group.py index e11af748b..47178fdc9 100644 --- a/tests/v3/test_group.py +++ b/tests/v3/test_group.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any -from zarr.array import AsyncArray +from zarr.array import Array, AsyncArray from zarr.buffer import Buffer from zarr.store.core import make_store_path from zarr.sync import sync @@ -32,12 +32,12 @@ def test_group_children(store: MemoryStore | LocalStore) -> None: store_path=StorePath(store=store, path=path), ) group = Group(agroup) - members_expected = {} + members_expected: dict[str, Array | Group] = {} members_expected["subgroup"] = group.create_group("subgroup") # make a sub-sub-subgroup, to ensure that the children calculation doesn't go # too deep in the hierarchy - _ = members_expected["subgroup"].create_group("subsubgroup") + _ = members_expected["subgroup"].create_group("subsubgroup") # type: ignore members_expected["subarray"] = group.create_array( "subarray", shape=(100,), dtype="uint8", chunk_shape=(10,), exists_ok=True @@ -114,6 +114,107 @@ def test_group_create(store: MemoryStore | LocalStore, exists_ok: bool) -> None: ) +@pytest.mark.parametrize("store", ("local", "memory"), indirect=True) +@pytest.mark.parametrize("zarr_format", [2, 3]) +@pytest.mark.parametrize("exists_ok", (True, False)) +def test_group_open( + store: MemoryStore | LocalStore, zarr_format: ZarrFormat, exists_ok: bool +) -> None: + """ + Test the Group.open method. + """ + spath = StorePath(store) + # attempt to open a group that does not exist + with pytest.raises(FileNotFoundError): + Group.open(store) + + # create the group + attrs = {"path": "foo"} + group_created = Group.create( + store, attributes=attrs, zarr_format=zarr_format, exists_ok=exists_ok + ) + assert group_created.attrs == attrs + assert group_created.metadata.zarr_format == zarr_format + assert group_created.store_path == spath + + # attempt to create a new group in place, to test exists_ok + new_attrs = {"path": "bar"} + if not exists_ok: + with pytest.raises(AssertionError): + Group.create(store, attributes=attrs, zarr_format=zarr_format, exists_ok=exists_ok) + else: + group_created_again = Group.create( + store, attributes=new_attrs, zarr_format=zarr_format, exists_ok=exists_ok + ) + assert group_created_again.attrs == new_attrs + assert group_created_again.metadata.zarr_format == zarr_format + assert group_created_again.store_path == spath + + +@pytest.mark.parametrize("store", ("local", "memory"), indirect=True) +@pytest.mark.parametrize("zarr_format", [2, 3]) +def test_group_getitem(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: + """ + Test the Group.__getitem__ method. + """ + + group = Group.create(store, zarr_format=zarr_format) + subgroup = group.create_group(name="subgroup") + subarray = group.create_array(name="subarray", shape=(10,), chunk_shape=(10,)) + + assert group["subgroup"] == subgroup + assert group["subarray"] == subarray + with pytest.raises(KeyError): + group["nope"] + + +@pytest.mark.parametrize("store", ("local", "memory"), indirect=True) +@pytest.mark.parametrize("zarr_format", [2, 3]) +def test_group_delitem(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: + """ + Test the Group.__delitem__ method. + """ + + group = Group.create(store, zarr_format=zarr_format) + subgroup = group.create_group(name="subgroup") + subarray = group.create_array(name="subarray", shape=(10,), chunk_shape=(10,)) + + assert group["subgroup"] == subgroup + assert group["subarray"] == subarray + + del group["subgroup"] + with pytest.raises(KeyError): + group["subgroup"] + + del group["subarray"] + with pytest.raises(KeyError): + group["subarray"] + + +@pytest.mark.parametrize("store", ("local", "memory"), indirect=True) +@pytest.mark.parametrize("zarr_format", [2, 3]) +def test_group_iter(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: + """ + Test the Group.__iter__ method. + """ + + group = Group.create(store, zarr_format=zarr_format) + with pytest.raises(NotImplementedError): + [x for x in group] # type: ignore + + +@pytest.mark.parametrize("store", ("local", "memory"), indirect=True) +@pytest.mark.parametrize("zarr_format", [2, 3]) +def test_group_len(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: + """ + Test Group__len__. + """ + + group = Group.create(store, zarr_format=zarr_format) + with pytest.raises(NotImplementedError): + len(group) # type: ignore + + @pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) @pytest.mark.parametrize("zarr_format", (2, 3)) @pytest.mark.parametrize("exists_ok", (True, False)) From 6f1a18eb295a3be284d0d76829a67ce3258a4a21 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 19 Jul 2024 18:05:32 +0200 Subject: [PATCH 02/14] add errors module --- src/zarr/errors.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/zarr/errors.py diff --git a/src/zarr/errors.py b/src/zarr/errors.py new file mode 100644 index 000000000..867e9976e --- /dev/null +++ b/src/zarr/errors.py @@ -0,0 +1,16 @@ +from typing import Any + + +class _BaseZarrError(ValueError): + _msg = "" + + def __init__(self, *args: Any) -> None: + super().__init__(self._msg.format(*args)) + + +class ContainsGroupError(_BaseZarrError): + _msg = "A group exists in store {0!r} at path {1!r}." + + +class ContainsArrayError(_BaseZarrError): + _msg = "An array exists in store {0!r} at path {1!r}." From d23d4d370859089fe8872e4f5cef7e74e007bca4 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 19 Jul 2024 18:06:55 +0200 Subject: [PATCH 03/14] memory store listdir fix, and a type annotation for a method on the test class --- src/zarr/store/memory.py | 5 +++-- tests/v3/test_store/test_memory.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/zarr/store/memory.py b/src/zarr/store/memory.py index 7b73330b6..7f3c57571 100644 --- a/src/zarr/store/memory.py +++ b/src/zarr/store/memory.py @@ -95,8 +95,9 @@ async def list_dir(self, prefix: str) -> AsyncGenerator[str, None]: prefix = prefix[:-1] if prefix == "": - for key in self._store_dict: - yield key.split("/", maxsplit=1)[0] + keys_unique = set(k.split("/")[0] for k in self._store_dict.keys()) + for key in keys_unique: + yield key else: for key in self._store_dict: if key.startswith(prefix + "/") and key != prefix: diff --git a/tests/v3/test_store/test_memory.py b/tests/v3/test_store/test_memory.py index 96b8b19e2..dd3cad7d7 100644 --- a/tests/v3/test_store/test_memory.py +++ b/tests/v3/test_store/test_memory.py @@ -17,7 +17,9 @@ def get(self, store: MemoryStore, key: str) -> Buffer: return store._store_dict[key] @pytest.fixture(scope="function", params=[None, {}]) - def store_kwargs(self, request) -> dict[str, str | None | dict[str, Buffer]]: + def store_kwargs( + self, request: pytest.FixtureRequest + ) -> dict[str, str | None | dict[str, Buffer]]: return {"store_dict": request.param, "mode": "w"} @pytest.fixture(scope="function") From cc83ddb3750542a034327f8e39d16d3104492592 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 19 Jul 2024 18:08:39 +0200 Subject: [PATCH 04/14] Use ContainsArrayError when a path contains an array; restore auto-chunking; restore data kwarg to array creation --- src/zarr/array.py | 22 ++++++++++---- src/zarr/store/core.py | 67 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/zarr/array.py b/src/zarr/array.py index 1cc4c8ccf..6c6a79b0e 100644 --- a/src/zarr/array.py +++ b/src/zarr/array.py @@ -37,6 +37,7 @@ product, ) from zarr.config import config, parse_indexing_order +from zarr.errors import ContainsArrayError from zarr.indexing import ( BasicIndexer, BasicSelection, @@ -62,7 +63,9 @@ ) from zarr.metadata import ArrayMetadata, ArrayV2Metadata, ArrayV3Metadata from zarr.store import StoreLike, StorePath, make_store_path +from zarr.store.core import contains_array from zarr.sync import sync +from zarr.v2.util import guess_chunks def parse_array_metadata(data: Any) -> ArrayV2Metadata | ArrayV3Metadata: @@ -137,12 +140,13 @@ async def create( compressor: dict[str, JSON] | None = None, # runtime exists_ok: bool = False, + data: npt.ArrayLike | None = None, ) -> AsyncArray: store_path = make_store_path(store) if chunk_shape is None: if chunks is None: - raise ValueError("Either chunk_shape or chunks needs to be provided.") + chunk_shape = chunks = guess_chunks(shape=shape, typesize=np.dtype(dtype).itemsize) chunk_shape = chunks elif chunks is not None: raise ValueError("Only one of chunk_shape or chunks must be provided.") @@ -164,7 +168,7 @@ async def create( raise ValueError( "compressor cannot be used for arrays with version 3. Use bytes-to-bytes codecs instead." ) - return await cls._create_v3( + result = await cls._create_v3( store_path, shape=shape, dtype=dtype, @@ -187,7 +191,7 @@ async def create( ) if dimension_names is not None: raise ValueError("dimension_names cannot be used for arrays with version 2.") - return await cls._create_v2( + result = await cls._create_v2( store_path, shape=shape, dtype=dtype, @@ -203,6 +207,12 @@ async def create( else: raise ValueError(f"Insupported zarr_format. Got: {zarr_format}") + if data is not None: + # insert user-provided data + await result.setitem(..., data) + + return result + @classmethod async def _create_v3( cls, @@ -224,7 +234,8 @@ async def _create_v3( exists_ok: bool = False, ) -> AsyncArray: if not exists_ok: - assert not await (store_path / ZARR_JSON).exists() + if await contains_array(store_path=store_path, zarr_format=3): + raise ContainsArrayError(store_path.store, store_path.path) codecs = list(codecs) if codecs is not None else [BytesCodec()] @@ -280,7 +291,8 @@ async def _create_v2( import numcodecs if not exists_ok: - assert not await (store_path / ZARRAY_JSON).exists() + if await contains_array(store_path=store_path, zarr_format=2): + raise ContainsArrayError(store_path.store, store_path.path) if order is None: order = "C" diff --git a/src/zarr/store/core.py b/src/zarr/store/core.py index caa30d699..86c0038e3 100644 --- a/src/zarr/store/core.py +++ b/src/zarr/store/core.py @@ -1,11 +1,12 @@ from __future__ import annotations +import json from pathlib import Path from typing import Any from zarr.abc.store import Store from zarr.buffer import Buffer, BufferPrototype, default_buffer_prototype -from zarr.common import OpenMode +from zarr.common import ZARR_JSON, ZARRAY_JSON, ZGROUP_JSON, OpenMode, ZarrFormat from zarr.store.local import LocalStore from zarr.store.memory import MemoryStore @@ -84,3 +85,67 @@ def make_store_path(store_like: StoreLike | None, *, mode: OpenMode | None = Non elif isinstance(store_like, str): return StorePath(LocalStore(Path(store_like), mode=mode or "r")) raise TypeError + + +async def contains_array(store_path: StorePath, zarr_format: ZarrFormat) -> bool: + """ + Check if an array exists at a given StorePath. + + Parameters + ---------- + + store_path: StorePath + The StorePath to check for an existing group. + zarr_format: + The zarr format to check for. + + Returns + ------- + + bool + True if the StorePath contains a group, False otherwise + + """ + if zarr_format == 3: + extant_meta_bytes = await (store_path / ZARR_JSON).get() + if extant_meta_bytes is None: + return False + else: + try: + extant_meta_json = json.loads(extant_meta_bytes.to_bytes()) + # we avoid constructing a full metadata document here in the name of speed. + if extant_meta_json["node_type"] == "array": + return True + except (ValueError, KeyError): + return False + elif zarr_format == 2: + return await (store_path / ZARRAY_JSON).exists() + msg = f"Invalid zarr_format provided. Got {zarr_format}, expected 2 or 3" + raise ValueError(msg) + + +async def contains_group(store_path: StorePath, zarr_format: ZarrFormat) -> bool: + """ + Check if a group exists at a given StorePath. + + Parameters + ---------- + + store_path: StorePath + The StorePath to check for an existing group. + zarr_format: + The zarr format to check for. + + Returns + ------- + + bool + True if the StorePath contains a group, False otherwise + + """ + if zarr_format == 3: + return await (store_path / ZARR_JSON).exists() + elif zarr_format == 2: + return await (store_path / ZGROUP_JSON).exists() + msg = f"Invalid zarr_format provided. Got {zarr_format}, expected 2 or 3" # type: ignore[unreachable] + raise ValueError(msg) From 700b0315157f66a0c83e523eb253e9bada2b5421 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 19 Jul 2024 18:09:12 +0200 Subject: [PATCH 05/14] use ContainsGroupError and contains_group for group routines --- src/zarr/group.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/zarr/group.py b/src/zarr/group.py index 875c1d7ec..5b14bc9e8 100644 --- a/src/zarr/group.py +++ b/src/zarr/group.py @@ -27,7 +27,9 @@ ZarrFormat, ) from zarr.config import config +from zarr.errors import ContainsGroupError from zarr.store import StoreLike, StorePath, make_store_path +from zarr.store.core import contains_group from zarr.sync import SyncMixin, sync if TYPE_CHECKING: @@ -130,10 +132,8 @@ async def create( ) -> AsyncGroup: store_path = make_store_path(store) if not exists_ok: - if zarr_format == 3: - assert not await (store_path / ZARR_JSON).exists() - elif zarr_format == 2: - assert not await (store_path / ZGROUP_JSON).exists() + if await contains_group(store_path=store_path, zarr_format=zarr_format): + raise ContainsGroupError(store_path.store, store_path.path) group = cls( metadata=GroupMetadata(attributes=attributes, zarr_format=zarr_format), store_path=store_path, @@ -346,6 +346,7 @@ async def create_array( compressor: dict[str, JSON] | None = None, # runtime exists_ok: bool = False, + data: npt.ArrayLike | None = None, ) -> AsyncArray: """ Create a zarr array within this AsyncGroup. @@ -409,6 +410,7 @@ async def create_array( compressor=compressor, exists_ok=exists_ok, zarr_format=self.metadata.zarr_format, + data=data, ) async def update_attributes(self, new_attributes: dict[str, Any]) -> AsyncGroup: @@ -451,6 +453,7 @@ async def members(self) -> AsyncGenerator[tuple[str, AsyncArray | AsyncGroup], N # would be nice to make these special keys accessible programmatically, # and scoped to specific zarr versions _skip_keys = ("zarr.json", ".zgroup", ".zattrs") + async for key in self.store_path.store.list_dir(self.store_path.path): if key in _skip_keys: continue @@ -687,6 +690,7 @@ def create_array( compressor: dict[str, JSON] | None = None, # runtime exists_ok: bool = False, + data: npt.ArrayLike | None = None, ) -> Array: """ Create a zarr array within this AsyncGroup. @@ -727,7 +731,8 @@ def create_array( If True, a pre-existing array or group at the path of this array will be overwritten. If False, the presence of a pre-existing array or group is an error. - + data: npt.ArrayLike | None = None + Array data to initialize the array with. Returns ------- @@ -751,6 +756,7 @@ def create_array( filters, compressor, exists_ok, + data, ) ) ) From 32377941a024e63765e3f5e363d441fa34da05d1 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 19 Jul 2024 18:09:47 +0200 Subject: [PATCH 06/14] style changes to store tests --- src/zarr/testing/store.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/zarr/testing/store.py b/src/zarr/testing/store.py index 9c37ce043..a4e154bbc 100644 --- a/src/zarr/testing/store.py +++ b/src/zarr/testing/store.py @@ -183,8 +183,12 @@ async def test_list_dir(self, store: S) -> None: await store.set("foo/zarr.json", Buffer.from_bytes(b"bar")) await store.set("foo/c/1", Buffer.from_bytes(b"\x01")) - keys = [k async for k in store.list_dir("foo")] - assert set(keys) == set(["zarr.json", "c"]), keys + keys_expected = ["zarr.json", "c"] + keys_observed = [k async for k in store.list_dir("foo")] - keys = [k async for k in store.list_dir("foo/")] - assert set(keys) == set(["zarr.json", "c"]), keys + assert len(keys_observed) == len(keys_expected), keys_observed + assert set(keys_observed) == set(keys_expected), keys_observed + + keys_observed = [k async for k in store.list_dir("foo/")] + assert len(keys_expected) == len(keys_observed), keys_observed + assert set(keys_observed) == set(keys_expected), keys_observed From dd4dbac5f64bf17d4c0b4ce5acd3fe4099246971 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 19 Jul 2024 18:10:39 +0200 Subject: [PATCH 07/14] add a lot of tests, remove redundant decorators --- tests/v3/test_group.py | 281 +++++++++++++++++++++++++++-------------- 1 file changed, 185 insertions(+), 96 deletions(-) diff --git a/tests/v3/test_group.py b/tests/v3/test_group.py index 47178fdc9..9018a331b 100644 --- a/tests/v3/test_group.py +++ b/tests/v3/test_group.py @@ -1,26 +1,77 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any, cast + +import numpy as np +import pytest +from _pytest.compat import LEGACY_PATH from zarr.array import Array, AsyncArray from zarr.buffer import Buffer +from zarr.common import ZarrFormat +from zarr.errors import ContainsArrayError, ContainsGroupError +from zarr.group import AsyncGroup, Group, GroupMetadata +from zarr.store import LocalStore, MemoryStore, StorePath from zarr.store.core import make_store_path from zarr.sync import sync -if TYPE_CHECKING: - from zarr.common import ZarrFormat - from zarr.store import LocalStore, MemoryStore +from .conftest import parse_store -import numpy as np -import pytest -from zarr.group import AsyncGroup, Group, GroupMetadata -from zarr.store import StorePath +@pytest.fixture(params=["local", "memory"]) +def store(request: pytest.FixtureRequest, tmpdir: LEGACY_PATH) -> LocalStore | MemoryStore: + result = parse_store(request.param, str(tmpdir)) + if not isinstance(result, MemoryStore | LocalStore): + raise TypeError("Wrong store class returned by test fixture!") + return result + + +@pytest.fixture(params=[True, False]) +def exists_ok(request: pytest.FixtureRequest) -> bool: + result = request.param + if not isinstance(result, bool): + raise TypeError("Wrong type returned by test fixture.") + return result + + +@pytest.fixture(params=[2, 3], ids=["zarr2", "zarr3"]) +def zarr_format(request: pytest.FixtureRequest) -> ZarrFormat: + result = request.param + if result not in (2, 3): + raise ValueError("Wrong value returned from test fixture.") + return cast(ZarrFormat, result) + + +def test_group_init(store: LocalStore | MemoryStore, zarr_format: ZarrFormat) -> None: + """ + Test that initializing a group from an asyncgroup works. + """ + agroup = sync(AsyncGroup.create(store=store, zarr_format=zarr_format)) + group = Group(agroup) + assert group._async_group == agroup -# todo: put RemoteStore in here -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -def test_group_children(store: MemoryStore | LocalStore) -> None: +def test_group_name_properties(store: LocalStore | MemoryStore, zarr_format: ZarrFormat) -> None: + """ + Test basic properties of groups + """ + root = Group.create(store=store, zarr_format=zarr_format) + assert root.path == "" + assert root.name == "/" + assert root.basename == "" + + foo = root.create_group("foo") + assert foo.path == "foo" + assert foo.name == "/foo" + assert foo.basename == "foo" + + bar = root.create_group("foo/bar") + assert bar.path == "foo/bar" + assert bar.name == "/foo/bar" + assert bar.basename == "bar" + + +def test_group_members(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: """ Test that `Group.members` returns correct values, i.e. the arrays and groups (explicit and implicit) contained in that group. @@ -28,7 +79,7 @@ def test_group_children(store: MemoryStore | LocalStore) -> None: path = "group" agroup = AsyncGroup( - metadata=GroupMetadata(), + metadata=GroupMetadata(zarr_format=zarr_format), store_path=StorePath(store=store, path=path), ) group = Group(agroup) @@ -55,12 +106,15 @@ def test_group_children(store: MemoryStore | LocalStore) -> None: assert sorted(dict(members_observed)) == sorted(members_expected) -@pytest.mark.parametrize("store", (("local", "memory")), indirect=["store"]) -def test_group(store: MemoryStore | LocalStore) -> None: +def test_group(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: + """ + Test basic Group routines. + """ store_path = StorePath(store) - agroup = AsyncGroup(metadata=GroupMetadata(), store_path=store_path) + agroup = AsyncGroup(metadata=GroupMetadata(zarr_format=zarr_format), store_path=store_path) group = Group(agroup) assert agroup.metadata is group.metadata + assert agroup.store_path == group.store_path == store_path # create two groups foo = group.create_group("foo") @@ -94,34 +148,29 @@ def test_group(store: MemoryStore | LocalStore) -> None: assert dict(bar3.attrs) == {"baz": "qux", "name": "bar"} -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("exists_ok", (True, False)) -def test_group_create(store: MemoryStore | LocalStore, exists_ok: bool) -> None: +def test_group_create( + store: MemoryStore | LocalStore, exists_ok: bool, zarr_format: ZarrFormat +) -> None: """ Test that `Group.create` works as expected. """ attributes = {"foo": 100} - group = Group.create(store, attributes=attributes, exists_ok=exists_ok) + group = Group.create(store, attributes=attributes, zarr_format=zarr_format, exists_ok=exists_ok) assert group.attrs == attributes if not exists_ok: - with pytest.raises(AssertionError): + with pytest.raises(ContainsGroupError): group = Group.create( - store, - attributes=attributes, - exists_ok=exists_ok, + store, attributes=attributes, exists_ok=exists_ok, zarr_format=zarr_format ) -@pytest.mark.parametrize("store", ("local", "memory"), indirect=True) -@pytest.mark.parametrize("zarr_format", [2, 3]) -@pytest.mark.parametrize("exists_ok", (True, False)) def test_group_open( store: MemoryStore | LocalStore, zarr_format: ZarrFormat, exists_ok: bool ) -> None: """ - Test the Group.open method. + Test the `Group.open` method. """ spath = StorePath(store) # attempt to open a group that does not exist @@ -140,7 +189,7 @@ def test_group_open( # attempt to create a new group in place, to test exists_ok new_attrs = {"path": "bar"} if not exists_ok: - with pytest.raises(AssertionError): + with pytest.raises(ContainsGroupError): Group.create(store, attributes=attrs, zarr_format=zarr_format, exists_ok=exists_ok) else: group_created_again = Group.create( @@ -151,11 +200,9 @@ def test_group_open( assert group_created_again.store_path == spath -@pytest.mark.parametrize("store", ("local", "memory"), indirect=True) -@pytest.mark.parametrize("zarr_format", [2, 3]) def test_group_getitem(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: """ - Test the Group.__getitem__ method. + Test the `Group.__getitem__` method. """ group = Group.create(store, zarr_format=zarr_format) @@ -168,11 +215,9 @@ def test_group_getitem(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) group["nope"] -@pytest.mark.parametrize("store", ("local", "memory"), indirect=True) -@pytest.mark.parametrize("zarr_format", [2, 3]) def test_group_delitem(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: """ - Test the Group.__delitem__ method. + Test the `Group.__delitem__` method. """ group = Group.create(store, zarr_format=zarr_format) @@ -191,11 +236,9 @@ def test_group_delitem(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) group["subarray"] -@pytest.mark.parametrize("store", ("local", "memory"), indirect=True) -@pytest.mark.parametrize("zarr_format", [2, 3]) def test_group_iter(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: """ - Test the Group.__iter__ method. + Test the `Group.__iter__` method. """ group = Group.create(store, zarr_format=zarr_format) @@ -203,11 +246,9 @@ def test_group_iter(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> [x for x in group] # type: ignore -@pytest.mark.parametrize("store", ("local", "memory"), indirect=True) -@pytest.mark.parametrize("zarr_format", [2, 3]) def test_group_len(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: """ - Test Group__len__. + Test the `Group.__len__` method. """ group = Group.create(store, zarr_format=zarr_format) @@ -215,9 +256,103 @@ def test_group_len(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> len(group) # type: ignore -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) -@pytest.mark.parametrize("exists_ok", (True, False)) +def test_group_setitem(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: + """ + Test the `Group.__setitem__` method. + """ + group = Group.create(store, zarr_format=zarr_format) + with pytest.raises(NotImplementedError): + group["key"] = 10 + + +def test_group_contains(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: + """ + Test the `Group.__contains__` method + """ + group = Group.create(store, zarr_format=zarr_format) + assert "foo" not in group + _ = group.create_group(name="foo") + assert "foo" in group + + +def test_group_subgroups(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: + """ + Test the behavior of `Group` methods for accessing subgroups, namely `Group.group_keys` and `Group.groups` + """ + group = Group.create(store, zarr_format=zarr_format) + keys = ("foo", "bar") + subgroups_expected = tuple(group.create_group(k) for k in keys) + # create a sub-array as well + _ = group.create_array("array", shape=(10,)) + subgroups_observed = group.groups() + assert set(group.group_keys()) == set(keys) + assert len(subgroups_observed) == len(subgroups_expected) + assert all(a in subgroups_observed for a in subgroups_expected) + + +def test_group_subarrays(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: + """ + Test the behavior of `Group` methods for accessing subgroups, namely `Group.group_keys` and `Group.groups` + """ + group = Group.create(store, zarr_format=zarr_format) + keys = ("foo", "bar") + subarrays_expected = tuple(group.create_array(k, shape=(10,)) for k in keys) + # create a sub-group as well + _ = group.create_group("group") + subarrays_observed = group.arrays() + assert set(group.array_keys()) == set(keys) + assert len(subarrays_observed) == len(subarrays_expected) + assert all(a in subarrays_observed for a in subarrays_expected) + + +def test_group_update_attributes(store: MemoryStore | LocalStore, zarr_format: ZarrFormat) -> None: + """ + Test the behavior of `Group.update_attributes` + """ + attrs = {"foo": 100} + group = Group.create(store, zarr_format=zarr_format, attributes=attrs) + assert group.attrs == attrs + new_attrs = {"bar": 100} + new_group = group.update_attributes(new_attrs) + assert new_group.attrs == new_attrs + + +async def test_group_update_attributes_async( + store: MemoryStore | LocalStore, zarr_format: ZarrFormat +) -> None: + """ + Test the behavior of `Group.update_attributes_async` + """ + attrs = {"foo": 100} + group = Group.create(store, zarr_format=zarr_format, attributes=attrs) + assert group.attrs == attrs + new_attrs = {"bar": 100} + new_group = await group.update_attributes_async(new_attrs) + assert new_group.attrs == new_attrs + + +def test_group_create_array( + store: MemoryStore | LocalStore, zarr_format: ZarrFormat, exists_ok: bool +) -> None: + """ + Test `Group.create_array` + """ + group = Group.create(store, zarr_format=zarr_format) + shape = (10, 10) + dtype = "uint8" + data = np.arange(np.prod(shape)).reshape(shape).astype(dtype) + + array = group.create_array(name="array", shape=shape, dtype=dtype, data=data) + + if not exists_ok: + with pytest.raises(ContainsArrayError): + group.create_array(name="array", shape=shape, dtype=dtype, data=data) + + assert array.shape == shape + assert array.dtype == np.dtype(dtype) + assert np.array_equal(array[:], data) + + async def test_asyncgroup_create( store: MemoryStore | LocalStore, exists_ok: bool, @@ -238,7 +373,7 @@ async def test_asyncgroup_create( assert agroup.store_path == make_store_path(store) if not exists_ok: - with pytest.raises(AssertionError): + with pytest.raises(ContainsGroupError): agroup = await AsyncGroup.create( store, attributes=attributes, @@ -247,8 +382,6 @@ async def test_asyncgroup_create( ) -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) async def test_asyncgroup_attrs(store: LocalStore | MemoryStore, zarr_format: ZarrFormat) -> None: attributes = {"foo": 100} agroup = await AsyncGroup.create(store, zarr_format=zarr_format, attributes=attributes) @@ -256,8 +389,6 @@ async def test_asyncgroup_attrs(store: LocalStore | MemoryStore, zarr_format: Za assert agroup.attrs == agroup.metadata.attributes == attributes -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) async def test_asyncgroup_info(store: LocalStore | MemoryStore, zarr_format: ZarrFormat) -> None: agroup = await AsyncGroup.create( # noqa store, @@ -267,8 +398,6 @@ async def test_asyncgroup_info(store: LocalStore | MemoryStore, zarr_format: Zar # assert agroup.info == agroup.metadata.info -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) async def test_asyncgroup_open( store: LocalStore | MemoryStore, zarr_format: ZarrFormat, @@ -290,14 +419,12 @@ async def test_asyncgroup_open( assert group_w == group_r -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) async def test_asyncgroup_open_wrong_format( store: LocalStore | MemoryStore, zarr_format: ZarrFormat, ) -> None: _ = await AsyncGroup.create(store=store, exists_ok=False, zarr_format=zarr_format) - + zarr_format_wrong: ZarrFormat # try opening with the wrong zarr format if zarr_format == 3: zarr_format_wrong = 2 @@ -312,7 +439,6 @@ async def test_asyncgroup_open_wrong_format( # todo: replace the dict[str, Any] type with something a bit more specific # should this be async? -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) @pytest.mark.parametrize( "data", ( @@ -335,8 +461,6 @@ def test_asyncgroup_from_dict(store: MemoryStore | LocalStore, data: dict[str, A # todo: replace this with a declarative API where we model a full hierarchy -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) async def test_asyncgroup_getitem(store: LocalStore | MemoryStore, zarr_format: ZarrFormat) -> None: """ Create an `AsyncGroup`, then create members of that group, and ensure that we can access those @@ -359,11 +483,6 @@ async def test_asyncgroup_getitem(store: LocalStore | MemoryStore, zarr_format: await agroup.getitem("foo") -# todo: replace this with a declarative API where we model a full hierarchy - - -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) async def test_asyncgroup_delitem(store: LocalStore | MemoryStore, zarr_format: ZarrFormat) -> None: agroup = await AsyncGroup.create(store=store, zarr_format=zarr_format) sub_array_path = "sub_array" @@ -393,8 +512,6 @@ async def test_asyncgroup_delitem(store: LocalStore | MemoryStore, zarr_format: raise AssertionError -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) async def test_asyncgroup_create_group( store: LocalStore | MemoryStore, zarr_format: ZarrFormat, @@ -411,11 +528,8 @@ async def test_asyncgroup_create_group( assert subnode.metadata.zarr_format == zarr_format -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) async def test_asyncgroup_create_array( - store: LocalStore | MemoryStore, - zarr_format: ZarrFormat, + store: LocalStore | MemoryStore, zarr_format: ZarrFormat, exists_ok: bool ) -> None: """ Test that the AsyncGroup.create_array method works correctly. We ensure that array properties @@ -424,6 +538,10 @@ async def test_asyncgroup_create_array( agroup = await AsyncGroup.create(store=store, zarr_format=zarr_format) + if not exists_ok: + with pytest.raises(ContainsGroupError): + agroup = await AsyncGroup.create(store=store, zarr_format=zarr_format) + shape = (10,) dtype = "uint8" chunk_shape = (4,) @@ -449,8 +567,6 @@ async def test_asyncgroup_create_array( assert subnode.metadata.zarr_format == zarr_format -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) async def test_asyncgroup_update_attributes( store: LocalStore | MemoryStore, zarr_format: ZarrFormat ) -> None: @@ -465,30 +581,3 @@ async def test_asyncgroup_update_attributes( agroup_new_attributes = await agroup.update_attributes(attributes_new) assert agroup_new_attributes.attrs == attributes_new - - -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) -def test_group_init(store: LocalStore | MemoryStore, zarr_format: ZarrFormat) -> None: - agroup = sync(AsyncGroup.create(store=store, zarr_format=zarr_format)) - group = Group(agroup) - assert group._async_group == agroup - - -@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) -@pytest.mark.parametrize("zarr_format", (2, 3)) -def test_group_name_properties(store: LocalStore | MemoryStore, zarr_format: ZarrFormat) -> None: - root = Group.create(store=store, zarr_format=zarr_format) - assert root.path == "" - assert root.name == "/" - assert root.basename == "" - - foo = root.create_group("foo") - assert foo.path == "foo" - assert foo.name == "/foo" - assert foo.basename == "foo" - - bar = root.create_group("foo/bar") - assert bar.path == "foo/bar" - assert bar.name == "/foo/bar" - assert bar.basename == "bar" From 1eac855110b34637efabe8a62bfb17c702940f90 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 19 Jul 2024 18:50:18 +0200 Subject: [PATCH 08/14] add data kwarg to group.array --- src/zarr/group.py | 71 ++++++++++++++++++++++-------------------- tests/v3/test_group.py | 24 ++++++++++---- 2 files changed, 56 insertions(+), 39 deletions(-) diff --git a/src/zarr/group.py b/src/zarr/group.py index 5b14bc9e8..a91bcc32f 100644 --- a/src/zarr/group.py +++ b/src/zarr/group.py @@ -668,6 +668,7 @@ def create_group(self, name: str, **kwargs: Any) -> Group: def create_array( self, name: str, + *, shape: ChunkCoords, dtype: npt.DTypeLike = "float64", fill_value: Any | None = None, @@ -741,22 +742,22 @@ def create_array( return Array( self._sync( self._async_group.create_array( - name, - shape, - dtype, - fill_value, - attributes, - chunk_shape, - chunk_key_encoding, - codecs, - dimension_names, - chunks, - dimension_separator, - order, - filters, - compressor, - exists_ok, - data, + path=name, + shape=shape, + dtype=dtype, + fill_value=fill_value, + attributes=attributes, + chunk_shape=chunk_shape, + chunk_key_encoding=chunk_key_encoding, + codecs=codecs, + dimension_names=dimension_names, + chunks=chunks, + dimension_separator=dimension_separator, + order=order, + filters=filters, + compressor=compressor, + exists_ok=exists_ok, + data=data, ) ) ) @@ -792,6 +793,7 @@ def move(self, source: str, dest: str) -> None: def array( self, name: str, + *, shape: ChunkCoords, dtype: npt.DTypeLike = "float64", fill_value: Any | None = None, @@ -814,10 +816,11 @@ def array( compressor: dict[str, JSON] | None = None, # runtime exists_ok: bool = False, + data: npt.ArrayLike | None = None, ) -> Array: """ Create a zarr array within this AsyncGroup. - This method lightly wraps AsyncArray.create. + This method lightly wraps `AsyncArray.create`. Parameters ---------- @@ -854,7 +857,8 @@ def array( If True, a pre-existing array or group at the path of this array will be overwritten. If False, the presence of a pre-existing array or group is an error. - + data: npt.ArrayLike | None = None + Array data to initialize the array with. Returns ------- @@ -863,21 +867,22 @@ def array( return Array( self._sync( self._async_group.create_array( - name, - shape, - dtype, - fill_value, - attributes, - chunk_shape, - chunk_key_encoding, - codecs, - dimension_names, - chunks, - dimension_separator, - order, - filters, - compressor, - exists_ok, + path=name, + shape=shape, + dtype=dtype, + fill_value=fill_value, + attributes=attributes, + chunk_shape=chunk_shape, + chunk_key_encoding=chunk_key_encoding, + codecs=codecs, + dimension_names=dimension_names, + chunks=chunks, + dimension_separator=dimension_separator, + order=order, + filters=filters, + compressor=compressor, + exists_ok=exists_ok, + data=data, ) ) ) diff --git a/tests/v3/test_group.py b/tests/v3/test_group.py index 9018a331b..7bbde5597 100644 --- a/tests/v3/test_group.py +++ b/tests/v3/test_group.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any, Literal, cast import numpy as np import pytest @@ -331,8 +331,12 @@ async def test_group_update_attributes_async( assert new_group.attrs == new_attrs +@pytest.mark.parametrize("method", ["create_array", "array"]) def test_group_create_array( - store: MemoryStore | LocalStore, zarr_format: ZarrFormat, exists_ok: bool + store: MemoryStore | LocalStore, + zarr_format: ZarrFormat, + exists_ok: bool, + method: Literal["create_array", "array"], ) -> None: """ Test `Group.create_array` @@ -342,12 +346,20 @@ def test_group_create_array( dtype = "uint8" data = np.arange(np.prod(shape)).reshape(shape).astype(dtype) - array = group.create_array(name="array", shape=shape, dtype=dtype, data=data) + if method == "create_array": + array = group.create_array(name="array", shape=shape, dtype=dtype, data=data) + elif method == "array": + array = group.array(name="array", shape=shape, dtype=dtype, data=data) + else: + raise AssertionError if not exists_ok: - with pytest.raises(ContainsArrayError): - group.create_array(name="array", shape=shape, dtype=dtype, data=data) - + if method == "create_array": + with pytest.raises(ContainsArrayError): + group.create_array(name="array", shape=shape, dtype=dtype, data=data) + elif method == "array": + with pytest.raises(ContainsArrayError): + group.array(name="array", shape=shape, dtype=dtype, data=data) assert array.shape == shape assert array.dtype == np.dtype(dtype) assert np.array_equal(array[:], data) From cd700d4dbeb0302e92ca906d91cd37369fcb7c69 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 19 Jul 2024 23:10:14 +0200 Subject: [PATCH 09/14] add newlines to end of docstrings --- src/zarr/group.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/zarr/group.py b/src/zarr/group.py index a91bcc32f..1ca8a70d6 100644 --- a/src/zarr/group.py +++ b/src/zarr/group.py @@ -392,6 +392,7 @@ async def create_array( ------- AsyncArray + """ return await AsyncArray.create( self.store_path / path, @@ -738,6 +739,7 @@ def create_array( ------- Array + """ return Array( self._sync( @@ -863,6 +865,7 @@ def array( ------- Array + """ return Array( self._sync( From 2e9e8f047962924158f3fe5b099c0396892f660b Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 19 Jul 2024 23:14:50 +0200 Subject: [PATCH 10/14] docstrings --- src/zarr/group.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/zarr/group.py b/src/zarr/group.py index 1ca8a70d6..5400052be 100644 --- a/src/zarr/group.py +++ b/src/zarr/group.py @@ -349,12 +349,11 @@ async def create_array( data: npt.ArrayLike | None = None, ) -> AsyncArray: """ - Create a zarr array within this AsyncGroup. + Create a Zarr array within this AsyncGroup. This method lightly wraps AsyncArray.create. Parameters ---------- - path: str The name of the array. shape: tuple[int, ...] @@ -390,7 +389,6 @@ async def create_array( Returns ------- - AsyncArray """ @@ -700,7 +698,6 @@ def create_array( Parameters ---------- - name: str The name of the array. shape: tuple[int, ...] @@ -735,9 +732,9 @@ def create_array( an error. data: npt.ArrayLike | None = None Array data to initialize the array with. + Returns ------- - Array """ @@ -826,7 +823,6 @@ def array( Parameters ---------- - name: str The name of the array. shape: tuple[int, ...] @@ -861,6 +857,7 @@ def array( an error. data: npt.ArrayLike | None = None Array data to initialize the array with. + Returns ------- From 5ee1f35cff89985d74d83170b6c75434c09757f8 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 19 Jul 2024 23:36:54 +0200 Subject: [PATCH 11/14] put long type annotation on one line --- src/zarr/group.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/zarr/group.py b/src/zarr/group.py index 5400052be..a7408aaf6 100644 --- a/src/zarr/group.py +++ b/src/zarr/group.py @@ -362,10 +362,7 @@ async def create_array( The data type of the array. chunk_shape: tuple[int, ...] | None = None The shape of the chunks of the array. V3 only. - chunk_key_encoding: ChunkKeyEncoding - | tuple[Literal["default"], Literal[".", "/"]] - | tuple[Literal["v2"], Literal[".", "/"]] - | None = None + chunk_key_encoding: ChunkKeyEncoding | tuple[Literal["default"], Literal[".", "/"]] | tuple[Literal["v2"], Literal[".", "/"]] | None = None A specification of how the chunk keys are represented in storage. codecs: Iterable[Codec | dict[str, JSON]] | None = None An iterable of Codec or dict serializations thereof. The elements of @@ -706,14 +703,11 @@ def create_array( The data type of the array. chunk_shape: tuple[int, ...] | None = None The shape of the chunks of the array. V3 only. - chunk_key_encoding: ChunkKeyEncoding - | tuple[Literal["default"], Literal[".", "/"]] - | tuple[Literal["v2"], Literal[".", "/"]] - | None = None + chunk_key_encoding: ChunkKeyEncoding | tuple[Literal["default"], Literal[".", "/"]] | tuple[Literal["v2"], Literal[".", "/"]] | None = None A specification of how the chunk keys are represented in storage. codecs: Iterable[Codec | dict[str, JSON]] | None = None - An iterable of Codec or dict serializations thereof. The elements of - this collection specify the transformation from array values to stored bytes. + An iterable of Codec or dict serializations thereof. The elements of this collection + specify the transformation from array values to stored bytes. dimension_names: Iterable[str] | None = None The names of the dimensions of the array. V3 only. chunks: ChunkCoords | None = None @@ -831,10 +825,7 @@ def array( The data type of the array. chunk_shape: tuple[int, ...] | None = None The shape of the chunks of the array. V3 only. - chunk_key_encoding: ChunkKeyEncoding - | tuple[Literal["default"], Literal[".", "/"]] - | tuple[Literal["v2"], Literal[".", "/"]] - | None = None + chunk_key_encoding: ChunkKeyEncoding | tuple[Literal["default"], Literal[".", "/"]] | tuple[Literal["v2"], Literal[".", "/"]] | None = None A specification of how the chunk keys are represented in storage. codecs: Iterable[Codec | dict[str, JSON]] | None = None An iterable of Codec or dict serializations thereof. The elements of From 5abaab6b1f3f4409d40e14d31803ac0f41eaa2ab Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Mon, 22 Jul 2024 16:50:10 +0200 Subject: [PATCH 12/14] port guess chunks to v3 --- src/zarr/array.py | 5 +-- src/zarr/chunk_grids.py | 72 ++++++++++++++++++++++++++++++++++++ tests/v3/test_chunk_grids.py | 18 +++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 tests/v3/test_chunk_grids.py diff --git a/src/zarr/array.py b/src/zarr/array.py index 6c6a79b0e..717d857a0 100644 --- a/src/zarr/array.py +++ b/src/zarr/array.py @@ -21,7 +21,7 @@ from zarr.abc.store import set_or_delete from zarr.attributes import Attributes from zarr.buffer import BufferPrototype, NDArrayLike, NDBuffer, default_buffer_prototype -from zarr.chunk_grids import RegularChunkGrid +from zarr.chunk_grids import RegularChunkGrid, _guess_chunks from zarr.chunk_key_encodings import ChunkKeyEncoding, DefaultChunkKeyEncoding, V2ChunkKeyEncoding from zarr.codecs import BytesCodec from zarr.codecs._v2 import V2Compressor, V2Filters @@ -65,7 +65,6 @@ from zarr.store import StoreLike, StorePath, make_store_path from zarr.store.core import contains_array from zarr.sync import sync -from zarr.v2.util import guess_chunks def parse_array_metadata(data: Any) -> ArrayV2Metadata | ArrayV3Metadata: @@ -146,7 +145,7 @@ async def create( if chunk_shape is None: if chunks is None: - chunk_shape = chunks = guess_chunks(shape=shape, typesize=np.dtype(dtype).itemsize) + chunk_shape = chunks = _guess_chunks(shape=shape, typesize=np.dtype(dtype).itemsize) chunk_shape = chunks elif chunks is not None: raise ValueError("Only one of chunk_shape or chunks must be provided.") diff --git a/src/zarr/chunk_grids.py b/src/zarr/chunk_grids.py index 941f79984..a92c894e7 100644 --- a/src/zarr/chunk_grids.py +++ b/src/zarr/chunk_grids.py @@ -1,6 +1,7 @@ from __future__ import annotations import itertools +import math import operator from abc import abstractmethod from collections.abc import Iterator @@ -8,6 +9,8 @@ from functools import reduce from typing import TYPE_CHECKING +import numpy as np + from zarr.abc.metadata import Metadata from zarr.common import ( JSON, @@ -22,6 +25,75 @@ from typing_extensions import Self +def _guess_chunks( + shape: ChunkCoords, + typesize: int, + *, + increment_bytes: int = 256 * 1024, + min_bytes: int = 128 * 1024, + max_bytes: int = 64 * 1024 * 1024, +) -> ChunkCoords: + """ + Iteratively guess an appropriate chunk layout for an array, given its shape and + the size of each element in bytes, and size constraints expressed in bytes. This logic is + adapted from h5py. + + Parameters + ---------- + shape: ChunkCoords + The chunk shape. + typesize: int + The size, in bytes, of each element of the chunk. + increment_bytes: int = 256 * 1024 + The number of bytes used to increment or decrement the target chunk size in bytes. + min_bytes: int = 128 * 1024 + The soft lower bound on the final chunk size in bytes. + max_bytes: int = 64 * 1024 * 1024 + The hard upper bound on the final chunk size in bytes. + + Returns + ------- + ChunkCoords + + """ + + ndims = len(shape) + # require chunks to have non-zero length for all dimensions + chunks = np.maximum(np.array(shape, dtype="=f8"), 1) + + # Determine the optimal chunk size in bytes using a PyTables expression. + # This is kept as a float. + dset_size = np.prod(chunks) * typesize + target_size = increment_bytes * (2 ** np.log10(dset_size / (1024.0 * 1024))) + + if target_size > max_bytes: + target_size = max_bytes + elif target_size < min_bytes: + target_size = min_bytes + + idx = 0 + while True: + # Repeatedly loop over the axes, dividing them by 2. Stop when: + # 1a. We're smaller than the target chunk size, OR + # 1b. We're within 50% of the target chunk size, AND + # 2. The chunk is smaller than the maximum chunk size + + chunk_bytes = np.prod(chunks) * typesize + + if ( + chunk_bytes < target_size or abs(chunk_bytes - target_size) / target_size < 0.5 + ) and chunk_bytes < max_bytes: + break + + if np.prod(chunks) == 1: + break # Element size larger than max_bytes + + chunks[idx % ndims] = math.ceil(chunks[idx % ndims] / 2.0) + idx += 1 + + return tuple(int(x) for x in chunks) + + @dataclass(frozen=True) class ChunkGrid(Metadata): @classmethod diff --git a/tests/v3/test_chunk_grids.py b/tests/v3/test_chunk_grids.py new file mode 100644 index 000000000..3cc6b64e5 --- /dev/null +++ b/tests/v3/test_chunk_grids.py @@ -0,0 +1,18 @@ +import numpy as np +import pytest + +from zarr.chunk_grids import _guess_chunks + + +@pytest.mark.parametrize( + "shape", ((0,), (0,) * 2, (1, 2, 0, 4, 5), (10, 0), (10,), (100,) * 3, (1000000,), (10000,) * 2) +) +@pytest.mark.parametrize("itemsize", (1, 2, 4)) +def test_guess_chunks(shape: tuple[int, ...], itemsize: int) -> None: + chunks = _guess_chunks(shape, itemsize) + chunk_size = np.prod(chunks) * itemsize + assert isinstance(chunks, tuple) + assert len(chunks) == len(shape) + assert chunk_size < (64 * 1024 * 1024) + # doesn't make any sense to allow chunks to have zero length dimension + assert all(0 < c <= max(s, 1) for c, s in zip(chunks, shape, strict=False)) From 460bfe2a07689f32f7b587c5c8281ddd69c4a512 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Mon, 22 Jul 2024 20:36:16 +0200 Subject: [PATCH 13/14] harden semantics for existing arrays / groups --- src/zarr/array.py | 12 ++--- src/zarr/errors.py | 9 ++++ src/zarr/group.py | 6 +-- src/zarr/store/core.py | 116 ++++++++++++++++++++++++++++++++++++++--- tests/v3/test_array.py | 53 +++++++++++++++++++ tests/v3/test_group.py | 62 +++++++++++++++++++++- 6 files changed, 240 insertions(+), 18 deletions(-) diff --git a/src/zarr/array.py b/src/zarr/array.py index 717d857a0..e366321b1 100644 --- a/src/zarr/array.py +++ b/src/zarr/array.py @@ -37,7 +37,6 @@ product, ) from zarr.config import config, parse_indexing_order -from zarr.errors import ContainsArrayError from zarr.indexing import ( BasicIndexer, BasicSelection, @@ -63,7 +62,9 @@ ) from zarr.metadata import ArrayMetadata, ArrayV2Metadata, ArrayV3Metadata from zarr.store import StoreLike, StorePath, make_store_path -from zarr.store.core import contains_array +from zarr.store.core import ( + ensure_no_existing_node, +) from zarr.sync import sync @@ -233,8 +234,7 @@ async def _create_v3( exists_ok: bool = False, ) -> AsyncArray: if not exists_ok: - if await contains_array(store_path=store_path, zarr_format=3): - raise ContainsArrayError(store_path.store, store_path.path) + await ensure_no_existing_node(store_path, zarr_format=3) codecs = list(codecs) if codecs is not None else [BytesCodec()] @@ -290,9 +290,7 @@ async def _create_v2( import numcodecs if not exists_ok: - if await contains_array(store_path=store_path, zarr_format=2): - raise ContainsArrayError(store_path.store, store_path.path) - + await ensure_no_existing_node(store_path, zarr_format=2) if order is None: order = "C" diff --git a/src/zarr/errors.py b/src/zarr/errors.py index 867e9976e..140229b2e 100644 --- a/src/zarr/errors.py +++ b/src/zarr/errors.py @@ -14,3 +14,12 @@ class ContainsGroupError(_BaseZarrError): class ContainsArrayError(_BaseZarrError): _msg = "An array exists in store {0!r} at path {1!r}." + + +class ContainsArrayAndGroupError(_BaseZarrError): + _msg = ( + "Array and group metadata documents (.zarray and .zgroup) were both found in store " + "{0!r} at path {1!r}." + "Only one of these files may be present in a given directory / prefix. " + "Remove the .zarray file, or the .zgroup file, or both." + ) diff --git a/src/zarr/group.py b/src/zarr/group.py index 4bd1d1238..5f52e8b4a 100644 --- a/src/zarr/group.py +++ b/src/zarr/group.py @@ -27,9 +27,8 @@ ZarrFormat, ) from zarr.config import config -from zarr.errors import ContainsGroupError from zarr.store import StoreLike, StorePath, make_store_path -from zarr.store.core import contains_group +from zarr.store.core import ensure_no_existing_node from zarr.sync import SyncMixin, sync if TYPE_CHECKING: @@ -132,8 +131,7 @@ async def create( ) -> AsyncGroup: store_path = make_store_path(store) if not exists_ok: - if await contains_group(store_path=store_path, zarr_format=zarr_format): - raise ContainsGroupError(store_path.store, store_path.path) + await ensure_no_existing_node(store_path, zarr_format=zarr_format) group = cls( metadata=GroupMetadata(attributes=attributes, zarr_format=zarr_format), store_path=store_path, diff --git a/src/zarr/store/core.py b/src/zarr/store/core.py index 86c0038e3..d98793b84 100644 --- a/src/zarr/store/core.py +++ b/src/zarr/store/core.py @@ -2,11 +2,12 @@ import json from pathlib import Path -from typing import Any +from typing import Any, Literal from zarr.abc.store import Store from zarr.buffer import Buffer, BufferPrototype, default_buffer_prototype from zarr.common import ZARR_JSON, ZARRAY_JSON, ZGROUP_JSON, OpenMode, ZarrFormat +from zarr.errors import ContainsArrayAndGroupError, ContainsArrayError, ContainsGroupError from zarr.store.local import LocalStore from zarr.store.memory import MemoryStore @@ -87,13 +88,106 @@ def make_store_path(store_like: StoreLike | None, *, mode: OpenMode | None = Non raise TypeError +async def ensure_no_existing_node(store_path: StorePath, zarr_format: ZarrFormat) -> None: + """ + Check if a store_path is safe for array / group creation. + Returns `None` or raises an exception. + + Parameters + ---------- + store_path: StorePath + The storage location to check. + zarr_format: ZarrFormat + The Zarr format to check. + + Raises + ------ + ContainsArrayError, ContainsGroupError, InvalidHierarchyError + """ + if zarr_format == 2: + extant_node = await _contains_node_v2(store_path) + elif zarr_format == 3: + extant_node = await _contains_node_v3(store_path) + + if extant_node == "array": + raise ContainsArrayError(store_path.store, store_path.path) + elif extant_node == "group": + raise ContainsGroupError(store_path.store, store_path.path) + elif extant_node == "nothing": + return + msg = f"Invalid value for extant_node: {extant_node}" # type: ignore[unreachable] + raise ValueError(msg) + + +async def _contains_node_v3(store_path: StorePath) -> Literal["array", "group", "nothing"]: + """ + Check if a store_path contains nothing, an array, or a group. This function + returns the string "array", "group", or "nothing" to denote containing an array, a group, or + nothing. + + Parameters + ---------- + store_path: StorePath + The location in storage to check. + + Returns + ------- + Literal["array", "group", "nothing"] + A string representing the zarr node found at store_path. + """ + result: Literal["array", "group", "nothing"] = "nothing" + extant_meta_bytes = await (store_path / ZARR_JSON).get() + # if no metadata document could be loaded, then we just return "nothing" + if extant_meta_bytes is not None: + try: + extant_meta_json = json.loads(extant_meta_bytes.to_bytes()) + # avoid constructing a full metadata document here in the name of speed. + if extant_meta_json["node_type"] == "array": + result = "array" + elif extant_meta_json["node_type"] == "group": + result = "group" + except (KeyError, json.JSONDecodeError): + # either of these errors is consistent with no array or group present. + pass + return result + + +async def _contains_node_v2(store_path: StorePath) -> Literal["array", "group", "nothing"]: + """ + Check if a store_path contains nothing, an array, a group, or both. If both an array and a + group are detected, a `ContainsArrayAndGroup` exception is raised. Otherwise, this function + returns the string "array", "group", or "nothing" to denote containing an array, a group, or + nothing. + + Parameters + ---------- + store_path: StorePath + The location in storage to check. + + Returns + ------- + Literal["array", "group", "nothing"] + A string representing the zarr node found at store_path. + """ + _array = await contains_array(store_path=store_path, zarr_format=2) + _group = await contains_group(store_path=store_path, zarr_format=2) + + if _array and _group: + raise ContainsArrayAndGroupError(store_path.store, store_path.path) + elif _array: + return "array" + elif _group: + return "group" + else: + return "nothing" + + async def contains_array(store_path: StorePath, zarr_format: ZarrFormat) -> bool: """ Check if an array exists at a given StorePath. Parameters ---------- - store_path: StorePath The StorePath to check for an existing group. zarr_format: @@ -101,9 +195,8 @@ async def contains_array(store_path: StorePath, zarr_format: ZarrFormat) -> bool Returns ------- - bool - True if the StorePath contains a group, False otherwise + True if the StorePath contains a group, False otherwise. """ if zarr_format == 3: @@ -119,7 +212,8 @@ async def contains_array(store_path: StorePath, zarr_format: ZarrFormat) -> bool except (ValueError, KeyError): return False elif zarr_format == 2: - return await (store_path / ZARRAY_JSON).exists() + result = await (store_path / ZARRAY_JSON).exists() + return result msg = f"Invalid zarr_format provided. Got {zarr_format}, expected 2 or 3" raise ValueError(msg) @@ -144,7 +238,17 @@ async def contains_group(store_path: StorePath, zarr_format: ZarrFormat) -> bool """ if zarr_format == 3: - return await (store_path / ZARR_JSON).exists() + extant_meta_bytes = await (store_path / ZARR_JSON).get() + if extant_meta_bytes is None: + return False + else: + try: + extant_meta_json = json.loads(extant_meta_bytes.to_bytes()) + # we avoid constructing a full metadata document here in the name of speed. + result: bool = extant_meta_json["node_type"] == "group" + return result + except (ValueError, KeyError): + return False elif zarr_format == 2: return await (store_path / ZGROUP_JSON).exists() msg = f"Invalid zarr_format provided. Got {zarr_format}, expected 2 or 3" # type: ignore[unreachable] diff --git a/tests/v3/test_array.py b/tests/v3/test_array.py index 08678f598..9fd135ad5 100644 --- a/tests/v3/test_array.py +++ b/tests/v3/test_array.py @@ -1,10 +1,63 @@ +from typing import Literal + import numpy as np import pytest from zarr.array import Array from zarr.common import ZarrFormat +from zarr.errors import ContainsArrayError, ContainsGroupError from zarr.group import Group from zarr.store import LocalStore, MemoryStore +from zarr.store.core import StorePath + + +@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) +@pytest.mark.parametrize("zarr_format", (2, 3)) +@pytest.mark.parametrize("exists_ok", [True, False]) +@pytest.mark.parametrize("extant_node", ["array", "group"]) +def test_array_creation_existing_node( + store: LocalStore | MemoryStore, + zarr_format: ZarrFormat, + exists_ok: bool, + extant_node: Literal["array", "group"], +) -> None: + """ + Check that an existing array or group is handled as expected during array creation. + """ + spath = StorePath(store) + group = Group.create(spath, zarr_format=zarr_format) + expected_exception: type[ContainsArrayError] | type[ContainsGroupError] + if extant_node == "array": + expected_exception = ContainsArrayError + _ = group.create_array("extant", shape=(10,), dtype="uint8") + elif extant_node == "group": + expected_exception = ContainsGroupError + _ = group.create_group("extant") + else: + raise AssertionError + + new_shape = (2, 2) + new_dtype = "float32" + + if exists_ok: + arr_new = Array.create( + spath / "extant", + shape=new_shape, + dtype=new_dtype, + exists_ok=exists_ok, + zarr_format=zarr_format, + ) + assert arr_new.shape == new_shape + assert arr_new.dtype == new_dtype + else: + with pytest.raises(expected_exception): + arr_new = Array.create( + spath / "extant", + shape=new_shape, + dtype=new_dtype, + exists_ok=exists_ok, + zarr_format=zarr_format, + ) @pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) diff --git a/tests/v3/test_group.py b/tests/v3/test_group.py index 7bbde5597..f942eb603 100644 --- a/tests/v3/test_group.py +++ b/tests/v3/test_group.py @@ -365,6 +365,53 @@ def test_group_create_array( assert np.array_equal(array[:], data) +@pytest.mark.parametrize("store", ("local", "memory"), indirect=["store"]) +@pytest.mark.parametrize("zarr_format", (2, 3)) +@pytest.mark.parametrize("exists_ok", [True, False]) +@pytest.mark.parametrize("extant_node", ["array", "group"]) +def test_group_creation_existing_node( + store: LocalStore | MemoryStore, + zarr_format: ZarrFormat, + exists_ok: bool, + extant_node: Literal["array", "group"], +) -> None: + """ + Check that an existing array or group is handled as expected during group creation. + """ + spath = StorePath(store) + group = Group.create(spath, zarr_format=zarr_format) + expected_exception: type[ContainsArrayError] | type[ContainsGroupError] + attributes = {"old": True} + + if extant_node == "array": + expected_exception = ContainsArrayError + _ = group.create_array("extant", shape=(10,), dtype="uint8", attributes=attributes) + elif extant_node == "group": + expected_exception = ContainsGroupError + _ = group.create_group("extant", attributes=attributes) + else: + raise AssertionError + + new_attributes = {"new": True} + + if exists_ok: + node_new = Group.create( + spath / "extant", + attributes=new_attributes, + zarr_format=zarr_format, + exists_ok=exists_ok, + ) + assert node_new.attrs == new_attributes + else: + with pytest.raises(expected_exception): + node_new = Group.create( + spath / "extant", + attributes=new_attributes, + zarr_format=zarr_format, + exists_ok=exists_ok, + ) + + async def test_asyncgroup_create( store: MemoryStore | LocalStore, exists_ok: bool, @@ -373,6 +420,7 @@ async def test_asyncgroup_create( """ Test that `AsyncGroup.create` works as expected. """ + spath = StorePath(store=store) attributes = {"foo": 100} agroup = await AsyncGroup.create( store, @@ -387,7 +435,19 @@ async def test_asyncgroup_create( if not exists_ok: with pytest.raises(ContainsGroupError): agroup = await AsyncGroup.create( - store, + spath, + attributes=attributes, + exists_ok=exists_ok, + zarr_format=zarr_format, + ) + # create an array at our target path + collision_name = "foo" + _ = await AsyncArray.create( + spath / collision_name, shape=(10,), dtype="uint8", zarr_format=zarr_format + ) + with pytest.raises(ContainsArrayError): + _ = await AsyncGroup.create( + StorePath(store=store) / collision_name, attributes=attributes, exists_ok=exists_ok, zarr_format=zarr_format, From d6d67ba32b2a523b9abcf2898ad18fb389049f62 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Mon, 22 Jul 2024 20:51:18 +0200 Subject: [PATCH 14/14] fix exception name in docs --- src/zarr/store/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zarr/store/core.py b/src/zarr/store/core.py index d98793b84..85f85aabd 100644 --- a/src/zarr/store/core.py +++ b/src/zarr/store/core.py @@ -102,7 +102,7 @@ async def ensure_no_existing_node(store_path: StorePath, zarr_format: ZarrFormat Raises ------ - ContainsArrayError, ContainsGroupError, InvalidHierarchyError + ContainsArrayError, ContainsGroupError, ContainsArrayAndGroupError """ if zarr_format == 2: extant_node = await _contains_node_v2(store_path)