Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions architecture/container-lifecycle.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions architecture/dependency-resolution.md
Original file line number Diff line number Diff line change
@@ -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).
23 changes: 23 additions & 0 deletions architecture/glossary.md
Original file line number Diff line number Diff line change
@@ -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).
19 changes: 18 additions & 1 deletion modern_di_starlette/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
143 changes: 143 additions & 0 deletions modern_di_starlette/main.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
23 changes: 23 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions tests/dependencies.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 0 additions & 4 deletions tests/test_import.py

This file was deleted.

Loading
Loading