From e3c5c9fea6ec1fe3b4bb833992c7fd05d7dae682 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 25 Sep 2026 10:10:13 +0200 Subject: [PATCH 1/4] fix(group): apply config when require_array returns an existing array require_array forwarded **kwargs, including config, only to create_array in the branch that creates a missing array. Requiring an existing array dropped config and returned it with the global defaults. Apply it with with_config(), as open_array does since #4408. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/zarr/core/group.py | 7 ++++- tests/test_group.py | 67 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index d734e6b7cd..54e4154758 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -1275,8 +1275,13 @@ async def require_array( if not np.can_cast(ds.dtype, dtype): raise TypeError(f"Incompatible dtype ({ds.dtype} vs {dtype})") except KeyError: - ds = await self.create_array(name, shape=shape, dtype=dtype, **kwargs) + return await self.create_array(name, shape=shape, dtype=dtype, **kwargs) + # `config` is the runtime configuration of the returned array, not stored metadata, + # so it applies to an existing array too. + config = kwargs.get("config") + if config is not None: + return ds.with_config(config) return ds async def update_attributes(self, new_attributes: dict[str, Any]) -> AsyncGroup: diff --git a/tests/test_group.py b/tests/test_group.py index 31fbd138cd..e47da5c1a2 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -22,6 +22,7 @@ from zarr.abc.store import Store from zarr.core import sync_group from zarr.core._info import GroupInfo +from zarr.core.array_spec import ArrayConfig from zarr.core.buffer import default_buffer_prototype from zarr.core.config import config as zarr_config from zarr.core.dtype import Float64, Int32 @@ -62,6 +63,7 @@ from collections.abc import Callable from zarr.core.array import ShardsLike + from zarr.core.array_spec import ArrayConfigLike from zarr.core.buffer.core import Buffer from zarr.core.common import JSON, ChunksLike, ZarrFormat from zarr.core.dtype import ZDType, ZDTypeLike @@ -1467,6 +1469,71 @@ async def test_require_array(store: Store, zarr_format: ZarrFormat) -> None: await root.require_array("bar", shape=(10,), dtype="int8") +@pytest.mark.parametrize("zarr_format", [2, 3]) +@pytest.mark.parametrize("api", ["sync", "async"]) +@pytest.mark.parametrize("exists", [True, False]) +@pytest.mark.parametrize( + ("config", "expected"), + [ + (None, ArrayConfig.from_dict({})), + ( + {"read_missing_chunks": False}, + ArrayConfig.from_dict({"read_missing_chunks": False}), + ), + ( + {"order": "F", "write_empty_chunks": True}, + ArrayConfig.from_dict({"order": "F", "write_empty_chunks": True}), + ), + ( + ArrayConfig.from_dict({"read_missing_chunks": False}), + ArrayConfig.from_dict({"read_missing_chunks": False}), + ), + ], +) +async def test_require_array_config( + zarr_format: ZarrFormat, + api: Literal["sync", "async"], + exists: bool, + config: ArrayConfigLike | None, + expected: ArrayConfig, +) -> None: + """ + require_array applies the config argument whether it creates the array or returns an + existing one, with keys missing from a partial config taken from the global defaults. + """ + group = Group.from_store(MemoryStore(), zarr_format=zarr_format) + if exists: + group.create_array("a", shape=(4,), dtype="uint8") + kwargs: dict[str, Any] = {} if config is None else {"config": config} + if api == "sync": + observed = group.require_array("a", shape=(4,), dtype="uint8", **kwargs).config + else: + observed = ( + await group._async_group.require_array("a", shape=(4,), dtype="uint8", **kwargs) + ).config + assert observed == expected + + +def test_require_array_existing_unknown_config_key() -> None: + """ + Requiring an existing array with a config containing an unknown key raises TypeError. + """ + group = Group.from_store(MemoryStore()) + group.create_array("a", shape=(4,), dtype="uint8") + with pytest.raises(TypeError, match="unexpected keyword argument 'nope'"): + group.require_array("a", shape=(4,), dtype="uint8", config={"nope": 1}) + + +def test_require_array_existing_invalid_config_value() -> None: + """ + Requiring an existing array with a config containing an invalid value raises ValueError. + """ + group = Group.from_store(MemoryStore()) + group.create_array("a", shape=(4,), dtype="uint8") + with pytest.raises(ValueError, match="Expected instance of bool"): + group.require_array("a", shape=(4,), dtype="uint8", config={"read_missing_chunks": "yes"}) + + @pytest.mark.parametrize( ("dtype", "expected"), [ From 2ca812069244c911059ac632a0dacda15a859db3 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 25 Sep 2026 10:10:26 +0200 Subject: [PATCH 2/4] docs(changes): add fragment for #4413 Assisted-by: ClaudeCode:claude-opus-5-5 --- changes/4413.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/4413.bugfix.md diff --git a/changes/4413.bugfix.md b/changes/4413.bugfix.md new file mode 100644 index 0000000000..9a0650d1c1 --- /dev/null +++ b/changes/4413.bugfix.md @@ -0,0 +1 @@ +`Group.require_array` now applies the `config` argument when it returns an existing array. Previously `config` was only used when `require_array` created a new array, so an existing array was returned with the global defaults. An invalid `config` now raises instead of being ignored. From da01fc4117a8055bedc7ac72cdce9abc7b409947 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 25 Sep 2026 11:22:50 +0200 Subject: [PATCH 3/4] refactor(group): make config a named parameter of require_array config was read from **kwargs, which is how the existing-array branch came to drop it. It is now a named parameter of Group.require_array and AsyncGroup.require_array, passed explicitly to create_array for a new array and applied with with_config() to an existing one. The existing array comes from getitem, which serves member lookup in general (including consolidated metadata) and takes no runtime config. Assisted-by: ClaudeCode:claude-opus-5-5 --- src/zarr/core/group.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index 54e4154758..39e7caf47b 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -1231,6 +1231,7 @@ async def require_array( shape: ShapeLike, dtype: ZDTypeLike | None = None, exact: bool = False, + config: ArrayConfigLike | None = None, **kwargs: Any, ) -> AnyAsyncArray: """Obtain an array, creating if it doesn't exist. @@ -1249,6 +1250,9 @@ async def require_array( exact : bool, optional If True, require `dtype` to match exactly. If false, require `dtype` can be cast from array dtype. + config : ArrayConfigLike or None, default=None + Runtime configuration for the array, whether it is created or already exists. + Keys not specified are taken from the global configuration. Returns ------- @@ -1275,11 +1279,10 @@ async def require_array( if not np.can_cast(ds.dtype, dtype): raise TypeError(f"Incompatible dtype ({ds.dtype} vs {dtype})") except KeyError: - return await self.create_array(name, shape=shape, dtype=dtype, **kwargs) + return await self.create_array(name, shape=shape, dtype=dtype, config=config, **kwargs) # `config` is the runtime configuration of the returned array, not stored metadata, # so it applies to an existing array too. - config = kwargs.get("config") if config is not None: return ds.with_config(config) return ds @@ -2819,7 +2822,14 @@ def create_array( ) ) - def require_array(self, name: str, *, shape: ShapeLike, **kwargs: Any) -> AnyArray: + def require_array( + self, + name: str, + *, + shape: ShapeLike, + config: ArrayConfigLike | None = None, + **kwargs: Any, + ) -> AnyArray: """Obtain an array, creating if it doesn't exist. Other `kwargs` are as per [zarr.Group.create_array][]. @@ -2828,6 +2838,9 @@ def require_array(self, name: str, *, shape: ShapeLike, **kwargs: Any) -> AnyArr ---------- name : str Array name. + config : ArrayConfigLike or None, default=None + Runtime configuration for the array, whether it is created or already exists. + Keys not specified are taken from the global configuration. **kwargs : See [zarr.Group.create_array][]. @@ -2835,7 +2848,9 @@ def require_array(self, name: str, *, shape: ShapeLike, **kwargs: Any) -> AnyArr ------- a : Array """ - return Array(self._sync(self._async_group.require_array(name, shape=shape, **kwargs))) + return Array( + self._sync(self._async_group.require_array(name, shape=shape, config=config, **kwargs)) + ) def empty(self, *, name: str, shape: tuple[int, ...], **kwargs: Any) -> AnyArray: """Create an empty array with the specified shape in this Group. The contents will be filled with From 9a025b2a6d20c18b0c4e32caf9e0439da94c7782 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 30 Sep 2026 10:11:47 +0200 Subject: [PATCH 4/4] fix(group): parse config the same way on both require_array branches The existing-array branch handed config to with_config, which merged it over the array's current config and rejected an unknown key with the dataclass's "unexpected keyword argument" message. The create branch went through parse_array_config, which fills missing keys from the global configuration and says "Unknown array config keys". The same bad input now raises the same error whichever branch runs, and the docstring's "taken from the global configuration" holds by construction. The error tests run on both branches, and the combinations test passes config=None explicitly instead of omitting the keyword. Assisted-by: ClaudeCode:claude-fable-5-1 --- src/zarr/core/group.py | 6 ++++-- tests/test_group.py | 25 +++++++++++++++---------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index 8572f375d7..aaca4084c8 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -28,6 +28,7 @@ _parse_deprecated_compressor, create_array, ) +from zarr.core.array_spec import parse_array_config from zarr.core.attributes import Attributes from zarr.core.buffer import default_buffer_prototype from zarr.core.common import ( @@ -1291,9 +1292,10 @@ async def require_array( return await self.create_array(name, shape=shape, dtype=dtype, config=config, **kwargs) # `config` is the runtime configuration of the returned array, not stored metadata, - # so it applies to an existing array too. + # so it applies to an existing array too. It is parsed the same way as on the create + # branch: missing keys come from the global configuration, unknown keys raise. if config is not None: - return ds.with_config(config) + return ds.with_config(parse_array_config(config)) return ds async def update_attributes(self, new_attributes: dict[str, Any]) -> AsyncGroup: diff --git a/tests/test_group.py b/tests/test_group.py index e47da5c1a2..a756354dca 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1504,32 +1504,37 @@ async def test_require_array_config( group = Group.from_store(MemoryStore(), zarr_format=zarr_format) if exists: group.create_array("a", shape=(4,), dtype="uint8") - kwargs: dict[str, Any] = {} if config is None else {"config": config} if api == "sync": - observed = group.require_array("a", shape=(4,), dtype="uint8", **kwargs).config + observed = group.require_array("a", shape=(4,), dtype="uint8", config=config).config else: observed = ( - await group._async_group.require_array("a", shape=(4,), dtype="uint8", **kwargs) + await group._async_group.require_array("a", shape=(4,), dtype="uint8", config=config) ).config assert observed == expected -def test_require_array_existing_unknown_config_key() -> None: +@pytest.mark.parametrize("exists", [True, False]) +def test_require_array_unknown_config_key(exists: bool) -> None: """ - Requiring an existing array with a config containing an unknown key raises TypeError. + Requiring an array with a config containing an unknown key raises TypeError, whether the + array exists or is created. """ group = Group.from_store(MemoryStore()) - group.create_array("a", shape=(4,), dtype="uint8") - with pytest.raises(TypeError, match="unexpected keyword argument 'nope'"): + if exists: + group.create_array("a", shape=(4,), dtype="uint8") + with pytest.raises(TypeError, match=r"Unknown array config keys: \['nope'\]"): group.require_array("a", shape=(4,), dtype="uint8", config={"nope": 1}) -def test_require_array_existing_invalid_config_value() -> None: +@pytest.mark.parametrize("exists", [True, False]) +def test_require_array_invalid_config_value(exists: bool) -> None: """ - Requiring an existing array with a config containing an invalid value raises ValueError. + Requiring an array with a config containing an invalid value raises ValueError, whether the + array exists or is created. """ group = Group.from_store(MemoryStore()) - group.create_array("a", shape=(4,), dtype="uint8") + if exists: + group.create_array("a", shape=(4,), dtype="uint8") with pytest.raises(ValueError, match="Expected instance of bool"): group.require_array("a", shape=(4,), dtype="uint8", config={"read_missing_chunks": "yes"})