From e62adc4a9315a052f672ea0fed38a89008b3936a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 20 Jul 2026 21:51:48 +0300 Subject: [PATCH 1/3] chore(deps): require modern-di 3.x --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e1c0b98..50412f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Typing :: Typed", "Topic :: Software Development :: Libraries", ] -dependencies = ["celery>=5,<6", "modern-di>=2.28,<3"] +dependencies = ["celery>=5,<6", "modern-di>=3,<4"] version = "0" [project.urls] From 9031e00629cd284d46e157dc02ee7184c0100d32 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 20 Jul 2026 21:51:54 +0300 Subject: [PATCH 2/3] fix: open the request-scope child container (modern-di 3.x mandatory-open) Under modern-di 3.0's mandatory-open lifecycle, resolving from an unopened container raises ContainerClosedError. inject's per-task wrapper built a Scope.REQUEST child container and resolved from it without opening it; wrap it in a sync `with` instead (Container.__enter__ opens, __exit__ closes), dropping the manual try/finally. Also opens the root APP container in the test fixture / bespoke test app: Celery's worker_process_init signal (which opens it in production) never fires under task_always_eager, so tests were masking the bug at the parent container instead of exercising the real per-task path. --- architecture/dependency-injection.md | 16 +++--- modern_di_celery/main.py | 7 ++- ...6-07-20.01-open-request-child-container.md | 49 +++++++++++++++++++ tests/conftest.py | 5 +- tests/test_inject.py | 3 +- 5 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 planning/changes/2026-07-20.01-open-request-child-container.md diff --git a/architecture/dependency-injection.md b/architecture/dependency-injection.md index 5e101ef..cfd0f7e 100644 --- a/architecture/dependency-injection.md +++ b/architecture/dependency-injection.md @@ -48,16 +48,18 @@ process as a whole. `inject`'s wrapper runs once per task invocation. On each call it: 1. Builds one `Scope.REQUEST` child container via - `fetch_di_container(current_app).build_child_container(scope=Scope.REQUEST)`. - Unlike a web-framework request, a Celery task carries no framework request - object to seed as context, so there is no equivalent of a "connection - provider" here — the child container is built with no context. + `fetch_di_container(current_app).build_child_container(scope=Scope.REQUEST)`, + entered with a sync `with` — modern-di 3.x's mandatory-open lifecycle means + a freshly built child is not usable until opened, and `Container.__enter__` + is what opens it. Unlike a web-framework request, a Celery task carries no + framework request object to seed as context, so there is no equivalent of a + "connection provider" here — the child container is built with no context. 2. Resolves every `FromDI` parameter against that child container, then calls the original function with the caller's `args`/`kwargs` plus the resolved dependencies. -3. Closes the child container with `close_sync()` in a `finally` block, so it - closes whether the task returns normally or raises — including the task - error path. +3. Closes the child container via `Container.__exit__` (which calls + `close_sync()`) when the `with` block exits, so it closes whether the task + returns normally or raises — including the task error path. ## Resolution diff --git a/modern_di_celery/main.py b/modern_di_celery/main.py index b9c38b5..5f245ad 100644 --- a/modern_di_celery/main.py +++ b/modern_di_celery/main.py @@ -57,14 +57,13 @@ def inject(func: typing.Callable[..., T]) -> typing.Callable[..., T]: visible_signature = signature.replace(parameters=visible_params) def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401 - container = fetch_di_container(typing.cast(Celery, current_app)).build_child_container(scope=Scope.REQUEST) - try: + with fetch_di_container(typing.cast(Celery, current_app)).build_child_container( + scope=Scope.REQUEST + ) as container: resolved = integrations.resolve_markers(container, di_params) bound = visible_signature.bind(*args, **kwargs) bound.apply_defaults() return func(**bound.arguments, **resolved) - finally: - container.close_sync() # NOT functools.wraps — keep Celery's arg-binding reading the stripped signature. wrapper.__name__ = func.__name__ # ty: ignore[unresolved-attribute] diff --git a/planning/changes/2026-07-20.01-open-request-child-container.md b/planning/changes/2026-07-20.01-open-request-child-container.md new file mode 100644 index 0000000..f496a93 --- /dev/null +++ b/planning/changes/2026-07-20.01-open-request-child-container.md @@ -0,0 +1,49 @@ +--- +summary: Fixed `inject`'s per-task wrapper to open the REQUEST-scope child container before resolving, per modern-di 3.x's mandatory-open lifecycle. +--- + +# Change: Open the request-scope child container in `inject` + +**Lane:** lightweight — 1 adapter file (~5 LOC net) + a test-fixture fix, no +new file, no public-API change, existing tests already cover the path. + +## Goal + +Under modern-di 3.0's mandatory-open lifecycle, resolving from (or building a +child of) an unopened container raises `ContainerClosedError`. `inject`'s +per-task wrapper built a `Scope.REQUEST` child container and resolved from it +without opening it, so every `@inject`-wrapped task call raised. + +## Approach + +Replace the manual `build_child_container(...)` / `try` / `finally: +close_sync()` shape with a sync `with` block: `Container.__enter__` opens the +child, `__exit__` calls `close_sync()` — identical teardown to before, +including on the exception path, with no manual `try`/`finally` needed. + +Separately, `tests/conftest.py`'s `app` fixture never opened the root APP +container: in production `setup_di` opens it via the `worker_process_init` +signal, but that signal doesn't fire under `task_always_eager` in tests. This +was masking the real bug — every child-container build failed at the *parent* +(the unopened APP container), not just at the child. Test-only fix: open the +root container explicitly in the fixture (and in the one test that builds its +own bespoke app/container). + +## Files + +- `modern_di_celery/main.py` — `inject`'s `wrapper`: `build_child_container(...)` + entered via `with` instead of manual `try`/`finally: close_sync()`. +- `tests/conftest.py` — `app` fixture opens the root container after `setup_di`. +- `tests/test_inject.py` — `test_child_closed_on_task_error` opens its own + bespoke container (it doesn't use the `app` fixture). +- `architecture/dependency-injection.md` — "Per-task scope" section updated + to describe the `with`-based open/close. + +## Verification + +- [x] Failing test first: `just test-ci` against modern-di 3.0.0 raised + `ContainerError` ("Container (scope APP) is not open...") from + `build_child_container` in `wrapper`, reproduced by the existing suite. +- [x] Apply the change (adapter `with` fix + conftest/test root-open fix). +- [x] `just test-ci` — full suite green, 100% line coverage. +- [x] `just lint-ci` — clean (`ruff`, `ty`, `check-planning`). diff --git a/tests/conftest.py b/tests/conftest.py index 9e47a2f..05b50c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,5 +11,8 @@ def app() -> Celery: app_ = Celery("test", broker="memory://", backend="cache+memory://") app_.conf.task_always_eager = True app_.conf.task_store_eager_result = True - setup_di(app_, container=Container(groups=[Dependencies], validate=True)) + container = setup_di(app_, container=Container(groups=[Dependencies], validate=True)) + # worker_process_init (which opens the container in production) doesn't fire + # under task_always_eager; open it here so the suite exercises the real path. + container.open() return app_ diff --git a/tests/test_inject.py b/tests/test_inject.py index 44bae12..e4fd568 100644 --- a/tests/test_inject.py +++ b/tests/test_inject.py @@ -64,7 +64,8 @@ class Boom(Group): app = Celery("boom", broker="memory://", backend="cache+memory://") app.conf.task_always_eager = True app.conf.task_store_eager_result = True - setup_di(app, Container(groups=[Boom], validate=True)) + boom_container = setup_di(app, Container(groups=[Boom], validate=True)) + boom_container.open() @app.task @inject From 6c6c360d1ca7b36d108bceeade72d0899f5079b3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 20 Jul 2026 21:51:57 +0300 Subject: [PATCH 3/3] docs(release): add 3.0.0 notes --- planning/releases/3.0.0.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 planning/releases/3.0.0.md diff --git a/planning/releases/3.0.0.md b/planning/releases/3.0.0.md new file mode 100644 index 0000000..09c703a --- /dev/null +++ b/planning/releases/3.0.0.md @@ -0,0 +1,32 @@ +# modern-di-celery 3.0.0 — modern-di 3.x + +Requires **modern-di >= 3, < 4**. **No public API change** — `DITask`, +`FromDI`, `fetch_di_container`, `inject`, and `setup_di` keep their signatures +and behavior. + +## Fix + +- **Opened the request-scope child container in `inject`.** What was broken: + under modern-di 3.0's mandatory-open lifecycle, `inject`'s per-task wrapper + built a `Scope.REQUEST` child container and resolved from it without + opening it first, so every `@inject`-wrapped task call raised + `ContainerClosedError`. Now fixed — the wrapper enters the child container + via a sync `with` block (`Container.__enter__` opens it, `__exit__` calls + `close_sync()`), preserving the exact same close-on-error behavior as the + previous manual `try`/`finally`. + +## Packaging + +- **Bumped the `modern-di` dependency to `>=3,<4`.** Requires modern-di 3.x; + drops support for modern-di 2.x. + +## Downstream + +No action needed beyond raising your own `modern-di` floor to `>=3`. If your +code builds child containers, open them with `with` / `async with` (or +`open()`) before resolving — see the +[modern-di 3.x migration guide](https://modern-di.modern-python.org/migration/to-3.x/). + +## Internals + +- 100% line coverage; `ruff`, `ty` clean.