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
1 change: 1 addition & 0 deletions changelog.d/1592.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`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.
22 changes: 22 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 **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) * 2 + 286
... 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 420
```


(metadata)=

## Metadata
Expand Down
2 changes: 1 addition & 1 deletion docs/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 28 additions & 5 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:: 26.2.0
*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
Expand Down Expand Up @@ -1168,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
Expand All @@ -1181,14 +1188,30 @@ 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:
nval = val
else:
nval = hook(self, a, val)
_OBJ_SETATTR(self, name, val)

return

if is_gen:
gen = hook(self, a, val)
nval = next(gen)
_OBJ_SETATTR(self, name, nval)
try:
next(gen)
except StopIteration:
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)

return

self._cls_dict["__attrs_own_setattr__"] = True
self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__)
self._wrote_own_setattr = True
Expand Down
16 changes: 15 additions & 1 deletion src/attr/_next_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ 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`.

Expand Down Expand Up @@ -329,6 +333,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:: 26.2.0
*on_setattr* hooks can now be generator functions that yield exactly
once.

.. note::

Expand Down Expand Up @@ -569,10 +576,14 @@ 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()`.

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
``__init__`` method. If left None, default to ``name`` stripped
Expand All @@ -588,6 +599,9 @@ def field(
.. versionchanged:: 25.4.0
*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 that yield exactly
once.

.. seealso::

Expand Down
15 changes: 15 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,20 @@ def pipe(*setters):
Run all *setters* and return the return value of the last one.

.. 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. They never made any sense but now they're explicitly
rejected.
"""
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
6 changes: 5 additions & 1 deletion src/attrs/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]"]
Expand Down
Loading