Repository: python-attrs/attrs

Problem statement:
Add option to collect annotated fields
Since I’m sick of hearing that “[PEP 557](https://www.python.org/dev/peps/pep-0557/) is like attrs, but using variable annotations for field declarations”, I’d like to have an option to collect annotated fields that have no attr.ib definition.

ie.

```python
@attr.s(collect_bare=True)
class C:
    x: int
```

should be equivalent to:

```python
@attr.s
class C:
    x: int = attr.ib()
```

I’m open to better/shorter names.

Volunteers?  @chadrik maybe? :)


Hints:
Hm I'd actually be ok with doing this by default?
A turn off switch would be nice, just default collect_bare to true? Is this a compatibility issue?
I’d love to but that is technically backward incompatible.
Durn it.
We could run it thru a deprecation cycle I guess?
`s/collect_bare/automatic_attributes/`

Maybe just `auto_attribs`?
So I gave this a lackluster shot and I’ve run into a problem:

```python
    def test_auto_attribs(self):
        """
        If *auto_attribs* is True, bare annotations are collected too.
        """
        @attr.s(auto_attribs=True)
        class C:
            x: typing.List[int]
            b = attr.ib()
            y: int

        assert "C(x=1, b=2, y=3)" == repr(C(1, 2, 3))
```

`x` and `y` are defined in `__annotations__` while `b` is defined in `__dict__`.  It there a way to determine their order?

***

To me it looks like all we can do is to allow either 100% annotations or 100% `attr.ib`s which only makes sense once we have something like this:

```python
@attr.s(auto_attribs=True, check_types=True)
class Point:
    x: float
    y: float
```

or am I missing something?  (since we can’t attach any more meta data to the fields, this has to be something that happens in `@attr.s`)

This kind of smells like a `record = attr.s(auto_attribs=True, check_types=True, frozen=True, slots=True)` which is certainly useful

***

Or am I missing something?.
Ah OK: we just have to enforce that all fields are annotated.  That’s not that bad.

Patch:
diff --git a/src/attr/__init__.py b/src/attr/__init__.py
--- a/src/attr/__init__.py
+++ b/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,
@@ -43,6 +45,7 @@
 
 s = attributes = attrs
 ib = attr = attrib
+dataclass = partial(attrs, auto_attribs=True)  # happy Easter ;)
 
 __all__ = [
     "Attribute",
diff --git a/src/attr/_make.py b/src/attr/_make.py
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -18,6 +18,7 @@
     DefaultAlreadySetError,
     FrozenInstanceError,
     NotAnAttrsClassError,
+    UnannotatedAttributeError,
 )
 
 
@@ -190,7 +191,17 @@ 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.
 
@@ -198,24 +209,58 @@ def _transform_attrs(cls, these):
 
     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
@@ -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
@@ -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 {}
@@ -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
@@ -535,6 +580,23 @@ 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*
@@ -542,12 +604,13 @@ def attrs(maybe_cls=None, these=None, repr_ns=None,
     ..  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)
diff --git a/src/attr/exceptions.py b/src/attr/exceptions.py
--- a/src/attr/exceptions.py
+++ b/src/attr/exceptions.py
@@ -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
+    """


Test patch:
diff --git a/tests/test_annotations.py b/tests/test_annotations.py
--- a/tests/test_annotations.py
+++ b/tests/test_annotations.py
@@ -4,12 +4,15 @@
 Python 3.6+ only.
 """
 
+import types
 import typing
 
 import pytest
 
 import attr
 
+from attr.exceptions import UnannotatedAttributeError
+
 
 class TestAnnotations:
     """
@@ -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
diff --git a/tests/test_make.py b/tests/test_make.py
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -144,7 +144,7 @@ def test_no_modifications(self):
         Doesn't attach __attrs_attrs__ to the class anymore.
         """
         C = make_tc()
-        _transform_attrs(C, None)
+        _transform_attrs(C, None, False)
 
         assert None is getattr(C, "__attrs_attrs__", None)
 
@@ -153,7 +153,7 @@ def test_normal(self):
         Transforms every `_CountingAttr` and leaves others (a) be.
         """
         C = make_tc()
-        attrs, _, = _transform_attrs(C, None)
+        attrs, _, = _transform_attrs(C, None, False)
 
         assert ["z", "y", "x"] == [a.name for a in attrs]
 
@@ -165,14 +165,14 @@ def test_empty(self):
         class C(object):
             pass
 
-        assert _Attributes(((), [])) == _transform_attrs(C, None)
+        assert _Attributes(((), [])) == _transform_attrs(C, None, False)
 
     def test_transforms_to_attribute(self):
         """
         All `_CountingAttr`s are transformed into `Attribute`s.
         """
         C = make_tc()
-        attrs, super_attrs = _transform_attrs(C, None)
+        attrs, super_attrs = _transform_attrs(C, None, False)
 
         assert [] == super_attrs
         assert 3 == len(attrs)
@@ -188,7 +188,7 @@ class C(object):
             y = attr.ib()
 
         with pytest.raises(ValueError) as e:
-            _transform_attrs(C, None)
+            _transform_attrs(C, None, False)
         assert (
             "No mandatory attributes allowed after an attribute with a "
             "default value or factory.  Attribute in question: Attribute"
@@ -207,7 +207,7 @@ class Base(object):
         class C(Base):
             y = attr.ib()
 
-        attrs, super_attrs = _transform_attrs(C, {"x": attr.ib()})
+        attrs, super_attrs = _transform_attrs(C, {"x": attr.ib()}, False)
 
         assert [] == super_attrs
         assert (
@@ -817,7 +817,7 @@ def test_repr(self):
         class C(object):
             pass
 
-        b = _ClassBuilder(C, None, True, True)
+        b = _ClassBuilder(C, None, True, True, False)
 
         assert "<_ClassBuilder(cls=C)>" == repr(b)
 
@@ -828,7 +828,7 @@ def test_returns_self(self):
         class C(object):
             x = attr.ib()
 
-        b = _ClassBuilder(C, None, True, True)
+        b = _ClassBuilder(C, None, True, True, False)
 
         cls = b.add_cmp().add_hash().add_init().add_repr("ns").add_str() \
             .build_class()