| # 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 |