-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* add in_bounds validator * update API in docs * add tests for in_bounds validator * add is_subdtype validator * update release notes * black * update release notes * better error message for in_bounds validator
- Loading branch information
Showing
4 changed files
with
220 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
import attr | ||
import numpy as np | ||
import pytest | ||
|
||
from xsimlab.validators import in_bounds, is_subdtype | ||
|
||
|
||
def simple_attr(name): | ||
""" | ||
Return an attribute with a name just for testing purpose. | ||
""" | ||
return attr.Attribute( | ||
name=name, | ||
default=attr.NOTHING, | ||
validator=None, | ||
repr=True, | ||
eq=True, | ||
cmp=None, | ||
hash=None, | ||
init=True, | ||
converter=None, | ||
kw_only=False, | ||
) | ||
|
||
|
||
def test_in_bounds_init(): | ||
with pytest.raises(ValueError, match=r"Invalid bounds.*"): | ||
in_bounds((5, 0)) | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"bounds,value", | ||
[ | ||
((0, 5), 2), | ||
((0, 5), 0), | ||
((0, 5), 5), | ||
((0, 5), np.array([0, 1, 2, 3, 4, 5])), | ||
((None, 5), -1000), | ||
((0, None), 1000), | ||
((None, None), 1000), | ||
], | ||
) | ||
def test_in_bounds_success(bounds, value): | ||
v = in_bounds(bounds) | ||
|
||
# nothing happens | ||
v(None, simple_attr("test"), value) | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"bounds,closed,value", | ||
[ | ||
((0, 5), (True, True), 6), | ||
((0, 5), (True, False), 5), | ||
((0, 5), (False, True), 0), | ||
((0, 5), (False, False), np.array([0, 1, 2, 3, 4, 5])), | ||
], | ||
) | ||
def test_in_bounds_fail(bounds, closed, value): | ||
v = in_bounds(bounds, closed=closed) | ||
|
||
with pytest.raises(ValueError, match=r".*out of bounds.*"): | ||
v(None, simple_attr("test"), value) | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"closed,interval_str", | ||
[ | ||
((True, True), "[0, 5]"), | ||
((True, False), "[0, 5)"), | ||
((False, True), "(0, 5]"), | ||
((False, False), "(0, 5)"), | ||
], | ||
) | ||
def test_in_bounds_repr(closed, interval_str): | ||
v = in_bounds((0, 5), closed=closed) | ||
|
||
expected = f"<in_bounds validator with bounds {interval_str}>" | ||
assert repr(v) == expected | ||
|
||
|
||
def test_is_subdtype_success(): | ||
v = is_subdtype(np.number) | ||
|
||
# nothing happends | ||
v(None, simple_attr("test"), np.array([1, 2, 3])) | ||
v(None, simple_attr("test"), np.array([1.0, 2.0, 3.0])) | ||
|
||
|
||
def test_is_subdtype_fail(): | ||
v = is_subdtype(np.number) | ||
|
||
with pytest.raises(TypeError, match=r".*not a sub-dtype of.*"): | ||
v(None, simple_attr("test"), np.array(["1", "2", "3"])) | ||
|
||
|
||
def test_is_subdtype_repr(): | ||
v = is_subdtype(np.number) | ||
|
||
assert repr(v) == "<is_subdtype validator with type: <class 'numpy.number'>>" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
from typing import Any, Tuple | ||
|
||
import attr | ||
import numpy as np | ||
|
||
|
||
__all__ = ["in_bounds", "is_subdtype"] | ||
|
||
|
||
@attr.s(auto_attribs=True, repr=False, hash=True) | ||
class _InBoundsValidator: | ||
bounds: Tuple[Any, Any] | ||
closed: Tuple[bool, bool] | ||
|
||
def __attrs_post_init__(self): | ||
delim_left = "[" if self.closed[0] else "(" | ||
delim_right = "]" if self.closed[1] else ")" | ||
|
||
self.bounds_str = f"{delim_left}{self.bounds[0]}, {self.bounds[1]}{delim_right}" | ||
|
||
if ( | ||
self.bounds[0] is not None | ||
and self.bounds[1] is not None | ||
and self.bounds[1] < self.bounds[0] | ||
): | ||
raise ValueError( | ||
f"Invalid bounds {self.bounds_str}: " | ||
"upper limit should be higher than lower limit" | ||
) | ||
|
||
def __call__(self, inst, attr, value): | ||
out_lower = self.bounds[0] is not None and ( | ||
value < self.bounds[0] if self.closed[0] else value <= self.bounds[0] | ||
) | ||
out_upper = self.bounds[1] is not None and ( | ||
value > self.bounds[1] if self.closed[1] else value >= self.bounds[1] | ||
) | ||
|
||
if np.any(out_lower) or np.any(out_upper): | ||
common_msg = f"out of bounds {self.bounds_str}" | ||
|
||
if np.isscalar(value): | ||
msg = f"Value {value} of variable '{attr.name}' is " + common_msg | ||
else: | ||
msg = f"Found value(s) in array '{attr.name}' " + common_msg | ||
|
||
raise ValueError(msg) | ||
|
||
def __repr__(self): | ||
return f"<in_bounds validator with bounds {self.bounds_str}>" | ||
|
||
|
||
def in_bounds(bounds, closed=(True, True)): | ||
"""A validator that raises a :exc:`ValueError` if a given value is out of | ||
the given bounded interval. | ||
It works with scalar values as well as with arrays (element-wise check). | ||
Parameters | ||
---------- | ||
bounds : tuple | ||
Lower and upper value bounds. Use ``None`` for either lower or upper | ||
value to set half-bounded intervals. | ||
closed : tuple, optional | ||
Set an open, half-open or closed interval, i.e., whether the | ||
lower and/or upper bound is included or not in the interval. | ||
Default: closed interval (i.e., includes both lower and upper bounds). | ||
""" | ||
return _InBoundsValidator(tuple(bounds), tuple(closed)) | ||
|
||
|
||
@attr.s(repr=False, hash=True) | ||
class _IsSubdtypeValidator: | ||
dtype = attr.ib() | ||
|
||
def __call__(self, inst, attr, value): | ||
if not np.issubdtype(value.dtype, self.dtype): | ||
raise TypeError( | ||
f"'{attr.name}' array has {value.dtype!r}, which is not " | ||
f"a sub-dtype of {self.dtype!r}" | ||
) | ||
|
||
def __repr__(self): | ||
return f"<is_subdtype validator with type: {self.dtype!r}>" | ||
|
||
|
||
def is_subdtype(dtype): | ||
"""A validator that raises a :exc:`TypeError` if a given array has a wrong | ||
dtype. | ||
Parameters | ||
---------- | ||
dtype : dtype_like | ||
dtype or string representing the typecode of the highest type | ||
allowed in the hierarchy. | ||
See Also | ||
-------- | ||
:func:`numpy.issubdtype` | ||
""" | ||
return _IsSubdtypeValidator(dtype) |