Repository: python-attrs/attrs

Issue:
Error occurs when using converters.optional and converters.pipe together
23.2.0 is OK.
```Python
Python 3.10.13 (main, Dec  6 2023, 12:01:00) [GCC 13.2.1 20230801] on linux
>>> import attr
>>> attr.__version__
'23.2.0'
>>> @attr.define
... class A:
...     abc: int | None = attr.field(converter=attr.converters.optional(attr.converters.pipe(str, int)), default=None)
...     
>>> A()
A(abc=None)
>>> A('1')
A(abc=1)
```

after 24.1.0, error occurs:
```Python
Python 3.10.13 (main, Dec  6 2023, 12:01:00) [GCC 13.2.1 20230801] on linux
>>> import attr
>>> attr.__version__
'24.2.0'
>>> @attr.define
... class A:
...     abc: int | None = attr.field(converter=attr.converters.optional(attr.converters.pipe(str, int)), default=None)
...     
>>> A()
A(abc=None)
>>> A('1')
Traceback (most recent call last):
  File "/opt/pycharm-professional/plugins/python-ce/helpers/pydev/pydevconsole.py", line 364, in runcode
    coro = func()
  File "<input>", line 1, in <module>
  File "<attrs generated init __main__.A>", line 3, in __init__
  File "/home/mio/Work/venv/calliper310N/lib/python3.10/site-packages/attr/converters.py", line 40, in optional_converter
    return converter(val)
TypeError: Converter.__init__.<locals>.<lambda>() missing 2 required positional arguments: 'instance' and 'field'

```


Discussion:
Also seeing this issue as I'm trying to upgrade to attrs 24, is there a fix or workaround for it?



Patch:
diff --git a/changelog.d/1372.change.md b/changelog.d/1372.change.md
new file mode 100644
index 000000000..fcb94345b
--- /dev/null
+++ b/changelog.d/1372.change.md
@@ -0,0 +1,1 @@
+`attrs.converters.optional()` works again when taking `attrs.converters.pipe()` or another Converter as its argument.
diff --git a/src/attr/converters.py b/src/attr/converters.py
index cac62dda7..0a79deef0 100644
--- a/src/attr/converters.py
+++ b/src/attr/converters.py
@@ -7,7 +7,7 @@
 import typing
 
 from ._compat import _AnnotationExtractor
-from ._make import NOTHING, Factory, pipe
+from ._make import NOTHING, Converter, Factory, pipe
 
 
 __all__ = [
@@ -33,10 +33,19 @@ def optional(converter):
     .. versionadded:: 17.1.0
     """
 
-    def optional_converter(val):
-        if val is None:
-            return None
-        return converter(val)
+    if isinstance(converter, Converter):
+
+        def optional_converter(val, inst, field):
+            if val is None:
+                return None
+            return converter(val, inst, field)
+
+    else:
+
+        def optional_converter(val):
+            if val is None:
+                return None
+            return converter(val)
 
     xtr = _AnnotationExtractor(converter)
 
@@ -48,6 +57,9 @@ def optional_converter(val):
     if rt:
         optional_converter.__annotations__["return"] = typing.Optional[rt]
 
+    if isinstance(converter, Converter):
+        return Converter(optional_converter, takes_self=True, takes_field=True)
+
     return optional_converter
 
 


Test patch:
diff --git a/tests/test_converters.py b/tests/test_converters.py
index 164fb0ffa..d4aca4307 100644
--- a/tests/test_converters.py
+++ b/tests/test_converters.py
@@ -143,6 +143,14 @@ def test_fail(self):
         with pytest.raises(ValueError):
             c("not_an_int")
 
+    def test_converter_instance(self):
+        """
+        Works when passed a Converter instance as argument.
+        """
+        c = optional(Converter(to_bool))
+
+        assert True is c("yes", None, None)
+
 
 class TestDefaultIfNone:
     def test_missing_default(self):
@@ -272,6 +280,48 @@ class C:
         )
 
 
+class TestOptionalPipe:
+    def test_optional(self):
+        """
+        Nothing happens if None.
+        """
+        c = optional(pipe(str, Converter(to_bool), bool))
+
+        assert None is c.converter(None, None, None)
+
+    def test_pipe(self):
+        """
+        A value is given, run it through all wrapped converters.
+        """
+        c = optional(pipe(str, Converter(to_bool), bool))
+
+        assert (
+            True
+            is c.converter("True", None, None)
+            is c.converter(True, None, None)
+        )
+
+    def test_instance(self):
+        """
+        Should work when set as an attrib.
+        """
+
+        @attr.s
+        class C:
+            x = attrib(
+                converter=optional(pipe(str, Converter(to_bool), bool)),
+                default=None,
+            )
+
+        c1 = C()
+
+        assert None is c1.x
+
+        c2 = C("True")
+
+        assert True is c2.x
+
+
 class TestToBool:
     def test_unhashable(self):
         """