Skip to content

Commit 888e523

Browse files
committed
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
1 parent c44b8b0 commit 888e523

3 files changed

Lines changed: 84 additions & 6 deletions

File tree

src/attr/_make.py

Lines changed: 23 additions & 4 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,
467+
# so that the transformer receives fully populated Attribute objects
468+
# with usable alias values instead of None.
469+
# See: https://github.com/python-attrs/attrs/issues/1479
470+
for a in attrs:
471+
if not a.alias:
472+
# Evolve is very slow, so we hold our nose and do it dirty.
473+
_OBJ_SETATTR.__get__(a)("alias", _default_init_alias_for(a.name))
474+
466475
if field_transformer is not None:
467476
attrs = tuple(field_transformer(cls, attrs))
468477

@@ -480,12 +489,10 @@ 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))
490497

491498
# Create AttrsClass *after* applying the field_transformer since it may
@@ -2585,6 +2592,18 @@ def evolve(self, **changes):
25852592

25862593
new._setattrs(changes.items())
25872594

2595+
# If the name changed but alias was not explicitly provided in
2596+
# changes, update the alias if it was auto-generated (i.e., it
2597+
# matches the default alias for the *old* name).
2598+
if (
2599+
"name" in changes
2600+
and "alias" not in changes
2601+
and self.alias == _default_init_alias_for(self.name)
2602+
):
2603+
_OBJ_SETATTR.__get__(new)(
2604+
"alias", _default_init_alias_for(new.name)
2605+
)
2606+
25882607
return new
25892608

25902609
# Don't use _add_pickle since fields(Attribute) doesn't work

tests/test_hooks.py

Lines changed: 60 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,65 @@ 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_aliases = []
244+
245+
def hook(cls, attribs):
246+
seen_aliases[:] = [(a.name, a.alias) 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"),
257+
("_explicit", "_explicit"),
258+
("public", "public"),
259+
] == seen_aliases
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+
assert "renamed" == attr.fields(C).renamed.alias
277+
278+
def test_hook_evolve_name_keeps_explicit_alias(self):
279+
"""
280+
When a field_transformer evolves a field's name but the field had
281+
an explicit alias, the alias is preserved.
282+
283+
Regression test for #1479.
284+
"""
285+
286+
def hook(cls, attribs):
287+
return [a.evolve(name="renamed") for a in attribs]
288+
289+
@attr.s(auto_attribs=True, field_transformer=hook)
290+
class C:
291+
original: int = attr.ib(alias="my_alias")
292+
293+
assert "my_alias" == attr.fields(C).renamed.alias
294+
236295

237296
class TestAsDictHook:
238297
def test_asdict(self):

tests/test_make.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ class C:
243243
"eq=True, eq_key=None, order=True, order_key=None, "
244244
"hash=None, init=True, "
245245
"metadata=mappingproxy({}), type=None, converter=None, "
246-
"kw_only=False, inherited=False, on_setattr=None, alias=None)",
246+
"kw_only=False, inherited=False, on_setattr=None, alias='y')",
247247
) == e.value.args
248248

249249
def test_kw_only(self):

0 commit comments

Comments
 (0)