skip to content

A @functools.wraps decorator changes a function's parameters - how do you keep inspect.signature honest?

level: seniorimportance: should knowfreq 30%

answer

  1. The reported contract no longer matches
  2. Introspection preferred the wrong source
  3. One attribute is consulted before the chain
  4. Build a new Signature and assign it
  5. Signature.replace with an edited parameter list

basics

~10 s

Assign an explicit inspect.Signature to the wrapper's signature attribute. inspect.signature checks signature before following wrapped, so build one from the original with Signature.replace(parameters=...) and the reported contract matches what the wrapper truly accepts.

solid answer

~40 s

`functools.wraps` sets `__wrapped__`, and `inspect.signature` follows it, so a wrapper that injects or removes a parameter still advertises the *original* contract. In a log-ingest pipeline whose dispatcher builds each stage's call from `inspect.signature`, that is not cosmetic: `Signature.bind` accepts an argument the wrapper will reject, or omits one it needs. The fix is an explicit `wrapper.__signature__` - take `inspect.signature(func)`, add or drop parameters with `inspect.Signature.replace(parameters=...)` and `inspect.Parameter`, and assign the result. `inspect.signature` consults `__signature__` before `__wrapped__` - even under `follow_wrapped=False` - so the reported signature becomes the truth while `__name__`, `__doc__` and `__wrapped__` keep working. Deleting `__wrapped__` instead is worse: it stops the lie but breaks `inspect.unwrap` and `inspect.getsource`.

code

python · 21 lines
python
import functools
import inspect

def inject_source(func):
    sig = inspect.signature(func)
    params = [p for name, p in sig.parameters.items() if name != "source"]

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, source="stdin", **kwargs)

    wrapper.__signature__ = sig.replace(parameters=params)
    return wrapper

@inject_source
def parse(line, source):
    return line, source

print(inspect.signature(parse))          # (line)
print(inspect.signature(parse.__wrapped__))  # (line, source)
print(parse.__name__, parse("boot ok"))  # parse ('boot ok', 'stdin')

go deeper

for a junior

Recall only the shape of the problem: a decorator can report parameters that do not match what it really accepts, because functools.wraps points introspection at the original function rather than at the wrapper.

for a middle

Be able to state inspect.signature's resolution order - signature, then the wrapped chain, then the code object - and to build a new signature with inspect.Signature.replace and inspect.Parameter.

for a senior

An interviewer expects you to have debugged the failure: a dispatcher binding arguments from a signature that no longer matches, and the choice between re-signing, deleting wrapped and not changing the contract at all. Name the shallow dict merge too.

for a principal

Own the boundary. Decide whether decorators in your codebase may alter call contracts at all, and if they may, whether runtime introspection or static typing is the authoritative description - since signature serves only the first and a checker only the second.

### The scenario A log-ingest pipeline is built from stage functions, and a dispatcher wires them together by reading `inspect.signature(stage)` and binding whatever the pipeline context can supply. A four-person team adds a decorator that injects the ingest `source` so individual stages stop threading it manually: ```python @inject_source def parse(line, source): ... ``` The wrapper supplies `source` itself, so callers must now pass only `line`. But the decorator used `functools.wraps`, `functools.wraps` set `__wrapped__`, and `inspect.signature` follows `__wrapped__` — so the dispatcher still reads `(line, source)`. It dutifully passes `source`, the wrapper passes its own too, and every stage dies on a duplicate keyword argument. Nobody wrote a bug; the metadata simply describes a contract the wrapper no longer honours. ### Why the lie happens `inspect.signature` resolves a callable in a fixed order: an explicit **`__signature__`** attribute first, then the **`__wrapped__`** chain, then the object's own code object. `functools.wraps` only ever populates the second of those. That default is right for the overwhelmingly common wrapper — one that forwards `*args, **kwargs` untouched — and wrong for any wrapper that changes the call contract: injecting an argument, consuming one, adding a keyword-only flag, or binding a value with `functools.partial`. ### The fix: publish the real contract Build the signature you actually accept and assign it: ```python sig = inspect.signature(func) params = [p for name, p in sig.parameters.items() if name != "source"] wrapper.__signature__ = sig.replace(parameters=params) ``` `inspect.Signature.replace` returns a new immutable `Signature`; `inspect.Parameter` lets you add one (`inspect.Parameter("trace", inspect.Parameter.KEYWORD_ONLY, default=False)`) as easily as drop one. Because `__signature__` is checked *before* `__wrapped__`, the reported signature becomes the truth while `__name__`, `__doc__`, `__module__` and `__wrapped__` keep doing their jobs. Note that `__signature__` wins even when a caller passes `follow_wrapped=False`, so there is no back door to the old answer — which is the point. Two worse alternatives worth being able to reject out loud: * **`del wrapper.__wrapped__`.** It does make `inspect.signature` fall back to the wrapper's own `(*args, **kwargs)` — technically not a lie, but useless to a dispatcher, and it breaks `inspect.unwrap` and `inspect.getsource`. * **Dropping `functools.wraps` entirely.** You trade one wrong answer for a wrapper with no name, no docstring and no `__qualname__`, which breaks registries and pickling too. A third option deserves respect: *do not change the contract in a decorator*. A wrapper that alters the parameter list is a small API redesign wearing an `@`, and on a shared pipeline it is often clearer to make `source` an explicit keyword-only parameter with a default than to hide the injection. ### The second, quieter problem: a shared `__dict__` The same team hit a related surprise. `functools.update_wrapper` merges attributes with `wrapper.__dict__.update(func.__dict__)` — an *update*, and a **shallow** one. If a first decorator attaches a mutable attribute to the function (`func.filters = []`) and a second decorator wraps the result, the wrapper's `filters` is the *same list object*, not a copy. Two stages configured by two different developers then mutate one shared list, and because the pipeline configures stages from several threads at import-adjacent startup, the observed state depends on ordering: a race on shared state whose root cause is one `update()` call in the standard library. The defence is to treat function attributes as immutable once set, or to have each decorator rebind rather than mutate — `wrapper.filters = [*getattr(func, "filters", ()), new]` — so no two layers ever hold the same container. ### Scope: runtime only `__signature__` is a *runtime* fact. A static type checker never executes your decorator and never reads the attribute; it reasons from the decorator's own declared parameter and return types. If both audiences matter, the decorator needs honest static typing (a `Callable` or a `Protocol` describing the returned callable) *and* a `__signature__` for the runtime dispatcher. Saying that out loud — that these are two separate mechanisms serving two separate consumers — is what separates a senior answer from a correct one. ### Proving it, and keeping it proved An explicit `__signature__` is hand-written metadata, so it can drift from the wrapper it describes the moment somebody edits the wrapper. Pin it with a test rather than a comment: take `inspect.signature(decorated)`, call `Signature.bind` with the arguments a caller would really pass, and then actually call the function with the same arguments. If the bind succeeds and the call raises `TypeError`, the published signature is lying. That one assertion catches the injected-argument case, the removed-argument case and the renamed-parameter case, and it costs a few lines per decorator. ### What to say when asked Lead with the resolution order, because everything else follows from it: `inspect.signature` checks `__signature__`, then the `__wrapped__` chain, then the object's own code object. `functools.wraps` populates only the middle one, which is correct for a forwarding wrapper and wrong for any wrapper that edits the call contract. Then give the repair - build a `Signature` with `replace` and assign it - and finish with the design point: a decorator that changes the parameter list is an API change in disguise, and re-signing is how you make it honest, not a reason to make more of them.

  • Why not simply delete __wrapped__ from the wrapper instead?
    It technically stops the lie - `inspect.signature` falls back to the wrapper's own `(*args, **kwargs)` - but that is useless to a dispatcher that needs a real parameter list, and it costs you everything else the link buys: `inspect.unwrap` no longer reaches the original, and `inspect.getsource` prints the wrapper body instead of the original `def`. Setting `__signature__` keeps the chain intact and replaces only the part that was wrong.
  • Does an explicit __signature__ help a static type checker understand the decorator?
    No. `__signature__` is a runtime attribute; a static checker never executes the decorator and reasons only from its declared parameter and return types. If both consumers matter, the decorator needs honest static typing - a `Callable` or a `typing.Protocol` describing the returned callable - as well as a `__signature__` for the runtime dispatcher. They are two separate mechanisms serving two separate audiences.
  • Where else does functools.wraps hand you shared state you did not ask for?
    In the `__dict__` merge. `functools.update_wrapper` does `wrapper.__dict__.update(func.__dict__)` - an update, and a shallow one - so a mutable attribute set on the original, say a list of filters, is the *same object* on the wrapper. Two decorators in a stack then mutate one shared container, and the outcome depends on ordering. Rebind rather than mutate: `wrapper.filters = [*getattr(func, "filters", ()), new]`.
  • Is changing the parameter list in a decorator ever the wrong design?
    Often. A wrapper that alters the call contract is a small API redesign wearing an `@`, and it makes every reader check the decorator before they can read the call site. On a shared pipeline it is usually clearer to give the function an explicit keyword-only parameter with a sensible default than to inject a value invisibly. Re-signing is the right repair when the decorator must exist; it is not a licence to reach for one.

saying these in an interview costs you the question

  • Assumes inspect.signature always reports what the wrapper truly accepts
  • Drops functools.wraps entirely just to fix the reported signature
  • Thinks follow_wrapped=False overrides an explicit __signature__
  • Believes setting __signature__ changes runtime argument handling
  • Treats the __dict__ merge as an independent copy of the attributes
  • Assumes a static type checker reads __signature__ at analysis time

context