Repository: python-attrs/attrs
Pull request #653 (2020-06-17 19:49:54)

Improve (de)serialization and validation capabilities
Improve (de)serialization and validation capabilities by adding hooks that can automatically update a class’ attributes and that can change the way how objects are serialized by `asdict()`.

See: #649

# Pull Request Check List

This is just a friendly reminder about the most common mistakes.  Please make sure that you tick all boxes.  But please read our [contribution guide](https://www.attrs.org/en/latest/contributing.html) at least once, it will save you unnecessary review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing left to do.

- [x] Added **tests** for changed code.
- [x] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/master/tests/strategies.py).
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [x] ...and used in the stub test file `tests/typing_example.py`.
- [x] Updated **documentation** for changed code.
    - [x] New functions/classes have to be added to `docs/api.rst` by hand.
    - [x] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).  Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/master/src/attr/__init__.py) file.
- [x] Documentation in `.rst` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [x] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/master/changelog.d).

If you have *any* questions to *any* of the points above, just **submit and ask**!  This checklist is here to *help* you, not to deter you from contributing!


sscherfke: I have a lot more uncommited code, but some (important) parts of it require at least Python 3.6.

When are you planning to drop support for Python 2 (and maybe even 3.5)?  This would make a few things easier and if that’s not too far into the future, I would gladly wait a little. :)
wsanchez: > When are you planning to drop support for Python 2 (and maybe even 3.5)? This would make a few things easier and if that’s not too far into the future, I would gladly wait a little. :)

I suggest you file an Issue called "[RFC] Drop support for Python 2" and have that discussion there, as it's not specific to this ticket. (And perhaps another for 3.5.)
hynek: I'm sorry I'm offline for the next week, but there's been a feature request asking for `_CountingAttr` to become public already so this might be two birds with one stone.

As for Python 2: https://www.attrs.org/en/stable/python-2.html

As much as I don't like it, it's one of our main features over dataclasses ATM.

_Once_ we go Python 3-only, it will definitely not be 3.5. :)
sscherfke: What is your preferred approach for tests that run (and parse) only with python 3.6 and above?
hynek: https://github.com/python-attrs/attrs/blob/3e3d84216c5c7b6b451610ebbe8f5a7ed9c3209b/conftest.py#L17
sscherfke: This PR will only focus on the hooks. I have removed the converters and validators and will re-propose them in another PR when this one is accepted.
sscherfke: `_CountingAttr` (or an interface for it) need to be made public for this PR.

What would be your prefered approach?  Create a new Interface that `_CountingAttr` inherits or rename `_CountingAttr` to `CountingAttr`? Should also be imported in `__init__.py` in that case?
wsanchez: > `_CountingAttr` (or an interface for it) need to be made public for this PR.
> 
> What would be your prefered approach? Create a new Interface that `_CountingAttr` inherits or rename `_CountingAttr` to `CountingAttr`? Should also be imported in `__init__.py` in that case?

Again, we've resisted making `_CountingAttr` in the past because it's not an API we want to commit to long-term, so an interface that is limited to what we *are* willing to support seems best, leaving `_CountingAttr` itself as an implementation detail.

That assumes that @hynek is amenable to the resulting public interface.
hynek: The main issue here is that I don't want it to be "counting" anymore if not necessary (ordered dicts), cf #643. I guess we'll have to extract a sub-interface?
sscherfke: An Interface would suffice since it will (at least in this PR) only be used for documentation and (mypy) typing. I can try to extract an Interface.
sscherfke: Hm, `_CountingAttr` is so "simple" and specific, that I don’t know which attributes/meths to extract into an interface.
wsanchez: > Hm, `_CountingAttr` is so "simple" and specific, that I don’t know which attributes/meths to extract into an interface.

I'd start with: what exactly is the minimum needed for this PR to function?
sscherfke: *I* only want to add a converter to the attrib, but instances of `_CountingAttr` (I’d call the interface `MutableAttr` since it is, in contrast to `Attribute`, mutable) are passed to the hook so it would not be very long-sighted to only add the converter attribute to the interface.  I think at least `converter`, `metadata`, `validator()` and `default()` should be made public.  Changing the other attributes in the hook might not make too much sense.
hynek: I guess a fairer name would be `AttributeDefinition` but I wonder if we couldn't spin it in a way that the hook receives `Attributes` but is free to replace them with something else? The would be definitely cleaner and less invasive.
sscherfke: I’m not sure if I understand what you mean.  The whole purpose of the hook is to be invasive and let the user mess with the attribute definition.
hynek: I mean that due to stuff like resolving decorator-based defaults and validators we can’t really provide a good interface? My hope would be that the hook would get a list of `Attribute`s will fully resolved validators etc and would return a list of `Attributes` that may replace some of them or even remove some?
sscherfke: Thanks for the clarification. I will think about this and see if it works.
hynek: Yeah, please let me know what you think. The upsides are mainly that we don’t have to introduce a new “in-between type” that would have to be mutated by the users. That smells like bugs and edge cases to me. I’m open to add more helper APIs to `Attribute`, though!
sscherfke: This was an excelent idea of you, @hynek.  :) The hook is now purely functional. It takes an `Attribute` and returns an `Attribute`.  The only thing that we’d need to change now is to make `Attribute._assoc` public in order to allow people to evolve an attribute.
sscherfke: ping :)
sscherfke: ping :)
hynek: Yeah I'm sorry, I really had to focus on landing all the `__setattr__` stuff (#663 still remains) because I was procrastinating on this stuff for months and it was the last hurdle before starting to work on "import attrs", because it affects default values. Once that lands, I urgently need to get 20.1 out of the door this, the `__eq__` PR, and "import attrs" will be my next priorities for 20.2 which I hope to get out of the door _much_ faster.

I was basically running behind a carrot all year and had to draw a line now. Sorry it affected you. :(((
sscherfke: No worries.  I know this problem.  Just wanted to make sure I’m not forgotten. :)
hynek: No worries, I **really** want to open attrs to your use cases!
hynek: (you may want to start rebasing tho, sadly the innards have quite changed :()
sscherfke: I think the PR is now complete.

When this gets merge, I will create another one with some new validators, a default transform_hook, and a new section in the *Examples* documentation.
hynek: At a first glance this look reasonable!

Just to double check: have you been able to implement your PoCs with this kind of code? Because as it stands here, there is no real justification for it so I just wanna make sure you’re 100% positive this is what you need and it’s sufficient. :)
sscherfke: Yes, I already tested the hooks with an API client (JSON (de)serialization to/from attrs classes) and the same hook should work for my typed-settings PoC as well.

Instead of creating a second PR, I can add its contents directly to this one. This might make the benefits of this PR more clear.
sscherfke: I’ve added a draft for additional validators and (auto) converters. This would allow us to use attrs directly for

- API clients (load attrs classes from json and serialize request data to json)
- Typed settings loader

Here is an example:

```python
from datetime import datetime
from enum import Enum
from typing import List, Set
import json

from attr.converters import auto_convert_hook
from attr.validators import le, maxlen
import attr


class LeEnum(Enum):
    spam = 'Le spam'
    eggs = 'Le eggs'


@attr.frozen(transform_hook=auto_convert_hook)
class Child:
    x: int = attr.ib()
    y: int = attr.ib(converter=float)


@attr.frozen(kw_only=True, transform_hook=auto_convert_hook)
class Parent:
    child: Child
    a: float
    b: float = attr.field(default=3.14, validator=le(2))
    c: LeEnum
    d: datetime
    e: 'List[Child]'
    f: Set[datetime]


DATA = {
    'a': '1',
    'b': '2',
    'c': 'Le spam',
    'd': '2020-05-04T13:37:00',
    'e': [
        {
            'x': '23',
            'y': '42',
        },
    ],
    'f': ['2020-05-04T13:37:00', '2020-05-04T13:37:00'],
    'child': {
        'x': '23',
        'y': '42',
    },
}
p = Parent(**DATA)
print(p)
d = attr.asdict(p, hook=attr.converters.serialize_hook)
print(d)

jd = json.dumps(d)
print(Parent(**json.loads(json.dumps(d))) == p)
```

The validators and converters should cover the most common use cases. I’d like to make the `auto_convert_hook()` more customizable, though, so that users could add additional types without having to create a completely new hook.
sscherfke: There is no a `make_auto_converter()` factory that allows you to extend the bundled `auto_convert()` hook. 
I also added a few tests for this.

What do you think of the general direction this PR is taking? Shall I continue with adding tests and docs?
si14: A note from a silent observer: this PR looks like a fabulous addition to `attrs` and I'm really looking forward to using it. Thanks for your great work, @sscherfke and @hynek !
sscherfke: Ok, I'll rip out the validators and converters again and put them in a separate PR.

Would a new section in the `examples.rst` be a gould place for the docs or shall I create a new document?
hynek: I think this is an advanced feature so either `extending.rst` or something new entirely is apt?
sscherfke: I renamed the hooks, (hopefully) fixed the docs format, added more documentation, removed the validators and converters, rebased on master, and updated *versionadded* labels to 20.3.0. 
sscherfke: Did some more polishing and fixed some formatting issues.
sscherfke: ping :cat2: 
hynek: > ping 🐈

Current status:

![current status](https://user-images.githubusercontent.com/41240/94290360-5e276100-ff5a-11ea-8e1f-8a63778c8e18.jpg)


sscherfke: Oh, I thought you’re already back. Then have a lot of fun and I’ll be patient! 🏄 
sscherfke: Ohai, this is the weekly ping. 🙊 

Patch:
diff --git a/changelog.d/653.change.rst b/changelog.d/653.change.rst
new file mode 100644
index 0000000..241a914
--- /dev/null
+++ b/changelog.d/653.change.rst
@@ -0,0 +1,3 @@
+``attr.s()`` now has a *field_transformer* hook that is called for all ``Attribute``\ s and returns a (modified or updated) list of ``Attribute`` instances.
+``attr.asdict()`` has a *value_serializer* hook that can change the way values are converted.
+Both hooks are meant to help with data (de-)serialization workflows.
diff --git a/docs/api.rst b/docs/api.rst
index ea1f16f..aa22943 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -24,7 +24,7 @@ Core
 
 .. autodata:: attr.NOTHING
 
-.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None)
+.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None, field_transformer=None)
 
    .. note::
 
@@ -93,11 +93,7 @@ Core
       ValueError: x must be positive
 
 .. autoclass:: attr.Attribute
-
-   Instances of this class are frequently used for introspection purposes like:
-
-   - `fields` returns a tuple of them.
-   - Validators get them passed as the first argument.
+   :members: assoc
 
    .. warning::
 
diff --git a/docs/extending.rst b/docs/extending.rst
index 654d250..ccd257c 100644
--- a/docs/extending.rst
+++ b/docs/extending.rst
@@ -159,3 +159,116 @@ Here are some tips for effective use of metadata:
     ...     x = typed(int, default=1, init=False)
     >>> attr.fields(C).x.metadata[MY_TYPE_METADATA]
     <class 'int'>
+
+
+.. _transform-fields:
+
+Automatic Field Transformation and Modification
+-----------------------------------------------
+
+Attrs allows you to automatically modify or transform the class' fields while the class is being created.
+You do this by passing a *field_transformer* hook to `attr.define` (and its friends).
+Its main purpose is to automatically add converters to attributes based on their type to aid the development of API clients and other typed data loaders.
+
+This hook must have the following signature:
+
+.. function:: your_hook(cls: type, fields: List[attr.Attribute]) -> List[attr.Attribute]
+   :noindex:
+
+- *cls* is your class right *before* it is being converted into an attrs class.
+  This means it does not yet have the ``__attrs_attrs__`` attribute.
+
+- *fields* is a list of all :class:`attr.Attribute` instances that will later be set to ``__attrs_attrs__``.
+  You can modify these attributes any way you want:
+  You can add converters, change types, and even remove attributes completely or create new ones!
+
+For example, let's assume that you really don't like floats:
+
+.. doctest::
+
+   >>> def drop_floats(cls, fields):
+   ...     return [f for f in fields if f.type not in {float, 'float'}]
+   ...
+   >>> @attr.frozen(field_transformer=drop_floats)
+   ... class Data:
+   ...     a: int
+   ...     b: float
+   ...     c: str
+   ...
+   >>> Data(42, "spam")
+   Data(a=42, c='spam')
+
+A more realistic example would be to automatically convert data that you, e.g., load from JSON:
+
+.. doctest::
+
+   >>> from datetime import datetime
+   >>>
+   >>> def auto_convert(cls, fields):
+   ...     results = []
+   ...     for field in fields:
+   ...         if field.converter is not None:
+   ...             results.append(field)
+   ...             continue
+   ...         if field.type in {datetime, 'datetime'}:
+   ...             converter = (lambda d: datetime.fromisoformat(d) if isinstance(d, str) else d)
+   ...         else:
+   ...             converter = None
+   ...         results.append(field.assoc(converter=converter))
+   ...     return results
+   ...
+   >>> @attr.frozen(field_transformer=auto_convert)
+   ... class Data:
+   ...     a: int
+   ...     b: str
+   ...     c: datetime
+   ...
+   >>> from_json = {"a": 3, "b": "spam", "c": "2020-05-04T13:37:00"}
+   >>> Data(**from_json)  # ****
+   Data(a=3, b='spam', c=datetime.datetime(2020, 5, 4, 13, 37))
+
+
+Customize Value Serialization in ``asdict()``
+---------------------------------------------
+
+``attrs`` allows you to serialize instances of ``attrs`` classes to dicts using the `attr.asdict` function.
+However, the result can not always be serialized since most data types will remain as they are:
+
+.. doctest::
+
+   >>> import json
+   >>> import datetime
+   >>>
+   >>> @attr.frozen
+   ... class Data:
+   ...    dt: datetime.datetime
+   ...
+   >>> data = attr.asdict(Data(datetime.datetime(2020, 5, 4, 13, 37)))
+   >>> data
+   {'dt': datetime.datetime(2020, 5, 4, 13, 37)}
+   >>> json.dumps(data)
+   Traceback (most recent call last):
+     ...
+   TypeError: Object of type datetime is not JSON serializable
+
+To help you with this, `attr.asdict` allows you to pass a *value_serializer* hook.
+It has the signature
+
+.. function:: your_hook(inst: type, field: attr.Attribute, value: typing.Any) -> typing.Any
+   :noindex:
+
+.. doctest::
+
+   >>> def serialize(inst, field, value):
+   ...     if isinstance(value, datetime.datetime):
+   ...         return value.isoformat()
+   ...     return value
+   ...
+   >>> data = attr.asdict(
+   ...     Data(datetime.datetime(2020, 5, 4, 13, 37)),
+   ...     value_serializer=serialize,
+   ... )
+   >>> data
+   {'dt': '2020-05-04T13:37:00'}
+   >>> json.dumps(data)
+   '{"dt": "2020-05-04T13:37:00"}'
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index 0869914..f903450 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -46,6 +46,7 @@ _OnSetAttrType = Callable[[Any, Attribute[Any], Any], Any]
 _OnSetAttrArgType = Union[
     _OnSetAttrType, List[_OnSetAttrType], setters._NoOpType
 ]
+_FieldTransformer = Callable[[type, List[Attribute]], List[Attribute]]
 # FIXME: in reality, if multiple validators are passed they must be in a list
 # or tuple, but those are invariant and so would prevent subtypes of
 # _ValidatorType from working when passed in a list or tuple.
@@ -274,6 +275,7 @@ def attrs(
     auto_detect: bool = ...,
     getstate_setstate: Optional[bool] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
+    field_transformer: Optional[_FieldTransformer] = ...,
 ) -> _C: ...
 @overload
 def attrs(
@@ -297,6 +299,7 @@ def attrs(
     auto_detect: bool = ...,
     getstate_setstate: Optional[bool] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
+    field_transformer: Optional[_FieldTransformer] = ...,
 ) -> Callable[[_C], _C]: ...
 @overload
 def define(
@@ -319,6 +322,7 @@ def define(
     auto_detect: bool = ...,
     getstate_setstate: Optional[bool] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
+    field_transformer: Optional[_FieldTransformer] = ...,
 ) -> _C: ...
 @overload
 def define(
@@ -341,6 +345,7 @@ def define(
     auto_detect: bool = ...,
     getstate_setstate: Optional[bool] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
+    field_transformer: Optional[_FieldTransformer] = ...,
 ) -> Callable[[_C], _C]: ...
 
 mutable = define
@@ -382,6 +387,7 @@ def make_class(
     eq: Optional[bool] = ...,
     order: Optional[bool] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
+    field_transformer: Optional[_FieldTransformer] = ...,
 ) -> type: ...
 
 # _funcs --
@@ -397,6 +403,7 @@ def asdict(
     filter: Optional[_FilterType[Any]] = ...,
     dict_factory: Type[Mapping[Any, Any]] = ...,
     retain_collection_types: bool = ...,
+    value_serializer: Optional[Callable[[type, Attribute, Any], Any]] = ...,
 ) -> Dict[str, Any]: ...
 
 # TODO: add support for returning NamedTuple from the mypy plugin
diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py
index ca92f9f..56e3fcf 100644
--- a/src/attr/_funcs.py
+++ b/src/attr/_funcs.py
@@ -13,6 +13,7 @@ def asdict(
     filter=None,
     dict_factory=dict,
     retain_collection_types=False,
+    value_serializer=None,
 ):
     """
     Return the ``attrs`` attribute values of *inst* as a dict.
@@ -32,6 +33,10 @@ def asdict(
     :param bool retain_collection_types: Do not convert to ``list`` when
         encountering an attribute whose type is ``tuple`` or ``set``.  Only
         meaningful if ``recurse`` is ``True``.
+    :param Optional[callable] value_serializer: A hook that is called for every
+        attribute or dict key/value.  It receives the current instance, field
+        and value and must return the (updated) value.  The hook is run *after*
+        the optional *filter* has been applied.
 
     :rtype: return type of *dict_factory*
 
@@ -40,6 +45,7 @@ def asdict(
 
     ..  versionadded:: 16.0.0 *dict_factory*
     ..  versionadded:: 16.1.0 *retain_collection_types*
+    ..  versionadded:: 20.3.0 *value_serializer*
     """
     attrs = fields(inst.__class__)
     rv = dict_factory()
@@ -47,17 +53,28 @@ def asdict(
         v = getattr(inst, a.name)
         if filter is not None and not filter(a, v):
             continue
+        if value_serializer is not None:
+            v = value_serializer(inst, a, v)
         if recurse is True:
             if has(v.__class__):
                 rv[a.name] = asdict(
-                    v, True, filter, dict_factory, retain_collection_types
+                    v,
+                    True,
+                    filter,
+                    dict_factory,
+                    retain_collection_types,
+                    value_serializer,
                 )
             elif isinstance(v, (tuple, list, set)):
                 cf = v.__class__ if retain_collection_types is True else list
                 rv[a.name] = cf(
                     [
                         _asdict_anything(
-                            i, filter, dict_factory, retain_collection_types
+                            i,
+                            filter,
+                            dict_factory,
+                            retain_collection_types,
+                            value_serializer,
                         )
                         for i in v
                     ]
@@ -67,10 +84,18 @@ def asdict(
                 rv[a.name] = df(
                     (
                         _asdict_anything(
-                            kk, filter, df, retain_collection_types
+                            kk,
+                            filter,
+                            df,
+                            retain_collection_types,
+                            value_serializer,
                         ),
                         _asdict_anything(
-                            vv, filter, df, retain_collection_types
+                            vv,
+                            filter,
+                            df,
+                            retain_collection_types,
+                            value_serializer,
                         ),
                     )
                     for kk, vv in iteritems(v)
@@ -82,19 +107,36 @@ def asdict(
     return rv
 
 
-def _asdict_anything(val, filter, dict_factory, retain_collection_types):
+def _asdict_anything(
+    val,
+    filter,
+    dict_factory,
+    retain_collection_types,
+    value_serializer,
+):
     """
     ``asdict`` only works on attrs instances, this works on anything.
     """
     if getattr(val.__class__, "__attrs_attrs__", None) is not None:
         # Attrs class.
-        rv = asdict(val, True, filter, dict_factory, retain_collection_types)
+        rv = asdict(
+            val,
+            True,
+            filter,
+            dict_factory,
+            retain_collection_types,
+            value_serializer,
+        )
     elif isinstance(val, (tuple, list, set)):
         cf = val.__class__ if retain_collection_types is True else list
         rv = cf(
             [
                 _asdict_anything(
-                    i, filter, dict_factory, retain_collection_types
+                    i,
+                    filter,
+                    dict_factory,
+                    retain_collection_types,
+                    value_serializer,
                 )
                 for i in val
             ]
@@ -103,13 +145,19 @@ def _asdict_anything(val, filter, dict_factory, retain_collection_types):
         df = dict_factory
         rv = df(
             (
-                _asdict_anything(kk, filter, df, retain_collection_types),
-                _asdict_anything(vv, filter, df, retain_collection_types),
+                _asdict_anything(
+                    kk, filter, df, retain_collection_types, value_serializer
+                ),
+                _asdict_anything(
+                    vv, filter, df, retain_collection_types, value_serializer
+                ),
             )
             for kk, vv in iteritems(val)
         )
     else:
         rv = val
+        if value_serializer is not None:
+            rv = value_serializer(None, None, rv)
     return rv
 
 
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 4c9e074..079fae8 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -373,7 +373,7 @@ def _collect_base_attrs(cls, taken_attr_names):
             if a.inherited or a.name in taken_attr_names:
                 continue
 
-            a = a._assoc(inherited=True)
+            a = a.assoc(inherited=True)
             base_attrs.append(a)
             base_attr_map[a.name] = base_cls
 
@@ -411,7 +411,7 @@ def _collect_base_attrs_broken(cls, taken_attr_names):
             if a.name in taken_attr_names:
                 continue
 
-            a = a._assoc(inherited=True)
+            a = a.assoc(inherited=True)
             taken_attr_names.add(a.name)
             base_attrs.append(a)
             base_attr_map[a.name] = base_cls
@@ -419,7 +419,9 @@ def _collect_base_attrs_broken(cls, taken_attr_names):
     return base_attrs, base_attr_map
 
 
-def _transform_attrs(cls, these, auto_attribs, kw_only, collect_by_mro):
+def _transform_attrs(
+    cls, these, auto_attribs, kw_only, collect_by_mro, field_transformer
+):
     """
     Transform all `_CountingAttr`s on a class into `Attribute`s.
 
@@ -451,6 +453,7 @@ def _transform_attrs(cls, these, auto_attribs, kw_only, collect_by_mro):
                 continue
             annot_names.add(attr_name)
             a = cd.get(attr_name, NOTHING)
+
             if not isinstance(a, _CountingAttr):
                 if a is NOTHING:
                     a = attrib()
@@ -498,8 +501,8 @@ def _transform_attrs(cls, these, auto_attribs, kw_only, collect_by_mro):
     AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names)
 
     if kw_only:
-        own_attrs = [a._assoc(kw_only=True) for a in own_attrs]
-        base_attrs = [a._assoc(kw_only=True) for a in base_attrs]
+        own_attrs = [a.assoc(kw_only=True) for a in own_attrs]
+        base_attrs = [a.assoc(kw_only=True) for a in base_attrs]
 
     attrs = AttrsClass(base_attrs + own_attrs)
 
@@ -518,6 +521,8 @@ def _transform_attrs(cls, these, auto_attribs, kw_only, collect_by_mro):
         if had_default is False and a.default is not NOTHING:
             had_default = True
 
+    if field_transformer is not None:
+        attrs = field_transformer(cls, attrs)
     return _Attributes((attrs, base_attrs, base_attr_map))
 
 
@@ -574,9 +579,15 @@ class _ClassBuilder(object):
         collect_by_mro,
         on_setattr,
         has_custom_setattr,
+        field_transformer,
     ):
         attrs, base_attrs, base_map = _transform_attrs(
-            cls, these, auto_attribs, kw_only, collect_by_mro
+            cls,
+            these,
+            auto_attribs,
+            kw_only,
+            collect_by_mro,
+            field_transformer,
         )
 
         self._cls = cls
@@ -1001,6 +1012,7 @@ def attrs(
     collect_by_mro=False,
     getstate_setstate=None,
     on_setattr=None,
+    field_transformer=None,
 ):
     r"""
     A class decorator that adds `dunder
@@ -1196,6 +1208,11 @@ def attrs(
         If a list of callables is passed, they're automatically wrapped in an
         `attr.setters.pipe`.
 
+    :param Optional[callable] field_transformer:
+        A function that is called with the original class object and all
+        fields right before ``attrs`` finalizes the class.  You can use
+        this, e.g., to automatically add converters or validators to
+        fields based on their types.  See `transform-fields` for more details.
 
     .. versionadded:: 16.0.0 *slots*
     .. versionadded:: 16.1.0 *frozen*
@@ -1225,6 +1242,7 @@ def attrs(
     .. versionadded:: 20.1.0 *collect_by_mro*
     .. versionadded:: 20.1.0 *getstate_setstate*
     .. versionadded:: 20.1.0 *on_setattr*
+    .. versionadded:: 20.3.0 *field_transformer*
     """
     if auto_detect and PY2:
         raise PythonTooOldError(
@@ -1271,6 +1289,7 @@ def attrs(
             collect_by_mro,
             on_setattr,
             has_own_setattr,
+            field_transformer,
         )
         if _determine_whether_to_implement(
             cls, repr, auto_detect, ("__repr__",)
@@ -2183,6 +2202,13 @@ class Attribute(object):
     """
     *Read-only* representation of an attribute.
 
+    Instances of this class are frequently used for introspection purposes
+    like:
+
+    - `fields` returns a tuple of them.
+    - Validators get them passed as the first argument.
+    - The *field transformer* hook receives a list of them.
+
     :attribute name: The name of the attribute.
     :attribute inherited: Whether or not that attribute has been inherited from
         a base class.
@@ -2306,9 +2332,12 @@ class Attribute(object):
         return self.eq and self.order
 
     # Don't use attr.assoc since fields(Attribute) doesn't work
-    def _assoc(self, **changes):
+    def assoc(self, **changes):
         """
         Copy *self* and apply *changes*.
+
+        This works similarly to `attr.evolve` but that function does not work
+        with ``Attribute``.
         """
         new = copy.copy(self)
 
diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py
index b5ff60e..2b5565c 100644
--- a/src/attr/_next_gen.py
+++ b/src/attr/_next_gen.py
@@ -33,6 +33,7 @@ def define(
     auto_detect=True,
     getstate_setstate=None,
     on_setattr=None,
+    field_transformer=None,
 ):
     r"""
     The only behavioral differences are the handling of the *auto_attribs*
@@ -72,6 +73,7 @@ def define(
             collect_by_mro=True,
             getstate_setstate=getstate_setstate,
             on_setattr=on_setattr,
+            field_transformer=field_transformer,
         )
 
     def wrap(cls):


Test patch:
diff --git a/conftest.py b/conftest.py
index 7d3d1f8..b34f1bd 100644
--- a/conftest.py
+++ b/conftest.py
@@ -18,6 +18,7 @@ if sys.version_info[:2] < (3, 6):
     collect_ignore.extend(
         [
             "tests/test_annotations.py",
+            "tests/test_hooks.py",
             "tests/test_init_subclass.py",
             "tests/test_next_gen.py",
         ]
diff --git a/tests/test_hooks.py b/tests/test_hooks.py
new file mode 100644
index 0000000..56049c8
--- /dev/null
+++ b/tests/test_hooks.py
@@ -0,0 +1,187 @@
+from datetime import datetime
+from typing import Dict, List
+
+import attr
+
+
+class TestTransformHook:
+    """
+    Tests for `attrs(tranform_value_serializer=func)`
+    """
+
+    def test_hook_applied(self):
+        """
+        The transform hook is applied to all attributes.  Types can be missing,
+        explicitly set, or annotated.
+        """
+        results = []
+
+        def hook(cls, attribs):
+            results[:] = [(a.name, a.type) for a in attribs]
+            return attribs
+
+        @attr.s(field_transformer=hook)
+        class C:
+            x = attr.ib()
+            y = attr.ib(type=int)
+            z: float = attr.ib()
+
+        assert results == [("x", None), ("y", int), ("z", float)]
+
+    def test_hook_applied_auto_attrib(self):
+        """
+        The transform hook is applied to all attributes and type annotations
+        are detected.
+        """
+        results = []
+
+        def hook(cls, attribs):
+            results[:] = [(a.name, a.type) for a in attribs]
+            return attribs
+
+        @attr.s(auto_attribs=True, field_transformer=hook)
+        class C:
+            x: int
+            y: str = attr.ib()
+
+        assert results == [("x", int), ("y", str)]
+
+    def test_hook_applied_modify_attrib(self):
+        """
+        The transform hook can modify attributes.
+        """
+
+        def hook(cls, attribs):
+            return [a.assoc(converter=a.type) for a in attribs]
+
+        @attr.s(auto_attribs=True, field_transformer=hook)
+        class C:
+            x: int = attr.ib(converter=int)
+            y: float
+
+        c = C(x="3", y="3.14")
+        assert c == C(x=3, y=3.14)
+
+    def test_hook_remove_field(self):
+        """
+        It is possible to remove fields via the hook.
+        """
+
+        def hook(cls, attribs):
+            return [a for a in attribs if a.type is not int]
+
+        @attr.s(auto_attribs=True, field_transformer=hook)
+        class C:
+            x: int
+            y: float
+
+        assert attr.asdict(C(2.7)) == {"y": 2.7}
+
+    def test_hook_add_field(self):
+        """
+        It is possible to add fields via the hook.
+        """
+
+        def hook(cls, attribs):
+            a1 = attribs[0]
+            a2 = a1.assoc(name="new")
+            return [a1, a2]
+
+        @attr.s(auto_attribs=True, field_transformer=hook)
+        class C:
+            x: int
+
+        assert attr.asdict(C(1, 2)) == {"x": 1, "new": 2}
+
+    def test_hook_with_inheritance(self):
+        """
+        The hook receives all fields from base classes.
+        """
+
+        def hook(cls, attribs):
+            assert [a.name for a in attribs] == ["x", "y"]
+            # Remove Base' "x"
+            return attribs[1:]
+
+        @attr.s(auto_attribs=True)
+        class Base:
+            x: int
+
+        @attr.s(auto_attribs=True, field_transformer=hook)
+        class Sub(Base):
+            y: int
+
+        assert attr.asdict(Sub(2)) == {"y": 2}
+
+
+class TestAsDictHook:
+    def test_asdict(self):
+        """
+        asdict() calls the hooks in attrs classes and in other datastructures
+        like lists or dicts.
+        """
+
+        def hook(inst, a, v):
+            if isinstance(v, datetime):
+                return v.isoformat()
+            return v
+
+        @attr.dataclass
+        class Child:
+            x: datetime
+            y: List[datetime]
+
+        @attr.dataclass
+        class Parent:
+            a: Child
+            b: List[Child]
+            c: Dict[str, Child]
+            d: Dict[str, datetime]
+
+        inst = Parent(
+            a=Child(1, [datetime(2020, 7, 1)]),
+            b=[Child(2, [datetime(2020, 7, 2)])],
+            c={"spam": Child(3, [datetime(2020, 7, 3)])},
+            d={"eggs": datetime(2020, 7, 4)},
+        )
+
+        result = attr.asdict(inst, value_serializer=hook)
+        assert result == {
+            "a": {"x": 1, "y": ["2020-07-01T00:00:00"]},
+            "b": [{"x": 2, "y": ["2020-07-02T00:00:00"]}],
+            "c": {"spam": {"x": 3, "y": ["2020-07-03T00:00:00"]}},
+            "d": {"eggs": "2020-07-04T00:00:00"},
+        }
+
+    def test_asdict_calls(self):
+        """
+        The correct instances and attribute names are passed to the hook.
+        """
+        calls = []
+
+        def hook(inst, a, v):
+            calls.append((inst, a.name if a else a, v))
+            return v
+
+        @attr.dataclass
+        class Child:
+            x: int
+
+        @attr.dataclass
+        class Parent:
+            a: Child
+            b: List[Child]
+            c: Dict[str, Child]
+
+        inst = Parent(a=Child(1), b=[Child(2)], c={"spam": Child(3)})
+
+        attr.asdict(inst, value_serializer=hook)
+        assert calls == [
+            (inst, "a", inst.a),
+            (inst.a, "x", inst.a.x),
+            (inst, "b", inst.b),
+            (inst.b[0], "x", inst.b[0].x),
+            (inst, "c", inst.c),
+            (None, None, "spam"),
+            (inst.c["spam"], "x", inst.c["spam"].x),
+        ]
diff --git a/tests/test_make.py b/tests/test_make.py
index d3f4a27..e475dc8 100644
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -167,7 +167,7 @@ class TestTransformAttrs(object):
         Does not attach __attrs_attrs__ to the class.
         """
         C = make_tc()
-        _transform_attrs(C, None, False, False, True)
+        _transform_attrs(C, None, False, False, True, None)
 
         assert None is getattr(C, "__attrs_attrs__", None)
 
@@ -176,7 +176,7 @@ class TestTransformAttrs(object):
         Transforms every `_CountingAttr` and leaves others (a) be.
         """
         C = make_tc()
-        attrs, _, _ = _transform_attrs(C, None, False, False, True)
+        attrs, _, _ = _transform_attrs(C, None, False, False, True, None)
 
         assert ["z", "y", "x"] == [a.name for a in attrs]
 
@@ -190,7 +190,7 @@ class TestTransformAttrs(object):
             pass
 
         assert _Attributes(((), [], {})) == _transform_attrs(
-            C, None, False, False, True
+            C, None, False, False, True, None
         )
 
     def test_transforms_to_attribute(self):
@@ -198,7 +198,9 @@ class TestTransformAttrs(object):
         All `_CountingAttr`s are transformed into `Attribute`s.
         """
         C = make_tc()
-        attrs, base_attrs, _ = _transform_attrs(C, None, False, False, True)
+        attrs, base_attrs, _ = _transform_attrs(
+            C, None, False, False, True, None
+        )
 
         assert [] == base_attrs
         assert 3 == len(attrs)
@@ -215,7 +217,7 @@ class TestTransformAttrs(object):
             y = attr.ib()
 
         with pytest.raises(ValueError) as e:
-            _transform_attrs(C, None, False, False, True)
+            _transform_attrs(C, None, False, False, True, None)
         assert (
             "No mandatory attributes allowed after an attribute with a "
             "default value or factory.  Attribute in question: Attribute"
@@ -245,7 +247,9 @@ class TestTransformAttrs(object):
             x = attr.ib(default=None)
             y = attr.ib()
 
-        attrs, base_attrs, _ = _transform_attrs(C, None, False, True, True)
+        attrs, base_attrs, _ = _transform_attrs(
+            C, None, False, True, True, None
+        )
 
         assert len(attrs) == 3
         assert len(base_attrs) == 1
@@ -268,7 +272,7 @@ class TestTransformAttrs(object):
             y = attr.ib()
 
         attrs, base_attrs, _ = _transform_attrs(
-            C, {"x": attr.ib()}, False, False, True
+            C, {"x": attr.ib()}, False, False, True, None
         )
 
         assert [] == base_attrs
@@ -1487,6 +1491,7 @@ class TestClassBuilder(object):
             True,
             None,
             False,
+            None,
         )
 
         assert "<_ClassBuilder(cls=C)>" == repr(b)
@@ -1513,6 +1518,7 @@ class TestClassBuilder(object):
             True,
             None,
             False,
+            None,
         )
 
         cls = (
@@ -1591,6 +1597,7 @@ class TestClassBuilder(object):
             collect_by_mro=True,
             on_setattr=None,
             has_custom_setattr=False,
+            field_transformer=None,
         )
         b._cls = {}  # no __module__; no __qualname__
 
diff --git a/tests/typing_example.py b/tests/typing_example.py
index 878837b..11f527b 100644
--- a/tests/typing_example.py
+++ b/tests/typing_example.py
@@ -199,6 +199,16 @@ class ValidatedSetter:
     )
 
 
+# field_transformer
+def ft_hook(cls: type, attribs: List[attr.Attribute]) -> List[attr.Attribute]:
+    return attribs
+
+
+@attr.s(field_transformer=ft_hook)
+class TransformedAttrs:
+    x: int
+
+
 # Auto-detect
 # XXX: needs support in mypy
 # @attr.s(auto_detect=True)