diff --git a/docs/dev/contributing.md b/docs/dev/contributing.md index 301c2f3c..12717c1f 100644 --- a/docs/dev/contributing.md +++ b/docs/dev/contributing.md @@ -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. diff --git a/docs/index.md b/docs/index.md index 2b1ee4b1..0b88c487 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 @@ -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. diff --git a/docs/integrations/arq.md b/docs/integrations/arq.md index 1aa5a374..adbfe63b 100644 --- a/docs/integrations/arq.md +++ b/docs/integrations/arq.md @@ -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 @@ -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. diff --git a/docs/integrations/celery.md b/docs/integrations/celery.md index bcaaae89..b25b4c8f 100644 --- a/docs/integrations/celery.md +++ b/docs/integrations/celery.md @@ -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. @@ -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: @@ -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. | diff --git a/docs/integrations/fastapi.md b/docs/integrations/fastapi.md index 19f7c49a..4e01b4dd 100644 --- a/docs/integrations/fastapi.md +++ b/docs/integrations/fastapi.md @@ -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. | diff --git a/docs/integrations/faststream.md b/docs/integrations/faststream.md index bc250cf5..045e9dd1 100644 --- a/docs/integrations/faststream.md +++ b/docs/integrations/faststream.md @@ -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. @@ -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`. | diff --git a/docs/integrations/flask.md b/docs/integrations/flask.md index 57c8e953..f344adac 100644 --- a/docs/integrations/flask.md +++ b/docs/integrations/flask.md @@ -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` diff --git a/docs/integrations/grpc.md b/docs/integrations/grpc.md index d1b9cf09..5fb46491 100644 --- a/docs/integrations/grpc.md +++ b/docs/integrations/grpc.md @@ -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. diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index 74b7cd79..25d5811d 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -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 diff --git a/docs/integrations/pytest.md b/docs/integrations/pytest.md index 7b4ae15f..97301f3c 100644 --- a/docs/integrations/pytest.md +++ b/docs/integrations/pytest.md @@ -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). diff --git a/docs/integrations/taskiq.md b/docs/integrations/taskiq.md index 473ce318..549eb819 100644 --- a/docs/integrations/taskiq.md +++ b/docs/integrations/taskiq.md @@ -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. @@ -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`. | diff --git a/docs/integrations/typer.md b/docs/integrations/typer.md index 421c3f39..f40e6d91 100644 --- a/docs/integrations/typer.md +++ b/docs/integrations/typer.md @@ -122,3 +122,4 @@ def run_job( | `@inject` | Decorator that resolves `FromDI`-annotated parameters before the command runs | | `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]`; accepts a provider instance or a plain type | | `fetch_di_container(ctx)` | Returns the app-scoped container registered by `setup_di`, from any command including those of nested `add_typer` sub-apps; does not read `ctx.obj` | +| `action_scope(ctx)` | Context manager that yields a `Scope.ACTION` child of the per-command container built by `@inject`, and closes it on exit. Raises `RuntimeError` when the command isn't decorated with `@inject` | diff --git a/docs/integrations/writing-integrations.md b/docs/integrations/writing-integrations.md index ef851a92..9caf605b 100644 --- a/docs/integrations/writing-integrations.md +++ b/docs/integrations/writing-integrations.md @@ -79,7 +79,7 @@ def fetch_di_container(app: myfw.App) -> Container: Store and read under a **named constant**, not a repeated string literal, when the framework uses a string-keyed store (FastStream's `ContextRepo`, Typer's -`ctx.obj`); it keeps writer and reader in provable agreement. +`context_settings["obj"]`); it keeps writer and reader in provable agreement. ### 4. Per-unit-of-work child-container builder @@ -240,8 +240,8 @@ Pattern-match your framework to the closest precedent. | Contract point | FastAPI | FastStream | Litestar | Typer | |---|---|---|---|---| -| Root attach + lifecycle | `setup_di` + composed lifespan | `setup_di` + `on_startup`/`after_shutdown` callbacks | `ModernDIPlugin.on_app_init` + lifespan | `setup_di` via `ctx.obj` | -| Fetch root | `app.state.di_container` | `context.get("di_container")` | `app.state.di_container` | `ctx.obj["di_container"]` | +| 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` | @@ -285,7 +285,9 @@ only what the framework's argument parser binds. There is nowhere to inject. The rule: an integration is decorator-free only where the framework evaluates a parameter default as a provider (FastAPI 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`. +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 @@ -341,7 +343,7 @@ strips **only** the marked ones; everything else still reaches the parser. | 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 | none — the handler carries no connection object | +| 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 | ### Pitfalls to get right @@ -380,13 +382,14 @@ Each official integration is its own repository and PyPI package, mirroring the - **Names.** Repo and PyPI package `modern-di-`; import package `modern_di_`. - **Layout.** - - `modern_di_/main.py` — the entire implementation. + - `modern_di_/main.py` — 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-export the public API from `main` and list it in an explicit `__all__` (this is the integration's surface; keep private helpers out of it). - **`pyproject.toml`.** `name = "modern-di-"`, `description = "modern-di integration for "`, dependencies - `[">=...,<...", "modern-di>=,<3"]`, the standard + `[">=...,<...", "modern-di>=,<4"]`, the standard `classifiers` (Typed, supported Python versions) and `[project.urls]` pointing at the shared docs site and the integration's own repo. `version = "0"`, since the release tag sets it. diff --git a/docs/introduction/design-decisions.md b/docs/introduction/design-decisions.md index e14fbcc9..49061467 100644 --- a/docs/introduction/design-decisions.md +++ b/docs/introduction/design-decisions.md @@ -6,7 +6,7 @@ Since 2.x, `Container.resolve(...)` and `resolve_provider(...)` are synchronous. There is no `await container.resolve(...)`, no `AsyncFactory`, no `AsyncSingleton`. Async work belongs in the framework's lifespan and per-request hooks; the container holds the already-constructed objects (see [Async resources via lifespan](../recipes/async-lifespan.md)). Resolution being sync does not mean teardown is: finalizers may be sync or async (`close_sync` / `close_async`), so async cleanup is fully supported. -This is a permanent choice, not a temporary limitation. There are no plans to reintroduce async resolution. +Async resolution will not be added. ## 2. Cached factories are thread-safe @@ -14,20 +14,23 @@ Cached `Factory` providers use one reentrant lock (`threading.RLock`) per contai ### The thread-safety boundary -- **Cached / singleton creation is locked.** The per-container reentrant lock guards the create-and-store step, so two threads racing to resolve the same cached provider get the same single instance. +- **Cached / singleton creation is locked.** The tree-wide reentrant lock guards the create-and-store step, so two threads racing to resolve the same cached provider get the same single instance. - **Provider registration is safe.** `ProvidersRegistry` mutations (`register`, `add_providers`) are guarded by the registry's own lock, and iteration snapshots the provider dict (`iter(list(...))`), so registering providers concurrently, or while another thread iterates, will not corrupt the registry or raise "dict changed size during iteration". - **Registration is a setup phase, not a coordination tool.** The registry is lock-guarded against corruption, but the supported model is register every provider *before* serving. Registering a provider while other threads are already resolving is timing-dependent by nature: nothing breaks, but whether a given resolve sees the new provider is undefined. -- **`set_context` and overrides are last-write-wins.** Both write into a - per-container dict with no ordering, queueing, or merge; concurrent writes to - the same key keep whichever landed last. Do them during setup, or per-request - on a request-local child container, never from competing threads. -- **Free-threaded CPython (PEP 703) is supported at `2 - Beta`.** Production-ready - and tested under real multithreading on the `3.14t` build. It is Beta rather than - Stable for one specific reason: modern-di relies on object-publication ordering +- **`set_context` and overrides are last-write-wins.** Both write into a dict + with no ordering, queueing, or merge; concurrent writes to the same key keep + whichever landed last. Context is per container, so per-request context + belongs on a request-local child container. Overrides live in one registry + shared by the whole container tree, so an override set on any container is + seen by every container in it. Set overrides during setup, never from + competing threads. +- **Free-threaded CPython (PEP 703) is supported at `2 - Beta`.** It is tested + under real multithreading on the `3.14t` build. It is Beta rather than Stable + for one specific reason: modern-di relies on object-publication ordering (that a reader observing a stored reference sees fully-initialized fields), and CPython publishes no memory model, so that is implementation behaviour rather than a spec guarantee. Throughput also does not scale across cores; per-op latency is @@ -60,7 +63,7 @@ Beyond the choices above, these are deliberately out of scope. Naming them here ### Auto-binding / auto-registration -modern-di never registers a provider for a type you did not declare and never infers wiring by scanning your code. Auto-binding defers a missing-provider error from declaration time, where `UnsupportedCreatorParameterError` already raises, to whichever request first exercises the untested path. Register the provider in a `Group`; if the boilerplate is real, a small helper that builds several `Factory` instances from a list of classes is application code, not a framework feature. +modern-di never registers a provider for a type you did not declare and never infers wiring by scanning your code. Without it, a missing provider is an `ArgumentResolutionError`, reported by `validate()` (run it once at startup or in a test) or raised at resolve. Auto-binding would hide that error until whichever request first exercises the untested path. Register the provider in a `Group`; if the boilerplate is real, a small helper that builds several `Factory` instances from a list of classes is application code, not a framework feature. ### In-package framework integrations @@ -72,7 +75,7 @@ One type, one provider. Registering several providers for one type and injecting ### Generator creators (teardown after `yield`) -`Factory` does not treat a generator creator as "yield the value, run the rest as a finalizer"; `CacheSettings(finalizer=)` is the only teardown spelling. The generator form is breaking (a generator creator resolves to the generator today), needs per-instance finalizer records for uncached factories and `bound_type` extraction from `Iterator[T]`, and cannot express an async finalizer under sync resolution, which the explicit form can. A `Factory` subclass in application code can wrap a generator creator and register the continuation as a finalizer. +`Factory` does not treat a generator creator as "yield the value, run the rest as a finalizer"; `CacheSettings(finalizer=)` is the only teardown spelling. The generator form is breaking (a generator creator resolves to the generator today), needs per-instance finalizer records for uncached factories and `bound_type` extraction from `Iterator[T]`, and cannot express an async finalizer under sync resolution, which the explicit form can. Write a plain creator that returns the value and pass the teardown as `Factory(..., cache=CacheSettings(finalizer=...))`. ### An `enter_scope` alias for `build_child_container` diff --git a/docs/migration/from-dependency-injector.md b/docs/migration/from-dependency-injector.md index acf2ceed..2c86056f 100644 --- a/docs/migration/from-dependency-injector.md +++ b/docs/migration/from-dependency-injector.md @@ -340,7 +340,7 @@ Call `container.validate()` explicitly during migration. The cycle row above is A handful of `dependency-injector` features have no direct port. Workarounds: -- **`ThreadLocalSingleton`**: use `threading.local()` inside a cached `Factory`'s creator and store the per-thread object there. +- **`ThreadLocalSingleton`**: register an uncached `Factory` whose creator reads the object from a module-level `threading.local()` and creates and stores it there on a thread's first call. A cached `Factory` can't do this, because it caches one object for the whole container. - **`Selector`**: write a creator function that takes whatever the selector depended on and returns the chosen object. If the choice is static (e.g. one implementation per environment), `Alias` may be cleaner. - **`Aggregate` / `FactoryAggregate`**: resolve each candidate provider individually (by type or by reference) and dispatch on the key yourself in a small creator function, rather than injecting the whole aggregate object. - **`.provided` (attribute / item / method-call access on a provider, e.g. `service.provided.value`)**: resolve the parent inside the consuming creator and access the attribute, item, or method result there, or expose a dedicated `Factory` whose creator returns just that piece. diff --git a/docs/migration/from-that-depends.md b/docs/migration/from-that-depends.md index c25ad2b1..24d3f45c 100644 --- a/docs/migration/from-that-depends.md +++ b/docs/migration/from-that-depends.md @@ -317,7 +317,7 @@ A handful of `that-depends` features have no direct port. Workarounds: - **`Selector`**: write a creator function that takes whatever the selector depended on and returns the chosen object. If the choice is static (e.g. one implementation per environment), `Alias` may be cleaner. - **`AttrGetter` (`provider.attr` syntax)**: resolve the parent inside the consuming creator and access the attribute there, or expose a dedicated `Factory` whose creator returns the attribute. -- **`ThreadLocalSingleton`**: use `threading.local()` inside a cached `Factory`'s creator and store the per-thread object there. +- **`ThreadLocalSingleton`**: register an uncached `Factory` whose creator reads the object from a module-level `threading.local()` and creates and stores it there on a thread's first call. A cached `Factory` can't do this, because it caches one object for the whole container. - **`@inject` + `Provide[T]()` for non-framework functions**: `modern-di` has no general-purpose injection decorator. Call `container.resolve(T)` explicitly at the call site, or expose the function through a framework integration and use `FromDI(T)`. ## More diff --git a/docs/migration/to-3.x.md b/docs/migration/to-3.x.md index 7a9b8a2c..fcd372db 100644 --- a/docs/migration/to-3.x.md +++ b/docs/migration/to-3.x.md @@ -258,9 +258,7 @@ change. A container is **open from construction** again (`closed = False` the mo explicit close warns (`ContainerClosedWarning`) and reopens instead of raising `ContainerClosedError`. -An earlier version of this note said every pattern shown above under "After (3.0)" kept -working unchanged, including that `with`/`open()` "still validates, still fails fast." That -part was wrong and has been corrected here: **validation is explicit-only as of 3.1.** +Validation is explicit-only as of 3.1. `open()` (and `with`/`async with`, which call it) no longer runs `validate()`. It only clears `closed`, unconditionally. Nothing validates automatically: not construction, not `open()`, not `add_providers`, not `resolve()`. @@ -280,6 +278,7 @@ not `open()`, not `add_providers`, not `resolve()`. pass without proving anything. Close the container first, so the transition stays observable. If the subject is the validation-ordering rule, point it at `container.validate()`. The rule still holds, it just binds a different call. + `container.validate()` is the only thing that walks the graph, and `Container(validate=...)` is deprecated: passing `True` or `False` is ignored and emits `ValidateArgumentWarning` (a `DeprecationWarning`), removed in 4.0. So in the "After (3.0)" example above, the comment diff --git a/docs/providers/errors-and-exceptions.md b/docs/providers/errors-and-exceptions.md index 6b49e642..7c140e9c 100644 --- a/docs/providers/errors-and-exceptions.md +++ b/docs/providers/errors-and-exceptions.md @@ -73,8 +73,8 @@ Catch `ContainerError` for any container/scope failure. current container but is missing from the scope chain (a level was skipped when building children). Carries the same breadcrumb `dependency_path` as `ScopeNotInitializedError`. See [Troubleshooting: ScopeSkippedError](../troubleshooting/scope-skipped-error.md). -- **`InvalidScopeTypeError`** is raised by the `Container` constructor when `scope` is not an - `enum.IntEnum`. See +- **`InvalidScopeTypeError`** is raised by the `Container` constructor, and by a `Group` subclass + declared as `class G(Group, scope=...)`, when `scope` is not an `enum.IntEnum`. See [Troubleshooting: InvalidScopeTypeError](../troubleshooting/invalid-scope-type-error.md). - **`ContainerClosedError`** is no longer raised as of modern-di 3.1; it stays importable for back-compat and is removed in 4.0. A container is open from construction, so there is nothing to diff --git a/docs/providers/factories.md b/docs/providers/factories.md index 42ee9f73..46626fae 100644 --- a/docs/providers/factories.md +++ b/docs/providers/factories.md @@ -229,6 +229,10 @@ Escaping problem shapes: if a parameter shape would raise at declaration, there 2. Supply the value via `kwargs={"items": []}` at `Factory` declaration time. 3. Pass `skip_creator_parsing=True` (and supply all required args via `kwargs`). +Routes 2 and 3 pass the value by keyword, so they only work for a parameterized generic. A +positional-only parameter needs route 1: with route 2 it still raises +`UnsupportedCreatorParameterError`, and with route 3 it raises `CreatorCallError` at resolve. + ### Provider passed as a kwargs value Passing an `AbstractProvider` instance directly as a value in the `kwargs` dict is treated as **explicit wiring**: Modern-DI resolves the provider and injects the resolved value; the provider object itself is never seen by the creator. diff --git a/docs/recipes/good-and-bad-practices.md b/docs/recipes/good-and-bad-practices.md index 48622055..73de1413 100644 --- a/docs/recipes/good-and-bad-practices.md +++ b/docs/recipes/good-and-bad-practices.md @@ -23,8 +23,8 @@ class Dependencies(Group): carrying an `InvalidScopeDependencyError` for this exact graph before anything is ever resolved. See [Scope chain violation](../troubleshooting/scope-chain.md). Nothing validates automatically, so if the graph is never validated, the runtime failure is a `ScopeNotInitializedError`/`ScopeSkippedError` -that (since the scope-error breadcrumb work) now names both the provider that captured the -dependency and the one that actually failed, and it fires on the first request that hits it rather +where the runtime error names both the provider that captured the dependency and the one that +failed, and it fires on the first request that hits it rather than at startup. Prefer catching it statically with an explicit `validate()` call. ## 2. Shipping a never-validated graph diff --git a/docs/troubleshooting/circular-dependency.md b/docs/troubleshooting/circular-dependency.md index 55ed56f7..74f403a0 100644 --- a/docs/troubleshooting/circular-dependency.md +++ b/docs/troubleshooting/circular-dependency.md @@ -11,10 +11,11 @@ Container.validate() found 1 issue(s): CircularDependencyError CircularDependencyError (1): - Circular dependency detected: - ServiceA - └─> ServiceB - └─> ServiceA + APP ServiceA (myapp.cycle:6) + APP └─> ServiceB (myapp.cycle:10) + APP └─> ServiceA (myapp.cycle:6) Check your provider graph for unintended cycles. +See: https://modern-di.modern-python.org/troubleshooting/validation-failed-error/ ``` It means the listed providers form a cycle that cannot be resolved. Each hop in the arrow chain may also end with a pointer to where that provider was declared (module and line number), making it easier to locate the offending provider in a large codebase. diff --git a/docs/troubleshooting/context-not-set.md b/docs/troubleshooting/context-not-set.md index 915aaf44..b211e0c7 100644 --- a/docs/troubleshooting/context-not-set.md +++ b/docs/troubleshooting/context-not-set.md @@ -6,8 +6,9 @@ A `ContextProvider(SomeType)` resolves by looking up `SomeType` in the container ``` Cannot resolve dependency chain: - REQUEST MyService - caused by: Argument tenant of type cannot be resolved. Trying to build dependency . + REQUEST MyService (myapp.ctx:9) + caused by: Argument tenant of type cannot be resolved. Trying to build dependency . +See: https://modern-di.modern-python.org/troubleshooting/argument-resolution-error/ ``` The error is an `ArgumentResolutionError` rendered as a chain: the top frame shows which provider failed, and the `caused by` line names the specific parameter that could not be wired. The parameter cannot be resolved because the `ContextProvider` for `TenantId` has no value in this container's context registry: nothing was set for that type on this container. diff --git a/docs/troubleshooting/max-scope-reached-error.md b/docs/troubleshooting/max-scope-reached-error.md index 02ce09c7..851d2e49 100644 --- a/docs/troubleshooting/max-scope-reached-error.md +++ b/docs/troubleshooting/max-scope-reached-error.md @@ -13,8 +13,7 @@ the smallest enum member greater than the parent's. The built-in `Scope` enum en ## Fix -Define a custom `IntEnum` scope with a member deeper than `STEP` and build the child with that scope -explicitly: +Define an `IntEnum` with a member deeper than `STEP` and build the child with that scope explicitly: ```python import enum @@ -22,17 +21,11 @@ import enum from modern_di import Scope -class ExtendedScope(enum.IntEnum): - APP = Scope.APP - SESSION = Scope.SESSION - REQUEST = Scope.REQUEST - ACTION = Scope.ACTION - STEP = Scope.STEP - SUBSTEP = 6 +class MyScope(enum.IntEnum): + SUBSTEP = Scope.STEP + 1 -step_container = Container(scope=ExtendedScope.STEP, parent_container=action_container) -sub_container = step_container.build_child_container(scope=ExtendedScope.SUBSTEP) +sub_container = step_container.build_child_container(scope=MyScope.SUBSTEP) ``` Root containers rarely need this. Reconsider whether the provider actually needs a scope deeper than diff --git a/docs/troubleshooting/missing-provider.md b/docs/troubleshooting/missing-provider.md index b88325fb..b9607858 100644 --- a/docs/troubleshooting/missing-provider.md +++ b/docs/troubleshooting/missing-provider.md @@ -7,15 +7,17 @@ This error fires when a creator parameter is typed `Foo` and the container has n **Direct miss.** Resolving an unregistered type directly: ``` -ProviderNotRegisteredError: Provider of type is not registered in providers registry. +ProviderNotRegisteredError: Provider of type is not registered in providers registry. +See: https://modern-di.modern-python.org/troubleshooting/missing-provider/ ``` **Nested miss.** A registered factory whose creator depends on an unregistered type: ``` ArgumentResolutionError: Cannot resolve dependency chain: - APP MyService - caused by: Argument dep of type cannot be resolved. Trying to build dependency . + APP MyService (myapp.missing:7) + caused by: Argument dep of type cannot be resolved. Trying to build dependency . +See: https://modern-di.modern-python.org/troubleshooting/argument-resolution-error/ ``` The resolver walked the creator's signature, found a parameter typed `MissingDep`, and looked it up in the providers registry. Nothing was there. The "dependency chain" header shows where in the resolution graph the miss occurred. diff --git a/docs/troubleshooting/unsupported-creator-parameter-error.md b/docs/troubleshooting/unsupported-creator-parameter-error.md index 9b356583..55905497 100644 --- a/docs/troubleshooting/unsupported-creator-parameter-error.md +++ b/docs/troubleshooting/unsupported-creator-parameter-error.md @@ -14,10 +14,10 @@ ones. ## Fix -Pick one of three escape routes, in order of preference: +For a parameterized generic, pick one of three escape routes, in order of preference: ```python -def create_thing(items: list[Item], /) -> Thing: ... +def create_thing(items: list[Item]) -> Thing: ... class Dependencies(Group): @@ -33,10 +33,15 @@ class Dependencies(Group): ) ``` +A positional-only parameter (`def create_thing(items: list[Item], /)`) can only be fixed with route +1. Routes 2 and 3 pass the value by keyword, so route 2 still raises this error and route 3 raises +`CreatorCallError` at resolve. If you can't give it a default, drop the `/` or wrap the creator in a +function that takes the parameter by keyword. + ## Escape hatches `skip_creator_parsing=True` bypasses signature parsing altogether (option 3 above). Use it when a -creator has several unsupported parameter shapes rather than fixing each one individually. +creator has several unsupported keyword-passable parameters rather than fixing each one individually. ## See also