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 7198cac..0917b15 100644 --- a/architecture/container-lifecycle.md +++ b/architecture/container-lifecycle.md @@ -38,12 +38,22 @@ one type, so: `_di_middleware` (`@web.middleware`) runs for every request. It: 1. Detects a WebSocket upgrade with `web.WebSocketResponse().can_prepare(request).ok` - (checks the handshake without starting it or raising). -2. Opens a `Scope.SESSION` child for a WebSocket, else a `Scope.REQUEST` child, - with `{web.Request: request}` injected as context. -3. Stashes the child on the request under `_CONTAINER_REQUEST_KEY`. -4. `close_async`s the child when the handler returns (for a WebSocket that is - when the socket closes, since the handler owns the socket's whole lifetime). + (checks the handshake without starting it or raising) and picks + `aiohttp_websocket_provider` or `aiohttp_request_provider` accordingly — this + probe is aiohttp's own; `modern_di.integrations.classify_connection`'s + isinstance-over-tuple dispatch can't do it, since both providers bind + `web.Request`. +2. Derives the child's scope and context from the picked provider via + `modern_di.integrations.bind(provider, request)` — `Scope.SESSION` for a + WebSocket, else `Scope.REQUEST`, with `{web.Request: request}` injected as + context. +3. Opens the child as an `async with` block — `Container.build_child_container` + returns a container that is already open, so entering it is a no-op — and + stashes it on the request under `_CONTAINER_REQUEST_KEY` for the duration of + the block. +4. Exiting the block closes the child (`close_async`), including on the + exception path — for a WebSocket that is when the socket closes, since the + handler owns the socket's whole lifetime. Per-message work inside a WebSocket handler opens a nested `Scope.REQUEST` child of the SESSION container (`session_container.build_child_container(scope=Scope.REQUEST)`). diff --git a/architecture/dependency-resolution.md b/architecture/dependency-resolution.md index 53b7fd5..1b8b978 100644 --- a/architecture/dependency-resolution.md +++ b/architecture/dependency-resolution.md @@ -6,21 +6,23 @@ uses an inert marker plus a decorator (the decorator path from modern-di's ## FromDI -`FromDI(dependency)` marks a handler parameter for injection inside an +`FromDI` is `modern_di.integrations.from_di`, re-exported directly — this +package does not wrap it. It marks a handler 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 (`resolve_provider` vs `resolve`). ## @inject -`inject` wraps a handler. At decoration time it reads +`inject` wraps a handler. 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`. aiohttp calls a handler with just +whose `Annotated` metadata holds a `Marker`. aiohttp calls a handler with just the request (`handler(request)`) and does not introspect its signature, so — unlike a CLI integration — no signature rewrite is needed. @@ -29,7 +31,8 @@ At call time the wrapper: 1. Reads the per-connection child container from `request[_CONTAINER_REQUEST_KEY]` (put there by `_di_middleware`); a missing key raises a `RuntimeError` pointing at `setup_di`. -2. Resolves each marked parameter. +2. Resolves each marked parameter via + `modern_di.integrations.resolve_markers(child_container, markers)`. 3. Calls the handler with the request plus the resolved parameters by keyword. The `web.Request` a provider receives as DI context is the same object aiohttp diff --git a/architecture/glossary.md b/architecture/glossary.md index 1ab8aa9..fb41870 100644 --- a/architecture/glossary.md +++ b/architecture/glossary.md @@ -18,6 +18,6 @@ A `ContextProvider` exposing the connection `web.Request`: `aiohttp_request_prov (REQUEST, by type) and the reference-only `aiohttp_websocket_provider` (SESSION). **FromDI marker**: -The inert `Annotated` metadata (`_FromDI`) that flags a handler parameter for -resolution by `@inject`. +The inert `Annotated` metadata (`modern_di.integrations.Marker`) that flags a +handler parameter for resolution by `@inject`. _Avoid_: Depends (that is FastAPI's mechanism). diff --git a/modern_di_aiohttp/main.py b/modern_di_aiohttp/main.py index b92f828..9054e75 100644 --- a/modern_di_aiohttp/main.py +++ b/modern_di_aiohttp/main.py @@ -1,15 +1,12 @@ """modern-di integration for aiohttp.""" -import dataclasses -import enum import functools import typing from aiohttp import web -from modern_di import Container, Scope, providers +from modern_di import Container, Scope, integrations, providers -T_co = typing.TypeVar("T_co", covariant=True) T = typing.TypeVar("T") @@ -61,16 +58,19 @@ async def _di_middleware( handler: typing.Callable[[web.Request], typing.Awaitable[web.StreamResponse]], ) -> web.StreamResponse: # `can_prepare` never raises and does not start the handshake; it only checks - # whether the request is a valid WebSocket upgrade. - connection_scope: enum.IntEnum = Scope.SESSION if web.WebSocketResponse().can_prepare(request).ok else Scope.REQUEST - child_container = fetch_di_container(request.app).build_child_container( - context={web.Request: request}, scope=connection_scope + # whether the request is a valid WebSocket upgrade. Both connection providers + # bind `web.Request`, so `integrations.classify_connection`'s isinstance + # dispatch can't tell them apart — this probe is what picks the provider; + # `integrations.bind` only derives the scope+context once it's picked. + provider = ( + aiohttp_websocket_provider if web.WebSocketResponse().can_prepare(request).ok else aiohttp_request_provider ) - request[_CONTAINER_REQUEST_KEY] = child_container - try: + match = integrations.bind(provider, request) + async with fetch_di_container(request.app).build_child_container( + scope=match.scope, context=match.context + ) as child_container: + request[_CONTAINER_REQUEST_KEY] = child_container return await handler(request) - finally: - await child_container.close_async() def setup_di(app: web.Application, container: Container) -> Container: @@ -82,39 +82,15 @@ def setup_di(app: web.Application, 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(request: web.Request) -> T: child_container = fetch_request_container(request) - return await func(request, **_resolve_di_params(child_container, di_params)) + return await func(request, **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..fad05bf --- /dev/null +++ b/planning/changes/2026-07-13.01-adopt-integration-kit.md @@ -0,0 +1,92 @@ +--- +summary: modern_di_aiohttp/main.py now composes modern_di.integrations (bind for scope/context derivation — the can_prepare() provider probe stays aiohttp's own — 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 `_di_middleware` and +`@inject`/`FromDI` already hand-roll. This change swaps the hand-rolled +internals for kit calls. Public API and runtime behavior are unchanged; every +existing test asserts on public behavior only. + +## Motivation + +Fourth of the 13 adapter conversions surveyed by the kit's own design — see +[modern-di's decision record](https://github.com/modern-python/modern-di/blob/main/planning/decisions/2026-07-13-integration-kit-shape.md). +This repo is one of the two adapters the decision record calls out by name: +aiohttp's `aiohttp_request_provider` and `aiohttp_websocket_provider` both +bind `web.Request` (a WebSocket is an upgraded HTTP request at middleware +entry, not a distinct connection type), so `integrations.classify_connection`'s +isinstance-over-tuple dispatch **cannot** distinguish them — aiohttp keeps its +own `can_prepare()` handshake probe to pick the provider. What the kit *does* +replace is the derivation step once the provider is known: +`integrations.bind(provider, connection) -> ConnectionMatch` produces the +same `{web.Request: request}` context dict and scope aiohttp already +hand-writes, from whichever provider `can_prepare()` selected. This mirrors +the [FromDI]/`@inject` conversion already validated in +[`modern-di-starlette`](https://github.com/modern-python/modern-di-starlette/pull/8) +(same non-native-DI shape: `_FromDI`, `_parse_inject_params`, +`_resolve_di_params` all map onto the kit byte-for-byte). + +## Design + +In `modern_di_aiohttp/main.py`: + +- Import `integrations` from `modern_di`. +- `_di_middleware`: keep the `can_prepare()` probe to select + `aiohttp_websocket_provider` or `aiohttp_request_provider`, then replace the + hand-written scope/context with + `match = integrations.bind(provider, request)`. Open the child via + `async with fetch_di_container(request.app).build_child_container(scope=match.scope, context=match.context) as child_container:` — + replacing the manual `try`/`finally: await child_container.close_async()`. + `bind()` never returns `None` (unlike `classify_connection`), so no + fallback branch is needed here. +- Delete `_FromDI`; replace `FromDI`'s 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: `enum` (only used for the old + `connection_scope: enum.IntEnum` annotation) and `dataclasses` and the + `T_co` TypeVar (only `_FromDI` used them). + +`__init__.py`'s re-exports are untouched — every public name keeps its exact +signature and behavior. No test constructs `_FromDI` directly (unlike +`modern-di-litestar`'s `_Dependency` — verified by reading all four test +files), so no test-file edit is expected here. + +## Non-goals + +- Any change to `setup_di`, `_on_startup`/`_on_cleanup`, or connection-provider + registration — none of that is part of the kit. +- The `can_prepare()` handshake probe itself — this is exactly the outlier + case the kit's design deliberately does not absorb (a hypothetical seam + used by only one adapter); it stays aiohttp's own code. +- Any test rewrite. The existing suite asserts on public behavior only + (`FromDI`, `inject`, `setup_di`, `fetch_di_container`, + `fetch_request_container`, the two connection providers, and the internal + `_CONTAINER_REQUEST_KEY`/`_on_startup`/`_on_cleanup` names some tests import + directly — none of it references `_FromDI` or the scope-derivation + internals being replaced). + +## 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`. + +## Risk + +- **Version-floor bump breaks on an environment still resolving `<2.28`** + (low likelihood — semver-compatible; low impact — `pyproject.toml` pins the + floor explicitly). +- **`bind()` derives a different context dict than the hand-written + `{web.Request: request}`** (low likelihood — `bind(provider, connection)` + returns `context={provider.context_type: connection}`; both + `aiohttp_request_provider` and `aiohttp_websocket_provider` have + `context_type=web.Request`, so the key is identical regardless of which + provider `can_prepare()` selects — verified against the provider + declarations before writing this spec). diff --git a/pyproject.toml b/pyproject.toml index 92d48af..ac3f997 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Typing :: Typed", "Topic :: Software Development :: Libraries", ] -dependencies = ["aiohttp>=3.9,<4", "modern-di>=2.25.0,<3"] +dependencies = ["aiohttp>=3.9,<4", "modern-di>=2.28.0,<3"] version = "0" [project.urls]