diff --git a/changelog.d/262.change.rst b/changelog.d/262.change.rst new file mode 100644 index 000000000..160da8b2c --- /dev/null +++ b/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. diff --git a/changelog.d/277.change.rst b/changelog.d/277.change.rst new file mode 100644 index 000000000..160da8b2c --- /dev/null +++ b/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. diff --git a/docs/api.rst b/docs/api.rst index 6144ab9d1..e2acb7400 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -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 diff --git a/docs/examples.rst b/docs/examples.rst index 2244485dc..88bc13354 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -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 `. +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 `_-annotations: + + +.. doctest:: + + >>> @attr.s + ... class C: + ... x = attr.ib(type=int) + ... y: int = attr.ib() + >>> attr.fields(C).x.type + + >>> attr.fields(C).y.type + + +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 + + >>> attr.fields(AutoC).foo.type + + >>> 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 diff --git a/src/attr/__init__.py b/src/attr/__init__.py index 35b1cf66a..351b32cb4 100644 --- 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 index 365e9de1f..6aa23836c 100644 --- 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 `_\ -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 index 96e9b2d56..f949f3c9c 100644 --- 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 + """ diff --git a/tests/test_annotations.py b/tests/test_annotations.py index e91a2ffcf..ee3094498 100644 --- 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 index 55a65717d..0c90532d9 100644 --- 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()