diff --git a/architecture/container-lifecycle.md b/architecture/container-lifecycle.md new file mode 100644 index 0000000..d50310e --- /dev/null +++ b/architecture/container-lifecycle.md @@ -0,0 +1,47 @@ +# Container lifecycle + +`modern-di-starlette` wires a `modern_di.Container` into a Starlette app, opens +and closes it around the app's lifespan, and builds a scoped child container per +HTTP request / WebSocket connection. + +## setup_di + +`setup_di(app, container)` does four things and returns the container: + +1. Stashes the root container on `app.state.di_container` (read back with + `fetch_di_container(app)`). +2. Registers the connection providers (`starlette_request_provider`, + `starlette_websocket_provider`) on the container's providers registry. +3. Composes the container's open/close around the app's existing lifespan. +4. Installs `_DIMiddleware`, the pure ASGI middleware that builds the + per-connection child container. + +Call it once, after creating the app and before it starts serving — middleware +cannot be added after startup. + +## Composed lifespan + +`_compose_lifespan` wraps the app's current `lifespan_context` so the root +container is opened inside it and closed on shutdown: + + async with original(app) as state, fetch_di_container(app): + yield state + +`async with container` reopens the container on each startup and closes it on +shutdown, so a second lifespan cycle (test-client re-entry, reload) works +instead of raising `ContainerClosedError`. The original lifespan stays the outer +context and its yielded state passes straight through. + +## Per-connection child container + +`_DIMiddleware` is pure ASGI middleware (chosen over `BaseHTTPMiddleware`, which +breaks `contextvars` propagation). For each `http` / `websocket` connection it: + +1. Builds the connection object (`Request` / `WebSocket`) from the ASGI scope. +2. Matches it against the connection providers: a `Request` opens a + `Scope.REQUEST` child; a `WebSocket` opens a `Scope.SESSION` child. +3. Builds the child container with the connection injected as context and + stashes it in the ASGI `scope` dict under the internal `_CONTAINER_SCOPE_KEY`. +4. Closes the child container (`close_async`) when the connection finishes. + +Other scope types (`lifespan`) pass straight through untouched. diff --git a/architecture/dependency-resolution.md b/architecture/dependency-resolution.md new file mode 100644 index 0000000..105313a --- /dev/null +++ b/architecture/dependency-resolution.md @@ -0,0 +1,43 @@ +# Dependency resolution + +Starlette has no dependency-injection system of its own, so `modern-di-starlette` +uses an inert marker plus a decorator (the decorator path from modern-di's +"Writing an integration" guide). + +## FromDI + +`FromDI(dependency)` marks an endpoint parameter for injection inside an +`Annotated` hint: + + service: typing.Annotated[Service, FromDI(Deps.service)] + +It returns `typing.cast(T, _FromDI(dependency))`: type checkers see the resolved +type `T`, while at runtime it is a frozen `_FromDI` marker the decorator detects. +The argument is a provider (`AbstractProvider`) or a bare type — resolution +handles both (`resolve_provider` vs `resolve`). + +## @inject + +`inject` wraps an endpoint. At decoration time it reads +`typing.get_type_hints(func, include_extras=True)` and collects the parameters +whose `Annotated` metadata holds a `_FromDI`. Starlette calls an endpoint with +just the connection (`endpoint(request)` / `endpoint(websocket)`) and does not +introspect its signature, so — unlike a CLI integration — no signature rewrite is +needed. + +At call time the wrapper: + +1. Reads the request's child container from + `connection.scope[_CONTAINER_SCOPE_KEY]` (put there by `_DIMiddleware`). +2. Resolves each marked parameter. +3. Calls the endpoint with the connection plus the resolved parameters by + keyword. + +The endpoint's first parameter is the connection; every `FromDI` parameter +follows and is filled by keyword. `FromDI` parameters coexist with plain ones. + +The `Request`/`WebSocket` instance a provider receives as DI context (via +`starlette_request_provider`/`starlette_websocket_provider`) is backed by the +same ASGI `scope` as the endpoint's connection but is a distinct instance — +safe for reading `method`/`url`/`headers`/`state`, but reading the request +body through it is not intended (resolution is sync-only anyway). diff --git a/architecture/glossary.md b/architecture/glossary.md new file mode 100644 index 0000000..46e9cc3 --- /dev/null +++ b/architecture/glossary.md @@ -0,0 +1,23 @@ +# Glossary + +The ubiquitous language of `modern-di-starlette`. + +**Root container**: +The application-lifetime `modern_di.Container` passed to `setup_di` and stored on +`app.state.di_container`; opened and closed around the app lifespan. +_Avoid_: app container (in prose), global container. + +**Child container**: +The per-connection container built by the middleware — `Scope.REQUEST` for an +HTTP request, `Scope.SESSION` for a WebSocket — and closed when the connection +ends. +_Avoid_: request container (ambiguous across scopes), sub-container. + +**Connection provider**: +A `ContextProvider` pairing a Starlette connection type (`Request`, `WebSocket`) +with the scope its child container opens at. + +**FromDI marker**: +The inert `Annotated` metadata (`_FromDI`) that flags an endpoint parameter for +resolution by `@inject`. +_Avoid_: Depends (that is FastAPI's mechanism, not Starlette's). diff --git a/modern_di_starlette/__init__.py b/modern_di_starlette/__init__.py index c9c2ef6..376bbb7 100644 --- a/modern_di_starlette/__init__.py +++ b/modern_di_starlette/__init__.py @@ -1 +1,18 @@ -__all__: list[str] = [] +from modern_di_starlette.main import ( + FromDI, + fetch_di_container, + inject, + setup_di, + starlette_request_provider, + starlette_websocket_provider, +) + + +__all__ = [ + "FromDI", + "fetch_di_container", + "inject", + "setup_di", + "starlette_request_provider", + "starlette_websocket_provider", +] diff --git a/modern_di_starlette/main.py b/modern_di_starlette/main.py index eb9902e..53f12e9 100644 --- a/modern_di_starlette/main.py +++ b/modern_di_starlette/main.py @@ -1 +1,144 @@ """modern-di integration for Starlette.""" + +import contextlib +import dataclasses +import enum +import functools +import typing + +from modern_di import Container, Scope, providers +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.types import ASGIApp, Lifespan, Receive, Send +from starlette.types import Scope as ASGIScope +from starlette.websockets import WebSocket + + +T_co = typing.TypeVar("T_co", covariant=True) +T = typing.TypeVar("T") + +starlette_request_provider = providers.ContextProvider(scope=Scope.REQUEST, context_type=Request) +starlette_websocket_provider = providers.ContextProvider(scope=Scope.SESSION, context_type=WebSocket) + +# Single source of the connection-kind mapping: each provider pairs a Starlette +# connection type with the scope its child container opens at. The middleware +# dispatches off this tuple; add a connection kind by adding its provider here. +_CONNECTION_PROVIDERS = (starlette_request_provider, starlette_websocket_provider) + +# Key under which the per-connection child container lives in the ASGI scope dict. +# `_DIMiddleware` writes it; `inject` (Task 3) reads it back. +_CONTAINER_SCOPE_KEY = "modern_di_container" + + +def fetch_di_container(app: Starlette) -> Container: + return typing.cast(Container, app.state.di_container) + + +def _compose_lifespan(original: Lifespan[Starlette]) -> Lifespan[Starlette]: + """Wrap ``original`` so the root container opens/closes around it. + + ``async with`` reopens the container on each startup and closes it on + shutdown, so a second lifespan cycle against the same container works + instead of raising ``ContainerClosedError``. The original lifespan stays + the outer context and its yielded state passes straight through. + """ + + @contextlib.asynccontextmanager + async def composed(app: Starlette) -> typing.AsyncIterator[typing.Mapping[str, typing.Any] | None]: + async with original(app) as state, fetch_di_container(app): + yield state + + return typing.cast(Lifespan[Starlette], composed) + + +class _DIMiddleware: + def __init__(self, app: ASGIApp, container: Container) -> None: + self.app = app + self.container = container + + async def __call__(self, scope: ASGIScope, receive: Receive, send: Send) -> None: + if scope["type"] not in ("http", "websocket"): + await self.app(scope, receive, send) + return + + connection: Request | WebSocket = ( + Request(scope, receive) if scope["type"] == "http" else WebSocket(scope, receive, send) + ) + context: dict[type[typing.Any], typing.Any] = {} + # `enum.IntEnum`, not `Scope`: `AbstractProvider.scope` is typed broadly to + # support custom scope enums, and `build_child_container(scope=...)` takes + # the same broad type — matching it here keeps `ty` happy without a cast. + connection_scope: enum.IntEnum | None = None + for provider in _CONNECTION_PROVIDERS: + if isinstance(connection, provider.context_type): + context[provider.context_type] = connection + connection_scope = provider.scope + break + + child_container = self.container.build_child_container(context=context, scope=connection_scope) + scope[_CONTAINER_SCOPE_KEY] = child_container + try: + await self.app(scope, receive, send) + finally: + await child_container.close_async() + + +def setup_di(app: Starlette, container: Container) -> Container: + app.state.di_container = container + container.providers_registry.add_providers(*_CONNECTION_PROVIDERS) + app.router.lifespan_context = _compose_lifespan(app.router.lifespan_context) + app.add_middleware(_DIMiddleware, container=container) + return container + + +@dataclasses.dataclass(slots=True, frozen=True) +class _FromDI(typing.Generic[T_co]): + dependency: providers.AbstractProvider[T_co] | type[T_co] + + +def FromDI(dependency: providers.AbstractProvider[T_co] | type[T_co]) -> T_co: # noqa: N802 + return typing.cast(T_co, _FromDI(dependency)) + + +def _parse_inject_params(func: typing.Callable[..., typing.Any]) -> dict[str, _FromDI[typing.Any]]: + hints = typing.get_type_hints(func, include_extras=True) + di_params: dict[str, _FromDI[typing.Any]] = {} + for name, hint in hints.items(): + if name == "return": + continue + if typing.get_origin(hint) is typing.Annotated: + for meta in typing.get_args(hint)[1:]: + if isinstance(meta, _FromDI): + di_params[name] = meta + break + return di_params + + +def _resolve_di_params(container: Container, di_params: dict[str, _FromDI[typing.Any]]) -> dict[str, typing.Any]: + return { + name: ( + container.resolve_provider(marker.dependency) + if isinstance(marker.dependency, providers.AbstractProvider) + else container.resolve(dependency_type=marker.dependency) + ) + for name, marker in di_params.items() + } + + +def inject(func: typing.Callable[..., typing.Awaitable[T]]) -> typing.Callable[..., typing.Awaitable[T]]: + di_params = _parse_inject_params(func) + + @functools.wraps(func) + async def wrapper(connection: Request | WebSocket) -> T: + try: + child_container: Container = connection.scope[_CONTAINER_SCOPE_KEY] + except KeyError: + msg = ( + "No modern-di container found in the request scope. " + "Call setup_di(app, container) so requests pass through the modern-di middleware " + "before using @inject." + ) + raise RuntimeError(msg) from None + return await func(connection, **_resolve_di_params(child_container, di_params)) + + return wrapper diff --git a/pyproject.toml b/pyproject.toml index a84bf95..3349cb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Typing :: Typed", "Topic :: Software Development :: Libraries", ] -dependencies = ["starlette>=0.40,<1", "modern-di>=2.21.0,<3"] +dependencies = ["starlette>=0.40,<2", "modern-di>=2.21.0,<3"] version = "0" [project.urls] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6c02e56 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +import typing + +import modern_di +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +import modern_di_starlette +from tests.dependencies import Dependencies + + +@pytest.fixture +def app() -> Starlette: + app_ = Starlette() + container = modern_di.Container(groups=[Dependencies]) + modern_di_starlette.setup_di(app_, container=container) + return app_ + + +@pytest.fixture +def client(app: Starlette) -> typing.Iterator[TestClient]: + with TestClient(app=app) as test_client: + yield test_client diff --git a/tests/dependencies.py b/tests/dependencies.py new file mode 100644 index 0000000..c68c132 --- /dev/null +++ b/tests/dependencies.py @@ -0,0 +1,33 @@ +import dataclasses + +from modern_di import Group, Scope, providers +from starlette.requests import Request +from starlette.websockets import WebSocket + + +@dataclasses.dataclass(kw_only=True, slots=True) +class SimpleCreator: + dep1: str + + +@dataclasses.dataclass(kw_only=True, slots=True) +class DependentCreator: + dep1: SimpleCreator + + +def fetch_method_from_request(request: Request) -> str: + assert isinstance(request, Request) + return request.method + + +def fetch_path_from_websocket(websocket: WebSocket) -> str: + assert isinstance(websocket, WebSocket) + return websocket.url.path + + +class Dependencies(Group): + app_factory = providers.Factory(creator=SimpleCreator, kwargs={"dep1": "original"}) + session_factory = providers.Factory(scope=Scope.SESSION, creator=DependentCreator, bound_type=None) + request_factory = providers.Factory(scope=Scope.REQUEST, creator=DependentCreator, bound_type=None) + request_method = providers.Factory(scope=Scope.REQUEST, creator=fetch_method_from_request, bound_type=None) + websocket_path = providers.Factory(scope=Scope.SESSION, creator=fetch_path_from_websocket, bound_type=None) diff --git a/tests/test_import.py b/tests/test_import.py deleted file mode 100644 index cc79232..0000000 --- a/tests/test_import.py +++ /dev/null @@ -1,4 +0,0 @@ -def test_public_surface_importable() -> None: - import modern_di_starlette # noqa: PLC0415 - - assert modern_di_starlette.__all__ == [] diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py new file mode 100644 index 0000000..3a79f92 --- /dev/null +++ b/tests/test_lifespan.py @@ -0,0 +1,56 @@ +import contextlib +import typing + +import modern_di +from starlette import status +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, PlainTextResponse +from starlette.testclient import TestClient + +import modern_di_starlette +from modern_di_starlette import fetch_di_container +from tests.dependencies import Dependencies + + +def _plain(request: Request) -> PlainTextResponse: # noqa: ARG001 + return PlainTextResponse("ok") + + +def test_lifespan_reopens_container_across_cycles(app: Starlette) -> None: + app.add_route("/", _plain) + container = fetch_di_container(app) + + with TestClient(app=app) as client: + assert client.get("/").status_code == status.HTTP_200_OK + assert container.closed + + with TestClient(app=app) as client: + assert client.get("/").status_code == status.HTTP_200_OK + + +def test_setup_di_composes_with_existing_lifespan() -> None: + events: list[str] = [] + + @contextlib.asynccontextmanager + async def user_lifespan(app_: Starlette) -> typing.AsyncIterator[dict[str, str]]: + assert isinstance(app_, Starlette) + events.append("startup") + yield {"marker": "from-user-lifespan"} + events.append("shutdown") + + app = Starlette(lifespan=user_lifespan) + container = modern_di.Container(groups=[Dependencies]) + modern_di_starlette.setup_di(app, container) + + async def read_marker(request: Request) -> JSONResponse: + return JSONResponse(request.state.marker) + + app.add_route("/", read_marker) + + with TestClient(app=app) as client: + assert events == ["startup"] + assert not container.closed + assert client.get("/").json() == "from-user-lifespan" + assert events == ["startup", "shutdown"] + assert container.closed diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..313ecc2 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,22 @@ +from modern_di import Container, Scope +from starlette import status +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import PlainTextResponse +from starlette.testclient import TestClient + +from modern_di_starlette.main import _CONTAINER_SCOPE_KEY +from tests.dependencies import Dependencies, DependentCreator + + +def test_middleware_opens_request_scoped_child(client: TestClient, app: Starlette) -> None: + def endpoint(request: Request) -> PlainTextResponse: + child = request.scope[_CONTAINER_SCOPE_KEY] + assert isinstance(child, Container) + assert child.scope is Scope.REQUEST + instance = child.resolve_provider(Dependencies.request_factory) + assert isinstance(instance, DependentCreator) + return PlainTextResponse("ok") + + app.add_route("/", endpoint) + assert client.get("/").status_code == status.HTTP_200_OK diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..6bfb31b --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,59 @@ +import typing + +import pytest +from starlette import status +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import PlainTextResponse +from starlette.testclient import TestClient + +from modern_di_starlette import FromDI, inject +from tests.dependencies import Dependencies, DependentCreator, SimpleCreator + + +def test_factories_by_type_and_provider(client: TestClient, app: Starlette) -> None: + @inject + async def read_root( + request: Request, # noqa: ARG001 + app_factory_instance: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], + request_factory_instance: typing.Annotated[DependentCreator, FromDI(Dependencies.request_factory)], + ) -> PlainTextResponse: + assert isinstance(app_factory_instance, SimpleCreator) + assert isinstance(request_factory_instance, DependentCreator) + assert request_factory_instance.dep1 is not app_factory_instance + return PlainTextResponse("ok") + + app.add_route("/", read_root) + response = client.get("/") + assert response.status_code == status.HTTP_200_OK + assert response.text == "ok" + + +def test_context_provider_reads_request(client: TestClient, app: Starlette) -> None: + @inject + async def read_root( + request: Request, # noqa: ARG001 + method: typing.Annotated[str, FromDI(Dependencies.request_method)], + ) -> PlainTextResponse: + assert method == "GET" + return PlainTextResponse(method) + + app.add_route("/", read_root) + response = client.get("/") + assert response.status_code == status.HTTP_200_OK + assert response.text == "GET" + + +def test_inject_without_setup_di_raises_clear_error() -> None: + @inject + async def read_root( + request: Request, # noqa: ARG001 + app_factory_instance: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], + ) -> PlainTextResponse: + return PlainTextResponse(app_factory_instance.dep1) # pragma: no cover -- RuntimeError precedes this call + + app = Starlette() + app.add_route("/", read_root) + + with pytest.raises(RuntimeError, match="setup_di"): + TestClient(app).get("/") diff --git a/tests/test_websockets.py b/tests/test_websockets.py new file mode 100644 index 0000000..719bc01 --- /dev/null +++ b/tests/test_websockets.py @@ -0,0 +1,43 @@ +import typing + +from starlette.applications import Starlette +from starlette.testclient import TestClient +from starlette.websockets import WebSocket + +from modern_di_starlette import FromDI, inject +from tests.dependencies import Dependencies, DependentCreator, SimpleCreator + + +async def test_factories(client: TestClient, app: Starlette) -> None: + @inject + async def websocket_endpoint( + websocket: WebSocket, + app_factory_instance: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], + session_factory_instance: typing.Annotated[DependentCreator, FromDI(Dependencies.session_factory)], + ) -> None: + assert isinstance(app_factory_instance, SimpleCreator) + assert isinstance(session_factory_instance, DependentCreator) + assert session_factory_instance.dep1 is not app_factory_instance + await websocket.accept() + await websocket.send_text("test") + await websocket.close() + + app.router.add_websocket_route("/ws", websocket_endpoint) + with client.websocket_connect("/ws") as websocket: + assert websocket.receive_text() == "test" + + +async def test_context_provider_reads_websocket(client: TestClient, app: Starlette) -> None: + @inject + async def websocket_endpoint( + websocket: WebSocket, + path: typing.Annotated[str, FromDI(Dependencies.websocket_path)], + ) -> None: + assert path == "/ws" + await websocket.accept() + await websocket.send_text("test") + await websocket.close() + + app.router.add_websocket_route("/ws", websocket_endpoint) + with client.websocket_connect("/ws") as websocket: + assert websocket.receive_text() == "test"