Skip to content

Commit

Permalink
Add auto_attribs (#277)
Browse files Browse the repository at this point in the history
Allow for attr.ib-less attribute definitions using class variable annotations.
  • Loading branch information
hynek committed Nov 8, 2017
1 parent 2a50c4b commit 88aa1c8
Show file tree
Hide file tree
Showing 9 changed files with 232 additions and 26 deletions.
3 changes: 3 additions & 0 deletions changelog.d/262.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added new option ``auto_attribs`` to ``@attr.s`` that allows to collect annotated fields without setting them to ``attr.ib()``.
Setting a field to an ``attr.ib()`` is still possible to supply options like validators.
Setting it to any other value is treated like it was passed as ``attr.ib(default=value)`` -- passing an instance of ``attr.Factory`` also works as expected.
3 changes: 3 additions & 0 deletions changelog.d/277.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added new option ``auto_attribs`` to ``@attr.s`` that allows to collect annotated fields without setting them to ``attr.ib()``.
Setting a field to an ``attr.ib()`` is still possible to supply options like validators.
Setting it to any other value is treated like it was passed as ``attr.ib(default=value)`` -- passing an instance of ``attr.Factory`` also works as expected.
8 changes: 8 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ Core
.. autoexception:: attr.exceptions.AttrsAttributeNotFoundError
.. autoexception:: attr.exceptions.NotAnAttrsClassError
.. autoexception:: attr.exceptions.DefaultAlreadySetError
.. autoexception:: attr.exceptions.UnannotatedAttributeError

For example::

@attr.s(auto_attribs=True)
class C:
x: int
y = attr.ib()


Influencing Initialization
Expand Down
51 changes: 51 additions & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,57 @@ The metadata dictionary follows the normal dictionary rules: keys need to be has
If you're the author of a third-party library with ``attrs`` integration, please see :ref:`Extending Metadata <extending_metadata>`.


Types
-----

``attrs`` also allows you to associate a type with an attribute using either the *type* argument to :func:`attr.ib` or -- as of Python 3.6 -- using `PEP 526 <https://www.python.org/dev/peps/pep-0526/>`_-annotations:


.. doctest::

>>> @attr.s
... class C:
... x = attr.ib(type=int)
... y: int = attr.ib()
>>> attr.fields(C).x.type
<class 'int'>
>>> attr.fields(C).y.type
<class 'int'>

If you don't mind annotating *all* attributes, you can even drop the :func:`attr.ib` and assign default values instead:

.. doctest::

>>> import typing
>>> @attr.s(auto_attribs=True)
... class AutoC:
... cls_var: typing.ClassVar[int] = 5 # this one is ignored
... l: typing.List[int] = attr.Factory(list)
... x: int = 1
... foo: str = attr.ib(
... default="every attrib needs a type if auto_attribs=True"
... )
... bar: typing.Any = None
>>> attr.fields(AutoC).l.type
typing.List[int]
>>> attr.fields(AutoC).x.type
<class 'int'>
>>> attr.fields(AutoC).foo.type
<class 'str'>
>>> attr.fields(AutoC).bar.type
typing.Any
>>> AutoC()
AutoC(l=[], x=1, foo='every attrib needs a type if auto_attribs=True', bar=None)
>>> AutoC.cls_var
5


.. warning::

``attrs`` itself doesn't have any features that work on top of type metadata *yet*.
However it's useful for writing your own validators or serialization frameworks.


.. _slots:

Slots
Expand Down
3 changes: 3 additions & 0 deletions src/attr/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import absolute_import, division, print_function

from functools import partial

from ._funcs import (
asdict,
assoc,
Expand Down Expand Up @@ -43,6 +45,7 @@

s = attributes = attrs
ib = attr = attrib
dataclass = partial(attrs, auto_attribs=True) # happy Easter ;)

__all__ = [
"Attribute",
Expand Down
99 changes: 81 additions & 18 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
DefaultAlreadySetError,
FrozenInstanceError,
NotAnAttrsClassError,
UnannotatedAttributeError,
)


Expand Down Expand Up @@ -190,32 +191,76 @@ class MyClassAttributes(tuple):
])


def _transform_attrs(cls, these):
def _is_class_var(annot):
"""
Check whether *annot* is a typing.ClassVar.
The implementation is gross but importing `typing` is slow and there are
discussions to remove it from the stdlib alltogether.
"""
return str(annot).startswith("typing.ClassVar")


def _transform_attrs(cls, these, auto_attribs):
"""
Transform all `_CountingAttr`s on a class into `Attribute`s.
If *these* is passed, use that and don't look for them on the class.
Return an `_Attributes`.
"""
if these is None:
ca_list = [(name, attr)
for name, attr
in cls.__dict__.items()
if isinstance(attr, _CountingAttr)]
cd = cls.__dict__
anns = getattr(cls, "__annotations__", {})

if these is None and auto_attribs is False:
ca_list = sorted((
(name, attr)
for name, attr
in cd.items()
if isinstance(attr, _CountingAttr)
), key=lambda e: e[1].counter)
elif these is None and auto_attribs is True:
ca_names = {
name
for name, attr
in cd.items()
if isinstance(attr, _CountingAttr)
}
ca_list = []
annot_names = set()
for attr_name, type in anns.items():
if _is_class_var(type):
continue
annot_names.add(attr_name)
a = cd.get(attr_name, NOTHING)
if not isinstance(a, _CountingAttr):
if a is NOTHING:
a = attrib()
else:
a = attrib(default=a)
ca_list.append((attr_name, a))

unannotated = ca_names - annot_names
if len(unannotated) > 0:
raise UnannotatedAttributeError(
"The following `attr.ib`s lack a type annotation: " +
", ".join(sorted(
unannotated,
key=lambda n: cd.get(n).counter
)) + "."
)
else:
ca_list = [(name, ca)
for name, ca
in iteritems(these)]
ca_list = sorted(ca_list, key=lambda e: e[1].counter)

ann = getattr(cls, "__annotations__", {})
ca_list = sorted((
(name, ca)
for name, ca
in iteritems(these)
), key=lambda e: e[1].counter)

non_super_attrs = [
Attribute.from_counting_attr(
name=attr_name,
ca=ca,
type=ann.get(attr_name),
type=anns.get(attr_name),
)
for attr_name, ca
in ca_list
Expand Down Expand Up @@ -250,7 +295,7 @@ def _transform_attrs(cls, these):
Attribute.from_counting_attr(
name=attr_name,
ca=ca,
type=ann.get(attr_name)
type=anns.get(attr_name)
)
for attr_name, ca
in ca_list
Expand Down Expand Up @@ -296,8 +341,8 @@ class _ClassBuilder(object):
"_frozen", "_has_post_init",
)

def __init__(self, cls, these, slots, frozen):
attrs, super_attrs = _transform_attrs(cls, these)
def __init__(self, cls, these, slots, frozen, auto_attribs):
attrs, super_attrs = _transform_attrs(cls, these, auto_attribs)

self._cls = cls
self._cls_dict = dict(cls.__dict__) if slots else {}
Expand Down Expand Up @@ -460,7 +505,7 @@ def add_cmp(self):

def attrs(maybe_cls=None, these=None, repr_ns=None,
repr=True, cmp=True, hash=None, init=True,
slots=False, frozen=False, str=False):
slots=False, frozen=False, str=False, auto_attribs=False):
r"""
A class decorator that adds `dunder
<https://wiki.python.org/moin/DunderAlias>`_\ -methods according to the
Expand Down Expand Up @@ -535,19 +580,37 @@ def attrs(maybe_cls=None, these=None, repr_ns=None,
``object.__setattr__(self, "attribute_name", value)``.
.. _slots: https://docs.python.org/3/reference/datamodel.html#slots
:param bool auto_attribs: If True, collect `PEP 526`_-annotated attributes
(Python 3.6 and later only) from the class body.
In this case, you **must** annotate every field. If ``attrs``
encounters a field that is set to an :func:`attr.ib` but lacks a type
annotation, an :exc:`attr.exceptions.UnannotatedAttributeError` is
raised. Use ``field_name: typing.Any = attr.ib(...)`` if you don't
want to set a type.
If you assign a value to those attributes (e.g. ``x: int = 42``), that
value becomes the default value like if it were passed using
``attr.ib(default=42)``. Passing an instance of :class:`Factory` also
works as expected.
Attributes annotated as :class:`typing.ClassVar` are **ignored**.
.. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/
.. versionadded:: 16.0.0 *slots*
.. versionadded:: 16.1.0 *frozen*
.. versionadded:: 16.3.0 *str*, and support for ``__attrs_post_init__``.
.. versionchanged::
17.1.0 *hash* supports ``None`` as value which is also the default
now.
.. versionadded:: 17.3.0 *auto_attribs*
"""
def wrap(cls):
if getattr(cls, "__class__", None) is None:
raise TypeError("attrs only works with new-style classes.")

builder = _ClassBuilder(cls, these, slots, frozen)
builder = _ClassBuilder(cls, these, slots, frozen, auto_attribs)

if repr is True:
builder.add_repr(repr_ns)
Expand Down
9 changes: 9 additions & 0 deletions src/attr/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,12 @@ class DefaultAlreadySetError(RuntimeError):
.. versionadded:: 17.1.0
"""


class UnannotatedAttributeError(RuntimeError):
"""
A class with ``auto_attribs=True`` has an ``attr.ib()`` without a type
annotation.
.. versionadded:: 17.3.0
"""
66 changes: 66 additions & 0 deletions tests/test_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
Python 3.6+ only.
"""

import types
import typing

import pytest

import attr

from attr.exceptions import UnannotatedAttributeError


class TestAnnotations:
"""
Expand Down Expand Up @@ -65,3 +68,66 @@ class C:
y: int

assert 1 == len(attr.fields(C))

@pytest.mark.parametrize("slots", [True, False])
def test_auto_attribs(self, slots):
"""
If *auto_attribs* is True, bare annotations are collected too.
Defaults work and class variables are ignored.
"""
@attr.s(auto_attribs=True, slots=slots)
class C:
cls_var: typing.ClassVar[int] = 23
a: int
x: typing.List[int] = attr.Factory(list)
y: int = 2
z: int = attr.ib(default=3)
foo: typing.Any = None

i = C(42)
assert "C(a=42, x=[], y=2, z=3, foo=None)" == repr(i)

attr_names = set(a.name for a in C.__attrs_attrs__)
assert "a" in attr_names # just double check that the set works
assert "cls_var" not in attr_names

assert int == attr.fields(C).a.type

assert attr.Factory(list) == attr.fields(C).x.default
assert typing.List[int] == attr.fields(C).x.type

assert int == attr.fields(C).y.type
assert 2 == attr.fields(C).y.default

assert int == attr.fields(C).z.type

assert typing.Any == attr.fields(C).foo.type

# Class body is clean.
if slots is False:
with pytest.raises(AttributeError):
C.y

assert 2 == i.y
else:
assert isinstance(C.y, types.MemberDescriptorType)

i.y = 23
assert 23 == i.y

@pytest.mark.parametrize("slots", [True, False])
def test_auto_attribs_unannotated(self, slots):
"""
Unannotated `attr.ib`s raise an error.
"""
with pytest.raises(UnannotatedAttributeError) as e:
@attr.s(slots=slots, auto_attribs=True)
class C:
v = attr.ib()
x: int
y = attr.ib()
z: str

assert (
"The following `attr.ib`s lack a type annotation: v, y.",
) == e.value.args
Loading

0 comments on commit 88aa1c8

Please sign in to comment.