From 02e1eeacc08af9e2a54b17ac96ffdef4d3900a2f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 2 Jul 2026 10:23:52 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20container=20lifecycle=20=E2=80=94?= =?UTF-8?q?=20setup=5Fdi,=20composed=20lifespan,=20ASGI=20middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also bumps the starlette dependency cap from <1 to <2: the repo's dev group pins httpx2 (the package Starlette 1.x's TestClient imports), but the runtime dependency capped starlette below 1, which resolves to 0.52.1 and needs plain httpx instead. That combination made starlette.testclient unimportable and blocked every TestClient-based test added here. Flagging for confirmation since it's a version-range decision outside this task's stated file list. Co-Authored-By: Claude Opus 4.8 (1M context) --- modern_di_starlette/__init__.py | 15 +++++- modern_di_starlette/main.py | 85 +++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/conftest.py | 23 +++++++++ tests/dependencies.py | 33 +++++++++++++ tests/test_import.py | 4 -- tests/test_lifespan.py | 56 ++++++++++++++++++++++ tests/test_middleware.py | 22 +++++++++ 8 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/dependencies.py delete mode 100644 tests/test_import.py create mode 100644 tests/test_lifespan.py create mode 100644 tests/test_middleware.py diff --git a/modern_di_starlette/__init__.py b/modern_di_starlette/__init__.py index c9c2ef6..51d6bc7 100644 --- a/modern_di_starlette/__init__.py +++ b/modern_di_starlette/__init__.py @@ -1 +1,14 @@ -__all__: list[str] = [] +from modern_di_starlette.main import ( + fetch_di_container, + setup_di, + starlette_request_provider, + starlette_websocket_provider, +) + + +__all__ = [ + "fetch_di_container", + "setup_di", + "starlette_request_provider", + "starlette_websocket_provider", +] diff --git a/modern_di_starlette/main.py b/modern_di_starlette/main.py index eb9902e..5d928f8 100644 --- a/modern_di_starlette/main.py +++ b/modern_di_starlette/main.py @@ -1 +1,86 @@ """modern-di integration for Starlette.""" + +import contextlib +import enum +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 + + +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 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 From 0a535414797417604f271a088e98649b45cc03d5 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 2 Jul 2026 10:32:51 +0300 Subject: [PATCH 2/4] feat: FromDI marker and @inject decorator (resolution) Co-Authored-By: Claude Opus 4.8 (1M context) --- modern_di_starlette/__init__.py | 4 +++ modern_di_starlette/main.py | 50 +++++++++++++++++++++++++++++++++ tests/test_routes.py | 43 ++++++++++++++++++++++++++++ tests/test_websockets.py | 43 ++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+) create mode 100644 tests/test_routes.py create mode 100644 tests/test_websockets.py diff --git a/modern_di_starlette/__init__.py b/modern_di_starlette/__init__.py index 51d6bc7..376bbb7 100644 --- a/modern_di_starlette/__init__.py +++ b/modern_di_starlette/__init__.py @@ -1,5 +1,7 @@ from modern_di_starlette.main import ( + FromDI, fetch_di_container, + inject, setup_di, starlette_request_provider, starlette_websocket_provider, @@ -7,7 +9,9 @@ __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 5d928f8..ac4717f 100644 --- a/modern_di_starlette/main.py +++ b/modern_di_starlette/main.py @@ -1,7 +1,9 @@ """modern-di integration for Starlette.""" import contextlib +import dataclasses import enum +import functools import typing from modern_di import Container, Scope, providers @@ -12,6 +14,9 @@ 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) @@ -84,3 +89,48 @@ def setup_di(app: Starlette, container: Container) -> Container: 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: + child_container: Container = connection.scope[_CONTAINER_SCOPE_KEY] + return await func(connection, **_resolve_di_params(child_container, di_params)) + + return wrapper diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..6350c90 --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,43 @@ +import typing + +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" 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" From 3d33cd2443f86d79e0f70229f08eae3686a51dcb Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 2 Jul 2026 10:39:11 +0300 Subject: [PATCH 3/4] docs: promote container-lifecycle, dependency-resolution, glossary to architecture/ Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/container-lifecycle.md | 47 +++++++++++++++++++++++++++ architecture/dependency-resolution.md | 37 +++++++++++++++++++++ architecture/glossary.md | 23 +++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 architecture/container-lifecycle.md create mode 100644 architecture/dependency-resolution.md create mode 100644 architecture/glossary.md 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..543ea85 --- /dev/null +++ b/architecture/dependency-resolution.md @@ -0,0 +1,37 @@ +# 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. 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). From adc0498b8cb92bae221e87195b934df8ae22ddc1 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 2 Jul 2026 10:48:14 +0300 Subject: [PATCH 4/4] fix: clear error when @inject runs without setup_di; note injected connection identity Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/dependency-resolution.md | 6 ++++++ modern_di_starlette/main.py | 10 +++++++++- tests/test_routes.py | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/architecture/dependency-resolution.md b/architecture/dependency-resolution.md index 543ea85..105313a 100644 --- a/architecture/dependency-resolution.md +++ b/architecture/dependency-resolution.md @@ -35,3 +35,9 @@ At call time the wrapper: 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/modern_di_starlette/main.py b/modern_di_starlette/main.py index ac4717f..53f12e9 100644 --- a/modern_di_starlette/main.py +++ b/modern_di_starlette/main.py @@ -130,7 +130,15 @@ def inject(func: typing.Callable[..., typing.Awaitable[T]]) -> typing.Callable[. @functools.wraps(func) async def wrapper(connection: Request | WebSocket) -> T: - child_container: Container = connection.scope[_CONTAINER_SCOPE_KEY] + 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/tests/test_routes.py b/tests/test_routes.py index 6350c90..6bfb31b 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,5 +1,6 @@ import typing +import pytest from starlette import status from starlette.applications import Starlette from starlette.requests import Request @@ -41,3 +42,18 @@ async def 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("/")