diff --git a/.gitignore b/.gitignore index 8cf31db..3db7a1f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ dist/ .python-version .venv uv.lock +.superpowers/ diff --git a/architecture/container-lifecycle.md b/architecture/container-lifecycle.md index d50310e..bbf7082 100644 --- a/architecture/container-lifecycle.md +++ b/architecture/container-lifecycle.md @@ -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. diff --git a/architecture/dependency-resolution.md b/architecture/dependency-resolution.md index d892bf2..c42f538 100644 --- a/architecture/dependency-resolution.md +++ b/architecture/dependency-resolution.md @@ -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. @@ -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. diff --git a/architecture/glossary.md b/architecture/glossary.md index 46e9cc3..2a3bbc9 100644 --- a/architecture/glossary.md +++ b/architecture/glossary.md @@ -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). diff --git a/modern_di_starlette/main.py b/modern_di_starlette/main.py index 684d54f..28a585d 100644 --- a/modern_di_starlette/main.py +++ b/modern_di_starlette/main.py @@ -1,12 +1,10 @@ """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 @@ -14,7 +12,6 @@ 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) @@ -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: @@ -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: @@ -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 diff --git a/planning/changes/2026-07-13.01-adopt-integration-kit.md b/planning/changes/2026-07-13.01-adopt-integration-kit.md new file mode 100644 index 0000000..5d7f2db --- /dev/null +++ b/planning/changes/2026-07-13.01-adopt-integration-kit.md @@ -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). diff --git a/pyproject.toml b/pyproject.toml index 6ca1d4e..7318a94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]