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