A @functools.wraps decorator changes a function's parameters - how do you keep inspect.signature honest?
answer
- The reported contract no longer matches
- Introspection preferred the wrong source
- One attribute is consulted before the chain
- Build a new Signature and assign it
- Signature.replace with an edited parameter list
basics
~10 sAssign 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 linesimport 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
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.
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.
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.
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