From 844dfa9d89fa681c7a96cb290138e498ba55d351 Mon Sep 17 00:00:00 2001 From: linhongkuan Date: Thu, 25 Jun 2026 07:02:53 +0800 Subject: [PATCH] Handle attrs.asdict sets of instances --- changelog.d/1180.change.md | 2 ++ src/attr/_funcs.py | 23 ++++++++++++++++++++--- tests/test_next_gen.py | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1180.change.md diff --git a/changelog.d/1180.change.md b/changelog.d/1180.change.md new file mode 100644 index 000000000..cb3a7b282 --- /dev/null +++ b/changelog.d/1180.change.md @@ -0,0 +1,2 @@ +`attrs.asdict()` now falls back to lists for sets and frozen sets whose +recursive contents become unhashable. diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py index 1adb50021..c989d20ff 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -25,6 +25,22 @@ ) +def _make_collection(collection_type, items): + """ + Create a collection from already-converted items. + + When retaining a set type, converted attrs instances can become + unhashable dictionaries. In that case, fall back to a list so recursive + serialization can still complete. + """ + try: + return collection_type(items) + except TypeError: + if collection_type in (set, frozenset): + return list(items) + raise + + def asdict( inst, recurse=True, @@ -114,7 +130,7 @@ def asdict( for i in v ] try: - rv[a.name] = cf(items) + rv[a.name] = _make_collection(cf, items) except TypeError: if not issubclass(cf, tuple): raise @@ -185,7 +201,8 @@ def _asdict_anything( else: cf = list - rv = cf( + rv = _make_collection( + cf, [ _asdict_anything( i, @@ -196,7 +213,7 @@ def _asdict_anything( value_serializer=value_serializer, ) for i in val - ] + ], ) elif issubclass(val_type, dict): df = dict_factory diff --git a/tests/test_next_gen.py b/tests/test_next_gen.py index 7241cfa28..ed54bac1a 100644 --- a/tests/test_next_gen.py +++ b/tests/test_next_gen.py @@ -448,6 +448,27 @@ def test_smoke(self): inst, retain_collection_types=True ) + @pytest.mark.parametrize("collection_type", [set, frozenset]) + def test_set_of_instances(self, collection_type): + """ + Sets and frozen sets of attrs instances fall back to lists after recursion. + + Since `attrs.asdict` always retains collection types, it used to try + to put the unstructured dictionaries back into a set. + """ + + @attrs.frozen + class Foo: + x: int + + @attrs.frozen + class Bar: + foos: set[Foo] + + assert attrs.asdict(Bar(collection_type([Foo(3)]))) == { + "foos": [{"x": 3}] + } + class TestProps: """