diff --git a/architecture/dependency-injection.md b/architecture/dependency-injection.md index 08a1a55..5e101ef 100644 --- a/architecture/dependency-injection.md +++ b/architecture/dependency-injection.md @@ -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 @@ -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 diff --git a/modern_di_celery/main.py b/modern_di_celery/main.py index cbb298f..b9c38b5 100644 --- a/modern_di_celery/main.py +++ b/modern_di_celery/main.py @@ -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 @@ -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) @@ -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) @@ -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) 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..a3f1861 --- /dev/null +++ b/planning/changes/2026-07-13.01-adopt-integration-kit.md @@ -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). diff --git a/pyproject.toml b/pyproject.toml index e90cb2c..e1c0b98 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.25,<3"] +dependencies = ["celery>=5,<6", "modern-di>=2.28,<3"] version = "0" [project.urls]