Skip to content

Commit e048efc

Browse files
jamesmurphy-mcpre-commit-ci[bot]hynek
authored
Perform attr order checks after field transformer. (#1401)
* Perform attr order checks after field transformer. Fixes: #1147 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix assert style in test_hooks --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Hynek Schlawack <hs@ox.cx>
1 parent 75723b7 commit e048efc

File tree

3 files changed

+80
-15
lines changed

3 files changed

+80
-15
lines changed

changelog.d/1147.change.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Checking mandatory vs non-mandatory attribute order is now performed after the field transformer, since the field transformer may change attributes and/or their order.

src/attr/_make.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,10 @@ def _transform_attrs(
449449

450450
attrs = base_attrs + own_attrs
451451

452+
if field_transformer is not None:
453+
attrs = field_transformer(cls, attrs)
454+
455+
# Check attr order after executing the field_transformer.
452456
# Mandatory vs non-mandatory attr order only matters when they are part of
453457
# the __init__ signature and when they aren't kw_only (which are moved to
454458
# the end and can be mandatory or non-mandatory in any order, as they will
@@ -462,9 +466,6 @@ def _transform_attrs(
462466
if had_default is False and a.default is not NOTHING:
463467
had_default = True
464468

465-
if field_transformer is not None:
466-
attrs = field_transformer(cls, attrs)
467-
468469
# Resolve default field alias after executing field_transformer.
469470
# This allows field_transformer to differentiate between explicit vs
470471
# default aliases and supply their own defaults.

tests/test_hooks.py

+75-12
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from datetime import datetime
66

7+
import pytest
8+
79
import attr
810

911

@@ -30,7 +32,7 @@ class C:
3032
y = attr.ib(type=int)
3133
z: float = attr.ib()
3234

33-
assert results == [("x", None), ("y", int), ("z", float)]
35+
assert [("x", None), ("y", int), ("z", float)] == results
3436

3537
def test_hook_applied_auto_attrib(self):
3638
"""
@@ -49,7 +51,7 @@ class C:
4951
x: int
5052
y: str = attr.ib()
5153

52-
assert results == [("x", int), ("y", str)]
54+
assert [("x", int), ("y", str)] == results
5355

5456
def test_hook_applied_modify_attrib(self):
5557
"""
@@ -66,7 +68,8 @@ class C:
6668
y: float
6769

6870
c = C(x="3", y="3.14")
69-
assert c == C(x=3, y=3.14)
71+
72+
assert C(x=3, y=3.14) == c
7073

7174
def test_hook_remove_field(self):
7275
"""
@@ -82,7 +85,7 @@ class C:
8285
x: int
8386
y: float
8487

85-
assert attr.asdict(C(2.7)) == {"y": 2.7}
88+
assert {"y": 2.7} == attr.asdict(C(2.7))
8689

8790
def test_hook_add_field(self):
8891
"""
@@ -98,7 +101,7 @@ def hook(cls, attribs):
98101
class C:
99102
x: int
100103

101-
assert attr.asdict(C(1, 2)) == {"x": 1, "new": 2}
104+
assert {"x": 1, "new": 2} == attr.asdict(C(1, 2))
102105

103106
def test_hook_override_alias(self):
104107
"""
@@ -118,13 +121,73 @@ class NameCase:
118121
1, 2, 3
119122
)
120123

124+
def test_hook_reorder_fields(self):
125+
"""
126+
It is possible to reorder fields via the hook.
127+
"""
128+
129+
def hook(cls, attribs):
130+
return sorted(attribs, key=lambda x: x.metadata["field_order"])
131+
132+
@attr.s(field_transformer=hook)
133+
class C:
134+
x: int = attr.ib(metadata={"field_order": 1})
135+
y: int = attr.ib(metadata={"field_order": 0})
136+
137+
assert {"x": 0, "y": 1} == attr.asdict(C(1, 0))
138+
139+
def test_hook_reorder_fields_before_order_check(self):
140+
"""
141+
It is possible to reorder fields via the hook before order-based errors are raised.
142+
143+
Regression test for #1147.
144+
"""
145+
146+
def hook(cls, attribs):
147+
return sorted(attribs, key=lambda x: x.metadata["field_order"])
148+
149+
@attr.s(field_transformer=hook)
150+
class C:
151+
x: int = attr.ib(metadata={"field_order": 1}, default=0)
152+
y: int = attr.ib(metadata={"field_order": 0})
153+
154+
assert {"x": 0, "y": 1} == attr.asdict(C(1))
155+
156+
def test_hook_conflicting_defaults_after_reorder(self):
157+
"""
158+
Raises `ValueError` if attributes with defaults are followed by
159+
mandatory attributes after the hook reorders fields.
160+
161+
Regression test for #1147.
162+
"""
163+
164+
def hook(cls, attribs):
165+
return sorted(attribs, key=lambda x: x.metadata["field_order"])
166+
167+
with pytest.raises(ValueError) as e:
168+
169+
@attr.s(field_transformer=hook)
170+
class C:
171+
x: int = attr.ib(metadata={"field_order": 1})
172+
y: int = attr.ib(metadata={"field_order": 0}, default=0)
173+
174+
assert (
175+
"No mandatory attributes allowed after an attribute with a "
176+
"default value or factory. Attribute in question: Attribute"
177+
"(name='x', default=NOTHING, validator=None, repr=True, "
178+
"eq=True, eq_key=None, order=True, order_key=None, "
179+
"hash=None, init=True, "
180+
"metadata=mappingproxy({'field_order': 1}), type='int', converter=None, "
181+
"kw_only=False, inherited=False, on_setattr=None, alias=None)",
182+
) == e.value.args
183+
121184
def test_hook_with_inheritance(self):
122185
"""
123186
The hook receives all fields from base classes.
124187
"""
125188

126189
def hook(cls, attribs):
127-
assert [a.name for a in attribs] == ["x", "y"]
190+
assert ["x", "y"] == [a.name for a in attribs]
128191
# Remove Base' "x"
129192
return attribs[1:]
130193

@@ -136,7 +199,7 @@ class Base:
136199
class Sub(Base):
137200
y: int
138201

139-
assert attr.asdict(Sub(2)) == {"y": 2}
202+
assert {"y": 2} == attr.asdict(Sub(2))
140203

141204
def test_attrs_attrclass(self):
142205
"""
@@ -151,7 +214,7 @@ class C:
151214
x: int
152215

153216
fields_type = type(attr.fields(C))
154-
assert fields_type.__name__ == "CAttributes"
217+
assert "CAttributes" == fields_type.__name__
155218
assert issubclass(fields_type, tuple)
156219

157220

@@ -187,12 +250,12 @@ class Parent:
187250
)
188251

189252
result = attr.asdict(inst, value_serializer=hook)
190-
assert result == {
253+
assert {
191254
"a": {"x": 1, "y": ["2020-07-01T00:00:00"]},
192255
"b": [{"x": 2, "y": ["2020-07-02T00:00:00"]}],
193256
"c": {"spam": {"x": 3, "y": ["2020-07-03T00:00:00"]}},
194257
"d": {"eggs": "2020-07-04T00:00:00"},
195-
}
258+
} == result
196259

197260
def test_asdict_calls(self):
198261
"""
@@ -217,12 +280,12 @@ class Parent:
217280
inst = Parent(a=Child(1), b=[Child(2)], c={"spam": Child(3)})
218281

219282
attr.asdict(inst, value_serializer=hook)
220-
assert calls == [
283+
assert [
221284
(inst, "a", inst.a),
222285
(inst.a, "x", inst.a.x),
223286
(inst, "b", inst.b),
224287
(inst.b[0], "x", inst.b[0].x),
225288
(inst, "c", inst.c),
226289
(None, None, "spam"),
227290
(inst.c["spam"], "x", inst.c["spam"].x),
228-
]
291+
] == calls

0 commit comments

Comments
 (0)