Repository: zarr-developers/zarr-python

Issue:
[v3] Support zero-sized arrays
### Zarr version

3.0.0a1

### Numcodecs version

0.12.1

### Python Version

3.11.9

### Operating System

Mac

### Installation

pip

### Description

Zero-sized arrays (i.e. arrays where one dimension is zero) are supported in v2, so they should be in v3 too.

### Steps to reproduce

```python
>>> import zarr.v2 as zarr
>>> z = zarr.open(store='example-v2.zarr', mode='w', shape=(3, 0))
>>> z[:] = 2
>>> z[:]
array([], shape=(3, 0), dtype=float64)
>>> import zarr
>>> z = zarr.open(store='example-v3.zarr', mode='w', shape=(3, 0))
>>> z[:] = 3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/tom/workspace/zarr-python/src/zarr/array.py", line 720, in __setitem__
    self.set_basic_selection(cast(BasicSelection, pure_selection), value, fields=fields)
  File "/Users/tom/workspace/zarr-python/src/zarr/array.py", line 750, in set_basic_selection
    indexer = BasicIndexer(selection, self.shape, self.metadata.chunk_grid)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tom/workspace/zarr-python/src/zarr/indexing.py", line 463, in __init__
    dim_indexer = SliceDimIndexer(dim_sel, dim_len, dim_chunk_len)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tom/workspace/zarr-python/src/zarr/indexing.py", line 287, in __init__
    object.__setattr__(self, "nchunks", ceildiv(dim_len, dim_chunk_len))
                                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tom/workspace/zarr-python/src/zarr/indexing.py", line 100, in ceildiv
    return math.ceil(a / b)
                     ~~^~~
ZeroDivisionError: division by zero
```

### Additional output

_No response_

Discussion:
dcherian: Is the shape `(1, 0, 1)` supported too?
brokkoli71: @dcherian Yes,` (1, 0, 1)` is supported
dcherian: property tests fixed in https://github.com/scalableminds/zarr-python/pull/4

Patch:
diff --git a/src/zarr/core/indexing.py b/src/zarr/core/indexing.py
index f1d5fd16..1873d5c8 100644
--- a/src/zarr/core/indexing.py
+++ b/src/zarr/core/indexing.py
@@ -94,6 +94,8 @@ class Indexer(Protocol):
 
 
 def ceildiv(a: float, b: float) -> int:
+    if a == 0:
+        return 0
     return math.ceil(a / b)
 
 
@@ -374,7 +376,7 @@ class SliceDimIndexer:
 
     def __iter__(self) -> Iterator[ChunkDimProjection]:
         # figure out the range of chunks we need to visit
-        dim_chunk_ix_from = self.start // self.dim_chunk_len
+        dim_chunk_ix_from = 0 if self.start == 0 else self.start // self.dim_chunk_len
         dim_chunk_ix_to = ceildiv(self.stop, self.dim_chunk_len)
 
         # iterate over chunks in range
diff --git a/src/zarr/core/metadata/v3.py b/src/zarr/core/metadata/v3.py
index 6b6f28dd..7a38e9fd 100644
--- a/src/zarr/core/metadata/v3.py
+++ b/src/zarr/core/metadata/v3.py
@@ -43,6 +43,13 @@ from zarr.registry import get_codec_class
 
 DEFAULT_DTYPE = "float64"
 
+# Keep in sync with _replace_special_floats
+SPECIAL_FLOATS_ENCODED = {
+    "Infinity": np.inf,
+    "-Infinity": -np.inf,
+    "NaN": np.nan,
+}
+
 
 def parse_zarr_format(data: object) -> Literal[3]:
     if data == 3:
@@ -149,7 +156,7 @@ class V3JsonEncoder(json.JSONEncoder):
                 if isinstance(out, complex):
                     # python complex types are not JSON serializable, so we use the
                     # serialization defined in the zarr v3 spec
-                    return [out.real, out.imag]
+                    return _replace_special_floats([out.real, out.imag])
                 elif np.isnan(out):
                     return "NaN"
                 elif np.isinf(out):
@@ -447,8 +454,11 @@ def parse_fill_value(
     if isinstance(fill_value, Sequence) and not isinstance(fill_value, str):
         if data_type in (DataType.complex64, DataType.complex128):
             if len(fill_value) == 2:
+                decoded_fill_value = tuple(
+                    SPECIAL_FLOATS_ENCODED.get(value, value) for value in fill_value
+                )
                 # complex datatypes serialize to JSON arrays with two elements
-                return np_dtype.type(complex(*fill_value))
+                return np_dtype.type(complex(*decoded_fill_value))
             else:
                 msg = (
                     f"Got an invalid fill value for complex data type {data_type.value}."
@@ -475,12 +485,20 @@ def parse_fill_value(
         pass
     elif fill_value in ["Infinity", "-Infinity"] and not np.isfinite(casted_value):
         pass
-    elif np_dtype.kind in "cf":
+    elif np_dtype.kind == "f":
         # float comparison is not exact, especially when dtype <float64
-        # so we us np.isclose for this comparison.
+        # so we use np.isclose for this comparison.
         # this also allows us to compare nan fill_values
         if not np.isclose(fill_value, casted_value, equal_nan=True):
             raise ValueError(f"fill value {fill_value!r} is not valid for dtype {data_type}")
+    elif np_dtype.kind == "c":
+        # confusingly np.isclose(np.inf, np.inf + 0j) is False on numpy<2, so compare real and imag parts
+        # explicitly.
+        if not (
+            np.isclose(np.real(fill_value), np.real(casted_value), equal_nan=True)
+            and np.isclose(np.imag(fill_value), np.imag(casted_value), equal_nan=True)
+        ):
+            raise ValueError(f"fill value {fill_value!r} is not valid for dtype {data_type}")
     else:
         if fill_value != casted_value:
             raise ValueError(f"fill value {fill_value!r} is not valid for dtype {data_type}")


Test patch:
diff --git a/src/zarr/testing/strategies.py b/src/zarr/testing/strategies.py
index 2c17fbf7..c82e168c 100644
--- a/src/zarr/testing/strategies.py
+++ b/src/zarr/testing/strategies.py
@@ -65,7 +65,7 @@ paths = st.just("/") | keys
 stores = st.builds(MemoryStore, st.just({}), mode=st.just("w"))
 compressors = st.sampled_from([None, "default"])
 zarr_formats: st.SearchStrategy[Literal[2, 3]] = st.sampled_from([2, 3])
-array_shapes = npst.array_shapes(max_dims=4)
+array_shapes = npst.array_shapes(max_dims=4, min_side=0)
 
 
 @st.composite  # type: ignore[misc]
@@ -85,7 +85,7 @@ def numpy_arrays(
 @st.composite  # type: ignore[misc]
 def np_array_and_chunks(
     draw: st.DrawFn, *, arrays: st.SearchStrategy[np.ndarray] = numpy_arrays
-) -> tuple[np.ndarray, tuple[int]]:  # type: ignore[type-arg]
+) -> tuple[np.ndarray, tuple[int, ...]]:  # type: ignore[type-arg]
     """A hypothesis strategy to generate small sized random arrays.
 
     Returns: a tuple of the array and a suitable random chunking for it.
@@ -93,9 +93,16 @@ def np_array_and_chunks(
     array = draw(arrays)
     # We want this strategy to shrink towards arrays with smaller number of chunks
     # 1. st.integers() shrinks towards smaller values. So we use that to generate number of chunks
-    numchunks = draw(st.tuples(*[st.integers(min_value=1, max_value=size) for size in array.shape]))
+    numchunks = draw(
+        st.tuples(
+            *[st.integers(min_value=0 if size == 0 else 1, max_value=size) for size in array.shape]
+        )
+    )
     # 2. and now generate the chunks tuple
-    chunks = tuple(size // nchunks for size, nchunks in zip(array.shape, numchunks, strict=True))
+    chunks = tuple(
+        size // nchunks if nchunks > 0 else 0
+        for size, nchunks in zip(array.shape, numchunks, strict=True)
+    )
     return (array, chunks)
 
 
diff --git a/tests/test_array.py b/tests/test_array.py
index ae8e7f99..6451c7fe 100644
--- a/tests/test_array.py
+++ b/tests/test_array.py
@@ -1,3 +1,5 @@
+import json
+import math
 import pickle
 from itertools import accumulate
 from typing import Any, Literal
@@ -9,6 +11,7 @@ import zarr.api.asynchronous
 from zarr import Array, AsyncArray, Group
 from zarr.codecs import BytesCodec, VLenBytesCodec
 from zarr.core.array import chunks_initialized
+from zarr.core.buffer import default_buffer_prototype
 from zarr.core.buffer.cpu import NDBuffer
 from zarr.core.common import JSON, MemoryOrder, ZarrFormat
 from zarr.core.group import AsyncGroup
@@ -624,3 +627,23 @@ def test_array_create_order(
         assert vals.flags.f_contiguous
     else:
         raise AssertionError
+
+
+@pytest.mark.parametrize(
+    ("fill_value", "expected"),
+    [
+        (np.nan * 1j, ["NaN", "NaN"]),
+        (np.nan, ["NaN", 0.0]),
+        (np.inf, ["Infinity", 0.0]),
+        (np.inf * 1j, ["NaN", "Infinity"]),
+        (-np.inf, ["-Infinity", 0.0]),
+        (math.inf, ["Infinity", 0.0]),
+    ],
+)
+async def test_special_complex_fill_values_roundtrip(fill_value: Any, expected: list[Any]) -> None:
+    store = MemoryStore({}, mode="w")
+    Array.create(store=store, shape=(1,), dtype=np.complex64, fill_value=fill_value)
+    content = await store.get("zarr.json", prototype=default_buffer_prototype())
+    assert content is not None
+    actual = json.loads(content.to_bytes())
+    assert actual["fill_value"] == expected
diff --git a/tests/test_indexing.py b/tests/test_indexing.py
index b3a19906..2c51f3da 100644
--- a/tests/test_indexing.py
+++ b/tests/test_indexing.py
@@ -11,6 +11,7 @@ import pytest
 from numpy.testing import assert_array_equal
 
 import zarr
+from zarr import Array
 from zarr.core.buffer import BufferPrototype, default_buffer_prototype
 from zarr.core.indexing import (
     BasicSelection,
@@ -31,7 +32,6 @@ from zarr.storage.memory import MemoryStore
 if TYPE_CHECKING:
     from collections.abc import AsyncGenerator
 
-    from zarr.core.array import Array
     from zarr.core.buffer.core import Buffer
     from zarr.core.common import ChunkCoords
 
@@ -1927,3 +1927,11 @@ def test_indexing_with_zarr_array(store: StorePath) -> None:
 
     assert_array_equal(a[ii], za[zii])
     assert_array_equal(a[ii], za.oindex[zii])
+
+
+@pytest.mark.parametrize("store", ["local", "memory"], indirect=["store"])
+@pytest.mark.parametrize("shape", [(0, 2, 3), (0), (3, 0)])
+def test_zero_sized_chunks(store: StorePath, shape: list[int]) -> None:
+    z = Array.create(store=store, shape=shape, chunk_shape=shape, zarr_format=3, dtype="f8")
+    z[...] = 42
+    assert_array_equal(z[...], np.zeros(shape, dtype="f8"))
diff --git a/tests/test_properties.py b/tests/test_properties.py
index 380a4d85..f70753ce 100644
--- a/tests/test_properties.py
+++ b/tests/test_properties.py
@@ -6,7 +6,7 @@ pytest.importorskip("hypothesis")
 
 import hypothesis.extra.numpy as npst  # noqa: E402
 import hypothesis.strategies as st  # noqa: E402
-from hypothesis import given  # noqa: E402
+from hypothesis import assume, given  # noqa: E402
 
 from zarr.testing.strategies import arrays, basic_indices, numpy_arrays, zarr_formats  # noqa: E402
 
@@ -35,11 +35,13 @@ def test_basic_indexing(data: st.DataObject) -> None:
 @given(data=st.data())
 def test_vindex(data: st.DataObject) -> None:
     zarray = data.draw(arrays())
+    # integer_array_indices can't handle 0-size dimensions.
+    assume(all(s > 0 for s in zarray.shape))
     nparray = zarray[:]
 
     indexer = data.draw(
         npst.integer_array_indices(
-            shape=nparray.shape, result_shape=npst.array_shapes(max_dims=None)
+            shape=nparray.shape, result_shape=npst.array_shapes(min_side=1, max_dims=None)
         )
     )
     actual = zarray.vindex[indexer]