diff --git a/architecture/dependency-injection.md b/architecture/dependency-injection.md index 1c78755..28a8c53 100644 --- a/architecture/dependency-injection.md +++ b/architecture/dependency-injection.md @@ -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 @@ -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 diff --git a/modern_di_arq/main.py b/modern_di_arq/main.py index c54969f..9134df0 100644 --- a/modern_di_arq/main.py +++ b/modern_di_arq/main.py @@ -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" @@ -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]]: @@ -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 @@ -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) diff --git a/planning/changes/2026-07-13.01-adopt-integration-kit.md b/planning/changes/2026-07-13.01-adopt-integration-kit.md new file mode 100644 index 0000000..4ba9e91 --- /dev/null +++ b/planning/changes/2026-07-13.01-adopt-integration-kit.md @@ -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). diff --git a/pyproject.toml b/pyproject.toml index 64ac331..076cf4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]