For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

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.

python# before/notifications/transport.py def deliver(user: str, channel: str = "email", *, template_id: str, locale: str = "en") -> str: # line 8
python# 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
python# 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 5

tests/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. notify at service.py:6 accepts **opts: Any, and send_with_retry at retry.py:8 accepts **kwargs: Any. The only signature that lists template_id is deliver at transport.py:8, three calls down.
  • **Any switches the check off.** The mypy documentation says to think of Any as a way to locally disable type checking, and that you can do anything with an Any value without the checker complaining. A keyword that arrives inside **opts: Any is not compared with deliver. PEP 612 states the same limit for Callable[..., 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_id appears at transport.py:8, at transport.py:11 and at api.py:9. The two stale calls spell template, a different word, and it reaches deliver as the key of a dict.
  • The retry layer cannot help. It catches TransportError at retry.py:13 and re-raises after three attempts. A TypeError is not a TransportError, 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:

python# 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.

diff--- 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)
diff--- 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. notify at service.py:4 has the same parameters as deliver. A call with template= names a keyword that is not there, which is an error at the caller. Renaming template_id again points at service.py:4, service.py:5 and every caller.
  • The wrapper carries the callee's parameters. P.args annotates *args and P.kwargs annotates **kwargs, and the documentation says both require the ParamSpec to be in scope. With Callable[P, R] on both sides of the decorator, send_with_retry has the parameter list of deliver, and the call at service.py:5 is checked against it.
  • The runtime metadata survives. functools.wraps copies __name__, __qualname__, __annotations__ and __doc__ to the wrapper and sets __wrapped__. after/tests/test_notifications.py:20 checks the name and the parameter list.
  • The retry still sees only transport errors. with_retry catches TransportError and nothing else, so a TypeError at run time still goes straight through.

6. Rules that apply

  • AR-11: avoid **kwargs pass-through layers that hide the real signature; spell the parameters or use ParamSpec. A thin adapter that must forward everything keeps its generic shape by typing it with ParamSpec, and a public entry point spells its parameters.

7. References

Python (fetched 2026-10-31):

  • typing (ParamSpec, P.args and P.kwargs, Concatenate, the Callable[P, T] decorator example, Unpack with TypedDict for **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 Callable means no validation on arguments): https://peps.python.org/pep-0612/

mypy (fetched 2026-10-31):

  • Dynamically typed code (Any locally 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 TypeVar with cast): https://mypy.readthedocs.io/en/stable/generics.html

Last verified

For agentsthis page as Markdown/agent-readable/ch-09.mdllms.txt