Skip to content

Commit 037adf6

Browse files
authored
Enable stateful tests for LocalStore (#2804)
* Restrict stateful tests to slow hypothesis CI workflow. * cleanuip * Strategy updates * Fix LocalStore test * add changes * add coverage to hypothesis workflow * review comments * Review comments
1 parent 57181c9 commit 037adf6

File tree

8 files changed

+74
-19
lines changed

8 files changed

+74
-19
lines changed

.github/workflows/hypothesis.yaml

+6
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ jobs:
6969
path: .hypothesis/
7070
key: cache-hypothesis-${{ runner.os }}-${{ github.run_id }}
7171

72+
- name: Upload coverage
73+
uses: codecov/codecov-action@v5
74+
with:
75+
token: ${{ secrets.CODECOV_TOKEN }}
76+
verbose: true # optional (default = false)
77+
7278
- name: Generate and publish the report
7379
if: |
7480
failure()

changes/2804.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:py:class:`LocalStore` learned to ``delete_dir``. This makes array and group deletes more efficient.

pyproject.toml

+3-2
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ run = "run-coverage --no-cov"
161161
run-pytest = "run"
162162
run-verbose = "run-coverage --verbose"
163163
run-mypy = "mypy src"
164-
run-hypothesis = "pytest --hypothesis-profile ci tests/test_properties.py tests/test_store/test_stateful*"
164+
run-hypothesis = "run-coverage --hypothesis-profile ci --run-slow-hypothesis tests/test_properties.py tests/test_store/test_stateful*"
165165
list-env = "pip list"
166166

167167
[tool.hatch.envs.doctest]
@@ -398,7 +398,8 @@ filterwarnings = [
398398
"ignore:.*is currently not part in the Zarr format 3 specification.*:UserWarning",
399399
]
400400
markers = [
401-
"gpu: mark a test as requiring CuPy and GPU"
401+
"gpu: mark a test as requiring CuPy and GPU",
402+
"slow_hypothesis: slow hypothesis tests",
402403
]
403404

404405
[tool.repo-review]

src/zarr/storage/_local.py

+13
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,19 @@ async def delete(self, key: str) -> None:
208208
else:
209209
await asyncio.to_thread(path.unlink, True) # Q: we may want to raise if path is missing
210210

211+
async def delete_dir(self, prefix: str) -> None:
212+
# docstring inherited
213+
self._check_writable()
214+
path = self.root / prefix
215+
if path.is_dir():
216+
shutil.rmtree(path)
217+
elif path.is_file():
218+
raise ValueError(f"delete_dir was passed a {prefix=!r} that is a file")
219+
else:
220+
# Non-existent directory
221+
# This path is tested by test_group:test_create_creates_parents for one
222+
pass
223+
211224
async def exists(self, key: str) -> bool:
212225
# docstring inherited
213226
path = self.root / key

src/zarr/testing/stateful.py

+13
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ def can_add(self, path: str) -> bool:
6868
# -------------------- store operations -----------------------
6969
@rule(name=node_names, data=st.data())
7070
def add_group(self, name: str, data: DataObject) -> None:
71+
# Handle possible case-insensitive file systems (e.g. MacOS)
72+
if isinstance(self.store, LocalStore):
73+
name = name.lower()
7174
if self.all_groups:
7275
parent = data.draw(st.sampled_from(sorted(self.all_groups)), label="Group parent")
7376
else:
@@ -90,6 +93,9 @@ def add_array(
9093
name: str,
9194
array_and_chunks: tuple[np.ndarray[Any, Any], tuple[int, ...]],
9295
) -> None:
96+
# Handle possible case-insensitive file systems (e.g. MacOS)
97+
if isinstance(self.store, LocalStore):
98+
name = name.lower()
9399
array, chunks = array_and_chunks
94100
fill_value = data.draw(npst.from_dtype(array.dtype))
95101
if self.all_groups:
@@ -135,6 +141,7 @@ def add_array(
135141
# self.model.rename(from_group, new_path)
136142
# self.repo.store.rename(from_group, new_path)
137143

144+
@precondition(lambda self: self.store.supports_deletes)
138145
@precondition(lambda self: len(self.all_arrays) >= 1)
139146
@rule(data=st.data())
140147
def delete_array_using_del(self, data: DataObject) -> None:
@@ -149,6 +156,7 @@ def delete_array_using_del(self, data: DataObject) -> None:
149156
del group[array_name]
150157
self.all_arrays.remove(array_path)
151158

159+
@precondition(lambda self: self.store.supports_deletes)
152160
@precondition(lambda self: len(self.all_groups) >= 2) # fixme don't delete root
153161
@rule(data=st.data())
154162
def delete_group_using_del(self, data: DataObject) -> None:
@@ -284,6 +292,10 @@ def supports_partial_writes(self) -> bool:
284292
def supports_writes(self) -> bool:
285293
return self.store.supports_writes
286294

295+
@property
296+
def supports_deletes(self) -> bool:
297+
return self.store.supports_deletes
298+
287299

288300
class ZarrStoreStateMachine(RuleBasedStateMachine):
289301
""" "
@@ -366,6 +378,7 @@ def get_partial_values(self, data: DataObject) -> None:
366378
model_vals_ls,
367379
)
368380

381+
@precondition(lambda self: self.store.supports_deletes)
369382
@precondition(lambda self: len(self.model.keys()) > 0)
370383
@rule(data=st.data())
371384
def delete(self, data: DataObject) -> None:

src/zarr/testing/strategies.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import sys
12
from typing import Any
23

34
import hypothesis.extra.numpy as npst
@@ -209,7 +210,7 @@ def basic_indices(draw: st.DrawFn, *, shape: tuple[int], **kwargs: Any) -> Any:
209210

210211

211212
def key_ranges(
212-
keys: SearchStrategy = node_names, max_size: int | None = None
213+
keys: SearchStrategy = node_names, max_size: int = sys.maxsize
213214
) -> SearchStrategy[list[int]]:
214215
"""
215216
Function to generate key_ranges strategy for get_partial_values()
@@ -218,10 +219,14 @@ def key_ranges(
218219
[(key, (range_start, range_end)),
219220
(key, (range_start, range_end)),...]
220221
"""
222+
223+
def make_request(start: int, length: int) -> RangeByteRequest:
224+
return RangeByteRequest(start, end=min(start + length, max_size))
225+
221226
byte_ranges = st.builds(
222-
RangeByteRequest,
227+
make_request,
223228
start=st.integers(min_value=0, max_value=max_size),
224-
end=st.integers(min_value=0, max_value=max_size),
229+
length=st.integers(min_value=0, max_value=max_size),
225230
)
226231
key_tuple = st.tuples(keys, byte_ranges)
227232
return st.lists(key_tuple, min_size=1, max_size=10)

tests/conftest.py

+18
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,24 @@ def zarr_format(request: pytest.FixtureRequest) -> ZarrFormat:
147147
raise ValueError(msg)
148148

149149

150+
def pytest_addoption(parser: Any) -> None:
151+
parser.addoption(
152+
"--run-slow-hypothesis",
153+
action="store_true",
154+
default=False,
155+
help="run slow hypothesis tests",
156+
)
157+
158+
159+
def pytest_collection_modifyitems(config: Any, items: Any) -> None:
160+
if config.getoption("--run-slow-hypothesis"):
161+
return
162+
skip_slow_hyp = pytest.mark.skip(reason="need --run-slow-hypothesis option to run")
163+
for item in items:
164+
if "slow_hypothesis" in item.keywords:
165+
item.add_marker(skip_slow_hyp)
166+
167+
150168
settings.register_profile(
151169
"ci",
152170
max_examples=1000,

tests/test_store/test_stateful.py

+12-14
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
11
# Stateful tests for arbitrary Zarr stores.
22
import pytest
33
from hypothesis.stateful import (
4-
Settings,
54
run_state_machine_as_test,
65
)
76

87
from zarr.abc.store import Store
9-
from zarr.storage import LocalStore, MemoryStore, ZipStore
8+
from zarr.storage import LocalStore, ZipStore
109
from zarr.testing.stateful import ZarrHierarchyStateMachine, ZarrStoreStateMachine
1110

11+
pytestmark = pytest.mark.slow_hypothesis
12+
1213

1314
def test_zarr_hierarchy(sync_store: Store):
1415
def mk_test_instance_sync() -> ZarrHierarchyStateMachine:
1516
return ZarrHierarchyStateMachine(sync_store)
1617

1718
if isinstance(sync_store, ZipStore):
1819
pytest.skip(reason="ZipStore does not support delete")
19-
if isinstance(sync_store, MemoryStore):
20-
run_state_machine_as_test(
21-
mk_test_instance_sync, settings=Settings(report_multiple_bugs=False, max_examples=50)
22-
)
20+
21+
run_state_machine_as_test(mk_test_instance_sync)
2322

2423

2524
def test_zarr_store(sync_store: Store) -> None:
@@ -28,11 +27,10 @@ def mk_test_instance_sync() -> None:
2827

2928
if isinstance(sync_store, ZipStore):
3029
pytest.skip(reason="ZipStore does not support delete")
31-
elif isinstance(sync_store, LocalStore):
32-
pytest.skip(reason="This test has errors")
33-
elif isinstance(sync_store, MemoryStore):
34-
run_state_machine_as_test(mk_test_instance_sync, settings=Settings(max_examples=50))
35-
else:
36-
run_state_machine_as_test(
37-
mk_test_instance_sync, settings=Settings(report_multiple_bugs=True)
38-
)
30+
31+
if isinstance(sync_store, LocalStore):
32+
# This test uses arbitrary keys, which are passed to `set` and `delete`.
33+
# It assumes that `set` and `delete` are the only two operations that modify state.
34+
# But LocalStore, directories can hang around even after a key is delete-d.
35+
pytest.skip(reason="Test isn't suitable for LocalStore.")
36+
run_state_machine_as_test(mk_test_instance_sync)

0 commit comments

Comments
 (0)