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:
parent
612700c3ea
commit
e09b1d6423
|
@ -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.
|
|
@ -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
|
||||
--------------
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue