Skip to content

Commit 566ada9

Browse files
chadrikTinche
authored andcommitted
Basic type support (#239)
* Add support for passing a type to attr.ib() and gathering the type from PEP526-style annotations. * Address review notes. * More review notes. * A few more review changes. * Quick final fix to the changelog.
1 parent 0d04d5e commit 566ada9

File tree

8 files changed

+123
-23
lines changed

8 files changed

+123
-23
lines changed

changelog.d/239.change.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Added ``type`` argument to ``attr.ib()`` and corresponding ``type`` attribute to ``attr.Attribute``.
2+
This change paves the way for automatic type checking and serialization (though as of this release ``attrs`` does not make use of it).
3+
In Python 3.6 or higher, the value of ``attr.Attribute.type`` can alternately be set using variable type annotations (see `PEP 526 <https://www.python.org/dev/peps/pep-0526/>`_).

conftest.py

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import absolute_import, division, print_function
22

3+
import sys
34
import pytest
45

56

@@ -16,3 +17,8 @@ class C(object):
1617
y = attr()
1718

1819
return C
20+
21+
22+
collect_ignore = []
23+
if sys.version_info[:2] < (3, 6):
24+
collect_ignore.append("tests/test_annotations.py")

docs/api.rst

+7-7
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Core
9090
... class C(object):
9191
... x = attr.ib()
9292
>>> C.x
93-
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}))
93+
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None)
9494

9595

9696
.. autofunction:: attr.make_class
@@ -202,9 +202,9 @@ Helpers
202202
... x = attr.ib()
203203
... y = attr.ib()
204204
>>> attr.fields(C)
205-
(Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({})), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({})))
205+
(Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None))
206206
>>> attr.fields(C)[1]
207-
Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}))
207+
Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None)
208208
>>> attr.fields(C).y is attr.fields(C)[1]
209209
True
210210

@@ -299,7 +299,7 @@ See :ref:`asdict` for examples.
299299
>>> attr.validate(i)
300300
Traceback (most recent call last):
301301
...
302-
TypeError: ("'x' must be <type 'int'> (got '1' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True), <type 'int'>, '1')
302+
TypeError: ("'x' must be <type 'int'> (got '1' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True, type=None), <type 'int'>, '1')
303303

304304

305305
Validators can be globally disabled if you want to run them only in development and tests but not in production because you fear their performance impact:
@@ -332,11 +332,11 @@ Validators
332332
>>> C("42")
333333
Traceback (most recent call last):
334334
...
335-
TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>), <type 'int'>, '42')
335+
TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None), <type 'int'>, '42')
336336
>>> C(None)
337337
Traceback (most recent call last):
338338
...
339-
TypeError: ("'x' must be <type 'int'> (got None that is a <type 'NoneType'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True), <type 'int'>, None)
339+
TypeError: ("'x' must be <type 'int'> (got None that is a <type 'NoneType'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, repr=True, cmp=True, hash=None, init=True, type=None), <type 'int'>, None)
340340

341341
.. autofunction:: attr.validators.in_
342342

@@ -388,7 +388,7 @@ Validators
388388
>>> C("42")
389389
Traceback (most recent call last):
390390
...
391-
TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>), <type 'int'>, '42')
391+
TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None), <type 'int'>, '42')
392392
>>> C(None)
393393
C(x=None)
394394

docs/examples.rst

+4-4
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ This example also shows of some syntactic sugar for using the :func:`attr.valida
368368
>>> C("42")
369369
Traceback (most recent call last):
370370
...
371-
TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=<instance_of validator for type <type 'int'>>), <type 'int'>, '42')
371+
TypeError: ("'x' must be <type 'int'> (got '42' that is a <type 'str'>).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=<instance_of validator for type <type 'int'>>, type=None), <type 'int'>, '42')
372372

373373
Of course you can mix and match the two approaches at your convenience:
374374

@@ -386,7 +386,7 @@ Of course you can mix and match the two approaches at your convenience:
386386
>>> C("128")
387387
Traceback (most recent call last):
388388
...
389-
TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=[<instance_of validator for type <class 'int'>>, <function fits_byte at 0x10fd7a0d0>], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({})), <class 'int'>, '128')
389+
TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=[<instance_of validator for type <class 'int'>>, <function fits_byte at 0x10fd7a0d0>], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}), type=None), <class 'int'>, '128')
390390
>>> C(256)
391391
Traceback (most recent call last):
392392
...
@@ -401,7 +401,7 @@ And finally you can disable validators globally:
401401
>>> C("128")
402402
Traceback (most recent call last):
403403
...
404-
TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=[<instance_of validator for type <class 'int'>>, <function fits_byte at 0x10fd7a0d0>], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({})), <class 'int'>, '128')
404+
TypeError: ("'x' must be <class 'int'> (got '128' that is a <class 'str'>).", Attribute(name='x', default=NOTHING, validator=[<instance_of validator for type <class 'int'>>, <function fits_byte at 0x10fd7a0d0>], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}), type=None), <class 'int'>, '128')
405405

406406

407407
Conversion
@@ -514,7 +514,7 @@ Slot classes are a little different than ordinary, dictionary-backed classes:
514514
... class C(object):
515515
... x = attr.ib()
516516
>>> C.x
517-
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}))
517+
Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None)
518518
>>> @attr.s(slots=True)
519519
... class C(object):
520520
... x = attr.ib()

docs/extending.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ So it is fairly simple to build your own decorators on top of ``attrs``:
1717
... @attr.s
1818
... class C(object):
1919
... a = attr.ib()
20-
(Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({})),)
20+
(Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None),)
2121

2222

2323
.. warning::

src/attr/_make.py

+31-10
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def __hash__(self):
6262

6363
def attr(default=NOTHING, validator=None,
6464
repr=True, cmp=True, hash=None, init=True,
65-
convert=None, metadata={}):
65+
convert=None, metadata={}, type=None):
6666
"""
6767
Create a new attribute on a class.
6868
@@ -125,10 +125,17 @@ def attr(default=NOTHING, validator=None,
125125
value is converted before being passed to the validator, if any.
126126
:param metadata: An arbitrary mapping, to be used by third-party
127127
components. See :ref:`extending_metadata`.
128+
:param type: The type of the attribute. In Python 3.6 or greater, the
129+
preferred method to specify the type is using a variable annotation
130+
(see `PEP 526 <https://www.python.org/dev/peps/pep-0526/>`_).
131+
This argument is provided for backward compatibility.
132+
Regardless of the approach used, the type will be stored on
133+
``Attribute.type``.
128134
129135
.. versionchanged:: 17.1.0 *validator* can be a ``list`` now.
130136
.. versionchanged:: 17.1.0
131137
*hash* is ``None`` and therefore mirrors *cmp* by default .
138+
.. versionadded:: 17.3.0 *type*
132139
"""
133140
if hash is not None and hash is not True and hash is not False:
134141
raise TypeError(
@@ -143,6 +150,7 @@ def attr(default=NOTHING, validator=None,
143150
init=init,
144151
convert=convert,
145152
metadata=metadata,
153+
type=type,
146154
)
147155

148156

@@ -191,8 +199,11 @@ def _transform_attrs(cls, these):
191199
for name, ca
192200
in iteritems(these)]
193201

202+
ann = getattr(cls, "__annotations__", {})
203+
194204
non_super_attrs = [
195-
Attribute.from_counting_attr(name=attr_name, ca=ca)
205+
Attribute.from_counting_attr(name=attr_name, ca=ca,
206+
type=ann.get(attr_name))
196207
for attr_name, ca
197208
in sorted(ca_list, key=lambda e: e[1].counter)
198209
]
@@ -212,7 +223,8 @@ def _transform_attrs(cls, these):
212223
AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names)
213224

214225
cls.__attrs_attrs__ = AttrsClass(super_cls + [
215-
Attribute.from_counting_attr(name=attr_name, ca=ca)
226+
Attribute.from_counting_attr(name=attr_name, ca=ca,
227+
type=ann.get(attr_name))
216228
for attr_name, ca
217229
in sorted(ca_list, key=lambda e: e[1].counter)
218230
])
@@ -853,11 +865,11 @@ class Attribute(object):
853865
"""
854866
__slots__ = (
855867
"name", "default", "validator", "repr", "cmp", "hash", "init",
856-
"convert", "metadata",
868+
"convert", "metadata", "type"
857869
)
858870

859871
def __init__(self, name, default, validator, repr, cmp, hash, init,
860-
convert=None, metadata=None):
872+
convert=None, metadata=None, type=None):
861873
# Cache this descriptor here to speed things up later.
862874
bound_setattr = _obj_setattr.__get__(self, Attribute)
863875

@@ -871,22 +883,30 @@ def __init__(self, name, default, validator, repr, cmp, hash, init,
871883
bound_setattr("convert", convert)
872884
bound_setattr("metadata", (metadata_proxy(metadata) if metadata
873885
else _empty_metadata_singleton))
886+
bound_setattr("type", type)
874887

875888
def __setattr__(self, name, value):
876889
raise FrozenInstanceError()
877890

878891
@classmethod
879-
def from_counting_attr(cls, name, ca):
892+
def from_counting_attr(cls, name, ca, type=None):
893+
# type holds the annotated value. deal with conflicts:
894+
if type is None:
895+
type = ca.type
896+
elif ca.type is not None:
897+
raise ValueError(
898+
"Type annotation and type argument cannot both be present"
899+
)
880900
inst_dict = {
881901
k: getattr(ca, k)
882902
for k
883903
in Attribute.__slots__
884904
if k not in (
885-
"name", "validator", "default",
905+
"name", "validator", "default", "type"
886906
) # exclude methods
887907
}
888908
return cls(name=name, validator=ca._validator, default=ca._default,
889-
**inst_dict)
909+
type=type, **inst_dict)
890910

891911
# Don't use _add_pickle since fields(Attribute) doesn't work
892912
def __getstate__(self):
@@ -929,7 +949,7 @@ class _CountingAttr(object):
929949
likely the result of a bug like a forgotten `@attr.s` decorator.
930950
"""
931951
__slots__ = ("counter", "_default", "repr", "cmp", "hash", "init",
932-
"metadata", "_validator", "convert")
952+
"metadata", "_validator", "convert", "type")
933953
__attrs_attrs__ = tuple(
934954
Attribute(name=name, default=NOTHING, validator=None,
935955
repr=True, cmp=True, hash=True, init=True)
@@ -942,7 +962,7 @@ class _CountingAttr(object):
942962
cls_counter = 0
943963

944964
def __init__(self, default, validator, repr, cmp, hash, init, convert,
945-
metadata):
965+
metadata, type):
946966
_CountingAttr.cls_counter += 1
947967
self.counter = _CountingAttr.cls_counter
948968
self._default = default
@@ -957,6 +977,7 @@ def __init__(self, default, validator, repr, cmp, hash, init, convert,
957977
self.init = init
958978
self.convert = convert
959979
self.metadata = metadata
980+
self.type = type
960981

961982
def validator(self, meth):
962983
"""

tests/test_annotations.py

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Tests for PEP-526 type annotations.
3+
"""
4+
5+
from __future__ import absolute_import, division, print_function
6+
7+
import pytest
8+
9+
from attr._make import (
10+
attr,
11+
attributes,
12+
fields
13+
)
14+
15+
import typing
16+
17+
18+
class TestAnnotations(object):
19+
"""
20+
Tests for types derived from variable annotations (PEP-526).
21+
"""
22+
23+
def test_basic_annotations(self):
24+
"""
25+
Sets the `Attribute.type` attr from basic type annotations.
26+
"""
27+
@attributes
28+
class C(object):
29+
x: int = attr()
30+
y = attr(type=str)
31+
z = attr()
32+
assert int is fields(C).x.type
33+
assert str is fields(C).y.type
34+
assert None is fields(C).z.type
35+
36+
def test_catches_basic_type_conflict(self):
37+
"""
38+
Raises ValueError type is specified both ways.
39+
"""
40+
with pytest.raises(ValueError) as e:
41+
@attributes
42+
class C:
43+
x: int = attr(type=int)
44+
assert ("Type annotation and type argument cannot "
45+
"both be present",) == e.value.args
46+
47+
def test_typing_annotations(self):
48+
"""
49+
Sets the `Attribute.type` attr from typing annotations.
50+
"""
51+
@attributes
52+
class C(object):
53+
x: typing.List[int] = attr()
54+
y = attr(type=typing.Optional[str])
55+
56+
assert typing.List[int] is fields(C).x.type
57+
assert typing.Optional[str] is fields(C).y.type

tests/test_make.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ class C(object):
194194
"default value or factory. Attribute in question: Attribute"
195195
"(name='y', default=NOTHING, validator=None, repr=True, "
196196
"cmp=True, hash=None, init=True, convert=None, "
197-
"metadata=mappingproxy({}))",
197+
"metadata=mappingproxy({}), type=None)",
198198
) == e.value.args
199199

200200
def test_these(self):
@@ -406,6 +406,19 @@ def __attrs_post_init__(self2):
406406
c = C(x=10, y=20)
407407
assert 30 == getattr(c, 'z', None)
408408

409+
def test_types(self):
410+
"""
411+
Sets the `Attribute.type` attr from type argument.
412+
"""
413+
@attributes
414+
class C(object):
415+
x = attr(type=int)
416+
y = attr(type=str)
417+
z = attr()
418+
assert int is fields(C).x.type
419+
assert str is fields(C).y.type
420+
assert None is fields(C).z.type
421+
409422

410423
@attributes
411424
class GC(object):

0 commit comments

Comments
 (0)