22
33import itertools
44import json
5- from collections .abc import Iterable , Mapping , Sequence
5+ from collections .abc import Iterable , Iterator , Mapping , Sequence
66from dataclasses import dataclass , field , replace
77from typing import TYPE_CHECKING , Any , Final , Literal , NotRequired , TypeGuard , cast
88
99from typing_extensions import TypedDict
1010
11- from zarr .abc .codec import ArrayArrayCodec , ArrayBytesCodec , BytesBytesCodec , Codec
11+ from zarr .abc .codec import ArrayArrayCodec , ArrayBytesCodec , BaseCodec , BytesBytesCodec , Codec
1212from zarr .abc .metadata import Metadata
1313from zarr .core ._json import json_to_buffer
1414from zarr .core .array_spec import ArrayConfig , ArraySpec
@@ -132,27 +132,39 @@ def representative_chunk_shape(chunk_grid: ChunkGridMetadata) -> tuple[int, ...]
132132 Regular grids have exactly one chunk shape. Rectilinear grids have many;
133133 the largest edge along each dimension is used. This is only suitable where
134134 a single shape is structurally required (rank checks, codec evolution);
135- size-sensitive validation must consider every distinct chunk shape, see
136- `_distinct_chunk_shapes`.
135+ size-sensitive validation must cover every chunk size, either through the
136+ factored grid or by streaming `_distinct_chunk_shapes`.
137137 """
138138 if isinstance (chunk_grid , RegularChunkGridMetadata ):
139139 return chunk_grid .chunk_shape
140140 return tuple (s if isinstance (s , int ) else max (s ) for s in chunk_grid .chunk_shapes )
141141
142142
143- def _distinct_chunk_shapes (chunk_grid : ChunkGridMetadata ) -> list [tuple [int , ...]]:
143+ def _distinct_chunk_shapes (chunk_grid : ChunkGridMetadata ) -> Iterator [tuple [int , ...]]:
144144 """Every distinct chunk shape occurring in `chunk_grid`.
145145
146146 For a rectilinear grid every combination of per-dimension distinct edges
147147 occurs as an actual chunk shape (each edge along one dimension meets each
148148 edge along every other), so this is the full cross product.
149149 """
150150 if isinstance (chunk_grid , RegularChunkGridMetadata ):
151- return [chunk_grid .chunk_shape ]
151+ yield chunk_grid .chunk_shape
152+ return
152153 per_dim = (
153154 (s ,) if isinstance (s , int ) else tuple (dict .fromkeys (s )) for s in chunk_grid .chunk_shapes
154155 )
155- return list (itertools .product (* per_dim ))
156+ yield from itertools .product (* per_dim )
157+
158+
159+ def _resolved_chunk_specs (
160+ chunk_grid : ChunkGridMetadata , spec : ArraySpec , codecs : tuple [Codec , ...]
161+ ) -> Iterator [ArraySpec ]:
162+ """Stream chunk specs through a codec prefix without storing the cross product."""
163+ for shape in _distinct_chunk_shapes (chunk_grid ):
164+ current = replace (spec , shape = shape )
165+ for codec in codecs :
166+ current = codec .resolve_metadata (current )
167+ yield current
156168
157169
158170def evolve_and_validate_codecs (
@@ -162,59 +174,76 @@ def evolve_and_validate_codecs(
162174 chunk_grid : ChunkGridMetadata ,
163175 chunk_spec : ArraySpec ,
164176) -> tuple [Codec , ...]:
165- """Evolve and validate a codec chain, threading the chunk spec.
166-
167- Each codec is evolved and validated against the chunk spec produced by the
168- previous codec's `resolve_metadata`, the same spec it will see at
169- encode/decode time, not against the array-level metadata. Earlier
170- array->array codecs may change the dtype (`cast_value`) or the shape and
171- even the rank of a chunk (the `reshape` extension codec, which the spec
172- explicitly allows to be followed by `transpose`).
173-
174- `shape` and `chunk_grid` are the array-level values passed to
175- `Codec.validate`. They are handed unchanged to every codec until one
176- changes the shape of any chunk; from then on the array-level values are no
177- longer meaningful for the remaining codecs. Because `validate` checks may
178- be size-sensitive (sharding divisibility), every *distinct* chunk shape of
179- the grid is threaded through `resolve_metadata` and validated
180- individually; for a rectilinear grid, a single representative shape would
181- not be sound: an inner chunk size that divides the largest chunk need not
182- divide the others. Each threaded shape is presented to `validate` as a
183- regular grid of that shape, the only shape-related facts that survive a
184- per-chunk transformation.
185-
186- `chunk_spec` (built from the representative chunk shape) is threaded
187- separately as the single spec used for codec evolution and dtype tracking,
188- since evolution must produce one codec chain.
189-
190- Per-codec `validate` runs before `resolve_metadata`, since the latter
191- may rely on invariants the former checks (e.g. `cast_value` rejects
192- complex source dtypes that would otherwise crash `_do_cast`).
177+ """Evolve and validate codecs while preserving factored chunk geometry.
178+
179+ Codecs with the default identity metadata resolver retain the grid.
180+ CastValue and ScaleOffset also retain its geometry; TransposeCodec permutes
181+ the dimensions of both the shape and grid. These paths resolve one spec per
182+ codec and validate the grid directly, without enumerating chunk combinations.
183+ Only the exact built-in classes use specialized geometry rules; overridden
184+ metadata resolvers must use the general path.
185+
186+ Arbitrary metadata resolvers stream each distinct chunk spec. Validation
187+ uses the array geometry until a chunk shape changes, then each resolved
188+ shape is validated as a regular grid. Full specs retain per-chunk dtype and
189+ fill values. Prefixes are replayed for subsequent codecs rather than cached:
190+ this bounds memory but may still require combinatorial work for arbitrary
191+ transforms. Evolution uses one representative spec to produce one chain.
192+
193+ Validation precedes metadata resolution, whose implementation may require
194+ the validated dtype and geometry. Each codec is evolved only once here.
193195 """
196+ from zarr .codecs .cast_value import CastValue
197+ from zarr .codecs .scale_offset import ScaleOffset
198+ from zarr .codecs .transpose import TransposeCodec
199+
194200 out : list [Codec ] = []
195201 spec = chunk_spec
196- threaded = _distinct_chunk_shapes (chunk_grid )
202+ grid = chunk_grid
203+ validation_shape = shape
204+ fallback_grid : ChunkGridMetadata | None = None
205+ fallback_spec = chunk_spec
206+ fallback_codecs : tuple [Codec , ...] = ()
197207 shapes_changed = False
198208 for codec in codecs :
199209 evolved = codec .evolve_from_array_spec (spec )
200- # The array-level shape and grid are handed to validate until a codec
201- # changes chunk shapes; from then on each distinct chunk shape is
202- # validated as its own regular grid.
203- if shapes_changed :
204- for s in threaded :
210+ preserves_shape = type (evolved ).resolve_metadata is BaseCodec .resolve_metadata or type (
211+ evolved
212+ ) in (CastValue , ScaleOffset )
213+ if fallback_grid is None and (preserves_shape or type (evolved ) is TransposeCodec ):
214+ evolved .validate (shape = validation_shape , dtype = spec .dtype , chunk_grid = grid )
215+ spec = evolved .resolve_metadata (spec )
216+ if type (evolved ) is TransposeCodec :
217+ order = evolved .order
218+ validation_shape = tuple (validation_shape [d ] for d in order )
219+ if isinstance (grid , RegularChunkGridMetadata ):
220+ grid = RegularChunkGridMetadata (
221+ chunk_shape = tuple (grid .chunk_shape [d ] for d in order )
222+ )
223+ else :
224+ grid = RectilinearChunkGridMetadata (
225+ chunk_shapes = tuple (grid .chunk_shapes [d ] for d in order )
226+ )
227+ else :
228+ if fallback_grid is None :
229+ fallback_grid , fallback_spec = grid , spec
230+ changed = False
231+ for current in _resolved_chunk_specs (fallback_grid , fallback_spec , fallback_codecs ):
205232 evolved .validate (
206- shape = s , dtype = spec .dtype , chunk_grid = RegularChunkGridMetadata (chunk_shape = s )
233+ shape = current .shape if shapes_changed else validation_shape ,
234+ dtype = current .dtype ,
235+ chunk_grid = (
236+ RegularChunkGridMetadata (chunk_shape = current .shape )
237+ if shapes_changed
238+ else grid
239+ ),
207240 )
208- else :
209- evolved .validate (shape = shape , dtype = spec .dtype , chunk_grid = chunk_grid )
241+ resolved = evolved .resolve_metadata (current )
242+ changed |= resolved .shape != current .shape
243+ shapes_changed |= changed
244+ fallback_codecs += (evolved ,)
245+ spec = evolved .resolve_metadata (spec )
210246 out .append (evolved )
211- resolved = list (
212- dict .fromkeys (evolved .resolve_metadata (replace (spec , shape = s )).shape for s in threaded )
213- )
214- if resolved != threaded :
215- shapes_changed = True
216- threaded = resolved
217- spec = evolved .resolve_metadata (spec )
218247 return tuple (out )
219248
220249
0 commit comments