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):