Skip to content

← Structural Typing and the Hard Parts step 11 of 24

Hard Framework

ParamSpec and Concatenate: decorators that keep the signature

One Callable[..., Any] decorator silently disables type checking for every call site of every function it wraps. No other typing mistake has a blast radius that large — a single @retry on fifty handlers erases the checked signature of fifty functions and hundreds of call sites, and --strict reports nothing, because the annotation is complete.

The shape

def retry[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: ...
    return wrapper

Two rules the checker enforces: P.args and P.kwargs must appear together, and only as the annotations of *args and **kwargs. You cannot use them anywhere else — they are not real types, they are a placeholder for “whatever this function’s parameter list was”.

Concatenate[Req, P] prepends positional-only parameters. A decorator annotated

def with_session[**P, R](fn: Callable[Concatenate[Session, P], R]) -> Callable[P, R]:

consumes the leading Session argument. The wrapped function declares it; the public signature no longer has it; and a caller who tries to pass one is a static error.

Its documented limits

PEP 612 is explicit that it does not cover everything:

  • arity reduction (functools.partial-style: bind an argument and shorten the signature) is not expressible;
  • there is no syntax to prepend a keyword-only parameter — Concatenate is positional-only.

Knowing those two saves days of trying.

Also note --disallow-untyped-decorators, which is part of --strict: an untyped decorator applied to a typed function is itself an error, because it silently returns Any.

Your task

Implement retry and with_session with the signatures above.

retry calls the function up to ATTEMPTS (3) times, swallowing RuntimeError; if all attempts fail it raises RuntimeError(f"gave up after {ATTEMPTS} attempts") chained from the last exception.

with_session constructs a fresh Session() per call and passes it as the first positional argument.

def solve(failures: list[int], labels: list[str], queries: list[str]) -> list[str]:

For each (fail, label) pair, call flaky([fail + 1], label) inside a try and append either its result or str(exc). Then for each query, append run_query(query) and run_query(query, upper=True)with no session argument, which is the whole point of Concatenate.

flaky decrements the single-element budget list it is handed and raises while it is still positive, so fail is literally the number of failures before success. State lives in the argument, not in the module — which is also how you make a retry policy testable in real code.