From 692e4613a08314014a14ac57e3a0d5227c9173fc Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 29 Jan 2015 12:20:17 +0100 Subject: [PATCH] Add instance_of validator --- attr/__init__.py | 2 ++ attr/_dunders.py | 7 +++-- attr/_make.py | 21 +++++++++------ attr/validators.py | 41 +++++++++++++++++++++++++++++ docs/api.rst | 44 ++++++++++++++++++++++++++++--- docs/examples.rst | 19 ++++++++++++++ tests/__init__.py | 21 +++++++++++++++ tests/test_dark_magic.py | 17 +++++++++++- tests/test_dunders.py | 11 ++------ tests/test_funcs.py | 4 +++ tests/test_make.py | 1 - tests/test_validators.py | 56 ++++++++++++++++++++++++++++++++++++++++ 12 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 attr/validators.py create mode 100644 tests/test_validators.py diff --git a/attr/__init__.py b/attr/__init__.py index fcb62c44..eb271044 100644 --- a/attr/__init__.py +++ b/attr/__init__.py @@ -12,6 +12,7 @@ from ._make import ( _add_methods, _make_attr, ) +from . import validators __version__ = "15.0.0.dev0" __author__ = "Hynek Schlawack" @@ -31,4 +32,5 @@ __all__ = [ "ls", "s", "to_dict", + "validators", ] diff --git a/attr/_dunders.py b/attr/_dunders.py index cb8971cb..e00569f9 100644 --- a/attr/_dunders.py +++ b/attr/_dunders.py @@ -184,8 +184,11 @@ def _attrs_to_script(attrs): "{name})" .format(name=a.name)) if a.default_value is not NOTHING: - args.append("{name}={default!r}".format(name=a.name, - default=a.default_value)) + args.append( + "{name}=attr_dict['{name}'].default_value".format( + name=a.name, + ) + ) lines.append("self.{name} = {name}".format(name=a.name)) elif a.default_factory is not NOTHING: args.append("{name}=NOTHING".format(name=a.name)) diff --git a/attr/_make.py b/attr/_make.py index 67b0ec17..95045718 100644 --- a/attr/_make.py +++ b/attr/_make.py @@ -80,9 +80,12 @@ def _make_attr(default_value=NOTHING, default_factory=NOTHING, validator=None): value is passed while instantiating. :type default_factory: callable - :param validator: :func:`callable` that is called on the attribute - if an ``attrs``-generated ``__init__`` is used. The return value is - *not* inspected so the validator has to throw an exception itself. + :param validator: :func:`callable` that is called within + ``attrs``-generated ``__init__`` methods with the :class:`Attribute` as + the first parameter and the passed value as the second parameter. + + The return value is *not* inspected so the validator has to throw an + exception itself. :type validator: callable """ if default_value is not NOTHING and default_factory is not NOTHING: @@ -94,7 +97,7 @@ def _make_attr(default_value=NOTHING, default_factory=NOTHING, validator=None): return _CountingAttr( default_value=default_value, default_factory=default_factory, - validator=None, + validator=validator, ) @@ -143,10 +146,12 @@ def _add_methods(maybe_cl=None, add_repr=True, add_cmp=True, add_hash=True, # as `@_add_methods()`. if isinstance(maybe_cl, type): cl = maybe_cl - cl.__attrs_attrs__ = [ - Attribute.from_counting_attr(name=name, ca=ca) - for name, ca in _get_attrs(cl) - ] + cl.__attrs_attrs__ = [] + # Replace internal `_CountingAttr`s with `Attribute`s. + for name, ca in _get_attrs(cl): + a = Attribute.from_counting_attr(name=name, ca=ca) + cl.__attrs_attrs__.append(a) + setattr(cl, name, a) return _add_init(_add_hash(_add_cmp(_add_repr(cl)))) else: def wrap(cl): diff --git a/attr/validators.py b/attr/validators.py new file mode 100644 index 00000000..55951f1b --- /dev/null +++ b/attr/validators.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + + +class InstanceValidator(object): + # We use a callable class to be able to change the ``__repr__``. + def __init__(self, type_): + self.type_ = type_ + + def __call__(self, attr, value): + if not isinstance(value, self.type_): + raise TypeError( + "'{name}' must be {type!r} (got {value!r} that is a " + "{actual!r})." + .format(name=attr.name, type=self.type_, + actual=value.__class__, value=value), + attr, self.type_, value, + ) + + def __repr__(self): + return ( + "" + .format(type=self.type_) + ) + + +def instance_of(type_): + """ + A validator that raises a :exc:`TypeError` if the initializer is called + with a wrong type for this particular attribute (checks are perfomed using + :func:`isinstance`). + + :param type_: The type to check for. + :type type_: type + + 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 InstanceValidator(type_) diff --git a/docs/api.rst b/docs/api.rst index 98196b92..95cf5fd0 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -31,6 +31,24 @@ Core ``attrs`` also comes with a less playful alias ``attr.attr``. +.. autoclass:: attr.Attribute + + Instances of this class are frequently used for introspection purposes like: + + - Class attributes on ``attrs``-decorated classes. + - :func:`ls` returns a list of them. + - Validators get them passed as the first argument. + + .. doctest:: + + >>> import attr + >>> @attr.s + ... class C(object): + ... x = attr.ib() + >>> C.x + Attribute(name='x', default_value=NOTHING, default_factory=NOTHING, validator=None) + + Helpers ------- @@ -42,7 +60,6 @@ Helpers .. doctest:: - >>> import attr >>> @attr.s ... class C(object): ... x = attr.ib() @@ -80,6 +97,27 @@ Helpers {'y': {'y': 3, 'x': 2}, 'x': 1} -.. autoclass:: attr.Attribute +.. _api_validators: - This class is only interesting because it is returned by :func:`ls`. +Validators +---------- + +``attrs`` comes with some common validators within the ``attrs.validators`` module: + + +.. autofunction:: attr.validators.instance_of + + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib(validator=attr.validators.instance_of(int)) + >>> C(42) + C(x=42) + >>> C("42") + Traceback (most recent call last): + ... + TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default_value=NOTHING, default_factory=NOTHING, validator=>), , '42') diff --git a/docs/examples.rst b/docs/examples.rst index 35295eb6..fba76914 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -100,3 +100,22 @@ And sometimes you even want mutable objects as default values (ever used acciden ConnectionPool(db_string='postgres://localhost', pool=deque([Connection(socket=42)]), debug=False) More information on why class methods for constructing objects are awesome can be found in this insightful `blog post `_. + + +Although your initializers should be a dumb as possible, it can come handy to do some kind of validation on the arguments. +That's when :func:`attr.ib`\ ’s ``validator`` argument comes into play. +A validator is simply a callable that takes two arguments: the attribute that it's validating + +.. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib(validator=attr.validators.instance_of(int)) + >>> C(42) + C(x=42) + >>> C("42") + Traceback (most recent call last): + ... + TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default_value=NOTHING, default_factory=NOTHING, validator=>), , '42') + +``attrs`` ships with a bunch of validators, make sure to :ref:`check them out ` before writing your own! diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..5eb80baa 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + + +import sys + + +PY3 = sys.version_info[0] == 3 +# TYPE is used in exceptions, repr(int) is differnt on Python 2 and 3. +TYPE = "class" if PY3 else "type" + + +def simple_attr(name): + """ + Return an attribute with a name and no other bells and whistles. + """ + from attr import Attribute + from attr._dunders import NOTHING + return Attribute(name=name, default_value=NOTHING, default_factory=NOTHING, + validator=None) diff --git a/tests/test_dark_magic.py b/tests/test_dark_magic.py index 7badb5eb..f8512a23 100644 --- a/tests/test_dark_magic.py +++ b/tests/test_dark_magic.py @@ -2,15 +2,18 @@ from __future__ import absolute_import, division, print_function +import pytest import attr from attr._make import Attribute, NOTHING +from . import TYPE + @attr.s class C1(object): - x = attr.ib() + x = attr.ib(validator=attr.validators.instance_of(int)) y = attr.ib() @@ -46,3 +49,15 @@ class TestDarkMagic(object): "x": 1, "y": 2, } == attr.to_dict(C1(x=1, y=2)) + + def test_validator(self): + """ + `instance_of` raises `TypeError` on type mismatch. + """ + with pytest.raises(TypeError) as e: + C1("1", 2) + assert ( + "'x' must be <{type} 'int'> (got '1' that is a <{type} " + "'str'>).".format(type=TYPE), + C1.x, int, "1", + ) == e.value.args diff --git a/tests/test_dunders.py b/tests/test_dunders.py index a05338a5..7eb25860 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -4,6 +4,7 @@ from __future__ import absolute_import, division, print_function import pytest +from . import simple_attr from attr._make import Attribute from attr._dunders import ( NOTHING, @@ -14,14 +15,6 @@ from attr._dunders import ( ) -def simple_attr(name): - """ - Return an attribute with a name and no other bells and whistles. - """ - return Attribute(name=name, default_value=NOTHING, default_factory=NOTHING, - validator=None) - - def make_class(): """ Return a new simple class. @@ -269,4 +262,4 @@ class TestAddInit(object): with pytest.raises(VException) as e: C(42) - assert ((a, 42),) == e.value.args + assert ((a, 42,),) == e.value.args diff --git a/tests/test_funcs.py b/tests/test_funcs.py index 4abfb899..b2859697 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -1,5 +1,9 @@ # -*- coding: utf-8 -*- +""" +Tests for `attr._funcs`. +""" + from __future__ import absolute_import, division, print_function import pytest diff --git a/tests/test_make.py b/tests/test_make.py index 71f13bec..2e396507 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -46,7 +46,6 @@ class TestGetAttrs(object): """ Returns attributes in correct order. """ - @_add_methods class C(object): z = _make_attr() y = _make_attr() diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 00000000..35ef0621 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +""" +Tests for `attr.validators`. +""" + +from __future__ import absolute_import, division, print_function + +import pytest + +from attr.validators import instance_of +from . import simple_attr, TYPE + + +class TestInstanceOf(object): + """ + Tests for `instance_of`. + """ + def test_success(self): + """ + Nothing happens if types match. + """ + v = instance_of(int) + v(simple_attr("test"), 42) + + def test_subclass(self): + """ + Subclasses are accepted too. + """ + v = instance_of(int) + v(simple_attr("test"), True) # yep, bools are a subclass of int :( + + def test_fail(self): + """ + Raises `TypeError` on wrong types. + """ + v = instance_of(int) + a = simple_attr("test") + with pytest.raises(TypeError) as e: + v(a, "42") + assert ( + "'test' must be <{type} 'int'> (got '42' that is a <{type} " + "'str'>).".format(type=TYPE), + a, int, "42", + + ) == e.value.args + + def test_repr(self): + """ + Returned validator has a useful `__repr__`. + """ + v = instance_of(int) + assert ( + ">" + .format(type=TYPE) + ) == repr(v)