Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/1592.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{func}`attrs.setters.pipe` now rejects generator functions.
`on_setattr` hooks can now be generator functions for pre/post-yield side effects.
20 changes: 16 additions & 4 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1183,11 +1186,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__)
Expand Down Expand Up @@ -1427,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
Expand Down
16 changes: 16 additions & 0 deletions src/attr/_next_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::

Expand Down Expand Up @@ -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
Expand All @@ -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::

Expand Down
14 changes: 14 additions & 0 deletions src/attr/setters.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
Commonly used hooks for on_setattr.
"""

import inspect

from . import _config
from .exceptions import FrozenAttributeError

Expand All @@ -13,7 +15,19 @@ 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):
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
Expand Down
203 changes: 203 additions & 0 deletions tests/test_setattr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down