Repository: python-attrs/attrs
Pull request #710 (2020-11-02 05:01:00)

Infer type annotations from converters
- [x] Added **tests** for changed code.
- [x] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/master/tests/strategies.py).
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [x] ...and used in the stub test file `tests/typing_example.py`.
- [x] Updated **documentation** for changed code.
    - [x] New functions/classes have to be added to `docs/api.rst` by hand.
    - [x] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).  Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/master/src/attr/__init__.py) file.
- [x] Documentation in `.rst` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [x] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/master/changelog.d).

nosewings: I went ahead and implemented inference for `pipe` and `optional` (`default_if_none` isn't possible without knowing the type of the default argument). I don't think the `update_wrapper` approach will work, because, e.g., the function returned by `pipe` takes exactly one positional argument named `val`, not any other arguments that the first wrapped converter might take. Instead, I had to use `inspect` and copy over the necessary annotations manually.
nosewings: I suppose the other option would be to make `pipe` (and `optional`) take `*args, **kwargs`; then the `update_wrapper` approach would make sense (I think).
hynek: @Drino would you mind having a look?
Drino: I'm OK with parsing only argument type (e.g. it may be better for 3-argument converters when they will be added), though in my personal opinion `*args, **kwargs` approach is a little bit more elegant.

Added a couple of pesky comments, but anyway it looks good to me.

@nosewings thank you for the great PR :)

Patch:
diff --git a/changelog.d/787.change.rst b/changelog.d/787.change.rst
new file mode 100644
index 0000000..a60e9b8
--- /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 c65e545..696b161 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': <class 'str'>}
+
+
 Post-Init Hook
 --------------
 
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 49484f9..64af3cc 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 715ce17..9a32353 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
 
 


Test patch:
diff --git a/tests/test_annotations.py b/tests/test_annotations.py
index f85b5d9..3e19aa1 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 = 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):