Repository: python-attrs/attrs (python)

Issue:
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. 

Pull request description:
converters: allow wrapping and passing self and fields
This allows to wrap converters into classes and pass them the instance that is being initialized and the field for which the value is converted.

Things open:

- [x] narrative docs in `init.md`
- [ ] I'm not sure how to approach the typing side, since every attempt failed. I suspect due to a lack of Mypy plugin support. Can we somehow cheat our way here?

fixes #404, fixes #709 (and probably more)

It will enable #240 if anyone still cares about it.

Patch:
diff --git a/changelog.d/1267.change.md b/changelog.d/1267.change.md
new file mode 100644
index 0000000..2b31c57
--- /dev/null
+++ b/changelog.d/1267.change.md
@@ -0,0 +1,3 @@
+It is now possible to wrap a converter into an `attrs.Converter` and get the current instance and/or the current field definition passed into the converter callable.
+
+Note that this is not supported by any type checker, yet.
diff --git a/docs/api.rst b/docs/api.rst
index c7cea64..86b787f 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -630,6 +630,27 @@ Validators can be both globally and locally disabled:
 Converters
 ----------
 
+.. autoclass:: attrs.Converter
+
+   For example:
+
+   .. doctest::
+
+      >>> def complicated(value, self_, field):
+      ...     return int(value) * self_.factor + field.metadata["offset"]
+      >>> @define
+      ... class C:
+      ...     factor = 5  # not an *attrs* field
+      ...     x = field(
+      ...         metadata={"offset": 200},
+      ...         converter=attrs.Converter(
+      ...             complicated,
+      ...             takes_self=True, takes_field=True
+      ...     ))
+      >>> C("42")
+      C(x=410)
+
+
 .. module:: attrs.converters
 
 All objects from ``attrs.converters`` are also available from ``attr.converters`` (it's the same module in a different namespace).
diff --git a/docs/init.md b/docs/init.md
index 68d2628..4b28541 100644
--- a/docs/init.md
+++ b/docs/init.md
@@ -361,6 +361,24 @@ A converter will override an explicit type annotation or `type` argument.
 {'return': None, 'x': <class 'str'>}
 ```
 
+If you need more control over the conversion process, you can wrap the converter with a {class}`attrs.Converter` and ask for the instance and/or the field that are being initialized:
+
+```{doctest}
+>>> def complicated(value, self_, field):
+...     return int(value) * self_.factor + field.metadata["offset"]
+>>> @define
+... class C:
+...     factor = 5  # not an *attrs* field
+...     x = field(
+...         metadata={"offset": 200},
+...         converter=attrs.Converter(
+...             complicated,
+...             takes_self=True, takes_field=True
+...     ))
+>>> C("42")
+C(x=410)
+```
+
 
 ## Hooking Yourself Into Initialization
 
diff --git a/src/attr/__init__.py b/src/attr/__init__.py
index f2db09f..51b1c25 100644
--- a/src/attr/__init__.py
+++ b/src/attr/__init__.py
@@ -15,6 +15,7 @@ from ._funcs import asdict, assoc, astuple, evolve, has, resolve_types
 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/__init__.pyi b/src/attr/__init__.pyi
index 14e53a8..6ae0a83 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -100,6 +100,35 @@ else:
         takes_self: bool = ...,
     ) -> _T: ...
 
+In = TypeVar("In")
+Out = TypeVar("Out")
+
+class Converter(Generic[In, Out]):
+    @overload
+    def __init__(self, converter: Callable[[In], Out]) -> None: ...
+    @overload
+    def __init__(
+        self,
+        converter: Callable[[In, AttrsInstance, Attribute], Out],
+        *,
+        takes_self: Literal[True],
+        takes_field: Literal[True],
+    ) -> None: ...
+    @overload
+    def __init__(
+        self,
+        converter: Callable[[In, Attribute], Out],
+        *,
+        takes_field: Literal[True],
+    ) -> None: ...
+    @overload
+    def __init__(
+        self,
+        converter: Callable[[In, AttrsInstance], Out],
+        *,
+        takes_self: Literal[True],
+    ) -> None: ...
+
 class Attribute(Generic[_T]):
     name: str
     default: _T | None
@@ -110,7 +139,7 @@ class Attribute(Generic[_T]):
     order: _EqOrderType
     hash: bool | None
     init: bool
-    converter: _ConverterType | None
+    converter: _ConverterType | Converter[Any, _T] | None
     metadata: dict[Any, Any]
     type: type[_T] | None
     kw_only: bool
@@ -174,7 +203,7 @@ def attrib(
     init: bool = ...,
     metadata: Mapping[Any, Any] | None = ...,
     type: type[_T] | None = ...,
-    converter: _ConverterType | None = ...,
+    converter: _ConverterType | Converter[Any, _T] | None = ...,
     factory: Callable[[], _T] | None = ...,
     kw_only: bool = ...,
     eq: _EqOrderType | None = ...,
@@ -194,7 +223,7 @@ def attrib(
     init: bool = ...,
     metadata: Mapping[Any, Any] | None = ...,
     type: type[_T] | None = ...,
-    converter: _ConverterType | None = ...,
+    converter: _ConverterType | Converter[Any, _T] | None = ...,
     factory: Callable[[], _T] | None = ...,
     kw_only: bool = ...,
     eq: _EqOrderType | None = ...,
@@ -214,7 +243,7 @@ def attrib(
     init: bool = ...,
     metadata: Mapping[Any, Any] | None = ...,
     type: object = ...,
-    converter: _ConverterType | None = ...,
+    converter: _ConverterType | Converter[Any, _T] | None = ...,
     factory: Callable[[], _T] | None = ...,
     kw_only: bool = ...,
     eq: _EqOrderType | None = ...,
diff --git a/src/attr/_make.py b/src/attr/_make.py
index e437e62..d7828f9 100644
--- 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 @@ from .exceptions import (
 
 # 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 _determine_setters(frozen, slots, base_attr_map):
 
 
 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 @@ _f = [
 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(*converters):
         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
index 0c24815..963b197 100644
--- a/src/attrs/__init__.py
+++ b/src/attrs/__init__.py
@@ -4,6 +4,7 @@ from attr import (
     NOTHING,
     Attribute,
     AttrsInstance,
+    Converter,
     Factory,
     _make_getattr,
     assoc,
@@ -42,6 +43,7 @@ __all__ = [
     "Attribute",
     "AttrsInstance",
     "cmp_using",
+    "Converter",
     "converters",
     "define",
     "evolve",
diff --git a/src/attrs/__init__.pyi b/src/attrs/__init__.pyi
index 3362466..b2670de 100644
--- a/src/attrs/__init__.pyi
+++ b/src/attrs/__init__.pyi
@@ -25,6 +25,7 @@ from attr import Attribute as Attribute
 from attr import AttrsInstance as AttrsInstance
 from attr import cmp_using as cmp_using
 from attr import converters as converters
+from attr import Converter as Converter
 from attr import evolve as evolve
 from attr import exceptions as exceptions
 from attr import Factory as Factory
@@ -93,7 +94,7 @@ def field(
     hash: bool | None = ...,
     init: bool = ...,
     metadata: Mapping[Any, Any] | None = ...,
-    converter: _ConverterType | None = ...,
+    converter: _ConverterType | Converter[Any, _T] | None = ...,
     factory: Callable[[], _T] | None = ...,
     kw_only: bool = ...,
     eq: _EqOrderType | None = ...,
@@ -113,7 +114,7 @@ def field(
     hash: bool | None = ...,
     init: bool = ...,
     metadata: Mapping[Any, Any] | None = ...,
-    converter: _ConverterType | None = ...,
+    converter: _ConverterType | Converter[Any, _T] | None = ...,
     factory: Callable[[], _T] | None = ...,
     kw_only: bool = ...,
     eq: _EqOrderType | None = ...,
@@ -133,7 +134,7 @@ def field(
     hash: bool | None = ...,
     init: bool = ...,
     metadata: Mapping[Any, Any] | None = ...,
-    converter: _ConverterType | None = ...,
+    converter: _ConverterType | Converter[Any, _T] | None = ...,
     factory: Callable[[], _T] | None = ...,
     kw_only: bool = ...,
     eq: _EqOrderType | None = ...,


Test patch:
diff --git a/tests/test_annotations.py b/tests/test_annotations.py
index 516c53f..b3f7b09 100644
--- a/tests/test_annotations.py
+++ b/tests/test_annotations.py
@@ -277,25 +277,27 @@ class TestAnnotations:
         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 @@ class TestAnnotations:
         """
 
         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 @@ class TestAnnotations:
         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
index 7607e55..4de08d9 100644
--- 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 @@ class TestPipe:
         """
         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 @@ class TestPipe:
 
         # 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 @@ class TestPipe:
         """
         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
index 364dee1..7a568bf 100644
--- 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 @@ class TestConverter:
         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
index 1a0a366..6e5bf04 100644
--- 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: |