Skip to content

Commit dba782a

Browse files
authored
Merge branch 'main' into sphinx-attr-getter-ext
2 parents 5b6e458 + 862696a commit dba782a

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
@@ -492,6 +492,15 @@ def _transform_attrs(
492492

493493
attrs = base_attrs + own_attrs
494494

495+
# Resolve default field alias before executing field_transformer, so that
496+
# the transformer receives fully populated Attribute objects with usable
497+
# alias values.
498+
for a in attrs:
499+
if not a.alias:
500+
# Evolve is very slow, so we hold our nose and do it dirty.
501+
_OBJ_SETATTR.__get__(a)("alias", _default_init_alias_for(a.name))
502+
_OBJ_SETATTR.__get__(a)("alias_is_default", True)
503+
495504
if field_transformer is not None:
496505
attrs = tuple(field_transformer(cls, attrs))
497506

@@ -509,13 +518,12 @@ def _transform_attrs(
509518
if had_default is False and a.default is not NOTHING:
510519
had_default = True
511520

512-
# Resolve default field alias after executing field_transformer.
513-
# This allows field_transformer to differentiate between explicit vs
514-
# default aliases and supply their own defaults.
521+
# Resolve default field alias for any new attributes that the
522+
# field_transformer may have added without setting an alias.
515523
for a in attrs:
516524
if not a.alias:
517-
# Evolve is very slow, so we hold our nose and do it dirty.
518525
_OBJ_SETATTR.__get__(a)("alias", _default_init_alias_for(a.name))
526+
_OBJ_SETATTR.__get__(a)("alias_is_default", True)
519527

520528
# Create AttrsClass *after* applying the field_transformer since it may
521529
# add or remove attributes!
@@ -2464,6 +2472,8 @@ class Attribute:
24642472
- ``name`` (`str`): The name of the attribute.
24652473
- ``alias`` (`str`): The __init__ parameter name of the attribute, after
24662474
any explicit overrides and default private-attribute-name handling.
2475+
- ``alias_is_default`` (`bool`): Whether the ``alias`` was automatically
2476+
generated (``True``) or explicitly provided by the user (``False``).
24672477
- ``inherited`` (`bool`): Whether or not that attribute has been inherited
24682478
from a base class.
24692479
- ``eq_key`` and ``order_key`` (`typing.Callable` or `None`): The
@@ -2489,6 +2499,7 @@ class Attribute:
24892499
equality checks and hashing anymore.
24902500
.. versionadded:: 21.1.0 *eq_key* and *order_key*
24912501
.. versionadded:: 22.2.0 *alias*
2502+
.. versionadded:: 26.1.0 *alias_is_default*
24922503
24932504
For the full version history of the fields, see `attr.ib`.
24942505
"""
@@ -2513,6 +2524,7 @@ class Attribute:
25132524
"inherited",
25142525
"on_setattr",
25152526
"alias",
2527+
"alias_is_default",
25162528
)
25172529

25182530
def __init__(
@@ -2535,6 +2547,7 @@ def __init__(
25352547
order_key=None,
25362548
on_setattr=None,
25372549
alias=None,
2550+
alias_is_default=None,
25382551
):
25392552
eq, eq_key, order, order_key = _determine_attrib_eq_order(
25402553
cmp, eq_key or eq, order_key or order, True
@@ -2569,6 +2582,10 @@ def __init__(
25692582
bound_setattr("inherited", inherited)
25702583
bound_setattr("on_setattr", on_setattr)
25712584
bound_setattr("alias", alias)
2585+
bound_setattr(
2586+
"alias_is_default",
2587+
alias is None if alias_is_default is None else alias_is_default,
2588+
)
25722589

25732590
def __setattr__(self, name, value):
25742591
raise FrozenInstanceError
@@ -2604,6 +2621,7 @@ def from_counting_attr(
26042621
ca.order_key,
26052622
ca.on_setattr,
26062623
ca.alias,
2624+
ca.alias is None,
26072625
)
26082626

26092627
# Don't use attrs.evolve since fields(Attribute) doesn't work
@@ -2622,6 +2640,20 @@ def evolve(self, **changes):
26222640

26232641
new._setattrs(changes.items())
26242642

2643+
if "alias" in changes and "alias_is_default" not in changes:
2644+
# Explicit alias provided -- no longer the default.
2645+
_OBJ_SETATTR.__get__(new)("alias_is_default", False)
2646+
elif (
2647+
"name" in changes
2648+
and "alias" not in changes
2649+
# Don't auto-generate alias if the user picked picked the old one.
2650+
and self.alias_is_default
2651+
):
2652+
# Name changed, alias was auto-generated -- update it.
2653+
_OBJ_SETATTR.__get__(new)(
2654+
"alias", _default_init_alias_for(new.name)
2655+
)
2656+
26252657
return new
26262658

26272659
# Don't use _add_pickle since fields(Attribute) doesn't work
@@ -2638,6 +2670,17 @@ def __setstate__(self, state):
26382670
"""
26392671
Play nice with pickle.
26402672
"""
2673+
if len(state) < len(self.__slots__):
2674+
# Pre-26.1.0 pickle without alias_is_default -- infer it
2675+
# heuristically.
2676+
state_dict = dict(zip(self.__slots__, state))
2677+
alias_is_default = state_dict.get(
2678+
"alias"
2679+
) is None or state_dict.get("alias") == _default_init_alias_for(
2680+
state_dict["name"]
2681+
)
2682+
state = (*state, alias_is_default)
2683+
26412684
self._setattrs(zip(self.__slots__, state))
26422685

26432686
def _setattrs(self, name_values_pairs):
@@ -2661,7 +2704,7 @@ def _setattrs(self, name_values_pairs):
26612704
name=name,
26622705
default=NOTHING,
26632706
validator=None,
2664-
repr=True,
2707+
repr=(name != "alias_is_default"),
26652708
cmp=None,
26662709
eq=True,
26672710
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)