numpydantic/tests/test_ndarray.py

396 lines
11 KiB
Python
Raw Normal View History

import json
2024-10-04 02:57:54 +00:00
from typing import Any, Optional, Union
import numpy as np
2024-10-04 02:57:54 +00:00
import pytest
from pydantic import BaseModel, Field, ValidationError
2024-10-04 02:57:54 +00:00
from numpydantic import NDArray, Shape, dtype
from numpydantic.dtype import Number
2024-10-04 02:57:54 +00:00
from numpydantic.exceptions import DtypeError
@pytest.mark.json_schema
def test_ndarray_type():
class Model(BaseModel):
array: NDArray[Shape["2 x, * y"], Number]
array_any: Optional[NDArray[Any, Any]] = None
schema = Model.model_json_schema()
assert schema["properties"]["array"]["items"] == {
"items": {"type": "number"},
"type": "array",
}
assert schema["properties"]["array"]["maxItems"] == 2
assert schema["properties"]["array"]["minItems"] == 2
# models should instantiate correctly!
2024-10-04 02:57:54 +00:00
_ = Model(array=np.zeros((2, 3)))
with pytest.raises(ValidationError):
2024-10-04 02:57:54 +00:00
_ = Model(array=np.zeros((4, 6)))
2024-06-28 05:24:57 +00:00
with pytest.raises(ValidationError):
2024-10-04 02:57:54 +00:00
_ = Model(array=np.ones((2, 3), dtype=bool))
2024-10-04 02:57:54 +00:00
_ = Model(array=np.zeros((2, 3)), array_any=np.ones((3, 4, 5)))
2024-09-23 20:28:38 +00:00
@pytest.mark.dtype
@pytest.mark.json_schema
def test_schema_unsupported_type():
"""
Complex numbers should just be made with an `any` schema
"""
class Model(BaseModel):
array: NDArray[Shape["2 x, * y"], complex]
schema = Model.model_json_schema()
assert schema["properties"]["array"]["items"] == {
"items": {},
"type": "array",
}
2024-09-23 20:28:38 +00:00
@pytest.mark.dtype
@pytest.mark.json_schema
def test_schema_tuple():
"""
2024-09-23 20:28:38 +00:00
Types specified as tuples should have their schemas as a union
"""
class Model(BaseModel):
array: NDArray[Shape["2 x, * y"], (np.uint8, np.uint16)]
schema = Model.model_json_schema()
2024-07-31 23:51:37 +00:00
assert "anyOf" in schema["properties"]["array"]["items"]["items"]
conditions = schema["properties"]["array"]["items"]["items"]["anyOf"]
assert all([i["type"] == "integer" for i in conditions])
assert sorted([i["maximum"] for i in conditions]) == [255, 65535]
assert all([i["minimum"] == 0 for i in conditions])
2024-09-23 20:28:38 +00:00
@pytest.mark.dtype
@pytest.mark.json_schema
def test_schema_number():
"""
np.numeric should just be the float schema
"""
class Model(BaseModel):
array: NDArray[Shape["2 x, * y"], np.number]
schema = Model.model_json_schema()
assert schema["properties"]["array"]["items"] == {
"items": {"type": "number"},
"type": "array",
}
def test_ndarray_union():
2024-10-04 02:57:54 +00:00
generator = np.random.default_rng()
2024-10-04 03:01:56 +00:00
class Model(BaseModel):
array: Optional[
Union[
NDArray[Shape["* x, * y"], Number],
NDArray[Shape["* x, * y, 3 r_g_b"], Number],
NDArray[Shape["* x, * y, 3 r_g_b, 4 r_g_b_a"], Number],
]
] = Field(None)
2024-10-04 02:57:54 +00:00
_ = Model()
_ = Model(array=generator.random((5, 10)))
_ = Model(array=generator.random((5, 10, 3)))
_ = Model(array=generator.random((5, 10, 3, 4)))
with pytest.raises(ValidationError):
2024-10-04 02:57:54 +00:00
_ = Model(array=generator.random((5,)))
with pytest.raises(ValidationError):
2024-10-04 02:57:54 +00:00
_ = Model(array=generator.random((5, 10, 4)))
with pytest.raises(ValidationError):
2024-10-04 02:57:54 +00:00
_ = Model(array=generator.random((5, 10, 3, 6)))
with pytest.raises(ValidationError):
2024-10-04 02:57:54 +00:00
_ = Model(array=generator.random((5, 10, 4, 6)))
2024-09-23 20:28:38 +00:00
@pytest.mark.shape
@pytest.mark.dtype
@pytest.mark.parametrize("dtype", dtype.Number)
def test_ndarray_unparameterized(dtype):
"""
NDArray without any parameters is any shape, any type
"""
2024-10-04 02:57:54 +00:00
generator = np.random.default_rng()
class Model(BaseModel):
array: NDArray
# not very sophisticated fuzzing of "any shape"
test_cases = 10
2024-10-04 02:57:54 +00:00
for _ in range(test_cases):
n_dimensions = generator.integers(1, 8)
dim_sizes = generator.integers(1, 7, size=n_dimensions)
_ = Model(array=np.zeros(dim_sizes, dtype=dtype))
2024-09-23 20:28:38 +00:00
@pytest.mark.shape
def test_ndarray_any():
"""
using :class:`typing.Any` in for the shape means any shape
"""
2024-10-04 02:57:54 +00:00
generator = np.random.default_rng()
class Model(BaseModel):
array: NDArray[Any, np.uint8]
# not very sophisticated fuzzing of "any shape"
test_cases = 100
2024-10-04 02:57:54 +00:00
for _ in range(test_cases):
n_dimensions = generator.integers(1, 8)
dim_sizes = generator.integers(1, 16, size=n_dimensions)
_ = Model(array=np.zeros(dim_sizes, dtype=np.uint8))
def test_ndarray_coercion():
"""
Coerce lists to arrays
"""
class Model(BaseModel):
array: NDArray[Shape["* x"], Number]
amod = Model(array=[1, 2, 3, 4.5])
assert np.allclose(amod.array, np.array([1, 2, 3, 4.5]))
2024-06-28 05:24:57 +00:00
with pytest.raises(ValidationError):
amod = Model(array=["a", "b", "c"])
2024-09-24 00:35:24 +00:00
@pytest.mark.shape
def test_shape_ellipsis():
"""
Test that ellipsis is a wildcard, rather than "repeat the last index"
"""
class MyModel(BaseModel):
array: NDArray[Shape["1, 2, ..."], Number]
_ = MyModel(array=np.zeros((1, 2, 3, 4, 5)))
@pytest.mark.serialization
def test_ndarray_serialize():
"""
2024-04-23 03:00:43 +00:00
Arrays should be dumped to a list when using json, but kept as ndarray otherwise
"""
class Model(BaseModel):
2024-04-23 03:00:43 +00:00
array: NDArray[Any, Number]
2024-10-04 02:57:54 +00:00
mod = Model(array=np.random.default_rng().random((3, 3)))
mod_str = mod.model_dump_json()
mod_json = json.loads(mod_str)
2024-04-23 03:00:43 +00:00
assert isinstance(mod_json["array"], list)
2024-04-23 03:00:43 +00:00
# but when we just dump to a dict we don't coerce
mod_dict = mod.model_dump()
2024-04-23 03:01:55 +00:00
assert isinstance(mod_dict["array"], np.ndarray)
2024-05-16 03:49:15 +00:00
_json_schema_types = [
*[(t, float) for t in dtype.Float],
*[(t, int) for t in dtype.Integer],
]
@pytest.mark.json_schema
2024-05-16 03:49:15 +00:00
def test_json_schema_basic(array_model):
"""
NDArray types should correctly generate a list of lists JSON schema
"""
shape = (15, 10)
dtype = float
model = array_model(shape, dtype)
schema = model.model_json_schema()
field = schema["properties"]["array"]
# outer shape
assert field["maxItems"] == shape[0]
assert field["minItems"] == shape[0]
assert field["type"] == "array"
# inner shape
inner = field["items"]
assert inner["minItems"] == shape[1]
assert inner["maxItems"] == shape[1]
assert inner["items"]["type"] == "number"
@pytest.mark.dtype
@pytest.mark.json_schema
2024-05-16 03:49:15 +00:00
@pytest.mark.parametrize("dtype", [*dtype.Integer, *dtype.Float])
def test_json_schema_dtype_single(dtype, array_model):
"""
dtypes should have correct mins and maxes set, and store the source dtype
"""
if issubclass(dtype, np.floating):
info = np.finfo(dtype)
min_val = info.min
max_val = info.max
schema_type = "number"
elif issubclass(dtype, np.integer):
info = np.iinfo(dtype)
min_val = info.min
max_val = info.max
schema_type = "integer"
else:
raise ValueError("These should all be numpy types!")
shape = (15, 10)
model = array_model(shape, dtype)
schema = model.model_json_schema()
inner_type = schema["properties"]["array"]["items"]["items"]
assert inner_type["minimum"] == min_val
assert inner_type["maximum"] == max_val
assert inner_type["type"] == schema_type
assert schema["properties"]["array"]["dtype"] == ".".join(
[dtype.__module__, dtype.__name__]
)
@pytest.mark.dtype
2024-09-23 20:28:38 +00:00
@pytest.mark.json_schema
@pytest.mark.parametrize(
"dtype,expected",
[
(dtype.Integer, "integer"),
(dtype.Float, "number"),
(dtype.Bool, "boolean"),
(int, "integer"),
(float, "number"),
(bool, "boolean"),
2024-05-18 01:05:36 +00:00
(complex, "any"),
],
)
def test_json_schema_dtype_builtin(dtype, expected, array_model):
2024-05-16 03:49:15 +00:00
"""
Using builtin or generic (eg. `dtype.Integer` ) dtypes should
make a simple json schema without mins/maxes/dtypes.
"""
model = array_model(dtype=dtype)
schema = model.model_json_schema()
inner_type = schema["properties"]["array"]["items"]["items"]
2024-05-18 01:05:36 +00:00
if expected == "any":
assert inner_type == {}
else:
assert inner_type["type"] == expected
2024-05-16 03:49:15 +00:00
@pytest.mark.dtype
@pytest.mark.json_schema
def test_json_schema_dtype_model():
"""
Pydantic models can be used in arrays as dtypes
"""
class TestModel(BaseModel):
x: int
y: int
z: int
class MyModel(BaseModel):
array: NDArray[Shape["*, *"], TestModel]
schema = MyModel.model_json_schema()
# we should have a "$defs" with TestModel in it,
# and our array should be objects of that type
assert schema["properties"]["array"]["items"]["items"] == {
"$ref": "#/$defs/TestModel"
}
# we don't test pydantic' generic json schema model generation,
# just that one was defined
assert "TestModel" in schema["$defs"]
2024-05-21 04:16:16 +00:00
def _recursive_array(schema):
assert "$defs" in schema
# get the key uses for the array
array_key = list(schema["$defs"].keys())[0]
# the array property should be a ref to the recursive array
# get the innermost part of the field schema
field_schema = schema["properties"]["array"]
while "items" in field_schema:
field_schema = field_schema["items"]
assert field_schema["$ref"] == f"#/$defs/{array_key}"
# and the recursive array should indeed be recursive...
# specifically it should be an array whose items can be itself or
# of the type specified by the dtype
any_of = schema["$defs"][array_key]["anyOf"]
assert any_of[0]["items"]["$ref"] == f"#/$defs/{array_key}"
assert any_of[0]["type"] == "array"
# here we are just assuming that it's a uint8 array..
assert any_of[1]["type"] == "integer"
assert any_of[1]["maximum"] == 255
assert any_of[1]["minimum"] == 0
2024-05-16 03:49:15 +00:00
@pytest.mark.shape
@pytest.mark.json_schema
2024-05-16 03:49:15 +00:00
def test_json_schema_ellipsis():
"""
NDArray types should create a recursive JSON schema for any-shaped arrays
"""
2024-05-21 04:16:16 +00:00
class AnyShape(BaseModel):
array: NDArray[Shape["*, ..."], np.uint8]
schema = AnyShape.model_json_schema()
_recursive_array(schema)
class ConstrainedAnyShape(BaseModel):
array: NDArray[Shape["3, 4, ..."], np.uint8]
schema = ConstrainedAnyShape.model_json_schema()
_recursive_array(schema)
def test_instancecheck():
"""
NDArray should handle ``isinstance()`` s.t. valid arrays are ``True``
and invalid arrays are ``False``
We don't make this test exhaustive because correctness of validation
is tested elsewhere. We are just testing that the type checking works
"""
array_type = NDArray[Shape["1, 2, 3"], int]
assert isinstance(np.zeros((1, 2, 3), dtype=int), array_type)
assert not isinstance(np.zeros((2, 2, 3), dtype=int), array_type)
assert not isinstance(np.zeros((1, 2, 3), dtype=float), array_type)
def my_function(array: NDArray[Shape["1, 2, 3"], int]):
return array
my_function(np.zeros((1, 2, 3), int))
2024-08-14 06:05:28 +00:00
def test_callable():
"""
NDArray objects are callable to validate and cast
Don't test validation here, just that we can be called
"""
annotation = NDArray[Shape["3"], int]
array = np.array([1, 2, 3], dtype=int)
validated = annotation(array)
assert validated is array
with pytest.raises(DtypeError):
_ = annotation(np.zeros((1, 2, 3)))