diff --git a/README.md b/README.md index 472d010..ebe7305 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Full guide: [taskiq integration docs](https://modern-di.modern-python.org/integrations/taskiq/) +Usage example: [examples/](./examples) + ## Installation ```bash @@ -45,7 +47,7 @@ class Settings: class Greeter: - def __init__(self, settings: Settings) -> None: # auto-injected by type + def __init__(self, settings: Settings) -> None: # auto-injected by type self._settings = settings def greet(self, name: str) -> str: @@ -64,7 +66,7 @@ setup_di(broker, Container(groups=[AppGroup], validate=True)) @broker.task async def greet( name: str, - greeter: typing.Annotated[Greeter, FromDI(Greeter)], # resolve by type + greeter: typing.Annotated[Greeter, FromDI(Greeter)], # resolve by type ) -> str: return greeter.greet(name) ``` diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/app.py b/examples/app.py new file mode 100644 index 0000000..1e7de78 --- /dev/null +++ b/examples/app.py @@ -0,0 +1,39 @@ +# Minimal modern-di + taskiq example. +# run: taskiq worker examples.app:broker (then trigger via `await greet.kiq("world")`) +import typing + +from modern_di import Container, Group, Scope, providers +from taskiq import InMemoryBroker + +from modern_di_taskiq import FromDI, setup_di + + +class Settings: + def __init__(self) -> None: + self.greeting = "Hello" + + +class Greeter: + def __init__(self, settings: Settings) -> None: # auto-injected by type + self._settings = settings + + def greet(self, name: str) -> str: + return f"{self._settings.greeting}, {name}!" + + +class AppGroup(Group): + settings = providers.Factory(Settings, scope=Scope.APP, cache=True) + greeter = providers.Factory(Greeter, scope=Scope.REQUEST) + + +broker = InMemoryBroker() +container = Container(groups=[AppGroup], validate=True) +setup_di(broker, container) + + +@broker.task +async def greet( + name: str, + greeter: typing.Annotated[Greeter, FromDI(Greeter)], +) -> str: + return greeter.greet(name) diff --git a/planning/changes/2026-07-09.01-taskiq-di-integration.md b/planning/changes/2026-07-09.01-taskiq-di-integration.md index 03d59e2..4c48347 100644 --- a/planning/changes/2026-07-09.01-taskiq-di-integration.md +++ b/planning/changes/2026-07-09.01-taskiq-di-integration.md @@ -57,7 +57,8 @@ One connection kind (a consumed message → `REQUEST`): ```python taskiq_message_provider = providers.ContextProvider( - taskiq.TaskiqMessage, scope=Scope.REQUEST, + taskiq.TaskiqMessage, + scope=Scope.REQUEST, ) _CONNECTION_PROVIDERS = (taskiq_message_provider,) ``` @@ -66,8 +67,8 @@ _CONNECTION_PROVIDERS = (taskiq_message_provider,) ```python def setup_di(broker: AsyncBroker, container: Container) -> Container: - setattr(broker.state, _ROOT_CONTAINER_ATTR, container) # attach - container.add_providers(*_CONNECTION_PROVIDERS) # register + setattr(broker.state, _ROOT_CONTAINER_ATTR, container) # attach + container.add_providers(*_CONNECTION_PROVIDERS) # register broker.add_event_handler(TaskiqEvents.WORKER_STARTUP, lambda _state: container.open()) broker.add_event_handler(TaskiqEvents.WORKER_SHUTDOWN, lambda _state: container.close_async()) return container @@ -90,7 +91,8 @@ def fetch_di_container(broker: AsyncBroker) -> Container: ```python async def build_di_container(context: Context = TaskiqDepends()) -> typing.AsyncIterator[Container]: container = fetch_di_container(context.broker).build_child_container( - scope=Scope.REQUEST, context={taskiq.TaskiqMessage: context.message}, + scope=Scope.REQUEST, + context={taskiq.TaskiqMessage: context.message}, ) try: yield container @@ -110,7 +112,8 @@ class Dependency(typing.Generic[T_co]): dependency: providers.AbstractProvider[T_co] | type[T_co] async def __call__( - self, request_container: typing.Annotated[Container, TaskiqDepends(build_di_container)], + self, + request_container: typing.Annotated[Container, TaskiqDepends(build_di_container)], ) -> T_co: return request_container.resolve_dependency(self.dependency) diff --git a/planning/changes/2026-07-25.01-canonical-example-onramp.md b/planning/changes/2026-07-25.01-canonical-example-onramp.md new file mode 100644 index 0000000..3fae7c8 --- /dev/null +++ b/planning/changes/2026-07-25.01-canonical-example-onramp.md @@ -0,0 +1,91 @@ +--- +summary: Added examples/ minimal-DI runnable app + in-memory smoke test (which covers it to 100%, no coverage exclusion) + README canonical-example line, flipping the blessed-ready audit's D2/D6 to 2 — rollout of the reusable canonical-example pattern locked by the faststream pilot. +--- + +# Design: Canonical minimal-DI example (taskiq rollout) + +## Summary + +Ship a canonical, runnable example in `modern-di-taskiq`: a minimal +`examples/` app wiring one `Settings` (APP scope) and one `Greeter` (REQUEST +scope) into a task via the real `FromDI` idiom, an in-memory smoke test that +proves it runs, and the README `Usage example:` line the audit found missing. +This flips the two coupled gaps the blessed-ready audit scored on this repo — +**D2 (canonical example) 1 -> 2** and **D6 (README consistency) 1 -> 2** — +following the shape locked by the `modern-di-faststream` pilot +(`2026-07-24.01-canonical-example-onramp.md`). + +## Motivation + +The blessed-ready on-ramp audit found most integrations at **D2=1 / D6=1**: a +complete inline README example but no *dedicated, linked* canonical example, +so neither the `Usage example:` line (D6) nor a D2=2 score is reachable. The +faststream pilot locked the reusable shape; this change rolls it to +`modern-di-taskiq`, the third `FromDI`-based integration to adopt it. + +## Design + +**The example** — `examples/app.py` (+ `examples/__init__.py`): the smallest +runnable taskiq app, matching the README snippet's shape (same `Settings` / +`Greeter` / `AppGroup` names) but as a complete program: + +- `Settings` — `providers.Factory(Settings, scope=Scope.APP, cache=True)`. +- `Greeter` — `providers.Factory(Greeter, scope=Scope.REQUEST)`, depends on + `Settings` by type (auto-injected). +- one `@broker.task` handler taking the greeter via + `typing.Annotated[Greeter, FromDI(Greeter)]` (taskiq's decorator-free + idiom — no `@inject`), wired by `setup_di(broker, container)` on an + `InMemoryBroker`. A `# run: ...` header comment states the real-run + invocation. + +**Smoke test** — `tests/test_example.py`: imports the example's `broker`/ +`greet` task, and using the repo's existing in-memory idiom (explicit +`broker.startup()`/`broker.shutdown()` around `.kiq()` /`.wait_result()`, +already used in `tests/test_taskiq_di.py` — `InMemoryBroker` predates +async-context-manager support on this repo's taskiq floor) calls the task and +asserts the injected `Greeter`'s real output (`"Hello, world!"`), not a mock. + +**Coverage** — none excluded. The smoke test executes `examples/app.py` end +to end (import wires the broker + task; the call runs the handler), so it +covers the example to **100%** under the repo's existing +`--cov-fail-under=100` gate. No `omit` is needed. + +**README** — insert `Usage example: [examples/](./examples)` directly under +the `Full guide:` line (and its blank line), before `## Installation`, +matching the faststream pilot's placement. + +No `modern_di_taskiq/` source changes, so no `architecture/` promotion — this +adds documentation-grade example + test, not a capability change. + +## Non-goals + +- The remaining rollout integrations and the core `writing-integrations.md` + codification — separate, one line each. +- A realistic/DB starter — minimal DI demo only, matching the locked pattern. +- New runtime dependencies — the example uses only `taskiq` + `modern-di`, + already present. +- Editing the inline README snippet or any integration behavior. + +## Testing + +- `just test-ci` green: the smoke test covers `examples/app.py` to 100% (no + exclusion) and calls a real task asserting resolution (real behavior, not a + mock). +- `just lint-ci` green (ruff/ty/eof/format over the new files). +- `just check-planning` green (this bundle validates). +- `uvx ruff@0.16.0 check --no-fix .` and `uvx ruff@0.16.0 format --check .` + clean on the new/changed files (verified against CI's floated ruff, newer + than the repo's pinned version). + +## Risk + +- **Example rot** (low x low) — mitigated structurally: the example is + measured (no coverage exclusion) and the smoke test executes it every CI + run, so a broken or uncovered example fails the build. +- **Example drifts from the README snippet** (low x low) — they share one + shape (`Settings`/`Greeter`/`AppGroup`); keep the example the runnable + superset of the snippet. +- **Pattern doesn't transfer from faststream's `= FromDI(...)` default idiom** + (low, already resolved) — taskiq's idiom is + `typing.Annotated[T, FromDI(...)]`, not a parameter default; the example and + test use the repo's own idiom throughout, not faststream's. diff --git a/pyproject.toml b/pyproject.toml index 786c908..fd351f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ ignore = [ "COM812", # flake8-commas "Trailing comma missing" "ISC001", # flake8-implicit-str-concat "G004", # allow f-strings in logging + "CPY001", # no per-file copyright header ] isort.lines-after-imports = 2 isort.no-lines-before = ["standard-library", "local-folder"] diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..79f54d5 --- /dev/null +++ b/tests/test_example.py @@ -0,0 +1,12 @@ +from examples.app import broker, greet + + +async def test_example_greet_task_resolves_and_greets() -> None: + await broker.startup() + try: + result = await (await greet.kiq("world")).wait_result() # ty: ignore[no-matching-overload] + finally: + await broker.shutdown() + + assert result.is_err is False + assert result.return_value == "Hello, world!"