diff --git a/changes/4455.misc.md b/changes/4455.misc.md new file mode 100644 index 0000000000..362c96bb3f --- /dev/null +++ b/changes/4455.misc.md @@ -0,0 +1 @@ +`zarr.create` and `zarr.api.asynchronous.create` now list `mode` and `data` in their signatures and docstrings. Both were accepted before without being declared. An unknown keyword now raises a `TypeError` from `create` before anything in the store is touched. Previously, with `mode="w"`, the existing data was deleted before the error was raised. `mode` is documented as a legacy spelling; `overwrite` is preferred. diff --git a/src/zarr/api/asynchronous.py b/src/zarr/api/asynchronous.py index 8371fb0663..bef4d5c78f 100644 --- a/src/zarr/api/asynchronous.py +++ b/src/zarr/api/asynchronous.py @@ -922,7 +922,8 @@ async def create( dimension_names: DimensionNamesLike = None, storage_options: dict[str, Any] | None = None, config: ArrayConfigLike | None = None, - **kwargs: Any, + mode: AccessModeLiteral | None = None, + data: npt.ArrayLike | None = None, ) -> AnyAsyncArray: """Create an array. @@ -1042,6 +1043,26 @@ async def create( config : ArrayConfigLike, optional Runtime configuration of the array. If provided, will override the default values from `zarr.config.array`. + mode : {'r', 'r+', 'a', 'w', 'w-'}, optional + Legacy way to control overwriting, kept for compatibility with Zarr-Python 2. + Prefer `overwrite`. The access mode used to open `store`; the default, `None`, + is `'a'`. + + - `'a'` and `'r+'` create the array and fail if a node exists at `path`, + unless `overwrite` is `True`. + - `'w'` replaces anything stored under `path` (the whole store if `path` is + not set), even if `overwrite` is `False`. On a store that cannot delete + keys, `'w'` raises an error instead of replacing an existing node. + - `'w-'` fails if anything is stored under `path`, even if `overwrite` is + `True`. + - `'r'` always fails. + + `mode` has no effect, and is not validated, if `store` is a `StorePath`. + data : array-like, optional + Values written into the new array after it is created. Unlike the `data` + parameter of `create_array`, it does not set the shape or data type of the + array. To create an array from existing data, use `create_array(data=...)`. + A Zarr array is not supported as `data`. Returns ------- @@ -1066,7 +1087,6 @@ async def create( if write_empty_chunks is not None: _warn_write_empty_chunks_kwarg() - mode = kwargs.pop("mode", None) if mode is None: mode = "a" store_path = await make_store_path(store, path=path, mode=mode, storage_options=storage_options) @@ -1102,7 +1122,7 @@ async def create( dimension_names=dimension_names, attributes=attributes, config=config_parsed, - **kwargs, + data=data, ) diff --git a/src/zarr/api/synchronous.py b/src/zarr/api/synchronous.py index 13bdf2a651..6b387dafa5 100644 --- a/src/zarr/api/synchronous.py +++ b/src/zarr/api/synchronous.py @@ -630,7 +630,6 @@ def create_group( ) -# TODO: add type annotations for kwargs def create( shape: tuple[int, ...] | int, *, # Note: this is a change from v2 @@ -666,7 +665,8 @@ def create( dimension_names: DimensionNamesLike = None, storage_options: dict[str, Any] | None = None, config: ArrayConfigLike | None = None, - **kwargs: Any, + mode: AccessModeLiteral | None = None, + data: npt.ArrayLike | None = None, ) -> AnyArray: """Create an array. @@ -786,6 +786,26 @@ def create( config : ArrayConfigLike, optional Runtime configuration of the array. If provided, will override the default values from `zarr.config.array`. + mode : {'r', 'r+', 'a', 'w', 'w-'}, optional + Legacy way to control overwriting, kept for compatibility with Zarr-Python 2. + Prefer `overwrite`. The access mode used to open `store`; the default, `None`, + is `'a'`. + + - `'a'` and `'r+'` create the array and fail if a node exists at `path`, + unless `overwrite` is `True`. + - `'w'` replaces anything stored under `path` (the whole store if `path` is + not set), even if `overwrite` is `False`. On a store that cannot delete + keys, `'w'` raises an error instead of replacing an existing node. + - `'w-'` fails if anything is stored under `path`, even if `overwrite` is + `True`. + - `'r'` always fails. + + `mode` has no effect, and is not validated, if `store` is a `StorePath`. + data : array-like, optional + Values written into the new array after it is created. Unlike the `data` + parameter of `create_array`, it does not set the shape or data type of the + array. To create an array from existing data, use `create_array(data=...)`. + A Zarr array is not supported as `data`. Returns ------- @@ -822,7 +842,8 @@ def create( dimension_names=dimension_names, storage_options=storage_options, config=config, - **kwargs, + mode=mode, + data=data, ) ) ) diff --git a/tests/test_api.py b/tests/test_api.py index 16dc5bbffb..0c0ecff77f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,8 +14,10 @@ from collections.abc import Callable from pathlib import Path + import numpy.typing as npt + from zarr.abc.store import Store - from zarr.core.common import JSON, MemoryOrder, ZarrFormat + from zarr.core.common import JSON, AccessModeLiteral, MemoryOrder, ZarrFormat from zarr.types import AnyArray import contextlib @@ -43,9 +45,11 @@ save_array, save_group, ) -from zarr.core.buffer import NDArrayLike +from zarr.core.buffer import NDArrayLike, default_buffer_prototype +from zarr.core.sync import sync from zarr.errors import ( ArrayNotFoundError, + ContainsArrayError, MetadataValidationError, ZarrDeprecationWarning, ZarrUserWarning, @@ -84,6 +88,117 @@ def test_create(memory_store: Store) -> None: z = create(shape=(400, 100), chunks=(16, 16.5), store=store, overwrite=True) # type: ignore[arg-type] +def _create_async(**kwargs: Any) -> AnyArray: + return Array(sync(zarr.api.asynchronous.create(**kwargs))) + + +CREATE_FUNCS = pytest.mark.parametrize( + "create_func", [create, _create_async], ids=["sync", "async"] +) + + +def _write_existing_array(store: MemoryStore) -> None: + """ + Store an array with written chunks, plus a key that belongs to no Zarr node. + """ + old = create_array(store, shape=(4,), chunks=(2,), dtype="int8") + old[:] = [1, 2, 3, 4] + sync(store.set("junk", default_buffer_prototype().buffer.from_bytes(b"junk"))) + + +@CREATE_FUNCS +@pytest.mark.parametrize( + ("mode", "overwrite", "existing"), + [ + (None, False, False), + ("a", False, False), + ("r+", False, False), + ("w", False, False), + ("w-", False, False), + ("a", True, True), + ("r+", True, True), + ("w", False, True), + ("w", True, True), + ], + ids=lambda v: repr(v), +) +@pytest.mark.parametrize("data", [None, np.arange(3, dtype="int8")], ids=["no-data", "int8-data"]) +def test_create_mode_data( + create_func: Callable[..., AnyArray], + mode: AccessModeLiteral | None, + overwrite: bool, + existing: bool, + data: npt.NDArray[Any] | None, +) -> None: + """ + `create` opens the store with `mode`, replaces what is stored under the path when + `mode` or `overwrite` says so, and writes `data` without taking its data type. + """ + store = MemoryStore() + if existing: + _write_existing_array(store) + reference = MemoryStore() + create(shape=(3,), chunks=(2,), store=reference, data=data) + + z = create_func(shape=(3,), chunks=(2,), store=store, mode=mode, overwrite=overwrite, data=data) + + assert sorted(store._store_dict) == sorted(reference._store_dict) + assert z.dtype == np.dtype("float64") + assert_array_equal(z[:], np.zeros(3) if data is None else data) + + +@pytest.mark.parametrize("mode", [None, "a", "r+"]) +def test_create_existing_array_without_overwrite(mode: AccessModeLiteral | None) -> None: + """ + `create` refuses to replace an existing array unless `mode` or `overwrite` allows it. + """ + store = MemoryStore() + _write_existing_array(store) + with pytest.raises(ContainsArrayError): + create(shape=(3,), store=store, mode=mode) + + +@pytest.mark.parametrize("overwrite", [False, True]) +def test_create_mode_w_minus_existing_data(overwrite: bool) -> None: + """ + `create` with mode `'w-'` refuses to write where data is stored, whatever `overwrite` says. + """ + store = MemoryStore() + _write_existing_array(store) + with pytest.raises(FileExistsError, match="mode 'w-'"): + create(shape=(3,), store=store, mode="w-", overwrite=overwrite) + + +def test_create_mode_r() -> None: + """ + `create` with mode `'r'` opens the store read-only, so creation fails. + """ + with pytest.raises(ValueError, match="read-only"): + create(shape=(3,), store=MemoryStore(), mode="r") + + +def test_create_invalid_mode() -> None: + """ + `create` rejects a mode that is not an access mode. + """ + with pytest.raises(ValueError, match="Invalid mode: x"): + create(shape=(3,), store=MemoryStore(), mode="x") # type: ignore[arg-type] + + +@CREATE_FUNCS +def test_create_unknown_keyword(create_func: Callable[..., AnyArray]) -> None: + """ + `create` rejects keyword arguments it does not declare, naming itself in the error, + before `mode="w"` deletes anything. + """ + store = MemoryStore() + _write_existing_array(store) + before = dict(store._store_dict) + with pytest.raises(TypeError, match=r"^create\(\) got an unexpected keyword argument 'typo'"): + create_func(shape=(3,), store=store, mode="w", typo=1) + assert store._store_dict == before + + @pytest.mark.parametrize( "func", [