diff --git a/README.md b/README.md index 1261fba..b6417c2 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Run the worker as usual (`arq mymodule.WorkerSettings`) and enqueue jobs with on |---|---| | `setup_di(worker_settings, container)` | Seeds the root container into arq's `ctx` and wires root + per-job lifecycle onto `on_startup`/`on_shutdown`/`on_job_start`/`on_job_end`. Accepts a `WorkerSettings` class/object or a settings `dict`; composes with existing hooks; returns the container. Raises `TypeError` if called twice on the same `worker_settings` | | `FromDI(dependency)` | Inert marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a type | -| `inject(task)` | Decorator that resolves `FromDI`-annotated parameters from the per-job `Scope.REQUEST` child. Order-insensitive; passthrough for tasks with no `FromDI`; raises `TypeError` at decoration if the task also declares `*args`/`**kwargs` | +| `inject(task)` | Decorator that resolves `FromDI`-annotated parameters from the per-job `Scope.REQUEST` child. Order-insensitive; passthrough for tasks with no `FromDI`; raises `TypeError` at decoration if the task also declares `*args`/`**kwargs`. Raises `RuntimeError` naming `setup_di` when a job reaches it without the modern-di hooks installed | | `fetch_di_container(ctx)` | Returns the root container from an arq `ctx` dict | ## 📦 [PyPI](https://pypi.org/project/modern-di-arq) diff --git a/modern_di_arq/main.py b/modern_di_arq/main.py index 691bfc7..5f0ad0d 100644 --- a/modern_di_arq/main.py +++ b/modern_di_arq/main.py @@ -30,6 +30,18 @@ def _set_setting(worker_settings: typing.Any, name: str, value: typing.Any) -> N setattr(worker_settings, name, value) +def _fetch_child_container(ctx: dict[str, typing.Any]) -> Container: + try: + return typing.cast(Container, ctx[_CHILD_CONTAINER_KEY]) + except KeyError: + msg = ( + "No modern-di container found for this job. " + "Call setup_di(worker_settings, container) so jobs pass through the modern-di hooks " + "before using @inject." + ) + raise RuntimeError(msg) from None + + _Hook = typing.Callable[[dict[str, typing.Any]], typing.Awaitable[None]] @@ -163,7 +175,7 @@ def inject(func: typing.Callable[..., typing.Awaitable[T]]) -> typing.Callable[. @functools.wraps(func) async def wrapper(*args: typing.Any, **kwargs: typing.Any) -> T: # noqa: ANN401 ctx = typing.cast("dict[str, typing.Any]", args[0]) - child = typing.cast(Container, ctx[_CHILD_CONTAINER_KEY]) + child = _fetch_child_container(ctx) # Reference-count opens so nested AND concurrent (@inject fan-out via gather) # share one open child and close it exactly once, when the LAST @inject body # exits. The check/open/increment run without an await, so asyncio cannot diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 9754226..f1a5121 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -298,3 +298,10 @@ async def bad_task( with pytest.raises(TypeError): inject(bad_task) + + +async def test_inject_without_setup_di_names_the_fix() -> None: + ctx: dict[str, typing.Any] = {} + + with pytest.raises(RuntimeError, match=r"setup_di\(worker_settings, container\)"): + await resolves_app_and_request(ctx, 7)