Infer type annotations from converters (#710)

* 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>
This commit is contained in:
Nicholas Coltharp 2020-12-13 09:30:06 -06:00 committed by GitHub
parent 612700c3ea
commit e09b1d6423
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 280 additions and 5 deletions

View File

@ -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.

View File

@ -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
--------------

View File

@ -1,6 +1,7 @@
from __future__ import absolute_import, division, print_function
import copy
import inspect
import linecache
import sys
import threading
@ -28,6 +29,10 @@ from .exceptions import (
)
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"
@ -2210,8 +2215,24 @@ def _attrs_to_init_script(
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:
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
@ -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
@ -2762,4 +2786,36 @@ def pipe(*converters):
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

View File

@ -4,9 +4,15 @@ Commonly useful converters.
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",
@ -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.
@ -30,6 +39,23 @@ def optional(converter):
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

View File

@ -196,15 +196,192 @@ class TestAnnotations:
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: int = attr.ib(converter=int)
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 = 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):