From e14240a1f6a0c37ba727a1c3a4714d1d276dde30 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 11 Jul 2026 13:00:33 +0300 Subject: [PATCH] fix: reject *args/**kwargs in @inject to prevent silent arg corruption Since 2.0.0, @inject binds a task's arguments to its visible signature by name (bound.arguments), which makes injection parameter-order-insensitive but cannot faithfully forward *args/**kwargs: Signature.bind stores their values under the literal names "args"/"kwargs", so a task declaring *args/**kwargs alongside a FromDI parameter had its real payload silently misrouted into a keyword argument instead of raising. Reject such signatures at decoration time with a clear TypeError naming the offending parameter. A task with no FromDI parameter is returned unchanged and may still use *args/**kwargs. Documented in architecture/dependency-injection.md; notes at planning/releases/2.0.1.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/dependency-injection.md | 11 ++++++++++ modern_di_celery/main.py | 8 +++++++ planning/releases/2.0.1.md | 33 ++++++++++++++++++++++++++++ tests/test_inject.py | 22 +++++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 planning/releases/2.0.1.md diff --git a/architecture/dependency-injection.md b/architecture/dependency-injection.md index 20c2773..08a1a55 100644 --- a/architecture/dependency-injection.md +++ b/architecture/dependency-injection.md @@ -88,6 +88,17 @@ opt into injection by annotating them Celery's own argument binding reads the rewritten signature and only expects the caller's real arguments, never the DI ones. +Resolution binds the caller's arguments to the visible signature *by name* +(`bound.arguments`), which is what makes injection parameter-order-insensitive. +That by-name call cannot faithfully forward `*args`/`**kwargs`: `Signature.bind` +stores their values under the literal names `"args"`/`"kwargs"`, so +`func(**bound.arguments, ...)` would misroute a variadic payload into a keyword +argument. Rather than silently corrupt arguments, `inject` **rejects at +decoration time** (raises `TypeError`) any task that declares a `VAR_POSITIONAL` +or `VAR_KEYWORD` parameter *alongside* a `FromDI` parameter. A task with no +`FromDI` parameter is returned unchanged (step 2) and may use `*args`/`**kwargs` +freely. + ## DITask `DITask(Task)` is the auto-inject path, used via `task_cls=DITask` on the diff --git a/modern_di_celery/main.py b/modern_di_celery/main.py index a705693..cbb298f 100644 --- a/modern_di_celery/main.py +++ b/modern_di_celery/main.py @@ -67,6 +67,14 @@ def inject(func: typing.Callable[..., T]) -> typing.Callable[..., T]: return func signature = inspect.signature(func) + for name, param in signature.parameters.items(): + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + func_name = getattr(func, "__qualname__", repr(func)) + msg = ( + f"@inject task {func_name!r} declares *args/**kwargs (parameter {name!r}), " + "which is unsupported; use explicit named parameters instead of *args/**kwargs with @inject." + ) + raise TypeError(msg) visible_params = [p for name, p in signature.parameters.items() if name not in di_params] visible_signature = signature.replace(parameters=visible_params) diff --git a/planning/releases/2.0.1.md b/planning/releases/2.0.1.md new file mode 100644 index 0000000..76dae3e --- /dev/null +++ b/planning/releases/2.0.1.md @@ -0,0 +1,33 @@ +# modern-di-celery 2.0.1 — `@inject` variadic fail-fast + +A patch release fixing silent argument corruption in `@inject`. + +## Fix + +- **`@inject` now rejects `*args`/`**kwargs` at decoration time.** Since 2.0.0, + `@inject` binds a task's arguments to its visible signature *by name* (which is + what makes injection parameter-order-insensitive). That by-name call could not + faithfully forward variadic parameters: `inspect.Signature.bind` stores + `*args`/`**kwargs` values under the literal names `"args"`/`"kwargs"`, so a + task declaring `*args`/`**kwargs` **alongside** a `FromDI` parameter had its + real payload silently misrouted into a keyword argument (e.g. a caller's + `(1, 2, foo="bar")` arrived inside the task as `args=(), kwargs={"args": (1, 2), + "kwargs": {"foo": "bar"}}`) instead of raising. + + `@inject` now raises a clear `TypeError` at decoration time when a task + declares a `VAR_POSITIONAL` or `VAR_KEYWORD` parameter together with a `FromDI` + parameter, naming the offending parameter and pointing at explicit named + parameters as the fix. A task with **no** `FromDI` parameter is returned + unchanged and may use `*args`/`**kwargs` freely. + +## Downstream + +- **Potentially breaking, intentionally.** A task that previously combined + `@inject`/`DITask` with `*args`/`**kwargs` and a `FromDI` parameter was already + broken at runtime (silently corrupted arguments); it now fails loudly at import + time. Replace the variadic parameters with explicit named parameters. + +## Internals + +- 100% line coverage; `ruff`, `ty`, and `eof-fixer` clean. Two new tests cover + the `VAR_POSITIONAL` and `VAR_KEYWORD` rejection paths. diff --git a/tests/test_inject.py b/tests/test_inject.py index 828c0f0..44bae12 100644 --- a/tests/test_inject.py +++ b/tests/test_inject.py @@ -75,3 +75,25 @@ def sample(_res: typing.Annotated[SimpleCreator, FromDI(Boom.resource)]) -> None with pytest.raises(ValueError, match="boom"): sample.delay().get() assert teardowns == ["closed"] # per-task child closed (finalizer ran) on the error path + + +def test_inject_rejects_var_positional_with_fromdi() -> None: + def bad_task( + _svc: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], + *args: int, + ) -> tuple[int, ...]: + return args # pragma: no cover + + with pytest.raises(TypeError): + inject(bad_task) + + +def test_inject_rejects_var_keyword_with_fromdi() -> None: + def bad_task( + _svc: typing.Annotated[SimpleCreator, FromDI(SimpleCreator)], + **kwargs: int, + ) -> dict[str, int]: + return kwargs # pragma: no cover + + with pytest.raises(TypeError): + inject(bad_task)