Skip to content

Commit fb2d7d0

Browse files
author
Zo Bot
committed
make_class: accept Attribute instances in 'these' (#1424)
When a class is rebuilt from attrs.fields() via attrs.make_class, the 'these' mapping contains Attribute instances rather than _CountingAttr objects. from_counting_attr was only reading the private _default, _validator, and _converter attributes, which Attribute does not expose, so the rebuild crashed with "'Attribute' object has no attribute '_default'". Detect an Attribute argument and read the public default/validator/ converter attributes directly. Add a regression test and a changelog entry.
1 parent 9cf1e26 commit fb2d7d0

3 files changed

Lines changed: 75 additions & 0 deletions

File tree

changelog.d/1424.change.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`attrs.make_class()` now works when its `attrs` argument is a dict of existing `attrs.Attribute` instances (e.g. from `attrs.fields(cls)`), not just fresh `attr.ib()` / `attrs.field()` call

src/attr/_make.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2570,6 +2570,36 @@ def from_counting_attr(
25702570
elif ca.type is not None:
25712571
msg = f"Type annotation and type argument cannot both be present for '{name}'."
25722572
raise ValueError(msg)
2573+
2574+
# `from_counting_attr` is also called with already-built `Attribute`
2575+
# instances (e.g. when a class is reconstructed from
2576+
# `attrs.fields(cls)` via `attrs.make_class`, see #1424). In that case
2577+
# `ca` is an `Attribute` and exposes the public `default`/`validator`/
2578+
# `converter` attributes rather than the `_default`/`_validator`/
2579+
# `_converter` internals of `_CountingAttr`.
2580+
if isinstance(ca, Attribute):
2581+
return cls(
2582+
name,
2583+
ca.default,
2584+
ca.validator,
2585+
ca.repr,
2586+
None,
2587+
ca.hash,
2588+
ca.init,
2589+
False,
2590+
ca.metadata,
2591+
type,
2592+
ca.converter,
2593+
kw_only if ca.kw_only is None else ca.kw_only,
2594+
ca.eq,
2595+
ca.eq_key,
2596+
ca.order,
2597+
ca.order_key,
2598+
ca.on_setattr,
2599+
ca.alias,
2600+
ca.alias_is_default,
2601+
)
2602+
25732603
return cls(
25742604
name,
25752605
ca._default,

tests/test_make.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,50 @@ def test_make_class_ordered(self):
15351535

15361536
assert "C(a=1, b=2)" == repr(C())
15371537

1538+
def test_make_class_from_attrs_fields(self):
1539+
"""
1540+
make_class can rebuild a class from attrs.fields() output, not only
1541+
fresh _CountingAttr / attr.ib() values.
1542+
1543+
https://github.com/python-attrs/attrs/issues/1424
1544+
"""
1545+
1546+
@attr.s
1547+
class Source:
1548+
a = attr.ib(type=int)
1549+
b = attr.ib(
1550+
default="x", validator=attr.validators.instance_of(str)
1551+
)
1552+
c = attr.ib(
1553+
default=0,
1554+
converter=attr.converters.optional(int),
1555+
)
1556+
1557+
# Convert the source's _CountingAttr-derived fields into proper
1558+
# Attribute instances via make_class, then re-run make_class on those
1559+
# Attribute instances. The second pass previously failed with
1560+
# "'Attribute' object has no attribute '_default'".
1561+
rebuilt = attr.make_class(
1562+
"Rebuilt",
1563+
{f.name: f for f in attr.fields(Source)},
1564+
)
1565+
1566+
# Order is preserved end-to-end.
1567+
assert ["a", "b", "c"] == [a.name for a in attr.fields(rebuilt)]
1568+
1569+
# `a` has no default and the rest do, so call with positional a and
1570+
# rely on defaults to fill b/c. `7` for c is converted via int.
1571+
ri = rebuilt(5)
1572+
assert 5 == ri.a
1573+
assert "x" == ri.b
1574+
assert 0 == ri.c
1575+
1576+
ri = rebuilt(5, "hello")
1577+
assert "hello" == ri.b
1578+
1579+
ri = rebuilt(5, "hello", "7")
1580+
assert 7 == ri.c
1581+
15381582
def test_generic_dynamic_class(self):
15391583
"""
15401584
make_class can create generic dynamic classes.

0 commit comments

Comments
 (0)