|
1 | 1 | """modern-di integration for Starlette.""" |
| 2 | + |
| 3 | +import contextlib |
| 4 | +import dataclasses |
| 5 | +import enum |
| 6 | +import functools |
| 7 | +import typing |
| 8 | + |
| 9 | +from modern_di import Container, Scope, providers |
| 10 | +from starlette.applications import Starlette |
| 11 | +from starlette.requests import Request |
| 12 | +from starlette.types import ASGIApp, Lifespan, Receive, Send |
| 13 | +from starlette.types import Scope as ASGIScope |
| 14 | +from starlette.websockets import WebSocket |
| 15 | + |
| 16 | + |
| 17 | +T_co = typing.TypeVar("T_co", covariant=True) |
| 18 | +T = typing.TypeVar("T") |
| 19 | + |
| 20 | +starlette_request_provider = providers.ContextProvider(scope=Scope.REQUEST, context_type=Request) |
| 21 | +starlette_websocket_provider = providers.ContextProvider(scope=Scope.SESSION, context_type=WebSocket) |
| 22 | + |
| 23 | +# Single source of the connection-kind mapping: each provider pairs a Starlette |
| 24 | +# connection type with the scope its child container opens at. The middleware |
| 25 | +# dispatches off this tuple; add a connection kind by adding its provider here. |
| 26 | +_CONNECTION_PROVIDERS = (starlette_request_provider, starlette_websocket_provider) |
| 27 | + |
| 28 | +# Key under which the per-connection child container lives in the ASGI scope dict. |
| 29 | +# `_DIMiddleware` writes it; `inject` (Task 3) reads it back. |
| 30 | +_CONTAINER_SCOPE_KEY = "modern_di_container" |
| 31 | + |
| 32 | + |
| 33 | +def fetch_di_container(app: Starlette) -> Container: |
| 34 | + return typing.cast(Container, app.state.di_container) |
| 35 | + |
| 36 | + |
| 37 | +def _compose_lifespan(original: Lifespan[Starlette]) -> Lifespan[Starlette]: |
| 38 | + """Wrap ``original`` so the root container opens/closes around it. |
| 39 | +
|
| 40 | + ``async with`` reopens the container on each startup and closes it on |
| 41 | + shutdown, so a second lifespan cycle against the same container works |
| 42 | + instead of raising ``ContainerClosedError``. The original lifespan stays |
| 43 | + the outer context and its yielded state passes straight through. |
| 44 | + """ |
| 45 | + |
| 46 | + @contextlib.asynccontextmanager |
| 47 | + async def composed(app: Starlette) -> typing.AsyncIterator[typing.Mapping[str, typing.Any] | None]: |
| 48 | + async with original(app) as state, fetch_di_container(app): |
| 49 | + yield state |
| 50 | + |
| 51 | + return typing.cast(Lifespan[Starlette], composed) |
| 52 | + |
| 53 | + |
| 54 | +class _DIMiddleware: |
| 55 | + def __init__(self, app: ASGIApp, container: Container) -> None: |
| 56 | + self.app = app |
| 57 | + self.container = container |
| 58 | + |
| 59 | + async def __call__(self, scope: ASGIScope, receive: Receive, send: Send) -> None: |
| 60 | + if scope["type"] not in ("http", "websocket"): |
| 61 | + await self.app(scope, receive, send) |
| 62 | + return |
| 63 | + |
| 64 | + connection: Request | WebSocket = ( |
| 65 | + Request(scope, receive) if scope["type"] == "http" else WebSocket(scope, receive, send) |
| 66 | + ) |
| 67 | + context: dict[type[typing.Any], typing.Any] = {} |
| 68 | + # `enum.IntEnum`, not `Scope`: `AbstractProvider.scope` is typed broadly to |
| 69 | + # support custom scope enums, and `build_child_container(scope=...)` takes |
| 70 | + # the same broad type — matching it here keeps `ty` happy without a cast. |
| 71 | + connection_scope: enum.IntEnum | None = None |
| 72 | + for provider in _CONNECTION_PROVIDERS: |
| 73 | + if isinstance(connection, provider.context_type): |
| 74 | + context[provider.context_type] = connection |
| 75 | + connection_scope = provider.scope |
| 76 | + break |
| 77 | + |
| 78 | + child_container = self.container.build_child_container(context=context, scope=connection_scope) |
| 79 | + scope[_CONTAINER_SCOPE_KEY] = child_container |
| 80 | + try: |
| 81 | + await self.app(scope, receive, send) |
| 82 | + finally: |
| 83 | + await child_container.close_async() |
| 84 | + |
| 85 | + |
| 86 | +def setup_di(app: Starlette, container: Container) -> Container: |
| 87 | + app.state.di_container = container |
| 88 | + container.providers_registry.add_providers(*_CONNECTION_PROVIDERS) |
| 89 | + app.router.lifespan_context = _compose_lifespan(app.router.lifespan_context) |
| 90 | + app.add_middleware(_DIMiddleware, container=container) |
| 91 | + return container |
| 92 | + |
| 93 | + |
| 94 | +@dataclasses.dataclass(slots=True, frozen=True) |
| 95 | +class _FromDI(typing.Generic[T_co]): |
| 96 | + dependency: providers.AbstractProvider[T_co] | type[T_co] |
| 97 | + |
| 98 | + |
| 99 | +def FromDI(dependency: providers.AbstractProvider[T_co] | type[T_co]) -> T_co: # noqa: N802 |
| 100 | + return typing.cast(T_co, _FromDI(dependency)) |
| 101 | + |
| 102 | + |
| 103 | +def _parse_inject_params(func: typing.Callable[..., typing.Any]) -> dict[str, _FromDI[typing.Any]]: |
| 104 | + hints = typing.get_type_hints(func, include_extras=True) |
| 105 | + di_params: dict[str, _FromDI[typing.Any]] = {} |
| 106 | + for name, hint in hints.items(): |
| 107 | + if name == "return": |
| 108 | + continue |
| 109 | + if typing.get_origin(hint) is typing.Annotated: |
| 110 | + for meta in typing.get_args(hint)[1:]: |
| 111 | + if isinstance(meta, _FromDI): |
| 112 | + di_params[name] = meta |
| 113 | + break |
| 114 | + return di_params |
| 115 | + |
| 116 | + |
| 117 | +def _resolve_di_params(container: Container, di_params: dict[str, _FromDI[typing.Any]]) -> dict[str, typing.Any]: |
| 118 | + return { |
| 119 | + name: ( |
| 120 | + container.resolve_provider(marker.dependency) |
| 121 | + if isinstance(marker.dependency, providers.AbstractProvider) |
| 122 | + else container.resolve(dependency_type=marker.dependency) |
| 123 | + ) |
| 124 | + for name, marker in di_params.items() |
| 125 | + } |
| 126 | + |
| 127 | + |
| 128 | +def inject(func: typing.Callable[..., typing.Awaitable[T]]) -> typing.Callable[..., typing.Awaitable[T]]: |
| 129 | + di_params = _parse_inject_params(func) |
| 130 | + |
| 131 | + @functools.wraps(func) |
| 132 | + async def wrapper(connection: Request | WebSocket) -> T: |
| 133 | + try: |
| 134 | + child_container: Container = connection.scope[_CONTAINER_SCOPE_KEY] |
| 135 | + except KeyError: |
| 136 | + msg = ( |
| 137 | + "No modern-di container found in the request scope. " |
| 138 | + "Call setup_di(app, container) so requests pass through the modern-di middleware " |
| 139 | + "before using @inject." |
| 140 | + ) |
| 141 | + raise RuntimeError(msg) from None |
| 142 | + return await func(connection, **_resolve_di_params(child_container, di_params)) |
| 143 | + |
| 144 | + return wrapper |
0 commit comments