Agent Rules / Chimeras
Why does my renamed keyword argument fail at runtime?
Chimera CH-09 · Python typing
Rules it tests: AR-11
A service function takes **opts, hands it to a retry helper, and the helper hands it to the function that does the work. Each layer is a few lines and none of them names a parameter. Rename a keyword on the bottom function and every layer above still accepts the old one, so the failure waits for a TypeError at the bottom call. Spell the parameters at the layer callers use, and give the retry wrapper a ParamSpec type so it carries the callee's signature.
The code below is from a small example project we wrote for this page. The break is shown by file and line, and we ran the project's own tests and type checks to confirm it.
1. The setup
Forwarding is a good tool for this job. A retry helper that takes **kwargs works for any function, and the layer above it needs no edit when a new option appears. A notifications service has four layers and three callers.
# before/notifications/transport.py
def deliver(user: str, channel: str = "email", *, template_id: str, locale: str = "en") -> str: # line 8# before/notifications/retry.py
def send_with_retry(**kwargs: Any) -> str: # line 8
...
return deliver(**kwargs) # line 12
# before/notifications/service.py
def notify(user: str, **opts: Any) -> str: # line 6
return send_with_retry(user=user, **opts) # line 7# before/notifications/api.py
return notify(user, template="welcome") # line 5
return notify(user, template_id="receipt", locale=locale) # line 9
# before/notifications/jobs.py
return notify(user, channel="sms", template="digest", locale="de") # line 5tests/test_transport.py calls deliver directly, with a template, a channel and a locale, and with an unknown channel.
2. Where the call path hides
- No layer names a parameter.
notifyatservice.py:6accepts**opts: Any, andsend_with_retryatretry.py:8accepts**kwargs: Any. The only signature that liststemplate_idisdeliverattransport.py:8, three calls down. - **
Anyswitches the check off.** The mypy documentation says to think ofAnyas a way to locally disable type checking, and that you can do anything with anAnyvalue without the checker complaining. A keyword that arrives inside**opts: Anyis not compared withdeliver. PEP 612 states the same limit forCallable[..., R]: an ellipsis in place of parameter types means no validation on arguments, so a wrong call type-checks and fails at runtime. - A search for the parameter finds the signature and one caller. In the source files,
template_idappears attransport.py:8, attransport.py:11and atapi.py:9. The two stale calls spelltemplate, a different word, and it reachesdeliveras the key of a dict. - The retry layer cannot help. It catches
TransportErroratretry.py:13and re-raises after three attempts. ATypeErroris not aTransportError, so it passes through on the first attempt.
3. The edit that breaks it, and which call is left wrong
The template argument was renamed so that it says what it holds. deliver now takes template_id:
# before/notifications/transport.py (edit applied)
def deliver(user: str, channel: str = "email", *, template_id: str, locale: str = "en") -> str: # line 8 (was: ..., *, template: str, locale: str = "en")The edit is finished in the files it touches. The direct tests were updated with it: tests/test_transport.py calls deliver("u1", template_id="welcome") and expects email:u1:welcome:en. api.py:9, the receipt sender, was updated in the same change.
The calls left wrong are two:
api.py:5,notify(user, template="welcome")jobs.py:5,notify(user, channel="sms", template="digest", locale="de")
Each call passes through notify, then send_with_retry, and arrives at deliver(**kwargs) at retry.py:12 with a key deliver does not have. Python raises TypeError: deliver() got an unexpected keyword argument 'template' at that line. Nothing says anything before that: the module loads, and no layer lists its parameters. The failure shows when welcome_user or send_digest runs, and the traceback ends at retry.py:12, a file the edit never touched.
The cases that catch it are tests/test_notifications.py:15 and :20, written as xfail with the reason. They call welcome_user and send_digest and expect the sent message. The receipt test at :7 is the part of the service the edit remembered. With pytest the before/ suite reports 4 passed and 2 xfailed; the after/ suite reports 7 passed.
Type checkers have nothing to flag: mypy 2.4.1 and pyright 1.1.414 both report no issues on before/. api.py:5 calls notify(user, template=...), and notify accepts any keyword. retry.py:12 calls deliver(**kwargs) with values typed Any, which the checker has no reason to compare with deliver's parameters. The call that is wrong is the one in api.py, and the only function that could reject it is three layers down.
4. The fix
Two changes. The public layer, notify, spells the parameters it forwards, so a stale keyword is an error where the caller wrote it. The retry layer becomes a decorator typed with ParamSpec: Callable[P, R] goes in and Callable[P, R] comes out, so the wrapped function keeps its parameter list. The Python typing documentation describes ParamSpec as the way to tell the checker that the callable passed into a decorator and the one it returns have inter-dependent parameters.
--- before/notifications/retry.py+++ after/notifications/retry.py-from typing import Any+import functools+from collections.abc import Callable+from typing import ParamSpec, TypeVar from notifications.transport import TransportError, deliver +P = ParamSpec("P")+R = TypeVar("R")+ MAX_ATTEMPTS = 3 -def send_with_retry(**kwargs: Any) -> str:- last_error: TransportError | None = None- for _ in range(MAX_ATTEMPTS):- try:- return deliver(**kwargs)- except TransportError as error:- last_error = error- assert last_error is not None- raise last_error+def with_retry(func: Callable[P, R]) -> Callable[P, R]:+ @functools.wraps(func)+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:+ last_error: TransportError | None = None+ for _ in range(MAX_ATTEMPTS):+ try:+ return func(*args, **kwargs)+ except TransportError as error:+ last_error = error+ assert last_error is not None+ raise last_error++ return wrapper+++send_with_retry = with_retry(deliver)--- before/notifications/service.py+++ after/notifications/service.py-from typing import Any- from notifications.retry import send_with_retry -def notify(user: str, **opts: Any) -> str:- return send_with_retry(user=user, **opts)+def notify(user: str, channel: str = "email", *, template_id: str, locale: str = "en") -> str:+ return send_with_retry(user, channel, template_id=template_id, locale=locale)api.py:5 and jobs.py:5 change from template= to template_id=. On before/ they stayed wrong because nothing reported them. On after/ the signature of notify has no template, so a checker has a mismatch to report at each of those two calls, and the list of edits comes from the tool and not from a search. With the stale template="welcome" put back into after/api.py:5, mypy 2.4.1 reports Unexpected keyword argument "template" for "notify"; did you mean "template_id"? and pyright 1.1.414 reports No parameter named "template" at that line.
transport.py is unchanged. Concatenate, from the same documentation page, is for a wrapper that adds, removes or transforms leading parameters of the function it wraps, for example a decorator that supplies a lock as the first argument. This retry wrapper changes nothing, so ParamSpec alone is enough.
For a function that takes many optional keywords, the typing documentation shows another form: Unpack with a TypedDict types **kwargs by key and value (added to the typing module in Python 3.11). A named TypedDict documents the options once and lets the checker validate each key.
5. Why the fix works
- The public layer lists the contract.
notifyatservice.py:4has the same parameters asdeliver. A call withtemplate=names a keyword that is not there, which is an error at the caller. Renamingtemplate_idagain points atservice.py:4,service.py:5and every caller. - The wrapper carries the callee's parameters.
P.argsannotates*argsandP.kwargsannotates**kwargs, and the documentation says both require theParamSpecto be in scope. WithCallable[P, R]on both sides of the decorator,send_with_retryhas the parameter list ofdeliver, and the call atservice.py:5is checked against it. - The runtime metadata survives.
functools.wrapscopies__name__,__qualname__,__annotations__and__doc__to the wrapper and sets__wrapped__.after/tests/test_notifications.py:20checks the name and the parameter list. - The retry still sees only transport errors.
with_retrycatchesTransportErrorand nothing else, so aTypeErrorat run time still goes straight through.
6. Rules that apply
- AR-11: avoid
**kwargspass-through layers that hide the real signature; spell the parameters or useParamSpec. A thin adapter that must forward everything keeps its generic shape by typing it withParamSpec, and a public entry point spells its parameters.
7. References
Python (fetched 2026-10-31):
- typing (
ParamSpec,P.argsandP.kwargs,Concatenate, theCallable[P, T]decorator example,UnpackwithTypedDictfor**kwargs, added in Python 3.11): https://docs.python.org/3/library/typing.html - functools (
wraps,update_wrapper,__wrapped__): https://docs.python.org/3/library/functools.html - PEP 612, motivation (ellipsis in
Callablemeans no validation on arguments): https://peps.python.org/pep-0612/
mypy (fetched 2026-10-31):
- Dynamically typed code (
Anylocally disables type checking): https://mypy.readthedocs.io/en/stable/dynamic_typing.html - Generics, declaring decorators (a parameter specification is a more faithful annotation than a bound
TypeVarwithcast): https://mypy.readthedocs.io/en/stable/generics.html
Last verified