4747@dataclass (frozen = True )
4848class 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+
663673def _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
0 commit comments