From e09b1d642339d7d94ba58d7855da40e65ed1a88f Mon Sep 17 00:00:00 2001 From: Nicholas Coltharp Date: Sun, 13 Dec 2020 09:30:06 -0600 Subject: [PATCH] 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 --- changelog.d/787.change.rst | 2 + docs/init.rst | 14 +++ src/attr/_make.py | 60 +++++++++++- src/attr/converters.py | 26 ++++++ tests/test_annotations.py | 183 ++++++++++++++++++++++++++++++++++++- 5 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 changelog.d/787.change.rst diff --git a/changelog.d/787.change.rst b/changelog.d/787.change.rst new file mode 100644 index 00000000..a60e9b84 --- /dev/null +++ b/changelog.d/787.change.rst @@ -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. diff --git a/docs/init.rst b/docs/init.rst index c65e545d..696b1617 100644 --- a/docs/init.rst +++ b/docs/init.rst @@ -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': } + + Post-Init Hook -------------- diff --git a/src/attr/_make.py b/src/attr/_make.py index 49484f93..64af3cca 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -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: - 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 @@ -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 diff --git a/src/attr/converters.py b/src/attr/converters.py index 715ce178..9a32353c 100644 --- a/src/attr/converters.py +++ b/src/attr/converters.py @@ -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 diff --git a/tests/test_annotations.py b/tests/test_annotations.py index f85b5d92..3e19aa14 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -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):