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
16 changes: 9 additions & 7 deletions architecture/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 3 additions & 4 deletions modern_di_celery/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
49 changes: 49 additions & 0 deletions planning/changes/2026-07-20.01-open-request-child-container.md
Original file line number Diff line number Diff line change
@@ -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`).
32 changes: 32 additions & 0 deletions planning/releases/3.0.0.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 4 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_
3 changes: 2 additions & 1 deletion tests/test_inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading