Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add auto_attribs #277

Merged
merged 17 commits into from Nov 8, 2017
3 changes: 3 additions & 0 deletions changelog.d/262.change.rst
@@ -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
@@ -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
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
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
@@ -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
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
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
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