Repository: python-attrs/attrs
Pull request #1331 (2024-08-06 13:02:53)

Make Converter (including pipe) correctly infer the return type
When running in adapter mode.



Patch:
diff --git a/src/attr/_compat.py b/src/attr/_compat.py
index 4c515d2..104eeb0 100644
--- a/src/attr/_compat.py
+++ b/src/attr/_compat.py
@@ -13,6 +13,7 @@ PYPY = platform.python_implementation() == "PyPy"
 PY_3_8_PLUS = sys.version_info[:2] >= (3, 8)
 PY_3_9_PLUS = sys.version_info[:2] >= (3, 9)
 PY_3_10_PLUS = sys.version_info[:2] >= (3, 10)
+PY_3_11_PLUS = sys.version_info[:2] >= (3, 11)
 PY_3_12_PLUS = sys.version_info[:2] >= (3, 12)
 PY_3_13_PLUS = sys.version_info[:2] >= (3, 13)
 PY_3_14_PLUS = sys.version_info[:2] >= (3, 14)
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 6ff79ba..bf00c5f 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -22,6 +22,7 @@ from . import _compat, _config, setters
 from ._compat import (
     PY_3_8_PLUS,
     PY_3_10_PLUS,
+    PY_3_11_PLUS,
     _AnnotationExtractor,
     _get_annotations,
     get_generic_base,
@@ -2705,28 +2706,27 @@ class Converter:
         self.takes_self = takes_self
         self.takes_field = takes_field
 
-        self._first_param_type = _AnnotationExtractor(
-            converter
-        ).get_first_param_type()
+        ex = _AnnotationExtractor(converter)
+        self._first_param_type = ex.get_first_param_type()
 
-        self.__call__ = {
-            (False, False): self._takes_only_value,
-            (True, False): self._takes_instance,
-            (False, True): self._takes_field,
-            (True, True): self._takes_both,
-        }[self.takes_self, self.takes_field]
-
-    def _takes_only_value(self, value, instance, field):
-        return self.converter(value)
-
-    def _takes_instance(self, value, instance, field):
-        return self.converter(value, instance)
-
-    def _takes_field(self, value, instance, field):
-        return self.converter(value, field)
+        if not (self.takes_self or self.takes_field):
+            self.__call__ = lambda value, _, __: self.converter(value)
+        elif self.takes_self and not self.takes_field:
+            self.__call__ = lambda value, instance, __: self.converter(
+                value, instance
+            )
+        elif not self.takes_self and self.takes_field:
+            self.__call__ = lambda value, __, field: self.converter(
+                value, field
+            )
+        else:
+            self.__call__ = lambda value, instance, field: self.converter(
+                value, instance, field
+            )
 
-    def _takes_both(self, value, instance, field):
-        return self.converter(value, instance, field)
+        rt = ex.get_return_type()
+        if rt is not None:
+            self.__call__.__annotations__["return"] = rt
 
     @staticmethod
     def _get_global_name(attr_name: str) -> str:
@@ -2948,8 +2948,12 @@ def pipe(*converters):
         if t:
             pipe_converter.__annotations__["val"] = t
 
+        last = converters[-1]
+        if not PY_3_11_PLUS and isinstance(last, Converter):
+            last = last.__call__
+
         # Get return type from last converter.
-        rt = _AnnotationExtractor(converters[-1]).get_return_type()
+        rt = _AnnotationExtractor(last).get_return_type()
         if rt:
             pipe_converter.__annotations__["return"] = rt
 


Test patch:
diff --git a/tests/test_converters.py b/tests/test_converters.py
index fc812aa..164fb0f 100644
--- a/tests/test_converters.py
+++ b/tests/test_converters.py
@@ -11,6 +11,7 @@ import pytest
 import attr
 
 from attr import Converter, Factory, attrib
+from attr._compat import _AnnotationExtractor
 from attr.converters import default_if_none, optional, pipe, to_bool
 
 
@@ -91,6 +92,26 @@ class TestConverter:
 
         assert (42, instance, field) == taken
 
+    def test_annotations_if_last_in_pipe(self):
+        """
+        If the wrapped converter has annotations, they are copied to the
+        Converter __call__.
+        """
+
+        def wrapped(_, __, ___) -> float:
+            pass
+
+        c = Converter(wrapped)
+
+        assert float is c.__call__.__annotations__["return"]
+
+        # Doesn't overwrite globally.
+
+        c2 = Converter(int)
+
+        assert float is c.__call__.__annotations__["return"]
+        assert None is c2.__call__.__annotations__.get("return")
+
 
 class TestOptional:
     """
@@ -227,6 +248,29 @@ class TestPipe:
 
         assert o is pipe().converter(o, None, None)
 
+    def test_wrapped_annotation(self):
+        """
+        The return type of the wrapped converter is copied into its __call__
+        and ultimately into pipe's wrapped converter.
+        """
+
+        def last(value) -> bool:
+            return bool(value)
+
+        @attr.s
+        class C:
+            x = attr.ib(converter=[Converter(int), Converter(last)])
+
+        i = C(5)
+
+        assert True is i.x
+        assert (
+            bool
+            is _AnnotationExtractor(
+                attr.fields(C).x.converter.__call__
+            ).get_return_type()
+        )
+
 
 class TestToBool:
     def test_unhashable(self):