/
niceSOFT
/
python3-attrs
Обзор
Документация
Войти
/
niceSOFT
/
python3-attrs
Код
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
tests/test_setattr.py
647 строк
16 KB
Vladyslav Fedoriuk
Make on_setattr hooks accept generators (#1592)
01 авг 2026, 15:07
Не верифицирован
01 авг 2026, 15:07
4b5b295
Код
Авторство
О чём код?
# SPDX-License-Identifier: MIT import pickle import pytest import attr from attr import setters from attr.exceptions import FrozenAttributeError from attr.validators import instance_of, matches_re @attr.s(frozen=True) class Frozen: x = attr.ib() @attr.s class WithOnSetAttrHook: x = attr.ib(on_setattr=lambda *args: None) class TestSetAttr: 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: 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_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. """ @attr.s class PartiallyFrozen: 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: 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. """ taken = None def takes_all(val, instance, attrib): nonlocal taken taken = val, instance, attrib return val s = [setters.convert, lambda _, __, nv: nv + 1] @attr.s class Piped: x1 = attr.ib( converter=[ attr.Converter( takes_all, takes_field=True, takes_self=True ), int, ], on_setattr=setters.pipe(*s), ) x2 = attr.ib(converter=int, on_setattr=s) p = Piped("41", "22") assert ("41", p) == taken[:-1] assert "x1" == taken[-1].name assert 41 == p.x1 assert 22 == p.x2 p.x1 = "41" p.x2 = "22" 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. """ 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: x = attr.ib() c = C(1) c.x = 2 assert 2 == c.x 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: 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: 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: x = attr.ib(on_setattr=setters.validate) assert "Frozen classes can't use on_setattr." == ei.value.args[0] @pytest.mark.parametrize("nop", [None, setters.NO_OP]) def test_frozen_on_setattr_nops(self, nop): """ on_setattr on frozen classes can be used for None and NO_OP. """ @attr.s(frozen=True) class C: x = attr.ib(on_setattr=nop) def test_setattr_reset_if_no_custom_setattr(self, slots): """ If a class with an active setattr is subclassed and no new setattr is generated, the __setattr__ is set to object.__setattr__. We do the double test because of Python 2. """ def boom(*args): pytest.fail("Must not be called.") @attr.s class Hooked: x = attr.ib(on_setattr=boom) @attr.s(slots=slots) class NoHook(WithOnSetAttrHook): x = attr.ib() assert NoHook.__setattr__ == object.__setattr__ assert 1 == NoHook(1).x assert Hooked.__attrs_own_setattr__ assert not NoHook.__attrs_own_setattr__ assert WithOnSetAttrHook.__attrs_own_setattr__ def test_setattr_inherited_do_not_reset(self, slots): """ If we inherit a __setattr__ that has been written by the user, we must not reset it unless necessary. """ class A: """ Not an attrs class on purpose to prevent accidental resets that would render the asserts meaningless. """ def __setattr__(self, *args): pass @attr.s(slots=slots) class B(A): pass assert B.__setattr__ == A.__setattr__ @attr.s(slots=slots) class C(B): pass assert C.__setattr__ == A.__setattr__ def test_pickling_retains_attrs_own(self, slots): """ Pickling/Unpickling does not lose ownership information about __setattr__. """ i = WithOnSetAttrHook(1) assert True is i.__attrs_own_setattr__ i2 = pickle.loads(pickle.dumps(i)) assert True is i2.__attrs_own_setattr__ WOSAH = pickle.loads(pickle.dumps(WithOnSetAttrHook)) assert True is WOSAH.__attrs_own_setattr__ def test_slotted_class_can_have_custom_setattr(self): """ A slotted class can define a custom setattr and it doesn't get overwritten. Regression test for #680. """ @attr.s(slots=True) class A: def __setattr__(self, key, value): raise SystemError with pytest.raises(SystemError): A().x = 1 @pytest.mark.xfail(raises=attr.exceptions.FrozenAttributeError) def test_slotted_confused(self): """ If we have a in-between non-attrs class, setattr reset detection should still work, but currently doesn't. It works with dict classes because we can look the finished class and patch it. With slotted classes we have to deduce it ourselves. """ @attr.s(slots=True) class A: x = attr.ib(on_setattr=setters.frozen) class B(A): pass @attr.s(slots=True) class C(B): x = attr.ib() C(1).x = 2 def test_setattr_auto_detect_if_no_custom_setattr(self, slots): """ It's possible to remove the on_setattr hook from an attribute and therefore write a custom __setattr__. """ assert 1 == WithOnSetAttrHook(1).x @attr.s(auto_detect=True, slots=slots) class RemoveNeedForOurSetAttr(WithOnSetAttrHook): x = attr.ib() def __setattr__(self, name, val): object.__setattr__(self, name, val * 2) i = RemoveNeedForOurSetAttr(1) assert not RemoveNeedForOurSetAttr.__attrs_own_setattr__ assert 2 == i.x def test_setattr_restore_respects_auto_detect(self, slots): """ If __setattr__ should be restored but the user supplied its own and set auto_detect, leave is alone. """ @attr.s(auto_detect=True, slots=slots) class CustomSetAttr: def __setattr__(self, _, __): pass assert CustomSetAttr.__setattr__ != object.__setattr__ def test_setattr_auto_detect_frozen(self, slots): """ frozen=True together with a detected custom __setattr__ are rejected. """ with pytest.raises( ValueError, match="Can't freeze a class with a custom __setattr__" ): @attr.s(auto_detect=True, slots=slots, frozen=True) class CustomSetAttr(Frozen): def __setattr__(self, _, __): pass def test_setattr_auto_detect_on_setattr(self, slots): """ on_setattr attributes together with a detected custom __setattr__ are rejected. """ with pytest.raises( ValueError, match="Can't combine custom __setattr__ with on_setattr hooks", ): @attr.s(auto_detect=True, slots=slots) class HookAndCustomSetAttr: x = attr.ib(on_setattr=lambda *args: None) def __setattr__(self, _, __): pass @pytest.mark.parametrize("a_slots", [True, False]) @pytest.mark.parametrize("b_slots", [True, False]) @pytest.mark.parametrize("c_slots", [True, False]) def test_setattr_inherited_do_not_reset_intermediate( self, a_slots, b_slots, c_slots ): """ A user-provided intermediate __setattr__ is not reset to object.__setattr__. This only can work with auto_detect activated, such that attrs can know that there is a user-provided __setattr__. """ @attr.s(slots=a_slots) class A: x = attr.ib(on_setattr=setters.frozen) @attr.s(slots=b_slots, auto_detect=True) class B(A): x = attr.ib(on_setattr=setters.NO_OP) def __setattr__(self, key, value): raise SystemError @attr.s(slots=c_slots) class C(B): pass assert getattr(A, "__attrs_own_setattr__", False) is True assert getattr(B, "__attrs_own_setattr__", False) is False assert getattr(C, "__attrs_own_setattr__", False) is False with pytest.raises(SystemError): C(1).x = 3 def test_docstring(self): """ Generated __setattr__ has a useful docstring. """ assert ( "Method generated by attrs for class WithOnSetAttrHook." == WithOnSetAttrHook.__setattr__.__doc__ ) def test_setattr_converter_piped(self): """ If a converter is used, it is piped through the on_setattr hooks. Regression test for https://github.com/python-attrs/attrs/issues/1327 """ @attr.define # converter on setattr is implied in NG class C: x = attr.field(converter=[int]) c = C("1") c.x = "2" assert 2 == c.x