From acd3afb6fde9be4c26fd4018e73cc102228e3496 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sat, 13 May 2017 12:45:09 +0200 Subject: [PATCH] Add takes_self to Factory and @_CountingAttr.default Fixes #165 --- CHANGELOG.rst | 7 +++ docs/api.rst | 18 ++++-- docs/examples.rst | 15 +++++ src/attr/_make.py | 122 +++++++++++++++++++++++++++------------ src/attr/exceptions.py | 9 +++ tests/test_dark_magic.py | 27 +++++++-- tests/test_make.py | 39 ++++++++++++- tests/utils.py | 2 +- 8 files changed, 190 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3cb421600..4bf43254f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -61,6 +61,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 `_ @@ -70,6 +76,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..d6b26aba6 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -7,7 +7,11 @@ 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. @@ -701,20 +705,25 @@ 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: 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))) + "attr_dict['{attr_name}'].default.factory({self})" + .format(attr_name=attr_name, self=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) + "attr_dict['{attr_name}'].default.factory({self})" + .format(attr_name=attr_name, self=maybe_self) )) else: if a.convert is not None: @@ -731,7 +740,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,7 +752,7 @@ 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)) @@ -753,8 +762,8 @@ def fmt_setter_with_converter(attr_name, value_var): lines.append("else:") lines.append(" " + fmt_setter_with_converter( attr_name, - "attr_dict['{attr_name}'].default.factory()" - .format(attr_name=attr_name) + "attr_dict['{attr_name}'].default.factory({self})" + .format(attr_name=attr_name, self=maybe_self) )) names_for_globals[_init_convert_pat.format(a.name)] = a.convert else: @@ -762,8 +771,8 @@ 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) + "attr_dict['{attr_name}'].default.factory({self})" + .format(attr_name=attr_name, self=maybe_self) )) else: args.append(arg_name) @@ -808,21 +817,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 +841,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 +861,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 +888,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 +905,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 +923,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 +932,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 )