Skip to content

Commit 10f8abe

Browse files
authored
fix: resolve chunks=False to chunk size 1 on zero-length axes (zarr-developers#4328)
* fix: resolve chunks=False to chunk size 1 on zero-length axes `chunks=False` means "one chunk covering the whole array", but on a zero-length axis it built a chunk of size 0. For Zarr format 3 this was rejected by RegularChunkGridMetadata with "integer chunk edge length must be >= 1, got 0"; with shards="auto" it raised ZeroDivisionError on both formats; and for Zarr format 2 it silently wrote `chunks: [0]`, which corrupted reads after the axis was later resized. Route `False` through the `-1` sentinel so the zero-length clamp added in zarr-developers#4307 (max(span, 1), matching _guess_regular_chunks) lives in one place. Both spellings now yield chunk size 1 on empty axes. Rebased over zarr-developers#4218, which rewrote normalize_chunks_nd to build FixedDimension/VaryingDimension grids: the False branch now falls through to the -1 path instead of returning a ChunkGrid directly, and the test table expectations use the new bare-int / tuple form. Assisted-by: ClaudeCode:claude-fable-5-1 * chore: rename changelog fragment to the PR number Assisted-by: ClaudeCode:claude-fable-5-1
1 parent a1b4416 commit 10f8abe

3 files changed

Lines changed: 67 additions & 20 deletions

File tree

changes/4328.bugfix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed `chunks=False` on a zero-length axis resolving to a chunk size of 0, which raised a `ValueError` for Zarr format 3, raised a `ZeroDivisionError` with `shards="auto"`, and silently wrote invalid `chunks` metadata for Zarr format 2. `False` now takes the same path as `chunks=-1`, so such axes get chunk size 1, matching `chunks="auto"`.

src/zarr/core/chunk_grids.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -808,11 +808,10 @@ def normalize_chunks_nd(
808808
f'{chunks!r} is not a valid chunk input. Use chunks=None or chunks="auto" from the top-level API for auto-chunking, or pass an int / tuple of ints.'
809809
)
810810

811-
# handle no chunking
811+
# handle no chunking: one chunk covering every axis. Routed through the -1 sentinel so
812+
# the zero-length-axis clamp lives in one place (normalize_chunks_1d).
812813
if chunks is False:
813-
return ChunkGrid(
814-
dimensions=tuple(FixedDimension(size=int(s), extent=int(s)) for s in shape)
815-
)
814+
chunks = -1
816815

817816
# handle 1D convenience form. bool is excluded above so this only catches actual ints.
818817
if isinstance(chunks, numbers.Integral):

tests/test_chunk_grids.py

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import Any
1+
import contextlib
2+
from typing import Any, Literal, cast
23

34
import numpy as np
45
import pytest
@@ -87,6 +88,10 @@ def test_guess_chunks(shape: tuple[int, ...], itemsize: int) -> None:
8788
(False, (100, 50), (100, 50)),
8889
# sentinel values
8990
(-1, (100,), (100,)),
91+
# False and -1 on a zero-length axis clamp to chunk size 1 (chunk sizes must be positive)
92+
(False, (0,), (1,)),
93+
(False, (0, 4), (1, 4)),
94+
(-1, (4, 0), (4, 1)),
9095
# zero-length dimensions preserve the declared chunk size
9196
(10, (0,), (10,)),
9297
((5, 10), (0, 100), (5, 10)),
@@ -362,24 +367,66 @@ def test_create_0d_array_auto_shards_with_target_shard_size() -> None:
362367
assert arr.shards == ()
363368

364369

370+
@pytest.mark.parametrize("chunks", [-1, False], ids=["minus-one", "false"])
371+
@pytest.mark.parametrize("shape", [(0,), (0, 4), (4, 0)], ids=["1d", "2d-lead", "2d-trail"])
365372
@pytest.mark.parametrize(
366-
"target_shard_size_bytes",
367-
[None, 128 * 1024 * 1024],
368-
ids=["no-budget", "budget"],
373+
("zarr_format", "shards", "target_shard_size_bytes"),
374+
[
375+
(2, None, None),
376+
(3, None, None),
377+
(3, "auto", None),
378+
(3, "auto", 128 * 1024 * 1024),
379+
],
380+
ids=["v2", "v3", "v3-auto-shards", "v3-auto-shards-budget"],
369381
)
370-
def test_create_zero_length_array_full_span_chunks_auto_shards(
382+
def test_create_zero_length_array_full_span_chunks(
383+
chunks: int | bool,
384+
shape: tuple[int, ...],
385+
zarr_format: Literal[2, 3],
386+
shards: Literal["auto"] | None,
371387
target_shard_size_bytes: int | None,
372388
) -> None:
373-
"""`chunks=-1` on a zero-length axis with shards="auto" must neither hang nor raise.
389+
"""`chunks=-1` and `chunks=False` on a zero-length axis must resolve to chunk size 1.
374390
375-
The -1 sentinel used to resolve to chunk size 0 on zero-length axes, which broke
376-
every sharding code path: a ZeroDivisionError without a shard size budget, and an
377-
infinite loop with one (https://github.com/zarr-developers/zarr-python/issues/4304).
391+
Both spellings mean "one chunk covering the whole axis". They used to resolve to chunk
392+
size 0 on zero-length axes, which broke every downstream path differently: a ValueError
393+
from the Zarr format 3 chunk grid metadata, a ZeroDivisionError with shards="auto", an
394+
infinite loop with a shard size budget (https://github.com/zarr-developers/zarr-python/issues/4304),
395+
and invalid `chunks: [0]` metadata for Zarr format 2 that silently corrupted reads after
396+
a resize.
378397
"""
379-
with (
380-
zarr.config.set({"array.target_shard_size_bytes": target_shard_size_bytes}),
381-
pytest.warns(ZarrUserWarning, match="Automatic shard shape inference is experimental"),
382-
):
383-
arr = zarr.create_array(store={}, shape=(0,), dtype="int64", chunks=-1, shards="auto")
384-
assert arr.chunks == (1,)
385-
assert arr.shards == (1,)
398+
expected_chunks = tuple(max(s, 1) for s in shape)
399+
warns = (
400+
pytest.warns(ZarrUserWarning, match="Automatic shard shape inference is experimental")
401+
if shards == "auto"
402+
else contextlib.nullcontext()
403+
)
404+
with zarr.config.set({"array.target_shard_size_bytes": target_shard_size_bytes}), warns:
405+
arr = zarr.create_array(
406+
store={},
407+
shape=shape,
408+
dtype="int64",
409+
chunks=chunks,
410+
shards=shards,
411+
zarr_format=zarr_format,
412+
)
413+
assert arr.chunks == expected_chunks
414+
assert arr.shards == (expected_chunks if shards == "auto" else None)
415+
416+
# The stored chunk grid must be the clamped shape, whichever format wrote it.
417+
meta = cast(dict[str, Any], arr.metadata.to_dict())
418+
if zarr_format == 2:
419+
assert meta["chunks"] == expected_chunks
420+
else:
421+
assert meta["chunk_grid"]["configuration"]["chunk_shape"] == expected_chunks
422+
423+
# The array must remain usable: grow the empty axis and round-trip data through it.
424+
axis = shape.index(0)
425+
grown = tuple(2 if s == 0 else s for s in shape)
426+
arr.append(np.full(grown, 7, dtype="int64"), axis=axis)
427+
assert arr.shape == grown
428+
np.testing.assert_array_equal(arr[...], np.full(grown, 7, dtype="int64"))
429+
resized = tuple(3 if s == 0 else s for s in shape)
430+
arr.resize(resized)
431+
assert arr.shape == resized
432+
assert int(np.asarray(arr[...]).sum()) == 7 * np.prod(grown)

0 commit comments

Comments
 (0)