From 120cc54a8c07cd1efcfdc2ac2dfbea01f61a8c6a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 25 Jul 2026 07:39:44 +0300 Subject: [PATCH 1/2] feat(examples): canonical minimal-DI example + README link Rolls out the reusable examples/ + smoke-test + README-link pattern locked by the modern-di-faststream pilot, flipping the blessed-ready audit's D2/D6 scores from 1 to 2 for this integration. --- README.md | 2 + examples/__init__.py | 0 examples/app.py | 39 ++++++++ .../2026-07-25.01-canonical-example-onramp.md | 91 +++++++++++++++++++ pyproject.toml | 1 + tests/test_example.py | 12 +++ 6 files changed, 145 insertions(+) create mode 100644 examples/__init__.py create mode 100644 examples/app.py create mode 100644 planning/changes/2026-07-25.01-canonical-example-onramp.md create mode 100644 tests/test_example.py diff --git a/README.md b/README.md index 472d010..1fbf4e9 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 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-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!" From 0ce7cf37e96998d9d608804e0a4ac2731d0de33b Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 25 Jul 2026 07:41:43 +0300 Subject: [PATCH 2/2] style: reformat pre-existing markdown code fences for ruff 0.16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI floats ruff to 0.16.0 (newer than the repo's pinned 0.15.22); its embedded-code-block formatter reflows two comment-spacing/line-length spots in README.md and an old planning change file. Pure reformat, no content change — needed so this PR's lint job (which runs repo-wide) passes against CI's floated version. --- README.md | 4 ++-- .../changes/2026-07-09.01-taskiq-di-integration.md | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1fbf4e9..ebe7305 100644 --- a/README.md +++ b/README.md @@ -47,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: @@ -66,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/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)