Repository: zarr-developers/zarr-python

Issue:
[v3] failure to roundtrip attrs when set via dict interface
### Zarr version

zarr-3.0.0a8.dev17+g6b11bb8b

### Numcodecs version

v0.13

### Python Version

3.11

### Operating System

Mac

### Installation

pip

### Description

Setting an attribute via the `Array.attrs.__setitem__` interface succeeds at updating the Array metadata in the store but subsequently accessing the attribute fails.

### Steps to reproduce

```python
In [1]: import zarr

In [2]: store = zarr.storage.MemoryStore({}, mode="w")
   ...: array = zarr.create(shape=1, store=store)
   ...: array.attrs['foo'] = 'bar'
   ...: dict(array.attrs) # -> empty!
Out[2]: {}

In [3]: store._store_dict['zarr.json'].to_bytes()
Out[3]: b'{"shape": [1], "data_type": "float64", "chunk_grid": {"name": "regular", "configuration": {"chunk_shape": [1]}}, "chunk_key_encoding": {"name": "default", "configuration": {"separator": "/"}}, "fill_value": 0.0, "codecs": [{"name": "bytes", "configuration": {"endian": "little"}}], "attributes": {"foo": "bar"}, "zarr_format": 3, "node_type": "array", "storage_transformers": []}'
```

### Additional output

_No response_

Discussion:

Patch:
diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index ef4921d4..5195db62 100644
--- a/src/zarr/core/array.py
+++ b/src/zarr/core/array.py
@@ -896,11 +896,14 @@ class AsyncArray(Generic[T_ArrayMetadata]):
         return replace(self, metadata=new_metadata)
 
     async def update_attributes(self, new_attributes: dict[str, JSON]) -> Self:
-        new_metadata = self.metadata.update_attributes(new_attributes)
+        # metadata.attributes is "frozen" so we simply clear and update the dict
+        self.metadata.attributes.clear()
+        self.metadata.attributes.update(new_attributes)
 
         # Write new metadata
-        await self._save_metadata(new_metadata)
-        return replace(self, metadata=new_metadata)
+        await self._save_metadata(self.metadata)
+
+        return self
 
     def __repr__(self) -> str:
         return f"<AsyncArray {self.store_path} shape={self.shape} dtype={self.dtype}>"


Test patch:
diff --git a/tests/v3/test_array.py b/tests/v3/test_array.py
index c89cd253..b558c826 100644
--- a/tests/v3/test_array.py
+++ b/tests/v3/test_array.py
@@ -406,3 +406,15 @@ def test_vlen_errors() -> None:
             dtype="<U4",
             codecs=[BytesCodec(), VLenBytesCodec()],
         )
+
+
+@pytest.mark.parametrize("zarr_format", [2, 3])
+def test_update_attrs(zarr_format: int) -> None:
+    # regression test for https://github.com/zarr-developers/zarr-python/issues/2328
+    store = MemoryStore({}, mode="w")
+    arr = Array.create(store=store, shape=5, chunk_shape=5, dtype="f8", zarr_format=zarr_format)
+    arr.attrs["foo"] = "bar"
+    assert arr.attrs["foo"] == "bar"
+
+    arr2 = zarr.open_array(store=store, zarr_format=zarr_format)
+    assert arr2.attrs["foo"] == "bar"
diff --git a/tests/v3/test_group.py b/tests/v3/test_group.py
index 9b686a66..90933abe 100644
--- a/tests/v3/test_group.py
+++ b/tests/v3/test_group.py
@@ -1257,3 +1257,12 @@ class TestGroupMetadata:
         result = GroupMetadata.from_dict(data)
         expected = GroupMetadata(attributes={"key": "value"}, zarr_format=2)
         assert result == expected
+
+
+def test_update_attrs() -> None:
+    # regression test for https://github.com/zarr-developers/zarr-python/issues/2328
+    root = Group.from_store(
+        MemoryStore({}, mode="w"),
+    )
+    root.attrs["foo"] = "bar"
+    assert root.attrs["foo"] == "bar"