Skip to content

Commit

Permalink
Serialize keys as tuples in asdict (#888)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
hynek authored Dec 16, 2021
1 parent 1706793 commit 7b02220
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 30 deletions.
1 change: 1 addition & 0 deletions changelog.d/646.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``attr.asdict(retain_collection_types=False)`` (default) dumps collection-esque keys as tuples.
1 change: 1 addition & 0 deletions changelog.d/888.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``attr.asdict(retain_collection_types=False)`` (default) dumps collection-esque keys as tuples.
1 change: 1 addition & 0 deletions src/attr/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ def asdict(
value_serializer: Optional[
Callable[[type, Attribute[Any], Any], Any]
] = ...,
tuple_keys: Optional[bool] = ...,
) -> Dict[str, Any]: ...

# TODO: add support for returning NamedTuple from the mypy plugin
Expand Down
81 changes: 52 additions & 29 deletions src/attr/_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ def asdict(
.. versionadded:: 16.0.0 *dict_factory*
.. versionadded:: 16.1.0 *retain_collection_types*
.. versionadded:: 20.3.0 *value_serializer*
.. versionadded:: 21.3.0 If a dict has a collection for a key, it is
serialized as a tuple.
"""
attrs = fields(inst.__class__)
rv = dict_factory()
Expand All @@ -61,22 +63,23 @@ def asdict(
if has(v.__class__):
rv[a.name] = asdict(
v,
True,
filter,
dict_factory,
retain_collection_types,
value_serializer,
recurse=True,
filter=filter,
dict_factory=dict_factory,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
)
elif isinstance(v, (tuple, list, set, frozenset)):
cf = v.__class__ if retain_collection_types is True else list
rv[a.name] = cf(
[
_asdict_anything(
i,
filter,
dict_factory,
retain_collection_types,
value_serializer,
is_key=False,
filter=filter,
dict_factory=dict_factory,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
)
for i in v
]
Expand All @@ -87,17 +90,19 @@ def asdict(
(
_asdict_anything(
kk,
filter,
df,
retain_collection_types,
value_serializer,
is_key=True,
filter=filter,
dict_factory=df,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
),
_asdict_anything(
vv,
filter,
df,
retain_collection_types,
value_serializer,
is_key=False,
filter=filter,
dict_factory=df,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
),
)
for kk, vv in iteritems(v)
Expand All @@ -111,6 +116,7 @@ def asdict(

def _asdict_anything(
val,
is_key,
filter,
dict_factory,
retain_collection_types,
Expand All @@ -123,22 +129,29 @@ def _asdict_anything(
# Attrs class.
rv = asdict(
val,
True,
filter,
dict_factory,
retain_collection_types,
value_serializer,
recurse=True,
filter=filter,
dict_factory=dict_factory,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
)
elif isinstance(val, (tuple, list, set, frozenset)):
cf = val.__class__ if retain_collection_types is True else list
if retain_collection_types is True:
cf = val.__class__
elif is_key:
cf = tuple
else:
cf = list

rv = cf(
[
_asdict_anything(
i,
filter,
dict_factory,
retain_collection_types,
value_serializer,
is_key=False,
filter=filter,
dict_factory=dict_factory,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
)
for i in val
]
Expand All @@ -148,10 +161,20 @@ def _asdict_anything(
rv = df(
(
_asdict_anything(
kk, filter, df, retain_collection_types, value_serializer
kk,
is_key=True,
filter=filter,
dict_factory=df,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
),
_asdict_anything(
vv, filter, df, retain_collection_types, value_serializer
vv,
is_key=False,
filter=filter,
dict_factory=df,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
),
)
for kk, vv in iteritems(val)
Expand Down
33 changes: 32 additions & 1 deletion tests/test_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@


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

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

def test_retain_keys_are_tuples(self):
"""
retain_collect_types also retains keys.
"""

@attr.s
class A(object):
a = attr.ib()

instance = A({(1,): 1})

assert {"a": {(1,): 1}} == attr.asdict(
instance, retain_collection_types=True
)

def test_tuple_keys(self):
"""
If a key is collection type, retain_collection_types is False,
the key is serialized as a tuple.
See #646
"""

@attr.s
class A(object):
a = attr.ib()

instance = A({(1,): 1})

assert {"a": {(1,): 1}} == attr.asdict(instance)


class TestAsTuple(object):
"""
Expand Down
4 changes: 4 additions & 0 deletions tests/typing_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,7 @@ class FactoryTest:
class MatchArgs:
a: int = attr.ib()
b: int = attr.ib()


attr.asdict(FactoryTest())
attr.asdict(FactoryTest(), retain_collection_types=False)

0 comments on commit 7b02220

Please sign in to comment.