Skip to content

Commit f5ef3df

Browse files
committed
Merge branch 'fix/zero-length-single-invariant' (zarr-developers#4334) into test/rectilinear-declaration-space
2 parents a19718b + f93193d commit f5ef3df

9 files changed

Lines changed: 506 additions & 137 deletions

File tree

‎changes/4334.bugfix.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Chunk-grid normalization now shares a helper that selects chunk size 1 for a zero-length axis when inferring a full-span chunk size. Chunk edge lengths remain positive while array extents may be zero. `FixedDimension(size=0, ...)` now raises `ValueError`. Rectilinear chunk grids can be created on a zero-length dimension with a non-empty list of positive chunk sizes; these sizes are retained for subsequent growth.
2+
3+
A stored chunk size of 0 on a zero-length axis is read as 1 with a `ZarrUserWarning`, in both Zarr format 2 (`chunks`) and Zarr format 3 (a regular grid's `chunk_shape`, including the JSON `false` written by zarr-python 3.0 for `chunks=False`); a zero chunk size on a positive-length axis is rejected. zarr-python wrote such metadata for empty arrays until 3.4 — Zarr format 2 through 3.3 and by zarr-python 2.x, Zarr format 3 in 3.0 and 3.1 — and appending to one of these Zarr format 2 arrays previously reported success while writing no chunk, so the appended data read back as the fill value. The warning explains how to store a corrected chunk size: open the array writable and call `array.update_attributes({})`.

‎docs/user-guide/arrays.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,12 @@ z.append(np.arange(10, dtype='float64'))
707707
print(f"After append: shape={z.shape}, chunk_sizes={z.write_chunk_sizes}")
708708
```
709709

710+
A rectilinear array can also be created with a zero-length dimension: because no
711+
non-empty list of positive chunk sizes can sum to 0, the chunk sizes given for such a
712+
dimension are stored as-is and describe the chunks the dimension will grow into
713+
on `append` or `resize` — the same state as resizing an existing rectilinear
714+
dimension down to 0.
715+
710716
### Compressors and filters
711717

712718
Rectilinear arrays work with all codecs — compressors, filters, and checksums.

‎src/zarr/core/chunk_grids.py‎

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -47,22 +47,24 @@
4747
@dataclass(frozen=True)
4848
class FixedDimension:
4949
"""Uniform chunk size. Boundary chunks contain less data but are
50-
encoded at full size by the codec pipeline."""
50+
encoded at full size by the codec pipeline.
5151
52-
size: int # chunk edge length (>= 0)
53-
extent: int # array dimension length
52+
The chunk edge length is always at least 1, matching the invariant the
53+
metadata layer enforces for every stored chunk grid. The extent may be 0:
54+
a zero-length axis simply has zero chunks (``ceildiv(0, size) == 0``).
55+
"""
56+
57+
size: int # chunk edge length (>= 1)
58+
extent: int # array dimension length (>= 0)
5459
nchunks: int = field(init=False, repr=False)
5560
ngridcells: int = field(init=False, repr=False)
5661

5762
def __post_init__(self) -> None:
58-
if self.size < 0:
59-
raise ValueError(f"FixedDimension size must be >= 0, got {self.size}")
63+
if self.size < 1:
64+
raise ValueError(f"FixedDimension size must be >= 1, got {self.size}")
6065
if self.extent < 0:
6166
raise ValueError(f"FixedDimension extent must be >= 0, got {self.extent}")
62-
if self.size == 0:
63-
n = 0
64-
else:
65-
n = ceildiv(self.extent, self.size)
67+
n = ceildiv(self.extent, self.size)
6668
object.__setattr__(self, "nchunks", n)
6769
object.__setattr__(self, "ngridcells", n)
6870

@@ -71,8 +73,6 @@ def index_to_chunk(self, idx: int) -> int:
7173
raise IndexError(f"Negative index {idx} is not allowed")
7274
if idx >= self.extent:
7375
raise IndexError(f"Index {idx} is out of bounds for extent {self.extent}")
74-
if self.size == 0:
75-
return 0
7676
return idx // self.size
7777

7878
def chunk_offset(self, chunk_ix: int) -> int:
@@ -97,8 +97,6 @@ def data_size(self, chunk_ix: int) -> int:
9797
Does not validate *chunk_ix* — callers must ensure it is in
9898
``[0, nchunks)``. Use ``ChunkGrid.__getitem__`` for safe access.
9999
"""
100-
if self.size == 0:
101-
return 0
102100
return max(0, min(self.size, self.extent - chunk_ix * self.size))
103101

104102
@property
@@ -112,8 +110,6 @@ def _unique_edge_lengths(self) -> Iterable[int]:
112110
return (self.size,)
113111

114112
def indices_to_chunks(self, indices: npt.NDArray[np.intp]) -> npt.NDArray[np.intp]:
115-
if self.size == 0:
116-
return np.zeros_like(indices)
117113
return indices // self.size
118114

119115
def with_extent(self, new_extent: int) -> FixedDimension:
@@ -660,6 +656,20 @@ class ChunkLayout(NamedTuple):
660656
inner: ChunkLayout | None = None
661657

662658

659+
def _full_span_chunk_size(span: int) -> int:
660+
"""The edge length of one chunk covering an entire axis of length *span*.
661+
662+
This is *the* definition of "one chunk spans the axis" for a possibly
663+
zero-length axis. Chunk edge lengths must be at least 1 (the invariant
664+
shared by `FixedDimension`, `VaryingDimension` and the stored chunk grid
665+
metadata), so a zero-length axis gets chunk size 1 and zero chunks. Every
666+
spelling that derives a chunk size from a span — ``chunks=-1``,
667+
``chunks=False``, ``chunks="auto"``, ``shards="auto"`` — must route
668+
through this helper rather than clamping on its own.
669+
"""
670+
return max(span, 1)
671+
672+
663673
def _guess_regular_chunks(
664674
shape: tuple[int, ...] | int,
665675
typesize: int,
@@ -697,11 +707,10 @@ def _guess_regular_chunks(
697707
shape = (shape,)
698708

699709
if typesize == 0:
700-
return shape
710+
return tuple(_full_span_chunk_size(s) for s in shape)
701711

702712
ndims = len(shape)
703-
# require chunks to have non-zero length for all dimensions
704-
chunks = np.maximum(np.array(shape, dtype="=f8"), 1)
713+
chunks = np.array([_full_span_chunk_size(s) for s in shape], dtype="=f8")
705714

706715
# Determine the optimal chunk size in bytes using a PyTables expression.
707716
# This is kept as a float.
@@ -744,13 +753,21 @@ def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionG
744753
the span, and the uniform form is O(1) in the number of chunks — a
745754
dimension with `2**62` chunks must not materialize one entry per chunk.
746755
747-
`-1` means "one chunk covering the entire span."
756+
`-1` means "one chunk covering the entire span" (see `_full_span_chunk_size`
757+
for what that means on a zero-length span).
748758
Explicit chunk size lists must sum to the span exactly and always produce
749759
`VaryingDimension`, even when the sizes happen to be uniform: the input
750760
syntax declares the grid kind, so a per-chunk list is preserved as a
751761
rectilinear dimension rather than silently collapsed to a regular one,
752762
which would change how the dimension grows on resize. For scalar sizes
753763
the last chunk may overhang the span.
764+
765+
The one exception to the sum rule is a zero-length span: no non-empty list of
766+
positive edges can sum to 0, so a non-empty list of positive integers is retained
767+
and the edges describe the chunks the axis will grow into on `append` /
768+
`resize`. This is the same state a rectilinear axis reaches when it is
769+
resized down to 0 — `VaryingDimension` allows trailing edges beyond the
770+
extent — so creating at length 0 and shrinking to 0 are indistinguishable.
754771
"""
755772
# `numbers.Integral` rather than `int` so that numpy integer scalars (which are not
756773
# `int` subclasses) take the uniform-chunk path instead of being treated as a sequence.
@@ -761,9 +778,7 @@ def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionG
761778
if chunk_size < -1 or chunk_size == 0:
762779
raise ValueError(f"Chunk size must be positive or -1, got {chunk_size}")
763780
if chunk_size == -1:
764-
# A zero-length span still gets chunk size 1 (chunk sizes must be positive),
765-
# matching the auto-chunking clamp in _guess_regular_chunks.
766-
return FixedDimension(size=max(span, 1), extent=span)
781+
return FixedDimension(size=_full_span_chunk_size(span), extent=span)
767782
return FixedDimension(size=chunk_size, extent=span)
768783
else:
769784
try:
@@ -788,7 +803,9 @@ def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionG
788803
ints: list[int] = [int(c) for c in chunk_list] # type: ignore[call-overload]
789804
if any(c <= 0 for c in ints):
790805
raise ValueError(f"All chunk sizes must be positive, got {ints}")
791-
if sum(ints) != span:
806+
# A zero-length span cannot be covered by positive edges; the edges are the
807+
# chunks the axis will grow into, exactly as after ``resize(0)``.
808+
if span > 0 and sum(ints) != span:
792809
raise ValueError(f"Chunk sizes {ints} do not sum to span {span}")
793810
return VaryingDimension(ints, extent=span)
794811

@@ -829,7 +846,7 @@ def normalize_chunks_nd(
829846
)
830847

831848
# handle no chunking: one chunk covering every axis. Routed through the -1 sentinel so
832-
# the zero-length-axis clamp lives in one place (normalize_chunks_1d).
849+
# the zero-length-axis rule lives in one place (_full_span_chunk_size).
833850
if chunks is False:
834851
chunks = -1
835852

@@ -884,8 +901,10 @@ def _guess_num_chunks_per_axis_shard(
884901
In other words the shard would be a (2,2,2) grid of (2,2,2) chunks
885902
i.e., prod(chunk_shape) * (returned_val ** len(chunk_shape)) * item_size = 256 bytes.
886903
887-
Degenerate chunk shapes — a 0-dimensional shape, or one containing a zero-length
888-
axis — return 1, as the search loop's stopping conditions can never be met.
904+
Degenerate inputs — a 0-dimensional chunk shape, or a zero-byte chunk (``item_size``
905+
of 0; chunk edge lengths themselves are always at least 1) — return 1, as the
906+
search loop's stopping conditions can never be met. A zero-length *array* axis
907+
needs no special case: the array-bound check fails immediately for it.
889908
890909
Parameters
891910
----------
@@ -906,8 +925,8 @@ def _guess_num_chunks_per_axis_shard(
906925
if max_bytes < bytes_per_chunk:
907926
return 1
908927
num_axes = len(chunk_shape)
909-
# For a 0-dimensional chunk shape or one with a zero-length axis, both loop
910-
# conditions below are constant, so the loop would never terminate.
928+
# For a 0-dimensional chunk shape or a zero-byte chunk, both loop conditions
929+
# below are constant, so the loop would never terminate.
911930
if num_axes == 0 or bytes_per_chunk == 0:
912931
return 1
913932
chunks_per_shard = 1

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

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,69 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
import warnings
4+
from typing import TYPE_CHECKING, Final
5+
6+
from zarr.errors import ZarrUserWarning
47

58
if TYPE_CHECKING:
9+
from collections.abc import Sequence
10+
611
from zarr.core.common import JSON
712

13+
RESAVE_METADATA_HINT: Final = (
14+
"Re-save the array metadata to store the corrected value: open the array "
15+
"writable and call `array.update_attributes({})`."
16+
)
17+
"""How to persist metadata that was read under a compatibility policy.
18+
19+
`update_attributes` rewrites the whole metadata document from the parsed
20+
(corrected) metadata, so an empty update is enough.
21+
"""
22+
823

924
def parse_attributes(data: dict[str, JSON] | None) -> dict[str, JSON]:
1025
if data is None:
1126
return {}
1227

1328
return dict(data)
29+
30+
31+
def parse_stored_regular_chunk_shape(
32+
chunk_shape: Sequence[int], shape: Sequence[int], *, legacy_writers: str
33+
) -> tuple[int, ...]:
34+
"""Validate a stored regular chunk grid's chunk shape against the array shape.
35+
36+
This is for regular chunk grids only: Zarr format 2 `chunks`, and the
37+
`chunk_shape` of a Zarr format 3 `regular` grid. Another chunk grid, such
38+
as the rectilinear grid, is free to define its own meaning for a chunk of
39+
length 0, so its chunk sizes must not be passed here.
40+
41+
The chunk shape must have one entry per array axis, and every chunk size
42+
must be at least 1. The exception is a chunk size of 0 on a zero-length
43+
axis, which `legacy_writers` stored for arrays created empty. An empty axis
44+
has no chunk for the size to describe, so it is read as 1 with a
45+
`ZarrUserWarning` that says how to re-save valid metadata. A chunk size of
46+
0 on a positive-length axis is rejected, because the metadata cannot say
47+
how the stored chunks were laid out. JSON `false` counts as 0.
48+
"""
49+
if len(chunk_shape) != len(shape):
50+
raise ValueError(
51+
f"The chunk shape {tuple(chunk_shape)} and the array shape {tuple(shape)} "
52+
"must have the same number of dimensions."
53+
)
54+
parsed: list[int] = []
55+
for dim_idx, (size, extent) in enumerate(zip(chunk_shape, shape, strict=True)):
56+
if size < 1:
57+
if size < 0 or extent != 0:
58+
raise ValueError(
59+
f"Dimension {dim_idx}: chunk edge length must be >= 1, got {size!r}"
60+
)
61+
warnings.warn(
62+
f"Dimension {dim_idx}: chunk edge length {size!r} on a zero-length axis "
63+
f"(as written by {legacy_writers}) is treated as 1. {RESAVE_METADATA_HINT}",
64+
ZarrUserWarning,
65+
stacklevel=3,
66+
)
67+
size = 1
68+
parsed.append(size)
69+
return tuple(parsed)

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

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
)
4343
from zarr.core.config import config, parse_indexing_order
4444
from zarr.core.json_parse import parse_field
45-
from zarr.core.metadata.common import parse_attributes
45+
from zarr.core.metadata.common import parse_attributes, parse_stored_regular_chunk_shape
4646

4747

4848
class ArrayV2MetadataDict(TypedDict):
@@ -88,7 +88,11 @@ def __init__(
8888
Metadata for a Zarr format 2 array.
8989
"""
9090
shape_parsed = parse_shapelike(shape)
91-
chunks_parsed = parse_shapelike(chunks)
91+
chunks_parsed = parse_stored_regular_chunk_shape(
92+
parse_shapelike(chunks),
93+
shape_parsed,
94+
legacy_writers="zarr-python 2.x, and by 3.x before 3.4",
95+
)
9296
compressor_parsed = parse_compressor(compressor)
9397
order_parsed = parse_indexing_order(order)
9498
dimension_separator_parsed = parse_separator(dimension_separator)
@@ -111,7 +115,6 @@ def __init__(
111115
object.__setattr__(self, "attributes", attributes_parsed)
112116

113117
# ensure that the metadata document is consistent
114-
_ = parse_metadata(self)
115118

116119
@property
117120
def ndim(self) -> int:
@@ -323,16 +326,6 @@ def parse_compressor(data: object) -> Numcodec | None:
323326
raise ValueError(msg)
324327

325328

326-
def parse_metadata(data: ArrayV2Metadata) -> ArrayV2Metadata:
327-
if (l_chunks := len(data.chunks)) != (l_shape := len(data.shape)):
328-
msg = (
329-
f"The `shape` and `chunks` attributes must have the same length. "
330-
f"`chunks` has length {l_chunks}, but `shape` has length {l_shape}."
331-
)
332-
raise ValueError(msg)
333-
return data
334-
335-
336329
def get_object_codec_id(maybe_object_codecs: Sequence[JSON]) -> str | None:
337330
"""
338331
Inspect a sequence of codecs / filters for an "object codec", i.e. a codec

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

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from dataclasses import dataclass, field, replace
66
from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypeGuard, cast
77

8+
import numpy as np
89
from typing_extensions import TypedDict
910

1011
from zarr.abc.codec import ArrayArrayCodec, ArrayBytesCodec, BytesBytesCodec, Codec
@@ -35,7 +36,7 @@
3536
from zarr.core.dtype import VariableLengthUTF8, ZDType, get_data_type_from_json
3637
from zarr.core.dtype.common import check_dtype_spec_v3
3738
from zarr.core.json_parse import parse_field
38-
from zarr.core.metadata.common import parse_attributes
39+
from zarr.core.metadata.common import parse_attributes, parse_stored_regular_chunk_shape
3940
from zarr.errors import MetadataValidationError, NodeTypeValidationError
4041
from zarr.registry import get_codec_class
4142

@@ -476,6 +477,48 @@ class ArrayMetadataJSON_V3(TypedDict, extra_items=AllowedExtraField): # type: i
476477
}
477478

478479

480+
def _is_regular_chunk_shape(value: object) -> TypeGuard[Sequence[int]]:
481+
"""Whether a stored `chunk_shape` is a regular chunk shape: a sequence of integers.
482+
483+
JSON `false` counts, because `bool` is an integer type.
484+
"""
485+
return (
486+
isinstance(value, Sequence)
487+
and not isinstance(value, str)
488+
and all(isinstance(size, int | np.integer) for size in value)
489+
)
490+
491+
492+
def _parse_stored_regular_chunk_grid(
493+
chunk_grid: dict[str, JSON] | ChunkGridMetadata | NamedConfig[str, Any],
494+
shape: tuple[int, ...],
495+
) -> dict[str, JSON] | ChunkGridMetadata | NamedConfig[str, Any]:
496+
"""Check a stored regular chunk grid's chunk shape against the array shape.
497+
498+
Only a `regular` grid whose `chunk_shape` is all integers is a regular chunk
499+
shape, and only that is handed to `parse_stored_regular_chunk_shape`.
500+
Anything else is not a regular chunk shape and is left for the chunk grid
501+
parser: other grids define their own chunk semantics. zarr-python 3.0 and
502+
3.1 stored `chunk_shape: [0]` (and 3.0 `[false]`) for an array created with
503+
a zero-length axis. This runs here rather than in the grid parser because
504+
it needs the array shape, which chunk grid metadata does not carry.
505+
"""
506+
if not isinstance(chunk_grid, Mapping) or chunk_grid.get("name") != "regular":
507+
return chunk_grid
508+
configuration = chunk_grid.get("configuration")
509+
if not isinstance(configuration, Mapping):
510+
return chunk_grid
511+
chunk_shape = configuration.get("chunk_shape")
512+
if not _is_regular_chunk_shape(chunk_shape):
513+
return chunk_grid
514+
parsed = parse_stored_regular_chunk_shape(
515+
chunk_shape, shape, legacy_writers="zarr-python 3.0 and 3.1"
516+
)
517+
corrected: dict[str, Any] = dict(chunk_grid)
518+
corrected["configuration"] = {**configuration, "chunk_shape": list(parsed)}
519+
return corrected
520+
521+
479522
@dataclass(frozen=True, kw_only=True)
480523
class ArrayV3Metadata(Metadata):
481524
shape: tuple[int, ...]
@@ -510,7 +553,9 @@ def __init__(
510553
"""
511554

512555
shape_parsed = parse_shapelike(shape)
513-
chunk_grid_parsed = parse_chunk_grid(chunk_grid)
556+
chunk_grid_parsed = parse_chunk_grid(
557+
_parse_stored_regular_chunk_grid(chunk_grid, shape_parsed)
558+
)
514559
chunk_key_encoding_parsed = parse_chunk_key_encoding(chunk_key_encoding)
515560
dimension_names_parsed = parse_dimension_names(dimension_names)
516561
# Note: relying on a type method is numpy-specific

0 commit comments

Comments
 (0)