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
22 changes: 12 additions & 10 deletions architecture/dependency-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,16 @@ optimized).

## Resolution

`FromDI(dependency)` returns an inert marker (`_FromDI`, a frozen, slotted
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`:

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.
parameters carrying a `Marker`.
2. If none are found, `func` is returned unchanged — `inject` short-circuits
without building a wrapper.
3. Otherwise it walks `inspect.signature(func).parameters` and raises
Expand All @@ -108,10 +108,12 @@ Parameters opt into injection by annotating them
5. It builds an `async def wrapper(*args, **kwargs)` decorated with
`functools.wraps(func)`. At call time the wrapper reads `ctx = args[0]`
(arq always calls a task as `coroutine(ctx, *args, **kwargs)`), reads the
per-job child off `ctx[_CHILD_CONTAINER_KEY]`, resolves each `FromDI`
dependency via `child.resolve_dependency(marker.dependency)` — which
dispatches to `resolve_provider` for a provider instance and to `resolve`
(by type) otherwise — binds the incoming `args`/`kwargs` against
per-job child off `ctx[_CHILD_CONTAINER_KEY]`, resolves every `FromDI`
dependency via `integrations.resolve_markers(child, di_params)` — which
calls each `Marker.resolve(container)`, itself
`container.resolve_dependency(...)`, dispatching to `resolve_provider`
for a provider instance and to `resolve` (by type) otherwise — binds the
incoming `args`/`kwargs` against
`visible_signature` (`bind` + `apply_defaults`), and calls `func` with the
bound arguments plus the resolved ones merged in by name. Injection is
therefore **order-insensitive**: a `FromDI` parameter may appear anywhere
Expand Down
34 changes: 4 additions & 30 deletions modern_di_arq/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
and a settings class/dict structurally, so this module needs no arq import.
"""

import dataclasses
import functools
import inspect
import typing

from modern_di import Container, Scope, providers
from modern_di import Container, Scope, integrations


_ROOT_CONTAINER_KEY = "modern_di_container"
Expand Down Expand Up @@ -111,34 +110,9 @@ def fetch_di_container(ctx: dict[str, typing.Any]) -> 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
"""Mark a task parameter for injection.

Use as ``Annotated[T, FromDI(provider_or_type)]`` on an ``inject``-decorated task.
"""
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[..., typing.Awaitable[T]]) -> typing.Callable[..., typing.Awaitable[T]]:
Expand All @@ -157,7 +131,7 @@ def inject(func: typing.Callable[..., typing.Awaitable[T]]) -> typing.Callable[.
named parameters instead.

"""
di_params = _parse_inject_params(func)
di_params = integrations.parse_markers(func)
if not di_params:
return func

Expand All @@ -178,7 +152,7 @@ def inject(func: typing.Callable[..., typing.Awaitable[T]]) -> typing.Callable[.
async def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401
ctx = typing.cast("dict[str, typing.Any]", args[0])
child = typing.cast(Container, ctx[_CHILD_CONTAINER_KEY])
resolved = {name: child.resolve_dependency(marker.dependency) for name, marker in di_params.items()}
resolved = integrations.resolve_markers(child, di_params)
bound = visible_signature.bind(*args, **kwargs)
bound.apply_defaults()
return await func(**bound.arguments, **resolved)
Expand Down
106 changes: 106 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,106 @@
---
summary: modern_di_arq/main.py now composes modern_di.integrations (Marker/from_di/parse_markers/resolve_markers) instead of hand-rolling the FromDI marker and its parse/resolve pair; no bind/classify_connection or is_injected/mark_injected involved (no connection object, no double-wrap guard 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` 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

Tenth 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 among the 8 adapters whose `_FromDI`/
`_parse_inject_params` skeleton was the extraction target for Layer 2. Like
`modern-di-typer`/`modern-di-celery`, arq has **no connection object** — its
per-job `ctx` is a bare `dict[str, Any]`, not an injectable connection (the
architecture doc's own words: "the same shape as the Celery and Typer
integrations"). This conversion is therefore **Layer 2 only**: no
`bind`/`classify_connection` call anywhere.

Unlike `modern-di-celery`, this repo does **not** use `is_injected`/
`mark_injected` either: arq has no auto-inject sweep (its
`WorkerSettings.functions` list is heterogeneous — plain coroutines,
`arq.func(...)` wrappers, import strings — so `@inject` is applied per task
explicitly, never swept), and `inject` itself has no double-wrap guard to
formalize. The repo's existing `_WRAPPED_MARKER` (`"__modern_di_wrapped__"`)
protects a *different* concern — `setup_di` refusing a second call on the
same `worker_settings` — unrelated to `inject`'s per-function wrapping, and
is out of scope for this change.

## Design

In `modern_di_arq/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 wrapper body: replace the manual dict comprehension
`{name: child.resolve_dependency(marker.dependency) for name, marker in di_params.items()}`
with `integrations.resolve_markers(child, di_params)`.
- 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), and the
`T_co` TypeVar (only `_FromDI(typing.Generic[T_co])` used it). `T` stays
— `inject`'s own signature (`Callable[..., Awaitable[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
(verified by reading all test files — the two string-literal couplings in
`tests/test_lifespan.py` hardcode `_ROOT_CONTAINER_KEY`/
`_CHILD_CONTAINER_KEY`'s string *values* as dict-literal keys, not a
private-class bypass, and neither constant is touched by this change), 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`, `_get_setting`/`_set_setting`, and all four wrapped
lifecycle hooks (`_wrap_startup`/`_wrap_shutdown`/`_wrap_job_start`/
`_wrap_job_end`) are untouched.
- `is_injected`/`mark_injected` — `inject` has no double-wrap guard to
formalize, and `_WRAPPED_MARKER` protects `setup_di`'s own
already-wired check, a different concern entirely (see Motivation).
- The `*args`/`**kwargs` decoration-time `TypeError` guard, the by-name
signature-binding via `visible_signature`, and `functools.wraps` (safe
here, unlike Celery/aiogram — see the architecture doc's own
explanation) — 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`, `setup_di`, `fetch_di_container` — none of it
references `_FromDI` or `_parse_inject_params`).

## 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`).
- **`integrations.resolve_markers` resolves against the wrong container**
(low likelihood — `resolve_markers(container, markers)` takes the
container as an explicit first argument, and the wrapper passes `child`
— the same per-job `Scope.REQUEST` container the manual dict
comprehension already resolved against — so the call site's argument is
unchanged, only the dict-comprehension body is replaced).
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 = ["arq>=0.25,<1", "modern-di>=2.25,<3"]
dependencies = ["arq>=0.25,<1", "modern-di>=2.28,<3"]
version = "0"

[project.urls]
Expand Down