diff --git a/CONTEXT.md b/CONTEXT.md index d844c30..480124e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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**: diff --git a/README.md b/README.md index e56bd56..a3b1bae 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/modern_di_aiohttp/main.py b/modern_di_aiohttp/main.py index df9785a..9da6ec2 100644 --- a/modern_di_aiohttp/main.py +++ b/modern_di_aiohttp/main.py @@ -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 diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index a392fb8..437684b 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -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 @@ -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"]