Skip to content

Commit ebc5743

Browse files
committed
Greatly improve property tests
1 parent 4263325 commit ebc5743

File tree

4 files changed

+107
-21
lines changed

4 files changed

+107
-21
lines changed

src/zarr/testing/stateful.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ def __init__(self, store: Store) -> None:
325325
def init_store(self) -> None:
326326
self.store.clear()
327327

328-
@rule(key=zarr_keys, data=st.binary(min_size=0, max_size=MAX_BINARY_SIZE))
328+
@rule(key=zarr_keys(), data=st.binary(min_size=0, max_size=MAX_BINARY_SIZE))
329329
def set(self, key: str, data: DataObject) -> None:
330330
note(f"(set) Setting {key!r} with {data}")
331331
assert not self.store.read_only
@@ -334,7 +334,7 @@ def set(self, key: str, data: DataObject) -> None:
334334
self.model[key] = data_buf
335335

336336
@precondition(lambda self: len(self.model.keys()) > 0)
337-
@rule(key=zarr_keys, data=st.data())
337+
@rule(key=zarr_keys(), data=st.data())
338338
def get(self, key: str, data: DataObject) -> None:
339339
key = data.draw(
340340
st.sampled_from(sorted(self.model.keys()))
@@ -344,7 +344,7 @@ def get(self, key: str, data: DataObject) -> None:
344344
# to bytes here necessary because data_buf set to model in set()
345345
assert self.model[key] == store_value
346346

347-
@rule(key=zarr_keys, data=st.data())
347+
@rule(key=zarr_keys(), data=st.data())
348348
def get_invalid_zarr_keys(self, key: str, data: DataObject) -> None:
349349
note("(get_invalid)")
350350
assume(key not in self.model)
@@ -408,7 +408,7 @@ def is_empty(self) -> None:
408408
# make sure they either both are or both aren't empty (same state)
409409
assert self.store.is_empty("") == (not self.model)
410410

411-
@rule(key=zarr_keys)
411+
@rule(key=zarr_keys())
412412
def exists(self, key: str) -> None:
413413
note("(exists)")
414414

src/zarr/testing/strategies.py

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import math
12
import sys
23
from typing import Any, Literal
34

45
import hypothesis.extra.numpy as npst
56
import hypothesis.strategies as st
67
import numpy as np
7-
from hypothesis import given, settings # noqa: F401
8+
from hypothesis import event, given, settings # noqa: F401
89
from hypothesis.strategies import SearchStrategy
910

1011
import zarr
@@ -28,6 +29,16 @@
2829
)
2930

3031

32+
@st.composite # type: ignore[misc]
33+
def keys(draw: st.DrawFn, *, max_num_nodes: int | None = None) -> Any:
34+
return draw(st.lists(node_names, min_size=1, max_size=max_num_nodes).map("/".join))
35+
36+
37+
@st.composite # type: ignore[misc]
38+
def paths(draw: st.DrawFn, *, max_num_nodes: int | None = None) -> Any:
39+
return draw(st.just("/") | keys(max_num_nodes=max_num_nodes))
40+
41+
3142
def v3_dtypes() -> st.SearchStrategy[np.dtype]:
3243
return (
3344
npst.boolean_dtypes()
@@ -87,17 +98,19 @@ def clear_store(x: Store) -> Store:
8798
node_names = st.text(zarr_key_chars, min_size=1).filter(
8899
lambda t: t not in (".", "..") and not t.startswith("__")
89100
)
101+
short_node_names = st.text(zarr_key_chars, max_size=3, min_size=1).filter(
102+
lambda t: t not in (".", "..") and not t.startswith("__")
103+
)
90104
array_names = node_names
91105
attrs = st.none() | st.dictionaries(_attr_keys, _attr_values)
92-
keys = st.lists(node_names, min_size=1).map("/".join)
93-
paths = st.just("/") | keys
94106
# st.builds will only call a new store constructor for different keyword arguments
95107
# i.e. stores.examples() will always return the same object per Store class.
96108
# So we map a clear to reset the store.
97109
stores = st.builds(MemoryStore, st.just({})).map(clear_store)
98110
compressors = st.sampled_from([None, "default"])
99111
zarr_formats: st.SearchStrategy[ZarrFormat] = st.sampled_from([3, 2])
100-
array_shapes = npst.array_shapes(max_dims=4, min_side=0)
112+
# We de-prioritize arrays having dim sizes 0, 1, 2
113+
array_shapes = npst.array_shapes(max_dims=4, min_side=3) | npst.array_shapes(max_dims=4, min_side=0)
101114

102115

103116
@st.composite # type: ignore[misc]
@@ -174,11 +187,19 @@ def chunk_shapes(draw: st.DrawFn, *, shape: tuple[int, ...]) -> tuple[int, ...]:
174187
st.tuples(*[st.integers(min_value=0 if size == 0 else 1, max_value=size) for size in shape])
175188
)
176189
# 2. and now generate the chunks tuple
177-
return tuple(
190+
chunks = tuple(
178191
size // nchunks if nchunks > 0 else 0
179192
for size, nchunks in zip(shape, numchunks, strict=True)
180193
)
181194

195+
for c in chunks:
196+
event("chunk size", c)
197+
198+
if any((c != 0 and s % c != 0) for s, c in zip(shape, chunks, strict=True)):
199+
event("smaller last chunk")
200+
201+
return chunks
202+
182203

183204
@st.composite # type: ignore[misc]
184205
def shard_shapes(
@@ -211,7 +232,7 @@ def arrays(
211232
shapes: st.SearchStrategy[tuple[int, ...]] = array_shapes,
212233
compressors: st.SearchStrategy = compressors,
213234
stores: st.SearchStrategy[StoreLike] = stores,
214-
paths: st.SearchStrategy[str | None] = paths,
235+
paths: st.SearchStrategy[str | None] = paths(), # noqa: B008
215236
array_names: st.SearchStrategy = array_names,
216237
arrays: st.SearchStrategy | None = None,
217238
attrs: st.SearchStrategy = attrs,
@@ -267,23 +288,56 @@ def arrays(
267288
return a
268289

269290

291+
@st.composite # type: ignore[misc]
292+
def simple_arrays(
293+
draw: st.DrawFn,
294+
*,
295+
shapes: st.SearchStrategy[tuple[int, ...]] = array_shapes,
296+
) -> Any:
297+
return draw(
298+
arrays(
299+
shapes=shapes,
300+
paths=paths(max_num_nodes=2),
301+
array_names=short_node_names,
302+
attrs=st.none(),
303+
compressors=st.sampled_from([None, "default"]),
304+
)
305+
)
306+
307+
270308
def is_negative_slice(idx: Any) -> bool:
271309
return isinstance(idx, slice) and idx.step is not None and idx.step < 0
272310

273311

312+
@st.composite # type: ignore[misc]
313+
def end_slices(draw: st.DrawFn, *, shape: tuple[int]) -> Any:
314+
"""
315+
A strategy that slices ranges that include the last chunk.
316+
This is intended to stress-test handling of a possibly smaller last chunk.
317+
"""
318+
slicers = []
319+
for size in shape:
320+
start = draw(st.integers(min_value=size // 2, max_value=size - 1))
321+
length = draw(st.integers(min_value=0, max_value=size - start))
322+
slicers.append(slice(start, start + length))
323+
event("drawing end slice")
324+
return tuple(slicers)
325+
326+
274327
@st.composite # type: ignore[misc]
275328
def basic_indices(draw: st.DrawFn, *, shape: tuple[int], **kwargs: Any) -> Any:
276329
"""Basic indices without unsupported negative slices."""
277-
return draw(
278-
npst.basic_indices(shape=shape, **kwargs).filter(
279-
lambda idxr: (
280-
not (
281-
is_negative_slice(idxr)
282-
or (isinstance(idxr, tuple) and any(is_negative_slice(idx) for idx in idxr))
283-
)
330+
strategy = npst.basic_indices(shape=shape, **kwargs).filter(
331+
lambda idxr: (
332+
not (
333+
is_negative_slice(idxr)
334+
or (isinstance(idxr, tuple) and any(is_negative_slice(idx) for idx in idxr))
284335
)
285336
)
286337
)
338+
if math.prod(shape) >= 3:
339+
strategy = end_slices(shape=shape) | strategy
340+
return draw(strategy)
287341

288342

289343
@st.composite # type: ignore[misc]

tests/test_indexing.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,17 @@ def test_orthogonal_indexing_fallback_on_getitem_2d(
425425
np.testing.assert_array_equal(z[index], expected_result)
426426

427427

428+
def test_setitem_repeated_index():
429+
array = zarr.array(data=np.zeros((4,)), chunks=(1,))
430+
indexer = np.array([-1, -1, 0, 0])
431+
array.oindex[(indexer,)] = [0, 1, 2, 3]
432+
np.testing.assert_array_equal(array[:], np.array([3, 0, 0, 1]))
433+
434+
indexer = np.array([-1, 0, 0, -1])
435+
array.oindex[(indexer,)] = [0, 1, 2, 3]
436+
np.testing.assert_array_equal(array[:], np.array([2, 0, 0, 3]))
437+
438+
428439
Index = list[int] | tuple[slice | int | list[int], ...]
429440

430441

@@ -816,6 +827,25 @@ def test_set_orthogonal_selection_1d(store: StorePath) -> None:
816827
_test_set_orthogonal_selection(v, a, z, selection)
817828

818829

830+
def test_set_item_1d_last_two_chunks():
831+
# regression test for GH2849
832+
g = zarr.open_group("foo.zarr", zarr_format=3, mode="w")
833+
a = g.create_array("bar", shape=(10,), chunks=(3,), dtype=int)
834+
data = np.array([7, 8, 9])
835+
a[slice(7, 10)] = data
836+
np.testing.assert_array_equal(a[slice(7, 10)], data)
837+
838+
z = zarr.open_group("foo.zarr", mode="w")
839+
z.create_array("zoo", dtype=float, shape=())
840+
z["zoo"][...] = np.array(1) # why doesn't [:] work?
841+
np.testing.assert_equal(z["zoo"][()], np.array(1))
842+
843+
z = zarr.open_group("foo.zarr", mode="w")
844+
z.create_array("zoo", dtype=float, shape=())
845+
z["zoo"][...] = 1 # why doesn't [:] work?
846+
np.testing.assert_equal(z["zoo"][()], np.array(1))
847+
848+
819849
def _test_set_orthogonal_selection_2d(
820850
v: npt.NDArray[np.int_],
821851
a: npt.NDArray[np.int_],

tests/test_properties.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import hypothesis.extra.numpy as npst
99
import hypothesis.strategies as st
10-
from hypothesis import assume, given
10+
from hypothesis import assume, given, note
1111

1212
from zarr.abc.store import Store
1313
from zarr.core.metadata import ArrayV2Metadata, ArrayV3Metadata
@@ -18,6 +18,7 @@
1818
basic_indices,
1919
numpy_arrays,
2020
orthogonal_indices,
21+
simple_arrays,
2122
stores,
2223
zarr_formats,
2324
)
@@ -50,7 +51,7 @@ def test_array_creates_implicit_groups(array):
5051

5152
@given(data=st.data())
5253
def test_basic_indexing(data: st.DataObject) -> None:
53-
zarray = data.draw(arrays())
54+
zarray = data.draw(simple_arrays())
5455
nparray = zarray[:]
5556
indexer = data.draw(basic_indices(shape=nparray.shape))
5657
actual = zarray[indexer]
@@ -65,7 +66,7 @@ def test_basic_indexing(data: st.DataObject) -> None:
6566
@given(data=st.data())
6667
def test_oindex(data: st.DataObject) -> None:
6768
# integer_array_indices can't handle 0-size dimensions.
68-
zarray = data.draw(arrays(shapes=npst.array_shapes(max_dims=4, min_side=1)))
69+
zarray = data.draw(simple_arrays(shapes=npst.array_shapes(max_dims=4, min_side=1)))
6970
nparray = zarray[:]
7071

7172
zindexer, npindexer = data.draw(orthogonal_indices(shape=nparray.shape))
@@ -76,13 +77,14 @@ def test_oindex(data: st.DataObject) -> None:
7677
new_data = data.draw(npst.arrays(shape=st.just(actual.shape), dtype=nparray.dtype))
7778
nparray[npindexer] = new_data
7879
zarray.oindex[zindexer] = new_data
80+
note((new_data, npindexer, nparray, zindexer, zarray[:]))
7981
assert_array_equal(nparray, zarray[:])
8082

8183

8284
@given(data=st.data())
8385
def test_vindex(data: st.DataObject) -> None:
8486
# integer_array_indices can't handle 0-size dimensions.
85-
zarray = data.draw(arrays(shapes=npst.array_shapes(max_dims=4, min_side=1)))
87+
zarray = data.draw(simple_arrays(shapes=npst.array_shapes(max_dims=4, min_side=1)))
8688
nparray = zarray[:]
8789

8890
indexer = data.draw(

0 commit comments

Comments
 (0)