Skip to content

Commit 862696a

Browse files
veeceeyhynek
andauthored
Resolve field aliases before calling field_transformer (#1509)
* Resolve field aliases before calling field_transformer Previously, field_transformer received attributes with alias=None for fields without an explicit alias. The default alias (e.g., stripping leading underscores) was only resolved after the transformer ran, making it impossible for transformers to access or use alias values. This moves alias resolution to before the field_transformer call, so transformers receive fully populated Attribute objects. A second pass after the transformer handles any new fields the transformer may have added. Additionally, Attribute.evolve() now automatically updates the alias when the name changes, if the alias was auto-generated (matching the default for the old name). Explicit aliases are preserved. Fixes #1479 * Fix CI failures: update doctest, add changelog, fix coverage - Update extending.md doctest to reflect that field_transformer now receives pre-resolved aliases (use alias == name.lstrip("_") instead of `not field.alias` to detect auto-generated aliases) - Add changelog entry for #1479 - Add test_hook_new_field_without_alias to cover the post-transformer alias resolution path (line 496 of _make.py) * Fix pre-commit * Change is neat but breaking Point change at PR, not bug. * Handle default alias detection explicitly * Fix docs build * We actually load 25.3-style --------- Co-authored-by: Hynek Schlawack <hs@ox.cx>
1 parent f3bcc37 commit 862696a

7 files changed

Lines changed: 301 additions & 9 deletions

File tree

changelog.d/1509.breaking.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Field aliases are now resolved *before* calling `field_transformer`, so transformers receive fully populated `Attribute` objects with usable `alias` values instead of `None`.
2+
The new `Attribute.alias_is_default` flag indicates whether the alias was auto-generated (`True`) or explicitly set by the user (`False`).

docs/extending.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,13 +248,14 @@ Data(a=3, b='spam', c=datetime.datetime(2020, 5, 4, 13, 37))
248248
```
249249

250250
Or, perhaps you would prefer to generate dataclass-compatible `__init__` signatures via a default field *alias*.
251-
Note, *field_transformer* operates on {class}`attrs.Attribute` instances before the default private-attribute handling is applied so explicit user-provided aliases can be detected.
251+
Note, *field_transformer* receives {class}`attrs.Attribute` instances with default aliases already resolved, so the leading-underscore stripping has already been applied.
252+
You can use the `attrs.Attribute.alias_is_default` flag to detect whether an alias was explicitly provided by the user or auto-generated.
252253

253254
```{doctest}
254255
>>> def dataclass_names(cls, fields):
255256
... return [
256257
... field.evolve(alias=field.name)
257-
... if not field.alias
258+
... if field.alias_is_default
258259
... else field
259260
... for field in fields
260261
... ]

src/attr/_make.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,15 @@ def _transform_attrs(
463463

464464
attrs = base_attrs + own_attrs
465465

466+
# Resolve default field alias before executing field_transformer, so that
467+
# the transformer receives fully populated Attribute objects with usable
468+
# alias values.
469+
for a in attrs:
470+
if not a.alias:
471+
# Evolve is very slow, so we hold our nose and do it dirty.
472+
_OBJ_SETATTR.__get__(a)("alias", _default_init_alias_for(a.name))
473+
_OBJ_SETATTR.__get__(a)("alias_is_default", True)
474+
466475
if field_transformer is not None:
467476
attrs = tuple(field_transformer(cls, attrs))
468477

@@ -480,13 +489,12 @@ def _transform_attrs(
480489
if had_default is False and a.default is not NOTHING:
481490
had_default = True
482491

483-
# Resolve default field alias after executing field_transformer.
484-
# This allows field_transformer to differentiate between explicit vs
485-
# default aliases and supply their own defaults.
492+
# Resolve default field alias for any new attributes that the
493+
# field_transformer may have added without setting an alias.
486494
for a in attrs:
487495
if not a.alias:
488-
# Evolve is very slow, so we hold our nose and do it dirty.
489496
_OBJ_SETATTR.__get__(a)("alias", _default_init_alias_for(a.name))
497+
_OBJ_SETATTR.__get__(a)("alias_is_default", True)
490498

491499
# Create AttrsClass *after* applying the field_transformer since it may
492500
# add or remove attributes!
@@ -2427,6 +2435,8 @@ class Attribute:
24272435
- ``name`` (`str`): The name of the attribute.
24282436
- ``alias`` (`str`): The __init__ parameter name of the attribute, after
24292437
any explicit overrides and default private-attribute-name handling.
2438+
- ``alias_is_default`` (`bool`): Whether the ``alias`` was automatically
2439+
generated (``True``) or explicitly provided by the user (``False``).
24302440
- ``inherited`` (`bool`): Whether or not that attribute has been inherited
24312441
from a base class.
24322442
- ``eq_key`` and ``order_key`` (`typing.Callable` or `None`): The
@@ -2452,6 +2462,7 @@ class Attribute:
24522462
equality checks and hashing anymore.
24532463
.. versionadded:: 21.1.0 *eq_key* and *order_key*
24542464
.. versionadded:: 22.2.0 *alias*
2465+
.. versionadded:: 26.1.0 *alias_is_default*
24552466
24562467
For the full version history of the fields, see `attr.ib`.
24572468
"""
@@ -2476,6 +2487,7 @@ class Attribute:
24762487
"inherited",
24772488
"on_setattr",
24782489
"alias",
2490+
"alias_is_default",
24792491
)
24802492

24812493
def __init__(
@@ -2498,6 +2510,7 @@ def __init__(
24982510
order_key=None,
24992511
on_setattr=None,
25002512
alias=None,
2513+
alias_is_default=None,
25012514
):
25022515
eq, eq_key, order, order_key = _determine_attrib_eq_order(
25032516
cmp, eq_key or eq, order_key or order, True
@@ -2532,6 +2545,10 @@ def __init__(
25322545
bound_setattr("inherited", inherited)
25332546
bound_setattr("on_setattr", on_setattr)
25342547
bound_setattr("alias", alias)
2548+
bound_setattr(
2549+
"alias_is_default",
2550+
alias is None if alias_is_default is None else alias_is_default,
2551+
)
25352552

25362553
def __setattr__(self, name, value):
25372554
raise FrozenInstanceError
@@ -2567,6 +2584,7 @@ def from_counting_attr(
25672584
ca.order_key,
25682585
ca.on_setattr,
25692586
ca.alias,
2587+
ca.alias is None,
25702588
)
25712589

25722590
# Don't use attrs.evolve since fields(Attribute) doesn't work
@@ -2585,6 +2603,20 @@ def evolve(self, **changes):
25852603

25862604
new._setattrs(changes.items())
25872605

2606+
if "alias" in changes and "alias_is_default" not in changes:
2607+
# Explicit alias provided -- no longer the default.
2608+
_OBJ_SETATTR.__get__(new)("alias_is_default", False)
2609+
elif (
2610+
"name" in changes
2611+
and "alias" not in changes
2612+
# Don't auto-generate alias if the user picked picked the old one.
2613+
and self.alias_is_default
2614+
):
2615+
# Name changed, alias was auto-generated -- update it.
2616+
_OBJ_SETATTR.__get__(new)(
2617+
"alias", _default_init_alias_for(new.name)
2618+
)
2619+
25882620
return new
25892621

25902622
# Don't use _add_pickle since fields(Attribute) doesn't work
@@ -2601,6 +2633,17 @@ def __setstate__(self, state):
26012633
"""
26022634
Play nice with pickle.
26032635
"""
2636+
if len(state) < len(self.__slots__):
2637+
# Pre-26.1.0 pickle without alias_is_default -- infer it
2638+
# heuristically.
2639+
state_dict = dict(zip(self.__slots__, state))
2640+
alias_is_default = state_dict.get(
2641+
"alias"
2642+
) is None or state_dict.get("alias") == _default_init_alias_for(
2643+
state_dict["name"]
2644+
)
2645+
state = (*state, alias_is_default)
2646+
26042647
self._setattrs(zip(self.__slots__, state))
26052648

26062649
def _setattrs(self, name_values_pairs):
@@ -2624,7 +2667,7 @@ def _setattrs(self, name_values_pairs):
26242667
name=name,
26252668
default=NOTHING,
26262669
validator=None,
2627-
repr=True,
2670+
repr=(name != "alias_is_default"),
26282671
cmp=None,
26292672
eq=True,
26302673
order=False,

tests/test_functional.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def test_fields(self, cls):
130130
hash=None,
131131
init=True,
132132
inherited=False,
133+
alias_is_default=True,
133134
),
134135
Attribute(
135136
name="y",
@@ -143,6 +144,7 @@ def test_fields(self, cls):
143144
hash=None,
144145
init=True,
145146
inherited=False,
147+
alias_is_default=True,
146148
),
147149
) == attr.fields(cls)
148150

@@ -201,6 +203,7 @@ def test_programmatic(self, slots, frozen):
201203
hash=None,
202204
init=True,
203205
inherited=False,
206+
alias_is_default=True,
204207
),
205208
Attribute(
206209
name="b",
@@ -214,6 +217,7 @@ def test_programmatic(self, slots, frozen):
214217
hash=None,
215218
init=True,
216219
inherited=False,
220+
alias_is_default=True,
217221
),
218222
) == attr.fields(PC)
219223

tests/test_hooks.py

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ class C:
178178
"eq=True, eq_key=None, order=True, order_key=None, "
179179
"hash=None, init=True, "
180180
"metadata=mappingproxy({'field_order': 1}), type='int', converter=None, "
181-
"kw_only=False, inherited=False, on_setattr=None, alias=None)",
181+
"kw_only=False, inherited=False, on_setattr=None, alias='x')",
182182
) == e.value.args
183183

184184
def test_hook_with_inheritance(self):
@@ -233,6 +233,150 @@ class Base:
233233

234234
assert ["x"] == [a.name for a in attr.fields(Base)]
235235

236+
def test_hook_alias_available(self):
237+
"""
238+
The field_transformer receives attributes with default aliases
239+
already resolved, not None.
240+
241+
Regression test for #1479.
242+
"""
243+
seen = []
244+
245+
def hook(cls, attribs):
246+
seen[:] = [(a.name, a.alias, a.alias_is_default) for a in attribs]
247+
return attribs
248+
249+
@attr.s(auto_attribs=True, field_transformer=hook)
250+
class C:
251+
_private: int
252+
_explicit: int = attr.ib(alias="_explicit")
253+
public: int
254+
255+
assert [
256+
("_private", "private", True),
257+
("_explicit", "_explicit", False),
258+
("public", "public", True),
259+
] == seen
260+
261+
def test_hook_evolve_name_updates_auto_alias(self):
262+
"""
263+
When a field_transformer evolves a field's name, the alias is
264+
automatically updated if it was auto-generated.
265+
266+
Regression test for #1479.
267+
"""
268+
269+
def hook(cls, attribs):
270+
return [a.evolve(name="renamed") for a in attribs]
271+
272+
@attr.s(auto_attribs=True, field_transformer=hook)
273+
class C:
274+
_original: int
275+
276+
f = attr.fields(C).renamed
277+
278+
assert "renamed" == f.alias
279+
assert f.alias_is_default is True
280+
281+
def test_hook_evolve_name_keeps_explicit_alias(self):
282+
"""
283+
When a field_transformer evolves a field's name but the field had
284+
an explicit alias, the alias is preserved.
285+
286+
Regression test for #1479.
287+
"""
288+
289+
def hook(cls, attribs):
290+
return [a.evolve(name="renamed") for a in attribs]
291+
292+
@attr.s(auto_attribs=True, field_transformer=hook)
293+
class C:
294+
original: int = attr.ib(alias="my_alias")
295+
296+
f = attr.fields(C).renamed
297+
298+
assert "my_alias" == f.alias
299+
assert f.alias_is_default is False
300+
301+
def test_hook_new_field_without_alias(self):
302+
"""
303+
When a field_transformer adds a brand-new field without setting an
304+
alias, the post-transformer alias resolution fills it in.
305+
306+
Regression test for #1479.
307+
"""
308+
309+
def hook(cls, attribs):
310+
return [
311+
*list(attribs),
312+
attr.Attribute(
313+
name="_extra",
314+
default=0,
315+
validator=None,
316+
repr=True,
317+
cmp=None,
318+
hash=None,
319+
init=True,
320+
metadata={},
321+
type=int,
322+
converter=None,
323+
kw_only=False,
324+
eq=True,
325+
eq_key=None,
326+
order=True,
327+
order_key=None,
328+
on_setattr=None,
329+
alias=None,
330+
inherited=False,
331+
),
332+
]
333+
334+
@attr.s(auto_attribs=True, field_transformer=hook)
335+
class C:
336+
x: int
337+
338+
f = attr.fields(C)._extra
339+
340+
assert "extra" == f.alias
341+
assert f.alias_is_default is True
342+
343+
def test_hook_explicit_alias_matching_default(self):
344+
"""
345+
When a user explicitly sets an alias that happens to equal the
346+
auto-generated default, alias_is_default is still False.
347+
348+
Regression test for #1479.
349+
"""
350+
351+
@attr.s(auto_attribs=True)
352+
class C:
353+
_private: int = attr.ib(alias="private")
354+
355+
f = attr.fields(C)._private
356+
357+
assert "private" == f.alias
358+
assert f.alias_is_default is False
359+
360+
def test_hook_evolve_alias_sets_not_default(self):
361+
"""
362+
When a field_transformer uses evolve() to set an explicit alias,
363+
alias_is_default becomes False.
364+
365+
Regression test for #1479.
366+
"""
367+
368+
def hook(cls, attribs):
369+
return [a.evolve(alias="custom") for a in attribs]
370+
371+
@attr.s(auto_attribs=True, field_transformer=hook)
372+
class C:
373+
x: int
374+
375+
f = attr.fields(C).x
376+
377+
assert "custom" == f.alias
378+
assert f.alias_is_default is False
379+
236380

237381
class TestAsDictHook:
238382
def test_asdict(self):

0 commit comments

Comments
 (0)