From 9058ec8b0f603833f6358d7ba286ffe926fcc163 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 2 Sep 2026 13:11:47 +0200 Subject: [PATCH 1/2] fix: resolve chunks=-1 to chunk size 1 on zero-length axes The -1 chunk sentinel resolved to chunk size 0 on zero-length axes, which broke every downstream sharding path differently: a ValueError with shards=None, a ZeroDivisionError with shards="auto", and an infinite loop with shards="auto" plus array.target_shard_size_bytes. Clamp it to 1, matching the auto-chunking clamp in _guess_regular_chunks. Also guard _guess_num_chunks_per_axis_shard against zero-size chunk shapes directly (same non-terminating-loop cause as the 0-d case fixed in #4305), and fix the arithmetic in its docstring example. Follow-up to #4305 / issue #4304. Assisted-by: ClaudeCode:claude-fable-5-1 --- src/zarr/core/chunk_grids.py | 13 ++++++++--- tests/test_chunk_grids.py | 44 +++++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/zarr/core/chunk_grids.py b/src/zarr/core/chunk_grids.py index 5e19364cae..13ec3d8ffa 100644 --- a/src/zarr/core/chunk_grids.py +++ b/src/zarr/core/chunk_grids.py @@ -734,7 +734,9 @@ def normalize_chunks_1d( if isinstance(chunks, numbers.Integral): chunk_size = int(chunks) if chunk_size == -1: - return np.array([span], dtype=np.int64) + # A zero-length span still gets chunk size 1 (chunk sizes must be positive), + # matching the auto-chunking clamp in _guess_regular_chunks. + return np.array([max(span, 1)], dtype=np.int64) if chunk_size <= 0: raise ValueError(f"Chunk size must be positive, got {chunk_size}") if span == 0: @@ -844,7 +846,10 @@ def _guess_num_chunks_per_axis_shard( For example, for a (2,2,2) chunk size and item size 4, maximum bytes of 256 would return 2. In other words the shard would be a (2,2,2) grid of (2,2,2) chunks - i.e., prod(chunk_shape) * (returned_val * len(chunk_shape)) * item_size = 256 bytes. + i.e., prod(chunk_shape) * (returned_val ** len(chunk_shape)) * item_size = 256 bytes. + + Degenerate chunk shapes — a 0-dimensional shape, or one containing a zero-length + axis — return 1, as the search loop's stopping conditions can never be met. Parameters ---------- @@ -865,7 +870,9 @@ def _guess_num_chunks_per_axis_shard( if max_bytes < bytes_per_chunk: return 1 num_axes = len(chunk_shape) - if num_axes == 0: + # For a 0-dimensional chunk shape or one with a zero-length axis, both loop + # conditions below are constant, so the loop would never terminate. + if num_axes == 0 or bytes_per_chunk == 0: return 1 chunks_per_shard = 1 # First check for byte size, second check to make sure we don't go bigger than the array shape diff --git a/tests/test_chunk_grids.py b/tests/test_chunk_grids.py index 4c3c437eb2..0f1309cf05 100644 --- a/tests/test_chunk_grids.py +++ b/tests/test_chunk_grids.py @@ -267,6 +267,8 @@ def test_normalize_chunks_nd_errors(case: ExpectFail[tuple[Any, tuple[int, ...]] Expect(input=([10, 20, 30, 40], 100), output=[10, 20, 30, 40], id="explicit-list"), # -1 sentinel branch: one chunk covering the full span. Expect(input=(-1, 100), output=[100], id="full-span-sentinel"), + # -1 on a zero-length span clamps to chunk size 1 (chunk sizes must be positive). + Expect(input=(-1, 0), output=[1], id="full-span-sentinel-empty"), ], ids=lambda c: c.id, ) @@ -282,11 +284,24 @@ def test_normalize_chunks_1d_returns_int64_array( assert result.tolist() == case.output -def test_guess_num_chunks_per_axis_shard_0d() -> None: - """Regression test for https://github.com/zarr-developers/zarr-python/issues/4304.""" +@pytest.mark.parametrize( + ("chunk_shape", "array_shape"), + [((), ()), ((0,), (0,)), ((0, 0), (0, 0))], + ids=["0d", "zero-1d", "zero-2d"], +) +def test_guess_num_chunks_per_axis_shard_degenerate( + chunk_shape: tuple[int, ...], array_shape: tuple[int, ...] +) -> None: + """Degenerate chunk shapes must return 1 instead of hanging the search loop. + + Regression test for https://github.com/zarr-developers/zarr-python/issues/4304. + """ assert ( _guess_num_chunks_per_axis_shard( - chunk_shape=(), item_size=8, max_bytes=128 * 1024 * 1024, array_shape=() + chunk_shape=chunk_shape, + item_size=8, + max_bytes=128 * 1024 * 1024, + array_shape=array_shape, ) == 1 ) @@ -303,3 +318,26 @@ def test_create_0d_array_auto_shards_with_target_shard_size() -> None: ): arr = zarr.create_array(store={}, shape=(), dtype="int64", shards="auto") assert arr.shards == () + + +@pytest.mark.parametrize( + "target_shard_size_bytes", + [None, 128 * 1024 * 1024], + ids=["no-budget", "budget"], +) +def test_create_zero_length_array_full_span_chunks_auto_shards( + target_shard_size_bytes: int | None, +) -> None: + """`chunks=-1` on a zero-length axis with shards="auto" must neither hang nor raise. + + The -1 sentinel used to resolve to chunk size 0 on zero-length axes, which broke + every sharding code path: a ZeroDivisionError without a shard size budget, and an + infinite loop with one (https://github.com/zarr-developers/zarr-python/issues/4304). + """ + with ( + zarr.config.set({"array.target_shard_size_bytes": target_shard_size_bytes}), + pytest.warns(ZarrUserWarning, match="Automatic shard shape inference is experimental"), + ): + arr = zarr.create_array(store={}, shape=(0,), dtype="int64", chunks=-1, shards="auto") + assert arr.chunks == (1,) + assert arr.shards == (1,) From f850df574a208642cfee61a0612cd8e7b6e5604e Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 2 Sep 2026 13:12:14 +0200 Subject: [PATCH 2/2] docs: add changelog fragment Assisted-by: ClaudeCode:claude-fable-5-1 --- changes/4307.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/4307.bugfix.md diff --git a/changes/4307.bugfix.md b/changes/4307.bugfix.md new file mode 100644 index 0000000000..b77c89cfa5 --- /dev/null +++ b/changes/4307.bugfix.md @@ -0,0 +1 @@ +Fixed `chunks=-1` on a zero-length axis resolving to an invalid chunk size of 0, which caused a `ValueError`, `ZeroDivisionError`, or infinite loop depending on the sharding configuration. Such axes now get chunk size 1, matching `chunks="auto"`.