diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d514827e2..b65d08e6b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -49,7 +49,10 @@ Changes: - Validators can now be defined conveniently inline by using the attribute as a decorator. Check out the `examples `_ to see it in action! `#143 `_ - +- New ``list_of`` validator ensures that an attribute is a homogeneous list with elements of a specified class or subclass. + See `examples `_. + `#157 `_ + ---- diff --git a/docs/api.rst b/docs/api.rst index acfc458de..134050510 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -257,6 +257,36 @@ Validators TypeError: ("'x' must be (got None that is a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True), , None) +.. autofunction:: attr.validators.list_of + + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib(validator=attr.validators.list_of(int)) + + >>> C([42]) + C(x=[42]) + + >>> C(42) + Traceback (most recent call last): + ... + TypeError: ("'x' must be of type (got a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({})), , 42) + + >>> C(["42"]) + Traceback (most recent call last): + ... + TypeError: ("'x' must be a list of elements.", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({})), , ['42']) + + >>> C([None]) + Traceback (most recent call last): + ... + TypeError: ("'x' must be a list of elements.", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({})), , [None]) + + .. autofunction:: attr.validators.provides .. autofunction:: attr.validators.optional diff --git a/src/attr/validators.py b/src/attr/validators.py index 8f8ded327..1679f5311 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -47,6 +47,53 @@ def instance_of(type): return _InstanceOfValidator(type) +@attributes(repr=False, slots=True) +class _ListOfValidator(object): + type = attr() + + def __call__(self, inst, attr, list_of_values): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not isinstance(list_of_values, list): + raise TypeError( + "'{name}' must be of type {type!r} (got a {actual!r})." + .format(name=attr.name, type=list, + actual=list_of_values.__class__), + attr, self.type, list_of_values, + ) + if not all(isinstance(value, self.type) for value in list_of_values): + raise TypeError( + "'{name}' must be a list of {type!r} elements." + .format(name=attr.name, type=self.type), + attr, self.type, list_of_values, + ) + + def __repr__(self): + return ( + "" + .format(type=self.type) + ) + + +def list_of(type): + """A validator that raises a :exc:`TypeError` if the initializer is + called with a non-list type or with a list that contains one or + more elements of a wrong type. None values are not permitted. As + with instance_of, checks are perfomed using :func:`isinstance` + therefore it's also valid to pass a tuple of types. + + :param type: The type to check for. + :type type: type or tuple of types + + The :exc:`TypeError` is raised with a human readable error message, the + attribute (of type :class:`attr.Attribute`), the expected type, and the + value it got. + + """ + return _ListOfValidator(type) + + @attributes(repr=False, slots=True) class _ProvidesValidator(object): interface = attr() diff --git a/tests/test_validators.py b/tests/test_validators.py index 5e08fe9e5..72d8be6a5 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -7,7 +7,7 @@ import pytest import zope.interface -from attr.validators import instance_of, provides, optional +from attr.validators import instance_of, list_of, provides, optional from attr._compat import TYPE from .utils import simple_attr @@ -58,6 +58,67 @@ def test_repr(self): ) == repr(v) +class TestListOf(object): + """ + Tests for `list_of`. + """ + def test_success(self): + """ + Nothing happens if types match. + """ + v = list_of(int) + v(None, simple_attr("test"), [42, 55]) + + def test_subclass(self): + """ + Subclasses are accepted too. + """ + class MyInt(int): + pass + v = list_of(int) + v(None, simple_attr("test"), [MyInt(42)]) + + def test_fail_not_a_list(self): + """ + Raises `TypeError` on wrong types. + """ + v = list_of(int) + a = simple_attr("test") + with pytest.raises(TypeError) as e: + v(None, a, 42) + assert ( + "'test' must be of type <{type} 'list'>" + " (got a <{type} 'int'>).".format(type=TYPE), + a, int, 42, + + ) == e.value.args + + def test_fail_list_of_wrong_type(self): + """ + Raises `TypeError` on wrong types. + """ + v = list_of(int) + a = simple_attr("test") + with pytest.raises(TypeError) as e: + v(None, a, ["42"]) + assert ( + "'test' must be a list of <{type}" + " 'int'> elements.".format(type=TYPE), + a, int, ["42"], + + ) == e.value.args + + def test_repr(self): + """ + Returned validator has a useful `__repr__`. + """ + v = list_of(int) + assert ( + ">" + .format(type=TYPE) + ) == repr(v) + + class IFoo(zope.interface.Interface): """ An interface.