Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/dev/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ CI runs the coverage-enforcing recipe `just test-ci` along with `just lint-ci`.

## Submitting changes
1. Fork the repo and branch off `main`.
2. Make your change with tests; keep **100% line coverage** (CI runs `just test-ci` with `--cov-fail-under=100`).
2. Make your change with tests; keep **100% line coverage** (CI runs `just test-ci`, which fails below the `report.fail_under = 100` gate in `pyproject.toml`).
3. Run `just lint` and `just test` locally before pushing (CI runs the non-fixing variants `just lint-ci` / `just test-ci`).
4. For non-trivial changes, the PR body is the spec; the pull-request template walks you through it (why, design, non-goals, verification).
5. Open a pull request upstream.
7 changes: 4 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ If you want a framework integration, install the matching adapter, one `modern-d

## 2. First success

One provider, no scopes, no caching: the smallest honest example. A `Group` is a namespace that
One provider, no scopes, no caching: the smallest example. A `Group` is a namespace that
lists your providers; `Container.resolve` looks a value up by its type.

```python
Expand Down Expand Up @@ -180,8 +180,9 @@ child container for you automatically. Resolution itself is always synchronous;
[FastStream](integrations/faststream.md), [Flask](integrations/flask.md), [gRPC](integrations/grpc.md),
[Litestar](integrations/litestar.md), [Starlette](integrations/starlette.md),
[taskiq](integrations/taskiq.md), [Typer](integrations/typer.md), [Pytest](integrations/pytest.md).
Each builds a scoped child container per request/task/call automatically and closes the APP
container at shutdown.
The framework integrations build a scoped child container per request/task/call automatically,
and most close the APP container at shutdown. Flask, gRPC, and Typer have no shutdown hook, so
you close the root container yourself. The Pytest plugin exposes providers as fixtures.
- [Resolving](introduction/resolving.md) — how type-based auto-injection works.
- [Factories](providers/factories.md) — the provider you just used.
- [Scopes](providers/scopes.md) — the APP → REQUEST scope model in one page.
Expand Down
21 changes: 12 additions & 9 deletions docs/integrations/arq.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,11 @@ async def main() -> None:
`setup_di(worker_settings, container)` seeds the container into arq's `ctx` dict
(arq's per-worker state store) and wraps four of arq's lifecycle hooks:
`on_startup`/`on_shutdown` open and close the root container, and
`on_job_start`/`on_job_end` build and close a `Scope.REQUEST` child container
around each job. Any hook you already defined still runs: yours runs *after*
ours on startup/job-start and *before* ours on shutdown/job-end, so your code
always sees a live container. It accepts a `WorkerSettings` class (the common
`on_job_start`/`on_job_end` build and, as a safety net, close a `Scope.REQUEST`
child container around each job. Any hook you already defined still runs: yours
runs *after* ours on startup/job-start and *before* ours on shutdown/job-end. The
root container is live in all of them, but for an `@inject` task the child is
already closed by the time your `on_job_end` runs. It accepts a `WorkerSettings` class (the common
case) or a plain settings `dict`, and returns the container.

`@inject` resolves each `FromDI`-annotated parameter from the per-job child
Expand All @@ -100,11 +101,13 @@ unchanged.

## Scopes

The integration builds one `Scope.REQUEST` child container **per job**. It is
created in `on_job_start` and closed with `close_async()` in `on_job_end`, which
arq runs whether the job succeeded or raised, so REQUEST-scoped providers (and
their finalizers) live exactly for the duration of one job and never leak on the
error path. APP-scoped providers persist for the whole worker: `setup_di` opens
The integration builds one `Scope.REQUEST` child container **per job** in
`on_job_start`. For an `@inject` task, the wrapper closes it with
`close_async()` when the task body exits, whether it returned or raised. Nested
or concurrent `@inject` calls in the same job share the child, and the last one
to exit closes it. `on_job_end` closes the child only if it is still open, which
covers jobs that ran no `@inject` wrapper. Either way REQUEST-scoped providers
(and their finalizers) never leak on the error path. APP-scoped providers persist for the whole worker: `setup_di` opens
the root container on `on_startup` and closes it on `on_shutdown`, running
APP-scoped finalizers once when the worker stops.

Expand Down
10 changes: 5 additions & 5 deletions docs/integrations/celery.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,13 @@ def run_report(report: typing.Annotated[Report, FromDI(Report)]) -> str:
return report.render()
```

`setup_di(app, container)` stores the container on `app.conf` and registers `worker_process_init`/`worker_process_shutdown` signal handlers that open/close it. Those fire when a real `celery worker` process starts and stops, so a script or test that calls tasks without spinning one up (e.g. with `task_always_eager = True`) must drive the container lifecycle itself; see [Worker-process lifecycle](#worker-process-lifecycle) below.
`setup_di(app, container)` stores the container on `app.conf` and registers `worker_process_init`/`worker_process_shutdown` and `worker_init`/`worker_shutdown` signal handlers that open/close it. Those fire when a real `celery worker` process starts and stops, so a script or test that calls tasks without spinning one up (e.g. with `task_always_eager = True`) must drive the container lifecycle itself; see [Worker-process lifecycle](#worker-process-lifecycle) below.

`@inject` builds a `Scope.REQUEST` child container per call and resolves `FromDI`-annotated parameters from it. It looks the container up through Celery's `current_app` proxy at call time, not the `app` object captured at decoration time, so it always resolves against whichever app is currently active.

## Scopes

The integration creates a `Scope.REQUEST` child container **for each task invocation**, whether wired via `@inject` or [`DITask`](#the-ditask-base-class). REQUEST-scoped providers (and their finalizers) live for the duration of that one call; the child container is closed with `close_sync()` once the task returns, including when it raises. APP-scoped providers persist for the whole worker process: `setup_di` opens the APP container on `worker_process_init` and closes it with `close_sync()` on `worker_process_shutdown`.
The integration creates a `Scope.REQUEST` child container **for each task invocation**, whether wired via `@inject` or [`DITask`](#the-ditask-base-class). REQUEST-scoped providers (and their finalizers) live for the duration of that one call; the child container is closed with `close_sync()` once the task returns, including when it raises. APP-scoped providers persist for the whole worker process: `setup_di` opens the APP container on `worker_process_init` (or `worker_init`) and closes it with `close_sync()` on `worker_process_shutdown` (or `worker_shutdown`).

There is no `Scope.SESSION` for Celery: a task queue doesn't have a session concept comparable to websockets.

Expand Down Expand Up @@ -145,7 +145,7 @@ def greet(name: str, settings: typing.Annotated[Settings, FromDI(Settings)]) ->

## Worker-process lifecycle

`setup_di` connects to Celery's `worker_process_init` and `worker_process_shutdown` signals with `weak=False`. Celery signals default to weak references, which would otherwise let the handlers be garbage-collected before a worker process ever fires them. Both signals fire once per **worker process**, not per task: `container.open()` runs on `worker_process_init`, `container.close_sync()` runs on `worker_process_shutdown`. APP-scoped providers are therefore built once per worker process and torn down when it exits.
`setup_di` connects to Celery's `worker_process_init`/`worker_process_shutdown` and `worker_init`/`worker_shutdown` signals with `weak=False`. Celery signals default to weak references, which would otherwise let the handlers be garbage-collected before a worker process ever fires them. `container.open()` runs on `worker_process_init` and `worker_init`, and `container.close_sync()` runs on `worker_process_shutdown` and `worker_shutdown`. The prefork and solo pools send `worker_process_init`/`worker_process_shutdown` once per worker process, so each forked process gets its own APP-scoped providers. The threads, gevent, and eventlet pools never fork and send only `worker_init`/`worker_shutdown`, once in the main worker process. Either way the signals fire per worker, not per task. Opening an open container and closing one with nothing cached are both no-ops, so the overlap is harmless.

A real `celery worker` invocation fires both signals automatically. Code that calls tasks without a running worker (a script, or a test using `task_always_eager`) must trigger the same signals (or drive the container directly) itself:

Expand Down Expand Up @@ -192,8 +192,8 @@ signals.worker_process_shutdown.send(sender=None) # a real worker fires this

| Symbol | Description |
|---|---|
| `setup_di(app, container)` | Wire the APP-scope container into Celery — stores it on `app.conf` and opens/closes it on `worker_process_init`/`worker_process_shutdown`. Returns the container. |
| `setup_di(app, container)` | Wire the APP-scope container into Celery — stores it on `app.conf` and opens/closes it on `worker_process_init`/`worker_process_shutdown` and `worker_init`/`worker_shutdown`. Returns the container. |
| `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a plain type. |
| `@inject` | Decorator that builds a `Scope.REQUEST` child container per call, resolves `FromDI`-annotated parameters from it, and closes the child container with `close_sync()` afterwards. Raises `RuntimeError` naming `setup_di` when a task reaches it without `setup_di` called. |
| `@inject` | Decorator that builds a `Scope.REQUEST` child container per call, resolves `FromDI`-annotated parameters from it, and closes the child container with `close_sync()` afterwards. Raises `RuntimeError` naming `setup_di` when a task reaches it without `setup_di` called. A task with `FromDI` parameters that also declares `*args`/`**kwargs` raises `TypeError` at decoration. |
| `DITask` | `Task` subclass that applies `@inject` to a task's `run` method automatically; pass `task_cls=DITask` to `Celery(...)` or `base=DITask` to `@app.task(...)`. |
| `fetch_di_container(app)` | Returns the APP-scope container registered with the Celery app. Raises `RuntimeError` naming `setup_di` when called on an app without `setup_di` called. |
1 change: 1 addition & 0 deletions docs/integrations/fastapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,4 @@ class AppGroup(Group):
| `build_di_container(connection)` | A `fastapi.Depends` callable that yields the per-request child container — REQUEST scope for an HTTP request, SESSION scope for a WebSocket. |
| `fastapi_request_provider` | `ContextProvider` for `fastapi.Request` (REQUEST scope), auto-registered. |
| `fastapi_websocket_provider` | `ContextProvider` for `fastapi.WebSocket` (SESSION scope), auto-registered. |
| `fetch_di_container(app)` | Returns the root `Container` stored on the app. Raises `RuntimeError` naming `setup_di` when called on an app without `setup_di` called. |
6 changes: 3 additions & 3 deletions docs/integrations/faststream.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ modern_di_faststream.setup_di(app, container)
app.add_broker(kafka_broker) # also gets the DI middleware at startup
```

A broker created inside an `on_startup` hook is covered as well, since `setup_di` no longer
needs a broker at call time. Hooks run in registration order, so register that hook **before**
A broker created inside an `on_startup` hook is covered as well, since `setup_di` doesn't need a
broker at call time. Hooks run in registration order, so register that hook **before**
calling `setup_di`; otherwise the install step runs first and does not see the broker. If the
app still has no broker when the install step runs, it raises a `RuntimeError` naming both
remedies.
Expand Down Expand Up @@ -184,6 +184,6 @@ class AppGroup(Group):
| Symbol | Description |
|---|---|
| `setup_di(app, container)` | Wire the APP-scope container into FastStream — at startup, installs the middleware that creates a REQUEST child container per message on every broker of the app, and raises `RuntimeError` if there is none by then; closes the APP container at shutdown. |
| `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]` in subscriber signatures; accepts a provider instance or a plain type. Raises `RuntimeError` naming `setup_di` when a message reaches it without the middleware installed. |
| `FromDI(dependency, *, use_cache=True, cast=False)` | A `faststream.Depends` wrapper for `Annotated[T, FromDI(...)]` in subscriber signatures; accepts a provider instance or a plain type. `use_cache` and `cast` are passed through to `faststream.Depends`. Raises `RuntimeError` naming `setup_di` when a message reaches it without the middleware installed. |
| `fetch_di_container(app)` | Returns the APP-scope container registered with the FastStream app. |
| `faststream_message_provider` | `ContextProvider` for the current `faststream.StreamMessage`. |
5 changes: 2 additions & 3 deletions docs/integrations/flask.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,13 @@ def get_report(report: typing.Annotated[Report, FromDI(Report)]) -> dict[str, st
return report.as_dict()


# call setup_di AFTER registering routes
container = Container(groups=[Dependencies])
setup_di(app, container)
container.validate() # after setup_di — its connection providers are now registered
```

`FromDI(dependency)` accepts either a provider reference (as above) or a plain
type, resolved from the per-request child container the middleware built.
`FromDI(dependency)` accepts either a provider reference or a plain type (as
above), resolved from the per-request child container the middleware built.

### 3. `auto_inject`

Expand Down
6 changes: 4 additions & 2 deletions docs/integrations/grpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,10 @@ class AppGroup(Group):
caller = providers.Factory(make_caller, scope=Scope.REQUEST)
```

The `| None = None` default lets the provider construct at validation time, when
no context is set. The protobuf request `Message` is **not** exposed as a provider
`validate()` never constructs a provider, so the default isn't needed for
validation. The `| None = None` default lets the provider resolve outside an RPC,
where no context is set: the creator gets `None` instead of the resolve raising
`ArgumentResolutionError`. The protobuf request `Message` is **not** exposed as a provider
(that would add a `protobuf` dependency); the request is already a servicer-method
argument.

Expand Down
4 changes: 3 additions & 1 deletion docs/integrations/litestar.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ async def list_users(user_repo: UserRepository) -> list[str]:
...
```

If the same attribute name appears in multiple groups, a `UserWarning` is emitted and the last group's provider wins.
If an attribute name appears in more than one group, or matches a dependency the app already has (including the plugin's own `di_container`), a `UserWarning` is emitted and the autowired provider overwrites the earlier one; among groups, the last one wins.

With `autowired_groups` set, `FromDI` on a route can still take a type. Pass a provider instance only for providers outside `autowired_groups`: Litestar rejects one provider registered under two keys and raises `ImproperlyConfiguredException`.

## Websockets

Expand Down
45 changes: 21 additions & 24 deletions docs/integrations/pytest.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,45 +130,42 @@ one or more `Group` subclasses can be exposed against the request container.

`modern-di-pytest` deliberately does **not** ship override sugar. Use
`Container.override()` directly; it is already backed by a tree-shared
`OverridesRegistry`:
`OverridesRegistry`.

```python
import modern_di

from app.ioc import Dependencies
from app.services import UserService
from tests.fakes import FakeRepo


def test_with_override(
di_container: modern_di.Container,
user_service: UserService,
) -> None:
di_container.override(Dependencies.user_repo, FakeRepo())
try:
assert user_service.list_users() == []
finally:
di_container.reset_override(Dependencies.user_repo)
```

When `di_container` is session-scoped, prefer to wrap the override in a
function-scoped fixture so cleanup is guaranteed:
A fixture such as `user_service` resolves during test setup, before the test
body runs, so an override set inside the test body comes too late to reach it.
Apply the override in a fixture and point the dependency's fixture at it with
`container_fixture=`, so the override is in place when the dependency resolves
and is reset afterwards:

```python
import typing

import modern_di
import pytest
from modern_di_pytest import modern_di_fixture

from app.ioc import Dependencies
from app.services import UserService
from tests.fakes import FakeRepo


@pytest.fixture
def mock_user_repo(di_container: modern_di.Container) -> typing.Iterator[None]:
def fake_repo_container(
di_container: modern_di.Container,
) -> typing.Iterator[modern_di.Container]:
di_container.override(Dependencies.user_repo, FakeRepo())
yield
yield di_container
di_container.reset_override(Dependencies.user_repo)


user_service_with_fake_repo = modern_di_fixture(
UserService, container_fixture="fake_repo_container"
)


def test_with_override(user_service_with_fake_repo: UserService) -> None:
assert user_service_with_fake_repo.list_users() == []
```

For deeper patterns (transactional DB sessions, resetting all overrides) see the [testing-with-overrides recipe](../recipes/testing-overrides.md).
Expand Down
6 changes: 3 additions & 3 deletions docs/integrations/taskiq.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ async def get_report(

## Scopes

The integration creates a `Scope.REQUEST` child container **for each task** the worker executes. REQUEST-scoped providers (and their finalizers) live for the duration of that one task: the child container is closed after the task returns, including when it raises. APP-scoped providers persist for the whole worker process; `setup_di` opens the APP container on `WORKER_STARTUP` and runs `await container.close_async()` on `WORKER_SHUTDOWN`.
The integration creates a `Scope.REQUEST` child container **for each task** that uses `FromDI`, built lazily through `TaskiqDepends` when the task's dependencies are resolved. A task with no `FromDI` parameter gets no child container. REQUEST-scoped providers (and their finalizers) live for the duration of that one task: the child container is closed after the task returns, including when it raises. APP-scoped providers persist for the whole worker process; `setup_di` opens the APP container on `WORKER_STARTUP` and runs `await container.close_async()` on `WORKER_SHUTDOWN`.

There is no `Scope.SESSION` for taskiq: a task queue doesn't have a session concept comparable to websockets.

Expand Down Expand Up @@ -145,7 +145,7 @@ class AppGroup(Group):

| Symbol | Description |
|---|---|
| `setup_di(broker, container)` | Wire the APP-scope container into taskiq — creates a REQUEST child container per task and opens/closes the APP container on worker startup/shutdown. |
| `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a plain type. Raises `RuntimeError` naming `setup_di` when a task reaches it without `setup_di` called. |
| `setup_di(broker, container)` | Wire the APP-scope container into taskiq — a REQUEST child container is then built through `TaskiqDepends` for each task that uses `FromDI`; opens/closes the APP container on worker startup/shutdown. |
| `FromDI(provider_or_type, *, use_cache=True)` | Marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a plain type. `use_cache` is passed through to `TaskiqDepends`. Raises `RuntimeError` naming `setup_di` when a task reaches it without `setup_di` called. |
| `fetch_di_container(broker)` | Returns the APP-scope container registered with the taskiq broker. |
| `taskiq_message_provider` | `ContextProvider` for the current `taskiq.TaskiqMessage`. |
Loading
Loading