diff --git a/changes/3478.bugfix.md b/changes/3478.bugfix.md new file mode 100644 index 0000000000..405f579c8c --- /dev/null +++ b/changes/3478.bugfix.md @@ -0,0 +1 @@ +Attributes holding numpy scalars, such as `np.float32(1.5)`, `np.int64(3)` or `np.bool_(True)`, can be written again. They were previously rejected with `Object of type float32 is not JSON serializable`. diff --git a/src/zarr/core/_json.py b/src/zarr/core/_json.py index 66f6236f44..bcb5e7f89d 100644 --- a/src/zarr/core/_json.py +++ b/src/zarr/core/_json.py @@ -25,7 +25,9 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast + +import numpy as np from zarr.core.buffer import default_buffer_prototype @@ -35,6 +37,13 @@ from zarr.core.common import JSON +def _json_default(obj: Any) -> Any: + """Convert a numpy scalar, which `json` cannot encode, to the equivalent Python value.""" + if isinstance(obj, np.generic): + return obj.item() + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + def buffer_to_json(buffer: Buffer) -> JSON: """Parse the contents of a `Buffer` as a JSON value.""" # json.loads is typed as returning Any; the result is by definition JSON. @@ -96,7 +105,9 @@ def json_to_buffer( """ if prototype is None: prototype = default_buffer_prototype() - return prototype.buffer.from_bytes(json.dumps(obj, indent=indent, allow_nan=allow_nan).encode()) + return prototype.buffer.from_bytes( + json.dumps(obj, indent=indent, allow_nan=allow_nan, default=_json_default).encode() + ) async def get_json(store: Store, key: str, *, byte_range: ByteRequest | None = None) -> JSON | None: diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 269704d2a0..1e0f25a3d4 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -26,6 +26,20 @@ def test_put(data: dict[str, Any], zarr_format: ZarrFormat) -> None: assert deep_nan_equal(dict(attrs), expected) +@pytest.mark.parametrize("zarr_format", [2, 3]) +@pytest.mark.parametrize( + "value", + [np.float32(1.5), np.float32(np.nan), np.int64(3), np.uint8(7), np.bool_(True)], +) +def test_numpy_scalar_attribute_roundtrip(value: Any, zarr_format: ZarrFormat) -> None: + """numpy scalars are stored as their Python equivalents and survive reopening the store.""" + store = zarr.storage.MemoryStore() + zarr.create_group(store, zarr_format=zarr_format, attributes={"foo": value}) + reopened = zarr.open_group(store, mode="r", zarr_format=zarr_format) + expected = value.item() + assert deep_nan_equal({"foo": reopened.attrs["foo"]}, {"foo": expected}) + + def test_asdict() -> None: store = zarr.storage.MemoryStore() attrs = zarr.core.attributes.Attributes(