Repository: python-attrs/attrs (python)

Issue:
`asdict` fails for attributes of type Mapping with keys of type `Tuple`
```
@attr.s
class A:
    a = attr.ib()
        
instance = A({(1, ): 1})
attr.asdict(instance)
```
produces: 
`TypeError: unhashable type: 'list'` 

when the `retain_collection_types` flag is set to false (the default).  This can take a while to figure out, since the error is not the most descriptive (and the classes are not toy examples like this one).
I just thought it would be worth mentioning. In any case, thanks for this awesome library.

Pull request:
Serialize keys as tuples in asdict
# Summary

This allows to have collection-esque keys in dict.

I have decided to switch it on by default if retain_collection_types is false, because the current behavior is non-sensical.

Fixes #646

Needed for #887 

# Pull Request Check List

This is just a friendly reminder about the most common mistakes.  Please make sure that you tick all boxes.  But please read our [contribution guide](https://github.com/python-attrs/attrs/blob/main/.github/CONTRIBUTING.md) at least once, it will save you unnecessary review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing left to do.  If your pull request is a documentation fix or a trivial typo, feel free to delete the whole thing.

- [x] Added **tests** for changed code.
- [x] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py).
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [x] ...and used in the stub test file `tests/typing_example.py`.
- [x] Updated **documentation** for changed code.
    - [x] New functions/classes have to be added to `docs/api.rst` by hand.
    - [x] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).  Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/main/src/attr/__init__.py) file.
- [x] Documentation in `.rst` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [x] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/main/changelog.d).

If you have *any* questions to *any* of the points above, just **submit and ask**!  This checklist is here to *help* you, not to deter you from contributing!


Patch:
diff --git a/changelog.d/646.change.rst b/changelog.d/646.change.rst
new file mode 100644
index 0000000..aa3e389
--- /dev/null
+++ b/changelog.d/646.change.rst
@@ -0,0 +1,1 @@
+``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
new file mode 100644
index 0000000..aa3e389
--- /dev/null
+++ b/changelog.d/888.change.rst
@@ -0,0 +1,1 @@
+``attr.asdict(retain_collection_types=False)`` (default) dumps collection-esque keys as tuples.
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index a2b23dc..2af76b7 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: 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 73271c5..6ea2de0 100644
--- a/src/attr/_funcs.py
+++ b/src/attr/_funcs.py
@@ -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()
@@ -61,11 +63,11 @@ 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
@@ -73,10 +75,11 @@ def asdict(
                     [
                         _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
                     ]
@@ -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)
@@ -111,6 +116,7 @@ def asdict(
 
 def _asdict_anything(
     val,
+    is_key,
     filter,
     dict_factory,
     retain_collection_types,
@@ -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
             ]
@@ -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)


Test patch:
diff --git a/tests/test_funcs.py b/tests/test_funcs.py
index e957d0e..79dfdff 100644
--- a/tests/test_funcs.py
+++ b/tests/test_funcs.py
@@ -26,7 +26,7 @@ SEQUENCE_TYPES = (list, tuple)
 
 
 @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,37 @@ class TestAsDict(object):
 
         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):
     """
diff --git a/tests/typing_example.py b/tests/typing_example.py
index 75b41b8..3fced27 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(), retain_collection_types=False)