diff --git a/docs/recipes/async-lifespan.md b/docs/recipes/async-lifespan.md index 9b49a13a..9faf3767 100644 --- a/docs/recipes/async-lifespan.md +++ b/docs/recipes/async-lifespan.md @@ -1,40 +1,34 @@ # Async resources via lifespan -Some resources need an `await` (or a running event loop) to construct, such as `aiohttp.ClientSession`, an `asyncpg` connection pool, an authenticated client whose construction does a token exchange. `modern-di` resolves synchronously, so the construction has to happen outside the resolve path. +Some resources need an `await` or a running event loop to construct: `aiohttp.ClientSession`, an `asyncpg` connection pool, or a client that exchanges a token at startup. `modern-di` resolves synchronously, so a creator cannot `await`. Build these resources in the framework's lifespan and hand the live object to the container. ## Solution -Do the async construction in the framework's lifespan. Use `container.set_context(SomeType, instance)` to register the live object on the APP container, then declare a `ContextProvider(SomeType, scope=Scope.APP)` so downstream factories can depend on the type. - - +In the lifespan, build the resource and register it on the APP container with `container.set_context(SomeType, instance)`. Declare a `ContextProvider(SomeType, scope=Scope.APP)` so other factories can depend on the type. With FastAPI: ```python import contextlib +import dataclasses from collections.abc import AsyncIterator import aiohttp import fastapi +import modern_di_fastapi from modern_di import Container, Group, Scope, providers -class Dependencies(Group): - http_client = providers.ContextProvider( - aiohttp.ClientSession, - scope=Scope.APP, - ) +@dataclasses.dataclass +class WeatherApi: + client: aiohttp.ClientSession - # Downstream factories declare `client: aiohttp.ClientSession` and get the live instance - weather_api = providers.Factory( - WeatherApi, # signature: (client: aiohttp.ClientSession) - scope=Scope.REQUEST, - ) + async def forecast(self, city: str) -> dict[str, str]: + async with self.client.get(f"https://weather.example.com/{city}") as response: + return await response.json() + + +class Dependencies(Group): + http_client = providers.ContextProvider(aiohttp.ClientSession, scope=Scope.APP) + weather_api = providers.Factory(WeatherApi, scope=Scope.REQUEST) container = Container(groups=[Dependencies]) @@ -42,44 +36,79 @@ container = Container(groups=[Dependencies]) @contextlib.asynccontextmanager async def lifespan(app: fastapi.FastAPI) -> AsyncIterator[None]: - async with container: # ensures close_async on exit - async with aiohttp.ClientSession() as session: # must be inside running loop - container.set_context(aiohttp.ClientSession, session) - yield - # ClientSession is closed by `async with` here + async with aiohttp.ClientSession() as session: + container.set_context(aiohttp.ClientSession, session) + yield app = fastapi.FastAPI(lifespan=lifespan) +modern_di_fastapi.setup_di(app, container) +container.validate() + + +@app.get("/forecast/{city}") +async def forecast( + city: str, + weather_api: WeatherApi = modern_di_fastapi.FromDI(WeatherApi), +) -> dict[str, str]: + return await weather_api.forecast(city) ``` -`aiohttp.ClientSession` captures the running event loop at construction time, so it has to be built inside an async context, which the lifespan provides. +`WeatherApi` receives the session through its `client: aiohttp.ClientSession` annotation. `aiohttp.ClientSession` needs a running event loop when it is constructed, and the lifespan runs inside the application's loop. -The same pattern works for `asyncpg.create_pool(...)` (truly async), authenticated API clients that do a token exchange at startup, or anything else that needs `await` to be ready. +`setup_di` wraps your lifespan: yours stays the outer context, and the integration opens the container inside it and closes it at shutdown, before your `async with` block exits. APP-scoped finalizers therefore run while the session is still open, and the session closes after them. + +Without an integration, enter the container yourself, after the resource, so it closes first: + +```python +import contextlib +from collections.abc import AsyncIterator + +import aiohttp +import fastapi + + +@contextlib.asynccontextmanager +async def lifespan(app: fastapi.FastAPI) -> AsyncIterator[None]: + async with aiohttp.ClientSession() as session, container: + container.set_context(aiohttp.ClientSession, session) + yield +``` + + -`asyncpg.create_pool(...)` returns an awaitable `Pool` that only opens its -connections when `await`ed (or entered with `async with`); modern-di has async -*finalizers* but no async *initializer*, so the `await` has to happen in the -lifespan. +The same pattern fits `asyncpg.create_pool(...)`, which returns a pool that opens its connections only when awaited, and any client that needs an `await` before it is ready. `modern-di` has async finalizers but no async initializer, so that `await` belongs in the lifespan. ## Pitfalls -- Set context *before* yielding. The lifespan hands control to the app inside the `yield`. If you `set_context` after yielding, requests that arrive in between won't see the value. -- `set_context` never propagates between containers; see [context propagation](../providers/context.md#context-propagation). In the lifespan pattern above that is fine, because the resource is APP-scoped, so the APP-scoped `ContextProvider` reads the value set on the APP container. Per-request context is passed to each REQUEST child via `build_child_container(context={...})`. -- Take care when combining a hand-written lifespan with an integration's `setup_di`. The integration (e.g. [`modern-di-fastapi`](../integrations/fastapi.md)'s `setup_di(app, container)`) already appends a lifespan that closes the container, and it merges with any `lifespan=` you pass. Keep the resource setup in your lifespan but drop the `async with container` wrapper: the integration owns the container close, and wrapping both closes it twice. -- Choose APP scope unless the resource is per-connection. Redis and Kafka clients are process-singletons. For per-websocket-session resources, use `Scope.SESSION`. -- `async with container:` handles APP-scope finalizers. If you also registered a `CacheSettings(finalizer=...)` somewhere, this runs it on exit. The lifespan-managed object isn't wrapped by a Factory, so its cleanup (`async with aiohttp.ClientSession()` in the example) is on you. +- `set_context` never propagates between containers; see [Context propagation](../providers/context.md#context-propagation). Here that is fine: the `ContextProvider` is APP-scoped and the value is set on the APP container, so every REQUEST child reaches it. +- Don't also wrap the lifespan in `async with container:` when you use `setup_di`. It is redundant: the integration already closes the container, and the second close runs no finalizers. +- The container does not own the lifespan's resource. It was never created by a `Factory`, so no finalizer closes it; the `async with aiohttp.ClientSession()` block does. ## When a sync creator works instead -Many "async" resources actually construct synchronously: `redis.asyncio.Redis.from_url(...)`, `sqlalchemy.ext.asyncio.create_async_engine(...)`, and `httpx.AsyncClient(...)` all return without awaiting. For those, prefer a normal `Factory` with `cache=CacheSettings(finalizer=async_close_fn)` and skip the lifespan + `set_context` dance entirely. Use this recipe only when construction needs `await` or a running event loop. +Many async clients construct synchronously: `redis.asyncio.Redis.from_url(...)`, `sqlalchemy.ext.asyncio.create_async_engine(...)` and `httpx.AsyncClient(...)` all return without awaiting. For those, use a plain `Factory` with `cache=CacheSettings(finalizer=...)` and an async finalizer, and skip the lifespan and `set_context`. Use this recipe only when construction needs `await` or a running event loop. ## See also diff --git a/docs/recipes/good-and-bad-practices.md b/docs/recipes/good-and-bad-practices.md index 3a8674f9..013d9232 100644 --- a/docs/recipes/good-and-bad-practices.md +++ b/docs/recipes/good-and-bad-practices.md @@ -5,8 +5,8 @@ mistakes the framework lets you make, each paired with the mechanism that catche ## 1. Captive dependency: a wide-scoped provider holding a narrow-scoped one -A *captive dependency* is a wide-scoped provider holding a narrow-scoped one it cannot actually -outlive. See [the scope dependency rule](../providers/scopes.md#the-scope-dependency-rule) for why. +A *captive dependency* is a wide-scoped provider holding a narrow-scoped one it cannot outlive. See +[the scope dependency rule](../providers/scopes.md#the-scope-dependency-rule) for why. ```python -# Broken: no reset, so the next test that resolves Clock silently gets the fake +class Clock: + def now(self) -> float: + return 0.0 + + +class Dependencies(Group): + clock = providers.Factory(Clock) + + +container = Container(groups=[Dependencies]) + + +# Broken: nothing resets the override, so every later test that resolves Clock gets the fake def test_one() -> None: - container.override(Dependencies.clock, fake_clock) - ... + container.override(Dependencies.clock, Mock(spec=Clock)) -# Works: always reset, even if the test fails; a fixture teardown is the reliable place for this + +# Works: the with block resets the override after each test, even when the test fails @pytest.fixture -def frozen_clock() -> Mock: - fake = Mock(spec=Clock) - container.override(Dependencies.clock, fake) - yield fake - container.reset_override(Dependencies.clock) +def fake_clock() -> typing.Iterator[Mock]: + with container.override(Dependencies.clock, Mock(spec=Clock)) as fake: + yield fake ``` -Nothing catches this automatically mid-suite. `reset_override(provider)` (or `reset_override()` with no -arguments, to clear everything) is the fix. Closing the root container does not clear overrides. See -[Testing with overrides](testing-overrides.md#pitfalls). + + +`reset_override(provider)`, or `reset_override()` with no argument, clears an imperative override. +Closing the container does not. See [Testing with overrides](testing-overrides.md#pitfalls). ## 6. `skip_creator_parsing=True` with no `bound_type` -`skip_creator_parsing=True` turns off signature introspection, which helps with callables that can't -be reflected (C extensions, `functools.partial`). But skipping introspection also means modern-di has -no idea what type the provider produces, so type-based resolution silently can't find it. +`skip_creator_parsing=True` turns off signature introspection, for creators modern-di cannot +introspect. It also skips the return annotation, so the provider has no bound type unless you pass +one: `Factory(...)` warns at declaration, and resolving the type raises `ProviderNotRegisteredError`. +See [`skip_creator_parsing`](../providers/factories.md#skip_creator_parsing). + +```python +# Broken: `with` closes the container with close_sync(), which cannot await close_client +with Container(groups=[ClientDependencies]) as client_container: + client_container.resolve(HttpClient) +``` + +```python +# Works: `async with` closes the container with close_async() +async with Container(groups=[ClientDependencies]) as client_container: + client_container.resolve(HttpClient) +``` ## See also diff --git a/docs/recipes/multi-group.md b/docs/recipes/multi-group.md index a790a769..47f465bc 100644 --- a/docs/recipes/multi-group.md +++ b/docs/recipes/multi-group.md @@ -4,79 +4,60 @@ Your application has 30+ providers, and one `Group` holding all of them is unrea ## Solution -Split providers into multiple `Group` subclasses by domain (database, cache, messaging, use cases) and pass them all to `Container(groups=[...])`. Cross-group dependencies wire by type, with no explicit references between groups. - - +Split providers into several `Group` subclasses by domain (database, cache, repositories, use cases) and pass them all to `Container(groups=[...])`. Dependencies between groups wire by type, so no group refers to another. ```python +import dataclasses + import redis.asyncio as aioredis import sqlalchemy.ext.asyncio as sa_async from modern_di import Container, Group, Scope, providers -# --- factory functions (defined once, shared across groups) --- - def create_engine() -> sa_async.AsyncEngine: return sa_async.create_async_engine("postgresql+asyncpg://localhost/app") -async def close_engine(engine: sa_async.AsyncEngine) -> None: - await engine.dispose() - - def create_session(engine: sa_async.AsyncEngine) -> sa_async.AsyncSession: return sa_async.AsyncSession(engine, expire_on_commit=False) -async def close_session(session: sa_async.AsyncSession) -> None: - await session.close() - - def create_redis() -> aioredis.Redis: return aioredis.Redis.from_url("redis://localhost") -async def close_redis(client: aioredis.Redis) -> None: - await client.aclose() +@dataclasses.dataclass +class UserRepository: + session: sa_async.AsyncSession + + +@dataclasses.dataclass +class OrderRepository: + session: sa_async.AsyncSession + + +@dataclasses.dataclass +class PlaceOrder: + users: UserRepository + orders: OrderRepository + cache: aioredis.Redis + +@dataclasses.dataclass +class CancelOrder: + orders: OrderRepository -# --- groups --- class Database(Group): engine = providers.Factory( create_engine, scope=Scope.APP, - cache=providers.CacheSettings(finalizer=close_engine), + cache=providers.CacheSettings(finalizer=sa_async.AsyncEngine.dispose), ) session = providers.Factory( create_session, scope=Scope.REQUEST, - cache=providers.CacheSettings(finalizer=close_session), + cache=providers.CacheSettings(finalizer=sa_async.AsyncSession.close), ) @@ -84,50 +65,59 @@ class Cache(Group): redis_client = providers.Factory( create_redis, scope=Scope.APP, - cache=providers.CacheSettings(finalizer=close_redis), + cache=providers.CacheSettings(finalizer=aioredis.Redis.aclose), ) -class Repositories(Group): - # UserRepository signature: (session: AsyncSession) - users = providers.Factory(UserRepository, scope=Scope.REQUEST) - orders = providers.Factory(OrderRepository, scope=Scope.REQUEST) +class Repositories(Group, scope=Scope.REQUEST): + users = providers.Factory(UserRepository) + orders = providers.Factory(OrderRepository) -class UseCases(Group): - # PlaceOrder signature: (users: UserRepository, orders: OrderRepository, cache: aioredis.Redis) - place_order = providers.Factory(PlaceOrder, scope=Scope.REQUEST) - cancel_order = providers.Factory(CancelOrder, scope=Scope.REQUEST) +class UseCases(Group, scope=Scope.REQUEST): + place_order = providers.Factory(PlaceOrder) + cancel_order = providers.Factory(CancelOrder) ALL_GROUPS = [Database, Cache, Repositories, UseCases] container = Container(groups=ALL_GROUPS) +container.validate() +``` + +`Repositories` and `UseCases` set `scope=Scope.REQUEST` once on the class instead of on every provider; see [Group-level default scope](../providers/scopes.md#group-level-default-scope). + +`PlaceOrder` depends on providers from three other groups: the repositories, the Redis client from `Cache`, and through the repositories the session from `Database`. Inside one request both repositories get the same cached session: + +```python +import sqlalchemy.ext.asyncio as sa_async + +async with container.build_child_container(scope=Scope.REQUEST) as request_container: + place_order = request_container.resolve(PlaceOrder) + session = request_container.resolve(sa_async.AsyncSession) + assert place_order.users.session is session + assert place_order.orders.session is session ``` -`PlaceOrder` depends on providers from three different groups: `Repositories`, `Cache`, and `Database` (transitively via the repositories). Nothing in `UseCases` references the other groups directly; type-based wiring sorts it out. - ## Pitfalls -- Duplicate `bound_type` raises at container creation. If two groups register providers for the same type (e.g. both bind to `AsyncSession`), `Container(groups=[...])` raises `DuplicateProviderTypeError` immediately. Fix by assigning distinct types, for instance by declaring thin subclasses (`class WriteSession(AsyncSession): ...`), or set `bound_type=None` on one provider and wire it explicitly via `kwargs`. See [Duplicate provider type](../troubleshooting/duplicate-type-error.md). -- Attribute-name collisions do not affect `Container`. `Container` keys providers on their `bound_type`, not on the attribute name. Two groups can both have an attribute named `session` as long as their `bound_type`s differ, and `Container` sees no conflict. The duplicate-name `ValueError` belongs to `modern-di-pytest`'s `expose(*groups)` helper (a separate package), which generates one pytest fixture per attribute name and does raise `ValueError` on duplicates. If you use `expose()`, ensure attribute names are unique across the groups you pass to it. -- Order in `groups=[...]` does not matter for resolution. Validate at startup by calling `container.validate()` explicitly, since nothing runs the check for you. +- Two providers for the same type make `Container(groups=[...])` raise `DuplicateProviderTypeError`, whichever groups they are in. Give one of them `bound_type=None` and pass it to its consumers through [`kwargs`](../providers/factories.md#kwargs); see [DuplicateProviderTypeError](../troubleshooting/duplicate-type-error.md). +- A provider object belongs to one group. Assigning it to a second group (`engine = Database.engine`) registers it twice and raises the same `DuplicateProviderTypeError`. To use another group's provider, depend on its type, or reference it in `kwargs={"engine": Database.engine}`. +- `Container` ignores attribute names, so two groups can both have a `session` attribute. Litestar's `autowired_groups` and `modern-di-pytest`'s `expose()` do use them: the first warns and keeps the provider from the last group, and the second raises `ValueError`. +- The order of `groups=[...]` does not matter. Nothing checks the combined graph for you, so call `container.validate()` at startup. ## Auto-wiring with Litestar -If you're on Litestar, pass `autowired_groups=ALL_GROUPS` to `ModernDIPlugin` and every provider in those groups is automatically registered as a Litestar dependency by attribute name. A handler then receives one by naming a parameter after it, `place_order: NamedDependency[PlaceOrder]`, with no per-route `FromDI`. - - +On Litestar, pass `autowired_groups=ALL_GROUPS` to `ModernDIPlugin` and every provider in those groups becomes a Litestar dependency named after its attribute. A handler receives one by naming a parameter after it, `place_order: NamedDependency[PlaceOrder]`, with no per-route `FromDI`: ```python +from litestar import Litestar from modern_di_litestar import ModernDIPlugin app = Litestar( @@ -135,7 +125,7 @@ app = Litestar( ) ``` -See the [Litestar integration](../integrations/litestar.md) for the full pattern. +See [Auto-wiring with `autowired_groups`](../integrations/litestar.md#auto-wiring-with-autowired_groups) for a handler and the name-collision rules. ## See also diff --git a/docs/recipes/pydantic-ai.md b/docs/recipes/pydantic-ai.md index 293bdb08..425a24a4 100644 --- a/docs/recipes/pydantic-ai.md +++ b/docs/recipes/pydantic-ai.md @@ -66,9 +66,11 @@ async with container: assert "Hello, user-" in await answer("greet a user") --> -Open the root container once at startup with `async with container:`, as in any other application. -`agent.run_sync(...)` works the same way inside a `with` block, and so do `agent.run_stream(...)` and -`agent.iter(...)`, as long as the child stays open until the run finishes. +The `openai:` model string needs the `pydantic-ai-slim[openai]` extra; the recipe does not depend on +the model. A constructed container is already open. Close it at shutdown, with +`async with container:` around the application's lifetime or `await container.close_async()`, so +APP-scoped finalizers run. `agent.run_sync(...)` works the same way inside a `with` block, and so do +`agent.run_stream(...)` and `agent.iter(...)`, as long as the child stays open until the run finishes. If the agent also needs your own deps object, register it with the container instead: declare a provider for it and resolve it in the tool. @@ -82,14 +84,35 @@ that must not be used concurrently, such as an `AsyncSession`. Give each tool ca ```python import contextvars +import dataclasses import typing -from modern_di import Container, Scope +from modern_di import Container, Group, Scope, providers from pydantic_ai import Agent, RunContext from pydantic_ai.capabilities import AbstractCapability from pydantic_ai.messages import ToolCallPart from pydantic_ai.tools import ToolDefinition + +@dataclasses.dataclass +class Session: + notes: list[str] = dataclasses.field(default_factory=list) + + def add(self, note: str) -> None: + self.notes.append(note) + + async def close(self) -> None: ... + + +class NoteDependencies(Group): + session = providers.Factory( + Session, + scope=Scope.ACTION, + cache=providers.CacheSettings(finalizer=Session.close), + ) + + +container = Container(groups=[Dependencies, NoteDependencies]) action_container: contextvars.ContextVar[Container] = contextvars.ContextVar("action_container") @@ -119,31 +142,24 @@ agent = Agent("openai:gpt-5", deps_type=Container, capabilities=[ActionScope()]) @agent.tool_plain async def save_note(note: str) -> str: session = action_container.get().resolve(Session) - ... + session.add(note) + return f"saved {note!r}" ``` -Declare per-call providers at `Scope.ACTION`, for example -`providers.Factory(Session, scope=Scope.ACTION, cache=providers.CacheSettings(finalizer=...))`. Each -tool call gets its own instance, and its finalizer runs when that call returns. The container lives in -a `ContextVar` rather than on the capability, because one capability instance serves all the -concurrent calls; each call sees only the child it opened. +`session` is declared at `Scope.ACTION`, so each tool call gets its own `Session`, and its finalizer +runs when that call returns. The container lives in a `ContextVar` rather than on the capability, +because one capability instance serves all the concurrent calls; each call sees only the child it +opened. ## See also diff --git a/docs/recipes/request-scoped-engine.md b/docs/recipes/request-scoped-engine.md index fa743d33..4754a953 100644 --- a/docs/recipes/request-scoped-engine.md +++ b/docs/recipes/request-scoped-engine.md @@ -6,24 +6,13 @@ This recipe routes read-only requests (`GET`, `HEAD`) to a read-replica engine a ## Solution -Two APP-scoped engine factories (primary and replica) and one REQUEST-scoped factory that inspects the request and returns the engine to use for it. Sessions and repositories depend on the *request-scoped* engine, not the named factories. - - +Declare two APP-scoped engine factories, primary and replica, and one REQUEST-scoped factory that looks at the request and returns the engine to use for it. The session depends on that request-scoped engine by type. ```python -import sqlalchemy.ext.asyncio as sa_async import fastapi -from modern_di import Group, Scope, providers +import modern_di_fastapi +import sqlalchemy.ext.asyncio as sa_async +from modern_di import Container, Group, Scope, providers def create_primary_engine() -> sa_async.AsyncEngine: @@ -34,13 +23,6 @@ def create_replica_engine() -> sa_async.AsyncEngine: return sa_async.create_async_engine("postgresql+asyncpg://replica/db") -async def close_engine(engine: sa_async.AsyncEngine) -> None: - await engine.dispose() - - -# Choose which engine this request uses. -# `primary` and `replica` are injected by name from kwargs. -# `request` is injected by type from the framework's request ContextProvider. def choose_engine( primary: sa_async.AsyncEngine, replica: sa_async.AsyncEngine, @@ -51,48 +33,67 @@ def choose_engine( return primary -class PrimaryEngine(sa_async.AsyncEngine): ... -class ReplicaEngine(sa_async.AsyncEngine): ... +def create_session(engine: sa_async.AsyncEngine) -> sa_async.AsyncSession: + return sa_async.AsyncSession(engine, expire_on_commit=False) class Dependencies(Group): primary = providers.Factory( create_primary_engine, scope=Scope.APP, - bound_type=PrimaryEngine, - cache=providers.CacheSettings(finalizer=close_engine), + bound_type=None, + cache=providers.CacheSettings(finalizer=sa_async.AsyncEngine.dispose), ) replica = providers.Factory( create_replica_engine, scope=Scope.APP, - bound_type=ReplicaEngine, - cache=providers.CacheSettings(finalizer=close_engine), + bound_type=None, + cache=providers.CacheSettings(finalizer=sa_async.AsyncEngine.dispose), ) - - # REQUEST-scope: picks per-request, cached for the rest of that request engine = providers.Factory( choose_engine, scope=Scope.REQUEST, kwargs={"primary": primary, "replica": replica}, cache=True, ) - - # Sessions and repositories use the REQUEST-scoped engine session = providers.Factory( create_session, scope=Scope.REQUEST, - cache=providers.CacheSettings(finalizer=close_session), + cache=providers.CacheSettings(finalizer=sa_async.AsyncSession.close), ) + + +container = Container(groups=[Dependencies]) + +app = fastapi.FastAPI() +modern_di_fastapi.setup_di(app, container) +container.validate() + + +@app.api_route("/engine", methods=["GET", "POST"]) +async def engine_url( + session: sa_async.AsyncSession = modern_di_fastapi.FromDI(sa_async.AsyncSession), +) -> str: + return str(session.bind.url) ``` -Why the `PrimaryEngine` / `ReplicaEngine` subclasses: type-based resolution needs distinct types for the two factories. Without them, both would register under `AsyncEngine` and `Container(groups=[...])` would raise `DuplicateProviderTypeError` at startup. See [Duplicate provider type](../troubleshooting/duplicate-type-error.md). + + +Only `engine` is registered under `AsyncEngine`, so `create_session` gets the engine chosen for the request. Two providers cannot both register under one type, so `primary` and `replica` have `bound_type=None` and reach `choose_engine` through `kwargs`; see [`bound_type`](../providers/factories.md#bound_type). `request` is wired by type to the `fastapi.Request` the integration puts in each request's container. ## Pitfalls -- The choice factory must be REQUEST-scoped. It depends on the per-request `Request` object. An APP-scoped factory cannot consume request-scoped data and `container.validate()` will reject it. -- The framework integration provides `fastapi.Request` (or `litestar.Request`) automatically. No need to declare a `ContextProvider` for it. For Litestar, use `litestar.Request`. -- Don't apply this to per-connection pooling decisions. Engines (and their pools) are APP-scoped, so the choice you make per request selects which long-lived pool the session checks out from. Trying to make the engine itself REQUEST-scoped would create and dispose a pool every request. -- Watch for write-after-read in a single request. If a `GET` handler ends up doing a write (e.g. updating a `last_seen_at` field), it'll go to the replica and fail. Either move the side-effect out of the read path, or pick a different routing predicate than HTTP method. +- The choice factory must be REQUEST-scoped, because it consumes the per-request `Request`. `container.validate()` rejects an APP-scoped one with `InvalidScopeDependencyError`. +- The integration registers the `ContextProvider` for `fastapi.Request` (on Litestar, `litestar.Request`), so you don't declare one. It does so in `setup_di`, so call `container.validate()` after `setup_di`, as above; see [Framework context objects](../providers/context.md#framework-context-objects). +- `kwargs` resolves both engines every time `choose_engine` runs, so the first request creates both and they stay cached, even though it uses one of them. `create_async_engine` opens no connection, so this costs nothing until a session checks one out. +- Keep the engines APP-scoped. Each engine owns a connection pool, and the per-request choice only selects which long-lived pool the session uses. A REQUEST-scoped engine would build and dispose a pool on every request. +- Watch for writes in a read request. If a `GET` handler writes (updating a `last_seen_at` column, say), the write goes to the replica and fails. Move the side effect out of the read path, or route on something other than the HTTP method. ## See also diff --git a/docs/recipes/sqlalchemy.md b/docs/recipes/sqlalchemy.md index 395c2588..b7acec8c 100644 --- a/docs/recipes/sqlalchemy.md +++ b/docs/recipes/sqlalchemy.md @@ -1,16 +1,17 @@ # Async SQLAlchemy: engine, session, repository -This recipe wires `create_async_engine` + `AsyncSession` + repository classes through `modern-di` so the engine is shared process-wide, sessions are per-request, and cleanup happens automatically at shutdown and at the end of each request. +This recipe wires `create_async_engine`, `AsyncSession` and repository classes through `modern-di`, so the process shares one engine, each request gets one session, and the containers close both. ## Solution The recipe uses three providers at two scopes: -- The engine is at `Scope.APP`: one per process, cached, disposed at shutdown. -- The session is at `Scope.REQUEST`: one per request, cached inside that request, closed at the end of the request. -- Repositories are at `Scope.REQUEST` and depend on the session by type, one per request. +- The engine is at `Scope.APP`: cached, so the whole process shares one engine and its connection pool, and disposed when the APP container closes. +- The session is at `Scope.REQUEST`: cached in the request's container and closed when that container closes at the end of the request. +- Repositories are at `Scope.REQUEST` and uncached. Every resolve builds a new repository, and all the repositories in one request share its session. ```python +import sqlalchemy as sa import sqlalchemy.ext.asyncio as sa_async from modern_di import Group, Scope, providers @@ -22,33 +23,28 @@ def create_engine() -> sa_async.AsyncEngine: ) -async def close_engine(engine: sa_async.AsyncEngine) -> None: - await engine.dispose() - - def create_session(engine: sa_async.AsyncEngine) -> sa_async.AsyncSession: return sa_async.AsyncSession(engine, expire_on_commit=False) -async def close_session(session: sa_async.AsyncSession) -> None: - await session.close() - - class UserRepository: def __init__(self, session: sa_async.AsyncSession) -> None: self.session = session + async def count(self) -> int: + return await self.session.scalar(sa.text("SELECT count(*) FROM users")) + class Dependencies(Group): engine = providers.Factory( create_engine, scope=Scope.APP, - cache=providers.CacheSettings(finalizer=close_engine), + cache=providers.CacheSettings(finalizer=sa_async.AsyncEngine.dispose), ) session = providers.Factory( create_session, scope=Scope.REQUEST, - cache=providers.CacheSettings(finalizer=close_session), + cache=providers.CacheSettings(finalizer=sa_async.AsyncSession.close), ) user_repository = providers.Factory( UserRepository, @@ -56,9 +52,9 @@ class Dependencies(Group): ) ``` -The session factory consumes `engine: sa_async.AsyncEngine` via type-based wiring, with no `kwargs={}` needed. `UserRepository` consumes `session: sa_async.AsyncSession` the same way. +`create_session` receives the engine through its `sa_async.AsyncEngine` annotation, and `UserRepository` receives the session through its `sa_async.AsyncSession` annotation, so no `kwargs` are needed. Both finalizers are unbound async methods: Python passes the cached instance as `self`, as [Caching and finalizers](../providers/lifecycle.md#caching-and-finalizers) explains. -Wire to your framework as usual: +Wire the container to your framework and inject the repository where a handler needs it. With FastAPI: ```python import fastapi @@ -70,21 +66,28 @@ container = Container(groups=[Dependencies]) app = fastapi.FastAPI() modern_di_fastapi.setup_di(app, container) +container.validate() + + +@app.get("/users/count") +async def count_users( + repository: UserRepository = modern_di_fastapi.FromDI(UserRepository), +) -> int: + return await repository.count() ``` -The integration creates a REQUEST child container per request, so the session and repository are created on first resolve and cleaned up when the request ends. +For a route that uses `FromDI`, `modern-di-fastapi` builds a REQUEST child container, resolves the session and the repository from it, and closes it after the response, which closes the session. The engine is disposed when the application shuts down. Call `container.validate()` after `setup_di`, because `setup_di` registers the integration's own providers; see [Validation](../providers/lifecycle.md#validation). ## Pitfalls -- `CacheSettings.finalizer` accepts sync or async functions; it auto-detects. Don't wrap with `asyncio.run` or `asyncio.ensure_future`. -- `expire_on_commit=False` on `AsyncSession` avoids expensive refreshes after commit. If you rely on `expire_on_commit=True`, leave it, though it's a common source of "session is closed" errors in async code. -- Don't share the engine across REQUEST containers manually. The provider already does it: REQUEST containers walk up to the APP container to resolve the engine. -- Repositories must be REQUEST-scoped, not APP-scoped: they hold a session which is REQUEST-scoped, and `container.validate()` will reject the inverse. +- Keep `expire_on_commit=False`. With the default `True`, a commit expires every loaded object, and the next attribute read tries to reload it. In async code that read fails with a `StatementError` wrapping `sqlalchemy.exc.MissingGreenlet`. +- The engine's finalizer is async, so the APP container has to be closed with `close_async()`. FastAPI and Litestar do that at shutdown. Flask, gRPC and Typer leave the APP container to you, and Celery closes it with `close_sync()`, which cannot await the finalizer. See [Per-scope finalization](../providers/lifecycle.md#per-scope-finalization) and [Async finalizers and `close_sync()`](../providers/lifecycle.md#async-finalizers-and-close_sync). +- Repositories hold a REQUEST-scoped session, so they cannot be APP-scoped. `container.validate()` rejects an APP-scoped repository; see [the scope dependency rule](../providers/scopes.md#the-scope-dependency-rule). ## Variations -- For multiple databases, declare two engine factories, two session factories, and give the second set distinct return types or `bound_type=` arguments so type-based resolution can tell them apart. -- Tests typically override the engine with an `AsyncConnection` inside a transaction. See [Testing with overrides](testing-overrides.md). +- For several databases, declare one engine and one session factory per database. Two providers cannot both register under `AsyncEngine`, so give the extra ones `bound_type=None` and pass them to their consumers through [`kwargs`](../providers/factories.md#kwargs). [Request-scoped engine selection](request-scoped-engine.md) shows the pattern with a primary and a replica. +- In tests, override the engine with a connection inside a transaction that rolls back after each test. See [Transactional database tests](testing-overrides.md#transactional-database-tests). ## See also diff --git a/docs/recipes/testing-overrides.md b/docs/recipes/testing-overrides.md index ba67a4a1..0ead9ca0 100644 --- a/docs/recipes/testing-overrides.md +++ b/docs/recipes/testing-overrides.md @@ -1,10 +1,10 @@ # Testing with overrides -Tests often need to swap a real dependency (database, HTTP client, clock) for a fake one without touching production wiring. +Tests often need to swap a real dependency (database, HTTP client, clock) for a fake one without touching production wiring. `container.override(provider, replacement)` makes every resolve of that provider return the replacement until the override is reset. -## Solution +## Override with a context manager -`container.override(provider, replacement)` replaces what the provider resolves to, immediately, and returns an `OverrideHandle`. Used as a context manager, it auto-resets on exit, and that is the primary spelling for tests: +`override()` returns an `OverrideHandle`. Used in a `with` block, it resets the override on exit, which makes it the primary spelling for tests: ```python -from unittest.mock import AsyncMock +import typing + +import modern_di import pytest -from app.ioc import Dependencies, container +from app import ioc +from app.services import Greeter, UserRepository + + +class FakeUserRepository(UserRepository): + def name(self, user_id: int) -> str: + return "fake" + + +@pytest.fixture(scope="session") +def di_container() -> typing.Iterator[modern_di.Container]: + with modern_di.Container(groups=ioc.ALL_GROUPS) as container: + container.validate() + yield container @pytest.fixture -def fake_users() -> AsyncMock: - fake = AsyncMock(spec=UserRepository) - container.override(Dependencies.user_repository, fake) - yield fake - container.reset_override(Dependencies.user_repository) +def request_container(di_container: modern_di.Container) -> typing.Iterator[modern_di.Container]: + with di_container.build_child_container(scope=modern_di.Scope.REQUEST) as container: + yield container -async def test_place_order_calls_users(fake_users: AsyncMock) -> None: - use_case = container.resolve(PlaceOrder) - await use_case.run(...) - fake_users.find_by_id.assert_awaited() +@pytest.fixture +def fake_users(di_container: modern_di.Container) -> typing.Iterator[FakeUserRepository]: + with di_container.override(ioc.Dependencies.user_repository, FakeUserRepository()) as fake: + yield fake + + +def test_greeting(request_container: modern_di.Container, fake_users: FakeUserRepository) -> None: + assert request_container.resolve(Greeter).greet(1) == "Hello, fake" ``` -## Pattern 2: Transactional session fixture (real database) + -For integration tests against a real database, run each test in a nested transaction that rolls back at the end. Override the engine provider with the test connection so every session created during the test reuses it. +`Greeter` is REQUEST-scoped, so the test resolves it from `request_container`; resolving it from `di_container` raises `ScopeNotInitializedError`. The override sits on the session-scoped container, so `fake_users` has to undo it after each test, and the `with` block does that even when the test fails. To get dependencies as fixtures instead of resolving them in the test, see [Pytest integration](../integrations/pytest.md): [Pointing a fixture at a child container](../integrations/pytest.md#pointing-a-fixture-at-a-child-container) and [Overrides](../integrations/pytest.md#overrides). -```python -import pytest -import sqlalchemy.ext.asyncio as sa_async +## Transactional database tests -from app.ioc import Dependencies, container +For tests against a real database, run each test inside a transaction and roll it back at the end. Override the engine provider from the [Async SQLAlchemy recipe](sqlalchemy.md) with a connection that holds the open transaction, so every session built during the test uses that connection: + + ```python -from modern_di_pytest import expose, modern_di_fixture +import typing -from app.ioc import Dependencies +import modern_di +import pytest +import sqlalchemy.ext.asyncio as sa_async +from app import ioc -# Single fixture from a specific provider -user_repository = modern_di_fixture(Dependencies.user_repository) -# Or expose every provider in a Group as a fixture (one per attribute) -expose(Dependencies) +@pytest.fixture +async def db_connection( + di_container: modern_di.Container, +) -> typing.AsyncIterator[sa_async.AsyncConnection]: + engine = sa_async.create_async_engine("postgresql+asyncpg://user:pass@localhost/test") + async with engine.connect() as connection: + transaction = await connection.begin() + with di_container.override(ioc.Dependencies.engine, connection): + yield connection + await transaction.rollback() + await engine.dispose() -async def test_user_repo(user_repository: UserRepository) -> None: - assert await user_repository.count() == 0 +@pytest.fixture +async def session( + db_connection: sa_async.AsyncConnection, + di_container: modern_di.Container, +) -> typing.AsyncIterator[sa_async.AsyncSession]: + async with di_container.build_child_container(scope=modern_di.Scope.REQUEST) as request_container: + yield request_container.resolve(sa_async.AsyncSession) ``` -Combine with `container.override(...)` in a setup fixture to swap underlying providers; `modern_di_fixture` resolves through the override. +The session is REQUEST-scoped, so the `session` fixture resolves it from a REQUEST child, which also closes it after the test. Async fixtures need an async test plugin; with pytest-asyncio, set `asyncio_mode = "auto"` so plain `@pytest.fixture` works on them. + +Build the session with `join_transaction_mode="create_savepoint"`: + +```python +import sqlalchemy.ext.asyncio as sa_async + + +def create_session(engine: sa_async.AsyncEngine) -> sa_async.AsyncSession: + return sa_async.AsyncSession(engine, expire_on_commit=False, join_transaction_mode="create_savepoint") +``` + +Under the default mode, a `session.rollback()` in the code under test rolls back the test's transaction too, and the fixture's own rollback then emits `SAWarning: transaction already deassociated from connection`. With `create_savepoint`, the session commits and rolls back a savepoint inside the test's transaction, and the fixture's rollback discards everything. In production the session is bound to an engine, not to a connection already in a transaction, and the option has no effect. ## Pitfalls -- Overrides are global. Override the root APP container and every child REQUEST container sees the replacement. Fine in tests; remember it if you also override in production code. -- `override` is keyed by provider reference. Pass `Dependencies.user_repository` (the provider object), not the string `"user_repository"`. -- Always `reset_override` in the fixture teardown. Leaking overrides between tests is a class of bug that doesn't fail loudly. -- Wrap session-scoped containers in a function-scoped override fixture. If the `Container` fixture itself is session-scoped (built once for the whole test run), don't call `override`/`reset_override` directly in a test. Wrap the pair in their own function-scoped fixture so the override is guaranteed to reset after each test, even on failure. -- Override the right level. If you override the engine but tests resolve the session, the session's creator still runs, so make sure the engine override produces something the creator can use. If the test relies on a specific session, override the session directly. +- Overrides are global to the container tree. Override on any container and every container sharing its root sees the replacement. That is what tests want; keep it in mind if you ever override outside tests. +- `override` is keyed by provider object. Pass `Dependencies.user_repository`, not the string `"user_repository"`, which raises `AttributeError`. +- Always reset. A leaked override reaches every later test that shares the container, and nothing reports it. With a session-scoped container, set overrides in a function-scoped fixture with `with container.override(...)`, so they are reset after each test even when it fails. +- Override the right level. If you override the engine but tests resolve the session, the session's creator still runs, so the replacement has to be something that creator accepts. If a test relies on a specific session, override the session itself. ## See also