Skip to content

Commit

Permalink
Infer type annotations from converters (#710)
Browse files Browse the repository at this point in the history
* Infer annotations from converters

* Use semantic newlines

* Add 787.change.rst

* Don't let type annotations override converters

* Make pipe() infer type annotations

* Use PY2 instead of sys.versioninfo >= (3, 3)

* Avert crashing with a nullary converter

* Small doc change

* Add type inference for optional()

* Make pipe() annotations actually work

Co-authored-by: Hynek Schlawack <hs@ox.cx>
  • Loading branch information
nosewings and hynek authored Dec 13, 2020
1 parent 612700c commit e09b1d6
Show file tree
Hide file tree
Showing 5 changed files with 280 additions and 5 deletions.
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.

.. 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")
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

0 comments on commit e09b1d6

Please sign in to comment.