Skip to content

Commit 6f30fdb

Browse files
committed
Expand hypothesis strategies
1 parent ca442f8 commit 6f30fdb

4 files changed

Lines changed: 206 additions & 73 deletions

File tree

src/zarr/testing/stateful.py

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@
2323
from zarr.core.buffer import Buffer, BufferPrototype, cpu, default_buffer_prototype
2424
from zarr.core.sync import SyncMixin
2525
from zarr.storage import LocalStore, MemoryStore
26+
from zarr.testing.strategies import (
27+
arrays as zarr_arrays,
28+
)
2629
from zarr.testing.strategies import (
2730
basic_indices,
2831
chunk_paths,
29-
dimension_names,
3032
key_ranges,
3133
node_names,
32-
np_array_and_chunks,
3334
orthogonal_indices,
3435
)
3536
from zarr.testing.strategies import keys as zarr_keys
@@ -118,18 +119,11 @@ def add_group(self, name: str, data: DataObject) -> None:
118119
zarr.group(store=self.store, path=path)
119120
zarr.group(store=self.model, path=path)
120121

121-
@rule(data=st.data(), name=node_names, array_and_chunks=np_array_and_chunks())
122-
def add_array(
123-
self,
124-
data: DataObject,
125-
name: str,
126-
array_and_chunks: tuple[np.ndarray[Any, Any], tuple[int, ...]],
127-
) -> None:
122+
@rule(data=st.data(), name=node_names)
123+
def add_array(self, data: DataObject, name: str) -> None:
128124
# Handle possible case-insensitive file systems (e.g. MacOS)
129125
if isinstance(self.store, LocalStore):
130126
name = name.lower()
131-
array, chunks = array_and_chunks
132-
fill_value = data.draw(npst.from_dtype(array.dtype))
133127
if self.all_groups:
134128
parent = data.draw(st.sampled_from(sorted(self.all_groups)), label="Array parent")
135129
else:
@@ -138,21 +132,46 @@ def add_array(
138132
# TODO: support overwriting potentially by just skipping `self.can_add`
139133
path = f"{parent}/{name}".lstrip("/")
140134
assume(self.can_add(path))
141-
note(f"Adding array: path='{path}' shape={array.shape} chunks={chunks}")
142-
for store in [self.store, self.model]:
143-
zarr.array(
144-
array,
145-
chunks=chunks,
146-
path=path,
147-
store=store,
148-
fill_value=fill_value,
149-
zarr_format=3,
150-
dimension_names=data.draw(
151-
dimension_names(ndim=array.ndim), label="dimension names"
152-
),
153-
# Chose bytes codec to avoid wasting time compressing the data being written
154-
codecs=[BytesCodec()],
155-
)
135+
136+
# Generate array on the model store using the arrays strategy
137+
a = data.draw(
138+
zarr_arrays(
139+
stores=st.just(self.model),
140+
paths=st.just(parent),
141+
array_names=st.just(name),
142+
zarr_formats=st.just(3),
143+
compressors=st.just(BytesCodec()),
144+
open_mode="a",
145+
),
146+
label="generated array",
147+
)
148+
note(f"Adding array: path='{path}' shape={a.shape} chunks={a.metadata.chunk_grid}")
149+
150+
# Recreate the same array in the store under test
151+
from zarr.core.metadata.v3 import RectilinearChunkGridMetadata, RegularChunkGridMetadata
152+
153+
chunk_grid = a.metadata.chunk_grid
154+
chunks_param: tuple[int, ...] | list[list[int]]
155+
if isinstance(chunk_grid, RectilinearChunkGridMetadata):
156+
chunks_param = [
157+
list(dim) if isinstance(dim, tuple) else [dim] for dim in chunk_grid.chunk_shapes
158+
]
159+
elif isinstance(chunk_grid, RegularChunkGridMetadata):
160+
chunks_param = chunk_grid.chunk_shape
161+
else:
162+
chunks_param = a.chunks
163+
164+
root = zarr.open_group(store=self.store, mode="a")
165+
arr = root.create_array(
166+
path,
167+
shape=a.shape,
168+
chunks=chunks_param,
169+
dtype=a.dtype,
170+
fill_value=a.fill_value,
171+
dimension_names=a.metadata.dimension_names, # type: ignore[union-attr]
172+
compressors=None,
173+
)
174+
arr[:] = a[:]
156175
self.all_arrays.add(path)
157176

158177
@rule()

src/zarr/testing/strategies.py

Lines changed: 142 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import math
22
import sys
33
from collections.abc import Callable, Mapping
4+
from contextlib import nullcontext
45
from typing import Any, Literal
56

67
import hypothesis.extra.numpy as npst
@@ -15,10 +16,10 @@
1516
from zarr.codecs.bytes import BytesCodec
1617
from zarr.core.array import Array
1718
from zarr.core.chunk_key_encodings import DefaultChunkKeyEncoding
18-
from zarr.core.common import JSON, ZarrFormat
19+
from zarr.core.common import JSON, AccessModeLiteral, ZarrFormat
1920
from zarr.core.dtype import get_data_type_from_native_dtype
2021
from zarr.core.metadata import ArrayV2Metadata, ArrayV3Metadata
21-
from zarr.core.metadata.v3 import RegularChunkGridMetadata
22+
from zarr.core.metadata.v3 import RectilinearChunkGridMetadata, RegularChunkGridMetadata
2223
from zarr.core.sync import sync
2324
from zarr.storage import MemoryStore, StoreLike
2425
from zarr.storage._common import _dereference_path
@@ -140,11 +141,11 @@ def array_metadata(
140141
# separator = draw(st.sampled_from(['/', '\\']))
141142
shape = draw(array_shapes())
142143
ndim = len(shape)
143-
chunk_shape = draw(array_shapes(min_dims=ndim, max_dims=ndim, min_side=1))
144144
np_dtype = draw(dtypes())
145145
dtype = get_data_type_from_native_dtype(np_dtype)
146146
fill_value = draw(npst.from_dtype(np_dtype))
147147
if zarr_format == 2:
148+
chunk_shape = draw(array_shapes(min_dims=ndim, max_dims=ndim, min_side=1))
148149
return ArrayV2Metadata(
149150
shape=shape,
150151
chunks=chunk_shape,
@@ -157,10 +158,11 @@ def array_metadata(
157158
compressor=None,
158159
)
159160
else:
161+
chunk_grid = draw(chunk_grids(shape=shape))
160162
return ArrayV3Metadata(
161163
shape=shape,
162164
data_type=dtype,
163-
chunk_grid=RegularChunkGridMetadata(chunk_shape=chunk_shape),
165+
chunk_grid=chunk_grid,
164166
fill_value=fill_value,
165167
attributes=draw(attributes), # type: ignore[arg-type]
166168
dimension_names=draw(dimension_names(ndim=ndim)),
@@ -255,6 +257,7 @@ def arrays(
255257
arrays: st.SearchStrategy | None = None,
256258
attrs: st.SearchStrategy = attrs,
257259
zarr_formats: st.SearchStrategy = zarr_formats,
260+
open_mode: AccessModeLiteral = "w",
258261
) -> AnyArray:
259262
store = draw(stores, label="store")
260263
path = draw(paths, label="array parent")
@@ -264,36 +267,61 @@ def arrays(
264267
if arrays is None:
265268
arrays = numpy_arrays(shapes=shapes)
266269
nparray = draw(arrays, label="array data")
267-
chunk_shape = draw(chunk_shapes(shape=nparray.shape), label="chunk shape")
268270
dim_names: None | list[str | None] = None
271+
272+
# For v3 arrays, optionally use RectilinearChunkGridMetadata
273+
chunk_grid_meta: RegularChunkGridMetadata | RectilinearChunkGridMetadata | None = None
269274
shard_shape = None
270275
if zarr_format == 3:
276+
chunk_grid_meta = draw(chunk_grids(shape=nparray.shape), label="chunk grid")
277+
278+
# Sharding is only supported with regular chunk grids, and has complex
279+
# divisibility constraints that don't play well with hypothesis shrinking.
280+
# Disabled for now — sharding should be tested separately.
281+
271282
dim_names = draw(dimension_names(ndim=nparray.ndim), label="dimension names")
272-
if all(s > 0 for s in nparray.shape) and all(c > 0 for c in chunk_shape):
273-
shard_shape = draw(
274-
st.none() | shard_shapes(shape=nparray.shape, chunk_shape=chunk_shape),
275-
label="shard shape",
276-
)
283+
else:
284+
dim_names = None
285+
277286
# test that None works too.
278287
fill_value = draw(st.one_of([st.none(), npst.from_dtype(nparray.dtype)]))
279288
# compressor = draw(compressors)
280289

281290
expected_attrs = {} if attributes is None else attributes
282291

283292
array_path = _dereference_path(path, name)
284-
root = zarr.open_group(store, mode="w", zarr_format=zarr_format)
285-
286-
a = root.create_array(
287-
array_path,
288-
shape=nparray.shape,
289-
chunks=chunk_shape,
290-
shards=shard_shape,
291-
dtype=nparray.dtype,
292-
attributes=attributes,
293-
# compressor=compressor, # FIXME
294-
fill_value=fill_value,
295-
dimension_names=dim_names,
296-
)
293+
root = zarr.open_group(store, mode=open_mode, zarr_format=zarr_format)
294+
295+
# Convert chunk grid metadata to a form create_array accepts:
296+
# - RegularChunkGridMetadata -> flat tuple of ints
297+
# - RectilinearChunkGridMetadata -> nested list of ints (triggers rectilinear path)
298+
# - v2 -> flat tuple of ints
299+
chunks_param: tuple[int, ...] | list[list[int]]
300+
use_rectilinear = False
301+
if zarr_format == 3 and chunk_grid_meta is not None:
302+
if isinstance(chunk_grid_meta, RectilinearChunkGridMetadata):
303+
chunks_param = [
304+
list(dim) if isinstance(dim, tuple) else [dim]
305+
for dim in chunk_grid_meta.chunk_shapes
306+
]
307+
use_rectilinear = True
308+
else:
309+
chunks_param = chunk_grid_meta.chunk_shape
310+
else:
311+
chunks_param = draw(chunk_shapes(shape=nparray.shape), label="chunk shape")
312+
313+
with zarr.config.set({"array.rectilinear_chunks": True}) if use_rectilinear else nullcontext():
314+
a = root.create_array(
315+
array_path,
316+
shape=nparray.shape,
317+
chunks=chunks_param,
318+
shards=shard_shape,
319+
dtype=nparray.dtype,
320+
attributes=attributes,
321+
# compressor=compressor, # FIXME
322+
fill_value=fill_value,
323+
dimension_names=dim_names,
324+
)
297325

298326
assert isinstance(a, Array)
299327
if a.metadata.zarr_format == 3:
@@ -303,8 +331,16 @@ def arrays(
303331
assert a.name == "/" + a.path
304332
assert isinstance(root[array_path], Array)
305333
assert nparray.shape == a.shape
306-
assert chunk_shape == a.chunks
307-
assert shard_shape == a.shards
334+
335+
# Verify chunks — for rectilinear grids, .chunks raises
336+
if zarr_format == 3:
337+
if isinstance(a.metadata.chunk_grid, RectilinearChunkGridMetadata):
338+
assert shard_shape is None
339+
else:
340+
assert isinstance(a.metadata.chunk_grid, RegularChunkGridMetadata)
341+
assert a.metadata.chunk_grid.chunk_shape == a.chunks
342+
assert shard_shape == a.shards
343+
308344
assert a.basename == name, (a.basename, name)
309345
assert dict(a.attrs) == expected_attrs
310346

@@ -334,35 +370,83 @@ def simple_arrays(
334370
def rectilinear_chunks(draw: st.DrawFn, *, shape: tuple[int, ...]) -> list[list[int]]:
335371
"""Generate valid rectilinear chunk shapes for a given array shape.
336372
337-
Each dimension is partitioned into 1..min(size, 10) chunks by drawing
338-
unique divider points within [1, size-1].
373+
Uses two modes per dimension:
374+
- "expanded": random divider points create arbitrary chunk sizes
375+
- "rle": uniform chunks with optional remainder, optionally shuffled
376+
377+
Keeps max chunks per dimension <= 20 to avoid performance issues
378+
in property tests. With higher dimensions, the total chunk count
379+
grows multiplicatively.
339380
"""
340381
chunk_shapes: list[list[int]] = []
341382
for size in shape:
342383
assert size > 0
343-
max_chunks = min(size, 10)
344-
nchunks = draw(st.integers(min_value=1, max_value=max_chunks))
345-
if nchunks == 1:
346-
chunk_shapes.append([size])
347-
else:
348-
dividers = sorted(
349-
draw(
350-
st.lists(
351-
st.integers(min_value=1, max_value=size - 1),
352-
min_size=nchunks - 1,
353-
max_size=nchunks - 1,
354-
unique=True,
384+
if size > 1:
385+
mode = draw(st.sampled_from(["expanded", "rle"]))
386+
if mode == "expanded":
387+
event("rectilinear expanded")
388+
max_chunks = min(size - 1, 20)
389+
nchunks = draw(st.integers(min_value=1, max_value=max_chunks))
390+
dividers = sorted(
391+
draw(
392+
st.lists(
393+
st.integers(min_value=1, max_value=size - 1),
394+
min_size=nchunks - 1,
395+
max_size=nchunks - 1,
396+
unique=True,
397+
)
355398
)
356399
)
357-
)
358-
chunk_shapes.append(
359-
[a - b for a, b in zip(dividers + [size], [0] + dividers, strict=False)]
360-
)
400+
chunk_shapes.append(
401+
[a - b for a, b in zip(dividers + [size], [0] + dividers, strict=False)]
402+
)
403+
else:
404+
# RLE mode: uniform chunks with optional remainder
405+
max_chunk_size = min(size, 20)
406+
chunk_size = draw(st.integers(min_value=1, max_value=max_chunk_size))
407+
n_full = size // chunk_size
408+
remainder = size % chunk_size
409+
chunks_list = [chunk_size] * n_full
410+
if remainder > 0:
411+
chunks_list.append(remainder)
412+
# Optionally shuffle to create non-contiguous duplicate patterns
413+
if draw(st.booleans()):
414+
event("rectilinear rle shuffled")
415+
chunks_list = draw(st.permutations(chunks_list))
416+
else:
417+
event("rectilinear rle")
418+
chunk_shapes.append(list(chunks_list))
419+
else:
420+
chunk_shapes.append([1])
361421
return chunk_shapes
362422

363423

364-
# Rectilinear arrays need min_side >= 2 so divider generation works
365-
_rectilinear_shapes = npst.array_shapes(max_dims=3, min_side=2, max_side=20)
424+
@st.composite
425+
def chunk_grids(
426+
draw: st.DrawFn, *, shape: tuple[int, ...]
427+
) -> RegularChunkGridMetadata | RectilinearChunkGridMetadata:
428+
"""Generate either a RegularChunkGridMetadata or RectilinearChunkGridMetadata.
429+
430+
This allows property tests to exercise both chunk grid types.
431+
"""
432+
# RectilinearChunkGridMetadata doesn't support zero-sized dimensions,
433+
# so use RegularChunkGridMetadata if any dimension is 0
434+
if any(s == 0 for s in shape):
435+
event("using RegularChunkGridMetadata (zero-sized dimensions)")
436+
return RegularChunkGridMetadata(chunk_shape=draw(chunk_shapes(shape=shape)))
437+
438+
if draw(st.booleans()):
439+
chunks = draw(rectilinear_chunks(shape=shape))
440+
event("using RectilinearChunkGridMetadata")
441+
with zarr.config.set({"array.rectilinear_chunks": True}):
442+
return RectilinearChunkGridMetadata(chunk_shapes=tuple(tuple(dim) for dim in chunks))
443+
else:
444+
event("using RegularChunkGridMetadata")
445+
return RegularChunkGridMetadata(chunk_shape=draw(chunk_shapes(shape=shape)))
446+
447+
448+
# Rectilinear arrays need min_side >= 1 so every dimension has at least one element
449+
_rectilinear_shapes = npst.array_shapes(max_dims=3, min_side=1, max_side=20)
366450

367451

368452
@st.composite
@@ -375,10 +459,21 @@ def rectilinear_arrays(
375459
shape = draw(shapes)
376460
chunk_shapes = draw(rectilinear_chunks(shape=shape))
377461

378-
nparray = np.arange(int(np.prod(shape)), dtype="int32").reshape(shape)
462+
np_dtype = draw(dtypes())
463+
nparray = draw(numpy_arrays(shapes=st.just(shape), dtype=np_dtype))
464+
fill_value = draw(st.one_of([st.none(), npst.from_dtype(np_dtype)]))
465+
dim_names = draw(dimension_names(ndim=len(shape)))
466+
379467
store = MemoryStore()
380468
with zarr.config.set({"array.rectilinear_chunks": True}):
381-
a = zarr.create_array(store=store, shape=shape, chunks=chunk_shapes, dtype="int32")
469+
a = zarr.create_array(
470+
store=store,
471+
shape=shape,
472+
chunks=chunk_shapes,
473+
dtype=np_dtype,
474+
fill_value=fill_value,
475+
dimension_names=dim_names,
476+
)
382477
a[:] = nparray
383478

384479
return a

0 commit comments

Comments
 (0)