Repository: python-attrs/attrs

Problem statement:
[RFC] __setattr__ hooks
There's two things that people keep asking for:

1. validation on setting attributes
2. freezing single attributes

Those two features have something in common: they require `attrs` to write a `__setattr__` method.

I actually had 1 done when I implemented validators but I took it out again, because I didn't want to tamper with `__setattr__` too. But it totally makes sense to expect that validators run there too.

Now that argument has gone away thanks to frozen classes and `attrs` is in the `__setattr__` business. So it feels like the right thing to do, to implement it and make it default for _Operation `import attrs`_ (I hope this is legit the last part of the puzzle).

To allow for 2 too, I would suggest to add a hook called `on_setattr` (better names welcome) that takes a callable that is called with the instance, the attribute definition, and the new value.

To solve 2, the implementation would look like

```python
def frozen(_, __, ___):
    raise FrozenInstanceError
```

***

Open questions:

- what to do about `on_setattr` attributes in a frozen class (incl inheritance)
- what about converters? Maybe it should take a list/`and` like validator/converter do? They would need to work as a chain, returning values for the next one.


Hints:
I agree not doing validation on attribute set but doing them in `__init__` is probably not reasonable and should be changed.

As for the implementation, it'd be ideal if we generated a smart `__setattr__` like we generate a smart `__init__`. If I were doing it I'd write a function basically containing a chain of `if name == {attr0.name} elif name == {attr1.name}` and compile it; it's gnarly but probably the fastest approach (the things we do for speed :).

Not really sure what you mean with the hook proposal. Don't we have basically two hook frameworks already (validators and converters)? Both of them seem inadequate for frozen attributes though, because they run on `__init__` too. Does it make sense to flesh this out more thoroughly?
I mean, we could flesh out validators so they would know if they are being run in the `__init__` context or the `__setattr__` context. Then you could implement frozen attributes using a validator.

If I was designing this system right now, I'd probably want to use a middleware pattern (kind of like aiohttp's middleware, where it's a pipeline of functions).
(1) When I first started using Attrs I found the lack of a validate-on-set option a bit strange but over time have become used to the validate-after-init approach ultimately still stopping execution when validators fail. So I, personally, dont have a strong preference.

(2) This is a feature I would LOVE to have. To date I've been using my own kludge to get this more or less working but I'd much rather have this implemeted professionally.

Would a class with all attributes frozen singly be functionally equivalent to a frozen class? Presumably not because you can add attributes to a nonfrozen class? _What I would really like to have is the ability to have a class functionally frozen with only attributes marked as not-frozen remaining mutable._


PS: Thank you for this amazing package. Using Attrs has resulted in a step change in the quality and productivty of my Python coding.
@Tinche the question really is how to expose the functionality without painting ourselves into a corner. Right now I can think of three reasons the user might want to tinker with `__setattr__`:

- validation
- conversion
- freezing

I guess that could be achieved using `frozen: bool, validate_on_set: bool, convert_on_set: bool` with some thing mutually excluding itself. I suspect tho that it might paint us into a corner (or force us adding dozens more of such arguments) so my suggestion is to approach from `on_setattr=pipe(convert, validate)` (that’s how they run in `__init__` too) and allow people to do whatever they want on `on_setattr`. Hopefully coming up with creative ways we didn’t think of. Freezing would become `on_setattr=explode`.

***

As for the implementation itself I suspect there’s a cut off point where a dict is faster than a long if-then-else but that remains to be benchmarked.
Ah #622 is an example. 

Patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -50,9 +50,6 @@
 EXTRAS_REQUIRE["dev"] = (
     EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["docs"] + ["pre-commit"]
 )
-EXTRAS_REQUIRE["azure-pipelines"] = EXTRAS_REQUIRE["tests"] + [
-    "pytest-azurepipelines"
-]
 
 ###############################################################################
 
diff --git a/src/attr/__init__.py b/src/attr/__init__.py
--- a/src/attr/__init__.py
+++ b/src/attr/__init__.py
@@ -2,7 +2,7 @@
 
 from functools import partial
 
-from . import converters, exceptions, filters, validators
+from . import converters, exceptions, filters, setters, validators
 from ._config import get_run_validators, set_run_validators
 from ._funcs import asdict, assoc, astuple, evolve, has
 from ._make import (
@@ -63,6 +63,7 @@
     "make_class",
     "s",
     "set_run_validators",
+    "setters",
     "validate",
     "validators",
 ]
diff --git a/src/attr/_make.py b/src/attr/_make.py
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -9,7 +9,7 @@
 
 from operator import itemgetter
 
-from . import _config
+from . import _config, setters
 from ._compat import (
     PY2,
     isclass,
@@ -29,7 +29,7 @@
 
 # This is used at least twice, so cache it here.
 _obj_setattr = object.__setattr__
-_init_converter_pat = "__attr_converter_{}"
+_init_converter_pat = "__attr_converter_%s"
 _init_factory_pat = "__attr_factory_{}"
 _tuple_property_pat = (
     "    {attr_name} = _attrs_property(_attrs_itemgetter({index}))"
@@ -109,6 +109,7 @@ def attrib(
     kw_only=False,
     eq=None,
     order=None,
+    on_setattr=None,
 ):
     """
     Create a new attribute on a class.
@@ -126,7 +127,7 @@ def attrib(
         used to construct a new value (useful for mutable data types like lists
         or dicts).
 
-        If a default is not set (or set manually to ``attr.NOTHING``), a value
+        If a default is not set (or set manually to `attr.NOTHING`), a value
         *must* be supplied when instantiating; otherwise a `TypeError`
         will be raised.
 
@@ -200,6 +201,12 @@ def attrib(
     :param kw_only: Make this attribute keyword-only (Python 3+)
         in the generated ``__init__`` (if ``init`` is ``False``, this
         parameter is ignored).
+    :param on_setattr: Allows to overwrite the *on_setattr* setting from
+        `attr.s`. If left `None`, the *on_setattr* value from `attr.s` is used.
+        Set to `attr.setters.NO_OP` to run **no** `setattr` hooks for this
+        attribute -- regardless of the setting in `attr.s`.
+    :type on_setattr: `callable`, or a list of callables, or `None`, or
+        `attr.setters.NO_OP`
 
     .. versionadded:: 15.2.0 *convert*
     .. versionadded:: 16.3.0 *metadata*
@@ -217,6 +224,7 @@ def attrib(
     .. versionchanged:: 19.2.0 *repr* also accepts a custom callable.
     .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01.
     .. versionadded:: 19.2.0 *eq* and *order*
+    .. versionadded:: 20.1.0 *on_setattr*
     """
     eq, order = _determine_eq_order(cmp, eq, order, True)
 
@@ -238,6 +246,16 @@ def attrib(
     if metadata is None:
         metadata = {}
 
+    # Apply syntactic sugar by auto-wrapping.
+    if isinstance(on_setattr, (list, tuple)):
+        on_setattr = setters.pipe(*on_setattr)
+
+    if validator and isinstance(validator, (list, tuple)):
+        validator = and_(*validator)
+
+    if converter and isinstance(converter, (list, tuple)):
+        converter = chain(*converter)
+
     return _CountingAttr(
         default=default,
         validator=validator,
@@ -251,6 +269,7 @@ def attrib(
         kw_only=kw_only,
         eq=eq,
         order=order,
+        on_setattr=on_setattr,
     )
 
 
@@ -524,19 +543,20 @@ class _ClassBuilder(object):
     """
 
     __slots__ = (
-        "_cls",
-        "_cls_dict",
+        "_attr_names",
         "_attrs",
+        "_base_attr_map",
         "_base_names",
-        "_attr_names",
-        "_slots",
-        "_frozen",
-        "_weakref_slot",
         "_cache_hash",
-        "_has_post_init",
+        "_cls",
+        "_cls_dict",
         "_delete_attribs",
-        "_base_attr_map",
+        "_frozen",
+        "_has_post_init",
         "_is_exc",
+        "_on_setattr",
+        "_slots",
+        "_weakref_slot",
     )
 
     def __init__(
@@ -552,6 +572,7 @@ def __init__(
         cache_hash,
         is_exc,
         collect_by_mro,
+        on_setattr,
     ):
         attrs, base_attrs, base_map = _transform_attrs(
             cls, these, auto_attribs, kw_only, collect_by_mro,
@@ -570,6 +591,7 @@ def __init__(
         self._has_post_init = bool(getattr(cls, "__attrs_post_init__", False))
         self._delete_attribs = not bool(these)
         self._is_exc = is_exc
+        self._on_setattr = on_setattr
 
         self._cls_dict["__attrs_attrs__"] = self._attrs
 
@@ -774,6 +796,8 @@ def add_init(self):
                 self._cache_hash,
                 self._base_attr_map,
                 self._is_exc,
+                self._on_setattr is not None
+                and self._on_setattr is not setters.NO_OP,
             )
         )
 
@@ -799,6 +823,33 @@ def add_order(self):
 
         return self
 
+    def add_setattr(self):
+        if self._frozen:
+            return self
+
+        sa_attrs = {}
+        for a in self._attrs:
+            on_setattr = a.on_setattr or self._on_setattr
+            if on_setattr and on_setattr is not setters.NO_OP:
+                sa_attrs[a.name] = a, on_setattr
+
+        if not sa_attrs:
+            return self
+
+        def __setattr__(self, name, val):
+            try:
+                a, hook = sa_attrs[name]
+            except KeyError:
+                nval = val
+            else:
+                nval = hook(self, a, val)
+
+            _obj_setattr(self, name, nval)
+
+        self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__)
+
+        return self
+
     def _add_method_dunders(self, method):
         """
         Add __module__ and __qualname__ to a *method* if possible.
@@ -816,8 +867,8 @@ def _add_method_dunders(self, method):
             pass
 
         try:
-            method.__doc__ = "Method generated by attrs for class {}.".format(
-                self._cls.__qualname__
+            method.__doc__ = "Method generated by attrs for class %s." % (
+                self._cls.__qualname__,
             )
         except AttributeError:
             pass
@@ -908,6 +959,7 @@ def attrs(
     auto_detect=False,
     collect_by_mro=False,
     getstate_setstate=None,
+    on_setattr=None,
 ):
     r"""
     A class decorator that adds `dunder
@@ -1090,6 +1142,19 @@ def attrs(
        on the class (i.e. not inherited), it is set to `False` (this is usually
        what you want).
 
+    :param on_setattr: A callable that is run whenever the user attempts to set
+        an attribute (either by assignment like ``i.x = 42`` or by using
+        `setattr` like ``setattr(i, "x", 42)``). It receives the same argument
+        as validators: the instance, the attribute that is being modified, and
+        the new value.
+
+        If no exception is raised, the attribute is set to the return value of
+        the callable.
+
+        If a list of callables is passed, they're automatically wrapped in an
+        `attr.setters.pipe`.
+
+
     .. versionadded:: 16.0.0 *slots*
     .. versionadded:: 16.1.0 *frozen*
     .. versionadded:: 16.3.0 *str*
@@ -1117,6 +1182,7 @@ def attrs(
     .. versionadded:: 20.1.0 *auto_detect*
     .. versionadded:: 20.1.0 *collect_by_mro*
     .. versionadded:: 20.1.0 *getstate_setstate*
+    .. versionadded:: 20.1.0 *on_setattr*
     """
     if auto_detect and PY2:
         raise PythonTooOldError(
@@ -1126,6 +1192,9 @@ def attrs(
     eq_, order_ = _determine_eq_order(cmp, eq, order, None)
     hash_ = hash  # work around the lack of nonlocal
 
+    if isinstance(on_setattr, (list, tuple)):
+        on_setattr = setters.pipe(*on_setattr)
+
     def wrap(cls):
 
         if getattr(cls, "__class__", None) is None:
@@ -1151,6 +1220,7 @@ def wrap(cls):
             cache_hash,
             is_exc,
             collect_by_mro,
+            on_setattr,
         )
         if _determine_whether_to_implement(
             cls, repr, auto_detect, ("__repr__",)
@@ -1169,6 +1239,8 @@ def wrap(cls):
         ):
             builder.add_order()
 
+        builder.add_setattr()
+
         if (
             hash_ is None
             and auto_detect is True
@@ -1579,43 +1651,6 @@ def _add_repr(cls, ns=None, attrs=None):
     return cls
 
 
-def _make_init(
-    cls, attrs, post_init, frozen, slots, cache_hash, base_attr_map, is_exc
-):
-    attrs = [a for a in attrs if a.init or a.default is not NOTHING]
-
-    unique_filename = _generate_unique_filename(cls, "init")
-
-    script, globs, annotations = _attrs_to_init_script(
-        attrs, frozen, slots, post_init, cache_hash, base_attr_map, is_exc
-    )
-    locs = {}
-    bytecode = compile(script, unique_filename, "exec")
-    attr_dict = dict((a.name, a) for a in attrs)
-    globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict})
-
-    if frozen is True:
-        # Save the lookup overhead in __init__ if we need to circumvent
-        # immutability.
-        globs["_cached_setattr"] = _obj_setattr
-
-    eval(bytecode, globs, locs)
-
-    # In order of debuggers like PDB being able to step through the code,
-    # we add a fake linecache entry.
-    linecache.cache[unique_filename] = (
-        len(script),
-        None,
-        script.splitlines(True),
-        unique_filename,
-    )
-
-    __init__ = locs["__init__"]
-    __init__.__annotations__ = annotations
-
-    return __init__
-
-
 def fields(cls):
     """
     Return the tuple of ``attrs`` attributes for a class.
@@ -1700,8 +1735,134 @@ def _is_slot_attr(a_name, base_attr_map):
     return a_name in base_attr_map and _is_slot_cls(base_attr_map[a_name])
 
 
+def _make_init(
+    cls,
+    attrs,
+    post_init,
+    frozen,
+    slots,
+    cache_hash,
+    base_attr_map,
+    is_exc,
+    has_global_on_setattr,
+):
+    if frozen and has_global_on_setattr:
+        raise ValueError("Frozen classes can't use on_setattr.")
+
+    needs_cached_setattr = cache_hash or frozen
+    filtered_attrs = []
+    attr_dict = {}
+    for a in attrs:
+        if not a.init and a.default is NOTHING:
+            continue
+
+        filtered_attrs.append(a)
+        attr_dict[a.name] = a
+
+        if a.on_setattr is not None:
+            if frozen is True:
+                raise ValueError("Frozen classes can't use on_setattr.")
+
+            needs_cached_setattr = True
+        elif (
+            has_global_on_setattr and a.on_setattr is not setters.NO_OP
+        ) or _is_slot_attr(a.name, base_attr_map):
+            needs_cached_setattr = True
+
+    unique_filename = _generate_unique_filename(cls, "init")
+
+    script, globs, annotations = _attrs_to_init_script(
+        filtered_attrs,
+        frozen,
+        slots,
+        post_init,
+        cache_hash,
+        base_attr_map,
+        is_exc,
+        needs_cached_setattr,
+        has_global_on_setattr,
+    )
+    locs = {}
+    bytecode = compile(script, unique_filename, "exec")
+    globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict})
+
+    if needs_cached_setattr:
+        # Save the lookup overhead in __init__ if we need to circumvent
+        # setattr hooks.
+        globs["_cached_setattr"] = _obj_setattr
+
+    eval(bytecode, globs, locs)
+
+    # In order of debuggers like PDB being able to step through the code,
+    # we add a fake linecache entry.
+    linecache.cache[unique_filename] = (
+        len(script),
+        None,
+        script.splitlines(True),
+        unique_filename,
+    )
+
+    __init__ = locs["__init__"]
+    __init__.__annotations__ = annotations
+
+    return __init__
+
+
+def _setattr(attr_name, value_var, has_on_setattr):
+    """
+    Use the cached object.setattr to set *attr_name* to *value_var*.
+    """
+    return "_setattr('%s', %s)" % (attr_name, value_var,)
+
+
+def _setattr_with_converter(attr_name, value_var, has_on_setattr):
+    """
+    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,
+    )
+
+
+def _assign(attr_name, value, has_on_setattr):
+    """
+    Unless *attr_name* has an on_setattr hook, use normal assignment. Otherwise
+    relegate to _setattr.
+    """
+    if has_on_setattr:
+        return _setattr(attr_name, value, True)
+
+    return "self.%s = %s" % (attr_name, value,)
+
+
+def _assign_with_converter(attr_name, value_var, has_on_setattr):
+    """
+    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 "self.%s = %s(%s)" % (
+        attr_name,
+        _init_converter_pat % (attr_name,),
+        value_var,
+    )
+
+
 def _attrs_to_init_script(
-    attrs, frozen, slots, post_init, cache_hash, base_attr_map, is_exc
+    attrs,
+    frozen,
+    slots,
+    post_init,
+    cache_hash,
+    base_attr_map,
+    is_exc,
+    needs_cached_setattr,
+    has_global_on_setattr,
 ):
     """
     Return a script of an initializer for *attrs* and a dict of globals.
@@ -1712,85 +1873,49 @@ def _attrs_to_init_script(
     a cached ``object.__setattr__``.
     """
     lines = []
-    any_slot_ancestors = any(
-        _is_slot_attr(a.name, base_attr_map) for a in attrs
-    )
+    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
+            "_setattr = _cached_setattr.__get__(self, self.__class__)"
+        )
+
     if frozen is True:
         if slots is True:
-            lines.append(
-                # Circumvent the __setattr__ descriptor to save one lookup per
-                # assignment.
-                # Note _setattr will be used again below if cache_hash is True
-                "_setattr = _cached_setattr.__get__(self, self.__class__)"
-            )
-
-            def fmt_setter(attr_name, value_var):
-                return "_setattr('%(attr_name)s', %(value_var)s)" % {
-                    "attr_name": attr_name,
-                    "value_var": value_var,
-                }
-
-            def fmt_setter_with_converter(attr_name, value_var):
-                conv_name = _init_converter_pat.format(attr_name)
-                return "_setattr('%(attr_name)s', %(conv)s(%(value_var)s))" % {
-                    "attr_name": attr_name,
-                    "value_var": value_var,
-                    "conv": conv_name,
-                }
-
+            fmt_setter = _setattr
+            fmt_setter_with_converter = _setattr_with_converter
         else:
             # Dict frozen classes assign directly to __dict__.
             # But only if the attribute doesn't come from an ancestor slot
             # class.
             # Note _inst_dict will be used again below if cache_hash is True
             lines.append("_inst_dict = self.__dict__")
-            if any_slot_ancestors:
-                lines.append(
-                    # Circumvent the __setattr__ descriptor to save one lookup
-                    # per assignment.
-                    "_setattr = _cached_setattr.__get__(self, self.__class__)"
-                )
 
-            def fmt_setter(attr_name, value_var):
-                if _is_slot_attr(attr_name, base_attr_map):
-                    res = "_setattr('%(attr_name)s', %(value_var)s)" % {
-                        "attr_name": attr_name,
-                        "value_var": value_var,
-                    }
-                else:
-                    res = "_inst_dict['%(attr_name)s'] = %(value_var)s" % {
-                        "attr_name": attr_name,
-                        "value_var": value_var,
-                    }
-                return res
-
-            def fmt_setter_with_converter(attr_name, value_var):
-                conv_name = _init_converter_pat.format(attr_name)
+            def fmt_setter(attr_name, value_var, has_on_setattr):
                 if _is_slot_attr(attr_name, base_attr_map):
-                    tmpl = "_setattr('%(attr_name)s', %(c)s(%(value_var)s))"
-                else:
-                    tmpl = "_inst_dict['%(attr_name)s'] = %(c)s(%(value_var)s)"
-                return tmpl % {
-                    "attr_name": attr_name,
-                    "value_var": value_var,
-                    "c": conv_name,
-                }
+                    return _setattr(attr_name, value_var, has_on_setattr)
+
+                return "_inst_dict['%s'] = %s" % (attr_name, value_var,)
+
+            def fmt_setter_with_converter(
+                attr_name, value_var, has_on_setattr
+            ):
+                if has_on_setattr or _is_slot_attr(attr_name, base_attr_map):
+                    return _setattr_with_converter(
+                        attr_name, value_var, has_on_setattr
+                    )
+
+                return "_inst_dict['%s'] = %s(%s)" % (
+                    attr_name,
+                    _init_converter_pat % (attr_name,),
+                    value_var,
+                )
 
     else:
         # Not frozen.
-        def fmt_setter(attr_name, value):
-            return "self.%(attr_name)s = %(value)s" % {
-                "attr_name": attr_name,
-                "value": value,
-            }
-
-        def fmt_setter_with_converter(attr_name, value_var):
-            conv_name = _init_converter_pat.format(attr_name)
-            return "self.%(attr_name)s = %(conv)s(%(value_var)s)" % {
-                "attr_name": attr_name,
-                "value_var": value_var,
-                "conv": conv_name,
-            }
+        fmt_setter = _assign
+        fmt_setter_with_converter = _assign_with_converter
 
     args = []
     kw_only_args = []
@@ -1804,13 +1929,19 @@ def fmt_setter_with_converter(attr_name, value_var):
     for a in attrs:
         if a.validator:
             attrs_to_validate.append(a)
+
         attr_name = a.name
+        has_on_setattr = a.on_setattr is not None or (
+            a.on_setattr is not setters.NO_OP and has_global_on_setattr
+        )
         arg_name = a.name.lstrip("_")
+
         has_factory = isinstance(a.default, Factory)
         if has_factory and a.default.takes_self:
             maybe_self = "self"
         else:
             maybe_self = ""
+
         if a.init is False:
             if has_factory:
                 init_factory_name = _init_factory_pat.format(a.name)
@@ -1818,16 +1949,18 @@ def fmt_setter_with_converter(attr_name, value_var):
                     lines.append(
                         fmt_setter_with_converter(
                             attr_name,
-                            init_factory_name + "({0})".format(maybe_self),
+                            init_factory_name + "(%s)" % (maybe_self,),
+                            has_on_setattr,
                         )
                     )
-                    conv_name = _init_converter_pat.format(a.name)
+                    conv_name = _init_converter_pat % (a.name,)
                     names_for_globals[conv_name] = a.converter
                 else:
                     lines.append(
                         fmt_setter(
                             attr_name,
-                            init_factory_name + "({0})".format(maybe_self),
+                            init_factory_name + "(%s)" % (maybe_self,),
+                            has_on_setattr,
                         )
                     )
                 names_for_globals[init_factory_name] = a.default.factory
@@ -1836,70 +1969,78 @@ def fmt_setter_with_converter(attr_name, value_var):
                     lines.append(
                         fmt_setter_with_converter(
                             attr_name,
-                            "attr_dict['{attr_name}'].default".format(
-                                attr_name=attr_name
-                            ),
+                            "attr_dict['%s'].default" % (attr_name,),
+                            has_on_setattr,
                         )
                     )
-                    conv_name = _init_converter_pat.format(a.name)
+                    conv_name = _init_converter_pat % (a.name,)
                     names_for_globals[conv_name] = a.converter
                 else:
                     lines.append(
                         fmt_setter(
                             attr_name,
-                            "attr_dict['{attr_name}'].default".format(
-                                attr_name=attr_name
-                            ),
+                            "attr_dict['%s'].default" % (attr_name,),
+                            has_on_setattr,
                         )
                     )
         elif a.default is not NOTHING and not has_factory:
-            arg = "{arg_name}=attr_dict['{attr_name}'].default".format(
-                arg_name=arg_name, attr_name=attr_name
-            )
+            arg = "%s=attr_dict['%s'].default" % (arg_name, attr_name,)
             if a.kw_only:
                 kw_only_args.append(arg)
             else:
                 args.append(arg)
+
             if a.converter is not None:
-                lines.append(fmt_setter_with_converter(attr_name, arg_name))
+                lines.append(
+                    fmt_setter_with_converter(
+                        attr_name, arg_name, has_on_setattr,
+                    )
+                )
                 names_for_globals[
-                    _init_converter_pat.format(a.name)
+                    _init_converter_pat % (a.name,)
                 ] = a.converter
             else:
-                lines.append(fmt_setter(attr_name, arg_name))
+                lines.append(fmt_setter(attr_name, arg_name, has_on_setattr))
+
         elif has_factory:
-            arg = "{arg_name}=NOTHING".format(arg_name=arg_name)
+            arg = "%s=NOTHING" % (arg_name,)
             if a.kw_only:
                 kw_only_args.append(arg)
             else:
                 args.append(arg)
-            lines.append(
-                "if {arg_name} is not NOTHING:".format(arg_name=arg_name)
-            )
+            lines.append("if %s is not NOTHING:" % (arg_name,))
+
             init_factory_name = _init_factory_pat.format(a.name)
             if a.converter is not None:
                 lines.append(
-                    "    " + fmt_setter_with_converter(attr_name, arg_name)
+                    "    "
+                    + fmt_setter_with_converter(
+                        attr_name, arg_name, has_on_setattr
+                    )
                 )
                 lines.append("else:")
                 lines.append(
                     "    "
                     + fmt_setter_with_converter(
                         attr_name,
-                        init_factory_name + "({0})".format(maybe_self),
+                        init_factory_name + "(" + maybe_self + ")",
+                        has_on_setattr,
                     )
                 )
                 names_for_globals[
-                    _init_converter_pat.format(a.name)
+                    _init_converter_pat % (a.name,)
                 ] = a.converter
             else:
-                lines.append("    " + fmt_setter(attr_name, arg_name))
+                lines.append(
+                    "    " + fmt_setter(attr_name, arg_name, has_on_setattr)
+                )
                 lines.append("else:")
                 lines.append(
                     "    "
                     + fmt_setter(
                         attr_name,
-                        init_factory_name + "({0})".format(maybe_self),
+                        init_factory_name + "(" + maybe_self + ")",
+                        has_on_setattr,
                     )
                 )
             names_for_globals[init_factory_name] = a.default.factory
@@ -1908,13 +2049,18 @@ def fmt_setter_with_converter(attr_name, value_var):
                 kw_only_args.append(arg_name)
             else:
                 args.append(arg_name)
+
             if a.converter is not None:
-                lines.append(fmt_setter_with_converter(attr_name, arg_name))
+                lines.append(
+                    fmt_setter_with_converter(
+                        attr_name, arg_name, has_on_setattr
+                    )
+                )
                 names_for_globals[
-                    _init_converter_pat.format(a.name)
+                    _init_converter_pat % (a.name,)
                 ] = a.converter
             else:
-                lines.append(fmt_setter(attr_name, arg_name))
+                lines.append(fmt_setter(attr_name, arg_name, has_on_setattr))
 
         if a.init is True and a.converter is None and a.type is not None:
             annotations[arg_name] = a.type
@@ -1923,13 +2069,14 @@ def fmt_setter_with_converter(attr_name, value_var):
         names_for_globals["_config"] = _config
         lines.append("if _config._run_validators is True:")
         for a in attrs_to_validate:
-            val_name = "__attr_validator_{}".format(a.name)
-            attr_name = "__attr_{}".format(a.name)
+            val_name = "__attr_validator_" + a.name
+            attr_name = "__attr_" + a.name
             lines.append(
-                "    {}(self, {}, self.{})".format(val_name, attr_name, a.name)
+                "    %s(self, %s, self.%s)" % (val_name, attr_name, a.name)
             )
             names_for_globals[val_name] = a.validator
             names_for_globals[attr_name] = a
+
     if post_init:
         lines.append("self.__attrs_post_init__()")
 
@@ -1992,6 +2139,7 @@ class Attribute(object):
     which is only syntactic sugar for ``default=Factory(...)``.
 
     .. versionadded:: 20.1.0 *inherited*
+    .. versionadded:: 20.1.0 *on_setattr*
 
     For the full version history of the fields, see `attr.ib`.
     """
@@ -2010,6 +2158,7 @@ class Attribute(object):
         "converter",
         "kw_only",
         "inherited",
+        "on_setattr",
     )
 
     def __init__(
@@ -2028,6 +2177,7 @@ def __init__(
         kw_only=False,
         eq=None,
         order=None,
+        on_setattr=None,
     ):
         eq, order = _determine_eq_order(cmp, eq, order, True)
 
@@ -2056,6 +2206,7 @@ def __init__(
         bound_setattr("type", type)
         bound_setattr("kw_only", kw_only)
         bound_setattr("inherited", inherited)
+        bound_setattr("on_setattr", on_setattr)
 
     def __setattr__(self, name, value):
         raise FrozenInstanceError()
@@ -2185,6 +2336,7 @@ class _CountingAttr(object):
         "converter",
         "type",
         "kw_only",
+        "on_setattr",
     )
     __attrs_attrs__ = tuple(
         Attribute(
@@ -2199,6 +2351,7 @@ class _CountingAttr(object):
             eq=True,
             order=False,
             inherited=False,
+            on_setattr=None,
         )
         for name in (
             "counter",
@@ -2208,6 +2361,7 @@ class _CountingAttr(object):
             "order",
             "hash",
             "init",
+            "on_setattr",
         )
     ) + (
         Attribute(
@@ -2222,6 +2376,7 @@ class _CountingAttr(object):
             eq=True,
             order=False,
             inherited=False,
+            on_setattr=None,
         ),
     )
     cls_counter = 0
@@ -2240,19 +2395,13 @@ def __init__(
         kw_only,
         eq,
         order,
+        on_setattr,
     ):
         _CountingAttr.cls_counter += 1
         self.counter = _CountingAttr.cls_counter
         self._default = default
-        # If validator is a list/tuple, wrap it using helper validator.
-        if validator and isinstance(validator, (list, tuple)):
-            self._validator = and_(*validator)
-        else:
-            self._validator = validator
-        if converter and isinstance(converter, (list, tuple)):
-            self.converter = chain(*converter)
-        else:
-            self.converter = converter
+        self._validator = validator
+        self.converter = converter
         self.repr = repr
         self.eq = eq
         self.order = order
@@ -2261,6 +2410,7 @@ def __init__(
         self.metadata = metadata
         self.type = type
         self.kw_only = kw_only
+        self.on_setattr = on_setattr
 
     def validator(self, meth):
         """
diff --git a/src/attr/exceptions.py b/src/attr/exceptions.py
--- a/src/attr/exceptions.py
+++ b/src/attr/exceptions.py
@@ -1,20 +1,37 @@
 from __future__ import absolute_import, division, print_function
 
 
-class FrozenInstanceError(AttributeError):
+class FrozenError(AttributeError):
     """
-    A frozen/immutable instance has been attempted to be modified.
+    A frozen/immutable instance or attribute haave been attempted to be
+    modified.
 
     It mirrors the behavior of ``namedtuples`` by using the same error message
     and subclassing `AttributeError`.
 
-    .. versionadded:: 16.1.0
+    .. versionadded:: 20.1.0
     """
 
     msg = "can't set attribute"
     args = [msg]
 
 
+class FrozenInstanceError(FrozenError):
+    """
+    A frozen instance has been attempted to be modified.
+
+    .. versionadded:: 16.1.0
+    """
+
+
+class FrozenAttributeError(FrozenError):
+    """
+    A frozen attribute has been attempted to be modified.
+
+    .. versionadded:: 20.1.0
+    """
+
+
 class AttrsAttributeNotFoundError(ValueError):
     """
     An ``attrs`` function couldn't find an attribute that the user asked for.
diff --git a/src/attr/setters.py b/src/attr/setters.py
new file mode 100644
--- /dev/null
+++ b/src/attr/setters.py
@@ -0,0 +1,77 @@
+"""
+Commonly used hooks for on_setattr.
+"""
+
+from __future__ import absolute_import, division, print_function
+
+from . import _config
+from .exceptions import FrozenAttributeError
+
+
+def pipe(*setters):
+    """
+    Run all *setters* and return the return value of the last one.
+
+    .. versionadded:: 20.1.0
+    """
+
+    def wrapped_pipe(instance, attrib, new_value):
+        rv = new_value
+
+        for setter in setters:
+            rv = setter(instance, attrib, rv)
+
+        return rv
+
+    return wrapped_pipe
+
+
+def frozen(_, __, ___):
+    """
+    Prevent an attribute to be modified.
+
+    .. versionadded:: 20.1.0
+    """
+    raise FrozenAttributeError()
+
+
+def validate(instance, attrib, new_value):
+    """
+    Run *attrib*'s validator on *new_value* if it has one.
+
+    .. versionadded:: 20.1.0
+    """
+    if _config._run_validators is False:
+        return new_value
+
+    v = attrib.validator
+    if not v:
+        return new_value
+
+    v(instance, attrib, new_value)
+
+    return new_value
+
+
+def convert(instance, attrib, new_value):
+    """
+    Run *attrib*'s converter -- if it has one --  on *new_value* and return the
+    result.
+
+    .. versionadded:: 20.1.0
+    """
+    c = attrib.converter
+    if c:
+        return c(new_value)
+
+    return new_value
+
+
+NO_OP = object()
+"""
+Sentinel for disabling class-wide *on_setattr* hooks for certain attributes.
+
+Does not work in `pipe` or within lists.
+
+.. versionadded:: 20.1.0
+"""
diff --git a/src/attr/validators.py b/src/attr/validators.py
--- a/src/attr/validators.py
+++ b/src/attr/validators.py
@@ -67,7 +67,7 @@ def instance_of(type):
     return _InstanceOfValidator(type)
 
 
-@attrs(repr=False, frozen=True)
+@attrs(repr=False, frozen=True, slots=True)
 class _MatchesReValidator(object):
     regex = attrib()
     flags = attrib()


Test patch:
diff --git a/tests/test_dunders.py b/tests/test_dunders.py
--- a/tests/test_dunders.py
+++ b/tests/test_dunders.py
@@ -68,6 +68,7 @@ def _add_init(cls, frozen):
         cache_hash=False,
         base_attr_map={},
         is_exc=False,
+        has_global_on_setattr=False,
     )
     return cls
 
diff --git a/tests/test_functional.py b/tests/test_functional.py
--- a/tests/test_functional.py
+++ b/tests/test_functional.py
@@ -16,9 +16,11 @@
 
 import attr
 
+from attr import setters
 from attr._compat import PY2, TYPE
 from attr._make import NOTHING, Attribute
-from attr.exceptions import FrozenInstanceError
+from attr.exceptions import FrozenAttributeError, FrozenInstanceError
+from attr.validators import instance_of, matches_re
 
 from .strategies import optional_bool
 
@@ -681,3 +683,183 @@ class C(object):
             "2021-06-01.  Please use `eq` and `order` instead."
             == w.message.args[0]
         )
+
+
+class TestSetAttr(object):
+    def test_change(self):
+        """
+        The return value of a hook overwrites the value. But they are not run
+        on __init__.
+        """
+
+        def hook(*a, **kw):
+            return "hooked!"
+
+        @attr.s
+        class Hooked(object):
+            x = attr.ib(on_setattr=hook)
+            y = attr.ib()
+
+        h = Hooked("x", "y")
+
+        assert "x" == h.x
+        assert "y" == h.y
+
+        h.x = "xxx"
+        h.y = "yyy"
+
+        assert "yyy" == h.y
+        assert "hooked!" == h.x
+
+    def test_frozen_attribute(self):
+        """
+        Frozen attributes raise FrozenAttributeError, others are not affected.
+        """
+
+        @attr.s
+        class PartiallyFrozen(object):
+            x = attr.ib(on_setattr=setters.frozen)
+            y = attr.ib()
+
+        pf = PartiallyFrozen("x", "y")
+
+        pf.y = "yyy"
+
+        assert "yyy" == pf.y
+
+        with pytest.raises(FrozenAttributeError):
+            pf.x = "xxx"
+
+        assert "x" == pf.x
+
+    @pytest.mark.parametrize(
+        "on_setattr",
+        [setters.validate, [setters.validate], setters.pipe(setters.validate)],
+    )
+    def test_validator(self, on_setattr):
+        """
+        Validators are run and they don't alter the value.
+        """
+
+        @attr.s(on_setattr=on_setattr)
+        class ValidatedAttribute(object):
+            x = attr.ib()
+            y = attr.ib(validator=[instance_of(str), matches_re("foo.*qux")])
+
+        va = ValidatedAttribute(42, "foobarqux")
+
+        with pytest.raises(TypeError) as ei:
+            va.y = 42
+
+        assert "foobarqux" == va.y
+
+        assert ei.value.args[0].startswith("'y' must be <")
+
+        with pytest.raises(ValueError) as ei:
+            va.y = "quxbarfoo"
+
+        assert ei.value.args[0].startswith("'y' must match regex '")
+
+        assert "foobarqux" == va.y
+
+        va.y = "foobazqux"
+
+        assert "foobazqux" == va.y
+
+    def test_pipe(self):
+        """
+        Multiple hooks are possible, in that case the last return value is
+        used. They can be supplied using the pipe functions or by passing a
+        list to on_setattr.
+        """
+
+        s = [setters.convert, lambda _, __, nv: nv + 1]
+
+        @attr.s
+        class Piped(object):
+            x1 = attr.ib(converter=int, on_setattr=setters.pipe(*s))
+            x2 = attr.ib(converter=int, on_setattr=s)
+
+        p = Piped("41", "22")
+
+        assert 41 == p.x1
+        assert 22 == p.x2
+
+        p.x1 = "41"
+        p.x2 = "22"
+
+        assert 42 == p.x1
+        assert 23 == p.x2
+
+    def test_make_class(self):
+        """
+        on_setattr of make_class gets forwarded.
+        """
+        C = attr.make_class("C", {"x": attr.ib()}, on_setattr=setters.frozen)
+
+        c = C(1)
+
+        with pytest.raises(FrozenAttributeError):
+            c.x = 2
+
+    def test_no_validator_no_converter(self):
+        """
+        validate and convert tolerate missing validators and converters.
+        """
+
+        @attr.s(on_setattr=[setters.convert, setters.validate])
+        class C(object):
+            x = attr.ib()
+
+        c = C(1)
+
+        c.x = 2
+
+    def test_validate_respects_run_validators_config(self):
+        """
+        If run validators is off, validate doesn't run them.
+        """
+
+        @attr.s(on_setattr=setters.validate)
+        class C(object):
+            x = attr.ib(validator=attr.validators.instance_of(int))
+
+        c = C(1)
+
+        attr.set_run_validators(False)
+
+        c.x = "1"
+
+        assert "1" == c.x
+
+        attr.set_run_validators(True)
+
+        with pytest.raises(TypeError) as ei:
+            c.x = "1"
+
+        assert ei.value.args[0].startswith("'x' must be <")
+
+    def test_frozen_on_setattr_class_is_caught(self):
+        """
+        @attr.s(on_setattr=X, frozen=True) raises an ValueError.
+        """
+        with pytest.raises(ValueError) as ei:
+
+            @attr.s(frozen=True, on_setattr=setters.validate)
+            class C(object):
+                x = attr.ib()
+
+        assert "Frozen classes can't use on_setattr." == ei.value.args[0]
+
+    def test_frozen_on_setattr_attribute_is_caught(self):
+        """
+        attr.ib(on_setattr=X) on a frozen class raises an ValueError.
+        """
+
+        with pytest.raises(ValueError) as ei:
+
+            @attr.s(frozen=True)
+            class C(object):
+                x = attr.ib(on_setattr=setters.validate)
+
+        assert "Frozen classes can't use on_setattr." == ei.value.args[0]
diff --git a/tests/test_make.py b/tests/test_make.py
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -222,7 +222,7 @@ class C(object):
             "(name='y', default=NOTHING, validator=None, repr=True, "
             "eq=True, order=True, hash=None, init=True, "
             "metadata=mappingproxy({}), type=None, converter=None, "
-            "kw_only=False, inherited=False)",
+            "kw_only=False, inherited=False, on_setattr=None)",
         ) == e.value.args
 
     def test_kw_only(self):
@@ -1425,7 +1425,18 @@ class C(object):
             pass
 
         b = _ClassBuilder(
-            C, None, True, True, False, False, False, False, False, False, True
+            C,
+            None,
+            True,
+            True,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            True,
+            None,
         )
 
         assert "<_ClassBuilder(cls=C)>" == repr(b)
@@ -1439,7 +1450,18 @@ class C(object):
             x = attr.ib()
 
         b = _ClassBuilder(
-            C, None, True, True, False, False, False, False, False, False, True
+            C,
+            None,
+            True,
+            True,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            True,
+            None,
         )
 
         cls = (
@@ -1516,6 +1538,7 @@ class C(object):
             kw_only=False,
             cache_hash=False,
             collect_by_mro=True,
+            on_setattr=None,
         )
         b._cls = {}  # no __module__; no __qualname__
 
diff --git a/tests/typing_example.py b/tests/typing_example.py
--- a/tests/typing_example.py
+++ b/tests/typing_example.py
@@ -185,6 +185,20 @@ class OrderFlags:
     b = attr.ib(eq=True, order=True)
 
 
+# on_setattr hooks
+@attr.s(on_setattr=attr.setters.validate)
+class ValidatedSetter:
+    a = attr.ib()
+    b = attr.ib(on_setattr=attr.setters.NO_OP)
+    c = attr.ib(on_setattr=attr.setters.frozen)
+    d = attr.ib(on_setattr=[attr.setters.convert, attr.setters.validate])
+    d = attr.ib(
+        on_setattr=attr.setters.pipe(
+            attr.setters.convert, attr.setters.validate
+        )
+    )
+
+
 # Auto-detect
 # XXX: needs support in mypy
 # @attr.s(auto_detect=True)