Skip to content

Commit 334d6fe

Browse files
Add hypothesis property tests (#1746)
* Add hypothesis tests 1. Roundtrip a numpy array 2. Basic Indexing * Add compressors This is important for #1931 * Add more test * Add zarr_version * Revert "Add zarr_version" This reverts commit 2ad1b35. * ADapt for V3 * Add workflow * Try again * always run * fix env * Try typing * Cleanup * Add vindex * Review feedback * cleanup * WIP * Cleanup * Move to v3/ * another type ignore * Add `_` * Update src/zarr/strategies.py * Update src/zarr/strategies.py * style: pre-commit fixes --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 064b2e0 commit 334d6fe

File tree

6 files changed

+319
-1
lines changed

6 files changed

+319
-1
lines changed

.github/workflows/hypothesis.yaml

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
name: Slow Hypothesis CI
2+
on:
3+
push:
4+
branches:
5+
- "main"
6+
- "v3"
7+
pull_request:
8+
branches:
9+
- "main"
10+
- "v3"
11+
types: [opened, reopened, synchronize, labeled]
12+
schedule:
13+
- cron: "0 0 * * *" # Daily “At 00:00” UTC
14+
workflow_dispatch: # allows you to trigger manually
15+
16+
env:
17+
FORCE_COLOR: 3
18+
19+
jobs:
20+
21+
hypothesis:
22+
name: Slow Hypothesis Tests
23+
runs-on: "ubuntu-latest"
24+
defaults:
25+
run:
26+
shell: bash -l {0}
27+
28+
strategy:
29+
matrix:
30+
python-version: ['3.11']
31+
numpy-version: ['1.26']
32+
dependency-set: ["optional"]
33+
34+
steps:
35+
- uses: actions/checkout@v4
36+
- name: Set up Python
37+
uses: actions/setup-python@v5
38+
with:
39+
python-version: ${{ matrix.python-version }}
40+
cache: 'pip'
41+
- name: Install Hatch
42+
run: |
43+
python -m pip install --upgrade pip
44+
pip install hatch
45+
- name: Set Up Hatch Env
46+
run: |
47+
hatch env create test.py${{ matrix.python-version }}-${{ matrix.numpy-version }}-${{ matrix.dependency-set }}
48+
hatch env run -e test.py${{ matrix.python-version }}-${{ matrix.numpy-version }}-${{ matrix.dependency-set }} list-env
49+
# https://github.com/actions/cache/blob/main/tips-and-workarounds.md#update-a-cache
50+
- name: Restore cached hypothesis directory
51+
id: restore-hypothesis-cache
52+
uses: actions/cache/restore@v4
53+
with:
54+
path: .hypothesis/
55+
key: cache-hypothesis-${{ runner.os }}-${{ github.run_id }}
56+
restore-keys: |
57+
cache-hypothesis-
58+
59+
- name: Run slow Hypothesis tests
60+
if: success()
61+
id: status
62+
run: |
63+
hatch env run --env test.py${{ matrix.python-version }}-${{ matrix.numpy-version }}-${{ matrix.dependency-set }} run-hypothesis
64+
65+
# explicitly save the cache so it gets updated, also do this even if it fails.
66+
- name: Save cached hypothesis directory
67+
id: save-hypothesis-cache
68+
if: always() && steps.status.outcome != 'skipped'
69+
uses: actions/cache/save@v4
70+
with:
71+
path: .hypothesis/
72+
key: cache-hypothesis-${{ runner.os }}-${{ github.run_id }}
73+
74+
- name: Generate and publish the report
75+
if: |
76+
failure()
77+
&& steps.status.outcome == 'failure'
78+
&& github.event_name == 'schedule'
79+
&& github.repository_owner == 'zarr-developers'
80+
uses: xarray-contrib/issue-from-pytest-log@v1
81+
with:
82+
log-path: output-${{ matrix.python-version }}-log.jsonl
83+
issue-title: "Nightly Hypothesis tests failed"
84+
issue-label: "topic-hypothesis"

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,8 @@ src/zarr/_version.py
7878
#test_sync*
7979
data/*
8080
src/fixture/
81+
fixture/
8182

8283
.DS_Store
84+
tests/.hypothesis
85+
.hypothesis/

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ extra-dependencies = [
120120
"flask-cors",
121121
"flask",
122122
"requests",
123-
"mypy"
123+
"mypy",
124+
"hypothesis"
124125
]
125126
features = ["extra"]
126127

@@ -139,6 +140,7 @@ run-coverage = "pytest --cov-config=pyproject.toml --cov=pkg --cov=tests"
139140
run = "run-coverage --no-cov"
140141
run-verbose = "run-coverage --verbose"
141142
run-mypy = "mypy src"
143+
run-hypothesis = "pytest --hypothesis-profile ci tests/v3/test_properties.py"
142144
list-env = "pip list"
143145

144146
[tool.hatch.envs.docs]

src/zarr/strategies.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from typing import Any
2+
3+
import hypothesis.extra.numpy as npst
4+
import hypothesis.strategies as st
5+
import numpy as np
6+
from hypothesis import given, settings # noqa
7+
8+
from .array import Array
9+
from .group import Group
10+
from .store import MemoryStore, StoreLike
11+
12+
# Copied from Xarray
13+
_attr_keys = st.text(st.characters(), min_size=1)
14+
_attr_values = st.recursive(
15+
st.none() | st.booleans() | st.text(st.characters(), max_size=5),
16+
lambda children: st.lists(children) | st.dictionaries(_attr_keys, children),
17+
max_leaves=3,
18+
)
19+
20+
# From https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html#node-names
21+
# 1. must not be the empty string ("")
22+
# 2. must not include the character "/"
23+
# 3. must not be a string composed only of period characters, e.g. "." or ".."
24+
# 4. must not start with the reserved prefix "__"
25+
zarr_key_chars = st.sampled_from(
26+
".-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"
27+
)
28+
node_names = st.text(zarr_key_chars, min_size=1).filter(
29+
lambda t: t not in (".", "..") and not t.startswith("__")
30+
)
31+
array_names = node_names
32+
attrs = st.none() | st.dictionaries(_attr_keys, _attr_values)
33+
paths = st.lists(node_names, min_size=1).map(lambda x: "/".join(x)) | st.just("/")
34+
np_arrays = npst.arrays(
35+
# TODO: re-enable timedeltas once they are supported
36+
dtype=npst.scalar_dtypes().filter(lambda x: x.kind != "m"),
37+
shape=npst.array_shapes(max_dims=4),
38+
)
39+
stores = st.builds(MemoryStore, st.just({}), mode=st.just("w"))
40+
compressors = st.sampled_from([None, "default"])
41+
42+
43+
@st.composite # type: ignore[misc]
44+
def np_array_and_chunks(
45+
draw: st.DrawFn, *, arrays: st.SearchStrategy[np.ndarray] = np_arrays
46+
) -> tuple[np.ndarray, tuple[int]]: # type: ignore[type-arg]
47+
"""A hypothesis strategy to generate small sized random arrays.
48+
49+
Returns: a tuple of the array and a suitable random chunking for it.
50+
"""
51+
array = draw(arrays)
52+
# We want this strategy to shrink towards arrays with smaller number of chunks
53+
# 1. st.integers() shrinks towards smaller values. So we use that to generate number of chunks
54+
numchunks = draw(st.tuples(*[st.integers(min_value=1, max_value=size) for size in array.shape]))
55+
# 2. and now generate the chunks tuple
56+
chunks = tuple(size // nchunks for size, nchunks in zip(array.shape, numchunks, strict=True))
57+
return (array, chunks)
58+
59+
60+
@st.composite # type: ignore[misc]
61+
def arrays(
62+
draw: st.DrawFn,
63+
*,
64+
compressors: st.SearchStrategy = compressors,
65+
stores: st.SearchStrategy[StoreLike] = stores,
66+
arrays: st.SearchStrategy[np.ndarray] = np_arrays,
67+
paths: st.SearchStrategy[None | str] = paths,
68+
array_names: st.SearchStrategy = array_names,
69+
attrs: st.SearchStrategy = attrs,
70+
) -> Array:
71+
store = draw(stores)
72+
nparray, chunks = draw(np_array_and_chunks(arrays=arrays))
73+
path = draw(paths)
74+
name = draw(array_names)
75+
attributes = draw(attrs)
76+
# compressor = draw(compressors)
77+
78+
# TODO: clean this up
79+
# if path is None and name is None:
80+
# array_path = None
81+
# array_name = None
82+
# elif path is None and name is not None:
83+
# array_path = f"{name}"
84+
# array_name = f"/{name}"
85+
# elif path is not None and name is None:
86+
# array_path = path
87+
# array_name = None
88+
# elif path == "/":
89+
# assert name is not None
90+
# array_path = name
91+
# array_name = "/" + name
92+
# else:
93+
# assert name is not None
94+
# array_path = f"{path}/{name}"
95+
# array_name = "/" + array_path
96+
97+
expected_attrs = {} if attributes is None else attributes
98+
99+
array_path = path + ("/" if not path.endswith("/") else "") + name
100+
root = Group.create(store)
101+
fill_value_args: tuple[Any, ...] = tuple()
102+
if nparray.dtype.kind == "M":
103+
fill_value_args = ("ns",)
104+
105+
a = root.create_array(
106+
array_path,
107+
shape=nparray.shape,
108+
chunks=chunks,
109+
dtype=nparray.dtype.str,
110+
attributes=attributes,
111+
# compressor=compressor, # TODO: FIXME
112+
fill_value=nparray.dtype.type(0, *fill_value_args),
113+
)
114+
115+
assert isinstance(a, Array)
116+
assert nparray.shape == a.shape
117+
assert chunks == a.chunks
118+
assert array_path == a.path, (path, name, array_path, a.name, a.path)
119+
# assert array_path == a.name, (path, name, array_path, a.name, a.path)
120+
# assert a.basename is None # TODO
121+
# assert a.store == normalize_store_arg(store)
122+
assert dict(a.attrs) == expected_attrs
123+
124+
a[:] = nparray
125+
126+
return a
127+
128+
129+
def is_negative_slice(idx: Any) -> bool:
130+
return isinstance(idx, slice) and idx.step is not None and idx.step < 0
131+
132+
133+
@st.composite # type: ignore[misc]
134+
def basic_indices(draw: st.DrawFn, *, shape: tuple[int], **kwargs): # type: ignore[no-untyped-def]
135+
"""Basic indices without unsupported negative slices."""
136+
return draw(
137+
npst.basic_indices(shape=shape, **kwargs).filter(
138+
lambda idxr: (
139+
not (
140+
is_negative_slice(idxr)
141+
or (isinstance(idxr, tuple) and any(is_negative_slice(idx) for idx in idxr))
142+
)
143+
)
144+
)
145+
)

tests/v3/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import numpy as np
2020
import pytest
21+
from hypothesis import HealthCheck, Verbosity, settings
2122

2223
from zarr.store import LocalStore, MemoryStore, StorePath
2324
from zarr.store.remote import RemoteStore
@@ -119,3 +120,17 @@ def array_fixture(request: pytest.FixtureRequest) -> np.ndarray:
119120
.reshape(array_request.shape, order=array_request.order)
120121
.astype(array_request.dtype)
121122
)
123+
124+
125+
settings.register_profile(
126+
"ci",
127+
max_examples=1000,
128+
deadline=None,
129+
suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow],
130+
)
131+
settings.register_profile(
132+
"local",
133+
max_examples=300,
134+
suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow],
135+
verbosity=Verbosity.verbose,
136+
)

tests/v3/test_properties.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import numpy as np
2+
import pytest
3+
from numpy.testing import assert_array_equal
4+
5+
pytest.importorskip("hypothesis")
6+
7+
import hypothesis.extra.numpy as npst # noqa
8+
import hypothesis.strategies as st # noqa
9+
from hypothesis import given, settings # noqa
10+
from zarr.strategies import arrays, np_arrays, basic_indices # noqa
11+
12+
13+
@given(st.data())
14+
def test_roundtrip(data):
15+
nparray = data.draw(np_arrays)
16+
zarray = data.draw(arrays(arrays=st.just(nparray)))
17+
assert_array_equal(nparray, zarray[:])
18+
19+
20+
@given(data=st.data())
21+
def test_basic_indexing(data):
22+
zarray = data.draw(arrays())
23+
nparray = zarray[:]
24+
indexer = data.draw(basic_indices(shape=nparray.shape))
25+
actual = zarray[indexer]
26+
assert_array_equal(nparray[indexer], actual)
27+
28+
new_data = np.ones_like(actual)
29+
zarray[indexer] = new_data
30+
nparray[indexer] = new_data
31+
assert_array_equal(nparray, zarray[:])
32+
33+
34+
@given(data=st.data())
35+
def test_vindex(data):
36+
zarray = data.draw(arrays())
37+
nparray = zarray[:]
38+
39+
indexer = data.draw(
40+
npst.integer_array_indices(
41+
shape=nparray.shape, result_shape=npst.array_shapes(max_dims=None)
42+
)
43+
)
44+
actual = zarray.vindex[indexer]
45+
assert_array_equal(nparray[indexer], actual)
46+
47+
48+
# @st.composite
49+
# def advanced_indices(draw, *, shape):
50+
# basic_idxr = draw(
51+
# basic_indices(
52+
# shape=shape, min_dims=len(shape), max_dims=len(shape), allow_ellipsis=False
53+
# ).filter(lambda x: isinstance(x, tuple))
54+
# )
55+
56+
# int_idxr = draw(
57+
# npst.integer_array_indices(shape=shape, result_shape=npst.array_shapes(max_dims=1))
58+
# )
59+
# args = tuple(
60+
# st.sampled_from((l, r)) for l, r in zip_longest(basic_idxr, int_idxr, fillvalue=slice(None))
61+
# )
62+
# return draw(st.tuples(*args))
63+
64+
65+
# @given(st.data())
66+
# def test_roundtrip_object_array(data):
67+
# nparray = data.draw(np_arrays)
68+
# zarray = data.draw(arrays(arrays=st.just(nparray)))
69+
# assert_array_equal(nparray, zarray[:])

0 commit comments

Comments
 (0)