Repository: python-attrs/attrs (python)

Issue:
NamedTuple serialization fails to work
MRE:
```python
import attrs
from typing import NamedTuple
class MyNamedTuple(NamedTuple):
    one: str
    two: int
@attrs.define
class MyAttrsClass:
    inner: MyNamedTuple
print(attrs.asdict(MyAttrsClass(MyNamedTuple("Hello!", 0))))
```
Expected output:
```
{'inner': MyNamedTuple(one='Hello!', two=0)}
```
or at least even a
```
{'inner': ('Hello!', 0)}
```
Instead we get 
```
File ~/.local/pipx/venvs/jupyter/lib/python3.11/site-packages/attr/_funcs.py:75, in asdict(inst, recurse, filter, dict_factory, retain_collection_types, value_serializer)
     73 elif isinstance(v, (tuple, list, set, frozenset)):
     74     cf = v.__class__ if retain_collection_types is True else list
---> 75     rv[a.name] = cf(
     76         [
     77             _asdict_anything(
     78                 i,
     79                 is_key=False,
     80                 filter=filter,
     81                 dict_factory=dict_factory,
     82                 retain_collection_types=retain_collection_types,
     83                 value_serializer=value_serializer,
     84             )
     85             for i in v
     86         ]
     87     )
     88 elif isinstance(v, dict):
     89     df = dict_factory

TypeError: MyNamedTuple.__new__() missing 1 required positional argument: 'two'
```

Python: 3.11.0
Attrs: 23.1.0

Pull request:
Fix TypeError for `asdict(<class with namedtuple>, retain_collection_types=True)`
# Summary

<!-- Please tell us what your pull request is about here. -->


# 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.
  Our CI fails if coverage is not 100%.
- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py).
- [ ] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [ ] ...and used in the stub test file `tests/typing_example.py`.
    - [ ] If they've been added to `attr/__init__.pyi`, they've *also* been re-imported in `attrs/__init__.pyi`.
- [ ] Updated **documentation** for changed code.
    - [ ] New functions/classes have to be added to `docs/api.rst` by hand.
    - [ ] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).
          The next version is the second number in the current release + 1.
          The first number represents the current year.
          So if the current version on PyPI is 22.2.0, the next version is gonna be 22.3.0.
          If the next version is the first in the new year, it'll be 23.1.0.
- [ ] 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).
- [x] Consider granting [push permissions to the PR branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork), so maintainers can fix minor issues themselves without pestering you.

<!--
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!
-->

Resolves #1164 

Patch:
diff --git a/changelog.d/1165.change.md b/changelog.d/1165.change.md
new file mode 100644
index 0000000..fb99d3a
--- /dev/null
+++ b/changelog.d/1165.change.md
@@ -0,0 +1,1 @@
+Fixed serialization of namedtuple fields using `attrs.asdict/astuple()` with `retain_collection_types=True`.
diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py
index 7f5d961..22609b1 100644
--- a/src/attr/_funcs.py
+++ b/src/attr/_funcs.py
@@ -72,19 +72,25 @@ def asdict(
                 )
             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,
-                            is_key=False,
-                            filter=filter,
-                            dict_factory=dict_factory,
-                            retain_collection_types=retain_collection_types,
-                            value_serializer=value_serializer,
-                        )
-                        for i in v
-                    ]
-                )
+                items = [
+                    _asdict_anything(
+                        i,
+                        is_key=False,
+                        filter=filter,
+                        dict_factory=dict_factory,
+                        retain_collection_types=retain_collection_types,
+                        value_serializer=value_serializer,
+                    )
+                    for i in v
+                ]
+                try:
+                    rv[a.name] = cf(items)
+                except TypeError:
+                    if not issubclass(cf, tuple):
+                        raise
+                    # Workaround for TypeError: cf.__new__() missing 1 required
+                    # positional argument (which appears, for a namedturle)
+                    rv[a.name] = cf(*items)
             elif isinstance(v, dict):
                 df = dict_factory
                 rv[a.name] = df(
@@ -241,22 +247,26 @@ def astuple(
                 )
             elif isinstance(v, (tuple, list, set, frozenset)):
                 cf = v.__class__ if retain is True else list
-                rv.append(
-                    cf(
-                        [
-                            astuple(
-                                j,
-                                recurse=True,
-                                filter=filter,
-                                tuple_factory=tuple_factory,
-                                retain_collection_types=retain,
-                            )
-                            if has(j.__class__)
-                            else j
-                            for j in v
-                        ]
+                items = [
+                    astuple(
+                        j,
+                        recurse=True,
+                        filter=filter,
+                        tuple_factory=tuple_factory,
+                        retain_collection_types=retain,
                     )
-                )
+                    if has(j.__class__)
+                    else j
+                    for j in v
+                ]
+                try:
+                    rv.append(cf(items))
+                except TypeError:
+                    if not issubclass(cf, tuple):
+                        raise
+                    # Workaround for TypeError: cf.__new__() missing 1 required
+                    # positional argument (which appears, for a namedturle)
+                    rv.append(cf(*items))
             elif isinstance(v, dict):
                 df = v.__class__ if retain is True else dict
                 rv.append(


Test patch:
diff --git a/tests/test_funcs.py b/tests/test_funcs.py
index 82f0f64..044aaab 100644
--- a/tests/test_funcs.py
+++ b/tests/test_funcs.py
@@ -4,9 +4,10 @@
 Tests for `attr._funcs`.
 """
 
+import re
 
 from collections import OrderedDict
-from typing import Generic, TypeVar
+from typing import Generic, NamedTuple, TypeVar
 
 import pytest
 
@@ -232,6 +233,52 @@ class TestAsDict:
 
         assert {"a": {(1,): 1}} == attr.asdict(instance)
 
+    def test_named_tuple_retain_type(self):
+        """
+        Namedtuples can be serialized if retain_collection_types is True.
+
+        See #1164
+        """
+
+        class Coordinates(NamedTuple):
+            lat: float
+            lon: float
+
+        @attr.s
+        class A:
+            coords: Coordinates = attr.ib()
+
+        instance = A(Coordinates(50.419019, 30.516225))
+
+        assert {"coords": Coordinates(50.419019, 30.516225)} == attr.asdict(
+            instance, retain_collection_types=True
+        )
+
+    def test_type_error_with_retain_type(self):
+        """
+        Serialization that fails with TypeError leaves the error through if
+        they're not tuples.
+
+        See #1164
+        """
+
+        message = "__new__() missing 1 required positional argument (asdict)"
+
+        class Coordinates(list):
+            def __init__(self, first, *rest):
+                if isinstance(first, list):
+                    raise TypeError(message)
+                super().__init__([first, *rest])
+
+        @attr.s
+        class A:
+            coords: Coordinates = attr.ib()
+
+        instance = A(Coordinates(50.419019, 30.516225))
+
+        with pytest.raises(TypeError, match=re.escape(message)):
+            attr.asdict(instance, retain_collection_types=True)
+
 
 class TestAsTuple:
     """
@@ -390,6 +437,52 @@ class TestAsTuple:
 
         assert (1, [1, 2, 3]) == d
 
+    def test_named_tuple_retain_type(self):
+        """
+        Namedtuples can be serialized if retain_collection_types is True.
+
+        See #1164
+        """
+
+        class Coordinates(NamedTuple):
+            lat: float
+            lon: float
+
+        @attr.s
+        class A:
+            coords: Coordinates = attr.ib()
+
+        instance = A(Coordinates(50.419019, 30.516225))
+
+        assert (Coordinates(50.419019, 30.516225),) == attr.astuple(
+            instance, retain_collection_types=True
+        )
+
+    def test_type_error_with_retain_type(self):
+        """
+        Serialization that fails with TypeError leaves the error through if
+        they're not tuples.
+
+        See #1164
+        """
+
+        message = "__new__() missing 1 required positional argument (astuple)"
+
+        class Coordinates(list):
+            def __init__(self, first, *rest):
+                if isinstance(first, list):
+                    raise TypeError(message)
+                super().__init__([first, *rest])
+
+        @attr.s
+        class A:
+            coords: Coordinates = attr.ib()
+
+        instance = A(Coordinates(50.419019, 30.516225))
+
+        with pytest.raises(TypeError, match=re.escape(message)):
+            attr.astuple(instance, retain_collection_types=True)
+
 
 class TestHas:
     """