From a1221639df2d4866d5044b311e30157600bd4c7e Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Wed, 15 Dec 2021 09:19:02 +0100 Subject: [PATCH 1/6] 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): """ From 53a0006ff0e2a2ba9936896718757dbf57ac0e9d Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Wed, 15 Dec 2021 09:25:52 +0100 Subject: [PATCH 2/6] Add typing example --- tests/typing_example.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/typing_example.py b/tests/typing_example.py index 75b41b89d..e92921561 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -293,3 +293,7 @@ class FactoryTest: class MatchArgs: a: int = attr.ib() b: int = attr.ib() + + +attr.asdict(FactoryTest()) +attr.asdict(FactoryTest(), tuple_keys=True) From 6435098e0f0607e97f90312119fafe5b3ec0dc41 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Wed, 15 Dec 2021 09:29:13 +0100 Subject: [PATCH 3/6] Add newsfragments --- changelog.d/646.change.rst | 1 + changelog.d/888.change.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog.d/646.change.rst create mode 100644 changelog.d/888.change.rst diff --git a/changelog.d/646.change.rst b/changelog.d/646.change.rst new file mode 100644 index 000000000..f261763b7 --- /dev/null +++ b/changelog.d/646.change.rst @@ -0,0 +1 @@ +Added a *tuple_keys* argument to ``attr.asdict()``. diff --git a/changelog.d/888.change.rst b/changelog.d/888.change.rst new file mode 100644 index 000000000..f261763b7 --- /dev/null +++ b/changelog.d/888.change.rst @@ -0,0 +1 @@ +Added a *tuple_keys* argument to ``attr.asdict()``. From 734fb2e89ec083d5401ba35c1544122b780965f6 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Wed, 15 Dec 2021 09:33:29 +0100 Subject: [PATCH 4/6] Add missing test --- tests/test_funcs.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_funcs.py b/tests/test_funcs.py index 24a201a56..2b818f4c6 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -199,6 +199,21 @@ def test_asdict_preserve_order(self, cls): assert [a.name for a in fields(cls)] == list(dict_instance.keys()) + def test_retain_tuple_key(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, @@ -212,7 +227,8 @@ class A(object): a = attr.ib() instance = A({(1,): 1}) - attr.asdict(instance, tuple_keys=True) + + assert {"a": {(1,): 1}} == attr.asdict(instance, tuple_keys=True) def test_tuple_keys_retain_caught(self, C): """ From c2bd55a6cbd9d3fe6df9928cc2412299659fc038 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 16 Dec 2021 08:40:00 +0100 Subject: [PATCH 5/6] Switch it on by default --- changelog.d/888.change.rst | 1 + src/attr/__init__.pyi | 2 +- src/attr/_funcs.py | 10 +++++++--- tests/test_funcs.py | 4 +++- tests/typing_example.py | 1 + 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/changelog.d/888.change.rst b/changelog.d/888.change.rst index f261763b7..4e9210090 100644 --- a/changelog.d/888.change.rst +++ b/changelog.d/888.change.rst @@ -1 +1,2 @@ Added a *tuple_keys* argument to ``attr.asdict()``. +It's True by default, if *retain_collection_types* is False. diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index fec7055f1..2af76b7a8 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -456,7 +456,7 @@ def asdict( value_serializer: Optional[ Callable[[type, Attribute[Any], Any], Any] ] = ..., - tuple_keys: bool = ..., + tuple_keys: Optional[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 8e9916b5b..a269938b5 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -14,7 +14,7 @@ def asdict( dict_factory=dict, retain_collection_types=False, value_serializer=None, - tuple_keys=False, + tuple_keys=None, ): """ Return the ``attrs`` attribute values of *inst* as a dict. @@ -38,8 +38,9 @@ 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. + :param Optional[bool] tuple_keys: If *retain_collection_types* is False, + make collection-esque dictionary serialize to tuples. True by default + if *retain_collection_types* is False. :rtype: return type of *dict_factory* @@ -58,6 +59,9 @@ def asdict( "`retain_collection_types and `tuple_keys` are mutually exclusive." ) + if not retain_collection_types and tuple_keys is None: + tuple_keys = True + attrs = fields(inst.__class__) rv = dict_factory() for a in attrs: diff --git a/tests/test_funcs.py b/tests/test_funcs.py index 2b818f4c6..cdba28272 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -217,7 +217,8 @@ class A(object): 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. + and tuple_keys is True, the key is serialized as a tuple. It's True + by default if retain_collection_types is False. See #646 """ @@ -228,6 +229,7 @@ class A(object): instance = A({(1,): 1}) + assert {"a": {(1,): 1}} == attr.asdict(instance) assert {"a": {(1,): 1}} == attr.asdict(instance, tuple_keys=True) def test_tuple_keys_retain_caught(self, C): diff --git a/tests/typing_example.py b/tests/typing_example.py index e92921561..d60455c25 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -297,3 +297,4 @@ class MatchArgs: attr.asdict(FactoryTest()) attr.asdict(FactoryTest(), tuple_keys=True) +attr.asdict(FactoryTest(), tuple_keys=None) From 180c0b8363604e5fc1edb8d034abcfc99067bddf Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Thu, 16 Dec 2021 08:57:10 +0100 Subject: [PATCH 6/6] Let's not make buggy behavior configurable --- changelog.d/646.change.rst | 2 +- changelog.d/888.change.rst | 3 +-- src/attr/_funcs.py | 29 ++++------------------------- tests/test_funcs.py | 14 ++------------ tests/typing_example.py | 3 +-- 5 files changed, 9 insertions(+), 42 deletions(-) diff --git a/changelog.d/646.change.rst b/changelog.d/646.change.rst index f261763b7..aa3e3893d 100644 --- a/changelog.d/646.change.rst +++ b/changelog.d/646.change.rst @@ -1 +1 @@ -Added a *tuple_keys* argument to ``attr.asdict()``. +``attr.asdict(retain_collection_types=False)`` (default) dumps collection-esque keys as tuples. diff --git a/changelog.d/888.change.rst b/changelog.d/888.change.rst index 4e9210090..aa3e3893d 100644 --- a/changelog.d/888.change.rst +++ b/changelog.d/888.change.rst @@ -1,2 +1 @@ -Added a *tuple_keys* argument to ``attr.asdict()``. -It's True by default, if *retain_collection_types* is False. +``attr.asdict(retain_collection_types=False)`` (default) dumps collection-esque keys as tuples. diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py index a269938b5..6ea2de0a0 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -14,7 +14,6 @@ def asdict( dict_factory=dict, retain_collection_types=False, value_serializer=None, - tuple_keys=None, ): """ Return the ``attrs`` attribute values of *inst* as a dict. @@ -38,30 +37,18 @@ 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 Optional[bool] tuple_keys: If *retain_collection_types* is False, - make collection-esque dictionary serialize to tuples. True by default - if *retain_collection_types* is False. :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* + .. versionadded:: 21.3.0 If a dict has a collection for a key, it is + serialized as a tuple. """ - if retain_collection_types and tuple_keys: - raise ValueError( - "`retain_collection_types and `tuple_keys` are mutually exclusive." - ) - - if not retain_collection_types and tuple_keys is None: - tuple_keys = True - attrs = fields(inst.__class__) rv = dict_factory() for a in attrs: @@ -81,7 +68,6 @@ def asdict( 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 @@ -90,7 +76,6 @@ def asdict( _asdict_anything( i, is_key=False, - tuple_keys=tuple_keys, filter=filter, dict_factory=dict_factory, retain_collection_types=retain_collection_types, @@ -106,7 +91,6 @@ def asdict( _asdict_anything( kk, is_key=True, - tuple_keys=tuple_keys, filter=filter, dict_factory=df, retain_collection_types=retain_collection_types, @@ -115,7 +99,6 @@ def asdict( _asdict_anything( vv, is_key=False, - tuple_keys=tuple_keys, filter=filter, dict_factory=df, retain_collection_types=retain_collection_types, @@ -134,7 +117,6 @@ def asdict( def _asdict_anything( val, is_key, - tuple_keys, filter, dict_factory, retain_collection_types, @@ -156,7 +138,7 @@ def _asdict_anything( elif isinstance(val, (tuple, list, set, frozenset)): if retain_collection_types is True: cf = val.__class__ - elif tuple_keys: + elif is_key: cf = tuple else: cf = list @@ -165,8 +147,7 @@ def _asdict_anything( [ _asdict_anything( i, - is_key=is_key, - tuple_keys=tuple_keys, + is_key=False, filter=filter, dict_factory=dict_factory, retain_collection_types=retain_collection_types, @@ -182,7 +163,6 @@ def _asdict_anything( _asdict_anything( kk, is_key=True, - tuple_keys=tuple_keys, filter=filter, dict_factory=df, retain_collection_types=retain_collection_types, @@ -191,7 +171,6 @@ def _asdict_anything( _asdict_anything( vv, is_key=False, - tuple_keys=tuple_keys, filter=filter, dict_factory=df, retain_collection_types=retain_collection_types, diff --git a/tests/test_funcs.py b/tests/test_funcs.py index cdba28272..79dfdffe2 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -199,7 +199,7 @@ def test_asdict_preserve_order(self, cls): assert [a.name for a in fields(cls)] == list(dict_instance.keys()) - def test_retain_tuple_key(self): + def test_retain_keys_are_tuples(self): """ retain_collect_types also retains keys. """ @@ -217,8 +217,7 @@ class A(object): 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. It's True - by default if retain_collection_types is False. + the key is serialized as a tuple. See #646 """ @@ -230,15 +229,6 @@ class A(object): instance = A({(1,): 1}) assert {"a": {(1,): 1}} == attr.asdict(instance) - assert {"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): diff --git a/tests/typing_example.py b/tests/typing_example.py index d60455c25..3fced27ee 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -296,5 +296,4 @@ class MatchArgs: attr.asdict(FactoryTest()) -attr.asdict(FactoryTest(), tuple_keys=True) -attr.asdict(FactoryTest(), tuple_keys=None) +attr.asdict(FactoryTest(), retain_collection_types=False)