Repository: python-attrs/attrs
Pull request #395 (2018-06-16 17:04:45)

Feature/recursive asdict
# Pull Request Check List

This is just a reminder about the most common mistakes.  Please make sure that you tick all *appropriate* boxes.  But please read our [contribution guide](http://www.attrs.org/en/latest/contributing.html) at least once, it will save you unnecessary review cycles!

- [x] Added **tests** for changed code.
- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/master/tests/strategies.py).
- [ ] Updated **documentation** for changed code.
- [x] Documentation in `.rst` files is written using [semantic newlines](http://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).
- [x] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/master/changelog.d).


codecov[bot]: # [Codecov](https://codecov.io/gh/python-attrs/attrs/pull/395?src=pr&el=h1) Report
> Merging [#395](https://codecov.io/gh/python-attrs/attrs/pull/395?src=pr&el=desc) into [master](https://codecov.io/gh/python-attrs/attrs/commit/2834ee4a0cebfbe782b061131ee6cd24415d0380?src=pr&el=desc) will **increase** coverage by `4.97%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/python-attrs/attrs/pull/395/graphs/tree.svg?width=650&src=pr&token=BxTJx0Ibv7&height=150)](https://codecov.io/gh/python-attrs/attrs/pull/395?src=pr&el=tree)

```diff
@@           Coverage Diff            @@
##           master   #395      +/-   ##
========================================
+ Coverage   95.02%   100%   +4.97%     
========================================
  Files           9      9              
  Lines         844    855      +11     
  Branches      178    183       +5     
========================================
+ Hits          802    855      +53     
+ Misses         35      0      -35     
+ Partials        7      0       -7
```


| [Impacted Files](https://codecov.io/gh/python-attrs/attrs/pull/395?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/attr/\_funcs.py](https://codecov.io/gh/python-attrs/attrs/pull/395/diff?src=pr&el=tree#diff-c3JjL2F0dHIvX2Z1bmNzLnB5) | `100% <100%> (ø)` | :arrow_up: |
| [src/attr/\_make.py](https://codecov.io/gh/python-attrs/attrs/pull/395/diff?src=pr&el=tree#diff-c3JjL2F0dHIvX21ha2UucHk=) | `100% <0%> (+1.81%)` | :arrow_up: |
| [src/attr/\_compat.py](https://codecov.io/gh/python-attrs/attrs/pull/395/diff?src=pr&el=tree#diff-c3JjL2F0dHIvX2NvbXBhdC5weQ==) | `100% <0%> (+50%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/python-attrs/attrs/pull/395?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/python-attrs/attrs/pull/395?src=pr&el=footer). Last update [2834ee4...d985f96](https://codecov.io/gh/python-attrs/attrs/pull/395?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

hynek: Thanks!

Patch:
diff --git a/changelog.d/395.change.rst b/changelog.d/395.change.rst
new file mode 100644
index 0000000..dc91818
--- /dev/null
+++ b/changelog.d/395.change.rst
@@ -0,0 +1,1 @@
+``attr.asdict()`` now properly handles deeply nested lists and dictionaries.
diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py
index 730d53f..b61d239 100644
--- a/src/attr/_funcs.py
+++ b/src/attr/_funcs.py
@@ -50,20 +50,15 @@ def asdict(
         if recurse is True:
             if has(v.__class__):
                 rv[a.name] = asdict(
-                    v, recurse=True, filter=filter, dict_factory=dict_factory
+                    v, True, filter, dict_factory, retain_collection_types
                 )
             elif isinstance(v, (tuple, list, set)):
                 cf = v.__class__ if retain_collection_types is True else list
                 rv[a.name] = cf(
                     [
-                        asdict(
-                            i,
-                            recurse=True,
-                            filter=filter,
-                            dict_factory=dict_factory,
+                        _asdict_anything(
+                            i, filter, dict_factory, retain_collection_types
                         )
-                        if has(i.__class__)
-                        else i
                         for i in v
                     ]
                 )
@@ -71,12 +66,12 @@ def asdict(
                 df = dict_factory
                 rv[a.name] = df(
                     (
-                        asdict(kk, dict_factory=df)
-                        if has(kk.__class__)
-                        else kk,
-                        asdict(vv, dict_factory=df)
-                        if has(vv.__class__)
-                        else vv,
+                        _asdict_anything(
+                            kk, filter, df, retain_collection_types
+                        ),
+                        _asdict_anything(
+                            vv, filter, df, retain_collection_types
+                        ),
                     )
                     for kk, vv in iteritems(v)
                 )
@@ -87,6 +82,37 @@ def asdict(
     return rv
 
 
+def _asdict_anything(val, filter, dict_factory, retain_collection_types):
+    """
+    ``asdict`` only works on attrs instances, this works on anything.
+    """
+    if getattr(val.__class__, "__attrs_attrs__", None) is not None:
+        # Attrs class.
+        rv = asdict(val, True, filter, dict_factory, retain_collection_types)
+    elif isinstance(val, (tuple, list, set)):
+        cf = val.__class__ if retain_collection_types is True else list
+        rv = cf(
+            [
+                _asdict_anything(
+                    i, filter, dict_factory, retain_collection_types
+                )
+                for i in val
+            ]
+        )
+    elif isinstance(val, dict):
+        df = dict_factory
+        rv = df(
+            (
+                _asdict_anything(kk, filter, df, retain_collection_types),
+                _asdict_anything(vv, filter, df, retain_collection_types),
+            )
+            for kk, vv in iteritems(val)
+        )
+    else:
+        rv = val
+    return rv
+
+
 def astuple(
     inst,
     recurse=True,


Test patch:
diff --git a/tests/test_funcs.py b/tests/test_funcs.py
index 23236b2..1d85243 100644
--- a/tests/test_funcs.py
+++ b/tests/test_funcs.py
@@ -48,6 +48,24 @@ class TestAsDict(object):
             C(C(1, 2), C(3, 4)), dict_factory=dict_class
         )
 
+    def test_nested_lists(self, C):
+        """
+        Test unstructuring deeply nested lists.
+        """
+        inner = C(1, 2)
+        outer = C([[inner]], None)
+
+        assert {"x": [[{"x": 1, "y": 2}]], "y": None} == asdict(outer)
+
+    def test_nested_dicts(self, C):
+        """
+        Test unstructuring deeply nested dictionaries.
+        """
+        inner = C(1, 2)
+        outer = C({1: {2: inner}}, None)
+
+        assert {"x": {1: {2: {"x": 1, "y": 2}}}, "y": None} == asdict(outer)
+
     @given(nested_classes, st.sampled_from(MAPPING_TYPES))
     @settings(suppress_health_check=[HealthCheck.too_slow])
     def test_recurse_property(self, cls, dict_class):