| # SPDX-License-Identifier: MIT | |
| """ | |
| End-to-end tests. | |
| """ | |
| import copy | |
| import inspect | |
| import pickle | |
| from copy import deepcopy | |
| import pytest | |
| from hypothesis import given | |
| from hypothesis.strategies import booleans | |
| import attr | |
| from attr._compat import PY_3_13_PLUS | |
| from attr._make import NOTHING, Attribute | |
| from attr.exceptions import FrozenInstanceError | |
| @attr.s | |
| class C1: | |
| x = attr.ib(validator=attr.validators.instance_of(int)) | |
| y = attr.ib() | |
| @attr.s(slots=True) | |
| class C1Slots: | |
| x = attr.ib(validator=attr.validators.instance_of(int)) | |
| y = attr.ib() | |
| foo = None | |
| @attr.s() | |
| class C2: | |
| x = attr.ib(default=foo) | |
| y = attr.ib(default=attr.Factory(list)) | |
| @attr.s(slots=True) | |
| class C2Slots: | |
| x = attr.ib(default=foo) | |
| y = attr.ib(default=attr.Factory(list)) | |
| @attr.s | |
| class Base: | |
| x = attr.ib() | |
| def meth(self): | |
| return self.x | |
| @attr.s(slots=True) | |
| class BaseSlots: | |
| x = attr.ib() | |
| def meth(self): | |
| return self.x | |
| @attr.s | |
| class Sub(Base): | |
| y = attr.ib() | |
| @attr.s(slots=True) | |
| class SubSlots(BaseSlots): | |
| y = attr.ib() | |
| @attr.s(frozen=True, slots=True) | |
| class Frozen: | |
| x = attr.ib() | |
| @attr.s | |
| class SubFrozen(Frozen): | |
| y = attr.ib() | |
| @attr.s(frozen=True, slots=False) | |
| class FrozenNoSlots: | |
| x = attr.ib() | |
| class Meta(type): | |
| pass | |
| @attr.s | |
| class WithMeta(metaclass=Meta): | |
| pass | |
| @attr.s(slots=True) | |
| class WithMetaSlots(metaclass=Meta): | |
| pass | |
| FromMakeClass = attr.make_class("FromMakeClass", ["x"]) | |
| class TestFunctional: | |
| """ | |
| Functional tests. | |
| """ | |
| @pytest.mark.parametrize("cls", [C2, C2Slots]) | |
| def test_fields(self, cls): | |
| """ | |
| `attr.fields` works. | |
| """ | |
| assert ( | |
| Attribute( | |
| name="x", | |
| alias="x", | |
| default=foo, | |
| validator=None, | |
| repr=True, | |
| cmp=None, | |
| eq=True, | |
| order=True, | |
| hash=None, | |
| init=True, | |
| inherited=False, | |
| alias_is_default=True, | |
| ), | |
| Attribute( | |
| name="y", | |
| alias="y", | |
| default=attr.Factory(list), | |
| validator=None, | |
| repr=True, | |
| cmp=None, | |
| eq=True, | |
| order=True, | |
| hash=None, | |
| init=True, | |
| inherited=False, | |
| alias_is_default=True, | |
| ), | |
| ) == attr.fields(cls) | |
| @pytest.mark.parametrize("cls", [C1, C1Slots]) | |
| def test_asdict(self, cls): | |
| """ | |
| `attr.asdict` works. | |
| """ | |
| assert {"x": 1, "y": 2} == attr.asdict(cls(x=1, y=2)) | |
| @pytest.mark.parametrize("cls", [C1, C1Slots]) | |
| def test_validator(self, cls): | |
| """ | |
| `instance_of` raises `TypeError` on type mismatch. | |
| """ | |
| with pytest.raises(TypeError) as e: | |
| cls("1", 2) | |
| # Using C1 explicitly, since slotted classes don't support this. | |
| assert ( | |
| "'x' must be <class 'int'> (got '1' that is a <class 'str'>).", | |
| attr.fields(C1).x, | |
| int, | |
| "1", | |
| ) == e.value.args | |
| @given(booleans()) | |
| def test_renaming(self, slots): | |
| """ | |
| Private members are renamed but only in `__init__`. | |
| """ | |
| @attr.s(slots=slots) | |
| class C3: | |
| _x = attr.ib() | |
| assert "C3(_x=1)" == repr(C3(x=1)) | |
| @given(booleans(), booleans()) | |
| def test_programmatic(self, slots, frozen): | |
| """ | |
| `attr.make_class` works. | |
| """ | |
| PC = attr.make_class("PC", ["a", "b"], slots=slots, frozen=frozen) | |
| assert ( | |
| Attribute( | |
| name="a", | |
| alias="a", | |
| default=NOTHING, | |
| validator=None, | |
| repr=True, | |
| cmp=None, | |
| eq=True, | |
| order=True, | |
| hash=None, | |
| init=True, | |
| inherited=False, | |
| alias_is_default=True, | |
| ), | |
| Attribute( | |
| name="b", | |
| alias="b", | |
| default=NOTHING, | |
| validator=None, | |
| repr=True, | |
| cmp=None, | |
| eq=True, | |
| order=True, | |
| hash=None, | |
| init=True, | |
| inherited=False, | |
| alias_is_default=True, | |
| ), | |
| ) == attr.fields(PC) | |
| @pytest.mark.parametrize("cls", [Sub, SubSlots]) | |
| def test_subclassing_with_extra_attrs(self, cls): | |
| """ | |
| Subclassing (where the subclass has extra attrs) does what you'd hope | |
| for. | |
| """ | |
| obj = object() | |
| i = cls(x=obj, y=2) | |
| assert i.x is i.meth() is obj | |
| assert i.y == 2 | |
| if cls is Sub: | |
| assert f"Sub(x={obj}, y=2)" == repr(i) | |
| else: | |
| assert f"SubSlots(x={obj}, y=2)" == repr(i) | |
| @pytest.mark.parametrize("base", [Base, BaseSlots]) | |
| def test_subclass_without_extra_attrs(self, base): | |
| """ | |
| Subclassing (where the subclass does not have extra attrs) still | |
| behaves the same as a subclass with extra attrs. | |
| """ | |
| class Sub2(base): | |
| pass | |
| obj = object() | |
| i = Sub2(x=obj) | |
| assert i.x is i.meth() is obj | |
| assert f"Sub2(x={obj})" == repr(i) | |
| @pytest.mark.parametrize( | |
| "frozen_class", | |
| [ | |
| Frozen, # has slots=True | |
| attr.make_class("FrozenToo", ["x"], slots=False, frozen=True), | |
| ], | |
| ) | |
| def test_frozen_instance(self, frozen_class): | |
| """ | |
| Frozen instances can't be modified (easily). | |
| """ | |
| frozen = frozen_class(1) | |
| with pytest.raises( | |
| FrozenInstanceError, match="can't set attribute" | |
| ) as e: | |
| frozen.x = 2 | |
| assert e.value.msg == e.value.args[0] == "can't set attribute" | |
| assert 1 == frozen.x | |
| with pytest.raises( | |
| FrozenInstanceError, match="can't set attribute" | |
| ) as e: | |
| del frozen.x | |
| assert e.value.msg == e.value.args[0] == "can't set attribute" | |
| assert 1 == frozen.x | |
| @pytest.mark.parametrize( | |
| "cls", | |
| [ | |
| C1, | |
| C1Slots, | |
| C2, | |
| C2Slots, | |
| Base, | |
| BaseSlots, | |
| Sub, | |
| SubSlots, | |
| Frozen, | |
| FrozenNoSlots, | |
| FromMakeClass, | |
| ], | |
| ) | |
| @pytest.mark.parametrize("protocol", range(2, pickle.HIGHEST_PROTOCOL + 1)) | |
| def test_pickle_attributes(self, cls, protocol): | |
| """ | |
| Pickling/un-pickling of Attribute instances works. | |
| """ | |
| for attribute in attr.fields(cls): | |
| assert attribute == pickle.loads(pickle.dumps(attribute, protocol)) | |
| @pytest.mark.parametrize( | |
| "cls", | |
| [ | |
| C1, | |
| C1Slots, | |
| C2, | |
| C2Slots, | |
| Base, | |
| BaseSlots, | |
| Sub, | |
| SubSlots, | |
| Frozen, | |
| FrozenNoSlots, | |
| FromMakeClass, | |
| ], | |
| ) | |
| @pytest.mark.parametrize("protocol", range(2, pickle.HIGHEST_PROTOCOL + 1)) | |
| def test_pickle_object(self, cls, protocol): | |
| """ | |
| Pickle object serialization works on all kinds of attrs classes. | |
| """ | |
| obj = cls(123, 456) if len(attr.fields(cls)) == 2 else cls(123) | |
| assert repr(obj) == repr(pickle.loads(pickle.dumps(obj, protocol))) | |
| def test_subclassing_frozen_gives_frozen(self): | |
| """ | |
| The frozen-ness of classes is inherited. Subclasses of frozen classes | |
| are also frozen and can be instantiated. | |
| """ | |
| i = SubFrozen("foo", "bar") | |
| assert i.x == "foo" | |
| assert i.y == "bar" | |
| with pytest.raises(FrozenInstanceError): | |
| i.x = "baz" | |
| @pytest.mark.parametrize("cls", [WithMeta, WithMetaSlots]) | |
| def test_metaclass_preserved(self, cls): | |
| """ | |
| Metaclass data is preserved. | |
| """ | |
| assert Meta is type(cls) | |
| def test_default_decorator(self): | |
| """ | |
| Default decorator sets the default and the respective method gets | |
| called. | |
| """ | |
| @attr.s | |
| class C: | |
| x = attr.ib(default=1) | |
| y = attr.ib() | |
| @y.default | |
| def compute(self): | |
| return self.x + 1 | |
| assert C(1, 2) == C() | |
| @pytest.mark.parametrize("weakref_slot", [True, False]) | |
| def test_attrib_overwrite(self, slots, frozen, weakref_slot): | |
| """ | |
| Subclasses can overwrite attributes of their base class. | |
| """ | |
| @attr.s(slots=slots, frozen=frozen, weakref_slot=weakref_slot) | |
| class SubOverwrite(Base): | |
| x = attr.ib(default=attr.Factory(list)) | |
| assert SubOverwrite([]) == SubOverwrite() | |
| def test_dict_patch_class(self): | |
| """ | |
| dict-classes are never replaced. | |
| """ | |
| class C: | |
| x = attr.ib() | |
| C_new = attr.s(C) | |
| assert C_new is C | |
| def test_hash_by_id(self): | |
| """ | |
| With dict classes, hashing by ID is active for hash=False. This is | |
| incorrect behavior but we have to retain it for | |
| backwards-compatibility. | |
| """ | |
| @attr.s(unsafe_hash=False) | |
| class HashByIDBackwardCompat: | |
| x = attr.ib() | |
| assert hash(HashByIDBackwardCompat(1)) != hash( | |
| HashByIDBackwardCompat(1) | |
| ) | |
| @attr.s(unsafe_hash=False, eq=False) | |
| class HashByID: | |
| x = attr.ib() | |
| assert hash(HashByID(1)) != hash(HashByID(1)) | |
| @attr.s(unsafe_hash=True) | |
| class HashByValues: | |
| x = attr.ib() | |
| assert hash(HashByValues(1)) == hash(HashByValues(1)) | |
| def test_handles_different_defaults(self): | |
| """ | |
| Unhashable defaults + subclassing values work. | |
| """ | |
| @attr.s | |
| class Unhashable: | |
| pass | |
| @attr.s | |
| class C: | |
| x = attr.ib(default=Unhashable()) | |
| @attr.s | |
| class D(C): | |
| pass | |
| def test_unsafe_hash_false_eq_false(self, slots): | |
| """ | |
| unsafe_hash=False and eq=False make a class hashable by ID. | |
| """ | |
| @attr.s(unsafe_hash=False, eq=False, slots=slots) | |
| class C: | |
| pass | |
| assert hash(C()) != hash(C()) | |
| def test_hash_deprecated(self): | |
| """ | |
| Using the hash argument is deprecated. | |
| """ | |
| def test_eq_false(self, slots): | |
| """ | |
| eq=False makes a class hashable by ID. | |
| """ | |
| @attr.s(eq=False, slots=slots) | |
| class C: | |
| pass | |
| # Ensure both objects live long enough such that their ids/hashes | |
| # can't be recycled. Thanks to Ask Hjorth Larsen for pointing that | |
| # out. | |
| c1 = C() | |
| c2 = C() | |
| assert hash(c1) != hash(c2) | |
| def test_overwrite_base(self): | |
| """ | |
| Base classes can overwrite each other and the attributes are added | |
| in the order they are defined. | |
| """ | |
| @attr.s | |
| class C: | |
| c = attr.ib(default=100) | |
| x = attr.ib(default=1) | |
| b = attr.ib(default=23) | |
| @attr.s | |
| class D(C): | |
| a = attr.ib(default=42) | |
| x = attr.ib(default=2) | |
| d = attr.ib(default=3.14) | |
| @attr.s | |
| class E(D): | |
| y = attr.ib(default=3) | |
| z = attr.ib(default=4) | |
| assert "E(c=100, b=23, a=42, x=2, d=3.14, y=3, z=4)" == repr(E()) | |
| @pytest.mark.parametrize("base_slots", [True, False]) | |
| @pytest.mark.parametrize("sub_slots", [True, False]) | |
| @pytest.mark.parametrize("base_frozen", [True, False]) | |
| @pytest.mark.parametrize("sub_frozen", [True, False]) | |
| @pytest.mark.parametrize("base_weakref_slot", [True, False]) | |
| @pytest.mark.parametrize("sub_weakref_slot", [True, False]) | |
| @pytest.mark.parametrize("base_converter", [True, False]) | |
| @pytest.mark.parametrize("sub_converter", [True, False]) | |
| def test_frozen_slots_combo( | |
| self, | |
| base_slots, | |
| sub_slots, | |
| base_frozen, | |
| sub_frozen, | |
| base_weakref_slot, | |
| sub_weakref_slot, | |
| base_converter, | |
| sub_converter, | |
| ): | |
| """ | |
| A class with a single attribute, inheriting from another class | |
| with a single attribute. | |
| """ | |
| @attr.s( | |
| frozen=base_frozen, | |
| slots=base_slots, | |
| weakref_slot=base_weakref_slot, | |
| ) | |
| class Base: | |
| a = attr.ib(converter=int if base_converter else None) | |
| @attr.s( | |
| frozen=sub_frozen, slots=sub_slots, weakref_slot=sub_weakref_slot | |
| ) | |
| class Sub(Base): | |
| b = attr.ib(converter=int if sub_converter else None) | |
| i = Sub("1", "2") | |
| assert i.a == (1 if base_converter else "1") | |
| assert i.b == (2 if sub_converter else "2") | |
| if base_frozen or sub_frozen: | |
| with pytest.raises(FrozenInstanceError): | |
| i.a = "2" | |
| with pytest.raises(FrozenInstanceError): | |
| i.b = "3" | |
| def test_tuple_class_aliasing(self): | |
| """ | |
| itemgetter and property are legal attribute names. | |
| """ | |
| @attr.s | |
| class C: | |
| property = attr.ib() | |
| itemgetter = attr.ib() | |
| x = attr.ib() | |
| assert "property" == attr.fields(C).property.name | |
| assert "itemgetter" == attr.fields(C).itemgetter.name | |
| assert "x" == attr.fields(C).x.name | |
| def test_auto_exc(self, slots, frozen): | |
| """ | |
| Classes with auto_exc=True have a Exception-style __str__, compare and | |
| hash by id, and store the fields additionally in self.args. | |
| """ | |
| @attr.s(auto_exc=True, slots=slots, frozen=frozen) | |
| class FooError(Exception): | |
| x = attr.ib() | |
| y = attr.ib(init=False, default=42) | |
| z = attr.ib(init=False) | |
| a = attr.ib() | |
| FooErrorMade = attr.make_class( | |
| "FooErrorMade", | |
| bases=(Exception,), | |
| attrs={ | |
| "x": attr.ib(), | |
| "y": attr.ib(init=False, default=42), | |
| "z": attr.ib(init=False), | |
| "a": attr.ib(), | |
| }, | |
| auto_exc=True, | |
| slots=slots, | |
| frozen=frozen, | |
| ) | |
| assert FooError(1, "foo") != FooError(1, "foo") | |
| assert FooErrorMade(1, "foo") != FooErrorMade(1, "foo") | |
| for cls in (FooError, FooErrorMade): | |
| with pytest.raises(cls) as ei1: | |
| raise cls(1, "foo") | |
| with pytest.raises(cls) as ei2: | |
| raise cls(1, "foo") | |
| e1 = ei1.value | |
| e2 = ei2.value | |
| assert e1 is e1 | |
| assert e1 == e1 | |
| assert e2 == e2 | |
| assert e1 != e2 | |
| assert "(1, 'foo')" == str(e1) == str(e2) | |
| assert (1, "foo") == e1.args == e2.args | |
| hash(e1) == hash(e1) | |
| hash(e2) == hash(e2) | |
| if not frozen: | |
| deepcopy(e1) | |
| deepcopy(e2) | |
| def test_auto_exc_one_attrib(self, slots, frozen): | |
| """ | |
| Having one attribute works with auto_exc=True. | |
| Easy to get wrong with tuple literals. | |
| """ | |
| @attr.s(auto_exc=True, slots=slots, frozen=frozen) | |
| class FooError(Exception): | |
| x = attr.ib() | |
| FooError(1) | |
| def test_eq_only(self, slots, frozen): | |
| """ | |
| Classes with order=False cannot be ordered. | |
| """ | |
| @attr.s(eq=True, order=False, slots=slots, frozen=frozen) | |
| class C: | |
| x = attr.ib() | |
| possible_errors = ( | |
| "unorderable types: C() < C()", | |
| "'<' not supported between instances of 'C' and 'C'", | |
| "unorderable types: C < C", # old PyPy 3 | |
| ) | |
| with pytest.raises(TypeError) as ei: | |
| C(5) < C(6) | |
| assert ei.value.args[0] in possible_errors | |
| @pytest.mark.parametrize("cmp", [True, False]) | |
| def test_attrib_cmp_shortcut(self, slots, cmp): | |
| """ | |
| Setting cmp on `attr.ib`s sets both eq and order. | |
| """ | |
| @attr.s(slots=slots) | |
| class C: | |
| x = attr.ib(cmp=cmp) | |
| assert cmp is attr.fields(C).x.eq | |
| assert cmp is attr.fields(C).x.order | |
| def test_no_setattr_if_validate_without_validators(self, slots): | |
| """ | |
| If a class has on_setattr=attr.setters.validate (former default in NG | |
| APIs) but sets no validators, don't use the (slower) setattr in | |
| __init__. | |
| Regression test for #816. | |
| """ | |
| @attr.s(on_setattr=attr.setters.validate, slots=slots) | |
| class C: | |
| x = attr.ib() | |
| @attr.s(on_setattr=attr.setters.validate, slots=slots) | |
| class D(C): | |
| y = attr.ib() | |
| src = inspect.getsource(D.__init__) | |
| assert "setattr" not in src | |
| assert "self.x = x" in src | |
| assert "self.y = y" in src | |
| assert object.__setattr__ == D.__setattr__ | |
| def test_no_setattr_if_convert_without_converters(self, slots): | |
| """ | |
| If a class has on_setattr=attr.setters.convert but sets no validators, | |
| don't use the (slower) setattr in __init__. | |
| """ | |
| @attr.s(on_setattr=attr.setters.convert, slots=slots) | |
| class C: | |
| x = attr.ib() | |
| @attr.s(on_setattr=attr.setters.convert, slots=slots) | |
| class D(C): | |
| y = attr.ib() | |
| src = inspect.getsource(D.__init__) | |
| assert "setattr" not in src | |
| assert "self.x = x" in src | |
| assert "self.y = y" in src | |
| assert object.__setattr__ == D.__setattr__ | |
| def test_no_setattr_with_ng_defaults(self, slots): | |
| """ | |
| If a class has the NG default on_setattr=[convert, validate] but sets | |
| no validators or converters, don't use the (slower) setattr in | |
| __init__. | |
| """ | |
| @attr.define(slots=slots) | |
| class C: | |
| x = attr.ib() | |
| src = inspect.getsource(C.__init__) | |
| assert "setattr" not in src | |
| assert "self.x = x" in src | |
| assert object.__setattr__ == C.__setattr__ | |
| @attr.define(slots=slots) | |
| class D(C): | |
| y = attr.ib() | |
| src = inspect.getsource(D.__init__) | |
| assert "setattr" not in src | |
| assert "self.x = x" in src | |
| assert "self.y = y" in src | |
| assert object.__setattr__ == D.__setattr__ | |
| def test_on_setattr_detect_inherited_validators(self): | |
| """ | |
| _make_init detects the presence of a validator even if the field is | |
| inherited. | |
| """ | |
| @attr.s(on_setattr=attr.setters.validate) | |
| class C: | |
| x = attr.ib(validator=42) | |
| @attr.s(on_setattr=attr.setters.validate) | |
| class D(C): | |
| y = attr.ib() | |
| src = inspect.getsource(D.__init__) | |
| assert "_setattr = _cached_setattr_get(self)" in src | |
| assert "_setattr('x', x)" in src | |
| assert "_setattr('y', y)" in src | |
| assert object.__setattr__ != D.__setattr__ | |
| def test_unsafe_hash(self, slots): | |
| """ | |
| attr.s(unsafe_hash=True) makes a class hashable. | |
| """ | |
| @attr.s(slots=slots, unsafe_hash=True) | |
| class Hashable: | |
| pass | |
| assert hash(Hashable()) | |
| def test_init_subclass(self, slots): | |
| """ | |
| __attrs_init_subclass__ is called on subclasses. | |
| """ | |
| REGISTRY = [] | |
| @attr.s(slots=slots) | |
| class Base: | |
| @classmethod | |
| def __attrs_init_subclass__(cls): | |
| REGISTRY.append(cls) | |
| @attr.s(slots=slots) | |
| class ToRegister(Base): | |
| pass | |
| assert [ToRegister] == REGISTRY | |
| @pytest.mark.skipif(not PY_3_13_PLUS, reason="requires Python 3.13+") | |
| class TestReplace: | |
| def test_replaces(self): | |
| """ | |
| copy.replace() is added by default and works like `attrs.evolve`. | |
| """ | |
| inst = C1(1, 2) | |
| assert C1(1, 42) == copy.replace(inst, y=42) | |
| assert C1(42, 2) == copy.replace(inst, x=42) | |
| def test_already_has_one(self): | |
| """ | |
| If the object already has a __replace__, it's left alone. | |
| """ | |
| sentinel = object() | |
| @attr.s | |
| class C: | |
| x = attr.ib() | |
| __replace__ = sentinel | |
| assert sentinel == C.__replace__ | |
| def test_invalid_field_name(self): | |
| """ | |
| Invalid field names raise a TypeError. | |
| This is consistent with dataclasses. | |
| """ | |
| inst = C1(1, 2) | |
| with pytest.raises(TypeError): | |
| copy.replace(inst, z=42) | |
| def test_is_not_evolve(self): | |
| """ | |
| __replace__ is not the same as attrs.evolve. | |
| Regression test for #1606. | |
| """ | |
| inst = C1(1, 2) | |
| assert "<bound method C1.__replace__ of C1(x=1, y=2)>" == str( | |
| inst.__replace__ | |
| ) | |
| assert str(attr.evolve).startswith("<function evolve at") |