From 5d4f03a8a912a9f3ec8b6569844ac277cb5d2ca8 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Thu, 10 Oct 2024 18:58:05 -0700 Subject: [PATCH] finish replacing interface tests with new helper system --- src/numpydantic/testing/cases.py | 97 ++++++++++++++------------- src/numpydantic/testing/helpers.py | 7 ++ src/numpydantic/testing/interfaces.py | 66 +++++++++++++----- tests/fixtures/generation.py | 5 +- tests/test_interface/test_hdf5.py | 57 +++++----------- tests/test_interface/test_numpy.py | 26 ++----- tests/test_interface/test_zarr.py | 75 ++++++--------------- 7 files changed, 150 insertions(+), 183 deletions(-) diff --git a/src/numpydantic/testing/cases.py b/src/numpydantic/testing/cases.py index b7a850e..12f0a36 100644 --- a/src/numpydantic/testing/cases.py +++ b/src/numpydantic/testing/cases.py @@ -31,6 +31,53 @@ else: YES_PIPE = False +def merged_product( + *args: Sequence[ValidationCase], +) -> Generator[ValidationCase, None, None]: + """ + Generator for the product of the iterators of validation cases, + merging each tuple, and respecting if they should be :meth:`.ValidationCase.skip` + or not. + + Examples: + + .. code-block:: python + + shape_cases = [ + ValidationCase(shape=(10, 10, 10), passes=True, id="valid shape"), + ValidationCase(shape=(10, 10), passes=False, id="missing dimension"), + ] + dtype_cases = [ + ValidationCase(dtype=float, passes=True, id="float"), + ValidationCase(dtype=int, passes=False, id="int"), + ] + + iterator = merged_product(shape_cases, dtype_cases)) + next(iterator) + # ValidationCase( + # shape=(10, 10, 10), + # dtype=float, + # passes=True, + # id="valid shape-float" + # ) + next(iterator) + # ValidationCase( + # shape=(10, 10, 10), + # dtype=int, + # passes=False, + # id="valid shape-int" + # ) + + + """ + iterator = product(*args) + for case_tuple in iterator: + case = merge_cases(case_tuple) + if case.skip(): + continue + yield case + + class BasicModel(BaseModel): x: int @@ -58,7 +105,6 @@ FLOAT: TypeAlias = NDArray[Shape["*, *, *"], Float] STRING: TypeAlias = NDArray[Shape["*, *, *"], str] MODEL: TypeAlias = NDArray[Shape["*, *, *"], BasicModel] UNION_TYPE: TypeAlias = NDArray[Shape["*, *, *"], Union[np.uint32, np.float32]] -UNION_PIPE: TypeAlias = NDArray[Shape["*, *, *"], np.uint32 | np.float32] SHAPE_CASES = ( ValidationCase(shape=(10, 10, 10), passes=True, id="valid shape"), @@ -135,6 +181,8 @@ DTYPE_CASES = [ if YES_PIPE: + UNION_PIPE: TypeAlias = NDArray[Shape["*, *, *"], np.uint32 | np.float32] + DTYPE_CASES.extend( [ ValidationCase( @@ -178,50 +226,3 @@ _INTERFACE_CASES = [ ZarrNestedCase, VideoCase, ] - - -def merged_product( - *args: Sequence[ValidationCase], -) -> Generator[ValidationCase, None, None]: - """ - Generator for the product of the iterators of validation cases, - merging each tuple, and respecting if they should be :meth:`.ValidationCase.skip` - or not. - - Examples: - - .. code-block:: python - - shape_cases = [ - ValidationCase(shape=(10, 10, 10), passes=True, id="valid shape"), - ValidationCase(shape=(10, 10), passes=False, id="missing dimension"), - ] - dtype_cases = [ - ValidationCase(dtype=float, passes=True, id="float"), - ValidationCase(dtype=int, passes=False, id="int"), - ] - - iterator = merged_product(shape_cases, dtype_cases)) - next(iterator) - # ValidationCase( - # shape=(10, 10, 10), - # dtype=float, - # passes=True, - # id="valid shape-float" - # ) - next(iterator) - # ValidationCase( - # shape=(10, 10, 10), - # dtype=int, - # passes=False, - # id="valid shape-int" - # ) - - - """ - iterator = product(*args) - for case_tuple in iterator: - case = merge_cases(case_tuple) - if case.skip(): - continue - yield case diff --git a/src/numpydantic/testing/helpers.py b/src/numpydantic/testing/helpers.py index 5abbcd0..20a1e16 100644 --- a/src/numpydantic/testing/helpers.py +++ b/src/numpydantic/testing/helpers.py @@ -46,9 +46,16 @@ class InterfaceCase(ABC): shape: Tuple[int, ...] = (10, 10), dtype: DtypeType = float, path: Optional[Path] = None, + array: Optional[NDArrayType] = None, ) -> Optional[NDArrayType]: """ Make an array from a shape and dtype, and a path if needed + + Args: + shape: shape of the array + dtype: dtype of the array + path: Path, if needed to generate on disk + array: Rather than passing shape and dtype, pass a literal arraylike thing """ @classmethod diff --git a/src/numpydantic/testing/interfaces.py b/src/numpydantic/testing/interfaces.py index c4846ae..85d19bf 100644 --- a/src/numpydantic/testing/interfaces.py +++ b/src/numpydantic/testing/interfaces.py @@ -19,7 +19,7 @@ from numpydantic.interface import ( ZarrInterface, ) from numpydantic.testing.helpers import InterfaceCase -from numpydantic.types import DtypeType +from numpydantic.types import DtypeType, NDArrayType class NumpyCase(InterfaceCase): @@ -33,8 +33,11 @@ class NumpyCase(InterfaceCase): shape: Tuple[int, ...] = (10, 10), dtype: DtypeType = float, path: Optional[Path] = None, + array: Optional[NDArrayType] = None, ) -> np.ndarray: - if issubclass(dtype, BaseModel): + if array is not None: + return np.array(array, dtype=dtype) + elif issubclass(dtype, BaseModel): return np.full(shape=shape, fill_value=dtype(x=1)) else: return np.zeros(shape=shape, dtype=dtype) @@ -59,6 +62,7 @@ class HDF5Case(_HDF5MetaCase): shape: Tuple[int, ...] = (10, 10), dtype: DtypeType = float, path: Optional[Path] = None, + array: Optional[NDArrayType] = None, ) -> Optional[H5ArrayPath]: if cls.skip(shape, dtype): return None @@ -67,7 +71,9 @@ class HDF5Case(_HDF5MetaCase): array_path = "/" + "_".join([str(s) for s in shape]) + "__" + dtype.__name__ generator = np.random.default_rng() - if dtype is str: + if array is not None: + data = np.array(array, dtype=dtype) + elif dtype is str: data = generator.random(shape).astype(bytes) elif dtype is datetime: data = np.empty(shape, dtype="S32") @@ -91,13 +97,16 @@ class HDF5CompoundCase(_HDF5MetaCase): shape: Tuple[int, ...] = (10, 10), dtype: DtypeType = float, path: Optional[Path] = None, + array: Optional[NDArrayType] = None, ) -> Optional[H5ArrayPath]: if cls.skip(shape, dtype): return None hdf5_file = path / "h5f.h5" array_path = "/" + "_".join([str(s) for s in shape]) + "__" + dtype.__name__ - if dtype is str: + if array is not None: + data = np.array(array, dtype=dtype) + elif dtype is str: dt = np.dtype([("data", np.dtype("S10")), ("extra", "i8")]) data = np.array([("hey", 0)] * np.prod(shape), dtype=dt).reshape(shape) elif dtype is datetime: @@ -128,7 +137,10 @@ class DaskCase(InterfaceCase): shape: Tuple[int, ...] = (10, 10), dtype: DtypeType = float, path: Optional[Path] = None, + array: Optional[NDArrayType] = None, ) -> da.Array: + if array is not None: + return da.array(array, dtype=dtype, chunks=-1) if issubclass(dtype, BaseModel): return da.full(shape=shape, fill_value=dtype(x=1), chunks=-1) else: @@ -142,7 +154,7 @@ class _ZarrMetaCase(InterfaceCase): @classmethod def skip(cls, shape: Tuple[int, ...], dtype: DtypeType) -> bool: - return not issubclass(dtype, BaseModel) + return issubclass(dtype, BaseModel) class ZarrCase(_ZarrMetaCase): @@ -154,8 +166,12 @@ class ZarrCase(_ZarrMetaCase): shape: Tuple[int, ...] = (10, 10), dtype: DtypeType = float, path: Optional[Path] = None, + array: Optional[NDArrayType] = None, ) -> Optional[zarr.Array]: - return zarr.zeros(shape=shape, dtype=dtype) + if array is not None: + return zarr.array(array, dtype=dtype, chunks=-1) + else: + return zarr.zeros(shape=shape, dtype=dtype) class ZarrDirCase(_ZarrMetaCase): @@ -167,9 +183,13 @@ class ZarrDirCase(_ZarrMetaCase): shape: Tuple[int, ...] = (10, 10), dtype: DtypeType = float, path: Optional[Path] = None, + array: Optional[NDArrayType] = None, ) -> Optional[zarr.Array]: store = zarr.DirectoryStore(str(path / "array.zarr")) - return zarr.zeros(shape=shape, dtype=dtype, store=store) + if array is not None: + return zarr.array(array, dtype=dtype, store=store, chunks=-1) + else: + return zarr.zeros(shape=shape, dtype=dtype, store=store) class ZarrZipCase(_ZarrMetaCase): @@ -181,9 +201,13 @@ class ZarrZipCase(_ZarrMetaCase): shape: Tuple[int, ...] = (10, 10), dtype: DtypeType = float, path: Optional[Path] = None, + array: Optional[NDArrayType] = None, ) -> Optional[zarr.Array]: store = zarr.ZipStore(str(path / "array.zarr"), mode="w") - return zarr.zeros(shape=shape, dtype=dtype, store=store) + if array is not None: + return zarr.array(array, dtype=dtype, store=store, chunks=-1) + else: + return zarr.zeros(shape=shape, dtype=dtype, store=store) class ZarrNestedCase(_ZarrMetaCase): @@ -195,11 +219,15 @@ class ZarrNestedCase(_ZarrMetaCase): shape: Tuple[int, ...] = (10, 10), dtype: DtypeType = float, path: Optional[Path] = None, + array: Optional[NDArrayType] = None, ) -> ZarrArrayPath: file = str(path / "nested.zarr") root = zarr.open(file, mode="w") subpath = "a/b/c" - _ = root.zeros(subpath, shape=shape, dtype=dtype) + if array is not None: + _ = root.array(subpath, array, dtype=dtype) + else: + _ = root.zeros(subpath, shape=shape, dtype=dtype) return ZarrArrayPath(file=file, path=subpath) @@ -214,10 +242,15 @@ class VideoCase(InterfaceCase): shape: Tuple[int, ...] = (10, 10), dtype: DtypeType = float, path: Optional[Path] = None, + array: Optional[NDArrayType] = None, ) -> Optional[Path]: if cls.skip(shape, dtype): return None + if array is not None: + array = np.ndarray(shape, dtype=np.uint8) + shape = array.shape + is_color = len(shape) == 4 frames = shape[0] frame_shape = shape[1:] @@ -232,13 +265,16 @@ class VideoCase(InterfaceCase): ) for i in range(frames): - # make fresh array every time bc opencv eats them - array = np.zeros(frame_shape, dtype=np.uint8) - if not is_color: - array[i, i] = i + if array is not None: + frame = array[i] else: - array[i, i, :] = i - writer.write(array) + # make fresh array every time bc opencv eats them + frame = np.zeros(frame_shape, dtype=np.uint8) + if not is_color: + frame[i, i] = i + else: + frame[i, i, :] = i + writer.write(frame) writer.release() return video_path diff --git a/tests/fixtures/generation.py b/tests/fixtures/generation.py index 798dfac..382b0b4 100644 --- a/tests/fixtures/generation.py +++ b/tests/fixtures/generation.py @@ -7,7 +7,6 @@ import zarr from numpydantic.interface.hdf5 import H5ArrayPath from numpydantic.interface.zarr import ZarrArrayPath -from numpydantic.testing import ValidationCase from numpydantic.testing.interfaces import HDF5Case, HDF5CompoundCase, VideoCase @@ -57,8 +56,8 @@ def avi_video(tmp_output_dir_func) -> Callable[[Tuple[int, int], int, bool], Pat shape = (frames, *shape) if is_color: shape = (*shape, 3) - return VideoCase.array_from_case( - ValidationCase(shape=shape, dtype=np.uint8), tmp_output_dir_func + return VideoCase.make_array( + shape=shape, dtype=np.uint8, path=tmp_output_dir_func ) return _make_video diff --git a/tests/test_interface/test_hdf5.py b/tests/test_interface/test_hdf5.py index f7cea74..af063cc 100644 --- a/tests/test_interface/test_hdf5.py +++ b/tests/test_interface/test_hdf5.py @@ -5,13 +5,11 @@ from typing import Any import h5py import numpy as np import pytest -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from numpydantic import NDArray, Shape -from numpydantic.exceptions import DtypeError, ShapeError from numpydantic.interface import H5Interface from numpydantic.interface.hdf5 import H5ArrayPath, H5Proxy -from numpydantic.testing.helpers import ValidationCase from numpydantic.testing.interfaces import HDF5Case, HDF5CompoundCase pytestmark = pytest.mark.hdf5 @@ -27,35 +25,24 @@ def hdf5_cases(request): return request.param -def hdf5_array_case( - case: ValidationCase, array_func, compound: bool = False -) -> H5ArrayPath: - """ - Args: - case: - array_func: ( the function returned from the `hdf5_array` fixture ) - - Returns: - - """ - if issubclass(case.dtype, BaseModel): - pytest.skip("hdf5 cant support arbitrary python objects") - return array_func(case.shape, case.dtype, compound) - - -def _test_hdf5_case(case: ValidationCase, array_func, compound: bool = False) -> None: - array = hdf5_array_case(case, array_func, compound) - if case.passes: - case.model(array=array) - else: - with pytest.raises((ValidationError, DtypeError, ShapeError)): - case.model(array=array) - - def test_hdf5_enabled(): assert H5Interface.enabled() +@pytest.mark.shape +def test_hdf5_shape(shape_cases, hdf5_cases): + shape_cases.interface = hdf5_cases + if shape_cases.skip(): + pytest.skip() + shape_cases.validate_case() + + +@pytest.mark.dtype +def test_hdf5_dtype(dtype_cases, hdf5_cases): + dtype_cases.interface = hdf5_cases + dtype_cases.validate_case() + + def test_hdf5_check(interface_type): if interface_type[1] is H5Interface: assert H5Interface.check(interface_type[0]) @@ -82,20 +69,6 @@ def test_hdf5_check_not_hdf5(tmp_path): assert not H5Interface.check(spec) -@pytest.mark.shape -def test_hdf5_shape(shape_cases, hdf5_cases): - shape_cases.interface = hdf5_cases - if shape_cases.skip(): - pytest.skip() - shape_cases.validate_case() - - -@pytest.mark.dtype -def test_hdf5_dtype(dtype_cases, hdf5_cases): - dtype_cases.interface = hdf5_cases - dtype_cases.validate_case() - - def test_hdf5_dataset_not_exists(hdf5_array, model_blank): array = hdf5_array() with pytest.raises(ValueError) as e: diff --git a/tests/test_interface/test_numpy.py b/tests/test_interface/test_numpy.py index e93bffb..a2a6f24 100644 --- a/tests/test_interface/test_numpy.py +++ b/tests/test_interface/test_numpy.py @@ -1,37 +1,21 @@ import numpy as np import pytest -from pydantic import BaseModel, ValidationError -from numpydantic.exceptions import DtypeError, ShapeError -from numpydantic.testing.helpers import ValidationCase +from numpydantic.testing.cases import NumpyCase pytestmark = pytest.mark.numpy -def numpy_array(case: ValidationCase) -> np.ndarray: - if issubclass(case.dtype, BaseModel): - return np.full(shape=case.shape, fill_value=case.dtype(x=1)) - else: - return np.zeros(shape=case.shape, dtype=case.dtype) - - -def _test_np_case(case: ValidationCase): - array = numpy_array(case) - if case.passes: - case.model(array=array) - else: - with pytest.raises((ValidationError, DtypeError, ShapeError)): - case.model(array=array) - - @pytest.mark.shape def test_numpy_shape(shape_cases): - _test_np_case(shape_cases) + shape_cases.interface = NumpyCase + shape_cases.validate_case() @pytest.mark.dtype def test_numpy_dtype(dtype_cases): - _test_np_case(dtype_cases) + dtype_cases.interface = NumpyCase + dtype_cases.validate_case() def test_numpy_coercion(model_blank): diff --git a/tests/test_interface/test_zarr.py b/tests/test_interface/test_zarr.py index af0d7e9..1c1b7dd 100644 --- a/tests/test_interface/test_zarr.py +++ b/tests/test_interface/test_zarr.py @@ -1,58 +1,21 @@ import json +import numpy as np import pytest -import zarr -from pydantic import BaseModel, ValidationError -from numpydantic.exceptions import DtypeError, ShapeError from numpydantic.interface import ZarrInterface from numpydantic.interface.zarr import ZarrArrayPath -from numpydantic.testing.helpers import ValidationCase +from numpydantic.testing.cases import ZarrCase, ZarrDirCase, ZarrNestedCase, ZarrZipCase +from numpydantic.testing.helpers import InterfaceCase pytestmark = pytest.mark.zarr -@pytest.fixture() -def dir_array(tmp_output_dir_func) -> zarr.DirectoryStore: - store = zarr.DirectoryStore(tmp_output_dir_func / "array.zarr") - return store - - -@pytest.fixture() -def zip_array(tmp_output_dir_func) -> zarr.ZipStore: - store = zarr.ZipStore(tmp_output_dir_func / "array.zip", mode="w") - return store - - -@pytest.fixture() -def nested_dir_array(tmp_output_dir_func) -> zarr.NestedDirectoryStore: - store = zarr.NestedDirectoryStore(tmp_output_dir_func / "nested") - return store - - -def _zarr_array(case: ValidationCase, store) -> zarr.core.Array: - if issubclass(case.dtype, BaseModel): - pytest.skip( - "Zarr can't handle objects properly at the moment, " - "see https://github.com/zarr-developers/zarr-python/issues/2081" - ) - # return zarr.full( - # shape=case.shape, - # fill_value=case.dtype(x=1), - # dtype=object, - # object_codec=Pickle(), - # ) - else: - return zarr.zeros(shape=case.shape, dtype=case.dtype, store=store) - - -def _test_zarr_case(case: ValidationCase, store): - array = _zarr_array(case, store) - if case.passes: - case.model(array=array) - else: - with pytest.raises((ValidationError, DtypeError, ShapeError)): - case.model(array=array) +@pytest.fixture( + params=[ZarrCase, ZarrZipCase, ZarrDirCase, ZarrNestedCase], +) +def zarr_case(request) -> InterfaceCase: + return request.param @pytest.fixture( @@ -86,13 +49,17 @@ def test_zarr_check(interface_type): @pytest.mark.shape -def test_zarr_shape(store, shape_cases): - _test_zarr_case(shape_cases, store) +def test_zarr_shape(shape_cases, zarr_case): + shape_cases.interface = zarr_case + shape_cases.validate_case() @pytest.mark.dtype -def test_zarr_dtype(dtype_cases, store): - _test_zarr_case(dtype_cases, store) +def test_zarr_dtype(dtype_cases, zarr_case): + dtype_cases.interface = zarr_case + if dtype_cases.skip(): + pytest.skip() + dtype_cases.validate_case() @pytest.mark.parametrize("array", ["zarr_nested_array", "zarr_array"]) @@ -126,7 +93,7 @@ def test_zarr_array_path_from_iterable(zarr_array): @pytest.mark.serialization @pytest.mark.parametrize("dump_array", [True, False]) @pytest.mark.parametrize("roundtrip", [True, False]) -def test_zarr_to_json(store, model_blank, roundtrip, dump_array): +def test_zarr_to_json(zarr_case, model_blank, roundtrip, dump_array, tmp_path): expected_fields = ( "Type", "Data type", @@ -136,9 +103,9 @@ def test_zarr_to_json(store, model_blank, roundtrip, dump_array): "Store type", "hexdigest", ) - lol_array = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + lol_array = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=int) - array = zarr.array(lol_array, store=store) + array = zarr_case.make_array(array=lol_array, dtype=int, path=tmp_path) instance = model_blank(array=array) context = {"dump_array": dump_array} @@ -148,7 +115,7 @@ def test_zarr_to_json(store, model_blank, roundtrip, dump_array): if roundtrip: if dump_array: - assert as_json["value"] == lol_array + assert np.array_equal(as_json["value"], lol_array) else: if as_json.get("file", False): assert "array" not in as_json @@ -158,4 +125,4 @@ def test_zarr_to_json(store, model_blank, roundtrip, dump_array): assert len(as_json["info"]["hexdigest"]) == 40 else: - assert as_json == lol_array + assert np.array_equal(as_json, lol_array)