diff --git a/docs/integrations/pytest.md b/docs/integrations/pytest.md index 2631aa6a..ed2c8844 100644 --- a/docs/integrations/pytest.md +++ b/docs/integrations/pytest.md @@ -1,17 +1,13 @@ # Usage with `pytest` -`modern-di-pytest` turns any DI dependency into a pytest fixture. Two -callables cover the entire surface: `modern_di_fixture` for a single -dependency and `expose` for bulk-generating one fixture per provider across -one or more `Group` subclasses. - -You don't need the extra dependency to test with modern-di. Define -`di_container` as a session-scoped pytest fixture around `Container(...)` -used as a context manager, build a request-scoped child-container fixture -from it, and resolve dependencies inside tests with `container.resolve(...)` -directly. See the -[testing-with-overrides recipe](../recipes/testing-overrides.md) for a worked -example of that approach. The rest of this page assumes the plugin. +`modern-di-pytest` turns DI dependencies into pytest fixtures. It registers no pytest plugin, so +nothing loads on its own: you import its two functions in `conftest.py` or a test module. +`modern_di_fixture` makes one fixture from one dependency, and `expose` makes one fixture per provider +across one or more `Group` subclasses. + +You can test without it. Yield a `Container` from your own fixture and call `container.resolve(...)` +in the tests; [Testing with overrides](../recipes/testing-overrides.md) covers swapping in fakes that +way. ## How to use @@ -37,8 +33,7 @@ example of that approach. The rest of this page assumes the plugin. ### 2. Define a `di_container` fixture -Define it at the highest pytest scope you want. The plugin never builds the -container; you own it: +The package never builds a container. You own it, and you pick its pytest scope: -For deeper patterns (transactional DB sessions, resetting all overrides) see the [testing-with-overrides recipe](../recipes/testing-overrides.md). +With a session-scoped `di_container`, watch cached providers (`cache=True`). A cached instance keeps +the dependencies it was built with. If an earlier test resolved it, a later override of one of its +dependencies does not reach it; if it was first resolved under an override, it keeps the fake after +the override ends. Override the cached provider itself, or give the tests that need the fake a +function-scoped container. + +For transactional database sessions and other patterns, see +[Testing with overrides](../recipes/testing-overrides.md). ## See also - [Testing with overrides](../recipes/testing-overrides.md): override patterns beyond fixtures. -- [Scopes](../providers/scopes.md): session vs request container fixtures. +- [Scopes](../providers/scopes.md): the APP → REQUEST lifetime model behind child container fixtures. +- [FinalizerError](../troubleshooting/finalizer-error.md): closing a container with async finalizers. + +## API + +| Symbol | Description | +|---|---| +| `modern_di_fixture(dependency, *, container_fixture="di_container", name=None, pytest_scope="function")` | Returns a pytest fixture that resolves `dependency` (a type or a provider) through `container.resolve_dependency` on the container fixture. Assign it to a module-level name; `name=` overrides the fixture name. | +| `expose(*groups, container_fixture="di_container", pytest_scope="function", module=None)` | Installs one fixture per provider attribute of each group onto the calling module, or onto `module=`. Raises `ValueError` on a duplicate name across groups and `TypeError` with no groups, before installing anything. | diff --git a/docs/integrations/writing-integrations.md b/docs/integrations/writing-integrations.md index 6b013259..d8bdf1a2 100644 --- a/docs/integrations/writing-integrations.md +++ b/docs/integrations/writing-integrations.md @@ -1,37 +1,31 @@ # Writing an integration -This page is the specification for building a modern-di integration for a -framework that does not yet have one (an ASGI app, a message broker, a CLI, a -test runner...). It is written to be followed step by step: implement the -contract below, mirror the scaffolding, and check every box in the final -checklist. +This page specifies how to build a modern-di integration for a framework that does not have one yet: +an ASGI app, a message broker, a CLI, a test runner. Implement the contract, mirror the scaffolding, +and work through the checklist at the end. -An integration does three jobs and nothing more: +An integration does three jobs: -1. Own the root container's lifecycle: open it when the app starts, close it - when the app stops. -2. Open a child container per unit of work (a request, a message, a command), - injecting the framework's connection object as context, and close it when - that unit ends. -3. Bridge modern-di into the framework's own injection, so a handler can ask - for a provider or a type and receive the resolved value. - -Everything else is framework-specific plumbing to realize those three jobs. +1. Own the root container's lifecycle: open it when the app starts, close it when the app stops. +2. Open a child container per unit of work (a request, a message, a command), injecting the + framework's connection object as context, and close it when that unit ends. +3. Bridge modern-di into the framework's own injection, so a handler can ask for a provider or a type + and receive the resolved value. ## The contract -Every integration exposes the following. Types are shown for an async web -framework; swap `async`/`close_async` for `close_sync` in a synchronous one. +Every integration exposes the following. The examples are for an async web framework; a synchronous +one swaps `async with` for `with` and `close_async` for `close_sync`. ### 1. Connection `ContextProvider`(s) -One module-level provider per **connection kind** the framework has. Each pairs -the framework's connection type with the [scope](../providers/scopes.md) its -child container should open at. This is the single source of the -kind → scope mapping; `setup_di` registers them and the child-container builder -dispatches off them. +One module-level provider per connection kind the framework has. Each pairs the framework's connection +type with the [scope](../providers/scopes.md) its child container opens at, which makes these providers +the single source of the kind-to-scope mapping. `setup_di` registers them and the child builder +dispatches on them. + + +```python +fetch_di_container(myfw.App()) +``` ### 4. Per-unit-of-work child-container builder -Build a child container at the connection's scope, inject the connection object -as context, hand it to the handler, and close it in `finally`. The shape -depends on how the framework runs handlers: +Build a child container at the connection's scope, inject the connection object as context, hand the +child to the handler, and close it when the unit of work ends. `async with` on the child closes it on +every exit path. Who builds the child depends on how the framework runs handlers: -- With a dependency generator (FastAPI, Litestar), an `async def` `yield`s - the container and closes it after. Derive the child's scope and context with - `modern_di.integrations.classify_connection`, which picks the first provider - the connection is an instance of and returns its scope + a - `{context_type: connection}` context, or `None` if nothing matches: +- A dependency generator (FastAPI, Litestar, taskiq) yields the child. With several connection kinds, + `modern_di.integrations.classify_connection` picks the first provider the connection is an instance + of and returns its scope and a `{context_type: connection}` context, or `None` when nothing matches: ```python from modern_di import integrations @@ -140,53 +170,34 @@ depends on how the framework runs handlers: yield container ``` - `Container` implements both sync and async context-manager protocols - (`__enter__`/`__exit__`, `__aenter__`/`__aexit__`). `async with`/`with` on a - freshly built child opens it (a no-op, since a freshly built child is already - open) and closes it on exit, equivalent to a `try`/`finally` around - `close_async`/`close_sync` but without hand-writing it. A freshly built child - is usable immediately either way, because `open()` runs no validation of its - own. The `with`/`async with` here buys guaranteed cleanup on the way out; it - is not a required open step or a fail-fast check. - - For a single connection kind with no dispatch to do, call - `integrations.bind(my_provider, connection)` directly; it returns the same - scope + context for one provider without the isinstance scan. An adapter - whose unit of work carries no connection object (a Typer command) skips - the kit entirely and calls `build_child_container(scope=...)` directly. See - [How the existing integrations realize the - contract](#how-the-existing-integrations-realize-the-contract) for which - shape fits which adapter. - -- With middleware (FastStream), the `consume_scope` of a `BaseMiddleware` builds - the child, stashes it in the framework context for the duration of the call, - and closes it in `finally`. - -- With a decorator (Typer), an `inject` decorator wraps the command, opens a - child container for the command's duration, resolves the marked parameters, - and closes the container (synchronously) on exit. + With one connection kind, `integrations.bind(provider, connection)` returns the same scope and + context without the `isinstance` scan. + +- Middleware or a framework hook builds the child, stores it where the handler's code can find it for + the duration of the call, and closes it afterwards. FastStream does this in + `BaseMiddleware.consume_scope`, Starlette in a pure-ASGI middleware, Flask in `before_request` and + `teardown_appcontext`, arq in `on_job_start` and `on_job_end`, and gRPC in a server interceptor. + +- A decorator builds the child around each call (Typer, Celery). A unit of work with no connection + object skips `bind` and `classify_connection` and calls `build_child_container(scope=...)` directly. ### 5. `FromDI` marker + `Dependency` resolver -`FromDI(dependency)` accepts a provider or a type and, at a handler's call -site, stands in for the resolved value: `x: Annotated[Foo, FromDI(foo_provider)]`. -How it delivers that value splits into two modes depending on the framework: - -- Native-DI frameworks (FastAPI, FastMCP, FastStream, Litestar) have a per-handler - injection seam: `Depends`, `Provide`. `FromDI` returns that native marker and - the framework calls your resolver with the request container. This is the path - documented below. FastMCP is the exception to the `Annotated` form: it strips an - injected parameter from the tool schema only when the marker is the parameter's - default, so there `FromDI` is a default value, `x: T = FromDI(T)`. -- Frameworks with no request-scoped DI (Typer/Click CLIs, argparse, task - runners) have no seam. `FromDI` returns an inert marker and a decorator - does the resolution. See [Frameworks without native - DI](#frameworks-without-native-di-the-decorator-path). - -For the native-DI path, `FromDI` returns the framework's injection marker -wrapping a frozen, slotted dataclass that holds a -`modern_di.integrations.Marker`. Its `__call__` receives the request container -(via the framework's own DI) and resolves through the marker: +`FromDI(dependency)` accepts a provider or a type and stands in for the resolved value at a handler's +call site: `x: Annotated[Foo, FromDI(foo_provider)]`. How it delivers the value depends on the +framework: + +- Native-DI frameworks (FastAPI, FastMCP, FastStream, Litestar, taskiq) have a per-handler injection + seam such as `Depends`, `Provide` or `TaskiqDepends`. `FromDI` returns that native marker, and the + framework calls your resolver with the request container. FastMCP needs the default-value form, + `x: T = FromDI(T)`, because it strips an injected parameter from the tool schema only when the marker + is the default; its integration refuses to start on the `Annotated` form. +- Frameworks without request-scoped DI return an inert marker from `FromDI`, and a decorator does the + resolution. See [the decorator path](#frameworks-without-native-di-the-decorator-path). + +On the native path, `FromDI` returns the framework's marker wrapping a frozen, slotted dataclass that +holds a `modern_di.integrations.Marker`. The dataclass's `__call__` receives the request container +through the framework's DI and resolves through the marker: ```python from modern_di import integrations @@ -213,64 +224,42 @@ assert request_container.scope is Scope.REQUEST assert request_container.resolve(myfw.Request) is request assert isinstance(await FromDI(Dependencies.service)(request_container), MyService) await builder.aclose() +assert request_container.closed --> -`Marker.resolve(container)` is a single call, invariant across every -integration and both modes: it hands the wrapped provider-or-type to -`container.resolve_dependency`, which routes to `resolve_provider`/`resolve` -accordingly. Overrides, caching, and did-you-mean suggestions are inherited -from whichever it dispatches to. `FromDI` is spelled in PascalCase (with -`# noqa: N802`) because it stands in for a type at call sites. +`Marker.resolve(container)` calls `container.resolve_dependency`, so overrides, caching and +did-you-mean suggestions come with it. `FromDI` is PascalCase, with `# noqa: N802`, because it stands +in for a type at call sites. ## Lifecycle rules -- Reopen the root container on startup. A container that was closed on shutdown - stays closed: the next resolve raises `ContainerClosedError`. Reopen it on - each startup so that a second lifespan cycle in the same process (test client - re-entry, broker restart) can resolve from it. - - With a context-manager lifespan: `async with fetch_di_container(app): yield`, - where `__aenter__` reopens and `__aexit__` closes. Compose *around* any - existing lifespan rather than replacing it. - - With callback hooks: `app.on_startup(container.open)` and - `app.after_shutdown(container.close_async)`. Calling `open()` on an - already-open container is a no-op: it unconditionally clears - `closed`, runs no validation, and costs nothing either way. -- Always close the child container in `finally`. Never leak a unit-of-work - container on the error path. -- Match async vs sync to the framework. Async frameworks use `close_async`; a - synchronous CLI uses `close_sync`. -- For boot-time fail-fast validation, call `container.validate()` *after* - `setup_di`, never before. `open()` validates nothing: a fresh container is - already usable, and `open()` is the symmetric counterpart to `close_*`. The - ordering constraint belongs to `validate()` instead. `setup_di` registers the - integration's own connection providers (typically via `add_providers`), so - a `validate()` call made *before* `setup_di` sees an incomplete graph and - raises for any service that depends on the connection object *by type*, which - isn't registered yet. Calling `validate()` after `setup_di` sees the - complete graph. Validating at all is optional, but document the ordering for whoever does. -- Open the root in *every* execution context the framework runs work in. A - worker may dispatch units of work from more than one place: Celery fires - `worker_process_init` only for the prefork/solo pools, never for the - gevent / eventlet / threads pools (which run in the main worker process). - Wire open/close to a hook that fires for *all* of them, e.g. `worker_init` / - `worker_shutdown` *in addition to* the per-process signals. Where a hook - exists, close there so finalizers run at shutdown, and open there too, so a - restart in the same process reopens the container the previous cycle closed - instead of raising `ContainerClosedError`. Where no hook fires for a given - pool, work still succeeds, because the container is already open from - construction, but nothing ever closes it, so that pool's finalizers never - run. `open()` and `close_*` are idempotent, so overlapping hooks are safe. If - the framework offers no lifecycle hook at all, the root's open/close is the - caller's to own; document it. On ASGI, the lifespan scope is optional: a - mounted sub-application never receives it from its parent, and some - deployments disable it (e.g. Mangum `lifespan="off"`). An app wired there - still serves requests (the container is already open), but nothing closes - it, so `setup_di` belongs on the top-level served app, or the caller closes - the root itself. +- Reopen the root container on startup. A container closed on shutdown stays closed, and the next + resolve raises `ContainerClosedError`, so a second lifespan cycle in the same process (a test client + re-entered, a broker restarted) needs the reopen. With a context-manager lifespan, compose + `async with container` around the existing lifespan, as `setup_di` above does: `__aenter__` opens and + `__aexit__` closes. With callback hooks, register `container.open` on startup and + `container.close_async` on shutdown, as FastStream, aiogram and taskiq do. `open()` validates nothing + and is a no-op on an open container. +- Close the child container on every exit path, errors included. +- Match async and sync to the framework: `close_async` in an async framework, `close_sync` in a + synchronous one (Typer, Flask, a sync gRPC server). +- Document that `container.validate()` goes after `setup_di`, never before. `setup_di` registers the + connection providers, so an earlier `validate()` sees an incomplete graph and raises for any provider + that depends on the connection object by type. Validating is optional. +- Open and close the root in every execution context the framework runs work in. Celery fires + `worker_process_init` only for the prefork and solo pools, never for gevent, eventlet or threads, + which run in the main worker process; its integration also hooks `worker_init` and `worker_shutdown`, + which fire for every pool. `open()` and `close_*` are idempotent, so overlapping hooks are safe. Where + no hook fires, work still succeeds because a new container is open, but nothing closes it and its + finalizers never run. If the framework has no lifecycle hook at all (Flask, gRPC, Typer), the caller + owns the root's open and close; say so on the docs page. +- On ASGI the lifespan scope is optional. A mounted sub-application never receives it from its parent, + and some deployments turn it off (Mangum `lifespan="off"`), so call `setup_di` on the top-level + served app or have the caller close the root. ## Scope mapping -Map each connection kind to the scope its child container opens at. Follow the +Map each connection kind to the scope its child container opens at, following the [scope hierarchy](../providers/scopes.md#what-each-scope-is-for): | Unit of work | Scope | Rationale | @@ -283,250 +272,210 @@ Map each connection kind to the scope its child container opens at. Follow the ## How the existing integrations realize the contract -Pattern-match your framework to the closest precedent. +Pick the closest precedent for your framework: -| Contract point | FastAPI | FastStream | Litestar | Typer | +| Integration | Path | Child built by | Connection providers | Root lifecycle | |---|---|---|---|---| -| Root attach + lifecycle | `setup_di` + composed lifespan | `setup_di` + `on_startup`/`after_shutdown` callbacks | `ModernDIPlugin.on_app_init` + lifespan | `setup_di` via `app.info.context_settings["obj"]` | -| Fetch root | `app.state.di_container` | `context.get("di_container")` | `app.state.di_container` | `ctx.find_root().command.context_settings["obj"]` | -| Connection providers | request + websocket | message | request + websocket | none (command has no connection object) | -| Child builder | `async` dependency generator | `BaseMiddleware.consume_scope` | `async` dependency generator | `inject` decorator | -| `FromDI` bridge | `fastapi.Depends(Dependency(Marker(...)))` | `faststream.Depends(Dependency(Marker(...)))` | `Provide(_Dependency(Marker(...)))` | inert `Marker` (`integrations.from_di`) + `inject` | -| Child close | `close_async` | `close_async` | `close_async` | `close_sync` | - -The Starlette integration ([`modern-di-starlette`](starlette.md)) is the -reference for a middleware + decorator hybrid: Starlette has no native DI, -so a pure-ASGI middleware owns the child-container lifecycle (like FastStream) -while an `@inject` decorator with an inert `FromDI` marker does resolution (like -Typer). It splits the two responsibilities of the decorator path: the middleware -builds and closes the per-connection child, and the decorator only reads it back -from the ASGI scope and resolves. See [Frameworks without native -DI](#frameworks-without-native-di-the-decorator-path). - -The aiohttp integration ([`modern-di-aiohttp`](aiohttp.md)) is another -middleware + decorator hybrid, for a non-ASGI server where the only connection -object at middleware entry is `web.Request`: a WebSocket is an upgraded HTTP -request, not a distinct type. It detects a WebSocket via -`web.WebSocketResponse().can_prepare(request).ok`, and opens a `Scope.REQUEST` -child for an HTTP request or a `Scope.SESSION` child for a WebSocket. Because -both connection providers bind `web.Request`, it registers -`aiohttp_request_provider` by type while keeping `aiohttp_websocket_provider` -reference-only (`bound_type=None`). Its root lifecycle rides aiohttp's -`on_startup`/`on_cleanup` signals rather than a composed lifespan. - -The pytest integration -([`modern-di-pytest`](pytest.md)) is a different shape: it has no app to wire, so -instead of `setup_di`/`FromDI` it exposes `modern_di_fixture` (turn one -dependency into a fixture) and `expose` (turn a `Group`'s providers into -fixtures). It resolves from a user-supplied `di_container` fixture. Follow it -when integrating a test runner. +| [FastAPI](fastapi.md) | native, `Depends` | async dependency generator | request, websocket | composed lifespan | +| [Litestar](litestar.md) | native, `Provide` | async dependency generator | request, websocket | `ModernDIPlugin.on_app_init` adds a lifespan | +| [FastStream](faststream.md) | native, `Depends` | `BaseMiddleware.consume_scope` | message | `on_startup` / `after_shutdown` callbacks | +| [taskiq](taskiq.md) | native, `TaskiqDepends` | async dependency generator | message | worker startup and shutdown events | +| [FastMCP](fastmcp.md) | native, default-value `FromDI` | middleware | `fastmcp.Context` | lifespan provider | +| [Starlette](starlette.md) | decorator | pure-ASGI middleware | request, websocket | composed lifespan | +| [aiohttp](aiohttp.md) | decorator | middleware | `web.Request` twice, the websocket one with `bound_type=None` | `on_startup` / `on_cleanup` signals | +| [Flask](flask.md) | decorator, or `auto_inject=True` | `before_request` / `teardown_appcontext` | request | caller | +| [aiogram](aiogram.md) | decorator, or `auto_inject=True` | dispatcher middleware | update, event | dispatcher startup and shutdown | +| [gRPC](grpc.md) | decorator | server interceptor | `ServicerContext` | caller | +| [arq](arq.md) | decorator | `on_job_start` / `on_job_end` hooks | none | wrapped `on_startup` / `on_shutdown` | +| [Celery](celery.md) | decorator, or the `DITask` base | the decorator | none | worker signals | +| [Typer](typer.md) | decorator | the decorator | none | caller | + +aiohttp has one connection type for both kinds, since a WebSocket is an upgraded `web.Request`. It +tells them apart with `web.WebSocketResponse().can_prepare(request).ok` and registers only the request +provider by type. + +[`modern-di-pytest`](pytest.md) has no app to wire. It exposes `modern_di_fixture` and `expose` in +place of `setup_di` and `FromDI`, and resolves from a `di_container` fixture the user defines. Follow it +for a test runner. ## Frameworks without native DI (the decorator path) -Contract points 4 and 5 assume a per-handler injection seam (FastAPI / -FastStream `Depends`, Litestar `Provide`) that you hand a native marker and -that calls your resolver with the request container. Some frameworks have none: -a Typer/Click command, an argparse handler, or a plain task callable receives -only what the framework's argument parser binds. - -The rule: an integration is decorator-free only where the framework evaluates a parameter -default as a provider (FastAPI, FastMCP and FastStream `Depends`, Litestar `Provide`, taskiq -`TaskiqDepends`). Flask, Starlette, aiohttp, Celery, arq, Typer and gRPC hand the handler a plain -callable, and aiogram matches its `data` dict by parameter name, so those need `@inject`. Some -of them apply it for you: Flask and aiogram take `setup_di(..., auto_inject=True)`, and Celery -has the `DITask` base class. - -For these, `FromDI` becomes an inert annotation marker and a decorator does -the work native DI would have. [`modern-di-typer`](typer.md)'s `@inject` is the -reference implementation. Reach for this shape whenever the framework runs -handlers as plain callables it parses arguments for. The decorator can build the -per-call child container itself (Typer), or read one built by middleware -([`modern-di-starlette`](starlette.md) builds it in a pure-ASGI middleware and the -decorator only resolves from it). The resolution mechanics below are the same -either way. - -### How it works - -- `FromDI` is inert. It returns `integrations.from_di(dependency)`, a `Marker` - cast to the resolved type so checkers still see `T`. On its own it does - nothing; the decorator interprets it. +An integration can skip the decorator only where the framework evaluates a parameter default as a +provider: FastAPI, FastMCP and FastStream `Depends`, Litestar `Provide`, taskiq `TaskiqDepends`. Flask, +Starlette, aiohttp, Celery, arq, Typer and gRPC hand the handler a plain call, and aiogram matches its +`data` dict by parameter name, so those need `@inject`. Some apply it for you: Flask and aiogram take +`setup_di(..., auto_inject=True)`, and Celery has the `DITask` base class. - ```python - service: typing.Annotated[MyService, FromDI(Dependencies.service)] - ``` +On this path `FromDI` is `integrations.from_di`, which returns an inert `Marker` cast to the resolved +type so checkers still see `T`. The decorator scans for markers once with `integrations.parse_markers` +at decoration time and resolves them with `integrations.resolve_markers` on each call. Here a +middleware stores the child on the connection, and `@inject` reads it back: -- At decoration time, the decorator introspects - `typing.get_type_hints(func, include_extras=True)`, finds parameters whose - `Annotated` metadata holds a `Marker`, then rewrites the signature: - *remove* those parameters (so the arg parser never treats them as CLI options) - and *insert* the framework's context parameter (`typer.Context`) at position 0 - if the handler didn't declare one. Assign the cleaned signature to - `wrapper.__signature__`. The parser reads that, and `functools.wraps` alone - won't set it. - -- Use the integration kit instead of hand-rolling the scan and resolve. - `integrations.parse_markers(func)` is the decoration-time scan; - `integrations.resolve_markers(container, markers)` is the call-time resolve. - Both are framework-agnostic; only the signature-rewriting and - argument-binding around them (below) is yours to write. If your adapter - sweeps an existing app/router to auto-inject handlers (rather than one - `@inject` per handler), guard against double-wrapping with - `integrations.is_injected(func)` / `integrations.mark_injected(wrapper)`. - -- At call time, bind incoming args against the rewritten signature, pull out - the context object (deleting it again if the decorator added it implicitly), - build the per-call child container, resolve each marked parameter by kind - (contract point 5), fill them into the call by name, invoke the original - function, and `close_sync` the container in `finally`. - -DI parameters coexist with ordinary framework parameters because the decorator -strips only the marked ones; everything else still reaches the parser. - -### What changes vs. the native path - -| Contract point | Native DI | Decorator | -|---|---|---| -| `FromDI` returns | framework marker (`Depends` / `Provide`) | inert `Marker` | -| Child container built by | framework, via your resolver | the decorator wrapper | -| Handler receives value via | framework's DI | signature rewrite + fill-by-name at call time | -| Root-container access | connection object passed in | framework's per-call context, injected into the signature if absent | -| Connection `ContextProvider` | one per connection kind | one per connection kind where the framework has a connection object (Flask, Starlette, aiohttp, aiogram, gRPC); none for Typer, Celery, and arq | +```python +import functools + +from modern_di import integrations + +FromDI = integrations.from_di + +_CHILD_CONTAINER_KEY = "modern_di_container" + + +async def di_middleware( + request: myfw.Request, call_next: typing.Callable[[myfw.Request], typing.Awaitable[typing.Any]] +) -> typing.Any: + match = integrations.bind(myfw_request_provider, request) + async with fetch_di_container(request.app).build_child_container( + scope=match.scope, context=match.context + ) as child: + request.scope[_CHILD_CONTAINER_KEY] = child + try: + return await call_next(request) + finally: + del request.scope[_CHILD_CONTAINER_KEY] + + +def inject(func: typing.Callable[..., typing.Awaitable[typing.Any]]) -> typing.Callable[..., typing.Awaitable[typing.Any]]: + markers = integrations.parse_markers(func) + + @functools.wraps(func) + async def wrapper(request: myfw.Request, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: + try: + child = request.scope[_CHILD_CONTAINER_KEY] + except KeyError: + msg = ( + "No modern-di container found for this request. " + "Call setup_di(app, container) so requests pass through the modern-di middleware " + "before using @inject." + ) + raise RuntimeError(msg) from None + return await func(request, *args, **kwargs, **integrations.resolve_markers(child, markers)) + + return wrapper + + +@inject +async def handler( + request: myfw.Request, service: typing.Annotated[MyService, FromDI(Dependencies.service)] +) -> MyService: + return service +``` + + + + +```python +await handler(myfw.Request(app)) +``` + +Delete the per-call entry in `finally`, as Starlette and aiohttp do. The child's context holds the +connection, so an entry left on the connection makes a reference cycle that only the garbage collector +can reclaim. + +### Signature rewriting + +Rewrite the signature only when the framework reads the handler's signature to bind its arguments. +Typer parses CLI options from it, Celery binds task arguments against it, and aiogram matches `data` +keys to parameter names. Starlette, aiohttp, Flask, gRPC and arq call the handler with arguments they +already know, so a plain `functools.wraps` wrapper is enough there. + +When you rewrite: + +- Remove the marked parameters and leave every other parameter in place, so the framework keeps + parsing them. +- Assign the cleaned signature to `wrapper.__signature__`. `__signature__` is missing from the stubs, + so add `# ty: ignore[unresolved-attribute]`. +- Skip `functools.wraps` when the framework unwraps `__wrapped__` to find the signature, as aiogram + does; otherwise it reads the original signature and ignores yours. +- Add any parameter the wrapper needs to reach the container, if the handler did not declare it. Typer + inserts a `typer.Context` at position 0 and drops it again before calling the handler. ### Pitfalls to get right -- Set `wrapper.__signature__`. Without it the parser still sees the stripped - DI params and errors. (`__signature__` isn't in the stub, so - `# ty: ignore[unresolved-attribute]`.) -- Strip only DI params. Leave real arguments/options in the signature or the - framework stops parsing them. -- Get the decorator order right. The framework's own registration decorator goes - outside: `@app.command()` above `@inject`, so it registers the rewritten - signature. -- Isolate per-call state. Stash the per-call container on a per-invocation - store (`ctx.meta`), not shared app state (`ctx.obj`), so nested scopes can - parent onto it and nothing leaks between invocations. -- Keep nested scopes caller-driven. Expose a helper (`action_scope(ctx)`) - that yields a fresh deeper-scope child of the per-call container per `with` - block, rather than auto-injecting one. -- Name the fix when the container is missing. A handler decorated with - `@inject` but reached on an app that never had `setup_di` called must not die - on the private key (`KeyError: 'modern_di_container'`). Catch the lookup - failure and raise a plain `RuntimeError` that names the cause and the call: - "No modern-di container found for this request. Call setup_di(app, container) - so requests pass through the modern-di middleware before using @inject." - Raise it with `from None`, so the `KeyError` does not trail it. Use a - package-local `RuntimeError` rather than a `ModernDIError` subclass: the fault - is framework wiring, not resolution, and `modern_di.integrations` stays - exception-free. - [`modern-di-aiohttp`](aiohttp.md), [`modern-di-starlette`](starlette.md) and - [`modern-di-aiogram`](aiogram.md) are the reference; pin it with a test. +- Put the framework's registration decorator outside: `@app.command()` above `@inject`, so it + registers the wrapper. +- Keep per-call state on a per-call store (Typer's `ctx.meta`, a request's scope), never on shared app + state, so nested scopes can parent onto it and nothing leaks between calls. +- Guard sweeps that apply `@inject` for the user (Flask and aiogram `auto_inject`, Celery `DITask`) + against double wrapping with `integrations.is_injected(func)` and + `integrations.mark_injected(wrapper)`. +- Leave nested scopes to the caller. Typer's `action_scope(ctx)` yields a fresh `ACTION` child of the + per-call container for each `with` block. ## Repo scaffolding -Each official integration is its own repository and PyPI package, mirroring the -`modern-di` repo's tooling. +Each official integration is its own repository and PyPI package, with the same tooling as +`modern-di`. -- Name the repo and PyPI package `modern-di-` and the import package +- Name the repo and the PyPI package `modern-di-`, and the import package `modern_di_`. -- Lay out the package as follows: - - `modern_di_/main.py` holds the implementation. A larger integration may add - modules beside it, as aiogram does with `dialog.py`; pytest keeps its code in `factory.py`. - - `modern_di_/__init__.py` re-exports the public API from - `main` and lists it in an explicit `__all__` (this is the integration's - surface; keep private helpers out of it). -- In `pyproject.toml`, set `name = "modern-di-"`, - `description = "modern-di integration for "`, dependencies - `[">=...,<...", "modern-di>=4,<5"]`, the standard - `classifiers` (Typed, supported Python versions) and `[project.urls]` pointing - at the shared docs site and the integration's own repo. Use `version = "0"`, since - the release tag sets it. -- Put tests in `tests/`: - - `conftest.py` holds fixtures that build an app, call `setup_di` (or install the - plugin) with a `Container(groups=[Dependencies])`, and yield a test client. - - `dependencies.py` defines a sample `Group` with `Factory` providers at several - scopes, plus providers that read the connection object (e.g. a request - header) to prove context injection works. - - `test_lifespan.py` (startup/shutdown + restart), `test_routes.py` / - `test_commands.py` (resolution through `FromDI`), and `test_websockets.py` - where the framework has websockets. Aim for the same 100%-coverage gate - `modern-di` holds. -- Ship a runnable canonical example, `examples/app.py` (plus an - empty `examples/__init__.py`), demonstrating the recommended wiring: an - APP-scoped `Settings` plus one work-scoped service that depends on it by type, - resolved into a single handler/task/command via the framework's real idiom. - Use the `typing.Annotated[T, FromDI(...)]` marker form, not a `= FromDI(...)` - default (the default-call form trips ruff `B008`). Name the example's types to - match the integration's `docs/integrations/.md` snippet; diverge - only where testability requires it (e.g. return a value the test can assert). - A `tests/test_example.py` smoke test drives it through the repo's own - in-memory test double (test client / eager mode / in-memory broker, whatever - the existing tests use) and asserts the real injected output, never a mock. - The smoke test must cover `examples/app.py` to 100% under the coverage - gate. Do not add a coverage `omit`; mark `# pragma: no cover` only on a - genuinely unreachable boot line (`if __name__ == "__main__"` / server-run). - Link it from the README with a `Usage example: [examples/](./examples)` line - directly under `Full guide:`. -- For tooling, mirror `modern-di`'s `AGENTS.md` and `justfile`. Keep behavioural - invariants in named tests rather than in a prose truth home, and record rejected - alternatives on the [design decisions](../introduction/design-decisions.md#non-goals) page. Keep resolution sync-only and add no - runtime dependency beyond the framework and `modern-di`. `ruff` is unpinned and - CI floats it forward, so keep `CPY001` (no per-file copyright header) in the - lint `ignore` and reflow any pre-existing Markdown-embedded code fences the - current `ruff` reformats. -- For docs, add a `docs/integrations/.md` usage page in the - `modern-di` repo and a nav entry for it in `mkdocs.yml` (under the matching - family group: Web / Tasks & events / Bots / RPC / CLI / Testing). Follow the - canonical page shape the existing pages use: a single realistic-but-compact - example, an APP-scoped `Settings` plus one work-scoped service (two providers, - no more) that depends on `Settings` by type, with the container built plain - (`Container(groups=[AppGroup])`) and `container.validate()` called explicitly - *after* `setup_di`, demonstrating the [ordering rule](#lifecycle-rules) above. Keep the - connection/message object out of that validated example: its - `ContextProvider` is registered by `setup_di`, so a service that requires it by - type would fail `validate()` if that call were placed *before* `setup_di`. - Demonstrate context injection in a dedicated "Framework context objects" - section instead, with a required parameter (`request: FrameworkType`). - For a factory that must also resolve outside a connection, show an optional - parameter (`request: FrameworkType | None = None`), which gets `None` when no - connection is set, as the [gRPC page](grpc.md#injecting-the-servicercontext) - does and [Optional parameters](../providers/context.md#optional-parameters) - documents. Follow - the example with any framework-specific sections, a tailored `## See also` - block linking [Testing with overrides](../recipes/testing-overrides.md), - [Lifecycle](../providers/lifecycle.md), [Scopes](../providers/scopes.md), and - the most relevant recipe, and the `## API` table last. Integrations do not - ship their own docs site. -- Releases are tag-driven, mirroring `modern-di`: push a bare semver tag off - green `main` and let the workflow publish. +- Put the implementation in `modern_di_/main.py`; a larger integration may add modules + beside it, as aiogram does with `dialog.py`. `__init__.py` re-exports the public API and lists it in + an explicit `__all__`, with private helpers kept out. +- In `pyproject.toml`, set `description = "modern-di integration for "`, dependencies + `[">=...,<...", "modern-di>=4,<5"]`, the standard classifiers, `[project.urls]` for the + docs site and the repo, and `version = "0"`, because the release tag sets the version. +- In `tests/`: + - `conftest.py` builds an app, calls `setup_di` with a `Container(groups=[Dependencies])`, and + yields a test client. + - `dependencies.py` defines a sample `Group` with providers at several scopes, plus providers that + read the connection object to prove context injection works. + - Cover the lifespan with a restart, resolution through `FromDI`, and websockets where the + framework has them, under the same 100% coverage gate `modern-di` holds. + - `test_public_surface.py` pins the package's public names. +- Ship `examples/app.py` (with an empty `examples/__init__.py`): an APP-scoped `Settings` and one + work-scoped service that depends on it by type, resolved into one handler through the framework's + real idiom. Use `typing.Annotated[T, FromDI(...)]`, since a `= FromDI(...)` default trips ruff + `B008`; FastMCP is the exception and needs the default. Name the types to match the docs page. + `tests/test_example.py` drives the example through the repo's own test double, asserts the real + injected output, and covers the example fully, with no coverage `omit` and `# pragma: no cover` only + on an unreachable boot line such as `if __name__ == "__main__"`. +- In the README, put a `Usage example:` line under `Full guide:` with an absolute link, such as + `[examples/](https://github.com/modern-python/modern-di-/tree/main/examples)`. PyPI does + not rewrite relative links. +- Copy `AGENTS.md` and the `justfile` from `modern-di`. Keep behavioural invariants in named tests, and + record rejected alternatives on the [design decisions](../introduction/design-decisions.md#non-goals) + page. Keep resolution sync-only and add no runtime dependency beyond the framework and `modern-di`. + `ruff` floats forward in CI, so keep `CPY001` in the lint `ignore` list. +- In the `modern-di` repo, add `docs/integrations/.md` and a `mkdocs.yml` nav entry under + the matching family: Web, Tasks & events, Bots, RPC, CLI or Testing. Model the page on an existing one + such as [Starlette](starlette.md): one compact example with the same two providers as `examples/app.py`, + `container.validate()` after `setup_di`, connection injection in its own "Framework context objects" + section, then framework-specific sections, a `## See also` block, and the `## API` table last. + For a provider that must also resolve outside a connection, show an optional parameter + (`request: FrameworkType | None = None`), as the [gRPC page](grpc.md#injecting-the-servicercontext) + does; [Optional parameters](../providers/context.md#optional-parameters) explains it. +- Releases are tag-driven: push a bare semver tag off a green `main` and the workflow publishes. ## Checklist -- [ ] Repo `modern-di-`, package `modern_di_`, `main.py` + - re-exporting `__init__.py` with explicit `__all__`. -- [ ] One connection `ContextProvider` per connection kind, grouped in a single - `_CONNECTION_PROVIDERS` tuple mapping kind → scope. -- [ ] `setup_di` (or a plugin) attaches the root container, registers the - connection providers, and wires startup/shutdown. +- [ ] Repo `modern-di-`, package `modern_di_`, `main.py` and a re-exporting + `__init__.py` with an explicit `__all__`. +- [ ] One connection `ContextProvider` per connection kind, each mapping the kind to a scope. +- [ ] `setup_di` (or a plugin) attaches the root container, registers the connection providers, and + wires startup and shutdown. - [ ] `fetch_di_container` reads the root container back out of framework state. -- [ ] A per-unit-of-work builder opens a child container at the right scope, - injects the connection as context, and closes it in `finally`. -- [ ] Root container reopens on startup, so a restart doesn't raise - `ContainerClosedError`, and gets finalizers wired to shutdown. -- [ ] `close_async` / `close_sync` matches the framework's async-ness. -- [ ] `FromDI` accepts `AbstractProvider[T] | type[T]` and resolves it via - `resolve_dependency`. Use `modern_di.integrations.from_di` (or a - factory wrapping `integrations.Marker`) rather than hand-rolling it. -- [ ] Without native DI, `FromDI` is an inert marker and a decorator rewrites the - handler signature (strips DI params, threads the context object, sets - `wrapper.__signature__`), resolves at call time, and closes the per-call - container in `finally`. See the [decorator - path](#frameworks-without-native-di-the-decorator-path). -- [ ] `@inject` reached without `setup_di` raises a `RuntimeError` naming - `setup_di`, never a `KeyError` on a private key; a test pins it. -- [ ] Tests cover lifespan (incl. restart), resolution through `FromDI`, and - context injection from the connection object; coverage gate green. -- [ ] Usage page + `mkdocs.yml` nav entry added in the `modern-di` repo. -- [ ] `examples/app.py` (+ smoke test asserting real injected output, 100% - coverage, no `omit`) and a README `Usage example: [examples/](./examples)` - line. +- [ ] Every container lookup that can miss raises a `RuntimeError` naming `setup_di`, never a + `KeyError` or `AttributeError` on a private key, and a test pins it. +- [ ] A per-unit-of-work builder opens a child container at the right scope, injects the connection as + context, and closes it on every exit path. +- [ ] The root container reopens on startup, so a restart doesn't raise `ContainerClosedError`, and + closes on shutdown so finalizers run. +- [ ] `close_async` or `close_sync` matches the framework. +- [ ] `FromDI` accepts `AbstractProvider[T] | type[T]` and resolves through `integrations.Marker` + (or is `integrations.from_di` on the decorator path). +- [ ] On the decorator path, `@inject` uses `parse_markers` and `resolve_markers`, and rewrites the + signature only if the framework binds arguments from it. See + [the decorator path](#frameworks-without-native-di-the-decorator-path). +- [ ] Tests cover the lifespan with a restart, resolution through `FromDI`, context injection from the + connection object, and the public surface; the coverage gate is green. +- [ ] Usage page and `mkdocs.yml` nav entry added in the `modern-di` repo. +- [ ] `examples/app.py` with a smoke test that asserts real injected output, and a README + `Usage example:` line with an absolute link. - [ ] `AGENTS.md` and `justfile` mirrored; invariants pinned by named tests.