| # SPDX-License-Identifier: MIT | |
| from __future__ import annotations | |
| from datetime import datetime | |
| import pytest | |
| import attr | |
| class TestTransformHook: | |
| """ | |
| Tests for `attrs(tranform_value_serializer=func)` | |
| """ | |
| def test_hook_applied(self): | |
| """ | |
| The transform hook is applied to all attributes. Types can be missing, | |
| explicitly set, or annotated. | |
| """ | |
| results = [] | |
| def hook(cls, attribs): | |
| attr.resolve_types(cls, attribs=attribs) | |
| results[:] = [(a.name, a.type) for a in attribs] | |
| return attribs | |
| @attr.s(field_transformer=hook) | |
| class C: | |
| x = attr.ib() | |
| y = attr.ib(type=int) | |
| z: float = attr.ib() | |
| assert [("x", None), ("y", int), ("z", float)] == results | |
| def test_hook_applied_auto_attrib(self): | |
| """ | |
| The transform hook is applied to all attributes and type annotations | |
| are detected. | |
| """ | |
| results = [] | |
| def hook(cls, attribs): | |
| attr.resolve_types(cls, attribs=attribs) | |
| results[:] = [(a.name, a.type) for a in attribs] | |
| return attribs | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class C: | |
| x: int | |
| y: str = attr.ib() | |
| assert [("x", int), ("y", str)] == results | |
| def test_hook_applied_modify_attrib(self): | |
| """ | |
| The transform hook can modify attributes. | |
| """ | |
| def hook(cls, attribs): | |
| attr.resolve_types(cls, attribs=attribs) | |
| return [a.evolve(converter=a.type) for a in attribs] | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class C: | |
| x: int = attr.ib(converter=int) | |
| y: float | |
| c = C(x="3", y="3.14") | |
| assert C(x=3, y=3.14) == c | |
| def test_hook_remove_field(self): | |
| """ | |
| It is possible to remove fields via the hook. | |
| """ | |
| def hook(cls, attribs): | |
| attr.resolve_types(cls, attribs=attribs) | |
| return [a for a in attribs if a.type is not int] | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class C: | |
| x: int | |
| y: float | |
| assert {"y": 2.7} == attr.asdict(C(2.7)) | |
| def test_hook_add_field(self): | |
| """ | |
| It is possible to add fields via the hook. | |
| """ | |
| def hook(cls, attribs): | |
| a1 = attribs[0] | |
| a2 = a1.evolve(name="new") | |
| return [a1, a2] | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class C: | |
| x: int | |
| assert {"x": 1, "new": 2} == attr.asdict(C(1, 2)) | |
| def test_hook_override_alias(self): | |
| """ | |
| It is possible to set field alias via hook | |
| """ | |
| def use_dataclass_names(cls, attribs): | |
| return [a.evolve(alias=a.name) for a in attribs] | |
| @attr.s(auto_attribs=True, field_transformer=use_dataclass_names) | |
| class NameCase: | |
| public: int | |
| _private: int | |
| __dunder__: int | |
| assert NameCase(public=1, _private=2, __dunder__=3) == NameCase( | |
| 1, 2, 3 | |
| ) | |
| def test_hook_reorder_fields(self): | |
| """ | |
| It is possible to reorder fields via the hook. | |
| """ | |
| def hook(cls, attribs): | |
| return sorted(attribs, key=lambda x: x.metadata["field_order"]) | |
| @attr.s(field_transformer=hook) | |
| class C: | |
| x: int = attr.ib(metadata={"field_order": 1}) | |
| y: int = attr.ib(metadata={"field_order": 0}) | |
| assert {"x": 0, "y": 1} == attr.asdict(C(1, 0)) | |
| def test_hook_reorder_fields_before_order_check(self): | |
| """ | |
| It is possible to reorder fields via the hook before order-based errors are raised. | |
| Regression test for #1147. | |
| """ | |
| def hook(cls, attribs): | |
| return sorted(attribs, key=lambda x: x.metadata["field_order"]) | |
| @attr.s(field_transformer=hook) | |
| class C: | |
| x: int = attr.ib(metadata={"field_order": 1}, default=0) | |
| y: int = attr.ib(metadata={"field_order": 0}) | |
| assert {"x": 0, "y": 1} == attr.asdict(C(1)) | |
| def test_hook_conflicting_defaults_after_reorder(self): | |
| """ | |
| Raises `ValueError` if attributes with defaults are followed by | |
| mandatory attributes after the hook reorders fields. | |
| Regression test for #1147. | |
| """ | |
| def hook(cls, attribs): | |
| return sorted(attribs, key=lambda x: x.metadata["field_order"]) | |
| with pytest.raises(ValueError) as e: | |
| @attr.s(field_transformer=hook) | |
| class C: | |
| x: int = attr.ib(metadata={"field_order": 1}) | |
| y: int = attr.ib(metadata={"field_order": 0}, default=0) | |
| assert ( | |
| ( | |
| "No mandatory attributes allowed after an attribute with a " | |
| "default value or factory. Attribute in question: Attribute" | |
| "(name='x', default=NOTHING, validator=None, repr=True, " | |
| "eq=True, eq_key=None, order=True, order_key=None, " | |
| "hash=None, init=True, " | |
| "metadata=mappingproxy({'field_order': 1}), type='int', converter=None, " | |
| "kw_only=False, inherited=False, on_setattr=None, alias='x')" | |
| ), | |
| ) == e.value.args | |
| def test_hook_with_inheritance(self): | |
| """ | |
| The hook receives all fields from base classes. | |
| """ | |
| def hook(cls, attribs): | |
| assert ["x", "y"] == [a.name for a in attribs] | |
| # Remove Base' "x" | |
| return attribs[1:] | |
| @attr.s(auto_attribs=True) | |
| class Base: | |
| x: int | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class Sub(Base): | |
| y: int | |
| assert {"y": 2} == attr.asdict(Sub(2)) | |
| def test_attrs_attrclass(self): | |
| """ | |
| The list of attrs returned by a field_transformer is converted to | |
| "AttrsClass" again. | |
| Regression test for #821. | |
| """ | |
| @attr.s(auto_attribs=True, field_transformer=lambda c, a: list(a)) | |
| class C: | |
| x: int | |
| fields_type = type(attr.fields(C)) | |
| assert "CAttributes" == fields_type.__name__ | |
| assert issubclass(fields_type, tuple) | |
| def test_hook_generator(self): | |
| """ | |
| field_transfromers can be a generators. | |
| Regression test for #1416. | |
| """ | |
| def hook(cls, attribs): | |
| yield from attribs | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class Base: | |
| x: int | |
| assert ["x"] == [a.name for a in attr.fields(Base)] | |
| def test_hook_alias_available(self): | |
| """ | |
| The field_transformer receives attributes with default aliases | |
| already resolved, not None. | |
| Regression test for #1479. | |
| """ | |
| seen = [] | |
| def hook(cls, attribs): | |
| seen[:] = [(a.name, a.alias, a.alias_is_default) for a in attribs] | |
| return attribs | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class C: | |
| _private: int | |
| _explicit: int = attr.ib(alias="_explicit") | |
| public: int | |
| assert [ | |
| ("_private", "private", True), | |
| ("_explicit", "_explicit", False), | |
| ("public", "public", True), | |
| ] == seen | |
| def test_hook_evolve_name_updates_auto_alias(self): | |
| """ | |
| When a field_transformer evolves a field's name, the alias is | |
| automatically updated if it was auto-generated. | |
| Regression test for #1479. | |
| """ | |
| def hook(cls, attribs): | |
| return [a.evolve(name="renamed") for a in attribs] | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class C: | |
| _original: int | |
| f = attr.fields(C).renamed | |
| assert "renamed" == f.alias | |
| assert f.alias_is_default is True | |
| def test_hook_evolve_name_keeps_explicit_alias(self): | |
| """ | |
| When a field_transformer evolves a field's name but the field had | |
| an explicit alias, the alias is preserved. | |
| Regression test for #1479. | |
| """ | |
| def hook(cls, attribs): | |
| return [a.evolve(name="renamed") for a in attribs] | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class C: | |
| original: int = attr.ib(alias="my_alias") | |
| f = attr.fields(C).renamed | |
| assert "my_alias" == f.alias | |
| assert f.alias_is_default is False | |
| def test_hook_new_field_without_alias(self): | |
| """ | |
| When a field_transformer adds a brand-new field without setting an | |
| alias, the post-transformer alias resolution fills it in. | |
| Regression test for #1479. | |
| """ | |
| def hook(cls, attribs): | |
| return [ | |
| *list(attribs), | |
| attr.Attribute( | |
| name="_extra", | |
| default=0, | |
| validator=None, | |
| repr=True, | |
| cmp=None, | |
| hash=None, | |
| init=True, | |
| metadata={}, | |
| type=int, | |
| converter=None, | |
| kw_only=False, | |
| eq=True, | |
| eq_key=None, | |
| order=True, | |
| order_key=None, | |
| on_setattr=None, | |
| alias=None, | |
| inherited=False, | |
| ), | |
| ] | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class C: | |
| x: int | |
| f = attr.fields(C)._extra | |
| assert "extra" == f.alias | |
| assert f.alias_is_default is True | |
| def test_hook_explicit_alias_matching_default(self): | |
| """ | |
| When a user explicitly sets an alias that happens to equal the | |
| auto-generated default, alias_is_default is still False. | |
| Regression test for #1479. | |
| """ | |
| @attr.s(auto_attribs=True) | |
| class C: | |
| _private: int = attr.ib(alias="private") | |
| f = attr.fields(C)._private | |
| assert "private" == f.alias | |
| assert f.alias_is_default is False | |
| def test_hook_evolve_alias_sets_not_default(self): | |
| """ | |
| When a field_transformer uses evolve() to set an explicit alias, | |
| alias_is_default becomes False. | |
| Regression test for #1479. | |
| """ | |
| def hook(cls, attribs): | |
| return [a.evolve(alias="custom") for a in attribs] | |
| @attr.s(auto_attribs=True, field_transformer=hook) | |
| class C: | |
| x: int | |
| f = attr.fields(C).x | |
| assert "custom" == f.alias | |
| assert f.alias_is_default is False | |
| class TestAsDictHook: | |
| def test_asdict(self): | |
| """ | |
| asdict() calls the hooks in attrs classes and in other datastructures | |
| like lists or dicts. | |
| """ | |
| def hook(inst, a, v): | |
| if isinstance(v, datetime): | |
| return v.isoformat() | |
| return v | |
| @attr.dataclass | |
| class Child: | |
| x: datetime | |
| y: list[datetime] | |
| @attr.dataclass | |
| class Parent: | |
| a: Child | |
| b: list[Child] | |
| c: dict[str, Child] | |
| d: dict[str, datetime] | |
| inst = Parent( | |
| a=Child(1, [datetime(2020, 7, 1)]), | |
| b=[Child(2, [datetime(2020, 7, 2)])], | |
| c={"spam": Child(3, [datetime(2020, 7, 3)])}, | |
| d={"eggs": datetime(2020, 7, 4)}, | |
| ) | |
| result = attr.asdict(inst, value_serializer=hook) | |
| assert { | |
| "a": {"x": 1, "y": ["2020-07-01T00:00:00"]}, | |
| "b": [{"x": 2, "y": ["2020-07-02T00:00:00"]}], | |
| "c": {"spam": {"x": 3, "y": ["2020-07-03T00:00:00"]}}, | |
| "d": {"eggs": "2020-07-04T00:00:00"}, | |
| } == result | |
| def test_asdict_calls(self): | |
| """ | |
| The correct instances and attribute names are passed to the hook. | |
| """ | |
| calls = [] | |
| def hook(inst, a, v): | |
| calls.append((inst, a.name if a else a, v)) | |
| return v | |
| @attr.dataclass | |
| class Child: | |
| x: int | |
| @attr.dataclass | |
| class Parent: | |
| a: Child | |
| b: list[Child] | |
| c: dict[str, Child] | |
| inst = Parent(a=Child(1), b=[Child(2)], c={"spam": Child(3)}) | |
| attr.asdict(inst, value_serializer=hook) | |
| assert [ | |
| (inst, "a", inst.a), | |
| (inst.a, "x", inst.a.x), | |
| (inst, "b", inst.b), | |
| (inst.b[0], "x", inst.b[0].x), | |
| (inst, "c", inst.c), | |
| (None, None, "spam"), | |
| (inst.c["spam"], "x", inst.c["spam"].x), | |
| ] == calls |