Repository: litestar-org/litestar

Issue:
Enhancement: resolve forward refs when producing signature models
We need to support where users declare `from __future__ import annotations` in modules where handlers and dependencies are declared.


Discussion:

Resolving patch:
diff --git a/starlite/signature.py b/starlite/signature.py
--- a/starlite/signature.py
+++ b/starlite/signature.py
@@ -41,7 +41,7 @@
 
 UNDEFINED_SENTINELS = {Undefined, Signature.empty}
 SKIP_NAMES = {"self", "cls"}
-SKIP_VALIDATION_NAMES = {"request", "socket", "state", "scope"}
+SKIP_VALIDATION_NAMES = {"request", "socket", "state", "scope", "receive", "send"}
 
 
 class SignatureModel(BaseModel):
@@ -178,6 +178,7 @@ class SignatureModelFactory:
         "dependency_name_set",
         "field_definitions",
         "field_plugin_mappings",
+        "fn_module_name",
         "fn_name",
         "plugins",
         "signature",
@@ -194,7 +195,8 @@ def __init__(self, fn: "AnyCallable", plugins: List["PluginProtocol"], dependenc
         if fn is None:
             raise ImproperlyConfiguredException("Parameter `fn` to `SignatureModelFactory` cannot be `None`.")
         self.signature = Signature.from_callable(fn)
-        self.fn_name = fn.__name__ if hasattr(fn, "__name__") else "anonymous"
+        self.fn_name = getattr(fn, "__name__", "anonymous")
+        self.fn_module_name = getattr(fn, "__module__", "pydantic.main")
         self.plugins = plugins
         self.field_plugin_mappings: Dict[str, PluginMapping] = {}
         self.field_definitions: Dict[str, Any] = {}
@@ -324,7 +326,10 @@ def create_signature_model(self) -> Type[SignatureModel]:
                     parameter.annotation = self.get_type_annotation_from_plugin(parameter, plugin)
                 self.field_definitions[parameter.name] = self.create_field_definition_from_parameter(parameter)
             model: Type[SignatureModel] = create_model(
-                self.fn_name + "_signature_model", __base__=SignatureModel, **self.field_definitions
+                self.fn_name + "_signature_model",
+                __base__=SignatureModel,
+                __module__=self.fn_module_name,
+                **self.field_definitions,
             )
             model.return_annotation = self.signature.return_annotation
             model.field_plugin_mappings = self.field_plugin_mappings


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,5 @@
+import importlib.util
+import sys
 from os import environ, urandom
 from pathlib import Path
 from sys import version_info
@@ -10,9 +12,11 @@
     Generator,
     Optional,
     Tuple,
+    TypeVar,
     Union,
     cast,
 )
+from uuid import uuid4
 
 import fakeredis.aioredis  # pyright: ignore
 import pytest
@@ -56,6 +60,10 @@
 from tests.mocks import FakeAsyncMemcached
 
 if TYPE_CHECKING:
+    from types import ModuleType
+
+    from pytest import MonkeyPatch
+
     from starlite import Starlite
     from starlite.types import (
         AnyIOBackend,
@@ -312,7 +320,7 @@ def inner(
         session: "ScopeSession" = None,
         state: Optional[Dict[str, Any]] = None,
         user: Any = None,
-        **kwargs: Dict[str, Any]
+        **kwargs: Dict[str, Any],
     ) -> "Scope":
         scope = {
             "app": app,
@@ -344,3 +352,35 @@ def inner(
 @pytest.fixture
 def scope(create_scope: Callable[..., "Scope"]) -> "Scope":
     return create_scope()
+
+
+@pytest.fixture
+def create_module(tmp_path: Path, monkeypatch: "MonkeyPatch") -> "Callable[[str], ModuleType]":
+    """Utility fixture for dynamic module creation."""
+
+    def wrapped(source: str) -> "ModuleType":
+        """
+
+        Args:
+            source: Source code as a string.
+
+        Returns:
+            An imported module.
+        """
+        T = TypeVar("T")
+
+        def not_none(val: Union[T, Optional[T]]) -> T:
+            assert val is not None
+            return val
+
+        module_name = uuid4().hex
+        path = tmp_path / f"{module_name}.py"
+        path.write_text(source)
+        # https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
+        spec = not_none(importlib.util.spec_from_file_location(module_name, path))
+        module = not_none(importlib.util.module_from_spec(spec))
+        monkeypatch.setitem(sys.modules, module_name, module)
+        not_none(spec.loader).exec_module(module)
+        return module
+
+    return wrapped
diff --git a/tests/test_signature.py b/tests/test_signature.py
--- a/tests/test_signature.py
+++ b/tests/test_signature.py
@@ -6,7 +6,7 @@
 from pydantic import BaseModel, ValidationError
 from pydantic.error_wrappers import ErrorWrapper
 
-from starlite import HTTPException, Provide, get
+from starlite import Provide, get
 from starlite.connection import WebSocket
 from starlite.datastructures import URL
 from starlite.exceptions import (
@@ -17,12 +17,16 @@
 from starlite.params import Dependency
 from starlite.signature import SignatureModel, SignatureModelFactory
 from starlite.status_codes import HTTP_204_NO_CONTENT
-from starlite.testing import RequestFactory, create_test_client
+from starlite.testing import RequestFactory, TestClient, create_test_client
 from tests.plugins.test_base import AModel, APlugin
 
 if TYPE_CHECKING:
+    from types import ModuleType
+    from typing import Callable
+
     from pydantic.error_wrappers import ErrorDict
 
+    from starlite import HTTPException
     from starlite.plugins.base import PluginProtocol
     from starlite.types import AnyCallable
 
@@ -236,3 +240,29 @@ def get_handler(p: int) -> None:
         str(e)
         == "<ExceptionInfo 500 - ImproperlyConfiguredException - Error creating signature model for 'get_handler': 'a type error' tblen=2>"
     )
+
+
+def test_signature_model_resolves_forward_ref_annotations(create_module: "Callable[[str], ModuleType]") -> None:
+    module = create_module(
+        """
+from __future__ import annotations
+from pydantic import BaseModel
+from starlite import Provide, Starlite, get
+
+class Test(BaseModel):
+    hello: str
+
+async def get_dep() -> Test:
+    return Test(hello="world")
+
+@get("/", dependencies={"test": Provide(get_dep)})
+def hello_world(test: Test) -> Test:
+    return test
+
+app = Starlite(route_handlers=[hello_world], openapi_config=None)
+"""
+    )
+    with TestClient(app=module.app) as client:
+        resp = client.get("/")
+        assert resp.status_code == 200
+        assert resp.json() == {"hello": "world"}