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

Infer type annotations from converters #710

Merged
merged 14 commits into from
Dec 13, 2020
2 changes: 2 additions & 0 deletions changelog.d/787.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
If present, type annotations of the ``converter`` argument to ``attr.ib`` are now used to give type annotations to ``__init__``.
``attr.converters.pipe`` and ``attr.converters.optional`` also infer type annotations based on the passed converters.
14 changes: 14 additions & 0 deletions docs/init.rst
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,20 @@ Arguably, you can abuse converters as one-argument validators:
ValueError: invalid literal for int() with base 10: 'x'


If a converter's first argument has a type annotation, that type will appear in the signature for ``__init__``.
A converter will override an explicit type annotation or ``type`` argument.
hynek marked this conversation as resolved.
Show resolved Hide resolved

.. doctest::

>>> def str2int(x: str) -> int:
... return int(x)
>>> @attr.s
... class C(object):
... x = attr.ib(converter=str2int)
>>> C.__init__.__annotations__
{'return': None, 'x': <class 'str'>}


Post-Init Hook
--------------

Expand Down
60 changes: 58 additions & 2 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import absolute_import, division, print_function

import copy
import inspect
import linecache
import sys
import threading
Expand Down Expand Up @@ -28,6 +29,10 @@
)


if not PY2:
import typing


# This is used at least twice, so cache it here.
_obj_setattr = object.__setattr__
_init_converter_pat = "__attr_converter_%s"
Expand Down Expand Up @@ -2210,8 +2215,24 @@ def fmt_setter_with_converter(
else:
lines.append(fmt_setter(attr_name, arg_name, has_on_setattr))

if a.init is True and a.converter is None and a.type is not None:
annotations[arg_name] = a.type
if a.init is True:
if a.type is not None and a.converter is None:
annotations[arg_name] = a.type
elif a.converter is not None and not PY2:
# Try to get the type from the converter.
sig = None
try:
sig = inspect.signature(a.converter)
except (ValueError, TypeError): # inspect failed
pass
if sig:
sig_params = list(sig.parameters.values())
if (
sig_params
and sig_params[0].annotation
is not inspect.Parameter.empty
):
annotations[arg_name] = sig_params[0].annotation

if attrs_to_validate: # we can skip this if there are no validators.
names_for_globals["_config"] = _config
Expand Down Expand Up @@ -2751,6 +2772,9 @@ def pipe(*converters):
When called on a value, it runs all wrapped converters, returning the
*last* value.

Type annotations will be inferred from the wrapped converters', if
they have any.

:param callables converters: Arbitrary number of converters.

.. versionadded:: 20.1.0
Expand All @@ -2762,4 +2786,36 @@ def pipe_converter(val):

return val

if not PY2:
if not converters:
# If the converter list is empty, pipe_converter is the identity.
A = typing.TypeVar("A")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I don't like how it works with empty pipe, as it will put TypeVar("A") in __init__ annotation. Can't figure a much better solution, though.

pipe_converter.__annotations__ = {"val": A, "return": A}
else:
# Get parameter type.
sig = None
try:
sig = inspect.signature(converters[0])
except (ValueError, TypeError): # inspect failed
pass
if sig:
params = list(sig.parameters.values())
if (
params
and params[0].annotation is not inspect.Parameter.empty
):
pipe_converter.__annotations__["val"] = params[
0
].annotation
# Get return type.
sig = None
try:
sig = inspect.signature(converters[-1])
except (ValueError, TypeError): # inspect failed
pass
if sig and sig.return_annotation is not inspect.Signature().empty:
pipe_converter.__annotations__[
"return"
] = sig.return_annotation

return pipe_converter
26 changes: 26 additions & 0 deletions src/attr/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@

from __future__ import absolute_import, division, print_function

from ._compat import PY2
from ._make import NOTHING, Factory, pipe


if not PY2:
import inspect
import typing


__all__ = [
"pipe",
"optional",
Expand All @@ -19,6 +25,9 @@ def optional(converter):
A converter that allows an attribute to be optional. An optional attribute
is one which can be set to ``None``.

Type annotations will be inferred from the wrapped converter's, if it
has any.

:param callable converter: the converter that is used for non-``None``
values.

Expand All @@ -30,6 +39,23 @@ def optional_converter(val):
return None
return converter(val)

if not PY2:
sig = None
try:
sig = inspect.signature(converter)
except (ValueError, TypeError): # inspect failed
pass
if sig:
params = list(sig.parameters.values())
if params and params[0].annotation is not inspect.Parameter.empty:
optional_converter.__annotations__["val"] = typing.Optional[
params[0].annotation
]
if sig.return_annotation is not inspect.Signature.empty:
optional_converter.__annotations__["return"] = typing.Optional[
sig.return_annotation
]

return optional_converter


Expand Down
183 changes: 180 additions & 3 deletions tests/test_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,15 +196,192 @@ class C(A):

def test_converter_annotations(self):
"""
Attributes with converters don't have annotations.
An unannotated attribute with an annotated converter gets its
annotation from the converter.
"""

@attr.s(auto_attribs=True)
def int2str(x: int) -> str:
return str(x)

@attr.s
class A:
a = attr.ib(converter=int2str)

assert A.__init__.__annotations__ == {"a": int, "return": None}

def int2str_(x: int, y: str = ""):
return str(x)

@attr.s
class A:
a: int = attr.ib(converter=int)
a = attr.ib(converter=int2str_)

assert A.__init__.__annotations__ == {"a": int, "return": None}

def test_converter_attrib_annotations(self):
"""
If a converter is provided, an explicit type annotation has no
effect on an attribute's type annotation.
"""

def int2str(x: int) -> str:
return str(x)

@attr.s
class A:
a: str = attr.ib(converter=int2str)
b = attr.ib(converter=int2str, type=str)

assert A.__init__.__annotations__ == {
"a": int,
"b": int,
"return": None,
}

def test_non_introspectable_converter(self):
"""
A non-introspectable converter doesn't cause a crash.
"""

@attr.s
class A:
a = attr.ib(converter=print)

def test_nullary_converter(self):
"""
A coverter with no arguments doesn't cause a crash.
"""

def noop():
pass

@attr.s
class A:
a = attr.ib(converter=noop)

assert A.__init__.__annotations__ == {"return": None}

def test_pipe(self):
"""
pipe() uses the input annotation of its first argument and the
output annotation of its last argument.
"""

def int2str(x: int) -> str:
return str(x)

def strlen(y: str) -> int:
return len(y)

def identity(z):
return z

assert attr.converters.pipe(int2str).__annotations__ == {
"val": int,
"return": str,
}
assert attr.converters.pipe(int2str, strlen).__annotations__ == {
"val": int,
"return": int,
}
assert attr.converters.pipe(identity, strlen).__annotations__ == {
"return": int
}
assert attr.converters.pipe(int2str, identity).__annotations__ == {
"val": int
}

def int2str_(x: int, y: int = 0) -> str:
return str(x)

assert attr.converters.pipe(int2str_).__annotations__ == {
"val": int,
"return": str,
}

def test_pipe_empty(self):
"""
pipe() with no converters is annotated like the identity.
"""

p = attr.converters.pipe()
assert "val" in p.__annotations__
t = p.__annotations__["val"]
assert isinstance(t, typing.TypeVar)
assert p.__annotations__ == {"val": t, "return": t}

def test_pipe_non_introspectable(self):
"""
pipe() doesn't crash when passed a non-introspectable converter.
"""

assert attr.converters.pipe(print).__annotations__ == {}

def test_pipe_nullary(self):
"""
pipe() doesn't crash when passed a nullary converter.
"""

def noop():
pass

assert attr.converters.pipe(noop).__annotations__ == {}

def test_optional(self):
"""
optional() uses the annotations of the converter it wraps.
"""

def int2str(x: int) -> str:
return str(x)

def int_identity(x: int):
return x

def strify(x) -> str:
return str(x)

def identity(x):
return x

assert attr.converters.optional(int2str).__annotations__ == {
"val": typing.Optional[int],
"return": typing.Optional[str],
}
assert attr.converters.optional(int_identity).__annotations__ == {
"val": typing.Optional[int]
}
assert attr.converters.optional(strify).__annotations__ == {
"return": typing.Optional[str]
}
assert attr.converters.optional(identity).__annotations__ == {}

def int2str_(x: int, y: int = 0) -> str:
return str(x)

assert attr.converters.optional(int2str_).__annotations__ == {
"val": typing.Optional[int],
"return": typing.Optional[str],
}

def test_optional_non_introspectable(self):
"""
optional() doesn't crash when passed a non-introspectable
converter.
"""

assert attr.converters.optional(print).__annotations__ == {}

def test_optional_nullary(self):
"""
optional() doesn't crash when passed a nullary converter.
"""

def noop():
pass

assert attr.converters.optional(noop).__annotations__ == {}

@pytest.mark.parametrize("slots", [True, False])
@pytest.mark.parametrize("classvar", _classvar_prefixes)
def test_annotations_strings(self, slots, classvar):
Expand Down