Skip to content

Commit

Permalink
Add takes_self to Factory and @_CountingAttr.default
Browse files Browse the repository at this point in the history
Fixes #165
  • Loading branch information
hynek committed May 13, 2017
1 parent fbe0bd5 commit acd3afb
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 49 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ Changes:
- 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>`_
- ``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 <https://github.com/python-attrs/attrs/issues/105>`_
`#173 <https://github.com/python-attrs/attrs/pull/173>`_
Expand All @@ -70,6 +76,7 @@ Changes:
`#155 <https://github.com/python-attrs/attrs/pull/155>`_

.. _`#136`: https://github.com/python-attrs/attrs/issues/136
.. _`#165`: https://github.com/python-attrs/attrs/issues/165


----
Expand Down
18 changes: 13 additions & 5 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://as.ynchrono.us/2014/12/asynchronous-object-initialization.html>`_.

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:

Expand Down
122 changes: 84 additions & 38 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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))
Expand All @@ -753,17 +762,17 @@ 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:
lines.append(" " + fmt_setter(attr_name, arg_name))
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)
Expand Down Expand Up @@ -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()
Expand All @@ -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)

Expand All @@ -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__]

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -912,26 +923,61 @@ 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
else:
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):
Expand Down
9 changes: 9 additions & 0 deletions src/attr/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
27 changes: 23 additions & 4 deletions tests/test_dark_magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Loading

0 comments on commit acd3afb

Please sign in to comment.