1212from zarrs ._internal import ChunkItem
1313
1414if TYPE_CHECKING :
15- from collections .abc import Iterable
15+ from collections .abc import Iterable , Iterator
1616 from types import EllipsisType
1717
1818 from zarr .abc .store import ByteGetter , ByteSetter
1919 from zarr .core .array_spec import ArraySpec
2020 from zarr .core .indexing import SelectorTuple
2121 from zarr .dtype import ZDType
2222
23+ BatchInfo = Iterable [
24+ tuple [ByteGetter | ByteSetter , ArraySpec , SelectorTuple , SelectorTuple , bool ]
25+ ]
26+
2327
2428# adapted from https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor
2529def get_max_threads () -> int :
@@ -38,6 +42,34 @@ class FillValueNoneError(Exception):
3842 pass
3943
4044
45+ def _as_int64_batch_info (batch_info : BatchInfo ) -> BatchInfo :
46+ """Normalise the batch's array indices to int64 positions, lazily."""
47+
48+ def cast (sel : SelectorTuple ) -> SelectorTuple :
49+ if isinstance (sel , np .ndarray ):
50+ # A BOOLEAN MASK is not an index array: `zarr`'s `BoolArrayDimIndexer` hands one
51+ # over with a slice out-selection. Cast blindly it becomes [1, 1, ...], which is
52+ # non-decreasing and the right length, so it passes every test below and reads
53+ # element 1 once per True -- silently wrong data. Its positions are what it means.
54+ if sel .dtype .kind == "b" :
55+ return np .flatnonzero (sel ).astype (np .int64 , copy = False )
56+ # Integers and floats only. A `uint64` selection reaches us as float64, because
57+ # zarr subtracts an int64 offset from it, so "f" has to be accepted; anything
58+ # else is not a selection this path can read, and declining is what the caller's
59+ # fallback is for.
60+ if sel .dtype .kind not in "iuf" :
61+ raise DiscontiguousArrayError (sel .dtype )
62+ return sel .astype (np .int64 , copy = False )
63+ if isinstance (sel , tuple ) and any (isinstance (s , np .ndarray ) for s in sel ):
64+ return tuple (map (cast , sel ))
65+ return sel
66+
67+ return (
68+ (byte_getter , chunk_spec , cast (chunk_sel ), cast (out_sel ), is_complete )
69+ for byte_getter , chunk_spec , chunk_sel , out_sel , is_complete in batch_info
70+ )
71+
72+
4173# This is a (mostly) copy of the function from zarr.core.indexing that fixes:
4274# DeprecationWarning: Conversion of an array with ndim > 0 to a scalar is deprecated
4375# TODO: Upstream this fix
@@ -53,10 +85,11 @@ def make_slice_selection(selection: tuple[np.ndarray | float]) -> list[slice]:
5385 slice (int (dim_selection .item ()), int (dim_selection .item ()) + 1 , 1 )
5486 )
5587 else :
56- diff = np .diff (dim_selection )
57- if (diff != 1 ).any () and (diff != 0 ).any ():
58- raise DiscontiguousArrayError (diff )
59- ls .append (slice (dim_selection [0 ], dim_selection [- 1 ] + 1 , 1 ))
88+ # int64 (see `_as_int64`): an unsigned diff wraps a decrease into +1.
89+ steps = dim_selection [1 :] - dim_selection [:- 1 ]
90+ if (steps != 1 ).any () and (steps != 0 ).any ():
91+ raise DiscontiguousArrayError (steps )
92+ ls .append (slice (int (dim_selection [0 ]), int (dim_selection [- 1 ]) + 1 , 1 ))
6093 else :
6194 ls .append (dim_selection )
6295 return ls
@@ -70,6 +103,89 @@ def selector_tuple_to_slice_selection(selector_tuple: SelectorTuple) -> list[sli
70103 return make_slice_selection (selector_tuple )
71104
72105
106+ def _as_selector_tuples (
107+ chunk_selection : SelectorTuple , out_selection : SelectorTuple
108+ ) -> tuple [tuple , tuple ]:
109+ """Both selections as tuples, so an axis can be addressed by position."""
110+ return (
111+ chunk_selection if isinstance (chunk_selection , tuple ) else (chunk_selection ,),
112+ out_selection if isinstance (out_selection , tuple ) else (out_selection ,),
113+ )
114+
115+
116+ def _is_sorted_integer_axis (indices : Any , out_axis_sel : Any ) -> bool :
117+ """Is this one sorted 1-D integer axis written to a contiguous output slice?"""
118+ # Negative indices and the output length are the caller's to judge: one raises where the
119+ # other declines, so the order of those checks belongs at the call site.
120+ return (
121+ isinstance (indices , np .ndarray )
122+ and indices .ndim == 1
123+ # Non-decreasing only: any decrease would mean one box per element, a decode each.
124+ and not (indices [1 :] < indices [:- 1 ]).any ()
125+ and isinstance (out_axis_sel , slice )
126+ and out_axis_sel .step in (None , 1 )
127+ )
128+
129+
130+ def _output_run_matches (indices : np .ndarray , out_axis_sel : slice ) -> bool :
131+ """Does the output slice hold exactly one element per index."""
132+ start = out_axis_sel .start or 0
133+ return out_axis_sel .stop - start == indices .size
134+
135+
136+ def split_selection_runs (
137+ chunk_selection : SelectorTuple , out_selection : SelectorTuple
138+ ) -> Iterator [tuple [SelectorTuple , SelectorTuple ]]:
139+ """Split a selection with one non-consecutive integer-array axis into contiguous boxes.
140+
141+ zarrs describes a chunk read as a rectangular subset, so ``z[[3, 7, 8], :]`` has no
142+ single-box description -- but it is a *stack* of boxes, one per run of consecutive
143+ indices. Only one array axis is split: with two, outer and coordinate indexing disagree
144+ on what the selection means. Anything not splittable is yielded unchanged.
145+ """
146+ chunk_sel , out_sel = _as_selector_tuples (chunk_selection , out_selection )
147+ unsplit = ((chunk_selection , out_selection ),)
148+
149+ array_axes = [
150+ axis for axis , sel in enumerate (chunk_sel ) if isinstance (sel , np .ndarray )
151+ ]
152+ # Equal arity means no axis was dropped, so chunk axis `axis` is output axis `axis`.
153+ if len (array_axes ) != 1 or len (chunk_sel ) != len (out_sel ):
154+ yield from unsplit
155+ return
156+ (axis ,) = array_axes
157+ indices = chunk_sel [axis ]
158+ out_axis_sel = out_sel [axis ]
159+ if not _is_sorted_integer_axis (indices , out_axis_sel ) or not all (
160+ isinstance (sel , slice ) for sel in out_sel
161+ ):
162+ yield from unsplit
163+ return
164+ # this line can be removed once https://github.com/zarr-developers/zarr-python/issues/4285 is fixed
165+ if (indices < 0 ).any ():
166+ raise DiscontiguousArrayError (indices )
167+ out_start = out_axis_sel .start or 0
168+ if not _output_run_matches (indices , out_axis_sel ):
169+ yield from unsplit
170+ return
171+
172+ # Always slices, even for one run: `resulting_shape_from_index` mis-orders a non-leading
173+ # advanced index, and the caller's element-count check then rejects the selection.
174+ boundaries = np .flatnonzero (indices [1 :] != indices [:- 1 ] + 1 ) + 1
175+
176+ for start , stop in zip (
177+ np .concatenate (([0 ], boundaries )),
178+ np .concatenate ((boundaries , [indices .size ])),
179+ strict = True ,
180+ ):
181+ rows = indices [start :stop ]
182+ box_chunk_sel = list (chunk_sel )
183+ box_chunk_sel [axis ] = slice (int (rows [0 ]), int (rows [- 1 ]) + 1 )
184+ box_out_sel = list (out_sel )
185+ box_out_sel [axis ] = slice (out_start + int (start ), out_start + int (stop ))
186+ yield tuple (box_chunk_sel ), tuple (box_out_sel )
187+
188+
73189def resulting_shape_from_index (
74190 array_shape : tuple [int , ...],
75191 index_tuple : tuple [int | slice | EllipsisType | np .ndarray ],
@@ -153,13 +269,56 @@ class RustChunkInfo:
153269 write_empty_chunks : bool
154270
155271
156- def make_chunk_info_for_rust_with_indices (
157- batch_info : Iterable [
158- tuple [ByteGetter | ByteSetter , ArraySpec , SelectorTuple , SelectorTuple , bool ]
159- ],
272+ def chunk_info_for_write (
273+ batch_info : BatchInfo ,
274+ drop_axes : tuple [int , ...],
275+ shape : tuple [int , ...],
276+ ) -> RustChunkInfo :
277+ """Describe a write batch to Rust, one item per entry.
278+
279+ Neither the chunk-unit grouping nor the run splitting a READ gets: both can put two
280+ items on one chunk key, and the write path is a read-modify-write, so two items on one
281+ key race. A write therefore describes exactly what it was given.
282+ """
283+ return _chunk_items (_as_int64_batch_info (batch_info ), drop_axes , shape )
284+
285+
286+ def chunk_info_for_read (
287+ batch_info : BatchInfo ,
288+ drop_axes : tuple [int , ...],
289+ shape : tuple [int , ...],
290+ ) -> RustChunkInfo :
291+ """Describe a read batch to Rust, one box per RUN of consecutive indices.
292+
293+ A sorted integer selection is mostly runs, and a run is a slice. Handing each run over
294+ as a slice is what lets the read reach zarrs at all: a fancy index that is not
295+ contiguous is otherwise declined, and the whole read falls back to zarr-python.
296+ """
297+ return _chunk_items (
298+ [
299+ (byte_getter , chunk_spec , box_chunk_sel , box_out_sel , is_complete )
300+ for (
301+ byte_getter ,
302+ chunk_spec ,
303+ chunk_selection ,
304+ out_selection ,
305+ is_complete ,
306+ ) in _as_int64_batch_info (batch_info )
307+ for box_chunk_sel , box_out_sel in split_selection_runs (
308+ chunk_selection , out_selection
309+ )
310+ ],
311+ drop_axes ,
312+ shape ,
313+ )
314+
315+
316+ def _chunk_items (
317+ batch_info : BatchInfo ,
160318 drop_axes : tuple [int , ...],
161319 shape : tuple [int , ...],
162320) -> RustChunkInfo :
321+ """One ChunkItem per batch entry, the description both paths end at."""
163322 is_constant = shape == ()
164323 chunk_info_with_indices : list [ChunkItem ] = []
165324 write_empty_chunks : bool = True
0 commit comments