How does inspect.signature see past a @functools.wraps wrapper to the original parameters?
answer
- Introspection is following a link
- The wrapper stores a pointer to the original
- A dunder attribute set by update_wrapper
- A keyword argument turns the following off
- inspect.unwrap walks the whole chain
basics
~10 sfunctools.wraps sets wrapper.wrapped to the original callable, and inspect.signature follows that link by default. Pass follow_wrapped=False to see the wrapper's real (*args, **kwargs) parameters, or walk the chain yourself with inspect.unwrap.
solid answer
~40 s`functools.update_wrapper` - the machinery behind `@functools.wraps` - finishes by setting `wrapper.__wrapped__ = func`. `inspect.signature` then resolves in a fixed order: an explicit `__signature__` attribute first, otherwise it follows `__wrapped__` to the end of the chain and builds the signature from the callable it lands on. That is why a decorated function reports `(line, strict=False)` rather than `(*args, **kwargs)` - nothing about the wrapper's own parameters changed. Stacked decorators build a chain of links, and `inspect.unwrap` walks it, raising `ValueError` on a loop. Call `inspect.signature(func, follow_wrapped=False)` to see what the wrapper itself declares. `inspect.getsource` unwraps too, which is why it prints the original `def`; `inspect.getfullargspec` does not, and still reports `*args, **kwargs`.
code
python · 22 linesimport functools
import inspect
def deco(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@deco
def parse(line: str, strict: bool = False) -> tuple[str, bool]:
"""Parse one log line."""
return line, strict
print(inspect.signature(parse))
# (line: str, strict: bool = False) -> tuple[str, bool]
print(inspect.signature(parse, follow_wrapped=False))
# (*args, **kwargs) -> tuple[str, bool]
print(inspect.unwrap(parse) is parse.__wrapped__)
# True
print(inspect.getfullargspec(parse).varargs)
# argsgo deeper
Recall that a decorated function can still report its original parameters, and that this works because functools.wraps stored a reference to the original on the wrapper. Knowing the attribute is called wrapped is enough here.
An interviewer expects the mechanics: update_wrapper sets wrapped, inspect.signature checks signature first and otherwise walks the chain, and follow_wrapped=False stops it. Be able to say that the wrapper's own parameters never changed.
Demonstrate that you have debugged this: name which inspect helpers follow the chain and which do not, explain how one naive layer in a decorator stack truncates it, and describe the loop ValueError from inspect.unwrap.
The tradeoff to own is why CPython stores a reference rather than an eagerly computed Signature - cost, mutability and a walkable chain - and what that costs you when a framework in your stack introspects callables to build its wiring.
### The link, not a rewrite The single fact that explains all of this: `functools.update_wrapper` — the machinery that `@functools.wraps(func)` invokes — finishes by executing `wrapper.__wrapped__ = func`. That attribute is a plain reference from the wrapper back to the callable it wraps. Nothing about the wrapper's own compiled parameters changes; the wrapper still accepts `*args, **kwargs` and always will. `inspect.signature` then resolves a callable in a defined order: 1. If the object has an explicit **`__signature__`** attribute holding an `inspect.Signature`, that wins outright — even when `follow_wrapped=False` is passed. 2. Otherwise, unless `follow_wrapped=False`, it walks the **`__wrapped__` chain** to its end. 3. It then builds a `Signature` from that final callable's code object and annotations. So the "magic" is a pointer traversal. A `@functools.wraps`-decorated function reports `(line: str, strict: bool = False)` because `inspect.signature` never looked at the wrapper at all. ### Seeing the wrapper's real parameters `inspect.signature(decorated, follow_wrapped=False)` stops the traversal and reports what the wrapper itself declares — typically `(*args, **kwargs)`. One wrinkle on 3.14: because `functools.WRAPPER_ASSIGNMENTS` copies `__annotate__`, the *return* annotation of the original comes along for the ride, so you may see `(*args, **kwargs) -> tuple[str, bool]`. The parameters are the wrapper's; the annotations were copied. `inspect.unwrap(obj)` walks the same chain and returns the object at the end. It takes an optional `stop` predicate to halt part-way, and it raises `ValueError` — `"wrapper loop when unwrapping ..."` — if it detects a cycle, which is why two decorators that accidentally point `__wrapped__` at each other fail loudly rather than hanging. ### Stacked decorators build a chain, not a single hop `@a` above `@b` above `def f` produces `a(b(f))`, and each layer that used `functools.wraps` set its own `__wrapped__`. The result is a linked list: `decorated.__wrapped__.__wrapped__ is f`. `inspect.signature` and `inspect.unwrap` both follow it to the end, so the reported parameters are the innermost function's regardless of stack depth — as long as *every* layer used `functools.wraps`. A single naive layer in the middle breaks the chain, and introspection stops at that layer's `(*args, **kwargs)`. ### Which other helpers follow the chain — and which do not This is the part candidates get wrong, because the behaviour is not uniform across `inspect`: * **Follows `__wrapped__`:** `inspect.signature`, `inspect.unwrap`, and the source-location helpers `inspect.getsource`, `inspect.getsourcefile` and `inspect.getfile`. That is why `inspect.getsource` on a decorated function prints the *original* `def` — including its decorator lines — rather than the three-line wrapper body. * **Does not follow it:** `inspect.getfullargspec`, which reports the wrapper's literal `args=[], varargs='args', varkw='kwargs'`. If you have legacy code introspecting callables with `getfullargspec`, decoration changes its answer even with `functools.wraps` applied. Prefer `inspect.signature`. `__wrapped__` is also just an attribute, so anything can set it — and a few libraries set it without running `update_wrapper` at all, purely to make introspection resolve. ### Why this design instead of copying the signature CPython could have had `update_wrapper` compute and store a `Signature` eagerly. Storing a reference instead is cheaper (no `Signature` object built at import time for decorators that are never introspected), it survives later mutation of the original, and it preserves a *chain* so a tool can choose how far to unwrap. The trade-off is the one that bites: what `inspect.signature` reports is the original's contract, which is correct only while the wrapper forwards arguments unchanged. A wrapper that injects, removes or renames a parameter makes the reported signature a lie — and the remedy is an explicit `__signature__`, which is exactly why step 1 of the resolution order exists. ### What to say in an interview State it as three sentences: `functools.wraps` sets `__wrapped__`; `inspect.signature` prefers an explicit `__signature__` and otherwise follows `__wrapped__` to the end of the chain; `follow_wrapped=False` or a direct look at the wrapper's code object shows the `*args, **kwargs` the wrapper really has. Then mention that `getfullargspec` does not follow the chain — it is the detail that shows you have actually debugged this rather than read about it. ### A debugging recipe worth keeping When a framework or dispatcher reports parameters that do not match reality, three lines localise the problem. Print `inspect.signature(obj)`, print `inspect.signature(obj, follow_wrapped=False)`, and print `inspect.unwrap(obj)`. If the first two agree, no chain is involved and the object really does declare those parameters. If they differ, you are looking through a wrapper, and the third line names exactly which callable the report came from - which is usually enough to find the decorator responsible. Walking `__wrapped__` in a loop, as the second example does, gives you the whole stack when several layers are involved. ### The related trap: a truncated chain Because every layer must cooperate, a single decorator in the middle of a stack that forgot `functools.wraps` cuts the chain at that point. Introspection then reports that layer's `(*args, **kwargs)`, and it does so silently - there is no warning, and the outer layers look innocent. If a decorated function suddenly reports bare varargs, the fault is almost never the outermost decorator; it is the first one, working inwards, that failed to wrap.
- How do you inspect what the wrapper itself accepts rather than the original?Pass `follow_wrapped=False`: `inspect.signature(decorated, follow_wrapped=False)` stops the traversal and reports the wrapper's own declaration, usually `(*args, **kwargs)`. Two caveats. An explicit `__signature__` attribute still wins even with `follow_wrapped=False`, because that check comes first. And on 3.14 the original's return annotation rides along, since `functools.WRAPPER_ASSIGNMENTS` copies `__annotate__` - so you may see `(*args, **kwargs) -> tuple[str, bool]`.
- What happens if two decorators end up pointing __wrapped__ at each other?`inspect.unwrap` detects it rather than spinning forever: it keeps a memo of objects it has seen and raises `ValueError` with a message of the form `wrapper loop when unwrapping <function ...>`. `inspect.signature` fails the same way, since it uses the same traversal. It is a rare failure, and it usually means a decorator assigned `__wrapped__` by hand instead of letting `functools.update_wrapper` do it.
- Which other inspect helpers follow the __wrapped__ chain, and which ignore it?`inspect.signature` and `inspect.unwrap` follow it, and so do the source-location helpers `inspect.getsource`, `inspect.getsourcefile` and `inspect.getfile` - which is why `inspect.getsource` on a decorated function prints the original `def` together with its decorator lines rather than the wrapper's body. `inspect.getfullargspec` does not follow it and reports the wrapper's literal `varargs='args'`, `varkw='kwargs'`. That asymmetry is a good reason to prefer `inspect.signature` in new code.
saying these in an interview costs you the question
- Thinks functools.wraps rewrites the wrapper's parameter list
- Believes inspect.signature reads the wrapper's code object directly
- Cannot name the attribute linking the wrapper to the original
- Assumes follow_wrapped=False also overrides an explicit __signature__
- Confuses inspect.unwrap with actually calling the wrapped function
- Expects inspect.getfullargspec to follow the chain like signature does