From e2663a853538c069abd48e79139a175457f7f706 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Wed, 15 Dec 2021 09:19:02 +0100 Subject: [PATCH] Add tuple_keys to asdict See #646 --- src/attr/__init__.pyi | 1 + src/attr/_funcs.py | 98 ++++++++++++++++++++++++++++++------------- tests/test_funcs.py | 25 ++++++++++- 3 files changed, 94 insertions(+), 30 deletions(-) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index a2b23dcc6..fec7055f1 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -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 diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py index 73271c5d5..8e9916b5b 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -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. @@ -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: @@ -61,11 +72,12 @@ 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 @@ -73,10 +85,12 @@ def asdict( [ _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 ] @@ -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) @@ -111,6 +129,8 @@ def asdict( def _asdict_anything( val, + is_key, + tuple_keys, filter, dict_factory, retain_collection_types, @@ -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 ] @@ -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) diff --git a/tests/test_funcs.py b/tests/test_funcs.py index e957d0e38..24a201a56 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -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. """ @@ -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): """