Repository: litestar-org/litestar

Issue:
DTO: fix data transfer when annotations are generic sequences
In this repo, a handler that returns a `list` is annotated to return a `Sequence`, which causes an error when we try to instantiate the return annotation type.

```py
Traceback (most recent call last):
  File "/workspace/app/.venv/lib/python3.11/site-packages/litestar/middleware/exceptions/middleware.py", line 150, in __call__
    await self.app(scope, receive, send)
  File "/workspace/app/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 77, in handle
    response = await self._get_response_for_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/app/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 129, in _get_response_for_request
    response = await self._call_handler_function(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/app/.venv/lib/python3.11/site-packages/litestar/routes/http.py", line 162, in _call_handler_function
    response: ASGIApp = await route_handler.to_response(app=scope["app"], data=response_data, request=request)
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/app/.venv/lib/python3.11/site-packages/litestar/handlers/http_handlers/base.py", line 469, in to_response
    return await response_handler(app=app, data=data, request=request, return_dto=self.resolve_return_dto())  # type: ignore
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/app/.venv/lib/python3.11/site-packages/litestar/handlers/http_handlers/_utils.py", line 99, in handler
    data = return_dto(ctx).data_to_encodable_type(data)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/app/.venv/lib/python3.11/site-packages/litestar/dto/factory/abc.py", line 103, in data_to_encodable_type
    return backend.encode_data(data, self.connection_context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/app/.venv/lib/python3.11/site-packages/litestar/dto/factory/_backends/abc.py", line 300, in encode_data
    return transfer_data(
           ^^^^^^^^^^^^^^
  File "/workspace/app/.venv/lib/python3.11/site-packages/litestar/dto/factory/_backends/utils.py", line 148, in transfer_data
    return origin(  # type:ignore[no-any-return]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Sequence() takes no arguments
```
 
 https://github.com/v3ss0n/litestar-fullstack-error-reproduce

_Originally posted by @v3ss0n in https://github.com/litestar-org/litestar/issues/1761#issuecomment-1568814594_
            


Discussion:

Resolving patch:
diff --git a/litestar/dto/factory/_backends/utils.py b/litestar/dto/factory/_backends/utils.py
--- a/litestar/dto/factory/_backends/utils.py
+++ b/litestar/dto/factory/_backends/utils.py
@@ -141,9 +141,9 @@ def transfer_data(
         Data parsed into ``destination_type``.
     """
     if not parsed_type.is_subclass_of(str) and not parsed_type.is_mapping and parsed_type.is_collection:
-        origin = parsed_type.origin
-        if not issubclass(origin, InstantiableCollection):
-            raise RuntimeError(f"Unexpected origin type '{parsed_type.origin}', expected collection type")
+        origin = parsed_type.instantiable_origin
+        if not issubclass(origin, InstantiableCollection):  # pragma: no cover
+            raise RuntimeError(f"Unexpected origin type '{parsed_type.instantiable_origin}', expected collection type")
 
         return origin(  # type:ignore[no-any-return]
             transfer_data(destination_type, item, field_definitions, dto_for, parsed_type.inner_types[0])
diff --git a/litestar/typing.py b/litestar/typing.py
--- a/litestar/typing.py
+++ b/litestar/typing.py
@@ -6,7 +6,7 @@
 from typing_extensions import Annotated, NotRequired, Required, get_args, get_origin
 
 from litestar.types.builtin_types import UNION_TYPES, NoneType
-from litestar.utils.typing import get_safe_generic_origin, unwrap_annotation
+from litestar.utils.typing import get_safe_generic_origin, instantiable_type_mapping, unwrap_annotation
 
 __all__ = ("ParsedType",)
 
@@ -21,6 +21,7 @@ class ParsedType:
         "origin",
         "args",
         "metadata",
+        "instantiable_origin",
         "is_annotated",
         "is_required",
         "is_not_required",
@@ -38,6 +39,8 @@ class ParsedType:
     """The result of calling ``get_args(annotation)`` after unwrapping Annotated, e.g. (int,)."""
     metadata: tuple[Any, ...]
     """Any metadata associated with the annotation via ``Annotated``."""
+    instantiable_origin: Any
+    """An equivalent type to ``origin`` that can be safely instantiated. E.g., ``Sequence`` -> ``list``."""
     is_annotated: bool
     """Whether the annotation included ``Annotated`` or not."""
     is_required: bool
@@ -71,6 +74,7 @@ def __init__(self, annotation: Any) -> None:
         object.__setattr__(self, "origin", origin)
         object.__setattr__(self, "args", args)
         object.__setattr__(self, "metadata", metadata)
+        object.__setattr__(self, "instantiable_origin", instantiable_type_mapping.get(origin, origin))
         object.__setattr__(self, "is_annotated", Annotated in wrappers)
         object.__setattr__(self, "is_required", Required in wrappers)
         object.__setattr__(self, "is_not_required", NotRequired in wrappers)
diff --git a/litestar/utils/typing.py b/litestar/utils/typing.py
--- a/litestar/utils/typing.py
+++ b/litestar/utils/typing.py
@@ -33,6 +33,7 @@
     "annotation_is_iterable_of_type",
     "get_origin_or_inner_type",
     "get_safe_generic_origin",
+    "instantiable_type_mapping",
     "make_non_optional_union",
     "unwrap_annotation",
 )
@@ -48,7 +49,7 @@
     )
 )
 
-types_mapping = {
+instantiable_type_mapping = {
     AbstractSet: set,
     DefaultDict: defaultdict,
     Deque: deque,
@@ -62,6 +63,19 @@
     Sequence: list,
     Set: set,
     Tuple: tuple,
+    abc.Mapping: dict,
+    abc.MutableMapping: dict,
+    abc.MutableSequence: list,
+    abc.MutableSet: set,
+    abc.Sequence: list,
+    abc.Set: set,
+    defaultdict: defaultdict,
+    deque: deque,
+    dict: dict,
+    frozenset: frozenset,
+    list: list,
+    set: set,
+    tuple: tuple,
 }
 
 _safe_generic_origin_map = {
@@ -107,7 +121,7 @@
 
 def normalize_type_annotation(annotation: Any) -> Any:
     """Normalize a type annotation to a standard form."""
-    return types_mapping.get(annotation, annotation)
+    return instantiable_type_mapping.get(annotation, annotation)
 
 
 def annotation_is_iterable_of_type(
@@ -208,7 +222,7 @@ def get_origin_or_inner_type(annotation: Any) -> Any:
         # we need to recursively call here 'get_origin_or_inner_type' because we might be dealing with a generic type alias
         # e.g. Annotated[dict[str, list[int]]
         origin = get_origin_or_inner_type(inner)
-    return types_mapping.get(origin, origin)
+    return instantiable_type_mapping.get(origin, origin)
 
 
 def get_safe_generic_origin(origin_type: Any) -> Any:


Test patch:
diff --git a/tests/dto/factory/test_integration.py b/tests/dto/factory/test_integration.py
--- a/tests/dto/factory/test_integration.py
+++ b/tests/dto/factory/test_integration.py
@@ -2,7 +2,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Dict, Optional
+from typing import TYPE_CHECKING, Dict, Optional, Sequence
 
 import pytest
 from typing_extensions import Annotated
@@ -302,3 +302,18 @@ def handler(data: DTOData[User] = Body(media_type=RequestEncodingType.URL_ENCODE
             headers={"Content-Type": "application/x-www-form-urlencoded"},
         )
         assert response.json() == {"name": "John"}
+
+
+def test_dto_with_generic_sequence_annotations() -> None:
+    @dataclass
+    class User:
+        name: str
+        age: int
+
+    @post(dto=DataclassDTO[User], signature_namespace={"User": User})
+    def handler(data: Sequence[User]) -> Sequence[User]:
+        return data
+
+    with create_test_client(route_handlers=[handler], debug=True) as client:
+        response = client.post("/", json=[{"name": "John", "age": 42}])
+        assert response.json() == [{"name": "John", "age": 42}]