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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ dist/
.python-version
.venv
uv.lock
.superpowers/
17 changes: 12 additions & 5 deletions architecture/container-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,17 @@ context and its yielded state passes straight through.
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.
2. Matches it against the connection providers via
`modern_di.integrations.classify_connection` (a `Request` opens a
`Scope.REQUEST` child; a `WebSocket` opens a `Scope.SESSION` child) — the
isinstance-over-tuple dispatch itself lives in modern-di's integration kit,
not here.
3. Opens the child container as an `async with` block —
`Container.build_child_container(scope=..., context=...)` returns a
container that is already open, so entering it is a no-op; the middleware
stashes it in the ASGI `scope` dict under the internal
`_CONTAINER_SCOPE_KEY` for the duration of the block.
4. Exiting the block closes the child container (`close_async`), including on
the exception path.

Other scope types (`lifespan`) pass straight through untouched.
19 changes: 11 additions & 8 deletions architecture/dependency-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@ uses an inert marker plus a decorator (the decorator path from modern-di's

## FromDI

`FromDI(dependency)` marks an endpoint parameter for injection inside an
`Annotated` hint:
`FromDI` is `modern_di.integrations.from_di`, re-exported directly — this
package does not wrap it. It 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
It returns `typing.cast(T, Marker(dependency))`: type checkers see the resolved
type `T`, while at runtime it is a frozen `Marker` the decorator detects. The
argument is a provider (`AbstractProvider`) or a bare type — resolution
handles both via `Container.resolve_dependency`, modern-di's marker-dispatch
seam for integrations.

## @inject

`inject` wraps an endpoint. At decoration time it reads
`inject` wraps an endpoint. At decoration time it calls
`modern_di.integrations.parse_markers(func)`, which reads
`typing.get_type_hints(func, include_extras=True)` and collects the parameters
whose `Annotated` metadata holds a `_FromDI`. Starlette calls an endpoint with
whose `Annotated` metadata holds a `Marker`. 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.
Expand All @@ -30,7 +32,8 @@ 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.
2. Resolves each marked parameter via
`modern_di.integrations.resolve_markers(child_container, markers)`.
3. Calls the endpoint with the connection plus the resolved parameters by
keyword.

Expand Down
4 changes: 2 additions & 2 deletions architecture/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ 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`.
The inert `Annotated` metadata (`modern_di.integrations.Marker`) that flags an
endpoint parameter for resolution by `@inject`.
_Avoid_: Depends (that is FastAPI's mechanism, not Starlette's).
57 changes: 10 additions & 47 deletions modern_di_starlette/main.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
"""modern-di integration for Starlette."""

import contextlib
import dataclasses
import enum
import functools
import typing

from modern_di import Container, Scope, providers
from modern_di import Container, Scope, integrations, 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)
Expand Down Expand Up @@ -64,23 +61,13 @@ async def __call__(self, scope: ASGIScope, receive: Receive, send: Send) -> None
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:
match = integrations.classify_connection(connection, _CONNECTION_PROVIDERS)
async with self.container.build_child_container(
scope=match.scope if match else None,
context=match.context if match else None,
) as child_container:
scope[_CONTAINER_SCOPE_KEY] = child_container
await self.app(scope, receive, send)
finally:
await child_container.close_async()


def setup_di(app: Starlette, container: Container) -> Container:
Expand All @@ -91,35 +78,11 @@ def setup_di(app: Starlette, container: 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_dependency(marker.dependency) for name, marker in di_params.items()}
FromDI = integrations.from_di


def inject(func: typing.Callable[..., typing.Awaitable[T]]) -> typing.Callable[..., typing.Awaitable[T]]:
di_params = _parse_inject_params(func)
markers = integrations.parse_markers(func)

@functools.wraps(func)
async def wrapper(connection: Request | WebSocket) -> T:
Expand All @@ -132,6 +95,6 @@ async def wrapper(connection: Request | WebSocket) -> T:
"before using @inject."
)
raise RuntimeError(msg) from None
return await func(connection, **_resolve_di_params(child_container, di_params))
return await func(connection, **integrations.resolve_markers(child_container, markers))

return wrapper
85 changes: 85 additions & 0 deletions planning/changes/2026-07-13.01-adopt-integration-kit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
summary: modern_di_starlette/main.py now composes modern_di.integrations (classify_connection, Marker/from_di/parse_markers/resolve_markers, Container's async-with) instead of hand-rolling them; no public-API or test change.
---

# Design: Adopt the modern-di integration kit

## Summary

`modern-di` 2.28.0 shipped `modern_di.integrations` — the framework-agnostic
primitives that formalize what this package's `_DIMiddleware` and
`@inject`/`FromDI` already hand-roll. This change swaps the hand-rolled
internals for kit calls. Public API, runtime behavior, and every existing test
are unchanged; only `modern_di_starlette/main.py`'s internals shrink.

## Motivation

This package was one of the 13 adapters the kit's own design surveyed for
duplication — see
[modern-di's decision record](https://github.com/modern-python/modern-di/blob/main/planning/decisions/2026-07-13-integration-kit-shape.md).
Concretely, in `main.py` today:

- `_DIMiddleware.__call__`'s isinstance-over-`_CONNECTION_PROVIDERS` loop is
`modern_di.integrations.classify_connection`, byte-for-byte.
- `_FromDI` (a frozen dataclass, `dependency: AbstractProvider | type`) is
`modern_di.integrations.Marker`.
- `_parse_inject_params` is `integrations.parse_markers`.
- `_resolve_di_params` is `integrations.resolve_markers`.
- `FromDI`'s body (`cast(T, _FromDI(dependency))`) is `integrations.from_di`
verbatim — this package is one of the seven non-native-DI adapters that can
re-export it directly rather than wrapping it.

This is the first of the 13 conversions (rollout order: starlette, then
fastapi) — see modern-di's change file for why starlette is first: richest
non-native-DI skeleton, exercises both kit layers.

## Design

In `modern_di_starlette/main.py`:

- Import `integrations` from `modern_di`.
- `_DIMiddleware.__call__`: replace the isinstance loop with
`match = integrations.classify_connection(connection, _CONNECTION_PROVIDERS)`,
then open the child via `async with self.container.build_child_container(scope=match.scope if match else None, context=match.context if match else None) as child_container:` —
replacing the manual `try`/`finally: await child_container.close_async()`
with `Container`'s own context-manager protocol (already used this way in
modern-di's own updated integration guide).
- Delete `_FromDI`; replace `FromDI`'s function body with a direct assignment:
`FromDI = integrations.from_di`.
- Delete `_parse_inject_params`/`_resolve_di_params`; `inject` calls
`integrations.parse_markers(func)` at decoration time and
`integrations.resolve_markers(child_container, markers)` at call time.
- Drop now-unused imports: `dataclasses`, `enum`, and the `T_co` TypeVar (only
`_FromDI` used it).

`__init__.py`'s re-exports are untouched — every public name keeps its exact
signature and behavior.

## Non-goals

- Changing `_DIMiddleware`'s ASGI shape, `_compose_lifespan`, or the
connection-provider registration — none of that is part of the kit.
- Any test rewrite. The existing suite asserts on public behavior only
(`FromDI`, `inject`, `setup_di`, `fetch_di_container`, the two connection
providers, `_CONTAINER_SCOPE_KEY`) — none of it references the internals
being replaced, so it should pass unmodified. If it doesn't, that itself is
a finding.

## Testing

- `just test-ci` — 100% line coverage, all existing tests green with zero
test-file changes.
- `just lint-ci` — ruff (`select=ALL`), `ty check`, `check-planning`.
- Manual smoke: run the existing test suite before and after to diff nothing
but internals moved.

## Risk

- **Version-floor bump breaks on an environment still resolving `<2.28`**
(low likelihood — semver-compatible, purely additive on modern-di's side;
low impact — `pyproject.toml` pins the floor explicitly).
- **`async with` on the child container behaves differently from the manual
try/finally** (low likelihood — `Container.__aenter__` is a no-op on an
already-open container per modern-di's own architecture docs; mitigated by
the unchanged test suite exercising close-on-request-end and
close-on-exception paths already).
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,<2", "modern-di>=2.25.0,<3"]
dependencies = ["starlette>=0.40,<2", "modern-di>=2.28.0,<3"]
version = "0"

[project.urls]
Expand Down
Loading