From 6f141c19ae8dad2ffad55a4645e4784a8bdb34fa Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 9 Sep 2026 12:45:05 +0200 Subject: [PATCH 1/2] fix: deep-copy source attributes in from_array `from_array` copied the source array's attributes with `dict(data.attrs)`, a shallow copy, so any nested dict or list inside the attributes was shared between the source array's in-memory metadata and the new array's. Mutating a nested attribute on the copy (e.g. appending to a list) silently changed the source array's attributes as well. The alias never reached the source's store, but it was created by zarr without the user's involvement. Copy the attributes with `copy.deepcopy` instead. Attributes are JSON values (dicts, lists, scalars), so a deep copy is safe and cheap. The existing `from_array` attribute test now mutates a nested dict and a nested list on the copy and asserts the source is unchanged. Assisted-by: ClaudeCode:claude-fable-5-1 --- changes/0000.bugfix.md | 1 + src/zarr/core/array.py | 5 ++++- tests/test_array.py | 14 +++++++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 changes/0000.bugfix.md diff --git a/changes/0000.bugfix.md b/changes/0000.bugfix.md new file mode 100644 index 0000000000..de5f941e95 --- /dev/null +++ b/changes/0000.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 bc43802ec7..67075d03c2 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 @@ -4948,7 +4949,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")) From 8d89e8e9b037795b8902557463c96001996702c6 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 9 Sep 2026 12:49:01 +0200 Subject: [PATCH 2/2] chore: rename changelog fragment to the PR number Assisted-by: ClaudeCode:claude-fable-5-1 --- changes/{0000.bugfix.md => 4325.bugfix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changes/{0000.bugfix.md => 4325.bugfix.md} (100%) diff --git a/changes/0000.bugfix.md b/changes/4325.bugfix.md similarity index 100% rename from changes/0000.bugfix.md rename to changes/4325.bugfix.md