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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ dist/
.python-version
.venv
uv.lock
.superpowers/
26 changes: 15 additions & 11 deletions architecture/injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
40 changes: 6 additions & 34 deletions modern_di_typer/main.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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]
Expand Down
118 changes: 118 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,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).
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 = ["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]
Expand Down
Loading