Skip to content

Commit a707312

Browse files
lesnik512claude
andauthored
feat: modern-di integration for Starlette (#1)
* feat: container lifecycle — setup_di, composed lifespan, ASGI middleware 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) <noreply@anthropic.com> * feat: FromDI marker and @Inject decorator (resolution) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: promote container-lifecycle, dependency-resolution, glossary to architecture/ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: clear error when @Inject runs without setup_di; note injected connection identity Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 804a808 commit a707312

13 files changed

Lines changed: 511 additions & 6 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Container lifecycle
2+
3+
`modern-di-starlette` wires a `modern_di.Container` into a Starlette app, opens
4+
and closes it around the app's lifespan, and builds a scoped child container per
5+
HTTP request / WebSocket connection.
6+
7+
## setup_di
8+
9+
`setup_di(app, container)` does four things and returns the container:
10+
11+
1. Stashes the root container on `app.state.di_container` (read back with
12+
`fetch_di_container(app)`).
13+
2. Registers the connection providers (`starlette_request_provider`,
14+
`starlette_websocket_provider`) on the container's providers registry.
15+
3. Composes the container's open/close around the app's existing lifespan.
16+
4. Installs `_DIMiddleware`, the pure ASGI middleware that builds the
17+
per-connection child container.
18+
19+
Call it once, after creating the app and before it starts serving — middleware
20+
cannot be added after startup.
21+
22+
## Composed lifespan
23+
24+
`_compose_lifespan` wraps the app's current `lifespan_context` so the root
25+
container is opened inside it and closed on shutdown:
26+
27+
async with original(app) as state, fetch_di_container(app):
28+
yield state
29+
30+
`async with container` reopens the container on each startup and closes it on
31+
shutdown, so a second lifespan cycle (test-client re-entry, reload) works
32+
instead of raising `ContainerClosedError`. The original lifespan stays the outer
33+
context and its yielded state passes straight through.
34+
35+
## Per-connection child container
36+
37+
`_DIMiddleware` is pure ASGI middleware (chosen over `BaseHTTPMiddleware`, which
38+
breaks `contextvars` propagation). For each `http` / `websocket` connection it:
39+
40+
1. Builds the connection object (`Request` / `WebSocket`) from the ASGI scope.
41+
2. Matches it against the connection providers: a `Request` opens a
42+
`Scope.REQUEST` child; a `WebSocket` opens a `Scope.SESSION` child.
43+
3. Builds the child container with the connection injected as context and
44+
stashes it in the ASGI `scope` dict under the internal `_CONTAINER_SCOPE_KEY`.
45+
4. Closes the child container (`close_async`) when the connection finishes.
46+
47+
Other scope types (`lifespan`) pass straight through untouched.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Dependency resolution
2+
3+
Starlette has no dependency-injection system of its own, so `modern-di-starlette`
4+
uses an inert marker plus a decorator (the decorator path from modern-di's
5+
"Writing an integration" guide).
6+
7+
## FromDI
8+
9+
`FromDI(dependency)` marks an endpoint parameter for injection inside an
10+
`Annotated` hint:
11+
12+
service: typing.Annotated[Service, FromDI(Deps.service)]
13+
14+
It returns `typing.cast(T, _FromDI(dependency))`: type checkers see the resolved
15+
type `T`, while at runtime it is a frozen `_FromDI` marker the decorator detects.
16+
The argument is a provider (`AbstractProvider`) or a bare type — resolution
17+
handles both (`resolve_provider` vs `resolve`).
18+
19+
## @inject
20+
21+
`inject` wraps an endpoint. At decoration time it reads
22+
`typing.get_type_hints(func, include_extras=True)` and collects the parameters
23+
whose `Annotated` metadata holds a `_FromDI`. Starlette calls an endpoint with
24+
just the connection (`endpoint(request)` / `endpoint(websocket)`) and does not
25+
introspect its signature, so — unlike a CLI integration — no signature rewrite is
26+
needed.
27+
28+
At call time the wrapper:
29+
30+
1. Reads the request's child container from
31+
`connection.scope[_CONTAINER_SCOPE_KEY]` (put there by `_DIMiddleware`).
32+
2. Resolves each marked parameter.
33+
3. Calls the endpoint with the connection plus the resolved parameters by
34+
keyword.
35+
36+
The endpoint's first parameter is the connection; every `FromDI` parameter
37+
follows and is filled by keyword. `FromDI` parameters coexist with plain ones.
38+
39+
The `Request`/`WebSocket` instance a provider receives as DI context (via
40+
`starlette_request_provider`/`starlette_websocket_provider`) is backed by the
41+
same ASGI `scope` as the endpoint's connection but is a distinct instance —
42+
safe for reading `method`/`url`/`headers`/`state`, but reading the request
43+
body through it is not intended (resolution is sync-only anyway).

architecture/glossary.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Glossary
2+
3+
The ubiquitous language of `modern-di-starlette`.
4+
5+
**Root container**:
6+
The application-lifetime `modern_di.Container` passed to `setup_di` and stored on
7+
`app.state.di_container`; opened and closed around the app lifespan.
8+
_Avoid_: app container (in prose), global container.
9+
10+
**Child container**:
11+
The per-connection container built by the middleware — `Scope.REQUEST` for an
12+
HTTP request, `Scope.SESSION` for a WebSocket — and closed when the connection
13+
ends.
14+
_Avoid_: request container (ambiguous across scopes), sub-container.
15+
16+
**Connection provider**:
17+
A `ContextProvider` pairing a Starlette connection type (`Request`, `WebSocket`)
18+
with the scope its child container opens at.
19+
20+
**FromDI marker**:
21+
The inert `Annotated` metadata (`_FromDI`) that flags an endpoint parameter for
22+
resolution by `@inject`.
23+
_Avoid_: Depends (that is FastAPI's mechanism, not Starlette's).

modern_di_starlette/__init__.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,18 @@
1-
__all__: list[str] = []
1+
from modern_di_starlette.main import (
2+
FromDI,
3+
fetch_di_container,
4+
inject,
5+
setup_di,
6+
starlette_request_provider,
7+
starlette_websocket_provider,
8+
)
9+
10+
11+
__all__ = [
12+
"FromDI",
13+
"fetch_di_container",
14+
"inject",
15+
"setup_di",
16+
"starlette_request_provider",
17+
"starlette_websocket_provider",
18+
]

modern_di_starlette/main.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,144 @@
11
"""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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ classifiers = [
1717
"Typing :: Typed",
1818
"Topic :: Software Development :: Libraries",
1919
]
20-
dependencies = ["starlette>=0.40,<1", "modern-di>=2.21.0,<3"]
20+
dependencies = ["starlette>=0.40,<2", "modern-di>=2.21.0,<3"]
2121
version = "0"
2222

2323
[project.urls]

tests/conftest.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import typing
2+
3+
import modern_di
4+
import pytest
5+
from starlette.applications import Starlette
6+
from starlette.testclient import TestClient
7+
8+
import modern_di_starlette
9+
from tests.dependencies import Dependencies
10+
11+
12+
@pytest.fixture
13+
def app() -> Starlette:
14+
app_ = Starlette()
15+
container = modern_di.Container(groups=[Dependencies])
16+
modern_di_starlette.setup_di(app_, container=container)
17+
return app_
18+
19+
20+
@pytest.fixture
21+
def client(app: Starlette) -> typing.Iterator[TestClient]:
22+
with TestClient(app=app) as test_client:
23+
yield test_client

tests/dependencies.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import dataclasses
2+
3+
from modern_di import Group, Scope, providers
4+
from starlette.requests import Request
5+
from starlette.websockets import WebSocket
6+
7+
8+
@dataclasses.dataclass(kw_only=True, slots=True)
9+
class SimpleCreator:
10+
dep1: str
11+
12+
13+
@dataclasses.dataclass(kw_only=True, slots=True)
14+
class DependentCreator:
15+
dep1: SimpleCreator
16+
17+
18+
def fetch_method_from_request(request: Request) -> str:
19+
assert isinstance(request, Request)
20+
return request.method
21+
22+
23+
def fetch_path_from_websocket(websocket: WebSocket) -> str:
24+
assert isinstance(websocket, WebSocket)
25+
return websocket.url.path
26+
27+
28+
class Dependencies(Group):
29+
app_factory = providers.Factory(creator=SimpleCreator, kwargs={"dep1": "original"})
30+
session_factory = providers.Factory(scope=Scope.SESSION, creator=DependentCreator, bound_type=None)
31+
request_factory = providers.Factory(scope=Scope.REQUEST, creator=DependentCreator, bound_type=None)
32+
request_method = providers.Factory(scope=Scope.REQUEST, creator=fetch_method_from_request, bound_type=None)
33+
websocket_path = providers.Factory(scope=Scope.SESSION, creator=fetch_path_from_websocket, bound_type=None)

tests/test_import.py

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)