From 9ce1be6851c11d2d6cb29a08fe0bfbae323fe407 Mon Sep 17 00:00:00 2001 From: Andre Dieb Martins Date: Fri, 12 May 2017 10:52:19 -0400 Subject: [PATCH 1/4] Implement attr.validators.in_() validator for checking inclusion --- CHANGELOG.rst | 2 ++ src/attr/validators.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_validators.py | 27 ++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3971045a4..e653f8b8b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -36,6 +36,8 @@ Deprecations: Changes: ^^^^^^^^ +- Added `attr.validators.in_(options)`` that, given the allowed `options`, checks whether the attribute value is in it. This can be used to check constants, enums, mappings, etc. + `#181 `_ - Fix default hashing behavior. Now *hash* mirrors the value of *cmp* and classes are unhashable by default. `#136`_ diff --git a/src/attr/validators.py b/src/attr/validators.py index 5c7f953a5..c6ad33f7f 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -111,3 +111,39 @@ def optional(validator): :param validator: A validator that is used for non-``None`` values. """ return _OptionalValidator(validator) + + +@attributes(repr=False, slots=True) +class _InValidator(object): + options = attr() + + def __call__(self, inst, attr, value): + if value not in self.options: + raise TypeError( + "'{name}' must be one of {options!r} (got {value!r})" + .format(name=attr.name, options=self.options, value=value) + ) + + def __repr__(self): + return ( + "" + .format(options=self.options) + ) + + +def in_(options): + """ + A validator that raises a :exc:`TypeError` if the initializer is called + with a value that does not belong in the options provided. The check is + performed using ``value in options``. + + :param options: Allowed options + :type options: list, tuple, enum.Enum + + The :exc:`TypeError` is raised with a human readable error message, the + attribute (of type :class:`attr.Attribute`), the expected options, and the + value it got. + + .. versionadded:: 17.1.0 + """ + return _InValidator(options) diff --git a/tests/test_validators.py b/tests/test_validators.py index 5e08fe9e5..591143b71 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, provides, optional, in_ from attr._compat import TYPE from .utils import simple_attr @@ -154,3 +154,28 @@ def test_repr(self): "<{type} 'int'>> or None>") .format(type=TYPE) ) == repr(v) + + +class TestIn_(object): + """ + Tests for `in_`. + """ + def test_success_with_value(self): + v = in_([1, 2, 3]) + a = simple_attr("test") + v(1, a, 3) + + def test_fail(self): + v = in_([1, 2, 3]) + a = simple_attr("test") + with pytest.raises(TypeError) as e: + v(None, a, None) + assert ( + "'test' must be one of [1, 2, 3] (got None)", + ) == e.value.args + + def test_repr(self): + v = in_([3, 4, 5]) + assert( + ("") + ) == repr(v) From db1e9b37c95f78c02232eb1f8b029cd983905195 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 12 May 2017 23:02:07 +0200 Subject: [PATCH 2/4] Make optional support lists of validators (#186) --- CHANGELOG.rst | 6 ++- docs/api.rst | 8 ++++ docs/examples.rst | 5 ++- src/attr/_make.py | 68 +++++++++++++++++++----------- src/attr/validators.py | 26 +++++++++--- tests/test_make.py | 19 ++++++--- tests/test_validators.py | 90 +++++++++++++++++++++++++++++++++------- 7 files changed, 170 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e653f8b8b..6f922da5a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -54,8 +54,12 @@ Changes: `#128 `_ - ``__attrs_post_init__()`` is now run if validation is disabled. `#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 `_ +- For convenience, the ``validator`` argument of ``@attr.s`` now can take a ``list`` of validators that are wrapped using ``and_()``. `#138 `_ +- Accordingly, ``attr.validators.optional()`` now can take a ``list`` of validators too. + `#161 `_ - Validators can now be defined conveniently inline by using the attribute as a decorator. Check out the `examples `_ to see it in action! `#143 `_ diff --git a/docs/api.rst b/docs/api.rst index 3a5b89634..949d6df4b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -262,6 +262,14 @@ 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.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 diff --git a/docs/examples.rst b/docs/examples.rst index daccd7d24..6b2fcae56 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -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) @@ -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:: diff --git a/src/attr/_make.py b/src/attr/_make.py index 5379cd824..cf59678dc 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -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 @@ -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): """ @@ -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)) diff --git a/src/attr/validators.py b/src/attr/validators.py index c6ad33f7f..85c5fbd46 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -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) @@ -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 ( - "" - .format(type=repr(self.validator)) + "" + .format(what=repr(self.validator)) ) @@ -108,8 +117,15 @@ 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) diff --git a/tests/test_make.py b/tests/test_make.py index c8eff23a3..66125a775 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -21,6 +21,7 @@ _AndValidator, _CountingAttr, _transform_attrs, + and_, attr, attributes, fields, @@ -71,6 +72,8 @@ 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() @@ -78,17 +81,23 @@ def test_validator_decorator_single(self): 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, _, __): diff --git a/tests/test_validators.py b/tests/test_validators.py index 591143b71..68ebf0a3d 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -7,8 +7,9 @@ import pytest import zope.interface -from attr.validators import instance_of, provides, optional, in_ +from attr.validators import and_, instance_of, provides, optional, in_ from attr._compat import TYPE +from attr._make import attributes, attr from .utils import simple_attr @@ -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. @@ -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") @@ -144,16 +196,24 @@ 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 ( - ("> or None>") - .format(type=TYPE) - ) == repr(v) + v = optional(validator) + + if isinstance(validator, list): + assert ( + (">]) or None>") + .format(func=repr(always_pass), type=TYPE) + ) == repr(v) + else: + assert ( + ("> or None>") + .format(type=TYPE) + ) == repr(v) class TestIn_(object): From 43f238977ee22badc238cf62823142bc478b606c Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Tue, 16 May 2017 09:36:39 +0200 Subject: [PATCH 3/4] Add takes_self to Factory and @_CountingAttr.default (#189) * Add takes_self to Factory and @_CountingAttr.default Fixes #165 * Add dark @Tinche magic --- CHANGELOG.rst | 7 +++ docs/api.rst | 18 ++++-- docs/examples.rst | 15 +++++ src/attr/_make.py | 123 +++++++++++++++++++++++++++------------ src/attr/exceptions.py | 9 +++ tests/test_dark_magic.py | 27 +++++++-- tests/test_make.py | 39 ++++++++++++- tests/utils.py | 2 +- 8 files changed, 191 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6f922da5a..033570464 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -63,6 +63,12 @@ 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 `_ +- ``attr.Factory()`` now has a ``takes_self`` argument that makes the initializer to pass the partially initialized instance into the factory. + In other words you can define attribute defaults based on other attributes. + `#165`_ +- Default factories can now also be defined inline using decorators. + They are *always* passed the partially initialized instance. + `#165`_ - Conversion can now be made optional using ``attr.converters.optional()``. `#105 `_ `#173 `_ @@ -72,6 +78,7 @@ Changes: `#155 `_ .. _`#136`: https://github.com/python-attrs/attrs/issues/136 +.. _`#165`: https://github.com/python-attrs/attrs/issues/165 ---- diff --git a/docs/api.rst b/docs/api.rst index 949d6df4b..53d01cc11 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -100,13 +100,20 @@ Core >>> @attr.s ... class C(object): ... x = attr.ib(default=attr.Factory(list)) + ... y = attr.ib(default=attr.Factory( + ... lambda self: set(self.x), + ... takes_self=True) + ... ) >>> C() - C(x=[]) + C(x=[], y=set()) + >>> C([1, 2, 3]) + C(x=[1, 2, 3], y={1, 2, 3}) .. autoexception:: attr.exceptions.FrozenInstanceError .. autoexception:: attr.exceptions.AttrsAttributeNotFoundError .. autoexception:: attr.exceptions.NotAnAttrsClassError +.. autoexception:: attr.exceptions.DefaultAlreadySetError .. _helpers: @@ -203,11 +210,12 @@ See :ref:`asdict` for examples. >>> i1 == i2 False - ``evolve`` creates a new instance using ``__init__``. This fact has several implications: + ``evolve`` creates a new instance using ``__init__``. + This fact has several implications: - * private attributes should be specified without the leading underscore, just like in ``__init__``. - * attributes with ``init=False`` can't be set with ``evolve``. - * the usual ``__init__`` validators will validate the new values. + * private attributes should be specified without the leading underscore, just like in ``__init__``. + * attributes with ``init=False`` can't be set with ``evolve``. + * the usual ``__init__`` validators will validate the new values. .. autofunction:: validate diff --git a/docs/examples.rst b/docs/examples.rst index 6b2fcae56..62627264f 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -262,6 +262,21 @@ And sometimes you even want mutable objects as default values (ever used acciden More information on why class methods for constructing objects are awesome can be found in this insightful `blog post `_. +Default factories can also be set using a decorator. +The method receives the partially initialiazed instance which enables you to base a default value on other attributes: + +.. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib(default=1) + ... y = attr.ib() + ... @y.default + ... def name_does_not_matter(self): + ... return self.x + 1 + >>> C() + C(x=1, y=2) + .. _examples_validators: diff --git a/src/attr/_make.py b/src/attr/_make.py index cf59678dc..9659cf5c0 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -7,12 +7,17 @@ from . import _config from ._compat import PY2, iteritems, isclass, iterkeys, metadata_proxy -from .exceptions import FrozenInstanceError, NotAnAttrsClassError +from .exceptions import ( + DefaultAlreadySetError, + FrozenInstanceError, + NotAnAttrsClassError, +) # This is used at least twice, so cache it here. _obj_setattr = object.__setattr__ _init_convert_pat = "__attr_convert_{}" +_init_factory_pat = "__attr_factory_{}" _tuple_property_pat = " {attr_name} = property(itemgetter({index}))" _empty_metadata_singleton = metadata_proxy({}) @@ -701,21 +706,26 @@ def fmt_setter_with_converter(attr_name, value_var): attrs_to_validate.append(a) attr_name = a.name arg_name = a.name.lstrip("_") + has_factory = isinstance(a.default, Factory) + if has_factory and a.default.takes_self: + maybe_self = "self" + else: + maybe_self = "" if a.init is False: - if isinstance(a.default, Factory): + if has_factory: + init_factory_name = _init_factory_pat.format(a.name) if a.convert is not None: lines.append(fmt_setter_with_converter( attr_name, - "attr_dict['{attr_name}'].default.factory()" - .format(attr_name=attr_name))) + init_factory_name + "({0})".format(maybe_self))) conv_name = _init_convert_pat.format(a.name) names_for_globals[conv_name] = a.convert else: lines.append(fmt_setter( attr_name, - "attr_dict['{attr_name}'].default.factory()" - .format(attr_name=attr_name) + init_factory_name + "({0})".format(maybe_self) )) + names_for_globals[init_factory_name] = a.default.factory else: if a.convert is not None: lines.append(fmt_setter_with_converter( @@ -731,7 +741,7 @@ def fmt_setter_with_converter(attr_name, value_var): "attr_dict['{attr_name}'].default" .format(attr_name=attr_name) )) - elif a.default is not NOTHING and not isinstance(a.default, Factory): + elif a.default is not NOTHING and not has_factory: args.append( "{arg_name}=attr_dict['{attr_name}'].default".format( arg_name=arg_name, @@ -743,18 +753,18 @@ def fmt_setter_with_converter(attr_name, value_var): names_for_globals[_init_convert_pat.format(a.name)] = a.convert else: lines.append(fmt_setter(attr_name, arg_name)) - elif a.default is not NOTHING and isinstance(a.default, Factory): + elif has_factory: args.append("{arg_name}=NOTHING".format(arg_name=arg_name)) lines.append("if {arg_name} is not NOTHING:" .format(arg_name=arg_name)) + init_factory_name = _init_factory_pat.format(a.name) if a.convert is not None: lines.append(" " + fmt_setter_with_converter(attr_name, arg_name)) lines.append("else:") lines.append(" " + fmt_setter_with_converter( attr_name, - "attr_dict['{attr_name}'].default.factory()" - .format(attr_name=attr_name) + init_factory_name + "({0})".format(maybe_self) )) names_for_globals[_init_convert_pat.format(a.name)] = a.convert else: @@ -762,9 +772,9 @@ def fmt_setter_with_converter(attr_name, value_var): lines.append("else:") lines.append(" " + fmt_setter( attr_name, - "attr_dict['{attr_name}'].default.factory()" - .format(attr_name=attr_name) + init_factory_name + "({0})".format(maybe_self) )) + names_for_globals[init_factory_name] = a.default.factory else: args.append(arg_name) if a.convert is not None: @@ -808,21 +818,21 @@ class Attribute(object): "convert", "metadata", ) - def __init__(self, name, default, _validator, repr, cmp, hash, init, + def __init__(self, name, _default, _validator, repr, cmp, hash, init, convert=None, metadata=None): # Cache this descriptor here to speed things up later. - __bound_setattr = _obj_setattr.__get__(self, Attribute) - - __bound_setattr("name", name) - __bound_setattr("default", default) - __bound_setattr("validator", _validator) - __bound_setattr("repr", repr) - __bound_setattr("cmp", cmp) - __bound_setattr("hash", hash) - __bound_setattr("init", init) - __bound_setattr("convert", convert) - __bound_setattr("metadata", (metadata_proxy(metadata) if metadata - else _empty_metadata_singleton)) + bound_setattr = _obj_setattr.__get__(self, Attribute) + + bound_setattr("name", name) + bound_setattr("default", _default) + bound_setattr("validator", _validator) + bound_setattr("repr", repr) + bound_setattr("cmp", cmp) + bound_setattr("hash", hash) + bound_setattr("init", init) + bound_setattr("convert", convert) + bound_setattr("metadata", (metadata_proxy(metadata) if metadata + else _empty_metadata_singleton)) def __setattr__(self, name, value): raise FrozenInstanceError() @@ -832,8 +842,10 @@ def from_counting_attr(cls, name, ca): inst_dict = { k: getattr(ca, k) for k - in Attribute.__slots__ + ("_validator",) - if k != "name" and k != "validator" # `validator` is a method + in Attribute.__slots__ + ("_validator", "_default") + if k != "name" and k not in ( + "validator", "default", + ) # exclude methods } return cls(name=name, **inst_dict) @@ -850,16 +862,16 @@ def __setstate__(self, state): """ Play nice with pickle. """ - __bound_setattr = _obj_setattr.__get__(self, Attribute) + bound_setattr = _obj_setattr.__get__(self, Attribute) for name, value in zip(self.__slots__, state): if name != "metadata": - __bound_setattr(name, value) + bound_setattr(name, value) else: - __bound_setattr(name, metadata_proxy(value) if value else - _empty_metadata_singleton) + bound_setattr(name, metadata_proxy(value) if value else + _empty_metadata_singleton) -_a = [Attribute(name=name, default=NOTHING, _validator=None, +_a = [Attribute(name=name, _default=NOTHING, _validator=None, repr=True, cmp=True, hash=(name != "metadata"), init=True) for name in Attribute.__slots__] @@ -877,15 +889,15 @@ class _CountingAttr(object): *Internal* data structure of the attrs library. Running into is most likely the result of a bug like a forgotten `@attr.s` decorator. """ - __slots__ = ("counter", "default", "repr", "cmp", "hash", "init", + __slots__ = ("counter", "_default", "repr", "cmp", "hash", "init", "metadata", "_validator", "convert") __attrs_attrs__ = tuple( - Attribute(name=name, default=NOTHING, _validator=None, + Attribute(name=name, _default=NOTHING, _validator=None, repr=True, cmp=True, hash=True, init=True) for name - in ("counter", "default", "repr", "cmp", "hash", "init",) + in ("counter", "_default", "repr", "cmp", "hash", "init",) ) + ( - Attribute(name="metadata", default=None, _validator=None, + Attribute(name="metadata", _default=None, _validator=None, repr=True, cmp=True, hash=False, init=True), ) cls_counter = 0 @@ -894,7 +906,7 @@ def __init__(self, default, validator, repr, cmp, hash, init, convert, metadata): _CountingAttr.cls_counter += 1 self.counter = _CountingAttr.cls_counter - self.default = default + self._default = default # If validator is a list/tuple, wrap it using helper validator. if validator and isinstance(validator, (list, tuple)): self._validator = and_(*validator) @@ -912,6 +924,8 @@ def validator(self, meth): Decorator that adds *meth* to the list of validators. Returns *meth* unchanged. + + .. versionadded:: 17.1.0 """ if self._validator is None: self._validator = meth @@ -919,19 +933,52 @@ def validator(self, meth): self._validator = and_(self._validator, meth) return meth + def default(self, meth): + """ + Decorator that allows to set the default for an attribute. + + Returns *meth* unchanged. + + :raises DefaultAlreadySetError: If default has been set before. + + .. versionadded:: 17.1.0 + """ + if self._default is not NOTHING: + raise DefaultAlreadySetError() + + self._default = Factory(meth, takes_self=True) + + return meth + _CountingAttr = _add_cmp(_add_repr(_CountingAttr)) -@attributes(slots=True) +@attributes(slots=True, init=False) class Factory(object): """ Stores a factory callable. If passed as the default value to :func:`attr.ib`, the factory is used to generate a new value. + + :param callable factory: A callable that takes either none or exactly one + mandatory positional argument depending on *takes_self*. + :param bool takes_self: Pass the partially initialized instance that is + being initialized as a positional argument. + + .. versionadded:: 17.1.0 *takes_self* """ factory = attr() + takes_self = attr() + + def __init__(self, factory, takes_self=False): + """ + `Factory` is part of the default machinery so if we want a default + value here, we have to implement it ourselves. + """ + self.factory = factory + self.takes_self = takes_self def make_class(name, attrs, bases=(object,), **attributes_arguments): diff --git a/src/attr/exceptions.py b/src/attr/exceptions.py index cdfabda4b..96e9b2d56 100644 --- a/src/attr/exceptions.py +++ b/src/attr/exceptions.py @@ -28,3 +28,12 @@ class NotAnAttrsClassError(ValueError): .. versionadded:: 16.2.0 """ + + +class DefaultAlreadySetError(RuntimeError): + """ + A default has been set using ``attr.ib()`` and is attempted to be reset + using the decorator. + + .. versionadded:: 17.1.0 + """ diff --git a/tests/test_dark_magic.py b/tests/test_dark_magic.py index bb00c607b..8e3e23d75 100644 --- a/tests/test_dark_magic.py +++ b/tests/test_dark_magic.py @@ -109,9 +109,9 @@ def test_fields(self, cls): `attr.fields` works. """ assert ( - Attribute(name="x", default=foo, _validator=None, + Attribute(name="x", _default=foo, _validator=None, repr=True, cmp=True, hash=None, init=True), - Attribute(name="y", default=attr.Factory(list), _validator=None, + Attribute(name="y", _default=attr.Factory(list), _validator=None, repr=True, cmp=True, hash=None, init=True), ) == attr.fields(cls) @@ -158,9 +158,9 @@ def test_programmatic(self, slots, frozen): """ PC = attr.make_class("PC", ["a", "b"], slots=slots, frozen=frozen) assert ( - Attribute(name="a", default=NOTHING, _validator=None, + Attribute(name="a", _default=NOTHING, _validator=None, repr=True, cmp=True, hash=None, init=True), - Attribute(name="b", default=NOTHING, _validator=None, + Attribute(name="b", _default=NOTHING, _validator=None, repr=True, cmp=True, hash=None, init=True), ) == attr.fields(PC) @@ -251,4 +251,23 @@ def test_subclassing_frozen_gives_frozen(self): @pytest.mark.parametrize("cls", [WithMeta, WithMetaSlots]) def test_metaclass_preserved(self, cls): + """ + Metaclass data is preserved. + """ assert Meta == type(cls) + + def test_default_decorator(self): + """ + Default decorator sets the default and the respective method gets + called. + """ + @attr.s + class C(object): + x = attr.ib(default=1) + y = attr.ib() + + @y.default + def compute(self): + return self.x + 1 + + assert C(1, 2) == C() diff --git a/tests/test_make.py b/tests/test_make.py index 66125a775..9a0f99b14 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -28,7 +28,7 @@ make_class, validate, ) -from attr.exceptions import NotAnAttrsClassError +from attr.exceptions import NotAnAttrsClassError, DefaultAlreadySetError from .utils import (gen_attr_names, list_of_attrs, simple_attr, simple_attrs, simple_attrs_without_metadata, simple_classes) @@ -105,6 +105,31 @@ def v2(self, _, __): assert _AndValidator((v, v2,)) == a._validator + def test_default_decorator_already_set(self): + """ + Raise DefaultAlreadySetError if the decorator is used after a default + has been set. + """ + a = attr(default=42) + + with pytest.raises(DefaultAlreadySetError): + @a.default + def f(self): + pass + + def test_default_decorator_sets(self): + """ + Decorator wraps the method in a Factory with pass_self=True and sets + the default. + """ + a = attr() + + @a.default + def f(self): + pass + + assert Factory(f, True) == a._default + def make_tc(): class TransformC(object): @@ -535,6 +560,18 @@ def test_convert_factory_property(self, val, init): assert c.x == val + 1 assert c.y == 2 + def test_factory_takes_self(self): + """ + If takes_self on factories is True, self is passed. + """ + C = make_class("C", {"x": attr(default=Factory( + (lambda self: self), takes_self=True + ))}) + + i = C() + + assert i is i.x + def test_convert_before_validate(self): """ Validation happens after conversion. diff --git a/tests/utils.py b/tests/utils.py index ac6a1d7cb..6cdf8f988 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -35,7 +35,7 @@ def simple_attr(name, default=NOTHING, validator=None, repr=True, Return an attribute with a name and no other bells and whistles. """ return Attribute( - name=name, default=default, _validator=validator, repr=repr, + name=name, _default=default, _validator=validator, repr=repr, cmp=cmp, hash=hash, init=init ) From cc5499d11c333006113d17fce67a12cbb302cb80 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Tue, 16 May 2017 10:27:55 +0200 Subject: [PATCH 4/4] Add docs and some minor polish --- CHANGELOG.rst | 5 +++-- docs/api.rst | 29 +++++++++++++++++++++++++++-- src/attr/validators.py | 30 +++++++++++++++--------------- tests/test_validators.py | 13 +++++++++++-- 4 files changed, 56 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 033570464..277ba606f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -36,8 +36,6 @@ Deprecations: Changes: ^^^^^^^^ -- Added `attr.validators.in_(options)`` that, given the allowed `options`, checks whether the attribute value is in it. This can be used to check constants, enums, mappings, etc. - `#181 `_ - Fix default hashing behavior. Now *hash* mirrors the value of *cmp* and classes are unhashable by default. `#136`_ @@ -54,6 +52,9 @@ Changes: `#128 `_ - ``__attrs_post_init__()`` is now run if validation is disabled. `#130 `_ +- Added `attr.validators.in_(options)`` that, given the allowed `options`, checks whether the attribute value is in it. + This can be used to check constants, enums, mappings, etc. + `#181 `_ - Added ``attr.validators.and_()`` that composes multiple validators into one. `#161 `_ - For convenience, the ``validator`` argument of ``@attr.s`` now can take a ``list`` of validators that are wrapped using ``and_()``. diff --git a/docs/api.rst b/docs/api.rst index 53d01cc11..43e57f1e2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -270,6 +270,33 @@ 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.in_ + + For example: + + .. doctest:: + + >>> import enum + >>> class State(enum.Enum): + ... ON = "on" + ... OFF = "off" + >>> @attr.s + ... class C(object): + ... state = attr.ib(validator=attr.validators.in_(State)) + ... val = attr.ib(validator=attr.validators.in_([1, 2, 3])) + >>> C(State.ON, 1) + C(state=, val=1) + >>> C("on", 1) + Traceback (most recent call last): + ... + ValueError: 'state' must be in (got 'on') + >>> C(State.ON, 4) + Traceback (most recent call last): + ... + ValueError: 'val' must be in [1, 2, 3] (got 4) + +.. autofunction:: attr.validators.provides + .. autofunction:: attr.validators.and_ For convenience, it's also possible to pass a list to :func:`attr.ib`'s validator argument. @@ -279,8 +306,6 @@ Validators x = attr.ib(validator=attr.validators.and_(v1, v2, v3)) x = attr.ib(validator=[v1, v2, v3]) -.. autofunction:: attr.validators.provides - .. autofunction:: attr.validators.optional For example: diff --git a/src/attr/validators.py b/src/attr/validators.py index 85c5fbd46..4474cb2b8 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -48,9 +48,9 @@ def instance_of(type): :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. + :raises TypeError: With a human readable error message, the attribute + (of type :class:`attr.Attribute`), the expected type, and the value it + got. """ return _InstanceOfValidator(type) @@ -87,9 +87,9 @@ def provides(interface): :param zope.interface.Interface interface: The interface to check for. - The :exc:`TypeError` is raised with a human readable error message, the - attribute (of type :class:`attr.Attribute`), the expected interface, and - the value it got. + :raises TypeError: With a human readable error message, the attribute + (of type :class:`attr.Attribute`), the expected interface, and the + value it got. """ return _ProvidesValidator(interface) @@ -135,8 +135,8 @@ class _InValidator(object): def __call__(self, inst, attr, value): if value not in self.options: - raise TypeError( - "'{name}' must be one of {options!r} (got {value!r})" + raise ValueError( + "'{name}' must be in {options!r} (got {value!r})" .format(name=attr.name, options=self.options, value=value) ) @@ -149,16 +149,16 @@ def __repr__(self): def in_(options): """ - A validator that raises a :exc:`TypeError` if the initializer is called - with a value that does not belong in the options provided. The check is + A validator that raises a :exc:`ValueError` if the initializer is called + with a value that does not belong in the options provided. The check is performed using ``value in options``. - :param options: Allowed options - :type options: list, tuple, enum.Enum + :param options: Allowed options. + :type options: list, tuple, :class:`enum.Enum`, ... - The :exc:`TypeError` is raised with a human readable error message, the - attribute (of type :class:`attr.Attribute`), the expected options, and the - value it got. + :raises ValueError: With a human readable error message, the attribute (of + type :class:`attr.Attribute`), the expected options, and the value it + got. .. versionadded:: 17.1.0 """ diff --git a/tests/test_validators.py b/tests/test_validators.py index 68ebf0a3d..2b8f4ed6f 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -221,20 +221,29 @@ class TestIn_(object): Tests for `in_`. """ def test_success_with_value(self): + """ + If the value is in our options, nothing happens. + """ v = in_([1, 2, 3]) a = simple_attr("test") v(1, a, 3) def test_fail(self): + """ + Raise ValueError if the value is outside our options. + """ v = in_([1, 2, 3]) a = simple_attr("test") - with pytest.raises(TypeError) as e: + with pytest.raises(ValueError) as e: v(None, a, None) assert ( - "'test' must be one of [1, 2, 3] (got None)", + "'test' must be in [1, 2, 3] (got None)", ) == e.value.args def test_repr(self): + """ + Returned validator has a useful `__repr__`. + """ v = in_([3, 4, 5]) assert( ("")