Skip to content

Commit

Permalink
Make optional support lists of validators (#186)
Browse files Browse the repository at this point in the history
  • Loading branch information
hynek authored May 12, 2017
1 parent fdfd51e commit fbe0bd5
Show file tree
Hide file tree
Showing 7 changed files with 170 additions and 52 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,12 @@ Changes:
`#128 <https://github.com/python-attrs/attrs/pull/128>`_
- ``__attrs_post_init__()`` is now run if validation is disabled.
`#130 <https://github.com/python-attrs/attrs/pull/130>`_
- The ``validator`` argument of ``@attr.s`` now can take a ``list`` of validators that all have to pass.
- Added ``attr.validators.and_()`` that composes multiple validators into one.
`#161 <https://github.com/python-attrs/attrs/issues/161>`_
- For convenience, the ``validator`` argument of ``@attr.s`` now can take a ``list`` of validators that are wrapped using ``and_()``.
`#138 <https://github.com/python-attrs/attrs/issues/138>`_
- Accordingly, ``attr.validators.optional()`` now can take a ``list`` of validators too.
`#161 <https://github.com/python-attrs/attrs/issues/161>`_
- Validators can now be defined conveniently inline by using the attribute as a decorator.
Check out the `examples <http://www.attrs.org/en/stable/examples.html#validators>`_ to see it in action!
`#143 <https://github.com/python-attrs/attrs/issues/143>`_
Expand Down
8 changes: 8 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,14 @@ Validators
...
TypeError: ("'x' must be <type 'int'> (got None that is a <type 'NoneType'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True), <type 'int'>, None)

.. autofunction:: attr.validators.and_

For convenience, it's also possible to pass a list to :func:`attr.ib`'s validator argument.

Thus the following two statements are equivalent::

x = attr.ib(validator=attr.validators.and_(v1, v2, v3))
x = attr.ib(validator=[v1, v2, v3])

.. autofunction:: attr.validators.provides

Expand Down
5 changes: 4 additions & 1 deletion docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,8 @@ Since the validators runs *after* the instance is initialized, you can refer to
... raise ValueError("'x' has to be smaller than 'y'!")
>>> @attr.s
... class C(object):
... x = attr.ib(validator=x_smaller_than_y)
... x = attr.ib(validator=[attr.validators.instance_of(int),
... x_smaller_than_y])
... y = attr.ib()
>>> C(x=3, y=4)
C(x=3, y=4)
Expand All @@ -327,6 +328,8 @@ Since the validators runs *after* the instance is initialized, you can refer to
...
ValueError: 'x' has to be smaller than 'y'!

This example also shows of some syntactic sugar for using the :func:`attr.validators.and_` validator: if you pass a list, all validators have to pass.

``attrs`` won't intercept your changes to those attributes but you can always call :func:`attr.validate` on any instance to verify that it's still valid:

.. doctest::
Expand Down
68 changes: 43 additions & 25 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ def __init__(self, default, validator, repr, cmp, hash, init, convert,
self.default = default
# If validator is a list/tuple, wrap it using helper validator.
if validator and isinstance(validator, (list, tuple)):
self._validator = _AndValidator(tuple(validator))
self._validator = and_(*validator)
else:
self._validator = validator
self.repr = repr
Expand All @@ -911,37 +911,18 @@ def validator(self, meth):
"""
Decorator that adds *meth* to the list of validators.
Returns meth unchanged.
Returns *meth* unchanged.
"""
if not isinstance(self._validator, _AndValidator):
self._validator = _AndValidator(
(self._validator,) if self._validator else ()
)
self._validator.add(meth)
if self._validator is None:
self._validator = meth
else:
self._validator = and_(self._validator, meth)
return meth


_CountingAttr = _add_cmp(_add_repr(_CountingAttr))


@attributes(slots=True)
class _AndValidator(object):
"""
Compose many validators to a single one.
"""
_validators = attr()

def __call__(self, inst, attr, value):
for v in self._validators:
v(inst, attr, value)

def add(self, validator):
"""
Add *validator*. Shouldn't be called after the class is done.
"""
self._validators += (validator,)


@attributes(slots=True)
class Factory(object):
"""
Expand Down Expand Up @@ -981,3 +962,40 @@ def make_class(name, attrs, bases=(object,), **attributes_arguments):
raise TypeError("attrs argument must be a dict or a list.")

return attributes(**attributes_arguments)(type(name, bases, cls_dict))


# These are required by whithin this module so we define them here and merely
# import into .validators.


@attributes(slots=True)
class _AndValidator(object):
"""
Compose many validators to a single one.
"""
_validators = attr()

def __call__(self, inst, attr, value):
for v in self._validators:
v(inst, attr, value)


def and_(*validators):
"""
A validator that composes multiple validators into one.
When called on a value, it runs all wrapped validators.
:param validators: Arbitrary number of validators.
:type validators: callables
.. versionadded:: 17.1.0
"""
vals = []
for validator in validators:
vals.extend(
validator._validators if isinstance(validator, _AndValidator)
else [validator]
)

return _AndValidator(tuple(vals))
26 changes: 21 additions & 5 deletions src/attr/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@

from __future__ import absolute_import, division, print_function

from ._make import attr, attributes
from ._make import attr, attributes, and_, _AndValidator


__all__ = [
"and_",
"instance_of",
"optional",
"provides",
]


@attributes(repr=False, slots=True)
Expand Down Expand Up @@ -93,12 +101,13 @@ class _OptionalValidator(object):
def __call__(self, inst, attr, value):
if value is None:
return
return self.validator(inst, attr, value)

self.validator(inst, attr, value)

def __repr__(self):
return (
"<optional validator for {type} or None>"
.format(type=repr(self.validator))
"<optional validator for {what} or None>"
.format(what=repr(self.validator))
)


Expand All @@ -108,6 +117,13 @@ def optional(validator):
which can be set to ``None`` in addition to satisfying the requirements of
the sub-validator.
:param validator: A validator that is used for non-``None`` values.
:param validator: A validator (or a list of validators) that is used for
non-``None`` values.
:type validator: callable or :class:`list` of callables.
.. versionadded:: 15.1.0
.. versionchanged:: 17.1.0 *validator* can be a list of validators.
"""
if isinstance(validator, list):
return _OptionalValidator(_AndValidator(validator))
return _OptionalValidator(validator)
19 changes: 14 additions & 5 deletions tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
_AndValidator,
_CountingAttr,
_transform_attrs,
and_,
attr,
attributes,
fields,
Expand Down Expand Up @@ -71,24 +72,32 @@ def v2(_, __):

def test_validator_decorator_single(self):
"""
If _CountingAttr.validator is used as a decorator and there is no
decorator set, the decorated method is used as the validator.
"""
a = attr()

@a.validator
def v():
pass

assert _AndValidator((v,)) == a._validator
assert v == a._validator

def test_validator_decorator(self):
@pytest.mark.parametrize("wrap", [
lambda v: v,
lambda v: [v],
lambda v: and_(v)
])
def test_validator_decorator(self, wrap):
"""
If _CountingAttr.validator is used as a decorator, the decorated method
is added to validators.
If _CountingAttr.validator is used as a decorator and there is already
a decorator set, the decorators are composed using `and_`.
"""
def v(_, __):
pass

a = attr(validator=[v])
a = attr(validator=wrap(v))

@a.validator
def v2(self, _, __):
Expand Down
90 changes: 75 additions & 15 deletions tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
import pytest
import zope.interface

from attr.validators import instance_of, provides, optional
from attr.validators import and_, instance_of, provides, optional
from attr._compat import TYPE
from attr._make import attributes, attr

from .utils import simple_attr

Expand Down Expand Up @@ -58,6 +59,53 @@ def test_repr(self):
) == repr(v)


def always_pass(_, __, ___):
"""
Toy validator that always passses.
"""


def always_fail(_, __, ___):
"""
Toy validator that always fails.
"""
0/0


class TestAnd(object):
def test_success(self):
"""
Succeeds if all wrapped validators succeed.
"""
v = and_(instance_of(int), always_pass)

v(None, simple_attr("test"), 42)

def test_fail(self):
"""
Fails if any wrapped validator fails.
"""
v = and_(instance_of(int), always_fail)

with pytest.raises(ZeroDivisionError):
v(None, simple_attr("test"), 42)

def test_sugar(self):
"""
`and_(v1, v2, v3)` and `[v1, v2, v3]` are equivalent.
"""
@attributes
class C(object):
a1 = attr("a1", validator=and_(
instance_of(int),
))
a2 = attr("a2", validator=[
instance_of(int),
])

assert C.__attrs_attrs__[0].validator == C.__attrs_attrs__[1].validator


class IFoo(zope.interface.Interface):
"""
An interface.
Expand Down Expand Up @@ -111,29 +159,33 @@ def test_repr(self):
) == repr(v)


@pytest.mark.parametrize("validator", [
instance_of(int),
[always_pass, instance_of(int)],
])
class TestOptional(object):
"""
Tests for `optional`.
"""
def test_success_with_type(self):
def test_success(self, validator):
"""
Nothing happens if types match.
Nothing happens if validator succeeds.
"""
v = optional(instance_of(int))
v = optional(validator)
v(None, simple_attr("test"), 42)

def test_success_with_none(self):
def test_success_with_none(self, validator):
"""
Nothing happens if None.
"""
v = optional(instance_of(int))
v = optional(validator)
v(None, simple_attr("test"), None)

def test_fail(self):
def test_fail(self, validator):
"""
Raises `TypeError` on wrong types.
"""
v = optional(instance_of(int))
v = optional(validator)
a = simple_attr("test")
with pytest.raises(TypeError) as e:
v(None, a, "42")
Expand All @@ -144,13 +196,21 @@ def test_fail(self):

) == e.value.args

def test_repr(self):
def test_repr(self, validator):
"""
Returned validator has a useful `__repr__`.
"""
v = optional(instance_of(int))
assert (
("<optional validator for <instance_of validator for type "
"<{type} 'int'>> or None>")
.format(type=TYPE)
) == repr(v)
v = optional(validator)

if isinstance(validator, list):
assert (
("<optional validator for _AndValidator(_validators=[{func}, "
"<instance_of validator for type <{type} 'int'>>]) or None>")
.format(func=repr(always_pass), type=TYPE)
) == repr(v)
else:
assert (
("<optional validator for <instance_of validator for type "
"<{type} 'int'>> or None>")
.format(type=TYPE)
) == repr(v)

0 comments on commit fbe0bd5

Please sign in to comment.