diff --git a/README.md b/README.md index 5f06c06..94a70b8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Full guide: [arq integration docs](https://modern-di.modern-python.org/integrations/arq/) +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: @@ -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) 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..8c1cc40 --- /dev/null +++ b/examples/app.py @@ -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) diff --git a/planning/changes/2026-07-10.01-arq-di-integration.md b/planning/changes/2026-07-10.01-arq-di-integration.md index 1dfeaf1..e70cabe 100644 --- a/planning/changes/2026-07-10.01-arq-di-integration.md +++ b/planning/changes/2026-07-10.01-arq-di-integration.md @@ -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 ``` @@ -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 ``` @@ -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) 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..7ca9d68 --- /dev/null +++ b/planning/changes/2026-07-25.01-canonical-example-onramp.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 213ef3d..41760c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..8f8cc7b --- /dev/null +++ b/tests/test_example.py @@ -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()