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

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

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 @@ -59,9 +61,9 @@ class AppGroup(Group):

@inject
async def greet(
ctx: dict[str, typing.Any], # arq passes its context dict as the first argument
ctx: dict[str, typing.Any], # arq passes its context dict as the first argument
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.
43 changes: 43 additions & 0 deletions examples/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Minimal modern-di + arq example.
# Run for real (needs a running arq worker + Redis): arq examples.app.WorkerSettings
import dataclasses
import typing

from modern_di import Container, Group, Scope, providers

from modern_di_arq import FromDI, inject, setup_di


@dataclasses.dataclass(kw_only=True)
class Settings:
greeting: str = "Hello"


@dataclasses.dataclass(kw_only=True)
class GreetingService:
settings: Settings # auto-injected by type

def greet(self, name: str) -> str:
return f"{self.settings.greeting}, {name}!"


class Dependencies(Group):
settings = providers.Factory(scope=Scope.APP, creator=Settings)
service = providers.Factory(scope=Scope.REQUEST, creator=GreetingService)


@inject
async def greet(
ctx: dict[str, typing.Any], # noqa: ARG001 # arq passes its context dict as the first argument
name: str,
service: typing.Annotated[GreetingService, FromDI(Dependencies.service)],
) -> str:
return service.greet(name)


class WorkerSettings:
functions: typing.ClassVar[list] = [greet]


container = Container(groups=[Dependencies], validate=True)
setup_di(WorkerSettings, container)
38 changes: 21 additions & 17 deletions planning/changes/2026-07-10.01-arq-di-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,12 @@ def setup_di(worker_settings, container):
ctx[_ROOT_CONTAINER_KEY] = container
_set_setting(worker_settings, "ctx", ctx)

_set_setting(worker_settings, "on_startup",
_wrap_startup(container, _get_setting(worker_settings, "on_startup")))
_set_setting(worker_settings, "on_shutdown",
_wrap_shutdown(container, _get_setting(worker_settings, "on_shutdown")))
_set_setting(worker_settings, "on_job_start",
_wrap_job_start(_get_setting(worker_settings, "on_job_start")))
_set_setting(worker_settings, "on_job_end",
_wrap_job_end(_get_setting(worker_settings, "on_job_end")))
_set_setting(worker_settings, "on_startup", _wrap_startup(container, _get_setting(worker_settings, "on_startup")))
_set_setting(
worker_settings, "on_shutdown", _wrap_shutdown(container, _get_setting(worker_settings, "on_shutdown"))
)
_set_setting(worker_settings, "on_job_start", _wrap_job_start(_get_setting(worker_settings, "on_job_start")))
_set_setting(worker_settings, "on_job_end", _wrap_job_end(_get_setting(worker_settings, "on_job_end")))
return container
```

Expand All @@ -131,33 +129,40 @@ def setup_di(worker_settings, container):
```python
def _wrap_startup(container, existing):
async def on_startup(ctx):
container.open() # reopen for restart / test re-entry
container.open() # reopen for restart / test re-entry
if existing is not None:
await existing(ctx)

return on_startup


def _wrap_shutdown(container, existing):
async def on_shutdown(ctx):
if existing is not None:
await existing(ctx)
await container.close_async() # run APP-scoped finalizers
await container.close_async() # run APP-scoped finalizers

return on_shutdown


def _wrap_job_start(existing):
async def on_job_start(ctx):
root = ctx[_ROOT_CONTAINER_KEY]
ctx[_CHILD_CONTAINER_KEY] = root.build_child_container(scope=Scope.REQUEST)
if existing is not None:
await existing(ctx)

return on_job_start


def _wrap_job_end(existing):
async def on_job_end(ctx):
if existing is not None:
await existing(ctx)
child = ctx.pop(_CHILD_CONTAINER_KEY, None)
if child is not None:
await child.close_async() # never leak on the error path
await child.close_async() # never leak on the error path

return on_job_end
```

Expand Down Expand Up @@ -186,19 +191,18 @@ bind-by-name** resolution so injection is **parameter-order-insensitive**:
```python
def inject(func):
di_params = _parse_inject_params(func)
if not di_params: # nothing to inject → passthrough
if not di_params: # nothing to inject → passthrough
return func

signature = inspect.signature(func)
visible_params = [p for name, p in signature.parameters.items() if name not in di_params]
visible_signature = signature.replace(parameters=visible_params)

@functools.wraps(func) # safe for arq; preserves __qualname__ for the job name
@functools.wraps(func) # safe for arq; preserves __qualname__ for the job name
async def wrapper(*args, **kwargs):
ctx = args[0] # arq always passes ctx first
child = ctx[_CHILD_CONTAINER_KEY] # built by on_job_start
resolved = {name: child.resolve_dependency(marker.dependency)
for name, marker in di_params.items()}
ctx = args[0] # arq always passes ctx first
child = ctx[_CHILD_CONTAINER_KEY] # built by on_job_start
resolved = {name: child.resolve_dependency(marker.dependency) for name, marker in di_params.items()}
bound = visible_signature.bind(*args, **kwargs)
bound.apply_defaults()
return await func(**bound.arguments, **resolved)
Expand Down
105 changes: 105 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,105 @@
---
summary: Added examples/ minimal-DI runnable app + in-memory smoke test (which covers it to 100%, no coverage exclusion) + README canonical-example line, rolling the faststream-piloted canonical-example pattern into modern-di-arq and flipping this repo's D2/D6 gaps to 2.
---

# Design: Canonical minimal-DI example (arq rollout)

## Summary

Ship a canonical, runnable example in `modern-di-arq`: a minimal `examples/`
app wiring one `Settings` (APP scope) and one `GreetingService` (REQUEST
scope) into an `@inject`-decorated arq task via the real `FromDI` idiom, an
in-memory smoke test that proves it runs without Redis, and the README
`Usage example:` line. This is the arq instance of the reusable pattern
locked by the [faststream pilot](https://github.com/modern-python/modern-di-faststream/blob/main/planning/changes/2026-07-24.01-canonical-example-onramp.md):
`examples/app.py` + a 100%-covered smoke test using the repo's own in-memory
test double + the README link.

## Motivation

The blessed-ready on-ramp audit found `modern-di-arq`, like 9 of 12
integrations, missing a dedicated, linked canonical example — no `examples/`
dir and no README `Usage example:` line. The faststream pilot locked the
shape; this change rolls it out here, using arq's own established in-memory
idiom (the repo's tests already exercise `@inject`-wrapped tasks by calling
the wrapped coroutine directly over a manually-seeded `ctx` dict, with no
Redis) rather than inventing a new one.

## Design

**The example** — `examples/app.py` (+ `examples/__init__.py`): the smallest
runnable arq setup matching the README snippet's shape as a complete program:

- `Settings` — `providers.Factory(scope=Scope.APP, creator=Settings)`.
- `GreetingService` — `providers.Factory(scope=Scope.REQUEST, ...)`, depends
on `Settings` by type (auto-injected).
- one `@inject`-decorated task, `greet(ctx, name, service: Annotated[GreetingService,
FromDI(Dependencies.service)])`, returning the greeting so a test can assert
on it — arq's real `Annotated`-marker form (arq has no native injection
seam, so `FromDI` only works as an `Annotated` metadata marker, not a
default value).
- a `WorkerSettings` class wired via `setup_di(WorkerSettings, container)`.
A `# run: arq examples.app.WorkerSettings (needs a running Redis)` header
states the real-run requirement.

**Smoke test** — `tests/test_example.py`: imports the example's `container`
and `greet`, and reuses the repo's own established in-memory idiom (already
exercised by `tests/test_jobs.py::test_wrapper_guarantees_close_without_on_job_end`):
open the container, seed a `ctx` dict with the root container under
`_ROOT_CONTAINER_KEY`, call the private `_wrap_job_start(None)` hook once to
build the per-job REQUEST child the same way `on_job_start` would, then call
the `@inject`-wrapped task directly as `await greet(ctx, "world")` — no real
arq `Worker`, no Redis. Asserts the real greeting string the injected
`GreetingService` produces.

**Coverage** — none excluded. The smoke test executes `examples/app.py` end
to end (import wires the container + task; the call runs the task body), so
it covers the example to **100%** under the repo's existing
`--cov-fail-under=100` gate. No `omit` needed.

**README** — insert `Usage example: [examples/](./examples)` directly under
the `Full guide` line, above `## Installation`, matching the faststream
placement. The existing inline `## Usage` snippet stays unchanged.

**Ruff 0.16 drift (incidental, pre-existing)** — `uv lock --upgrade` now
resolves `ruff` (unpinned in the `lint` dependency group) to 0.16.0, which
started format-checking fenced Python code blocks inside Markdown by default.
That newly flagged two **pre-existing** files (`README.md`'s inline snippet,
`planning/changes/2026-07-10.01-arq-di-integration.md`) for comment-spacing —
unrelated to this change's own content, but left unfixed it would break CI on
the next `uv lock --upgrade` regardless of this PR. Reformatted both
mechanically (`ruff format`) alongside this change so CI stays green.

## Non-goals

- Editing the inline README `## Usage` snippet's content or wiring — only the
new `Usage example:` link line.
- A realistic/DB-or-broker-backed starter — minimal DI demo only.
- New runtime dependencies — the example uses only `arq` + `modern-di`,
already present.
- Further rollout to remaining integrations — tracked per repo.

## Testing

- `just test-ci` green: the smoke test covers `examples/app.py` to 100% (no
exclusion) and asserts the real greeting string (real behavior, not a
mock), with no Redis required.
- `just lint-ci` green (ruff/ty/eof/format + planning index over the new and
reformatted files), including under `ruff@0.16.0` (`check --no-fix` and
`format --check`), matching the version CI's unpinned `lint` group floats
to.
- `just check-planning` green (this bundle validates).
- Manual sanity: `arq examples.app.WorkerSettings` starts against a local
Redis (documented via the header comment, not a CI gate).

## Risk

- **Example rot** (low x low) — mitigated structurally: the example is
measured (no coverage exclusion) and the smoke test executes it every CI
run.
- **Example drifts from the README snippet** (low x low) — they share one
shape; keep the example the runnable superset of the snippet.
- **Ruff version drift re-breaking CI** (low x medium, pre-existing and
outside this change's control) — `ruff` stays unpinned in the `lint` group
by repo convention; a future ruff release could again start flagging
previously-clean files. Out of scope to pin here; noted for awareness.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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
15 changes: 15 additions & 0 deletions tests/test_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import typing

from examples.app import container, greet
from modern_di_arq.main import _ROOT_CONTAINER_KEY, _wrap_job_start


async def test_example_resolves_and_greets() -> None:
container.open()
ctx: dict[str, typing.Any] = {_ROOT_CONTAINER_KEY: container}
await _wrap_job_start(None)(ctx) # seeds the per-job REQUEST child, mirroring on_job_start

result = await greet(ctx, "world")

assert result == "Hello, world!"
await container.close_async()