Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/4334.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Zero-length dimensions are now handled by a single rule instead of a per-spelling patch: a chunk edge length is always at least 1, while a dimension's extent may be 0 (such a dimension simply has zero chunks). Every way of asking for "one chunk covering the axis" β€” `chunks=-1`, `chunks=False`, `chunks="auto"`, and `shards="auto"` β€” now derives the chunk size from the same helper, so they agree on chunk size 1 for a zero-length axis in both Zarr formats. Zarr format 2 metadata now applies the same rule: a stored chunk edge length of 0 on a zero-length axis β€” which zarr-python 2.x wrote for `chunks=False`, `chunks=-1` and `chunks=(0,)` and then could never read, write, append to or resize (every operation raised `ZeroDivisionError`), and which 3.0–3.3 opened but lost data on append β€” is read as 1 with a `ZarrUserWarning`, making such arrays usable, while a chunk edge of 0 on an axis that has data is rejected with a clear error. Rectilinear chunk grids (`chunks=[[...], ...]`) can now be created on a zero-length dimension: since no list of positive edge lengths can sum to 0, the given edge lengths are stored as-is and describe the chunks the dimension will grow into on `append` or `resize`, exactly the state a rectilinear dimension is in after being resized down to 0. The private `FixedDimension(size=0, ...)` model, which previously carried its own zero-size special cases, now raises `ValueError`.
6 changes: 6 additions & 0 deletions docs/user-guide/arrays.md
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,12 @@ z.append(np.arange(10, dtype='float64'))
print(f"After append: shape={z.shape}, chunk_sizes={z.write_chunk_sizes}")
```

A rectilinear array can also be created with a zero-length dimension: because no
list of positive chunk sizes can sum to 0, the chunk sizes given for such a
dimension are stored as-is and describe the chunks the dimension will grow into
on `append` or `resize` β€” the same state as resizing an existing rectilinear
dimension down to 0.

### Compressors and filters

Rectilinear arrays work with all codecs β€” compressors, filters, and checksums.
Expand Down
75 changes: 47 additions & 28 deletions src/zarr/core/chunk_grids.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,24 @@
@dataclass(frozen=True)
class FixedDimension:
"""Uniform chunk size. Boundary chunks contain less data but are
encoded at full size by the codec pipeline."""
encoded at full size by the codec pipeline.

size: int # chunk edge length (>= 0)
extent: int # array dimension length
The chunk edge length is always at least 1, matching the invariant the
metadata layer enforces for every stored chunk grid. The extent may be 0:
a zero-length axis simply has zero chunks (``ceildiv(0, size) == 0``).
"""

size: int # chunk edge length (>= 1)
extent: int # array dimension length (>= 0)
nchunks: int = field(init=False, repr=False)
ngridcells: int = field(init=False, repr=False)

def __post_init__(self) -> None:
if self.size < 0:
raise ValueError(f"FixedDimension size must be >= 0, got {self.size}")
if self.size < 1:
raise ValueError(f"FixedDimension size must be >= 1, got {self.size}")
if self.extent < 0:
raise ValueError(f"FixedDimension extent must be >= 0, got {self.extent}")
if self.size == 0:
n = 0
else:
n = ceildiv(self.extent, self.size)
n = ceildiv(self.extent, self.size)
object.__setattr__(self, "nchunks", n)
object.__setattr__(self, "ngridcells", n)

Expand All @@ -69,8 +71,6 @@ def index_to_chunk(self, idx: int) -> int:
raise IndexError(f"Negative index {idx} is not allowed")
if idx >= self.extent:
raise IndexError(f"Index {idx} is out of bounds for extent {self.extent}")
if self.size == 0:
return 0
return idx // self.size

def chunk_offset(self, chunk_ix: int) -> int:
Expand All @@ -95,8 +95,6 @@ def data_size(self, chunk_ix: int) -> int:
Does not validate *chunk_ix* β€” callers must ensure it is in
``[0, nchunks)``. Use ``ChunkGrid.__getitem__`` for safe access.
"""
if self.size == 0:
return 0
return max(0, min(self.size, self.extent - chunk_ix * self.size))

@property
Expand All @@ -110,8 +108,6 @@ def _unique_edge_lengths(self) -> Iterable[int]:
return (self.size,)

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

def with_extent(self, new_extent: int) -> FixedDimension:
Expand Down Expand Up @@ -640,6 +636,20 @@ class ChunkLayout(NamedTuple):
inner: ChunkLayout | None = None


def _full_span_chunk_size(span: int) -> int:
"""The edge length of one chunk covering an entire axis of length *span*.

This is *the* definition of "one chunk spans the axis" for a possibly
zero-length axis. Chunk edge lengths must be at least 1 (the invariant
shared by `FixedDimension`, `VaryingDimension` and the stored chunk grid
metadata), so a zero-length axis gets chunk size 1 and zero chunks. Every
spelling that derives a chunk size from a span β€” ``chunks=-1``,
``chunks=False``, ``chunks="auto"``, ``shards="auto"`` β€” must route
through this helper rather than clamping on its own.
"""
return max(span, 1)


def _guess_regular_chunks(
shape: tuple[int, ...] | int,
typesize: int,
Expand Down Expand Up @@ -677,11 +687,10 @@ def _guess_regular_chunks(
shape = (shape,)

if typesize == 0:
return shape
return tuple(_full_span_chunk_size(s) for s in shape)

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

# Determine the optimal chunk size in bytes using a PyTables expression.
# This is kept as a float.
Expand Down Expand Up @@ -724,13 +733,21 @@ def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionG
the span, and the uniform form is O(1) in the number of chunks β€” a
dimension with `2**62` chunks must not materialize one entry per chunk.

`-1` means "one chunk covering the entire span."
`-1` means "one chunk covering the entire span" (see `_full_span_chunk_size`
for what that means on a zero-length span).
Explicit chunk size lists must sum to the span exactly and always produce
`VaryingDimension`, even when the sizes happen to be uniform: the input
syntax declares the grid kind, so a per-chunk list is preserved as a
rectilinear dimension rather than silently collapsed to a regular one,
which would change how the dimension grows on resize. For scalar sizes
the last chunk may overhang the span.

The one exception to the sum rule is a zero-length span: no list of
positive edges can sum to 0, so any non-empty list is accepted verbatim
and the edges describe the chunks the axis will grow into on `append` /
`resize`. This is the same state a rectilinear axis reaches when it is
resized down to 0 β€” `VaryingDimension` allows trailing edges beyond the
extent β€” so creating at length 0 and shrinking to 0 are indistinguishable.
"""
# `numbers.Integral` rather than `int` so that numpy integer scalars (which are not
# `int` subclasses) take the uniform-chunk path instead of being treated as a sequence.
Expand All @@ -741,9 +758,7 @@ def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionG
if chunk_size < -1 or chunk_size == 0:
raise ValueError(f"Chunk size must be positive or -1, got {chunk_size}")
if chunk_size == -1:
# A zero-length span still gets chunk size 1 (chunk sizes must be positive),
# matching the auto-chunking clamp in _guess_regular_chunks.
return FixedDimension(size=max(span, 1), extent=span)
return FixedDimension(size=_full_span_chunk_size(span), extent=span)
return FixedDimension(size=chunk_size, extent=span)
else:
try:
Expand All @@ -768,7 +783,9 @@ def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionG
ints: list[int] = [int(c) for c in chunk_list] # type: ignore[call-overload]
if any(c <= 0 for c in ints):
raise ValueError(f"All chunk sizes must be positive, got {ints}")
if sum(ints) != span:
# A zero-length span cannot be covered by positive edges; the edges are the
# chunks the axis will grow into, exactly as after ``resize(0)``.
if span > 0 and sum(ints) != span:
raise ValueError(f"Chunk sizes {ints} do not sum to span {span}")
return VaryingDimension(ints, extent=span)

Expand Down Expand Up @@ -809,7 +826,7 @@ def normalize_chunks_nd(
)

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

Expand Down Expand Up @@ -864,8 +881,10 @@ def _guess_num_chunks_per_axis_shard(
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.

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.
Degenerate inputs β€” a 0-dimensional chunk shape, or a zero-byte chunk (``item_size``
of 0; chunk edge lengths themselves are always at least 1) β€” return 1, as the
search loop's stopping conditions can never be met. A zero-length *array* axis
needs no special case: the array-bound check fails immediately for it.

Parameters
----------
Expand All @@ -886,8 +905,8 @@ def _guess_num_chunks_per_axis_shard(
if max_bytes < bytes_per_chunk:
return 1
num_axes = len(chunk_shape)
# 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.
# For a 0-dimensional chunk shape or a zero-byte chunk, 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
Expand Down
24 changes: 24 additions & 0 deletions src/zarr/core/metadata/v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,30 @@ def __init__(
"""
shape_parsed = parse_shapelike(shape)
chunks_parsed = parse_shapelike(chunks)
# Same invariant as the Zarr format 3 chunk grid metadata: every chunk edge
# length is at least 1. zarr-python 2.x wrote `chunks: [0]` for a zero-length
# axis created with `chunks=False`, `-1` or `(0,)`, and then could not read,
# write, append to or resize the array (every operation divided by zero);
# 3.0-3.3 opened such documents but lost data on append. The axis holds no
# chunks, so the edge is normalized to 1 β€” the grid every other "one chunk
# spans the axis" spelling produces β€” which makes the array usable at last.
# On an axis that has data, 0 is invalid and any data was never stored.
normalized_chunks: list[int] = []
for dim_idx, (extent, chunk) in enumerate(zip(shape_parsed, chunks_parsed, strict=False)):
if chunk < 1:
if chunk < 0 or extent != 0:
raise ValueError(
f"Dimension {dim_idx}: chunk edge length must be >= 1, got {chunk}"
)
warnings.warn(
f"Dimension {dim_idx}: chunk edge length 0 on a zero-length axis "
"(as written by zarr-python 2.x) is treated as 1.",
ZarrUserWarning,
stacklevel=2,
)
chunk = 1
normalized_chunks.append(chunk)
chunks_parsed = tuple(normalized_chunks) + chunks_parsed[len(shape_parsed) :]
compressor_parsed = parse_compressor(compressor)
order_parsed = parse_indexing_order(order)
dimension_separator_parsed = parse_separator(dimension_separator)
Expand Down
Loading
Loading