diff --git a/src/numpydantic/interface/dask.py b/src/numpydantic/interface/dask.py index ba16d5e..8073c92 100644 --- a/src/numpydantic/interface/dask.py +++ b/src/numpydantic/interface/dask.py @@ -10,7 +10,7 @@ from numpydantic.interface.interface import Interface try: from dask.array.core import Array as DaskArray -except ImportError: +except ImportError: # pragma: no cover DaskArray = None diff --git a/src/numpydantic/interface/hdf5.py b/src/numpydantic/interface/hdf5.py index 67cecda..ae294ae 100644 --- a/src/numpydantic/interface/hdf5.py +++ b/src/numpydantic/interface/hdf5.py @@ -13,7 +13,7 @@ from numpydantic.types import NDArrayType try: import h5py -except ImportError: +except ImportError: # pragma: no cover h5py = None if sys.version_info.minor >= 10: @@ -160,9 +160,11 @@ class H5Interface(Interface): """Create an :class:`.H5Proxy` to use throughout validation""" if isinstance(array, H5ArrayPath): array = H5Proxy.from_h5array(h5array=array) - elif isinstance(array, (tuple, list)) and len(array) == 2: + elif isinstance(array, (tuple, list)) and len(array) == 2: # pragma: no cover array = H5Proxy(file=array[0], path=array[1]) - else: + else: # pragma: no cover + # this should never happen really since `check` confirms this before + # we'd reach here, but just to complete the if else... raise ValueError( "Need to specify a file and a path within an HDF5 file to use the HDF5 " "Interface" diff --git a/src/numpydantic/interface/interface.py b/src/numpydantic/interface/interface.py index 6bb03e6..fd14931 100644 --- a/src/numpydantic/interface/interface.py +++ b/src/numpydantic/interface/interface.py @@ -141,7 +141,7 @@ class Interface(ABC, Generic[T]): for iface in cls.interfaces(): if isinstance(iface.input_types, Union[tuple, list]): in_types.extend(iface.input_types) - else: + else: # pragma: no cover in_types.append(iface.input_types) return tuple(in_types) diff --git a/src/numpydantic/interface/numpy.py b/src/numpydantic/interface/numpy.py index 75efafd..f6417ca 100644 --- a/src/numpydantic/interface/numpy.py +++ b/src/numpydantic/interface/numpy.py @@ -11,7 +11,7 @@ try: ENABLED = True -except ImportError: +except ImportError: # pragma: no cover ENABLED = False ndarray = None @@ -23,6 +23,13 @@ class NumpyInterface(Interface): input_types = (ndarray, list) return_type = ndarray + priority = -1 + """ + The numpy interface is usually the interface of last resort. + We want to use any more specific interface that we might have, + because the numpy interface checks for anything that could be coerced + to a numpy array (see :meth:`.NumpyInterface.check` ) + """ @classmethod def check(cls, array: Any) -> bool: diff --git a/src/numpydantic/interface/zarr.py b/src/numpydantic/interface/zarr.py index 23bee57..4244b0e 100644 --- a/src/numpydantic/interface/zarr.py +++ b/src/numpydantic/interface/zarr.py @@ -13,7 +13,7 @@ try: import zarr from zarr.core import Array as ZarrArray from zarr.storage import StoreLike -except ImportError: +except ImportError: # pragma: no cover ZarrArray = None StoreLike = None storage = None diff --git a/tests/fixtures.py b/tests/fixtures.py index 6364962..2ae8ff2 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,6 +1,6 @@ import shutil from pathlib import Path -from typing import Callable, Optional, Tuple, Type, Union +from typing import Any, Callable, Optional, Tuple, Type, Union import h5py import numpy as np @@ -85,6 +85,16 @@ def model_rgb() -> Type[BaseModel]: return RGB +@pytest.fixture(scope="session") +def model_blank() -> Type[BaseModel]: + """A model with any shape and dtype""" + + class BlankModel(BaseModel): + array: NDArray[Shape["*, ..."], Any] + + return BlankModel + + @pytest.fixture(scope="function") def hdf5_file(tmp_output_dir_func) -> h5py.File: h5f_file = tmp_output_dir_func / "h5f.h5" diff --git a/tests/test_interface/test_dask.py b/tests/test_interface/test_dask.py index 8584ff2..6f7a8ac 100644 --- a/tests/test_interface/test_dask.py +++ b/tests/test_interface/test_dask.py @@ -1,4 +1,7 @@ +import pdb + import pytest +import json import dask.array as da from pydantic import ValidationError @@ -42,3 +45,12 @@ def test_dask_shape(shape_cases): def test_dask_dtype(dtype_cases): _test_dask_case(dtype_cases) + + +def test_dask_to_json(array_model): + array_list = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + array = da.array(array_list) + model = array_model((3, 3), int) + instance = model(array=array) + jsonified = json.loads(instance.model_dump_json()) + assert jsonified["array"] == array_list diff --git a/tests/test_interface/test_hdf5.py b/tests/test_interface/test_hdf5.py index a3d4dac..3697736 100644 --- a/tests/test_interface/test_hdf5.py +++ b/tests/test_interface/test_hdf5.py @@ -1,7 +1,69 @@ import pdb import json +import pytest -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError + +from numpydantic.interface import H5Interface +from numpydantic.interface.hdf5 import H5ArrayPath +from numpydantic.exceptions import DtypeError, ShapeError + +from tests.conftest import ValidationCase + + +def hdf5_array_case(case: ValidationCase, array_func) -> H5ArrayPath: + """ + Args: + case: + array_func: ( the function returned from the `hdf5_array` fixture ) + + Returns: + + """ + return array_func(case.shape, case.dtype) + + +def _test_hdf5_case(case: ValidationCase, array_func): + array = hdf5_array_case(case, array_func) + 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() + + +def test_hdf5_check(interface_type): + if interface_type[1] is H5Interface: + if interface_type[0].__name__ == "_hdf5_array": + interface_type = (interface_type[0](), interface_type[1]) + assert H5Interface.check(interface_type[0]) + if isinstance(interface_type[0], H5ArrayPath): + # also test that we can instantiate from a tuple like the H5ArrayPath + assert H5Interface.check((interface_type[0].file, interface_type[0].path)) + else: + assert not H5Interface.check(interface_type[0]) + + +def test_hdf5_shape(shape_cases, hdf5_array): + _test_hdf5_case(shape_cases, hdf5_array) + + +def test_hdf5_dtype(dtype_cases, hdf5_array): + if dtype_cases.dtype is str: + pytest.skip("hdf5 cant do string arrays") + _test_hdf5_case(dtype_cases, hdf5_array) + + +def test_hdf5_dataset_not_exists(hdf5_array, model_blank): + array = hdf5_array() + with pytest.raises(ValueError) as e: + model_blank(array=H5ArrayPath(file=array.file, path="/some/random/path")) + assert "file located" in e + assert "no array found" in e def test_to_json(hdf5_array, array_model): diff --git a/tests/test_interface/test_interface.py b/tests/test_interface/test_interface.py new file mode 100644 index 0000000..291de43 --- /dev/null +++ b/tests/test_interface/test_interface.py @@ -0,0 +1,90 @@ +import pytest + +from numpydantic.interface import Interface + + +@pytest.fixture(scope="module") +def interfaces(): + """Define test interfaces in this module, and delete afterwards""" + + class Interface1(Interface): + input_types = (list,) + return_type = tuple + + @classmethod + def check(cls, array): + if isinstance(array, list): + return True + return False + + @classmethod + def enabled(cls) -> bool: + return True + + Interface2 = type("Interface2", Interface1.__bases__, dict(Interface1.__dict__)) + + class Interface3(Interface1): + @classmethod + def enabled(cls) -> bool: + return False + + class Interfaces: + interface1 = Interface1 + interface2 = Interface2 + interface3 = Interface3 + + yield Interfaces + del Interface1 + del Interface2 + del Interface3 + + +def test_interface_match_error(interfaces): + """ + Test that `match` and `match_output` raises errors when no or multiple matches are found + """ + with pytest.raises(ValueError) as e: + Interface.match([1, 2, 3]) + assert "Interface1" in e + assert "Interface2" in e + + with pytest.raises(ValueError) as e: + Interface.match("hey") + assert "No matching interfaces" in e + + with pytest.raises(ValueError) as e: + Interface.match_output((1, 2, 3)) + assert "Interface1" in e + assert "Interface2" in e + + with pytest.raises(ValueError) as e: + Interface.match_output("hey") + assert "No matching interfaces" in e + + +def test_interface_enabled(interfaces): + """ + An interface shouldn't be included if it's not enabled + """ + assert not interfaces.Interface3.enabled() + assert interfaces.Interface3 not in Interface.interfaces() + + +def test_interface_type_lists(): + """ + Seems like a silly test, but ensure that our return types and input types + lists have all the class attrs + """ + for interface in Interface.interfaces(): + + if isinstance(interface.input_types, (list, tuple)): + for atype in interface.input_types: + assert atype in Interface.input_types() + else: + assert interface.input_types in Interface.input_types() + + if isinstance(interface.return_type, (list, tuple)): + for atype in interface.return_type: + assert atype in Interface.return_types() + else: + assert interface.return_type in Interface.return_types() diff --git a/tests/test_interface/test_numpy.py b/tests/test_interface/test_numpy.py index 0ffea50..1ab6208 100644 --- a/tests/test_interface/test_numpy.py +++ b/tests/test_interface/test_numpy.py @@ -25,3 +25,9 @@ def test_numpy_shape(shape_cases): def test_numpy_dtype(dtype_cases): _test_np_case(dtype_cases) + + +def test_numpy_coercion(model_blank): + """If no other interface matches, we try and coerce to a numpy array""" + instance = model_blank(array=[1, 2, 3]) + assert isinstance(instance.array, np.ndarray)