From a34a7de8cfa608ca9e9937a576e8759f4e218cc4 Mon Sep 17 00:00:00 2001 From: Vladyslav Fedoriuk Date: Sat, 18 Jul 2026 16:19:10 +0200 Subject: [PATCH 01/19] Make on_setattr hooks accept generators --- src/attr/_make.py | 14 ++- src/attr/setters.py | 10 +++ tests/test_setattr.py | 203 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 4 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 6794464e9..8ac1c3573 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -1183,11 +1183,17 @@ def __setattr__(self, name, val): try: a, hook = sa_attrs[name] except KeyError: - nval = val + _OBJ_SETATTR(self, name, val) else: - nval = hook(self, a, val) - - _OBJ_SETATTR(self, name, nval) + if inspect.isgeneratorfunction(hook): + gen = hook(self, a, val) + nval = next(gen) + _OBJ_SETATTR(self, name, nval) + with contextlib.suppress(StopIteration): + next(gen) + else: + nval = hook(self, a, val) + _OBJ_SETATTR(self, name, nval) self._cls_dict["__attrs_own_setattr__"] = True self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__) diff --git a/src/attr/setters.py b/src/attr/setters.py index 78b08398a..43fb38c20 100644 --- a/src/attr/setters.py +++ b/src/attr/setters.py @@ -4,6 +4,8 @@ Commonly used hooks for on_setattr. """ +import inspect + from . import _config from .exceptions import FrozenAttributeError @@ -14,6 +16,14 @@ def pipe(*setters): .. versionadded:: 20.1.0 """ + for s in setters: + if inspect.isgeneratorfunction(s): + msg = ( + "Generator functions are not allowed in pipe(). " + "Use a regular function for value transformation or a generator directly " + "as an on_setattr hook for post-assignment side effects." + ) + raise TypeError(msg) def wrapped_pipe(instance, attrib, new_value): rv = new_value diff --git a/tests/test_setattr.py b/tests/test_setattr.py index f44abf658..8bb1e6329 100644 --- a/tests/test_setattr.py +++ b/tests/test_setattr.py @@ -48,6 +48,51 @@ class Hooked: assert "yyy" == h.y assert "hooked!" == h.x + def test_change_with_generator_hook(self): + """ + The value yielded by a generator hook overwrites the value. + """ + + def hook(instance, attrib, value): + yield "yielded!" + + @attr.s + class Hooked: + x = attr.ib(on_setattr=hook) + + h = Hooked("x") + + assert "x" == h.x + + h.x = "xxx" + + assert "yielded!" == h.x + + def test_generator_hook_executes_after_yielding(self): + """ + Generator post-yield code runs after the value is set on the instance. + """ + calls = [] + + def hook(instance, attrib, value): + calls.append(("pre", instance.x)) + yield value + calls.append(("post", instance.x)) + + @attr.s + class Hooked: + x = attr.ib(on_setattr=hook) + + h = Hooked("x") + + assert "x" == h.x + assert [] == calls + + h.x = "xxx" + + assert [("pre", "x"), ("post", "xxx")] == calls + assert "xxx" == h.x + def test_frozen_attribute(self): """ Frozen attributes raise FrozenAttributeError, others are not affected. @@ -146,6 +191,164 @@ class Piped: assert 42 == p.x1 assert 23 == p.x2 + def test_pipe_rejects_generator(self): + """ + Generator functions cannot be used inside pipe(). + """ + + def gen(_, __, val): + yield val + + with pytest.raises( + TypeError, match="Generator functions are not allowed" + ): + setters.pipe(setters.convert, gen) + + def test_list_on_setattr_rejects_generator(self): + """ + Passing a generator function in a list to on_setattr raises TypeError. + """ + + def gen(_, __, val): + yield val + + with pytest.raises( + TypeError, match="Generator functions are not allowed" + ): + + @attr.s + class C: + x = attr.ib(on_setattr=[setters.convert, gen]) + + def test_generator_hook_not_called_in_init(self): + """ + Generator hooks are not invoked during __init__, same as regular hooks. + """ + + def hook(instance, attrib, value): + yield "hooked" + + @attr.s + class Hooked: + x = attr.ib(on_setattr=hook) + + h = Hooked("x") + + assert "x" == h.x + + h.x = "xxx" + + assert "hooked" == h.x + + def test_generator_hook_with_define(self): + """ + Generator hooks work with attrs.define. + """ + + def hook(instance, attrib, value): + yield value.upper() + + @attr.define + class C: + x: str = attr.field(on_setattr=hook) + + c = C("hello") + + assert "hello" == c.x + + c.x = "world" + + assert "WORLD" == c.x + + def test_generator_hook_class_level(self): + """ + Generator hooks work as class-level on_setattr. + """ + + call_count = 0 + + def hook(instance, attrib, value): + nonlocal call_count + call_count += 1 + yield value + + @attr.s(on_setattr=hook) + class C: + x = attr.ib() + y = attr.ib() + + c = C(1, 2) + + assert 1 == c.x + assert 2 == c.y + + c.x = 10 + assert 1 == call_count + + c.y = 20 + assert 2 == call_count + + def test_generator_pre_yield_exception(self): + """ + If a generator raises before yielding, the value is not set. + """ + + def hook(instance, attrib, value): + raise ValueError("bad value") + yield + + @attr.s + class C: + x = attr.ib(on_setattr=hook) + + c = C("original") + + with pytest.raises(ValueError, match="bad value"): + c.x = "new" + + assert "original" == c.x + + def test_generator_post_yield_exception(self): + """ + If a generator raises after yielding, the value is already set and + the exception propagates. + """ + + def hook(instance, attrib, value): + yield value + raise RuntimeError("post-yield failure") + + @attr.s + class C: + x = attr.ib(on_setattr=hook) + + c = C("original") + + with pytest.raises(RuntimeError, match="post-yield failure"): + c.x = "new" + + assert "new" == c.x + + def test_generator_yields_multiple_times(self): + """ + Only the first yield is used as the new value. + """ + + def hook(instance, attrib, value): + yield "first" + yield "second" + yield "third" + + @attr.s + class C: + x = attr.ib(on_setattr=hook) + + c = C("old") + + c.x = "new" + + assert "first" == c.x + def test_make_class(self): """ on_setattr of make_class gets forwarded. From e27e09bfffe1a83b5e952813e64d780ed5bab55a Mon Sep 17 00:00:00 2001 From: Vladyslav Fedoriuk Date: Sat, 18 Jul 2026 16:39:50 +0200 Subject: [PATCH 02/19] Adjust documentation of public APIs using on_setattr --- src/attr/_make.py | 6 ++++++ src/attr/_next_gen.py | 16 ++++++++++++++++ src/attr/setters.py | 4 ++++ 3 files changed, 26 insertions(+) diff --git a/src/attr/_make.py b/src/attr/_make.py index 8ac1c3573..d70ca911d 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -160,6 +160,9 @@ def attrib( .. versionchanged:: 25.4.0 *kw_only* can now be None, and its default is also changed from False to None. + .. versionchanged:: 25.5.0 + *on_setattr* hooks can now be generator functions for pre/post-yield + side effects. """ eq, eq_key, order, order_key = _determine_attrib_eq_order( cmp, eq, order, True @@ -1433,6 +1436,9 @@ def attrs( *kw_only* now only applies to attributes defined in the current class, and respects attribute-level ``kw_only=False`` settings. .. versionadded:: 25.4.0 *force_kw_only* + .. versionchanged:: 25.5.0 + *on_setattr* hooks can now be generator functions for pre/post-yield + side effects. """ if repr_ns is not None: import warnings diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index db386acb7..3c1fa30da 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -114,6 +114,13 @@ def define( If left None, the default behavior is to run converters and validators whenever an attribute is set. + .. versionchanged:: 25.5.0 + Generators can be used as hooks. + Pre-yield code transforms the value, ``yield`` provides the + value to assign, and post-yield code executes after the + assignment. + Generators are forbidden inside ``pipe()``. + init (bool): Create a ``__init__`` method that initializes the *attrs* attributes. Leading underscores are stripped for the argument name, @@ -329,6 +336,9 @@ def define( and respects attribute-level ``kw_only=False`` settings. .. versionadded:: 25.4.0 Added *force_kw_only* to go back to the previous *kw_only* behavior. + .. versionchanged:: 25.5.0 + *on_setattr* hooks can now be generator functions for pre/post-yield + side effects. .. note:: @@ -573,6 +583,9 @@ def field( `attrs.setters.NO_OP` to run **no** `setattr` hooks for this attribute -- regardless of the setting in `define()`. + .. versionchanged:: 25.5.0 + Generators can be used as hooks for pre/post-yield side effects. + alias (str | None): Override this attribute's parameter name in the generated ``__init__`` method. If left None, default to ``name`` stripped @@ -588,6 +601,9 @@ def field( .. versionchanged:: 25.4.0 *kw_only* can now be None, and its default is also changed from False to None. + .. versionchanged:: 25.5.0 + *on_setattr* hooks can now be generator functions for pre/post-yield + side effects. .. seealso:: diff --git a/src/attr/setters.py b/src/attr/setters.py index 43fb38c20..ad4526f2d 100644 --- a/src/attr/setters.py +++ b/src/attr/setters.py @@ -15,6 +15,10 @@ def pipe(*setters): Run all *setters* and return the return value of the last one. .. versionadded:: 20.1.0 + .. versionchanged:: 25.5.0 + Generator functions are no longer allowed in ``pipe()``. + Use a generator directly as an ``on_setattr`` hook for post-assignment + side effects. """ for s in setters: if inspect.isgeneratorfunction(s): From 07b66b00d72d74b22c7fe96414510d795c1a37e7 Mon Sep 17 00:00:00 2001 From: Vladyslav Fedoriuk Date: Sat, 18 Jul 2026 16:44:00 +0200 Subject: [PATCH 03/19] Create a changelog file --- changelog.d/1592.change.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1592.change.md diff --git a/changelog.d/1592.change.md b/changelog.d/1592.change.md new file mode 100644 index 000000000..900a99460 --- /dev/null +++ b/changelog.d/1592.change.md @@ -0,0 +1,2 @@ +`attrs.setters.pipe()` now rejects generator functions. +`on_setattr` hooks can now be generator functions for pre/post-yield side effects. \ No newline at end of file From b002d610a8e616b01b0d79e276febe43c4dcf7bf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:44:20 +0000 Subject: [PATCH 04/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- changelog.d/1592.change.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1592.change.md b/changelog.d/1592.change.md index 900a99460..95fa51f6b 100644 --- a/changelog.d/1592.change.md +++ b/changelog.d/1592.change.md @@ -1,2 +1,2 @@ `attrs.setters.pipe()` now rejects generator functions. -`on_setattr` hooks can now be generator functions for pre/post-yield side effects. \ No newline at end of file +`on_setattr` hooks can now be generator functions for pre/post-yield side effects. From 731f8f6f12b443a1e115600305db84401e35eb25 Mon Sep 17 00:00:00 2001 From: Vladyslav Fedoriuk Date: Sun, 19 Jul 2026 19:05:15 +0200 Subject: [PATCH 05/19] Update changelog.d/1592.change.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) --- changelog.d/1592.change.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1592.change.md b/changelog.d/1592.change.md index 95fa51f6b..123d5f392 100644 --- a/changelog.d/1592.change.md +++ b/changelog.d/1592.change.md @@ -1,2 +1,2 @@ -`attrs.setters.pipe()` now rejects generator functions. +{func}`attrs.setters.pipe` now rejects generator functions. `on_setattr` hooks can now be generator functions for pre/post-yield side effects. From c63ef15bc9bc453c6ca3d13c23bb496459391394 Mon Sep 17 00:00:00 2001 From: Vladyslav Fedoriuk Date: Sun, 26 Jul 2026 21:21:09 +0200 Subject: [PATCH 06/19] Fix version added comments --- src/attr/_make.py | 4 ++-- src/attr/_next_gen.py | 8 ++++---- src/attr/setters.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index d70ca911d..c898941b9 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -160,7 +160,7 @@ def attrib( .. versionchanged:: 25.4.0 *kw_only* can now be None, and its default is also changed from False to None. - .. versionchanged:: 25.5.0 + .. versionchanged:: 26.2.0 *on_setattr* hooks can now be generator functions for pre/post-yield side effects. """ @@ -1436,7 +1436,7 @@ def attrs( *kw_only* now only applies to attributes defined in the current class, and respects attribute-level ``kw_only=False`` settings. .. versionadded:: 25.4.0 *force_kw_only* - .. versionchanged:: 25.5.0 + .. versionchanged:: 26.2.0 *on_setattr* hooks can now be generator functions for pre/post-yield side effects. """ diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index 3c1fa30da..63f79a05f 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -114,7 +114,7 @@ def define( If left None, the default behavior is to run converters and validators whenever an attribute is set. - .. versionchanged:: 25.5.0 + .. versionchanged:: 26.2.0 Generators can be used as hooks. Pre-yield code transforms the value, ``yield`` provides the value to assign, and post-yield code executes after the @@ -336,7 +336,7 @@ def define( and respects attribute-level ``kw_only=False`` settings. .. versionadded:: 25.4.0 Added *force_kw_only* to go back to the previous *kw_only* behavior. - .. versionchanged:: 25.5.0 + .. versionchanged:: 26.2.0 *on_setattr* hooks can now be generator functions for pre/post-yield side effects. @@ -583,7 +583,7 @@ def field( `attrs.setters.NO_OP` to run **no** `setattr` hooks for this attribute -- regardless of the setting in `define()`. - .. versionchanged:: 25.5.0 + .. versionchanged:: 26.2.0 Generators can be used as hooks for pre/post-yield side effects. alias (str | None): @@ -601,7 +601,7 @@ def field( .. versionchanged:: 25.4.0 *kw_only* can now be None, and its default is also changed from False to None. - .. versionchanged:: 25.5.0 + .. versionchanged:: 26.2.0 *on_setattr* hooks can now be generator functions for pre/post-yield side effects. diff --git a/src/attr/setters.py b/src/attr/setters.py index ad4526f2d..cce4ba3d9 100644 --- a/src/attr/setters.py +++ b/src/attr/setters.py @@ -15,7 +15,7 @@ def pipe(*setters): Run all *setters* and return the return value of the last one. .. versionadded:: 20.1.0 - .. versionchanged:: 25.5.0 + .. versionchanged:: 26.2.0 Generator functions are no longer allowed in ``pipe()``. Use a generator directly as an ``on_setattr`` hook for post-assignment side effects. From 3b12a25550dc3e1ebf220be2012b0d664ddf88ea Mon Sep 17 00:00:00 2001 From: Vladyslav Fedoriuk Date: Sun, 26 Jul 2026 21:25:46 +0200 Subject: [PATCH 07/19] Remove the changelog Sphinx cross-reference --- changelog.d/1592.change.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1592.change.md b/changelog.d/1592.change.md index 123d5f392..7de78ac63 100644 --- a/changelog.d/1592.change.md +++ b/changelog.d/1592.change.md @@ -1,2 +1,2 @@ -{func}`attrs.setters.pipe` now rejects generator functions. +`attrs.setters.pipe` now rejects generator functions. `on_setattr` hooks can now be generator functions for pre/post-yield side effects. From 53b601bca8d93e8fd9af65828c22b91a77dc5ef7 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 30 Jul 2026 13:39:27 +0200 Subject: [PATCH 08/19] Optimize: only check corouting when generating method --- src/attr/_make.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index c898941b9..3d3110636 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -1171,7 +1171,11 @@ def add_setattr(self): 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 + sa_attrs[a.name] = ( + a, + on_setattr, + inspect.isgeneratorfunction(on_setattr), + ) if not sa_attrs: return self @@ -1184,19 +1188,25 @@ def add_setattr(self): # docstring comes from _add_method_dunders def __setattr__(self, name, val): try: - a, hook = sa_attrs[name] + a, hook, is_gen = sa_attrs[name] except KeyError: _OBJ_SETATTR(self, name, val) - else: - if inspect.isgeneratorfunction(hook): - gen = hook(self, a, val) - nval = next(gen) - _OBJ_SETATTR(self, name, nval) - with contextlib.suppress(StopIteration): - next(gen) - else: - nval = hook(self, a, val) - _OBJ_SETATTR(self, name, nval) + + return + + if is_gen: + gen = hook(self, a, val) + nval = next(gen) + _OBJ_SETATTR(self, name, nval) + with contextlib.suppress(StopIteration): + next(gen) + + return + + nval = hook(self, a, val) + _OBJ_SETATTR(self, name, nval) + + return self._cls_dict["__attrs_own_setattr__"] = True self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__) From 101af8b0b9556fa8909529e65f3a8bbee3489076 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 30 Jul 2026 13:46:26 +0200 Subject: [PATCH 09/19] docs --- changelog.d/1592.change.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/changelog.d/1592.change.md b/changelog.d/1592.change.md index 7de78ac63..6c3c8cf90 100644 --- a/changelog.d/1592.change.md +++ b/changelog.d/1592.change.md @@ -1,2 +1 @@ -`attrs.setters.pipe` now rejects generator functions. -`on_setattr` hooks can now be generator functions for pre/post-yield side effects. +`on_setattr` hooks can now be generator functions to run code before and after an attribute is set. From ec2e7da574ea5d294df61629585c54dbd96f8e7f Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 30 Jul 2026 16:41:14 +0200 Subject: [PATCH 10/19] Tighten generator contract --- src/attr/_make.py | 12 ++++++++---- tests/test_setattr.py | 20 +++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 3d3110636..ae43bd9ca 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -162,7 +162,7 @@ def attrib( None. .. versionchanged:: 26.2.0 *on_setattr* hooks can now be generator functions for pre/post-yield - side effects. + side effects. Generator hooks must yield exactly once. """ eq, eq_key, order, order_key = _determine_attrib_eq_order( cmp, eq, order, True @@ -1198,10 +1198,14 @@ def __setattr__(self, name, val): gen = hook(self, a, val) nval = next(gen) _OBJ_SETATTR(self, name, nval) - with contextlib.suppress(StopIteration): + try: next(gen) + except StopIteration: + return - return + gen.close() + msg = "Generator on_setattr hook yielded more than once." + raise RuntimeError(msg) nval = hook(self, a, val) _OBJ_SETATTR(self, name, nval) @@ -1448,7 +1452,7 @@ def attrs( .. versionadded:: 25.4.0 *force_kw_only* .. versionchanged:: 26.2.0 *on_setattr* hooks can now be generator functions for pre/post-yield - side effects. + side effects. Generator hooks must yield exactly once. """ if repr_ns is not None: import warnings diff --git a/tests/test_setattr.py b/tests/test_setattr.py index 8bb1e6329..15a9d7400 100644 --- a/tests/test_setattr.py +++ b/tests/test_setattr.py @@ -331,13 +331,18 @@ class C: def test_generator_yields_multiple_times(self): """ - Only the first yield is used as the new value. + Yielding more than once raises an error and closes the generator. """ + cleaned_up = False def hook(instance, attrib, value): - yield "first" - yield "second" - yield "third" + nonlocal cleaned_up + + try: + yield "first" + yield "second" + finally: + cleaned_up = True @attr.s class C: @@ -345,9 +350,14 @@ class C: c = C("old") - c.x = "new" + with pytest.raises( + RuntimeError, + match="Generator on_setattr hook yielded more than once", + ): + c.x = "new" assert "first" == c.x + assert cleaned_up is True def test_make_class(self): """ From 3e2cff7870618e38ca265b0760a15699458b9f38 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 31 Jul 2026 06:07:53 +0200 Subject: [PATCH 11/19] Polish docstrings --- changelog.d/1592.change.md | 2 +- src/attr/_make.py | 7 ++----- src/attr/_next_gen.py | 26 ++++++++++++-------------- src/attr/setters.py | 7 ++++--- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/changelog.d/1592.change.md b/changelog.d/1592.change.md index 6c3c8cf90..3e2573b3a 100644 --- a/changelog.d/1592.change.md +++ b/changelog.d/1592.change.md @@ -1 +1 @@ -`on_setattr` hooks can now be generator functions to run code before and after an attribute is set. +`attrs.field(on_setattr=…)` hooks can now be generator functions to run code before and after an attribute is set and whose yield value is used as the value to set the attribute to. diff --git a/src/attr/_make.py b/src/attr/_make.py index ae43bd9ca..2461694c7 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -161,8 +161,8 @@ def attrib( *kw_only* can now be None, and its default is also changed from False to None. .. versionchanged:: 26.2.0 - *on_setattr* hooks can now be generator functions for pre/post-yield - side effects. Generator hooks must yield exactly once. + *on_setattr* hooks can now be generator functions to run code before and + after an attribute is set. """ eq, eq_key, order, order_key = _determine_attrib_eq_order( cmp, eq, order, True @@ -1450,9 +1450,6 @@ def attrs( *kw_only* now only applies to attributes defined in the current class, and respects attribute-level ``kw_only=False`` settings. .. versionadded:: 25.4.0 *force_kw_only* - .. versionchanged:: 26.2.0 - *on_setattr* hooks can now be generator functions for pre/post-yield - side effects. Generator hooks must yield exactly once. """ if repr_ns is not None: import warnings diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index 63f79a05f..6c1298a4d 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -108,19 +108,16 @@ def define( If no exception is raised, the attribute is set to the return value of the callable. + If the callable is a generator, it may yield exactly once and runs + before and after the assignment and its yield value is used as the + new value. + If a list of callables is passed, they're automatically wrapped in an `attrs.setters.pipe`. If left None, the default behavior is to run converters and validators whenever an attribute is set. - .. versionchanged:: 26.2.0 - Generators can be used as hooks. - Pre-yield code transforms the value, ``yield`` provides the - value to assign, and post-yield code executes after the - assignment. - Generators are forbidden inside ``pipe()``. - init (bool): Create a ``__init__`` method that initializes the *attrs* attributes. Leading underscores are stripped for the argument name, @@ -337,8 +334,8 @@ def define( .. versionadded:: 25.4.0 Added *force_kw_only* to go back to the previous *kw_only* behavior. .. versionchanged:: 26.2.0 - *on_setattr* hooks can now be generator functions for pre/post-yield - side effects. + *on_setattr* hooks can now be generator functions that yield exactly + once. .. note:: @@ -579,12 +576,13 @@ def field( on_setattr (~typing.Callable | list[~typing.Callable] | None | ~typing.Literal[attrs.setters.NO_OP]): Allows to overwrite the *on_setattr* setting from `attr.s`. If left - None, the *on_setattr* value from `attr.s` is used. Set to + None, the *on_setattr* value from `attrs.define` is used. Set to `attrs.setters.NO_OP` to run **no** `setattr` hooks for this attribute -- regardless of the setting in `define()`. - .. versionchanged:: 26.2.0 - Generators can be used as hooks for pre/post-yield side effects. + May be a generator function that yields exactly once and runs + before and after the assignment and its yield value is used as the + new value. alias (str | None): Override this attribute's parameter name in the generated @@ -602,8 +600,8 @@ def field( *kw_only* can now be None, and its default is also changed from False to None. .. versionchanged:: 26.2.0 - *on_setattr* hooks can now be generator functions for pre/post-yield - side effects. + *on_setattr* hooks can now be generator functions that yield exactly + once. .. seealso:: diff --git a/src/attr/setters.py b/src/attr/setters.py index cce4ba3d9..8d4cf0fd5 100644 --- a/src/attr/setters.py +++ b/src/attr/setters.py @@ -16,9 +16,10 @@ def pipe(*setters): .. versionadded:: 20.1.0 .. versionchanged:: 26.2.0 - Generator functions are no longer allowed in ``pipe()``. - Use a generator directly as an ``on_setattr`` hook for post-assignment - side effects. + Generator functions are no longer allowed in ``pipe()``. Use a + generator directly as an ``on_setattr`` hook for post-assignment + side effects. They never made any sense but now they're explicitly + rejected. """ for s in setters: if inspect.isgeneratorfunction(s): From ce717e1a5ae04036ced7434ff83f4627dce0abf2 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 31 Jul 2026 06:27:09 +0200 Subject: [PATCH 12/19] Add type hints --- src/attrs/__init__.pyi | 6 +++++- typing-examples/baseline.py | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/attrs/__init__.pyi b/src/attrs/__init__.pyi index 6364bac4e..0c694ee61 100644 --- a/src/attrs/__init__.pyi +++ b/src/attrs/__init__.pyi @@ -7,6 +7,7 @@ from typing import ( Sequence, overload, TypeVar, + Generator, ) # Because we need to type our own stuff, we have to make everything from @@ -56,7 +57,10 @@ _CallableConverterType = Callable[[Any], Any] _ConverterType = _CallableConverterType | Converter[Any, Any] _ReprType = Callable[[Any], str] _ReprArgType = bool | _ReprType -_OnSetAttrType = Callable[[Any, "Attribute[Any]", Any], Any] +_OnSetAttrType = ( + Callable[[Any, "Attribute[Any]", Any], Any] + | Callable[[Any, "Attribute[Any]", Any], Generator[Any]] +) _OnSetAttrArgType = _OnSetAttrType | list[_OnSetAttrType] | setters._NoOpType _FieldTransformer = Callable[ [type, list["Attribute[Any]"]], list["Attribute[Any]"] diff --git a/typing-examples/baseline.py b/typing-examples/baseline.py index 15542cab7..ae5207a6c 100644 --- a/typing-examples/baseline.py +++ b/typing-examples/baseline.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, Generator import attrs @@ -130,6 +130,12 @@ class WithCustomRepr: d: bool = attrs.field(repr=str) +def gen_on_setattr_hook( + instance: Any, attribute: attrs.Attribute[Any], new_value: Any +) -> Generator[int]: + yield 42 + + @attrs.define(on_setattr=attrs.setters.validate) class ValidatedSetter2: a: int @@ -143,6 +149,7 @@ class ValidatedSetter2: attrs.setters.convert, attrs.setters.validate ) ) + f: int = attrs.field(on_setattr=gen_on_setattr_hook) @attrs.define(eq=True, order=True) From a721f08c76f1160664b7c83cc05a068b41a945b7 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 31 Jul 2026 06:38:58 +0200 Subject: [PATCH 13/19] Add example --- docs/examples.md | 22 ++++++++++++++++++++++ docs/init.md | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/examples.md b/docs/examples.md index 2393decf4..54bc03d85 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -497,6 +497,28 @@ This behavior can be changed using the *on_setattr* argument. Check out {ref}`converters` for more details. + +## Running Custom Code Before/After Setting Attributes + +If your `__setattr__` hook needs to run custom code before or after setting attributes, you can use the `on_setattr` argument to `field` and make it a generator: + +```{doctest} +>>> from typing import Generator +>>> def gen_hook(instance: Any, attribute: attr.Attribute[int], value: Any) -> Generator[int]: +... print(f"value coming in: {value!r}") +... new_value = int(value) +... yield new_value +... print(f"set {attribute.name} to {new_value!r}") +>>> @define +... class C: +... x: int = field(on_setattr=gen_hook) +>>> c = C(42) # doesn't run on __init__ +>>> c.x = "67" +value coming in: '67' +set x to 67 +``` + + (metadata)= ## Metadata diff --git a/docs/init.md b/docs/init.md index d6ec01be8..653cb098c 100644 --- a/docs/init.md +++ b/docs/init.md @@ -384,8 +384,8 @@ If you need more control over the conversion process, you can wrap the converter C(x=410) ``` - Or as a decorator + ```{doctest} >>> from typing import ClassVar >>> @define From a8eff4d7a80c45374840ec9628e7072c06830a72 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 31 Jul 2026 06:48:55 +0200 Subject: [PATCH 14/19] Rephrase --- docs/examples.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 54bc03d85..4a7e57812 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -500,13 +500,13 @@ Check out {ref}`converters` for more details. ## Running Custom Code Before/After Setting Attributes -If your `__setattr__` hook needs to run custom code before or after setting attributes, you can use the `on_setattr` argument to `field` and make it a generator: +If your `__setattr__` hook needs to run custom code before **and** after setting attributes, you can pass a generator to the `on_setattr` argument: ```{doctest} >>> from typing import Generator >>> def gen_hook(instance: Any, attribute: attr.Attribute[int], value: Any) -> Generator[int]: ... print(f"value coming in: {value!r}") -... new_value = int(value) +... new_value = int(value) * 2 + 92 ... yield new_value ... print(f"set {attribute.name} to {new_value!r}") >>> @define @@ -515,7 +515,7 @@ If your `__setattr__` hook needs to run custom code before or after setting attr >>> c = C(42) # doesn't run on __init__ >>> c.x = "67" value coming in: '67' -set x to 67 +set x to 420 ``` From e4822796abfc47405a2d194b8b285dac34fd2d03 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 31 Jul 2026 06:50:38 +0200 Subject: [PATCH 15/19] fix math --- docs/examples.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples.md b/docs/examples.md index 4a7e57812..cb356c423 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -506,7 +506,7 @@ If your `__setattr__` hook needs to run custom code before **and** after setting >>> from typing import Generator >>> def gen_hook(instance: Any, attribute: attr.Attribute[int], value: Any) -> Generator[int]: ... print(f"value coming in: {value!r}") -... new_value = int(value) * 2 + 92 +... new_value = int(value) * 2 + 286 ... yield new_value ... print(f"set {attribute.name} to {new_value!r}") >>> @define From f574030d9319e40a50959625a8e7af5b7fd756a2 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 31 Jul 2026 06:51:19 +0200 Subject: [PATCH 16/19] be more explicit --- docs/examples.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/examples.md b/docs/examples.md index cb356c423..f48b848bd 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -516,6 +516,8 @@ If your `__setattr__` hook needs to run custom code before **and** after setting >>> c.x = "67" value coming in: '67' set x to 420 +>>> c.x +420 ``` From 925771dabfe7cedb031a7f55a98efefedf09e504 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 31 Jul 2026 06:52:36 +0200 Subject: [PATCH 17/19] align --- src/attr/_next_gen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index 6c1298a4d..a3a843818 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -334,8 +334,8 @@ def define( .. versionadded:: 25.4.0 Added *force_kw_only* to go back to the previous *kw_only* behavior. .. versionchanged:: 26.2.0 - *on_setattr* hooks can now be generator functions that yield exactly - once. + *on_setattr* hooks can now be generator functions that yield exactly + once. .. note:: From c7067ccd2eade9ab19989d3c7df99e7b85a6c6d2 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 31 Jul 2026 19:05:49 +0200 Subject: [PATCH 18/19] Add missing lazy import --- src/attr/_make.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/attr/_make.py b/src/attr/_make.py index 5c5e8d0fb..b9a256bd6 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -1170,6 +1170,8 @@ def add_order(self): return self def add_setattr(self): + import inspect + sa_attrs = {} for a in self._attrs: on_setattr = a.on_setattr or self._on_setattr From dde7929212a85273b489de016b26f59bc6ae8b6a Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 31 Jul 2026 19:09:23 +0200 Subject: [PATCH 19/19] Make inspect import lazy --- src/attr/setters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/attr/setters.py b/src/attr/setters.py index 8d4cf0fd5..56e13f0ed 100644 --- a/src/attr/setters.py +++ b/src/attr/setters.py @@ -4,8 +4,6 @@ Commonly used hooks for on_setattr. """ -import inspect - from . import _config from .exceptions import FrozenAttributeError @@ -21,6 +19,8 @@ def pipe(*setters): side effects. They never made any sense but now they're explicitly rejected. """ + import inspect + for s in setters: if inspect.isgeneratorfunction(s): msg = (