Skip to content

Commit

Permalink
Add tuple_keys to asdict
Browse files Browse the repository at this point in the history
See #646
  • Loading branch information
hynek committed Dec 15, 2021
1 parent 3833e4f commit e2663a8
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 30 deletions.
1 change: 1 addition & 0 deletions src/attr/__init__.pyi
Expand Up @@ -456,6 +456,7 @@ def asdict(
value_serializer: Optional[
Callable[[type, Attribute[Any], Any], Any]
] = ...,
tuple_keys: bool = ...,
) -> Dict[str, Any]: ...

# TODO: add support for returning NamedTuple from the mypy plugin
Expand Down
98 changes: 69 additions & 29 deletions src/attr/_funcs.py
Expand Up @@ -14,6 +14,7 @@ def asdict(
dict_factory=dict,
retain_collection_types=False,
value_serializer=None,
tuple_keys=False,
):
"""
Return the ``attrs`` attribute values of *inst* as a dict.
Expand All @@ -37,16 +38,26 @@ def asdict(
attribute or dict key/value. It receives the current instance, field
and value and must return the (updated) value. The hook is run *after*
the optional *filter* has been applied.
:param bool tuple_keys: If *retain_collection_types* is False, make
collection-esque dictionary serialize to tuples.
:rtype: return type of *dict_factory*
:raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs``
class.
:raise ValueError: if *retain_collection_types* and *tuple_keys* are both
True.
.. versionadded:: 16.0.0 *dict_factory*
.. versionadded:: 16.1.0 *retain_collection_types*
.. versionadded:: 20.3.0 *value_serializer*
.. versionadded:: 21.3.0 *tuple_keys*
"""
if retain_collection_types and tuple_keys:
raise ValueError(
"`retain_collection_types and `tuple_keys` are mutually exclusive."
)

attrs = fields(inst.__class__)
rv = dict_factory()
for a in attrs:
Expand All @@ -61,22 +72,25 @@ 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,
tuple_keys=tuple_keys,
)
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,
tuple_keys=tuple_keys,
filter=filter,
dict_factory=dict_factory,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
)
for i in v
]
Expand All @@ -87,17 +101,21 @@ def asdict(
(
_asdict_anything(
kk,
filter,
df,
retain_collection_types,
value_serializer,
is_key=True,
tuple_keys=tuple_keys,
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,
tuple_keys=tuple_keys,
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 +129,8 @@ def asdict(

def _asdict_anything(
val,
is_key,
tuple_keys,
filter,
dict_factory,
retain_collection_types,
Expand All @@ -123,22 +143,30 @@ 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 tuple_keys:
cf = tuple
else:
cf = list

rv = cf(
[
_asdict_anything(
i,
filter,
dict_factory,
retain_collection_types,
value_serializer,
is_key=is_key,
tuple_keys=tuple_keys,
filter=filter,
dict_factory=dict_factory,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
)
for i in val
]
Expand All @@ -148,10 +176,22 @@ def _asdict_anything(
rv = df(
(
_asdict_anything(
kk, filter, df, retain_collection_types, value_serializer
kk,
is_key=True,
tuple_keys=tuple_keys,
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,
tuple_keys=tuple_keys,
filter=filter,
dict_factory=df,
retain_collection_types=retain_collection_types,
value_serializer=value_serializer,
),
)
for kk, vv in iteritems(val)
Expand Down
25 changes: 24 additions & 1 deletion tests/test_funcs.py
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,29 @@ def test_asdict_preserve_order(self, cls):

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

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

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

instance = A({(1,): 1})
attr.asdict(instance, tuple_keys=True)

def test_tuple_keys_retain_caught(self, C):
"""
retain_collection_types and tuple_keys are mutually exclusive and raise
a ValueError if both are True.
"""
with pytest.raises(ValueError, match="mutually exclusive"):
attr.asdict(C(1, 2), retain_collection_types=True, tuple_keys=True)


class TestAsTuple(object):
"""
Expand Down

0 comments on commit e2663a8

Please sign in to comment.