diff --git a/changelog.d/1592.change.md b/changelog.d/1592.change.md new file mode 100644 index 000000000..3e2573b3a --- /dev/null +++ b/changelog.d/1592.change.md @@ -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. diff --git a/docs/examples.md b/docs/examples.md index 2393decf4..f48b848bd 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -497,6 +497,30 @@ 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 +>>> c.x +420 +``` + + (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 diff --git a/src/attr/_compat.py b/src/attr/_compat.py index 37e714857..52a3f245e 100644 --- a/src/attr/_compat.py +++ b/src/attr/_compat.py @@ -4,7 +4,7 @@ import threading from collections.abc import Mapping, Sequence # noqa: F401 -from typing import _GenericAlias +from typing import Callable, _GenericAlias PYPY = sys.implementation.name == "pypy" @@ -101,3 +101,28 @@ def get_generic_base(cl): if cl.__class__ is _GenericAlias: return cl.__origin__ return None + + +_IS_GENERATOR_RESULTS = {} + + +def _lazy_is_generator(f: Callable) -> Callable[[], bool]: + """ + Return a caching closure over callable f that returns whether f is a + generator function. + + Not thread-safe but doesn't matter. + """ + + def is_gen() -> bool: + import inspect + + try: + if f not in _IS_GENERATOR_RESULTS: + _IS_GENERATOR_RESULTS[f] = inspect.isgeneratorfunction(f) + except TypeError: # f is not hashable + return inspect.isgeneratorfunction(f) + + return _IS_GENERATOR_RESULTS[f] + + return is_gen diff --git a/src/attr/_make.py b/src/attr/_make.py index c71e87280..f3f7ec7cd 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -25,6 +25,7 @@ PY_3_13_PLUS, _AnnotationExtractor, _get_annotations, + _lazy_is_generator, get_generic_base, ) from .exceptions import ( @@ -158,6 +159,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 @@ -1171,7 +1175,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, + _lazy_is_generator(on_setattr), + ) if not sa_attrs: return self @@ -1184,14 +1192,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 diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index db386acb7..a3a843818 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -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`. @@ -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:: @@ -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 @@ -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:: diff --git a/src/attr/setters.py b/src/attr/setters.py index 78b08398a..cff843278 100644 --- a/src/attr/setters.py +++ b/src/attr/setters.py @@ -12,6 +12,9 @@ def pipe(*setters): """ Run all *setters* and return the return value of the last one. + .. warning:: + Generator functions are not allowed in ``pipe()``. + .. versionadded:: 20.1.0 """ 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/tests/test_compat.py b/tests/test_compat.py index a7cbf3e07..f24da3559 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -8,6 +8,8 @@ import attr +from attr._compat import _IS_GENERATOR_RESULTS, _lazy_is_generator + @pytest.fixture(name="mp") def _mp(): @@ -63,3 +65,36 @@ def test_attrsinstance_subclass_protocol(): class Foo(attr.AttrsInstance, Protocol): def attribute(self) -> int: ... + + +class TestLazyIsGenerator: + def test_is_generator(self): + """ + Returns True for generator functions and caches the result. + """ + + def gen(): + yield 1 + + assert _lazy_is_generator(gen)() + assert _IS_GENERATOR_RESULTS[gen] is True + + def test_is_not_generator(self): + """ + Returns False for non-generator functions and caches the result. + """ + + def non_gen(): + return 1 + + assert not _lazy_is_generator(non_gen)() + assert _IS_GENERATOR_RESULTS[non_gen] is False + + def test_not_hashable(self): + """ + If the user somehow manages to create a non-hashable function, + the result is not hashed but the result is correct. + """ + non_hashable = {} + + assert not _lazy_is_generator(non_hashable)() diff --git a/tests/test_setattr.py b/tests/test_setattr.py index f44abf658..eba407830 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,145 @@ class Piped: assert 42 == p.x1 assert 23 == p.x2 + 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): + """ + Yielding more than once raises an error and closes the generator. + """ + cleaned_up = False + + def hook(instance, attrib, value): + nonlocal cleaned_up + + try: + yield "first" + yield "second" + finally: + cleaned_up = True + + @attr.s + class C: + x = attr.ib(on_setattr=hook) + + c = C("old") + + 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): """ on_setattr of make_class gets forwarded. 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)