Skip to content

Commit b77fdaf

Browse files
authored
Add manage_lifespan flag to setup_di (#50)
1 parent 0a45725 commit b77fdaf

4 files changed

Lines changed: 110 additions & 7 deletions

File tree

‎CONTEXT.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ connection is meant, because this package always has more than one of each in pl
1818

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

2425
**Child container**:

‎README.md‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,37 @@ An HTTP request opens a `Scope.REQUEST` child container; a WebSocket connection
9393

9494
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.
9595

96+
### Sharing a container
97+
98+
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.
99+
100+
```python
101+
import asyncio
102+
103+
104+
async def main() -> None:
105+
app = web.Application()
106+
container = Container(groups=[Dependencies])
107+
setup_di(app, container, manage_lifespan=False)
108+
109+
async with container:
110+
runner = web.AppRunner(app)
111+
await runner.setup()
112+
site = web.TCPSite(runner, "localhost", 8080)
113+
await site.start()
114+
try:
115+
await asyncio.Event().wait()
116+
finally:
117+
await runner.cleanup()
118+
```
119+
120+
APP-scoped finalizers run once, when the `async with` block exits.
121+
96122
## API
97123

98124
| Symbol | Description |
99125
|---|---|
100-
| `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 |
126+
| `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) |
101127
| `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 |
102128
| `FromDI(dependency)` | Inert `Annotated` marker resolved by `@inject`; accepts a provider or a type |
103129
| `fetch_di_container(app)` | Returns the app-scoped root container |

‎modern_di_aiohttp/main.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,12 @@ async def _di_middleware(
8484
request[_CONTAINER_REQUEST_KEY] = outer_container
8585

8686

87-
def setup_di(app: web.Application, container: Container) -> Container:
87+
def setup_di(app: web.Application, container: Container, *, manage_lifespan: bool = True) -> Container:
8888
app[_DI_CONTAINER_APP_KEY] = container
8989
container.add_providers(*_CONNECTION_PROVIDERS)
90-
app.on_startup.append(_on_startup)
91-
app.on_cleanup.append(_on_cleanup)
90+
if manage_lifespan:
91+
app.on_startup.append(_on_startup)
92+
app.on_cleanup.append(_on_cleanup)
9293
app.middlewares.append(_di_middleware)
9394
return container
9495

‎tests/test_lifespan.py‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import collections.abc
2+
import dataclasses
23
import http
4+
import typing
35

46
import modern_di
57
from aiohttp import web
6-
from aiohttp.test_utils import TestClient
8+
from aiohttp.test_utils import TestClient, TestServer
9+
from modern_di import providers
710

8-
from modern_di_aiohttp import fetch_di_container, setup_di
11+
from modern_di_aiohttp import FromDI, fetch_di_container, inject, setup_di
912
from modern_di_aiohttp.main import _on_cleanup, _on_startup
1013
from tests.dependencies import Dependencies
1114

@@ -42,3 +45,75 @@ async def test_cleanup_closes_root() -> None:
4245
await _on_cleanup(app)
4346
assert container.closed
4447
assert fetch_di_container(app) is container
48+
49+
50+
@dataclasses.dataclass(kw_only=True, slots=True)
51+
class Label:
52+
name: str
53+
54+
55+
def build_unmanaged_app(finalized: list[str]) -> tuple[web.Application, modern_di.Container]:
56+
class LabelDependencies(modern_di.Group):
57+
app_label = providers.Factory(
58+
creator=lambda: Label(name="app"),
59+
bound_type=None,
60+
cache=providers.CacheSettings(finalizer=lambda label: finalized.append(label.name)),
61+
)
62+
request_label = providers.Factory(
63+
scope=modern_di.Scope.REQUEST,
64+
creator=lambda: Label(name="request"),
65+
bound_type=None,
66+
cache=providers.CacheSettings(finalizer=lambda label: finalized.append(label.name)),
67+
)
68+
69+
@inject
70+
async def labels(
71+
request: web.Request, # noqa: ARG001
72+
app_label: typing.Annotated[Label, FromDI(LabelDependencies.app_label)],
73+
request_label: typing.Annotated[Label, FromDI(LabelDependencies.request_label)],
74+
) -> web.Response:
75+
return web.Response(text=f"{app_label.name},{request_label.name}")
76+
77+
app = web.Application()
78+
container = modern_di.Container(groups=[LabelDependencies])
79+
setup_di(app, container, manage_lifespan=False)
80+
app.router.add_get("/", labels)
81+
return app, container
82+
83+
84+
async def test_manage_lifespan_false_leaves_the_root_alone() -> None:
85+
finalized: list[str] = []
86+
app, container = build_unmanaged_app(finalized)
87+
88+
async with TestClient(TestServer(app)) as client:
89+
response = await client.get("/")
90+
assert await response.text() == "app,request"
91+
assert finalized == ["request"]
92+
93+
assert not container.closed
94+
assert finalized == ["request"]
95+
96+
97+
async def test_manage_lifespan_false_does_not_reopen_a_closed_root() -> None:
98+
app, container = build_unmanaged_app([])
99+
container.close_sync()
100+
101+
async with TestClient(TestServer(app)) as client:
102+
response = await client.get("/")
103+
assert response.status == http.HTTPStatus.INTERNAL_SERVER_ERROR
104+
105+
assert container.closed
106+
107+
108+
async def test_user_owned_root_runs_app_finalizer_once_at_exit() -> None:
109+
finalized: list[str] = []
110+
app, container = build_unmanaged_app(finalized)
111+
112+
async with container:
113+
async with TestClient(TestServer(app)) as client:
114+
first = await client.get("/")
115+
second = await client.get("/")
116+
assert (await first.text(), await second.text()) == ("app,request", "app,request")
117+
assert finalized == ["request", "request"]
118+
119+
assert finalized == ["request", "request", "app"]

0 commit comments

Comments
 (0)