diff --git a/changelog.d/239.change.rst b/changelog.d/239.change.rst
new file mode 100644
index 000000000..f50cd999f
--- /dev/null
+++ b/changelog.d/239.change.rst
@@ -0,0 +1,3 @@
+Added ``type`` argument to ``attr.ib()`` and corresponding ``type`` attribute to ``attr.Attribute``.
+This change paves the way for automatic type checking and serialization (though as of this release ``attrs`` does not make use of it).
+In Python 3.6 or higher, the value of ``attr.Attribute.type`` can alternately be set using variable type annotations (see `PEP 526 `_).
diff --git a/conftest.py b/conftest.py
index be9968ce2..47e24c311 100644
--- a/conftest.py
+++ b/conftest.py
@@ -1,5 +1,6 @@
from __future__ import absolute_import, division, print_function
+import sys
import pytest
@@ -16,3 +17,8 @@ class C(object):
y = attr()
return C
+
+
+collect_ignore = []
+if sys.version_info[:2] < (3, 6):
+ collect_ignore.append("tests/test_annotations.py")
diff --git a/docs/api.rst b/docs/api.rst
index 02fa5b237..45076c2b3 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -90,7 +90,7 @@ Core
... class C(object):
... x = attr.ib()
>>> C.x
- Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}))
+ Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None)
.. autofunction:: attr.make_class
@@ -202,9 +202,9 @@ Helpers
... x = attr.ib()
... y = attr.ib()
>>> attr.fields(C)
- (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({})))
+ (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))
>>> attr.fields(C)[1]
- Attribute(name='y', 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({}), type=None)
>>> attr.fields(C).y is attr.fields(C)[1]
True
@@ -299,7 +299,7 @@ See :ref:`asdict` for examples.
>>> attr.validate(i)
Traceback (most recent call last):
...
- TypeError: ("'x' must be (got '1' that is a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True), , '1')
+ TypeError: ("'x' must be (got '1' that is a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True, type=None), , '1')
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
>>> C("42")
Traceback (most recent call last):
...
- TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, validator=>), , '42')
+ TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, validator=>, type=None), , '42')
>>> C(None)
Traceback (most recent call last):
...
- TypeError: ("'x' must be (got None that is a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True), , None)
+ TypeError: ("'x' must be (got None that is a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True, type=None), , None)
.. autofunction:: attr.validators.in_
@@ -388,7 +388,7 @@ Validators
>>> C("42")
Traceback (most recent call last):
...
- TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, validator=>), , '42')
+ TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, validator=>, type=None), , '42')
>>> C(None)
C(x=None)
diff --git a/docs/examples.rst b/docs/examples.rst
index 21c2e464e..4e888c1f0 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -368,7 +368,7 @@ This example also shows of some syntactic sugar for using the :func:`attr.valida
>>> C("42")
Traceback (most recent call last):
...
- TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=>), , '42')
+ TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=>, type=None), , '42')
Of course you can mix and match the two approaches at your convenience:
@@ -386,7 +386,7 @@ Of course you can mix and match the two approaches at your convenience:
>>> C("128")
Traceback (most recent call last):
...
- TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({})), , '128')
+ TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}), type=None), , '128')
>>> C(256)
Traceback (most recent call last):
...
@@ -401,7 +401,7 @@ And finally you can disable validators globally:
>>> C("128")
Traceback (most recent call last):
...
- TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({})), , '128')
+ TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, convert=None, metadata=mappingproxy({}), type=None), , '128')
Conversion
@@ -514,7 +514,7 @@ Slot classes are a little different than ordinary, dictionary-backed classes:
... class C(object):
... x = attr.ib()
>>> C.x
- Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}))
+ Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None)
>>> @attr.s(slots=True)
... class C(object):
... x = attr.ib()
diff --git a/docs/extending.rst b/docs/extending.rst
index e7a20ad93..ba612384d 100644
--- a/docs/extending.rst
+++ b/docs/extending.rst
@@ -17,7 +17,7 @@ So it is fairly simple to build your own decorators on top of ``attrs``:
... @attr.s
... class C(object):
... a = attr.ib()
- (Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({})),)
+ (Attribute(name='a', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None),)
.. warning::
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 987e50be6..672f8c5e9 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -62,7 +62,7 @@ def __hash__(self):
def attr(default=NOTHING, validator=None,
repr=True, cmp=True, hash=None, init=True,
- convert=None, metadata={}):
+ convert=None, metadata={}, type=None):
"""
Create a new attribute on a class.
@@ -125,10 +125,17 @@ def attr(default=NOTHING, validator=None,
value is converted before being passed to the validator, if any.
:param metadata: An arbitrary mapping, to be used by third-party
components. See :ref:`extending_metadata`.
+ :param type: The type of the attribute. In Python 3.6 or greater, the
+ preferred method to specify the type is using a variable annotation
+ (see `PEP 526 `_).
+ This argument is provided for backward compatibility.
+ Regardless of the approach used, the type will be stored on
+ ``Attribute.type``.
.. versionchanged:: 17.1.0 *validator* can be a ``list`` now.
.. versionchanged:: 17.1.0
*hash* is ``None`` and therefore mirrors *cmp* by default .
+ .. versionadded:: 17.3.0 *type*
"""
if hash is not None and hash is not True and hash is not False:
raise TypeError(
@@ -143,6 +150,7 @@ def attr(default=NOTHING, validator=None,
init=init,
convert=convert,
metadata=metadata,
+ type=type,
)
@@ -191,8 +199,11 @@ def _transform_attrs(cls, these):
for name, ca
in iteritems(these)]
+ ann = getattr(cls, "__annotations__", {})
+
non_super_attrs = [
- Attribute.from_counting_attr(name=attr_name, ca=ca)
+ Attribute.from_counting_attr(name=attr_name, ca=ca,
+ type=ann.get(attr_name))
for attr_name, ca
in sorted(ca_list, key=lambda e: e[1].counter)
]
@@ -212,7 +223,8 @@ def _transform_attrs(cls, these):
AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names)
cls.__attrs_attrs__ = AttrsClass(super_cls + [
- Attribute.from_counting_attr(name=attr_name, ca=ca)
+ Attribute.from_counting_attr(name=attr_name, ca=ca,
+ type=ann.get(attr_name))
for attr_name, ca
in sorted(ca_list, key=lambda e: e[1].counter)
])
@@ -853,11 +865,11 @@ class Attribute(object):
"""
__slots__ = (
"name", "default", "validator", "repr", "cmp", "hash", "init",
- "convert", "metadata",
+ "convert", "metadata", "type"
)
def __init__(self, name, default, validator, repr, cmp, hash, init,
- convert=None, metadata=None):
+ convert=None, metadata=None, type=None):
# Cache this descriptor here to speed things up later.
bound_setattr = _obj_setattr.__get__(self, Attribute)
@@ -871,22 +883,30 @@ def __init__(self, name, default, validator, repr, cmp, hash, init,
bound_setattr("convert", convert)
bound_setattr("metadata", (metadata_proxy(metadata) if metadata
else _empty_metadata_singleton))
+ bound_setattr("type", type)
def __setattr__(self, name, value):
raise FrozenInstanceError()
@classmethod
- def from_counting_attr(cls, name, ca):
+ def from_counting_attr(cls, name, ca, type=None):
+ # type holds the annotated value. deal with conflicts:
+ if type is None:
+ type = ca.type
+ elif ca.type is not None:
+ raise ValueError(
+ "Type annotation and type argument cannot both be present"
+ )
inst_dict = {
k: getattr(ca, k)
for k
in Attribute.__slots__
if k not in (
- "name", "validator", "default",
+ "name", "validator", "default", "type"
) # exclude methods
}
return cls(name=name, validator=ca._validator, default=ca._default,
- **inst_dict)
+ type=type, **inst_dict)
# Don't use _add_pickle since fields(Attribute) doesn't work
def __getstate__(self):
@@ -929,7 +949,7 @@ class _CountingAttr(object):
likely the result of a bug like a forgotten `@attr.s` decorator.
"""
__slots__ = ("counter", "_default", "repr", "cmp", "hash", "init",
- "metadata", "_validator", "convert")
+ "metadata", "_validator", "convert", "type")
__attrs_attrs__ = tuple(
Attribute(name=name, default=NOTHING, validator=None,
repr=True, cmp=True, hash=True, init=True)
@@ -942,7 +962,7 @@ class _CountingAttr(object):
cls_counter = 0
def __init__(self, default, validator, repr, cmp, hash, init, convert,
- metadata):
+ metadata, type):
_CountingAttr.cls_counter += 1
self.counter = _CountingAttr.cls_counter
self._default = default
@@ -957,6 +977,7 @@ def __init__(self, default, validator, repr, cmp, hash, init, convert,
self.init = init
self.convert = convert
self.metadata = metadata
+ self.type = type
def validator(self, meth):
"""
diff --git a/tests/test_annotations.py b/tests/test_annotations.py
new file mode 100644
index 000000000..8cba682d3
--- /dev/null
+++ b/tests/test_annotations.py
@@ -0,0 +1,57 @@
+"""
+Tests for PEP-526 type annotations.
+"""
+
+from __future__ import absolute_import, division, print_function
+
+import pytest
+
+from attr._make import (
+ attr,
+ attributes,
+ fields
+)
+
+import typing
+
+
+class TestAnnotations(object):
+ """
+ Tests for types derived from variable annotations (PEP-526).
+ """
+
+ def test_basic_annotations(self):
+ """
+ Sets the `Attribute.type` attr from basic type annotations.
+ """
+ @attributes
+ class C(object):
+ x: int = attr()
+ y = attr(type=str)
+ z = attr()
+ assert int is fields(C).x.type
+ assert str is fields(C).y.type
+ assert None is fields(C).z.type
+
+ def test_catches_basic_type_conflict(self):
+ """
+ Raises ValueError type is specified both ways.
+ """
+ with pytest.raises(ValueError) as e:
+ @attributes
+ class C:
+ x: int = attr(type=int)
+ assert ("Type annotation and type argument cannot "
+ "both be present",) == e.value.args
+
+ def test_typing_annotations(self):
+ """
+ Sets the `Attribute.type` attr from typing annotations.
+ """
+ @attributes
+ class C(object):
+ x: typing.List[int] = attr()
+ y = attr(type=typing.Optional[str])
+
+ assert typing.List[int] is fields(C).x.type
+ assert typing.Optional[str] is fields(C).y.type
diff --git a/tests/test_make.py b/tests/test_make.py
index a9e400f5e..540663309 100644
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -194,7 +194,7 @@ class C(object):
"default value or factory. Attribute in question: Attribute"
"(name='y', default=NOTHING, validator=None, repr=True, "
"cmp=True, hash=None, init=True, convert=None, "
- "metadata=mappingproxy({}))",
+ "metadata=mappingproxy({}), type=None)",
) == e.value.args
def test_these(self):
@@ -406,6 +406,19 @@ def __attrs_post_init__(self2):
c = C(x=10, y=20)
assert 30 == getattr(c, 'z', None)
+ def test_types(self):
+ """
+ Sets the `Attribute.type` attr from type argument.
+ """
+ @attributes
+ class C(object):
+ x = attr(type=int)
+ y = attr(type=str)
+ z = attr()
+ assert int is fields(C).x.type
+ assert str is fields(C).y.type
+ assert None is fields(C).z.type
+
@attributes
class GC(object):