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/
22 changes: 16 additions & 6 deletions architecture/container-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`).
Expand Down
17 changes: 10 additions & 7 deletions architecture/dependency-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
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` 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).
54 changes: 15 additions & 39 deletions modern_di_aiohttp/main.py
Original file line number Diff line number Diff line change
@@ -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")


Expand Down Expand Up @@ -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:
Expand All @@ -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
92 changes: 92 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,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).
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 = ["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]
Expand Down
Loading