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
33 changes: 17 additions & 16 deletions architecture/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,27 +61,28 @@ process as a whole.

## Resolution

`FromDI(dependency)` returns an inert marker (`_FromDI`, a frozen dataclass
wrapping a provider or a bare type) — it does nothing on its own. Parameters
opt into injection by annotating them
`typing.Annotated[SomeType, FromDI(dependency)]`.
`FromDI` is `modern_di.integrations.from_di` — its marker factory. Calling
`FromDI(dependency)` returns an inert `Marker(dependency)` wrapping a
provider or a bare type; it does nothing on its own. Parameters opt into
injection by annotating them `typing.Annotated[SomeType, FromDI(dependency)]`.

`inject` rewrites a task function's signature at decoration time:

1. `_parse_inject_params` scans the resolved type hints
1. `integrations.parse_markers(func)` scans the resolved type hints
(`typing.get_type_hints(func, include_extras=True)`) for `Annotated`
parameters carrying a `_FromDI` marker.
2. If none are found, the function is returned unchanged — only marked
`func.__modern_di_injected__ = True` — and `inject` short-circuits without
parameters carrying a `Marker`.
2. If none are found, the function is returned unchanged — only marked via
`integrations.mark_injected(func)` — and `inject` short-circuits without
building a wrapper at all.
3. Otherwise `inject` builds a `wrapper` whose visible signature drops every
DI parameter (`visible_params`, computed by excluding the DI parameter
names from the original `inspect.signature(func)`). At call time the
wrapper resolves each DI parameter via
`container.resolve_dependency(marker.dependency)` — which dispatches to
`resolve_provider` when `dependency` is a provider instance and to
`resolve` (by type) otherwise — and calls the original function with the
DI arguments merged into the caller's `args`/`kwargs`.
wrapper resolves every DI parameter via
`integrations.resolve_markers(container, di_params)` — which calls each
`Marker.resolve(container)`, itself `container.resolve_dependency(...)`,
dispatching to `resolve_provider` when `dependency` is a provider
instance and to `resolve` (by type) otherwise — and calls the original
function with the DI arguments merged into the caller's `args`/`kwargs`.
4. The wrapper deliberately does **not** use `functools.wraps`. Instead it
copies just `__name__`, `__qualname__`, `__doc__`, and `__module__` by
hand, and sets `__signature__` explicitly to the stripped signature — so
Expand All @@ -103,14 +104,14 @@ freely.

`DITask(Task)` is the auto-inject path, used via `task_cls=DITask` on the
`Celery` app or `base=DITask` on an individual task. On `__init__` it checks
whether `self.run` is already marked `__modern_di_injected__`; if not, it
`integrations.is_injected(self.run)`; if not already marked, it
wraps `self.run` with `inject`, reassigns the wrapped callable back onto
`self.run`, and resets `self.__header__ = head_from_fun(injected)`. Celery
precomputes `__header__` — the arg-binding header used when a task is
called — from the task function's original signature, so it has to be
recomputed from the rewritten signature after wrapping. Checking
`__modern_di_injected__` first means a task explicitly decorated with
`@inject` and also based on `DITask` is not wrapped twice.
`is_injected` first means a task explicitly decorated with `@inject` and
also based on `DITask` is not wrapped twice.

## Synchronous only

Expand Down
36 changes: 7 additions & 29 deletions modern_di_celery/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import dataclasses
import inspect
import typing

from celery import Celery, Task, current_app, signals
from celery.utils.functional import head_from_fun
from modern_di import Container, Scope, providers
from modern_di import Container, Scope, integrations


# The root container lives on ``app.conf`` under this named key; read back with
Expand Down Expand Up @@ -34,36 +33,15 @@ def fetch_di_container(app: Celery) -> Container:


T = typing.TypeVar("T")
T_co = typing.TypeVar("T_co", covariant=True)


@dataclasses.dataclass(slots=True, frozen=True)
class _FromDI(typing.Generic[T_co]):
dependency: providers.AbstractProvider[T_co] | type[T_co]


def FromDI(dependency: providers.AbstractProvider[T] | type[T]) -> T: # noqa: N802
return typing.cast(T, _FromDI(dependency))


def _parse_inject_params(func: typing.Callable[..., typing.Any]) -> dict[str, _FromDI[typing.Any]]:
hints = typing.get_type_hints(func, include_extras=True)
di_params: dict[str, _FromDI[typing.Any]] = {}
for name, hint in hints.items():
if name == "return":
continue
if typing.get_origin(hint) is typing.Annotated:
for meta in typing.get_args(hint)[1:]:
if isinstance(meta, _FromDI):
di_params[name] = meta
break
return di_params
FromDI = integrations.from_di


def inject(func: typing.Callable[..., T]) -> typing.Callable[..., T]:
di_params = _parse_inject_params(func)
di_params = integrations.parse_markers(func)
if not di_params:
func.__modern_di_injected__ = True # ty: ignore[unresolved-attribute]
integrations.mark_injected(func)
return func

signature = inspect.signature(func)
Expand All @@ -81,7 +59,7 @@ def inject(func: typing.Callable[..., T]) -> typing.Callable[..., T]:
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:
resolved = {name: container.resolve_dependency(marker.dependency) for name, marker in di_params.items()}
resolved = integrations.resolve_markers(container, di_params)
bound = visible_signature.bind(*args, **kwargs)
bound.apply_defaults()
return func(**bound.arguments, **resolved)
Expand All @@ -94,14 +72,14 @@ def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401
wrapper.__doc__ = func.__doc__
wrapper.__module__ = func.__module__
wrapper.__signature__ = visible_signature # ty: ignore[unresolved-attribute]
wrapper.__modern_di_injected__ = True # ty: ignore[unresolved-attribute]
integrations.mark_injected(wrapper)
return wrapper


class DITask(Task):
def __init__(self) -> None:
super().__init__()
if not getattr(self.run, "__modern_di_injected__", False):
if not integrations.is_injected(self.run):
injected = inject(self.run)
self.run = injected # ty: ignore[invalid-assignment]
self.__header__ = head_from_fun(injected)
108 changes: 108 additions & 0 deletions planning/changes/2026-07-13.01-adopt-integration-kit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
summary: modern_di_celery/main.py now composes modern_di.integrations (Marker/from_di/parse_markers/resolve_markers for FromDI/inject, is_injected/mark_injected for the DITask double-wrap guard) instead of hand-rolling them; no bind/classify_connection involved (no connection object in this integration); no public-API or test change.
---

# Design: Adopt the modern-di integration kit

## Summary

`modern-di` 2.28.0 shipped `modern_di.integrations` — the framework-agnostic
primitives that formalize what this package's `FromDI`/`inject`/`DITask`
already hand-roll. This change swaps the hand-rolled internals for kit
calls. Public API and runtime behavior are unchanged; every existing test
asserts on public behavior only.

## Motivation

Ninth of the 13 adapter conversions surveyed by the kit's own design — see
[modern-di's decision record](https://github.com/modern-python/modern-di/blob/main/planning/decisions/2026-07-13-integration-kit-shape.md)
and its source design doc
([`changes/2026-07-13.02-integration-kit.md`](https://github.com/modern-python/modern-di/blob/main/planning/changes/2026-07-13.02-integration-kit.md)),
which names this repo explicitly among the 8 adapters whose `_FromDI`/
`_parse_inject_params` skeleton and `__modern_di_injected__` double-wrap
guard were the extraction target. Like `modern-di-typer`, Celery has **no
connection object** — a task invocation carries no framework request to
seed as context, so `container.build_child_container(scope=Scope.REQUEST)`
is called with no `context=` argument, same as it always has been. This
conversion is therefore **Layer 2 only**: no `bind`/`classify_connection`
call anywhere.

Unlike `modern-di-typer`, this repo **does** use the kit's `is_injected`/
`mark_injected` pair: `DITask.__init__` checks
`getattr(self.run, "__modern_di_injected__", False)` before wrapping, the
exact double-wrap guard those two primitives formalize (the same pattern
`modern-di-flask`'s `auto_inject` sweep already uses).

## Design

In `modern_di_celery/main.py`:

- Import `integrations` from `modern_di`:
`from modern_di import Container, Scope, integrations`. Drop `providers`
— see the dead-imports note below.
- Delete `_FromDI`; replace `FromDI`'s body with a direct assignment:
`FromDI = integrations.from_di`.
- `inject`: replace `di_params = _parse_inject_params(func)` with
`di_params = integrations.parse_markers(func)`, then delete
`_parse_inject_params` entirely (its body is an exact duplicate of what
`integrations.parse_markers` does, and no test imports it directly).
- `inject`'s no-DI-params short-circuit: replace
`func.__modern_di_injected__ = True` with `integrations.mark_injected(func)`.
- `inject`'s wrapper body: replace the manual dict comprehension
`{name: container.resolve_dependency(marker.dependency) for name, marker in di_params.items()}`
with `integrations.resolve_markers(container, di_params)`.
- `inject`'s wrapper tail: replace `wrapper.__modern_di_injected__ = True`
with `integrations.mark_injected(wrapper)`.
- `DITask.__init__`: replace
`getattr(self.run, "__modern_di_injected__", False)` with
`integrations.is_injected(self.run)` — `integrations._INJECTED_ATTR` is
the same `"__modern_di_injected__"` string literal, so this is a
behavioral no-op.
- Drop now-unused imports/names: `dataclasses` (only `_FromDI` used it),
`providers` (only `_FromDI.dependency`'s and old `FromDI`'s type hints
used it — this repo has no connection provider at module level, unlike
the web/broker integrations), and the `T_co` TypeVar (only
`_FromDI(typing.Generic[T_co])` used it). `T` stays — `inject`'s own
signature (`Callable[..., T]`) still uses it, independent of `_FromDI`.

`__init__.py`'s re-exports are untouched — every public name keeps its
exact signature and behavior. No test constructs `_FromDI` directly or
reads the raw `__modern_di_injected__` attribute (verified by reading all
test files — `test_ditask_skips_already_injected` exercises the guard
behaviorally, through the public `@inject`/`DITask` surface only), so no
test-file edit is expected.

## Non-goals

- `bind`/`classify_connection` — there is no connection object in this
integration for either to derive from (see Motivation). `setup_di`,
`fetch_di_container`, and the `worker_process_init`/
`worker_process_shutdown` signal wiring are untouched.
- The `*args`/`**kwargs` decoration-time `TypeError` guard, the
by-name signature-binding/rewriting, and the deliberate non-use of
`functools.wraps` — all task-specific mechanism this kit has no
equivalent for and that stays exactly as-is.
- Any test rewrite. The existing suite asserts on public behavior only
(`FromDI`, `inject`, `DITask`, `setup_di`, `fetch_di_container` — none of
it references `_FromDI`, `_parse_inject_params`, or the raw
`__modern_di_injected__` attribute being replaced).

## Testing

- `just test-ci` — 100% line coverage, all existing tests green with zero
test-file changes.
- `just lint-ci` — ruff (`select=ALL`), `ty check`, `check-planning`.

## Risk

- **Version-floor bump breaks on an environment still resolving `<2.28`**
(low likelihood — semver-compatible; low impact — `pyproject.toml` pins
the floor explicitly, in this repo's existing `>=2.25,<3` style without a
patch component, kept consistent as `>=2.28,<3`).
- **`is_injected`/`mark_injected` use a different attribute name than the
hand-written `__modern_di_injected__`, silently breaking the
`DITask`/`@inject` double-wrap guard** (low likelihood — `integrations
._INJECTED_ATTR` is the literal string `"__modern_di_injected__"`,
confirmed by reading `modern_di/integrations.py` directly before writing
this spec; `test_ditask_skips_already_injected` exercises exactly this
path and must still pass unmodified).
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.25,<3"]
dependencies = ["celery>=5,<6", "modern-di>=2.28,<3"]
version = "0"

[project.urls]
Expand Down
Loading