finish replacing interface tests with new helper system

This commit is contained in:
sneakers-the-rat 2024-10-10 18:58:05 -07:00
parent 3356738e42
commit 5d4f03a8a9
Signed by untrusted user who does not match committer: jonny
GPG key ID: 6DCB96EF1E4D232D
7 changed files with 150 additions and 183 deletions

View file

@ -31,6 +31,53 @@ else:
YES_PIPE = False 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): class BasicModel(BaseModel):
x: int x: int
@ -58,7 +105,6 @@ FLOAT: TypeAlias = NDArray[Shape["*, *, *"], Float]
STRING: TypeAlias = NDArray[Shape["*, *, *"], str] STRING: TypeAlias = NDArray[Shape["*, *, *"], str]
MODEL: TypeAlias = NDArray[Shape["*, *, *"], BasicModel] MODEL: TypeAlias = NDArray[Shape["*, *, *"], BasicModel]
UNION_TYPE: TypeAlias = NDArray[Shape["*, *, *"], Union[np.uint32, np.float32]] UNION_TYPE: TypeAlias = NDArray[Shape["*, *, *"], Union[np.uint32, np.float32]]
UNION_PIPE: TypeAlias = NDArray[Shape["*, *, *"], np.uint32 | np.float32]
SHAPE_CASES = ( SHAPE_CASES = (
ValidationCase(shape=(10, 10, 10), passes=True, id="valid shape"), ValidationCase(shape=(10, 10, 10), passes=True, id="valid shape"),
@ -135,6 +181,8 @@ DTYPE_CASES = [
if YES_PIPE: if YES_PIPE:
UNION_PIPE: TypeAlias = NDArray[Shape["*, *, *"], np.uint32 | np.float32]
DTYPE_CASES.extend( DTYPE_CASES.extend(
[ [
ValidationCase( ValidationCase(
@ -178,50 +226,3 @@ _INTERFACE_CASES = [
ZarrNestedCase, ZarrNestedCase,
VideoCase, 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

View file

@ -46,9 +46,16 @@ class InterfaceCase(ABC):
shape: Tuple[int, ...] = (10, 10), shape: Tuple[int, ...] = (10, 10),
dtype: DtypeType = float, dtype: DtypeType = float,
path: Optional[Path] = None, path: Optional[Path] = None,
array: Optional[NDArrayType] = None,
) -> Optional[NDArrayType]: ) -> Optional[NDArrayType]:
""" """
Make an array from a shape and dtype, and a path if needed 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 @classmethod

View file

@ -19,7 +19,7 @@ from numpydantic.interface import (
ZarrInterface, ZarrInterface,
) )
from numpydantic.testing.helpers import InterfaceCase from numpydantic.testing.helpers import InterfaceCase
from numpydantic.types import DtypeType from numpydantic.types import DtypeType, NDArrayType
class NumpyCase(InterfaceCase): class NumpyCase(InterfaceCase):
@ -33,8 +33,11 @@ class NumpyCase(InterfaceCase):
shape: Tuple[int, ...] = (10, 10), shape: Tuple[int, ...] = (10, 10),
dtype: DtypeType = float, dtype: DtypeType = float,
path: Optional[Path] = None, path: Optional[Path] = None,
array: Optional[NDArrayType] = None,
) -> np.ndarray: ) -> 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)) return np.full(shape=shape, fill_value=dtype(x=1))
else: else:
return np.zeros(shape=shape, dtype=dtype) return np.zeros(shape=shape, dtype=dtype)
@ -59,6 +62,7 @@ class HDF5Case(_HDF5MetaCase):
shape: Tuple[int, ...] = (10, 10), shape: Tuple[int, ...] = (10, 10),
dtype: DtypeType = float, dtype: DtypeType = float,
path: Optional[Path] = None, path: Optional[Path] = None,
array: Optional[NDArrayType] = None,
) -> Optional[H5ArrayPath]: ) -> Optional[H5ArrayPath]:
if cls.skip(shape, dtype): if cls.skip(shape, dtype):
return None return None
@ -67,7 +71,9 @@ class HDF5Case(_HDF5MetaCase):
array_path = "/" + "_".join([str(s) for s in shape]) + "__" + dtype.__name__ array_path = "/" + "_".join([str(s) for s in shape]) + "__" + dtype.__name__
generator = np.random.default_rng() 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) data = generator.random(shape).astype(bytes)
elif dtype is datetime: elif dtype is datetime:
data = np.empty(shape, dtype="S32") data = np.empty(shape, dtype="S32")
@ -91,13 +97,16 @@ class HDF5CompoundCase(_HDF5MetaCase):
shape: Tuple[int, ...] = (10, 10), shape: Tuple[int, ...] = (10, 10),
dtype: DtypeType = float, dtype: DtypeType = float,
path: Optional[Path] = None, path: Optional[Path] = None,
array: Optional[NDArrayType] = None,
) -> Optional[H5ArrayPath]: ) -> Optional[H5ArrayPath]:
if cls.skip(shape, dtype): if cls.skip(shape, dtype):
return None return None
hdf5_file = path / "h5f.h5" hdf5_file = path / "h5f.h5"
array_path = "/" + "_".join([str(s) for s in shape]) + "__" + dtype.__name__ 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")]) dt = np.dtype([("data", np.dtype("S10")), ("extra", "i8")])
data = np.array([("hey", 0)] * np.prod(shape), dtype=dt).reshape(shape) data = np.array([("hey", 0)] * np.prod(shape), dtype=dt).reshape(shape)
elif dtype is datetime: elif dtype is datetime:
@ -128,7 +137,10 @@ class DaskCase(InterfaceCase):
shape: Tuple[int, ...] = (10, 10), shape: Tuple[int, ...] = (10, 10),
dtype: DtypeType = float, dtype: DtypeType = float,
path: Optional[Path] = None, path: Optional[Path] = None,
array: Optional[NDArrayType] = None,
) -> da.Array: ) -> da.Array:
if array is not None:
return da.array(array, dtype=dtype, chunks=-1)
if issubclass(dtype, BaseModel): if issubclass(dtype, BaseModel):
return da.full(shape=shape, fill_value=dtype(x=1), chunks=-1) return da.full(shape=shape, fill_value=dtype(x=1), chunks=-1)
else: else:
@ -142,7 +154,7 @@ class _ZarrMetaCase(InterfaceCase):
@classmethod @classmethod
def skip(cls, shape: Tuple[int, ...], dtype: DtypeType) -> bool: def skip(cls, shape: Tuple[int, ...], dtype: DtypeType) -> bool:
return not issubclass(dtype, BaseModel) return issubclass(dtype, BaseModel)
class ZarrCase(_ZarrMetaCase): class ZarrCase(_ZarrMetaCase):
@ -154,7 +166,11 @@ class ZarrCase(_ZarrMetaCase):
shape: Tuple[int, ...] = (10, 10), shape: Tuple[int, ...] = (10, 10),
dtype: DtypeType = float, dtype: DtypeType = float,
path: Optional[Path] = None, path: Optional[Path] = None,
array: Optional[NDArrayType] = None,
) -> Optional[zarr.Array]: ) -> Optional[zarr.Array]:
if array is not None:
return zarr.array(array, dtype=dtype, chunks=-1)
else:
return zarr.zeros(shape=shape, dtype=dtype) return zarr.zeros(shape=shape, dtype=dtype)
@ -167,8 +183,12 @@ class ZarrDirCase(_ZarrMetaCase):
shape: Tuple[int, ...] = (10, 10), shape: Tuple[int, ...] = (10, 10),
dtype: DtypeType = float, dtype: DtypeType = float,
path: Optional[Path] = None, path: Optional[Path] = None,
array: Optional[NDArrayType] = None,
) -> Optional[zarr.Array]: ) -> Optional[zarr.Array]:
store = zarr.DirectoryStore(str(path / "array.zarr")) store = zarr.DirectoryStore(str(path / "array.zarr"))
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) return zarr.zeros(shape=shape, dtype=dtype, store=store)
@ -181,8 +201,12 @@ class ZarrZipCase(_ZarrMetaCase):
shape: Tuple[int, ...] = (10, 10), shape: Tuple[int, ...] = (10, 10),
dtype: DtypeType = float, dtype: DtypeType = float,
path: Optional[Path] = None, path: Optional[Path] = None,
array: Optional[NDArrayType] = None,
) -> Optional[zarr.Array]: ) -> Optional[zarr.Array]:
store = zarr.ZipStore(str(path / "array.zarr"), mode="w") store = zarr.ZipStore(str(path / "array.zarr"), mode="w")
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) return zarr.zeros(shape=shape, dtype=dtype, store=store)
@ -195,10 +219,14 @@ class ZarrNestedCase(_ZarrMetaCase):
shape: Tuple[int, ...] = (10, 10), shape: Tuple[int, ...] = (10, 10),
dtype: DtypeType = float, dtype: DtypeType = float,
path: Optional[Path] = None, path: Optional[Path] = None,
array: Optional[NDArrayType] = None,
) -> ZarrArrayPath: ) -> ZarrArrayPath:
file = str(path / "nested.zarr") file = str(path / "nested.zarr")
root = zarr.open(file, mode="w") root = zarr.open(file, mode="w")
subpath = "a/b/c" subpath = "a/b/c"
if array is not None:
_ = root.array(subpath, array, dtype=dtype)
else:
_ = root.zeros(subpath, shape=shape, dtype=dtype) _ = root.zeros(subpath, shape=shape, dtype=dtype)
return ZarrArrayPath(file=file, path=subpath) return ZarrArrayPath(file=file, path=subpath)
@ -214,10 +242,15 @@ class VideoCase(InterfaceCase):
shape: Tuple[int, ...] = (10, 10), shape: Tuple[int, ...] = (10, 10),
dtype: DtypeType = float, dtype: DtypeType = float,
path: Optional[Path] = None, path: Optional[Path] = None,
array: Optional[NDArrayType] = None,
) -> Optional[Path]: ) -> Optional[Path]:
if cls.skip(shape, dtype): if cls.skip(shape, dtype):
return None return None
if array is not None:
array = np.ndarray(shape, dtype=np.uint8)
shape = array.shape
is_color = len(shape) == 4 is_color = len(shape) == 4
frames = shape[0] frames = shape[0]
frame_shape = shape[1:] frame_shape = shape[1:]
@ -232,13 +265,16 @@ class VideoCase(InterfaceCase):
) )
for i in range(frames): for i in range(frames):
# make fresh array every time bc opencv eats them if array is not None:
array = np.zeros(frame_shape, dtype=np.uint8) frame = array[i]
if not is_color:
array[i, i] = i
else: else:
array[i, i, :] = i # make fresh array every time bc opencv eats them
writer.write(array) 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() writer.release()
return video_path return video_path

View file

@ -7,7 +7,6 @@ import zarr
from numpydantic.interface.hdf5 import H5ArrayPath from numpydantic.interface.hdf5 import H5ArrayPath
from numpydantic.interface.zarr import ZarrArrayPath from numpydantic.interface.zarr import ZarrArrayPath
from numpydantic.testing import ValidationCase
from numpydantic.testing.interfaces import HDF5Case, HDF5CompoundCase, VideoCase 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) shape = (frames, *shape)
if is_color: if is_color:
shape = (*shape, 3) shape = (*shape, 3)
return VideoCase.array_from_case( return VideoCase.make_array(
ValidationCase(shape=shape, dtype=np.uint8), tmp_output_dir_func shape=shape, dtype=np.uint8, path=tmp_output_dir_func
) )
return _make_video return _make_video

View file

@ -5,13 +5,11 @@ from typing import Any
import h5py import h5py
import numpy as np import numpy as np
import pytest import pytest
from pydantic import BaseModel, ValidationError from pydantic import BaseModel
from numpydantic import NDArray, Shape from numpydantic import NDArray, Shape
from numpydantic.exceptions import DtypeError, ShapeError
from numpydantic.interface import H5Interface from numpydantic.interface import H5Interface
from numpydantic.interface.hdf5 import H5ArrayPath, H5Proxy from numpydantic.interface.hdf5 import H5ArrayPath, H5Proxy
from numpydantic.testing.helpers import ValidationCase
from numpydantic.testing.interfaces import HDF5Case, HDF5CompoundCase from numpydantic.testing.interfaces import HDF5Case, HDF5CompoundCase
pytestmark = pytest.mark.hdf5 pytestmark = pytest.mark.hdf5
@ -27,35 +25,24 @@ def hdf5_cases(request):
return request.param 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(): def test_hdf5_enabled():
assert H5Interface.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): def test_hdf5_check(interface_type):
if interface_type[1] is H5Interface: if interface_type[1] is H5Interface:
assert H5Interface.check(interface_type[0]) assert H5Interface.check(interface_type[0])
@ -82,20 +69,6 @@ def test_hdf5_check_not_hdf5(tmp_path):
assert not H5Interface.check(spec) 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): def test_hdf5_dataset_not_exists(hdf5_array, model_blank):
array = hdf5_array() array = hdf5_array()
with pytest.raises(ValueError) as e: with pytest.raises(ValueError) as e:

View file

@ -1,37 +1,21 @@
import numpy as np import numpy as np
import pytest import pytest
from pydantic import BaseModel, ValidationError
from numpydantic.exceptions import DtypeError, ShapeError from numpydantic.testing.cases import NumpyCase
from numpydantic.testing.helpers import ValidationCase
pytestmark = pytest.mark.numpy 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 @pytest.mark.shape
def test_numpy_shape(shape_cases): def test_numpy_shape(shape_cases):
_test_np_case(shape_cases) shape_cases.interface = NumpyCase
shape_cases.validate_case()
@pytest.mark.dtype @pytest.mark.dtype
def test_numpy_dtype(dtype_cases): 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): def test_numpy_coercion(model_blank):

View file

@ -1,58 +1,21 @@
import json import json
import numpy as np
import pytest import pytest
import zarr
from pydantic import BaseModel, ValidationError
from numpydantic.exceptions import DtypeError, ShapeError
from numpydantic.interface import ZarrInterface from numpydantic.interface import ZarrInterface
from numpydantic.interface.zarr import ZarrArrayPath 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 pytestmark = pytest.mark.zarr
@pytest.fixture() @pytest.fixture(
def dir_array(tmp_output_dir_func) -> zarr.DirectoryStore: params=[ZarrCase, ZarrZipCase, ZarrDirCase, ZarrNestedCase],
store = zarr.DirectoryStore(tmp_output_dir_func / "array.zarr") )
return store def zarr_case(request) -> InterfaceCase:
return request.param
@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( @pytest.fixture(
@ -86,13 +49,17 @@ def test_zarr_check(interface_type):
@pytest.mark.shape @pytest.mark.shape
def test_zarr_shape(store, shape_cases): def test_zarr_shape(shape_cases, zarr_case):
_test_zarr_case(shape_cases, store) shape_cases.interface = zarr_case
shape_cases.validate_case()
@pytest.mark.dtype @pytest.mark.dtype
def test_zarr_dtype(dtype_cases, store): def test_zarr_dtype(dtype_cases, zarr_case):
_test_zarr_case(dtype_cases, store) dtype_cases.interface = zarr_case
if dtype_cases.skip():
pytest.skip()
dtype_cases.validate_case()
@pytest.mark.parametrize("array", ["zarr_nested_array", "zarr_array"]) @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.serialization
@pytest.mark.parametrize("dump_array", [True, False]) @pytest.mark.parametrize("dump_array", [True, False])
@pytest.mark.parametrize("roundtrip", [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 = ( expected_fields = (
"Type", "Type",
"Data type", "Data type",
@ -136,9 +103,9 @@ def test_zarr_to_json(store, model_blank, roundtrip, dump_array):
"Store type", "Store type",
"hexdigest", "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) instance = model_blank(array=array)
context = {"dump_array": dump_array} context = {"dump_array": dump_array}
@ -148,7 +115,7 @@ def test_zarr_to_json(store, model_blank, roundtrip, dump_array):
if roundtrip: if roundtrip:
if dump_array: if dump_array:
assert as_json["value"] == lol_array assert np.array_equal(as_json["value"], lol_array)
else: else:
if as_json.get("file", False): if as_json.get("file", False):
assert "array" not in as_json 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 assert len(as_json["info"]["hexdigest"]) == 40
else: else:
assert as_json == lol_array assert np.array_equal(as_json, lol_array)