From 2393256e85fcaa3dbba811576c4495480b1fc4f6 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 27 Oct 2017 10:00:22 +0200 Subject: [PATCH 01/13] Add auto_attribs --- changelog.d/262.change.rst | 1 + changelog.d/277.change.rst | 1 + docs/examples.rst | 45 +++++++++++++++++++++++++++++++ src/attr/_make.py | 55 +++++++++++++++++++++++++------------- tests/test_annotations.py | 15 +++++++++++ tests/test_make.py | 16 +++++------ 6 files changed, 107 insertions(+), 26 deletions(-) create mode 100644 changelog.d/262.change.rst create mode 100644 changelog.d/277.change.rst diff --git a/changelog.d/262.change.rst b/changelog.d/262.change.rst new file mode 100644 index 000000000..ed08390c3 --- /dev/null +++ b/changelog.d/262.change.rst @@ -0,0 +1 @@ +Added new option ``auto_attribs`` to ``@attr.s`` that allows to collect annotated fields without setting them to ``attr.ib()``. diff --git a/changelog.d/277.change.rst b/changelog.d/277.change.rst new file mode 100644 index 000000000..ed08390c3 --- /dev/null +++ b/changelog.d/277.change.rst @@ -0,0 +1 @@ +Added new option ``auto_attribs`` to ``@attr.s`` that allows to collect annotated fields without setting them to ``attr.ib()``. diff --git a/docs/examples.rst b/docs/examples.rst index 885b368fc..139770517 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -461,6 +461,51 @@ 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`: + +.. doctest:: + + >>> import typing + >>> @attr.s(auto_attribs=True) + ... class AutoC: + ... x: int + ... y: int + ... foo: typing.Any = attr.ib( + ... default="every attrib needs a type if auto_attribs=True" + ... ) + >>> attr.fields(AutoC).x.type + + >>> attr.fields(AutoC).y.type + + >>> attr.fields(AutoC).foo.type + typing.Any + >>> AutoC(1, 2) + AutoC(x=1, y=2, foo='every attrib needs a type if auto_attribs=True') + + +.. 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/_make.py b/src/attr/_make.py index eaac0843a..f3ef850e5 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -189,7 +189,7 @@ class MyClassAttributes(tuple): ]) -def _transform_attrs(cls, these): +def _transform_attrs(cls, these, auto_attribs): """ Transform all `_CountingAttr`s on a class into `Attribute`s. @@ -197,24 +197,36 @@ 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 auto_attribs is True: + ca_list = [ + (attr_name, cd.get(attr_name, None) or attrib()) + for attr_name + in anns + ] 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__", {}) + if these is None: + ca_list = [ + (name, attr) + for name, attr + in cd.items() + if isinstance(attr, _CountingAttr) + ] + else: + ca_list = [ + (name, ca) + for name, ca + in iteritems(these) + ] + ca_list.sort(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 @@ -249,7 +261,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 @@ -295,8 +307,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 {} @@ -459,7 +471,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 @@ -534,6 +546,12 @@ 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 + from the class body. In this case, you can **not** use + :func:`attr.ib` to define unannotated attributes (they are silently + ignored). Use ``field_name: typing.Any = attr.ib(...)`` instead. + + .. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/ .. versionadded:: 16.0.0 *slots* .. versionadded:: 16.1.0 *frozen* @@ -541,12 +559,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/tests/test_annotations.py b/tests/test_annotations.py index e91a2ffcf..ec5fc8c04 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -65,3 +65,18 @@ class C: y: int assert 1 == len(attr.fields(C)) + + 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] + y: int + z: typing.Any = attr.ib(default=3) + + assert "C(x=1, y=2, z=3)" == repr(C(1, 2)) + assert typing.List[int] == attr.fields(C).x.type + assert int == attr.fields(C).y.type + assert typing.Any == attr.fields(C).z.type diff --git a/tests/test_make.py b/tests/test_make.py index 5eb6f13a4..2e88722a0 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -143,7 +143,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) @@ -152,7 +152,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] @@ -164,14 +164,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) @@ -187,7 +187,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" @@ -206,7 +206,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 ( @@ -803,7 +803,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) @@ -814,7 +814,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() From 0ea9b00635cdac543d4e1bfdf86099b7f9db3af1 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sat, 4 Nov 2017 09:52:00 +0100 Subject: [PATCH 02/13] Implement defaults --- docs/examples.rst | 13 ++++++++----- src/attr/_make.py | 14 +++++++++----- tests/test_annotations.py | 21 ++++++++++++++++----- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 139770517..3da7fe3d0 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -478,26 +478,29 @@ Types >>> attr.fields(C).y.type -If you don't mind annotating all attributes, you can even drop the :func:`attr.ib`: +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: - ... x: int - ... y: int - ... foo: typing.Any = attr.ib( + ... x: int = attr.Factory(list) + ... y: int = 2 + ... foo: str = attr.ib( ... default="every attrib needs a type if auto_attribs=True" ... ) + ... bar: typing.Any = None >>> attr.fields(AutoC).x.type >>> attr.fields(AutoC).y.type >>> attr.fields(AutoC).foo.type + + >>> attr.fields(AutoC).bar.type typing.Any >>> AutoC(1, 2) - AutoC(x=1, y=2, foo='every attrib needs a type if auto_attribs=True') + AutoC(x=1, y=2, foo='every attrib needs a type if auto_attribs=True', bar=None) .. warning:: diff --git a/src/attr/_make.py b/src/attr/_make.py index f3ef850e5..803ef734c 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -201,11 +201,15 @@ def _transform_attrs(cls, these, auto_attribs): anns = getattr(cls, "__annotations__", {}) if auto_attribs is True: - ca_list = [ - (attr_name, cd.get(attr_name, None) or attrib()) - for attr_name - in anns - ] + ca_list = [] + for attr_name in anns: + 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)) else: if these is None: ca_list = [ diff --git a/tests/test_annotations.py b/tests/test_annotations.py index ec5fc8c04..177449bdb 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -72,11 +72,22 @@ def test_auto_attribs(self): """ @attr.s(auto_attribs=True) class C: - x: typing.List[int] - y: int - z: typing.Any = attr.ib(default=3) + a: int + x: typing.List[int] = attr.Factory(list) + y: int = 2 + z: int = attr.ib(default=3) + foo: typing.Any = None + + assert "C(a=42, x=[], y=2, z=3, foo=None)" == repr(C(42)) + + assert int == attr.fields(C).a.type - assert "C(x=1, y=2, z=3)" == repr(C(1, 2)) + 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 typing.Any == attr.fields(C).z.type + assert 2 == attr.fields(C).y.default + + assert int == attr.fields(C).z.type + + assert typing.Any == attr.fields(C).foo.type From 3bd126ff53b0ba1b3ae4a022f6884119af53b261 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sat, 4 Nov 2017 10:07:57 +0100 Subject: [PATCH 03/13] Update API docs --- src/attr/_make.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 803ef734c..fb4146736 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -551,9 +551,14 @@ def attrs(maybe_cls=None, these=None, repr_ns=None, .. _slots: https://docs.python.org/3/reference/datamodel.html#slots :param bool auto_attribs: If True, collect `PEP 526`_-annotated attributes - from the class body. In this case, you can **not** use - :func:`attr.ib` to define unannotated attributes (they are silently - ignored). Use ``field_name: typing.Any = attr.ib(...)`` instead. + from the class body. In this case, you can **not** use :func:`attr.ib` + only to define unannotated attributes (they are silently ignored). Use + ``field_name: typing.Any = attr.ib(...)`` instead. + + 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 a :class:`Factory` also works as + expected. .. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/ From 01aff642a83b45e28714cad1071fee492a767c9b Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sat, 4 Nov 2017 10:13:41 +0100 Subject: [PATCH 04/13] Update changelog --- changelog.d/262.change.rst | 2 ++ changelog.d/277.change.rst | 2 ++ 2 files changed, 4 insertions(+) diff --git a/changelog.d/262.change.rst b/changelog.d/262.change.rst index ed08390c3..05d10bda4 100644 --- a/changelog.d/262.change.rst +++ b/changelog.d/262.change.rst @@ -1 +1,3 @@ Added new option ``auto_attribs`` to ``@attr.s`` that allows to collect annotated fields without setting them to ``attr.ib()``. +Setting 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 ``attr.Factory`` also works as expected. diff --git a/changelog.d/277.change.rst b/changelog.d/277.change.rst index ed08390c3..05d10bda4 100644 --- a/changelog.d/277.change.rst +++ b/changelog.d/277.change.rst @@ -1 +1,3 @@ Added new option ``auto_attribs`` to ``@attr.s`` that allows to collect annotated fields without setting them to ``attr.ib()``. +Setting 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 ``attr.Factory`` also works as expected. From 3a08044d1dd64e587f5dc0eb50412c24df7df18d Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sat, 4 Nov 2017 10:23:41 +0100 Subject: [PATCH 05/13] Grammar --- changelog.d/262.change.rst | 2 +- changelog.d/277.change.rst | 2 +- src/attr/_make.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog.d/262.change.rst b/changelog.d/262.change.rst index 05d10bda4..e71a7f9ef 100644 --- a/changelog.d/262.change.rst +++ b/changelog.d/262.change.rst @@ -1,3 +1,3 @@ Added new option ``auto_attribs`` to ``@attr.s`` that allows to collect annotated fields without setting them to ``attr.ib()``. Setting 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 ``attr.Factory`` also works as expected. +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 index 05d10bda4..e71a7f9ef 100644 --- a/changelog.d/277.change.rst +++ b/changelog.d/277.change.rst @@ -1,3 +1,3 @@ Added new option ``auto_attribs`` to ``@attr.s`` that allows to collect annotated fields without setting them to ``attr.ib()``. Setting 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 ``attr.Factory`` also works as expected. +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/src/attr/_make.py b/src/attr/_make.py index fb4146736..8933104ff 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -557,8 +557,8 @@ def attrs(maybe_cls=None, these=None, repr_ns=None, 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 a :class:`Factory` also works as - expected. + ``attr.ib(default=42)``. Passing an instance of :class:`Factory` also + works as expected. .. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/ From 2d22e730da5bc05773246b4f4e7c908e7fdfe060 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sat, 4 Nov 2017 11:46:11 +0100 Subject: [PATCH 06/13] Ignore class variables --- src/attr/_make.py | 26 ++++++++++++++++++++++---- tests/test_annotations.py | 6 ++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 8933104ff..babb9f6d9 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -189,6 +189,18 @@ class MyClassAttributes(tuple): ]) +try: + from typing import ClassVar + + # You cannot use ClassVar together with isinstance. + _CLASS_VAR_CLS = ClassVar.__class__ +except ImportError: + class FakeClassVar(object): + pass + + _CLASS_VAR_CLS = FakeClassVar + + def _transform_attrs(cls, these, auto_attribs): """ Transform all `_CountingAttr`s on a class into `Attribute`s. @@ -202,7 +214,9 @@ def _transform_attrs(cls, these, auto_attribs): if auto_attribs is True: ca_list = [] - for attr_name in anns: + for attr_name, type in anns.items(): + if isinstance(type, _CLASS_VAR_CLS): + continue a = cd.get(attr_name, NOTHING) if not isinstance(a, _CountingAttr): if a is NOTHING: @@ -551,15 +565,19 @@ def attrs(maybe_cls=None, these=None, repr_ns=None, .. _slots: https://docs.python.org/3/reference/datamodel.html#slots :param bool auto_attribs: If True, collect `PEP 526`_-annotated attributes - from the class body. In this case, you can **not** use :func:`attr.ib` - only to define unannotated attributes (they are silently ignored). Use - ``field_name: typing.Any = attr.ib(...)`` instead. + from the class body. In this case, you **must** annotate every field. + Fields that are assigned an :func:`attr.ib` but have no type + annotation are silently ignored. 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* diff --git a/tests/test_annotations.py b/tests/test_annotations.py index 177449bdb..b0d716842 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -69,9 +69,11 @@ class C: def test_auto_attribs(self): """ If *auto_attribs* is True, bare annotations are collected too. + Defaults work and class variables are ignored. """ @attr.s(auto_attribs=True) class C: + cls_var: typing.ClassVar[int] = 23 a: int x: typing.List[int] = attr.Factory(list) y: int = 2 @@ -80,6 +82,10 @@ class C: assert "C(a=42, x=[], y=2, z=3, foo=None)" == repr(C(42)) + 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 From 6597020d0e96caea4175c11ed256593975a18d0f Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sat, 4 Nov 2017 11:55:23 +0100 Subject: [PATCH 07/13] Document ClassVar behavior in examples --- docs/examples.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/examples.rst b/docs/examples.rst index 3da7fe3d0..5adc6bdd1 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -485,6 +485,7 @@ If you don't mind annotating all attributes, you can even drop the :func:`attr.i >>> import typing >>> @attr.s(auto_attribs=True) ... class AutoC: + ... cls_var: typing.ClassVar[int] = 5 # this one is ignored ... x: int = attr.Factory(list) ... y: int = 2 ... foo: str = attr.ib( @@ -501,6 +502,8 @@ If you don't mind annotating all attributes, you can even drop the :func:`attr.i typing.Any >>> AutoC(1, 2) AutoC(x=1, y=2, foo='every attrib needs a type if auto_attribs=True', bar=None) + >>> AutoC.cls_var + 5 .. warning:: From 38f4f27ecadbda1368216fc38c7ec87a84d18125 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sat, 4 Nov 2017 12:08:25 +0100 Subject: [PATCH 08/13] Add tests to ensure the class body is clean --- tests/test_annotations.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/test_annotations.py b/tests/test_annotations.py index b0d716842..f8339ec34 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -4,6 +4,7 @@ Python 3.6+ only. """ +import types import typing import pytest @@ -66,12 +67,13 @@ class C: assert 1 == len(attr.fields(C)) - def test_auto_attribs(self): + @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) + @attr.s(auto_attribs=True, slots=slots) class C: cls_var: typing.ClassVar[int] = 23 a: int @@ -80,7 +82,8 @@ class C: z: int = attr.ib(default=3) foo: typing.Any = None - assert "C(a=42, x=[], y=2, z=3, foo=None)" == repr(C(42)) + 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 @@ -97,3 +100,15 @@ class C: 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 From 8550b1eb749df86c5f3bc2d7912400f5c06f6d85 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sat, 4 Nov 2017 12:31:53 +0100 Subject: [PATCH 09/13] Add Easter egg --- src/attr/__init__.py | 3 +++ 1 file changed, 3 insertions(+) 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", From 72eb21c1eae2064cd23e09d212ef2059ff91c17b Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sat, 4 Nov 2017 13:43:11 +0100 Subject: [PATCH 10/13] Better wording --- changelog.d/262.change.rst | 2 +- changelog.d/277.change.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/262.change.rst b/changelog.d/262.change.rst index e71a7f9ef..160da8b2c 100644 --- a/changelog.d/262.change.rst +++ b/changelog.d/262.change.rst @@ -1,3 +1,3 @@ Added new option ``auto_attribs`` to ``@attr.s`` that allows to collect annotated fields without setting them to ``attr.ib()``. -Setting to an ``attr.ib()`` is still possible to supply options like validators. +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 index e71a7f9ef..160da8b2c 100644 --- a/changelog.d/277.change.rst +++ b/changelog.d/277.change.rst @@ -1,3 +1,3 @@ Added new option ``auto_attribs`` to ``@attr.s`` that allows to collect annotated fields without setting them to ``attr.ib()``. -Setting to an ``attr.ib()`` is still possible to supply options like validators. +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. From be65f420f928f8e5778c4f30ad0568a226715a4b Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sun, 5 Nov 2017 07:59:10 +0100 Subject: [PATCH 11/13] Fix example --- docs/examples.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index ddb4d7aac..979c1ded4 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -486,22 +486,22 @@ If you don't mind annotating all attributes, you can even drop the :func:`attr.i >>> @attr.s(auto_attribs=True) ... class AutoC: ... cls_var: typing.ClassVar[int] = 5 # this one is ignored - ... x: int = attr.Factory(list) - ... y: int = 2 + ... 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).y.type - >>> attr.fields(AutoC).foo.type >>> attr.fields(AutoC).bar.type typing.Any - >>> AutoC(1, 2) - AutoC(x=1, y=2, foo='every attrib needs a type if auto_attribs=True', bar=None) + >>> AutoC() + AutoC(l=[], x=1, foo='every attrib needs a type if auto_attribs=True', bar=None) >>> AutoC.cls_var 5 From 23e4e06cd6719705769a5af0babe12afd0de22f5 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Mon, 6 Nov 2017 08:01:27 +0100 Subject: [PATCH 12/13] Raise errors on unannotated attr.ibs --- docs/api.rst | 8 ++++++ docs/examples.rst | 2 +- src/attr/_make.py | 59 ++++++++++++++++++++++++++------------- src/attr/exceptions.py | 9 ++++++ tests/test_annotations.py | 19 +++++++++++++ 5 files changed, 76 insertions(+), 21 deletions(-) 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 979c1ded4..32390df8b 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -478,7 +478,7 @@ Types >>> 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: +If you don't mind annotating *all* attributes, you can even drop the :func:`attr.ib` and assign default values instead: .. doctest:: diff --git a/src/attr/_make.py b/src/attr/_make.py index 28405a2fb..a3fe07d98 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -18,6 +18,7 @@ DefaultAlreadySetError, FrozenInstanceError, NotAnAttrsClassError, + UnannotatedAttributeError, ) @@ -213,11 +214,26 @@ def _transform_attrs(cls, these, auto_attribs): cd = cls.__dict__ anns = getattr(cls, "__annotations__", {}) - if auto_attribs is True: + 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 isinstance(type, _CLASS_VAR_CLS): continue + annot_names.add(attr_name) a = cd.get(attr_name, NOTHING) if not isinstance(a, _CountingAttr): if a is NOTHING: @@ -225,21 +241,22 @@ def _transform_attrs(cls, these, auto_attribs): 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: - if these is None: - ca_list = [ - (name, attr) - for name, attr - in cd.items() - if isinstance(attr, _CountingAttr) - ] - else: - ca_list = [ - (name, ca) - for name, ca - in iteritems(these) - ] - ca_list.sort(key=lambda e: e[1].counter) + ca_list = sorted(( + (name, ca) + for name, ca + in iteritems(these) + ), key=lambda e: e[1].counter) non_super_attrs = [ Attribute.from_counting_attr( @@ -566,11 +583,13 @@ def attrs(maybe_cls=None, these=None, repr_ns=None, .. _slots: https://docs.python.org/3/reference/datamodel.html#slots :param bool auto_attribs: If True, collect `PEP 526`_-annotated attributes - from the class body. In this case, you **must** annotate every field. - Fields that are assigned an :func:`attr.ib` but have no type - annotation are silently ignored. Use - ``field_name: typing.Any = attr.ib(...)`` if you don't want to set a - type. + (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 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 f8339ec34..ee3094498 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -11,6 +11,8 @@ import attr +from attr.exceptions import UnannotatedAttributeError + class TestAnnotations: """ @@ -112,3 +114,20 @@ class C: 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 From 33b9ac9630acb892f5f398b89614847c073fbd0d Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Tue, 7 Nov 2017 13:07:05 +0100 Subject: [PATCH 13/13] Use gross hacks in ClassVar detection to avoid importing typing Father forgive me for I have sinned. ref https://github.com/ericvsmith/dataclasses/issues/61 --- src/attr/_make.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index a3fe07d98..da8e8c17c 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -191,16 +191,14 @@ class MyClassAttributes(tuple): ]) -try: - from typing import ClassVar - - # You cannot use ClassVar together with isinstance. - _CLASS_VAR_CLS = ClassVar.__class__ -except ImportError: - class FakeClassVar(object): - pass +def _is_class_var(annot): + """ + Check whether *annot* is a typing.ClassVar. - _CLASS_VAR_CLS = FakeClassVar + 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): @@ -231,7 +229,7 @@ def _transform_attrs(cls, these, auto_attribs): ca_list = [] annot_names = set() for attr_name, type in anns.items(): - if isinstance(type, _CLASS_VAR_CLS): + if _is_class_var(type): continue annot_names.add(attr_name) a = cd.get(attr_name, NOTHING)