Skip to content

Commit 7b02220

Browse files
authored
Serialize keys as tuples in asdict (#888)
* Add tuple_keys to asdict See #646 * Add typing example * Add newsfragments * Add missing test * Switch it on by default * Let's not make buggy behavior configurable
1 parent 1706793 commit 7b02220

File tree

6 files changed

+91
-30
lines changed

6 files changed

+91
-30
lines changed

changelog.d/646.change.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
``attr.asdict(retain_collection_types=False)`` (default) dumps collection-esque keys as tuples.

changelog.d/888.change.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
``attr.asdict(retain_collection_types=False)`` (default) dumps collection-esque keys as tuples.

src/attr/__init__.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ def asdict(
456456
value_serializer: Optional[
457457
Callable[[type, Attribute[Any], Any], Any]
458458
] = ...,
459+
tuple_keys: Optional[bool] = ...,
459460
) -> Dict[str, Any]: ...
460461

461462
# TODO: add support for returning NamedTuple from the mypy plugin

src/attr/_funcs.py

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ def asdict(
4646
.. versionadded:: 16.0.0 *dict_factory*
4747
.. versionadded:: 16.1.0 *retain_collection_types*
4848
.. versionadded:: 20.3.0 *value_serializer*
49+
.. versionadded:: 21.3.0 If a dict has a collection for a key, it is
50+
serialized as a tuple.
4951
"""
5052
attrs = fields(inst.__class__)
5153
rv = dict_factory()
@@ -61,22 +63,23 @@ def asdict(
6163
if has(v.__class__):
6264
rv[a.name] = asdict(
6365
v,
64-
True,
65-
filter,
66-
dict_factory,
67-
retain_collection_types,
68-
value_serializer,
66+
recurse=True,
67+
filter=filter,
68+
dict_factory=dict_factory,
69+
retain_collection_types=retain_collection_types,
70+
value_serializer=value_serializer,
6971
)
7072
elif isinstance(v, (tuple, list, set, frozenset)):
7173
cf = v.__class__ if retain_collection_types is True else list
7274
rv[a.name] = cf(
7375
[
7476
_asdict_anything(
7577
i,
76-
filter,
77-
dict_factory,
78-
retain_collection_types,
79-
value_serializer,
78+
is_key=False,
79+
filter=filter,
80+
dict_factory=dict_factory,
81+
retain_collection_types=retain_collection_types,
82+
value_serializer=value_serializer,
8083
)
8184
for i in v
8285
]
@@ -87,17 +90,19 @@ def asdict(
8790
(
8891
_asdict_anything(
8992
kk,
90-
filter,
91-
df,
92-
retain_collection_types,
93-
value_serializer,
93+
is_key=True,
94+
filter=filter,
95+
dict_factory=df,
96+
retain_collection_types=retain_collection_types,
97+
value_serializer=value_serializer,
9498
),
9599
_asdict_anything(
96100
vv,
97-
filter,
98-
df,
99-
retain_collection_types,
100-
value_serializer,
101+
is_key=False,
102+
filter=filter,
103+
dict_factory=df,
104+
retain_collection_types=retain_collection_types,
105+
value_serializer=value_serializer,
101106
),
102107
)
103108
for kk, vv in iteritems(v)
@@ -111,6 +116,7 @@ def asdict(
111116

112117
def _asdict_anything(
113118
val,
119+
is_key,
114120
filter,
115121
dict_factory,
116122
retain_collection_types,
@@ -123,22 +129,29 @@ def _asdict_anything(
123129
# Attrs class.
124130
rv = asdict(
125131
val,
126-
True,
127-
filter,
128-
dict_factory,
129-
retain_collection_types,
130-
value_serializer,
132+
recurse=True,
133+
filter=filter,
134+
dict_factory=dict_factory,
135+
retain_collection_types=retain_collection_types,
136+
value_serializer=value_serializer,
131137
)
132138
elif isinstance(val, (tuple, list, set, frozenset)):
133-
cf = val.__class__ if retain_collection_types is True else list
139+
if retain_collection_types is True:
140+
cf = val.__class__
141+
elif is_key:
142+
cf = tuple
143+
else:
144+
cf = list
145+
134146
rv = cf(
135147
[
136148
_asdict_anything(
137149
i,
138-
filter,
139-
dict_factory,
140-
retain_collection_types,
141-
value_serializer,
150+
is_key=False,
151+
filter=filter,
152+
dict_factory=dict_factory,
153+
retain_collection_types=retain_collection_types,
154+
value_serializer=value_serializer,
142155
)
143156
for i in val
144157
]
@@ -148,10 +161,20 @@ def _asdict_anything(
148161
rv = df(
149162
(
150163
_asdict_anything(
151-
kk, filter, df, retain_collection_types, value_serializer
164+
kk,
165+
is_key=True,
166+
filter=filter,
167+
dict_factory=df,
168+
retain_collection_types=retain_collection_types,
169+
value_serializer=value_serializer,
152170
),
153171
_asdict_anything(
154-
vv, filter, df, retain_collection_types, value_serializer
172+
vv,
173+
is_key=False,
174+
filter=filter,
175+
dict_factory=df,
176+
retain_collection_types=retain_collection_types,
177+
value_serializer=value_serializer,
155178
),
156179
)
157180
for kk, vv in iteritems(val)

tests/test_funcs.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727

2828
@pytest.fixture(scope="session", name="C")
29-
def fixture_C():
29+
def _C():
3030
"""
3131
Return a simple but fully featured attrs class with an x and a y attribute.
3232
"""
@@ -199,6 +199,37 @@ def test_asdict_preserve_order(self, cls):
199199

200200
assert [a.name for a in fields(cls)] == list(dict_instance.keys())
201201

202+
def test_retain_keys_are_tuples(self):
203+
"""
204+
retain_collect_types also retains keys.
205+
"""
206+
207+
@attr.s
208+
class A(object):
209+
a = attr.ib()
210+
211+
instance = A({(1,): 1})
212+
213+
assert {"a": {(1,): 1}} == attr.asdict(
214+
instance, retain_collection_types=True
215+
)
216+
217+
def test_tuple_keys(self):
218+
"""
219+
If a key is collection type, retain_collection_types is False,
220+
the key is serialized as a tuple.
221+
222+
See #646
223+
"""
224+
225+
@attr.s
226+
class A(object):
227+
a = attr.ib()
228+
229+
instance = A({(1,): 1})
230+
231+
assert {"a": {(1,): 1}} == attr.asdict(instance)
232+
202233

203234
class TestAsTuple(object):
204235
"""

tests/typing_example.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,7 @@ class FactoryTest:
293293
class MatchArgs:
294294
a: int = attr.ib()
295295
b: int = attr.ib()
296+
297+
298+
attr.asdict(FactoryTest())
299+
attr.asdict(FactoryTest(), retain_collection_types=False)

0 commit comments

Comments
 (0)