Skip to content

Commit 93d849b

Browse files
d-v-bclaude
andcommitted
revert: keep the strict chunk-size changes in zarr-developers#4431, out of this patch PR
Reverts 47587bc, baddb31 and ad34ae8, and the error-message part of e547401. Rejecting a chunk size of 0 in ArrayV2Metadata and ShardingCodec, and no longer reading a falsy `chunks` as absent in the legacy Zarr format 2 zarr.create, reject inputs zarr 3.4.0 accepted; this PR targets the 3.4.1 patch release, which must not. zarr-developers#4431 makes the same changes, more strictly, for the next minor release. The widened chunk and shard annotations and the zarr.create docstring fix stay. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e547401 commit 93d849b

8 files changed

Lines changed: 99 additions & 134 deletions

File tree

‎changes/4376.bugfix.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Chunk specifications are judged only by the chunk normalizer: the separate duck-typed check for rectilinear input is gone, so the Zarr format 2 and sharding restrictions apply to what the normalized grid says was declared. A chunk size is an integer by Python's integer protocol: numpy integer scalars and 0-d integer arrays are accepted wherever a chunk or shard shape is. The legacy `zarr.create(..., zarr_format=2)` no longer tests the truth value of `chunks`: only `None` means not given, as for Zarr format 3. `chunks=False` is now one chunk instead of an automatic chunk shape, numpy arrays work, and `0`, `[]` and `np.array([0])` raise instead of chunking automatically. A non-integer scalar chunk specification raises a `TypeError` naming the problem. An explicit list of chunk edges declares a rectilinear dimension everywhere, including a stored rectilinear chunk grid passed as `chunks=` whose edges happen to be uniform, so the Zarr format 2 and sharding restrictions treat it the same whether it is given as lists or as metadata, and `zarr.create` stores it as a rectilinear chunk grid, as `create_array` does. A `RectilinearChunkGridMetadata` made only of bare integers declares no explicit edge list, so it is read as the regular grid it describes: it is accepted for Zarr format 2 and as the chunk shape of a sharded array. The `chunks` and `chunk_shape` parameters of `zarr.create` and the `ShardsLike` type now declare every chunk specification the normalizer accepts, including numpy integers and arrays, explicit edge lists and stored chunk grid metadata. The chunk shape of `ArrayV2Metadata` and the inner chunk shape of `ShardingCodec` are parsed with the chunk normalizer's integer test as a regular chunk shape: every size must be at least 1, so `ArrayV2Metadata(chunks=(0,))` raises a `ValueError` as the Zarr format 3 regular chunk grid does (a stored Zarr format 2 chunk size of 0 is still repaired on read), a stored inner chunk size of 0 raises a `ValueError` instead of a `ZeroDivisionError` on read, and an explicit list of chunk edges is rejected with a `TypeError`.
1+
Chunk specifications are judged only by the chunk normalizer: the separate duck-typed check for rectilinear input is gone, so the Zarr format 2 and sharding restrictions apply to what the normalized grid says was declared. A chunk size is an integer by Python's integer protocol: numpy integer scalars and 0-d integer arrays are accepted wherever a chunk or shard shape is. The legacy `zarr.create(..., zarr_format=2)` no longer tests the truth value of a numpy array given as `chunks`, so numpy arrays work there. A non-integer scalar chunk specification raises a `TypeError` naming the problem. An explicit list of chunk edges declares a rectilinear dimension everywhere, including a stored rectilinear chunk grid passed as `chunks=` whose edges happen to be uniform, so the Zarr format 2 and sharding restrictions treat it the same whether it is given as lists or as metadata, and `zarr.create` stores it as a rectilinear chunk grid, as `create_array` does. A `RectilinearChunkGridMetadata` made only of bare integers declares no explicit edge list, so it is read as the regular grid it describes: it is accepted for Zarr format 2 and as the chunk shape of a sharded array. The `chunks` and `chunk_shape` parameters of `zarr.create` and the `ShardsLike` type now declare every chunk specification the normalizer accepts, including numpy integers and arrays, explicit edge lists and stored chunk grid metadata.

‎src/zarr/codecs/sharding.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
default_buffer_prototype,
3737
numpy_buffer_prototype,
3838
)
39-
from zarr.core.chunk_grids import ChunkGrid, parse_regular_chunk_shape
39+
from zarr.core.chunk_grids import ChunkGrid
4040
from zarr.core.chunk_utils import (
4141
ChunkTransform,
4242
decode_and_scatter_chunk,
@@ -47,6 +47,7 @@
4747
from zarr.core.common import (
4848
ShapeLike,
4949
parse_named_configuration,
50+
parse_shapelike,
5051
product,
5152
)
5253
from zarr.core.config import config as zarr_config
@@ -467,7 +468,7 @@ def __init__(
467468
index_location: ShardingCodecIndexLocation | IndexLocation = "end",
468469
subchunk_write_order: SubchunkWriteOrder = "morton",
469470
) -> None:
470-
chunk_shape_parsed = parse_regular_chunk_shape(chunk_shape)
471+
chunk_shape_parsed = parse_shapelike(chunk_shape)
471472
codecs_parsed = parse_codecs(codecs)
472473
index_codecs_parsed = parse_codecs(index_codecs)
473474
_check_index_codecs_fixed_size(index_codecs_parsed)
@@ -506,7 +507,7 @@ def __getstate__(self) -> dict[str, Any]:
506507

507508
def __setstate__(self, state: dict[str, Any]) -> None:
508509
config = state["configuration"]
509-
object.__setattr__(self, "chunk_shape", parse_regular_chunk_shape(config["chunk_shape"]))
510+
object.__setattr__(self, "chunk_shape", parse_shapelike(config["chunk_shape"]))
510511
object.__setattr__(self, "codecs", parse_codecs(config["codecs"]))
511512
object.__setattr__(self, "index_codecs", parse_codecs(config["index_codecs"]))
512513
object.__setattr__(self, "index_location", _parse_index_location(config["index_location"]))

‎src/zarr/core/array.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,18 @@ def _array_metadata_dict_v3(zarr_json_bytes: Buffer) -> dict[str, JSON]:
351351
return metadata_dict
352352

353353

354+
def _v2_chunks_given(chunks: object) -> bool:
355+
"""Whether the legacy Zarr format 2 `chunks` argument is given.
356+
357+
A falsy `chunks` (such as `None`, 0, `[]` or `False`) is read as not given. A numpy
358+
array with more than one element has no truth value and is always given; a shorter
359+
one is given when its element is nonzero (an empty array is not given).
360+
"""
361+
if isinstance(chunks, np.ndarray):
362+
return chunks.size > 1 or bool(chunks.any())
363+
return bool(chunks)
364+
365+
354366
@dataclass(frozen=True)
355367
class AsyncArray[T_ArrayMetadata: (ArrayV2Metadata, ArrayV3Metadata)]:
356368
"""
@@ -520,10 +532,11 @@ async def _create(
520532
item_size = 1
521533
if isinstance(dtype_parsed, HasItemSize):
522534
item_size = dtype_parsed.item_size
523-
if _raw_chunks is None:
535+
_raw_v2 = chunks if _v2_chunks_given(chunks) else chunk_shape
536+
if _raw_v2 is None:
524537
outer_chunks = guess_chunks(shape, item_size)
525538
else:
526-
outer_chunks = normalize_chunks_nd(_raw_chunks, shape)
539+
outer_chunks = normalize_chunks_nd(_raw_v2, shape)
527540
if not outer_chunks.is_regular:
528541
raise ValueError("Zarr format 2 does not support rectilinear chunk grids.")
529542
_chunks = outer_chunks.chunk_shape

‎src/zarr/core/chunk_grids.py‎

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -824,9 +824,7 @@ def normalize_chunks_nd(
824824

825825
if chunks is None or chunks is True:
826826
raise ValueError(
827-
f"{chunks!r} is not a valid chunk input. For automatic chunking pass "
828-
f'chunks="auto" to `create_array` or chunks=None to `zarr.create`; otherwise '
829-
f"pass an int or a sequence of ints."
827+
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.'
830828
)
831829

832830
# handle no chunking: one chunk covering every axis.
@@ -853,35 +851,6 @@ def normalize_chunks_nd(
853851
)
854852

855853

856-
def parse_regular_chunk_shape(chunk_shape: ShapeLike) -> tuple[int, ...]:
857-
"""Parse the chunk shape of a regular chunk grid that has no array shape to be
858-
normalized against, such as the inner chunk shape of a shard.
859-
860-
The chunk normalizer's integer test (`_chunk_int`) decides what a chunk size is, and
861-
every chunk size is at least 1. A single integer is a one-dimensional chunk shape.
862-
Without the lengths of the axes `-1` and `False` cannot be resolved, so they are
863-
rejected as non-positive; an explicit list of chunk edges declares a rectilinear
864-
dimension, which a regular chunk grid cannot have, and is rejected as a non-integer.
865-
"""
866-
size = _chunk_int(chunk_shape)
867-
elements = [size] if size is not None else _chunk_list(chunk_shape)
868-
as_ints = [_chunk_int(e) for e in elements]
869-
non_int = [
870-
(idx, e) for idx, (e, i) in enumerate(zip(elements, as_ints, strict=True)) if i is None
871-
]
872-
if non_int:
873-
non_int_idxs, non_int_vals = [*zip(*non_int, strict=False)]
874-
raise TypeError(
875-
f"Each chunk size of a regular chunk shape must be an integer; got non-integer "
876-
f"element(s) {non_int_vals!r} at indices {non_int_idxs!r}. A regular chunk shape "
877-
f"is a flat sequence of positive integers, one per dimension (e.g. [10, 10])."
878-
)
879-
sizes = tuple(i for i in as_ints if i is not None)
880-
if any(c < 1 for c in sizes):
881-
raise ValueError(f"All chunk sizes must be positive, got {list(sizes)}")
882-
return sizes
883-
884-
885854
def guess_chunks(
886855
shape: tuple[int, ...], typesize: int, *, max_bytes: int | None = None
887856
) -> ChunkGrid:

‎src/zarr/core/metadata/v2.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333

3434
from zarr.core._json import json_to_buffer
3535
from zarr.core.array_spec import ArrayConfig, ArraySpec
36-
from zarr.core.chunk_grids import parse_regular_chunk_shape
3736
from zarr.core.chunk_key_encodings import parse_separator
3837
from zarr.core.common import (
3938
JSON,
@@ -332,10 +331,9 @@ def parse_compressor(data: object) -> Numcodec | None:
332331

333332

334333
def parse_chunks(chunks: ShapeLike, shape: tuple[int, ...]) -> tuple[int, ...]:
335-
"""Check a chunk shape: one integer of at least 1 per array axis, as a regular chunk
336-
shape (see `parse_regular_chunk_shape`). Stored chunk sizes of 0 are read by
337-
`zarr.core.metadata.repair` before they get here."""
338-
chunks_parsed = parse_regular_chunk_shape(chunks)
334+
"""Check a chunk shape: one non-negative integer per array axis (see
335+
`parse_shapelike`). Stored chunk sizes of 0 are read by `zarr.core.metadata.repair`."""
336+
chunks_parsed = parse_shapelike(chunks)
339337
if len(chunks_parsed) != len(shape):
340338
raise ValueError(
341339
f"The `shape` and `chunks` attributes must have the same length. "

‎tests/test_codecs/test_sharding.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -765,13 +765,15 @@ def test_structured_dtype_fill_value() -> None:
765765
[
766766
ShardingCodec(chunk_shape=(8, 8)),
767767
ShardingCodec(chunk_shape=(8, 8), subchunk_write_order="lexicographic"),
768+
ShardingCodec(chunk_shape=(0,)),
768769
],
769-
ids=["default", "lexicographic"],
770+
ids=["default", "lexicographic", "zero-chunk-size"],
770771
)
771772
def test_pickle(codec: ShardingCodec) -> None:
772773
"""ShardingCodec round-trips through pickle, including the non-serialized
773774
``subchunk_write_order`` (which ``to_dict`` omits and which must not silently
774-
revert to the ``morton`` default)."""
775+
revert to the ``morton`` default), and an inner chunk size of 0, which the
776+
constructor accepts."""
775777
restored = pickle.loads(pickle.dumps(codec))
776778
assert restored == codec
777779
assert restored.chunk_shape == codec.chunk_shape

‎tests/test_metadata/test_repair.py‎

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ def _open_strictly(path: Path, mode: Literal["r", "a", "r+"] = "r") -> AnyArray:
254254

255255
def test_stored_negative_chunk_size_rejected() -> None:
256256
"""No known writer stored a negative chunk size: it is rejected, not repaired."""
257-
with pytest.raises(ValueError, match=r"^All chunk sizes must be positive, got \[-1\]$"):
257+
with pytest.raises(ValueError, match="^Expected all values to be non-negative"):
258258
_read_strictly(_v2_doc([4], [-1]))
259259

260260

@@ -331,9 +331,9 @@ def test_read_invalid_edges_in_rectilinear_grid(
331331
(_v3_doc([4], [4.5]), "Dimension 0: chunk edge length must be an int, got 4.5"),
332332
(
333333
_v3_doc([8], [4], inner=[2.0]),
334-
"got non-integer element(s) (2.0,) at indices (0,)",
334+
"Expected an iterable of integers. Got [2.0] instead.",
335335
),
336-
(_v2_doc([20], [10.0]), "got non-integer element(s) (10.0,) at indices (0,)"),
336+
(_v2_doc([20], [10.0]), "Expected an iterable of integers. Got [10.0] instead."),
337337
(
338338
_rectilinear_doc([8], [[[4, 2.0]]]),
339339
"Dimension 0: RLE repeat count must be an int, got 2.0",
@@ -504,9 +504,8 @@ def _sharding_chunk_shape(chunks: Any) -> tuple[tuple[int, ...], object]:
504504
"v2": lambda chunks: ((md := _v2_metadata(chunks)).chunks, md.to_dict()["chunks"]),
505505
"sharding-inner": _sharding_chunk_shape,
506506
}
507-
"""`ArrayV2Metadata` and `ShardingCodec` read a regular chunk shape (see
508-
`parse_regular_chunk_shape`), returning the chunk shape and the value `to_dict` writes
509-
for it."""
507+
"""`ArrayV2Metadata` and `ShardingCodec` read a chunk shape as an array shape, returning
508+
the chunk shape and the value `to_dict` writes for it."""
510509

511510

512511
@pytest.mark.parametrize("site", CHUNK_SHAPE_SITES)
@@ -520,44 +519,37 @@ def _sharding_chunk_shape(chunks: Any) -> tuple[tuple[int, ...], object]:
520519
((np.int64(4),), (4,)),
521520
(np.array([4]), (4,)),
522521
((True,), (1,)),
522+
((0,), (0,)),
523+
((False,), (0,)),
523524
(range(4, 5), (4,)),
524525
],
525526
)
526-
def test_regular_chunk_shape(site: str, chunks: object, expected: tuple[int, ...]) -> None:
527-
"""`ArrayV2Metadata` and `ShardingCodec` read their chunk shape with the chunk
528-
normalizer's integer test: an integer or an iterable of integers of at least 1,
529-
including NumPy integers and bools, written back as given. Reading a stored 0 is
530-
`zarr.core.metadata.repair`' business, before the constructors see it."""
527+
def test_chunk_shape_read_as_array_shape(
528+
site: str, chunks: object, expected: tuple[int, ...]
529+
) -> None:
530+
"""`ArrayV2Metadata` and `ShardingCodec` read their chunk shape as `parse_shapelike`
531+
reads an array shape: an integer or an iterable of non-negative integers, including
532+
NumPy integers and bools. A chunk size of 0 is written back as given; reading a
533+
stored 0 is `zarr.core.metadata.repair`' business."""
531534
parsed, written = CHUNK_SHAPE_SITES[site](chunks)
532535
assert parsed == expected
533536
assert all(type(size) is int for size in parsed)
534537
assert written == expected
535538

536539

537540
@pytest.mark.parametrize("site", CHUNK_SHAPE_SITES)
538-
@pytest.mark.parametrize("chunks", [(0,), (False,), (-1,), 0])
539-
def test_regular_chunk_shape_rejects_non_positive(site: str, chunks: object) -> None:
540-
"""A chunk size below 1 is rejected, including `-1` and `False`, which have no axis
541-
length to be resolved against here."""
542-
with pytest.raises(ValueError, match="All chunk sizes must be positive"):
543-
CHUNK_SHAPE_SITES[site](chunks)
541+
def test_chunk_shape_read_as_array_shape_rejects_negative(site: str) -> None:
542+
with pytest.raises(ValueError, match="Expected all values to be non-negative"):
543+
CHUNK_SHAPE_SITES[site]((-1,))
544544

545545

546546
@pytest.mark.parametrize("site", CHUNK_SHAPE_SITES)
547-
@pytest.mark.parametrize("chunks", [(4.0,), "4", [[2, 2]]])
548-
def test_regular_chunk_shape_rejects_non_integer(site: str, chunks: object) -> None:
549-
"""A non-integer chunk size is rejected, and so is an explicit list of chunk edges,
550-
which declares a rectilinear dimension that a regular chunk shape cannot have."""
551-
with pytest.raises(TypeError, match="Each chunk size of a regular chunk shape must be"):
547+
@pytest.mark.parametrize("chunks", [(4.0,), "4", None])
548+
def test_chunk_shape_read_as_array_shape_rejects_non_integer(site: str, chunks: object) -> None:
549+
with pytest.raises(TypeError, match="Expected an"):
552550
CHUNK_SHAPE_SITES[site](chunks)
553551

554552

555-
@pytest.mark.parametrize("site", CHUNK_SHAPE_SITES)
556-
def test_regular_chunk_shape_rejects_non_iterable(site: str) -> None:
557-
with pytest.raises(TypeError, match="must be an integer or an iterable of integers"):
558-
CHUNK_SHAPE_SITES[site](None)
559-
560-
561553
def _rewrite_doc(path: Path, zarr_format: Literal[2, 3], edit: Any) -> None:
562554
doc_path = path / (".zarray" if zarr_format == 2 else "zarr.json")
563555
doc = json.loads(doc_path.read_text())
@@ -985,6 +977,30 @@ def test_from_dict_keeps_values_that_are_not_json(codec: Any) -> None:
985977
assert isinstance(metadata.codecs[0], ShardingCodec)
986978

987979

980+
@pytest.mark.parametrize(("shape", "expected"), [((0,), (1,)), ((3,), (1,))])
981+
def test_array_from_metadata_with_chunk_size_zero(shape: tuple[int], expected: tuple[int]) -> None:
982+
"""`ArrayV2Metadata` accepts a chunk size of 0, as a stored document may hold it. An
983+
array built from such metadata reads it as the repairs read that document, silently
984+
(no data was read or written under it): `create_hierarchy` stores the metadata as
985+
given and yields such an array, which stores the repair before its first write."""
986+
metadata = ArrayV2Metadata(shape=shape, chunks=(0,), dtype=Int16(), fill_value=0, order="C")
987+
store = MemoryStore()
988+
with warnings.catch_warnings():
989+
warnings.simplefilter("error", ZarrUserWarning)
990+
nodes = dict(zarr.create_hierarchy(store=store, nodes={"a": metadata}))
991+
array = nodes["a"]
992+
assert isinstance(array, zarr.Array)
993+
assert array.chunks == expected
994+
assert json.loads(store._store_dict["a/.zarray"].to_bytes())["chunks"] == [0]
995+
996+
array[...] = 1
997+
998+
# Writing an empty selection stores no chunks, so it stores no metadata either.
999+
resaved = list(expected) if array.size else [0]
1000+
assert json.loads(store._store_dict["a/.zarray"].to_bytes())["chunks"] == resaved
1001+
np.testing.assert_array_equal(zarr.open_array(store, path="a")[...], np.ones(shape))
1002+
1003+
9881004
def test_array_from_metadata_with_numpy_scalar_codec_configuration() -> None:
9891005
"""An array is built from metadata whose codec configuration holds NumPy scalars
9901006
(which are not JSON values), as zarr always built one."""

0 commit comments

Comments
 (0)