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