diff --git a/.gitignore b/.gitignore index 8cf31db..3db7a1f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ dist/ .python-version .venv uv.lock +.superpowers/ diff --git a/architecture/injection.md b/architecture/injection.md index 324ad30..2a3547d 100644 --- a/architecture/injection.md +++ b/architecture/injection.md @@ -12,19 +12,21 @@ have them resolved before the command body runs. The whole mechanism lives in service: typing.Annotated[MyService, FromDI(Dependencies.service)] ``` -It is a factory that returns `typing.cast(T_co, _FromDI(provider))`: type -checkers see it as returning the resolved type `T_co`, while at runtime it -returns a frozen `_FromDI` dataclass instance that `inject` detects. The -argument is either a provider instance (`AbstractProvider`) or a bare type — -resolution handles both. +`FromDI` is `modern_di.integrations.from_di` — its marker factory. Type +checkers see it as returning the resolved type `T`, while at runtime it +returns a frozen `Marker` instance that `inject` detects. The argument is +either a provider instance (`AbstractProvider`) or a bare type — resolution +handles both. ## `@inject` — decoration time `inject(func)` runs once, when the command is defined: -1. `_parse_inject_params` reads `typing.get_type_hints(func, include_extras=True)` - to find parameters whose `Annotated` metadata contains a `_FromDI` marker, and - notes any existing `typer.Context` parameter. +1. `_parse_inject_params` delegates to `modern_di.integrations.parse_markers(func)` + to find parameters whose `Annotated` metadata contains a `Marker`, and + separately scans `typing.get_type_hints(func, include_extras=True)` itself + for any existing `typer.Context` parameter — a typer-specific concern the + kit has no equivalent for. 2. It rewrites `func.__signature__` to **remove** the `FromDI` parameters (so Typer never prompts for them on the CLI) and **inserts** a `typer.Context` parameter at position 0 if the command didn't already declare one. @@ -37,9 +39,11 @@ The wrapper runs on every invocation: `typer.Context` (deleting it again if `inject` added it implicitly). 2. Build the per-command `Scope.REQUEST` container and stash it on `ctx.meta` (see [scopes.md](scopes.md)). -3. If there are `FromDI` parameters, `_resolve_di_params` resolves each from the - command container via `resolve_dependency(...)` — modern-di's single - provider-or-type dispatch — and fills them into the call. +3. If there are `FromDI` parameters, `modern_di.integrations.resolve_markers(cmd_container, di_params)` + resolves each from the command container — under the hood, each + `Marker.resolve(container)` calls `container.resolve_dependency(...)`, + modern-di's single provider-or-type dispatch — and fills them into the + call. 4. Call the original function; close the command container on return. `FromDI` parameters coexist with ordinary Typer arguments and options: because diff --git a/modern_di_typer/main.py b/modern_di_typer/main.py index 54ebebb..6e6947f 100644 --- a/modern_di_typer/main.py +++ b/modern_di_typer/main.py @@ -1,26 +1,18 @@ import contextlib -import dataclasses import functools import inspect import typing import typer -from modern_di import Container, Scope, providers +from modern_di import Container, Scope, integrations -T_co = typing.TypeVar("T_co", covariant=True) T = typing.TypeVar("T") _COMMAND_CONTAINER_KEY: typing.Final = "modern_di_typer.command_container" -@dataclasses.dataclass(slots=True, frozen=True) -class _FromDI(typing.Generic[T_co]): - provider: providers.AbstractProvider[T_co] | type[T_co] - - -def FromDI(provider: providers.AbstractProvider[T_co] | type[T_co]) -> T_co: # noqa: N802 - return typing.cast(T_co, _FromDI(provider)) +FromDI = integrations.from_di def setup_di(app: typer.Typer, container: Container) -> Container: @@ -54,30 +46,10 @@ def action_scope(ctx: typer.Context) -> typing.Iterator[Container]: def _parse_inject_params( func: typing.Callable[..., typing.Any], -) -> tuple[dict[str, _FromDI[typing.Any]], str | None]: +) -> tuple[dict[str, integrations.Marker[typing.Any]], str | None]: hints = typing.get_type_hints(func, include_extras=True) - di_params: dict[str, _FromDI[typing.Any]] = {} - ctx_param_name: str | None = None - - for name, hint in hints.items(): - if name == "return": - continue - if hint is typer.Context: - ctx_param_name = name - elif 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, ctx_param_name - - -def _resolve_di_params( - cmd_container: Container, - di_params: dict[str, _FromDI[typing.Any]], -) -> dict[str, typing.Any]: - return {name: cmd_container.resolve_dependency(marker.provider) for name, marker in di_params.items()} + ctx_param_name = next((name for name, hint in hints.items() if hint is typer.Context), None) + return integrations.parse_markers(func), ctx_param_name def inject(func: typing.Callable[..., T]) -> typing.Callable[..., T]: @@ -110,7 +82,7 @@ def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401 with _build_command_container(ctx) as cmd_container: ctx.meta[_COMMAND_CONTAINER_KEY] = cmd_container if di_params: - arguments.update(_resolve_di_params(cmd_container, di_params)) + arguments.update(integrations.resolve_markers(cmd_container, di_params)) return func(**arguments) wrapper.__signature__ = new_sig # ty: ignore[unresolved-attribute] 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..b30e45d --- /dev/null +++ b/planning/changes/2026-07-13.01-adopt-integration-kit.md @@ -0,0 +1,118 @@ +--- +summary: modern_di_typer/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 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` already +hand-roll. This change swaps the hand-rolled marker/resolution internals for +kit calls. Public API and runtime behavior are unchanged; every existing +test asserts on public behavior only. + +## Motivation + +Seventh 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). +`modern-di-typer` is the first conversion where the kit's **Layer 1** +(`bind`/`classify_connection`) has nothing to attach to at all: a CLI +invocation has no "connection" object analogous to a web `Request` or a +broker `StreamMessage` — the only per-invocation object is Click's own +`typer.Context`, which `@inject` already threads through natively rather +than binding into DI context. `_build_command_container` builds its +`Scope.REQUEST` child with no `context=` argument (`container.build_child_container(scope=Scope.REQUEST)`) +and always has been — there is no dict key this conversion could derive via +`bind()`. This conversion is **Layer 2 only**: `_FromDI`/`_parse_inject_params`/ +`_resolve_di_params` map onto `Marker`/`parse_markers`/`resolve_markers`, +the same triad already validated in the +[`modern-di-starlette`](https://github.com/modern-python/modern-di-starlette/pull/8) +conversion. + +One wrinkle unique to this repo: `_parse_inject_params` does two unrelated +things in one type-hints scan today — finding `FromDI` markers, and +detecting an existing `typer.Context`-typed parameter (so `@inject` doesn't +insert a redundant one). Only the marker-finding half maps onto +`integrations.parse_markers`; the `typer.Context` detection is a +typer-specific concern the kit has no equivalent for and stays hand-rolled. + +## Design + +In `modern_di_typer/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`. +- `_parse_inject_params` keeps its name and its `typer.Context`-detection + half, but its marker half delegates to the kit: + + ```python + def _parse_inject_params( + func: typing.Callable[..., typing.Any], + ) -> tuple[dict[str, integrations.Marker[typing.Any]], str | None]: + hints = typing.get_type_hints(func, include_extras=True) + ctx_param_name = next((name for name, hint in hints.items() if hint is typer.Context), None) + return integrations.parse_markers(func), ctx_param_name + ``` + + `integrations.parse_markers(func)` re-derives `typing.get_type_hints` + internally — a second call, matching the cost profile of every other + integration that calls it (starlette/fastapi/litestar/aiohttp/flask/ + faststream all call it once per decoration, same as here); decoration + happens once per command definition, not per invocation, so this is not a + hot path. +- Delete `_resolve_di_params`; `inject`'s wrapper calls + `integrations.resolve_markers(cmd_container, di_params)` directly in place + of the manual dict comprehension. +- Drop now-unused imports: `dataclasses` (only used by `_FromDI`), `providers` + (only used by `_FromDI.provider`'s and old `FromDI`'s type hints — this + repo, unlike the web/broker integrations, has no connection provider at + module level to keep the import alive for), and the `T_co` TypeVar (only + used by `_FromDI(typing.Generic[T_co])` and old `FromDI`'s signature). `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 (verified by +reading the one test file), 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`, `_build_command_container`, and `action_scope` are + untouched. +- `is_injected`/`mark_injected` — `@inject` here is applied once per command + definition by hand (`@app.command() @inject def cmd(...)`); there is no + auto-sweep mechanism (unlike Flask's `auto_inject`) that could re-wrap an + already-injected function, so there is no double-wrap guard to replace. +- `architecture/scopes.md` — it describes the App/Command/Action container + hierarchy in terms of behavior, not the marker-resolution mechanism this + change touches; verified it names none of the internals being replaced. +- Any test rewrite. The existing suite asserts on public behavior only + (`FromDI`, `inject`, `action_scope`, `fetch_di_container`, `setup_di` — + none of it references `_FromDI`, `_parse_inject_params`, or + `_resolve_di_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.0,<3` style with a + patch component, kept consistent as `>=2.28.0,<3`). +- **Splitting `_parse_inject_params`'s single hints-scan into two separate + scans (one hand-rolled for `ctx`, one inside `integrations.parse_markers` + for markers) changes behavior** (low likelihood — both scans read the same + `typing.get_type_hints(func, include_extras=True)` result independently; + neither has a side effect the other depends on, and decoration-time cost + duplication is negligible — verified against every sibling integration's + identical call pattern). diff --git a/pyproject.toml b/pyproject.toml index 5421a77..a558abd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Typing :: Typed", "Topic :: Software Development :: Libraries", ] -dependencies = ["typer>=0.9,<1", "modern-di>=2.25.0,<3"] +dependencies = ["typer>=0.9,<1", "modern-di>=2.28.0,<3"] version = "0" [project.urls]