Repository: python-attrs/attrs

Problem statement:
Allow three-argument converters (like validators/on_setattr)
I'd like to move the discussion from [converter decorator PR](https://github.com/python-attrs/attrs/pull/404) to this issue.

I think converters are semantically closer to `on_setattr` and `validator` than `default`. E.g. `attr.ib(converter=...)` allows you to pass a list of callables and pipes them automatically - exactly like `attr.ib(validator=...)` and `attr.ib(on_setattr=...)` and there is `attr.converters` module, like `attr.setters` and `attr.validators`. 

If we allow passing half-initialized self to converter, why don't allow full-form converters, `converter(self, attr, value)` to make them the same as `on_setattr`, but for initialization? 

To support one, two and three-argument converters there should be either inspect-magic (in py2 - getargspec, in py3 - signature) or mandatory `Converter` wrapper for two and three-argument converters. 


Hints:
Maybe, converters and validators can (or event should) be merged (similarly to [click callbacks](https://click.palletsprojects.com/en/7.x/options/#callbacks-for-validation))?

```python
def int_validator(self, attrib, value):
    return int(value)  # Validates and converts at the same time :-)
```

I guess that would be very backwards incompatible, but maybe this can (still) be added to the new `attrs` API?
> To support one, two and three-argument converters there should be either inspect-magic (in py2 - getargspec, in py3 - signature) or mandatory `Converter` wrapper for two and three-argument converters.

This is something I've been thinking about in the past days. I _think_ a marker-wrapper for three-argument converters would be most solid and in line with our other APIs?
There's also #146  which still bothers me that it's unresolved but to which I still don't have a good answer, 3.5 years later. :(
Thank you for the discussion! 

@sscherfke I think this is kind of thing that happened to `on_setattr` already. The only big difference here is that converters should work with half-initialized self, but `@default` shows that it can be tackled by users (and #404 shows that some of them are actually craving that possibility in a converter). 

Personally I like separate validators, as they are running after all the conversions (hence - guaranteed to work with fully-initialized self!) and imply that the field value doesn't change (unless some reckless guy will do  `object.__setattr__(self, attr.name, new_value)` inside, but it seems like a dirty hack and wouldn't pass any code-review. Or at least I hope so...)

@hynek #146 is pretty informative, thank you for mentioning it! :) I actually missed the stacktrace point when I was thinking about it (and maybe some performance issues, as there is a level of indirection here...). Though, I think that having `on_setattr` on board actually introduces some `common` API for a pipeline here. 

I do like the marker-wrapper idea (e.g. `Converter(fun, takes_self=True, takes_attr=True)`), though it may be a bit unexpected for a newcomer that `attr.ib(converter=c)` expects one-argument function or wrapper, while `attr.ib(validator=v, on_seattr=s)` expect three-argument function (and no wrappers provided). But - considering possible issues with half-initialized `self` - it may be a good practice, actually. 

UPD: Just saw the comment in the PR. Actually, if `converter` is kind of shortcut to something like `on_init` - it sounds like a great way to get away from this unexpected behavior :)
Any update to this issue ? 

Patch:
diff --git a/src/attr/__init__.py b/src/attr/__init__.py
--- a/src/attr/__init__.py
+++ b/src/attr/__init__.py
@@ -15,6 +15,7 @@
 from ._make import (
     NOTHING,
     Attribute,
+    Converter,
     Factory,
     attrib,
     attrs,
@@ -39,6 +40,7 @@ class AttrsInstance(Protocol):
 __all__ = [
     "Attribute",
     "AttrsInstance",
+    "Converter",
     "Factory",
     "NOTHING",
     "asdict",
diff --git a/src/attr/_make.py b/src/attr/_make.py
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -1,5 +1,7 @@
 # SPDX-License-Identifier: MIT
 
+from __future__ import annotations
+
 import contextlib
 import copy
 import enum
@@ -32,7 +34,6 @@
 
 # This is used at least twice, so cache it here.
 _OBJ_SETATTR = object.__setattr__
-_INIT_CONVERTER_PAT = "__attr_converter_%s"
 _INIT_FACTORY_PAT = "__attr_factory_%s"
 _CLASSVAR_PREFIXES = (
     "typing.ClassVar",
@@ -213,11 +214,17 @@ def attrib(
 
             .. seealso:: `init`
 
-        converter (typing.Callable(): `callable` that is called by
-            *attrs*-generated ``__init__`` methods to convert attribute's value
-            to the desired format.  It is given the passed-in value, and the
-            returned value will be used as the new value of the attribute.  The
-            value is converted before being passed to the validator, if any.
+        converter (typing.Callable | Converter):
+            `callable` that is called by *attrs*-generated ``__init__`` methods
+            to convert attribute's value to the desired format.
+
+            If a vanilla callable is passed, it is given the passed-in value as
+            the only positional argument. It is possible to receive additional
+            arguments by wrapping the callable in a `Converter`.
+
+            Either way, the returned value will be used as the new value of the
+            attribute.  The value is converted before being passed to the
+            validator, if any.
 
             .. seealso:: :ref:`converters`
 
@@ -2239,7 +2246,7 @@ def _make_init(
         is_exc,
         needs_cached_setattr,
         has_cls_on_setattr,
-        attrs_init,
+        "__attrs_init__" if attrs_init else "__init__",
     )
     if cls.__module__ in sys.modules:
         # This makes typing.get_type_hints(CLS.__init__) resolve string types.
@@ -2263,26 +2270,24 @@ def _make_init(
     return init
 
 
-def _setattr(attr_name, value_var, has_on_setattr):
+def _setattr(attr_name: str, value_var: str, has_on_setattr: bool) -> str:
     """
     Use the cached object.setattr to set *attr_name* to *value_var*.
     """
     return f"_setattr('{attr_name}', {value_var})"
 
 
-def _setattr_with_converter(attr_name, value_var, has_on_setattr):
+def _setattr_with_converter(
+    attr_name: str, value_var: str, has_on_setattr: bool, converter: Converter
+) -> str:
     """
     Use the cached object.setattr to set *attr_name* to *value_var*, but run
     its converter first.
     """
-    return "_setattr('%s', %s(%s))" % (
-        attr_name,
-        _INIT_CONVERTER_PAT % (attr_name,),
-        value_var,
-    )
+    return f"_setattr('{attr_name}', {converter._fmt_converter_call(attr_name, value_var)})"
 
 
-def _assign(attr_name, value, has_on_setattr):
+def _assign(attr_name: str, value: str, has_on_setattr: bool) -> str:
     """
     Unless *attr_name* has an on_setattr hook, use normal assignment. Otherwise
     relegate to _setattr.
@@ -2293,22 +2298,22 @@ def _assign(attr_name, value, has_on_setattr):
     return f"self.{attr_name} = {value}"
 
 
-def _assign_with_converter(attr_name, value_var, has_on_setattr):
+def _assign_with_converter(
+    attr_name: str, value_var: str, has_on_setattr: bool, converter: Converter
+) -> str:
     """
     Unless *attr_name* has an on_setattr hook, use normal assignment after
     conversion. Otherwise relegate to _setattr_with_converter.
     """
     if has_on_setattr:
-        return _setattr_with_converter(attr_name, value_var, True)
+        return _setattr_with_converter(attr_name, value_var, True, converter)
 
-    return "self.%s = %s(%s)" % (
-        attr_name,
-        _INIT_CONVERTER_PAT % (attr_name,),
-        value_var,
-    )
+    return f"self.{attr_name} = {converter._fmt_converter_call(attr_name, value_var)}"
 
 
-def _determine_setters(frozen, slots, base_attr_map):
+def _determine_setters(
+    frozen: bool, slots: bool, base_attr_map: dict[str, type]
+):
     """
     Determine the correct setter functions based on whether a class is frozen
     and/or slotted.
@@ -2322,23 +2327,26 @@ def _determine_setters(frozen, slots, base_attr_map):
         # class.
         # Note _inst_dict will be used again below if cache_hash is True
 
-        def fmt_setter(attr_name, value_var, has_on_setattr):
+        def fmt_setter(
+            attr_name: str, value_var: str, has_on_setattr: bool
+        ) -> str:
             if _is_slot_attr(attr_name, base_attr_map):
                 return _setattr(attr_name, value_var, has_on_setattr)
 
             return f"_inst_dict['{attr_name}'] = {value_var}"
 
-        def fmt_setter_with_converter(attr_name, value_var, has_on_setattr):
+        def fmt_setter_with_converter(
+            attr_name: str,
+            value_var: str,
+            has_on_setattr: bool,
+            converter: Converter,
+        ) -> str:
             if has_on_setattr or _is_slot_attr(attr_name, base_attr_map):
                 return _setattr_with_converter(
-                    attr_name, value_var, has_on_setattr
+                    attr_name, value_var, has_on_setattr, converter
                 )
 
-            return "_inst_dict['%s'] = %s(%s)" % (
-                attr_name,
-                _INIT_CONVERTER_PAT % (attr_name,),
-                value_var,
-            )
+            return f"_inst_dict['{attr_name}'] = {converter._fmt_converter_call(attr_name, value_var)}"
 
         return (
             ("_inst_dict = self.__dict__",),
@@ -2351,39 +2359,37 @@ def fmt_setter_with_converter(attr_name, value_var, has_on_setattr):
 
 
 def _attrs_to_init_script(
-    attrs,
-    frozen,
-    slots,
-    pre_init,
-    pre_init_has_args,
-    post_init,
-    cache_hash,
-    base_attr_map,
-    is_exc,
-    needs_cached_setattr,
-    has_cls_on_setattr,
-    attrs_init,
-):
-    """
-    Return a script of an initializer for *attrs* and a dict of globals.
-
-    The globals are expected by the generated script.
-
-    If *frozen* is True, we cannot set the attributes directly so we use
-    a cached ``object.__setattr__``.
-    """
-    lines = ["self.__attrs_pre_init__()"] if pre_init else []
+    attrs: list[Attribute],
+    is_frozen: bool,
+    is_slotted: bool,
+    call_pre_init: bool,
+    pre_init_has_args: bool,
+    call_post_init: bool,
+    does_cache_hash: bool,
+    base_attr_map: dict[str, type],
+    is_exc: bool,
+    needs_cached_setattr: bool,
+    has_cls_on_setattr: bool,
+    method_name: str,
+) -> tuple[str, dict, dict]:
+    """
+    Return a script of an initializer for *attrs*, a dict of globals, and
+    annotations for the initializer.
+
+    The globals are required by the generated script.
+    """
+    lines = ["self.__attrs_pre_init__()"] if call_pre_init else []
 
     if needs_cached_setattr:
         lines.append(
             # Circumvent the __setattr__ descriptor to save one lookup per
-            # assignment.
-            # Note _setattr will be used again below if cache_hash is True
+            # assignment. Note _setattr will be used again below if
+            # does_cache_hash is True.
             "_setattr = _cached_setattr_get(self)"
         )
 
     extra_lines, fmt_setter, fmt_setter_with_converter = _determine_setters(
-        frozen, slots, base_attr_map
+        is_frozen, is_slotted, base_attr_map
     )
     lines.extend(extra_lines)
 
@@ -2411,19 +2417,25 @@ def _attrs_to_init_script(
         has_factory = isinstance(a.default, Factory)
         maybe_self = "self" if has_factory and a.default.takes_self else ""
 
+        if a.converter and not isinstance(a.converter, Converter):
+            converter = Converter(a.converter)
+        else:
+            converter = a.converter
+
         if a.init is False:
             if has_factory:
                 init_factory_name = _INIT_FACTORY_PAT % (a.name,)
-                if a.converter is not None:
+                if converter is not None:
                     lines.append(
                         fmt_setter_with_converter(
                             attr_name,
                             init_factory_name + f"({maybe_self})",
                             has_on_setattr,
+                            converter,
                         )
                     )
-                    names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
-                        a.converter
+                    names_for_globals[converter._get_global_name(a.name)] = (
+                        converter.converter
                     )
                 else:
                     lines.append(
@@ -2434,16 +2446,17 @@ def _attrs_to_init_script(
                         )
                     )
                 names_for_globals[init_factory_name] = a.default.factory
-            elif a.converter is not None:
+            elif converter is not None:
                 lines.append(
                     fmt_setter_with_converter(
                         attr_name,
                         f"attr_dict['{attr_name}'].default",
                         has_on_setattr,
+                        converter,
                     )
                 )
-                names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
-                    a.converter
+                names_for_globals[converter._get_global_name(a.name)] = (
+                    converter.converter
                 )
             else:
                 lines.append(
@@ -2460,14 +2473,14 @@ def _attrs_to_init_script(
             else:
                 args.append(arg)
 
-            if a.converter is not None:
+            if converter is not None:
                 lines.append(
                     fmt_setter_with_converter(
-                        attr_name, arg_name, has_on_setattr
+                        attr_name, arg_name, has_on_setattr, converter
                     )
                 )
-                names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
-                    a.converter
+                names_for_globals[converter._get_global_name(a.name)] = (
+                    converter.converter
                 )
             else:
                 lines.append(fmt_setter(attr_name, arg_name, has_on_setattr))
@@ -2481,11 +2494,11 @@ def _attrs_to_init_script(
             lines.append(f"if {arg_name} is not NOTHING:")
 
             init_factory_name = _INIT_FACTORY_PAT % (a.name,)
-            if a.converter is not None:
+            if converter is not None:
                 lines.append(
                     "    "
                     + fmt_setter_with_converter(
-                        attr_name, arg_name, has_on_setattr
+                        attr_name, arg_name, has_on_setattr, converter
                     )
                 )
                 lines.append("else:")
@@ -2495,10 +2508,11 @@ def _attrs_to_init_script(
                         attr_name,
                         init_factory_name + "(" + maybe_self + ")",
                         has_on_setattr,
+                        converter,
                     )
                 )
-                names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
-                    a.converter
+                names_for_globals[converter._get_global_name(a.name)] = (
+                    converter.converter
                 )
             else:
                 lines.append(
@@ -2520,26 +2534,24 @@ def _attrs_to_init_script(
             else:
                 args.append(arg_name)
 
-            if a.converter is not None:
+            if converter is not None:
                 lines.append(
                     fmt_setter_with_converter(
-                        attr_name, arg_name, has_on_setattr
+                        attr_name, arg_name, has_on_setattr, converter
                     )
                 )
-                names_for_globals[_INIT_CONVERTER_PAT % (a.name,)] = (
-                    a.converter
+                names_for_globals[converter._get_global_name(a.name)] = (
+                    converter.converter
                 )
             else:
                 lines.append(fmt_setter(attr_name, arg_name, has_on_setattr))
 
         if a.init is True:
-            if a.type is not None and a.converter is None:
+            if a.type is not None and converter is None:
                 annotations[arg_name] = a.type
-            elif a.converter is not None:
-                # Try to get the type from the converter.
-                t = _AnnotationExtractor(a.converter).get_first_param_type()
-                if t:
-                    annotations[arg_name] = t
+            elif converter is not None and converter._first_param_type:
+                # Use the type from the converter if present.
+                annotations[arg_name] = converter._first_param_type
 
     if attrs_to_validate:  # we can skip this if there are no validators.
         names_for_globals["_config"] = _config
@@ -2551,25 +2563,23 @@ def _attrs_to_init_script(
             names_for_globals[val_name] = a.validator
             names_for_globals[attr_name] = a
 
-    if post_init:
+    if call_post_init:
         lines.append("self.__attrs_post_init__()")
 
-    # because this is set only after __attrs_post_init__ is called, a crash
+    # Because this is set only after __attrs_post_init__ is called, a crash
     # will result if post-init tries to access the hash code.  This seemed
-    # preferable to setting this beforehand, in which case alteration to
-    # field values during post-init combined with post-init accessing the
-    # hash code would result in silent bugs.
-    if cache_hash:
-        if frozen:
-            if slots:  # noqa: SIM108
-                # if frozen and slots, then _setattr defined above
-                init_hash_cache = "_setattr('%s', %s)"
+    # preferable to setting this beforehand, in which case alteration to field
+    # values during post-init combined with post-init accessing the hash code
+    # would result in silent bugs.
+    if does_cache_hash:
+        if is_frozen:
+            if is_slotted:
+                init_hash_cache = f"_setattr('{_HASH_CACHE_FIELD}', None)"
             else:
-                # if frozen and not slots, then _inst_dict defined above
-                init_hash_cache = "_inst_dict['%s'] = %s"
+                init_hash_cache = f"_inst_dict['{_HASH_CACHE_FIELD}'] = None"
         else:
-            init_hash_cache = "self.%s = %s"
-        lines.append(init_hash_cache % (_HASH_CACHE_FIELD, "None"))
+            init_hash_cache = f"self.{_HASH_CACHE_FIELD} = None"
+        lines.append(init_hash_cache)
 
     # For exceptions we rely on BaseException.__init__ for proper
     # initialization.
@@ -2593,14 +2603,13 @@ def _attrs_to_init_script(
         )  # handle only kwargs and no regular args
         pre_init_args += pre_init_kw_only_args
 
-    if pre_init and pre_init_has_args:
+    if call_pre_init and pre_init_has_args:
         # If pre init method has arguments, pass same arguments as `__init__`
-        lines[0] = "self.__attrs_pre_init__(%s)" % pre_init_args
+        lines[0] = f"self.__attrs_pre_init__({pre_init_args})"
 
     return (
-        "def %s(self, %s):\n    %s\n"
+        f"def {method_name}(self, %s):\n    %s\n"
         % (
-            ("__attrs_init__" if attrs_init else "__init__"),
             args,
             "\n    ".join(lines) if lines else "pass",
         ),
@@ -3056,6 +3065,109 @@ def __setstate__(self, state):
 Factory = _add_hash(_add_eq(_add_repr(Factory, attrs=_f), attrs=_f), attrs=_f)
 
 
+class Converter:
+    """
+    Stores a converter callable.
+
+    Allows for the wrapped converter to take additional arguments. The
+    arguments are passed in the order they are documented.
+
+    Args:
+        converter (Callable): A callable that converts the passed value.
+
+        takes_self (bool):
+            Pass the partially initialized instance that is being initialized
+            as a positional argument. (default: `False`)
+
+        takes_field (bool):
+            Pass the field definition (an `Attribute`) into the converter as a
+            positional argument. (default: `False`)
+
+    .. versionadded:: 24.1.0
+    """
+
+    __slots__ = (
+        "converter",
+        "takes_self",
+        "takes_field",
+        "_first_param_type",
+        "_global_name",
+        "__call__",
+    )
+
+    def __init__(self, converter, *, takes_self=False, takes_field=False):
+        self.converter = converter
+        self.takes_self = takes_self
+        self.takes_field = takes_field
+
+        self._first_param_type = _AnnotationExtractor(
+            converter
+        ).get_first_param_type()
+
+    @staticmethod
+    def _get_global_name(attr_name: str) -> str:
+        """
+        Return the name that a converter for an attribute name *attr_name*
+        would have.
+        """
+        return f"__attr_converter_{attr_name}"
+
+    def _fmt_converter_call(self, attr_name: str, value_var: str) -> str:
+        """
+        Return a string that calls the converter for an attribute name
+        *attr_name* and the value in variable named *value_var* according to
+        `self.takes_self` and `self.takes_field`.
+        """
+        if not (self.takes_self or self.takes_field):
+            return f"{self._get_global_name(attr_name)}({value_var})"
+
+        if self.takes_self and self.takes_field:
+            return f"{self._get_global_name(attr_name)}({value_var}, self, attr_dict['{attr_name}'])"
+
+        if self.takes_self:
+            return f"{self._get_global_name(attr_name)}({value_var}, self)"
+
+        return f"{self._get_global_name(attr_name)}({value_var}, attr_dict['{attr_name}'])"
+
+    def __getstate__(self):
+        """
+        Return a dict containing only converter and takes_self -- the rest gets
+        computed when loading.
+        """
+        return {
+            "converter": self.converter,
+            "takes_self": self.takes_self,
+            "takes_field": self.takes_field,
+        }
+
+    def __setstate__(self, state):
+        """
+        Load instance from state.
+        """
+        self.__init__(**state)
+
+
+_f = [
+    Attribute(
+        name=name,
+        default=NOTHING,
+        validator=None,
+        repr=True,
+        cmp=None,
+        eq=True,
+        order=False,
+        hash=True,
+        init=True,
+        inherited=False,
+    )
+    for name in ("converter", "takes_self", "takes_field")
+]
+
+Converter = _add_hash(
+    _add_eq(_add_repr(Converter, attrs=_f), attrs=_f), attrs=_f
+)
+
+
 def make_class(
     name, attrs, bases=(object,), class_body=None, **attributes_arguments
 ):
@@ -3196,16 +3308,27 @@ def pipe(*converters):
     .. versionadded:: 20.1.0
     """
 
-    def pipe_converter(val):
-        for converter in converters:
-            val = converter(val)
+    def pipe_converter(val, inst, field):
+        for c in converters:
+            if isinstance(c, Converter):
+                val = c.converter(
+                    val,
+                    *{
+                        (False, False): (),
+                        (True, False): (c.takes_self,),
+                        (False, True): (c.takes_field,),
+                        (True, True): (c.takes_self, c.takes_field),
+                    }[c.takes_self, c.takes_field],
+                )
+            else:
+                val = c(val)
 
         return val
 
     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}
+        pipe_converter.__annotations__.update({"val": A, "return": A})
     else:
         # Get parameter type from first converter.
         t = _AnnotationExtractor(converters[0]).get_first_param_type()
@@ -3217,4 +3340,4 @@ def pipe_converter(val):
         if rt:
             pipe_converter.__annotations__["return"] = rt
 
-    return pipe_converter
+    return Converter(pipe_converter, takes_self=True, takes_field=True)
diff --git a/src/attrs/__init__.py b/src/attrs/__init__.py
--- a/src/attrs/__init__.py
+++ b/src/attrs/__init__.py
@@ -4,6 +4,7 @@
     NOTHING,
     Attribute,
     AttrsInstance,
+    Converter,
     Factory,
     _make_getattr,
     assoc,
@@ -42,6 +43,7 @@
     "Attribute",
     "AttrsInstance",
     "cmp_using",
+    "Converter",
     "converters",
     "define",
     "evolve",


Test patch:
diff --git a/tests/test_annotations.py b/tests/test_annotations.py
--- a/tests/test_annotations.py
+++ b/tests/test_annotations.py
@@ -277,25 +277,27 @@ def strlen(y: str) -> int:
         def identity(z):
             return z
 
-        assert attr.converters.pipe(int2str).__annotations__ == {
+        assert attr.converters.pipe(int2str).converter.__annotations__ == {
             "val": int,
             "return": str,
         }
-        assert attr.converters.pipe(int2str, strlen).__annotations__ == {
+        assert attr.converters.pipe(
+            int2str, strlen
+        ).converter.__annotations__ == {
             "val": int,
             "return": int,
         }
-        assert attr.converters.pipe(identity, strlen).__annotations__ == {
-            "return": int
-        }
-        assert attr.converters.pipe(int2str, identity).__annotations__ == {
-            "val": int
-        }
+        assert attr.converters.pipe(
+            identity, strlen
+        ).converter.__annotations__ == {"return": int}
+        assert attr.converters.pipe(
+            int2str, identity
+        ).converter.__annotations__ == {"val": int}
 
         def int2str_(x: int, y: int = 0) -> str:
             return str(x)
 
-        assert attr.converters.pipe(int2str_).__annotations__ == {
+        assert attr.converters.pipe(int2str_).converter.__annotations__ == {
             "val": int,
             "return": str,
         }
@@ -306,17 +308,20 @@ def test_pipe_empty(self):
         """
 
         p = attr.converters.pipe()
-        assert "val" in p.__annotations__
-        t = p.__annotations__["val"]
+
+        assert "val" in p.converter.__annotations__
+
+        t = p.converter.__annotations__["val"]
+
         assert isinstance(t, typing.TypeVar)
-        assert p.__annotations__ == {"val": t, "return": t}
+        assert p.converter.__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__ == {}
+        assert attr.converters.pipe(print).converter.__annotations__ == {}
 
     def test_pipe_nullary(self):
         """
@@ -326,7 +331,7 @@ def test_pipe_nullary(self):
         def noop():
             pass
 
-        assert attr.converters.pipe(noop).__annotations__ == {}
+        assert attr.converters.pipe(noop).converter.__annotations__ == {}
 
     def test_optional(self):
         """
diff --git a/tests/test_converters.py b/tests/test_converters.py
--- a/tests/test_converters.py
+++ b/tests/test_converters.py
@@ -4,15 +4,61 @@
 Tests for `attr.converters`.
 """
 
+import pickle
 
 import pytest
 
 import attr
 
-from attr import Factory, attrib
+from attr import Converter, Factory, attrib
 from attr.converters import default_if_none, optional, pipe, to_bool
 
 
+class TestConverter:
+    @pytest.mark.parametrize("takes_self", [True, False])
+    @pytest.mark.parametrize("takes_field", [True, False])
+    def test_pickle(self, takes_self, takes_field):
+        """
+        Wrapped converters can be pickled.
+        """
+        c = Converter(int, takes_self=takes_self, takes_field=takes_field)
+
+        new_c = pickle.loads(pickle.dumps(c))
+
+        assert c == new_c
+        assert takes_self == new_c.takes_self
+        assert takes_field == new_c.takes_field
+
+    @pytest.mark.parametrize(
+        "scenario",
+        [
+            ((False, False), "__attr_converter_le_name(le_value)"),
+            (
+                (True, True),
+                "__attr_converter_le_name(le_value, self, attr_dict['le_name'])",
+            ),
+            (
+                (True, False),
+                "__attr_converter_le_name(le_value, self)",
+            ),
+            (
+                (False, True),
+                "__attr_converter_le_name(le_value, attr_dict['le_name'])",
+            ),
+        ],
+    )
+    def test_fmt_converter_call(self, scenario):
+        """
+        _fmt_converter_call determines the arguments to the wrapped converter
+        according to `takes_self` and `takes_field`.
+        """
+        (takes_self, takes_field), expect = scenario
+
+        c = Converter(None, takes_self=takes_self, takes_field=takes_field)
+
+        assert expect == c._fmt_converter_call("le_name", "le_value")
+
+
 class TestOptional:
     """
     Tests for `optional`.
@@ -105,9 +151,13 @@ def test_success(self):
         """
         Succeeds if all wrapped converters succeed.
         """
-        c = pipe(str, to_bool, bool)
+        c = pipe(str, Converter(to_bool), bool)
 
-        assert True is c("True") is c(True)
+        assert (
+            True
+            is c.converter("True", None, None)
+            is c.converter(True, None, None)
+        )
 
     def test_fail(self):
         """
@@ -117,11 +167,11 @@ def test_fail(self):
 
         # First wrapped converter fails:
         with pytest.raises(ValueError):
-            c(33)
+            c.converter(33, None, None)
 
         # Last wrapped converter fails:
         with pytest.raises(ValueError):
-            c("33")
+            c.converter("33", None, None)
 
     def test_sugar(self):
         """
@@ -142,7 +192,7 @@ def test_empty(self):
         """
         o = object()
 
-        assert o is pipe()(o)
+        assert o is pipe().converter(o, None, None)
 
 
 class TestToBool:
diff --git a/tests/test_make.py b/tests/test_make.py
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -1312,7 +1312,7 @@ class TestConverter:
     Tests for attribute conversion.
     """
 
-    def test_convert(self):
+    def test_converter(self):
         """
         Return value of converter is used as the attribute's value.
         """
@@ -1324,6 +1324,46 @@ def test_convert(self):
         assert c.x == 2
         assert c.y == 2
 
+    def test_converter_wrapped_takes_self(self):
+        """
+        When wrapped and passed `takes_self`, the converter receives the
+        instance that's being initializes -- and the return value is used as
+        the field's value.
+        """
+
+        def converter_with_self(v, self_):
+            return v * self_.y
+
+        @attr.define
+        class C:
+            x: int = attr.field(
+                converter=attr.Converter(converter_with_self, takes_self=True)
+            )
+            y = 42
+
+        assert 84 == C(2).x
+
+    def test_converter_wrapped_takes_field(self):
+        """
+        When wrapped and passed `takes_field`, the converter receives the field
+        definition -- and the return value is used as the field's value.
+        """
+
+        def converter_with_field(v, field):
+            assert isinstance(field, attr.Attribute)
+            return v * field.metadata["x"]
+
+        @attr.define
+        class C:
+            x: int = attr.field(
+                converter=attr.Converter(
+                    converter_with_field, takes_field=True
+                ),
+                metadata={"x": 42},
+            )
+
+        assert 84 == C(2).x
+
     @given(integers(), booleans())
     def test_convert_property(self, val, init):
         """
diff --git a/tests/test_mypy.yml b/tests/test_mypy.yml
--- a/tests/test_mypy.yml
+++ b/tests/test_mypy.yml
@@ -767,7 +767,7 @@
             return 'hello'
 
 - case: testAttrsUsingBadConverter
-  regex: true
+  skip: sys.version_info[:2] < (3, 10)
   main: |
     import attr
     from typing import overload
@@ -787,14 +787,14 @@
         bad_overloaded: int = attr.ib(converter=bad_overloaded_converter)
     reveal_type(A)
   out: |
-    main:15: error: Cannot determine __init__ type from converter  \[misc\]
-    main:15: error: Argument "converter" has incompatible type \"Callable\[\[\], str\]\"; expected (\"Callable\[\[Any\], Any\] \| None\"|\"Optional\[Callable\[\[Any\], Any\]\]\")  \[arg-type\]
-    main:16: error: Cannot determine __init__ type from converter  \[misc\]
-    main:16: error: Argument "converter" has incompatible type overloaded function; expected (\"Callable\[\[Any\], Any\] \| None\"|\"Optional\[Callable\[\[Any\], Any\]\]\")  \[arg-type\]
-    main:17: note: Revealed type is "def (bad: Any, bad_overloaded: Any\) -> main.A"
+    main:15: error: Cannot determine __init__ type from converter  [misc]
+    main:15: error: Argument "converter" has incompatible type "Callable[[], str]"; expected "Callable[[Any], Any] | Converter[Any, Never] | None"  [arg-type]
+    main:16: error: Cannot determine __init__ type from converter  [misc]
+    main:16: error: Argument "converter" has incompatible type overloaded function; expected "Callable[[Any], Any] | Converter[Any, Never] | None"  [arg-type]
+    main:17: note: Revealed type is "def (bad: Any, bad_overloaded: Any) -> main.A"
 
 - case: testAttrsUsingBadConverterReprocess
-  regex: true
+  skip: sys.version_info[:2] < (3, 10)
   main: |
     import attr
     from typing import overload
@@ -815,11 +815,11 @@
         bad_overloaded: int = attr.ib(converter=bad_overloaded_converter)
     reveal_type(A)
   out: |
-    main:16: error: Cannot determine __init__ type from converter  \[misc\]
-    main:16: error: Argument \"converter\" has incompatible type \"Callable\[\[\], str\]\"; expected (\"Callable\[\[Any\], Any\] \| None\"|\"Optional\[Callable\[\[Any\], Any\]\]\")  \[arg-type\]
-    main:17: error: Cannot determine __init__ type from converter  \[misc\]
-    main:17: error: Argument "converter" has incompatible type overloaded function; expected (\"Callable\[\[Any\], Any\] \| None\"|\"Optional\[Callable\[\[Any\], Any\]\]\")  \[arg-type\]
-    main:18: note: Revealed type is "def (bad: Any, bad_overloaded: Any\) -> main.A"
+    main:16: error: Cannot determine __init__ type from converter  [misc]
+    main:16: error: Argument "converter" has incompatible type "Callable[[], str]"; expected "Callable[[Any], Any] | Converter[Any, Never] | None"  [arg-type]
+    main:17: error: Cannot determine __init__ type from converter  [misc]
+    main:17: error: Argument "converter" has incompatible type overloaded function; expected "Callable[[Any], Any] | Converter[Any, Never] | None"  [arg-type]
+    main:18: note: Revealed type is "def (bad: Any, bad_overloaded: Any) -> main.A"
 
 - case: testAttrsUsingUnsupportedConverter
   main: |
@@ -874,6 +874,66 @@
     o = C("1", "2", "3")
     o = C(1, 2, "3")
 
+- case: testThreeArgConverterTypes
+  main: |
+    from typing import Any
+    from attrs import AttrsInstance, Attribute, Converter
+
+    def my_converter(value: Any) -> str:
+        """A converter that only takes the value."""
+        return str(value)
+
+    def my_converter_with_self(value: Any, self: AttrsInstance) -> str:
+        """This converter takes the value and the self."""
+        return str(value)
+
+
+    def my_converter_with_field(value: Any, field: Attribute) -> str:
+        """This converter takes the value and the field."""
+        return str(value)
+
+    reveal_type(Converter(my_converter))
+    Converter(my_converter_with_self)
+    Converter(my_converter_with_field)
+
+    reveal_type(Converter(my_converter_with_self, takes_self=True))
+    Converter(my_converter, takes_self=True)
+    Converter(my_converter_with_field, takes_self=True)
+
+    reveal_type(Converter(my_converter_with_field, takes_field=True))
+    Converter(my_converter, takes_field=True)
+    Converter(my_converter_with_self, takes_field=True)
+  out: |
+    main:17: note: Revealed type is "attr.Converter[Any, builtins.str]"
+    main:18: error: Argument 1 to "Converter" has incompatible type "Callable[[Any, AttrsInstance], str]"; expected "Callable[[Any], str]"  [arg-type]
+    main:19: error: Argument 1 to "Converter" has incompatible type "Callable[[Any, Attribute[Any]], str]"; expected "Callable[[Any], str]"  [arg-type]
+    main:21: note: Revealed type is "attr.Converter[Any, builtins.str]"
+    main:22: error: No overload variant of "Converter" matches argument types "Callable[[Any], str]", "bool"  [call-overload]
+    main:22: note: Possible overload variants:
+    main:22: note:     def [In, Out] Converter(self, converter: Callable[[In], Out]) -> Converter[In, Out]
+    main:22: note:     def [In, Out] Converter(self, converter: Callable[[In, AttrsInstance, Attribute[Any]], Out], *, takes_self: Literal[True], takes_field: Literal[True]) -> Converter[In, Out]
+    main:22: note:     def [In, Out] Converter(self, converter: Callable[[In, Attribute[Any]], Out], *, takes_field: Literal[True]) -> Converter[In, Out]
+    main:22: note:     def [In, Out] Converter(self, converter: Callable[[In, AttrsInstance], Out], *, takes_self: Literal[True]) -> Converter[In, Out]
+    main:23: error: No overload variant of "Converter" matches argument types "Callable[[Any, Attribute[Any]], str]", "bool"  [call-overload]
+    main:23: note: Possible overload variants:
+    main:23: note:     def [In, Out] Converter(self, converter: Callable[[In], Out]) -> Converter[In, Out]
+    main:23: note:     def [In, Out] Converter(self, converter: Callable[[In, AttrsInstance, Attribute[Any]], Out], *, takes_self: Literal[True], takes_field: Literal[True]) -> Converter[In, Out]
+    main:23: note:     def [In, Out] Converter(self, converter: Callable[[In, Attribute[Any]], Out], *, takes_field: Literal[True]) -> Converter[In, Out]
+    main:23: note:     def [In, Out] Converter(self, converter: Callable[[In, AttrsInstance], Out], *, takes_self: Literal[True]) -> Converter[In, Out]
+    main:25: note: Revealed type is "attr.Converter[Any, builtins.str]"
+    main:26: error: No overload variant of "Converter" matches argument types "Callable[[Any], str]", "bool"  [call-overload]
+    main:26: note: Possible overload variants:
+    main:26: note:     def [In, Out] Converter(self, converter: Callable[[In], Out]) -> Converter[In, Out]
+    main:26: note:     def [In, Out] Converter(self, converter: Callable[[In, AttrsInstance, Attribute[Any]], Out], *, takes_self: Literal[True], takes_field: Literal[True]) -> Converter[In, Out]
+    main:26: note:     def [In, Out] Converter(self, converter: Callable[[In, Attribute[Any]], Out], *, takes_field: Literal[True]) -> Converter[In, Out]
+    main:26: note:     def [In, Out] Converter(self, converter: Callable[[In, AttrsInstance], Out], *, takes_self: Literal[True]) -> Converter[In, Out]
+    main:27: error: No overload variant of "Converter" matches argument types "Callable[[Any, AttrsInstance], str]", "bool"  [call-overload]
+    main:27: note: Possible overload variants:
+    main:27: note:     def [In, Out] Converter(self, converter: Callable[[In], Out]) -> Converter[In, Out]
+    main:27: note:     def [In, Out] Converter(self, converter: Callable[[In, AttrsInstance, Attribute[Any]], Out], *, takes_self: Literal[True], takes_field: Literal[True]) -> Converter[In, Out]
+    main:27: note:     def [In, Out] Converter(self, converter: Callable[[In, Attribute[Any]], Out], *, takes_field: Literal[True]) -> Converter[In, Out]
+    main:27: note:     def [In, Out] Converter(self, converter: Callable[[In, AttrsInstance], Out], *, takes_self: Literal[True]) -> Converter[In, Out]
+
 - case: testAttrsCmpWithSubclasses
   regex: true
   main: |