Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic type support #239

Merged
merged 5 commits into from
Sep 17, 2017
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/239.change.rst
Original file line number Diff line number Diff line change
@@ -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 also be set using variable type annotations (see `PEP 526 <https://www.python.org/dev/peps/pep-0526/>`_).
6 changes: 6 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import absolute_import, division, print_function

import sys
import pytest


Expand All @@ -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")
14 changes: 7 additions & 7 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -299,7 +299,7 @@ See :ref:`asdict` for examples.
>>> attr.validate(i)
Traceback (most recent call last):
...
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')
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')


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:
Expand Down Expand Up @@ -332,11 +332,11 @@ Validators
>>> C("42")
Traceback (most recent call last):
...
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')
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')
>>> C(None)
Traceback (most recent call last):
...
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)
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)

.. autofunction:: attr.validators.in_

Expand Down Expand Up @@ -388,7 +388,7 @@ Validators
>>> C("42")
Traceback (most recent call last):
...
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')
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')
>>> C(None)
C(x=None)

Expand Down
8 changes: 4 additions & 4 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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')
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')

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

Expand All @@ -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 <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')
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')
>>> C(256)
Traceback (most recent call last):
...
Expand All @@ -401,7 +401,7 @@ And finally you can disable validators globally:
>>> C("128")
Traceback (most recent call last):
...
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')
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')


Conversion
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion docs/extending.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::
Expand Down
41 changes: 31 additions & 10 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 <https://www.python.org/dev/peps/pep-0526/>`_).
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*

This comment was marked as spam.

This comment was marked as spam.

"""
if hash is not None and hash is not True and hash is not False:
raise TypeError(
Expand All @@ -143,6 +150,7 @@ def attr(default=NOTHING, validator=None,
init=init,
convert=convert,
metadata=metadata,
type=type,
)


Expand Down Expand Up @@ -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)
]
Expand All @@ -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)
])
Expand Down Expand Up @@ -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)

Expand All @@ -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(

This comment was marked as spam.

"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):
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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):
"""
Expand Down
57 changes: 57 additions & 0 deletions tests/test_annotations.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 14 additions & 1 deletion tests/test_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down