diff --git a/changes/4325.bugfix.md b/changes/4325.bugfix.md new file mode 100644 index 0000000000..de5f941e95 --- /dev/null +++ b/changes/4325.bugfix.md @@ -0,0 +1 @@ +`zarr.from_array` now deep-copies the source array's attributes instead of sharing nested dicts and lists between the source and the new array. Previously, mutating a nested attribute on the new array (for example ``dst.attrs["meta"]["tags"].append(...)``) silently changed the source array's in-memory attributes too. diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index 70e3813e71..3f67eb7c75 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import math import warnings from asyncio import gather @@ -4955,7 +4956,9 @@ def _parse_keep_array_attr( if dimension_names is None and data.metadata.zarr_format == 3: dimension_names = data.metadata.dimension_names if attributes is None: - attributes = dict(data.attrs) + # Deep copy so nested containers are not shared between the source + # array's in-memory metadata and the new array's. + attributes = copy.deepcopy(dict(data.attrs)) else: if chunks == "keep": chunks = "auto" diff --git a/tests/test_array.py b/tests/test_array.py index 46890244ec..a1cd687ab9 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -1829,7 +1829,7 @@ async def test_from_array_arraylike( @pytest.mark.parametrize("store", ["local", "memory"], indirect=True) def test_from_array_keeps_fill_value_and_attributes(store: Store, zarr_format: ZarrFormat) -> None: """`from_array` defaults to the fill value and attributes of the source array.""" - attributes: dict[str, JSON] = {"units": "K"} + attributes: dict[str, JSON] = {"units": "K", "nested": {"x": [1]}, "tags": ["a"]} src = zarr.create_array( store, name="src", @@ -1845,6 +1845,18 @@ def test_from_array_keeps_fill_value_and_attributes(store: Store, zarr_format: Z assert result.fill_value == 42 assert dict(result.attrs) == attributes + # The copied attributes must not alias the source's nested containers. + nested = result.attrs["nested"] + assert isinstance(nested, dict) + nested_x = nested["x"] + assert isinstance(nested_x, list) + nested_x.append(99) + tags = result.attrs["tags"] + assert isinstance(tags, list) + tags.append("b") + assert src.attrs["nested"] == {"x": [1]} + assert src.attrs["tags"] == ["a"] + # A metadata-only copy must read back the source's fill value, not the dtype default. meta_only = zarr.from_array({}, data=src, write_data=False) np.testing.assert_array_equal(meta_only[:], np.full((4,), 42, dtype="int32"))