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
3 changes: 2 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ connection is meant, because this package always has more than one of each in pl

**Root container**:
The application-lifetime `Container` handed to `setup_di` and stored on the `web.Application`,
opened on startup and closed on cleanup. Every other container here descends from it, so an
opened on startup and closed on cleanup unless `setup_di` gets `manage_lifespan=False`, in which
case the user opens and closes it. Every other container here descends from it, so an
unqualified "the container" is ambiguous in this repo and should not be written.

**Child container**:
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,37 @@ An HTTP request opens a `Scope.REQUEST` child container; a WebSocket connection

Sub-apps added with `app.add_subapp` work with or without their own `setup_di`. When both the parent and the sub-app call `setup_di`, a sub-app route resolves from a child of the sub-app's root container, and the parent's middleware still opens and closes its own child container around it. When only the parent calls `setup_di`, a sub-app route resolves from a child of the parent's root container.

### Sharing a container

By default `setup_di` opens the root container on startup and closes it on cleanup. If the same container also serves another app, such as a worker or an MCP server from another modern-di integration, the first app to shut down would close it for the rest. Pass `manage_lifespan=False` and own the root yourself. The middleware still builds and closes a child container per connection.

```python
import asyncio


async def main() -> None:
app = web.Application()
container = Container(groups=[Dependencies])
setup_di(app, container, manage_lifespan=False)

async with container:
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "localhost", 8080)
await site.start()
try:
await asyncio.Event().wait()
finally:
await runner.cleanup()
```

APP-scoped finalizers run once, when the `async with` block exits.

## API

| Symbol | Description |
|---|---|
| `setup_di(app, container)` | Stores the container on the app, wires `on_startup`/`on_cleanup` (reopen on startup, close on cleanup), registers the connection providers, and installs the per-connection middleware |
| `setup_di(app, container, *, manage_lifespan=True)` | Stores the container on the app, wires `on_startup`/`on_cleanup` (reopen on startup, close on cleanup), registers the connection providers, and installs the per-connection middleware. With `manage_lifespan=False` it skips the `on_startup`/`on_cleanup` hooks and never opens or closes the root container (see Sharing a container) |
| `inject(handler)` | Decorator that resolves every `FromDI`-marked parameter from the request's child container and passes them to the handler; works on a sync or `async def` function handler and on a method of a `web.View` subclass |
| `FromDI(dependency)` | Inert `Annotated` marker resolved by `@inject`; accepts a provider or a type |
| `fetch_di_container(app)` | Returns the app-scoped root container |
Expand Down
7 changes: 4 additions & 3 deletions modern_di_aiohttp/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ async def _di_middleware(
request[_CONTAINER_REQUEST_KEY] = outer_container


def setup_di(app: web.Application, container: Container) -> Container:
def setup_di(app: web.Application, container: Container, *, manage_lifespan: bool = True) -> Container:
app[_DI_CONTAINER_APP_KEY] = container
container.add_providers(*_CONNECTION_PROVIDERS)
app.on_startup.append(_on_startup)
app.on_cleanup.append(_on_cleanup)
if manage_lifespan:
app.on_startup.append(_on_startup)
app.on_cleanup.append(_on_cleanup)
app.middlewares.append(_di_middleware)
return container

Expand Down
79 changes: 77 additions & 2 deletions tests/test_lifespan.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import collections.abc
import dataclasses
import http
import typing

import modern_di
from aiohttp import web
from aiohttp.test_utils import TestClient
from aiohttp.test_utils import TestClient, TestServer
from modern_di import providers

from modern_di_aiohttp import fetch_di_container, setup_di
from modern_di_aiohttp import FromDI, fetch_di_container, inject, setup_di
from modern_di_aiohttp.main import _on_cleanup, _on_startup
from tests.dependencies import Dependencies

Expand Down Expand Up @@ -42,3 +45,75 @@ async def test_cleanup_closes_root() -> None:
await _on_cleanup(app)
assert container.closed
assert fetch_di_container(app) is container


@dataclasses.dataclass(kw_only=True, slots=True)
class Label:
name: str


def build_unmanaged_app(finalized: list[str]) -> tuple[web.Application, modern_di.Container]:
class LabelDependencies(modern_di.Group):
app_label = providers.Factory(
creator=lambda: Label(name="app"),
bound_type=None,
cache=providers.CacheSettings(finalizer=lambda label: finalized.append(label.name)),
)
request_label = providers.Factory(
scope=modern_di.Scope.REQUEST,
creator=lambda: Label(name="request"),
bound_type=None,
cache=providers.CacheSettings(finalizer=lambda label: finalized.append(label.name)),
)

@inject
async def labels(
request: web.Request, # noqa: ARG001
app_label: typing.Annotated[Label, FromDI(LabelDependencies.app_label)],
request_label: typing.Annotated[Label, FromDI(LabelDependencies.request_label)],
) -> web.Response:
return web.Response(text=f"{app_label.name},{request_label.name}")

app = web.Application()
container = modern_di.Container(groups=[LabelDependencies])
setup_di(app, container, manage_lifespan=False)
app.router.add_get("/", labels)
return app, container


async def test_manage_lifespan_false_leaves_the_root_alone() -> None:
finalized: list[str] = []
app, container = build_unmanaged_app(finalized)

async with TestClient(TestServer(app)) as client:
response = await client.get("/")
assert await response.text() == "app,request"
assert finalized == ["request"]

assert not container.closed
assert finalized == ["request"]


async def test_manage_lifespan_false_does_not_reopen_a_closed_root() -> None:
app, container = build_unmanaged_app([])
container.close_sync()

async with TestClient(TestServer(app)) as client:
response = await client.get("/")
assert response.status == http.HTTPStatus.INTERNAL_SERVER_ERROR

assert container.closed


async def test_user_owned_root_runs_app_finalizer_once_at_exit() -> None:
finalized: list[str] = []
app, container = build_unmanaged_app(finalized)

async with container:
async with TestClient(TestServer(app)) as client:
first = await client.get("/")
second = await client.get("/")
assert (await first.text(), await second.text()) == ("app,request", "app,request")
assert finalized == ["request", "request"]

assert finalized == ["request", "request", "app"]
Loading