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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

Full guide: [taskiq integration docs](https://modern-di.modern-python.org/integrations/taskiq/)

Usage example: [examples/](./examples)

## Installation

```bash
Expand All @@ -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:
Expand All @@ -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)
```
Expand Down
Empty file added examples/__init__.py
Empty file.
39 changes: 39 additions & 0 deletions examples/app.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 8 additions & 5 deletions planning/changes/2026-07-09.01-taskiq-di-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,)
```
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand Down
91 changes: 91 additions & 0 deletions planning/changes/2026-07-25.01-canonical-example-onramp.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
12 changes: 12 additions & 0 deletions tests/test_example.py
Original file line number Diff line number Diff line change
@@ -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!"