Skip to content

Commit

Permalink
Add validators (#87)
Browse files Browse the repository at this point in the history
* 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
benbovy committed Jan 11, 2020
1 parent d94c629 commit 537555d
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 0 deletions.
15 changes: 15 additions & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,18 @@ Variable
foreign
group
on_demand

Validators
==========

See also `attrs' validators`_. Some additional validators for common usage are
listed below. These are defined in ``xsimlab.validators``.

.. currentmodule:: xsimlab.validators
.. autosummary::
:toctree: _api_generated/

in_bounds
is_subdtype

.. _`attrs' validators`: https://www.attrs.org/en/stable/examples.html#validators
2 changes: 2 additions & 0 deletions doc/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Enhancements
to automatically add an attributes section to the docstring of the class to
which the decorator is applied, using the metadata of each variable declared
in the class (:issue:`67`).
- Added :func:`~xsimlab.validators.in_bounds` and
:func:`~xsimlab.validators.is_subdtype` validators (:issue:`87`).

Bug fixes
~~~~~~~~~
Expand Down
100 changes: 100 additions & 0 deletions xsimlab/tests/test_validators.py
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'>>"
103 changes: 103 additions & 0 deletions xsimlab/validators.py
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)

0 comments on commit 537555d

Please sign in to comment.