attrs/tests/test_setattr.py

348 lines
9.0 KiB
Python

from __future__ import absolute_import, division, print_function
import pickle
import pytest
import attr
from attr import setters
from attr._compat import PY2
from attr.exceptions import FrozenAttributeError
from attr.validators import instance_of, matches_re
@attr.s(frozen=True)
class Frozen(object):
x = attr.ib()
@attr.s
class WithOnSetAttrHook(object):
x = attr.ib(on_setattr=lambda *args: None)
class TestSetAttr(object):
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(object):
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(object):
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(object):
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.
"""
s = [setters.convert, lambda _, __, nv: nv + 1]
@attr.s
class Piped(object):
x1 = attr.ib(converter=int, on_setattr=setters.pipe(*s))
x2 = attr.ib(converter=int, on_setattr=s)
p = Piped("41", "22")
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(object):
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(object):
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(object):
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(object):
x = attr.ib(on_setattr=setters.validate)
assert "Frozen classes can't use on_setattr." == ei.value.args[0]
@pytest.mark.parametrize("slots", [True, False])
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(object):
x = attr.ib(on_setattr=boom)
@attr.s(slots=slots)
class NoHook(WithOnSetAttrHook):
x = attr.ib()
if not PY2:
assert NoHook.__setattr__ == object.__setattr__
assert 1 == NoHook(1).x
@pytest.mark.parametrize("slots", [True, False])
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(object):
"""
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__
@pytest.mark.parametrize("slots", [True, False])
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__
@pytest.mark.skipif(PY2, reason="Python 3-only.")
class TestSetAttrNoPy2(object):
"""
__setattr__ tests for Py3+ to avoid the skip repetition.
"""
@pytest.mark.parametrize("slots", [True, False])
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 2 == i.x
@pytest.mark.parametrize("slots", [True, False])
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__
@pytest.mark.parametrize("slots", [True, False])
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
@pytest.mark.parametrize("slots", [True, False])
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(object):
x = attr.ib(on_setattr=lambda *args: None)
def __setattr__(self, _, __):
pass