Why does inspect.signature see through functools.wraps, and when do you call inspect.unwrap?
answer
- The decorator leaves a trail
- One attribute links wrapper to original
- Reflection follows the chain by default
- A keyword argument turns following off
- __wrapped__, follow_wrapped, unwrap
basics
~20 sfunctools.wraps sets wrapped on the wrapper, and inspect.signature follows that chain by default, so a decorated function still reports the inner function's parameters. Pass follow_wrapped=False for the wrapper's own signature; inspect.unwrap walks the chain explicitly.
solid answer
~40 s`functools.wraps` copies the wrapped function's metadata onto the wrapper and, crucially, sets `wrapper.__wrapped__` to the original. `inspect.signature` defaults to `follow_wrapped=True`, so it walks that chain to the innermost callable and reports **its** parameters — which is why a decorated function still advertises `(sensor_id, window=60)` to a documentation tool or an injector instead of the useless `(*args, **kwargs)`. Passing `follow_wrapped=False` gives the wrapper's own signature. `inspect.unwrap(func, stop=...)` walks the same `__wrapped__` chain explicitly and hands back the innermost object, with `stop` as a predicate to halt early; `inspect.getsource` unwraps too, which is why it prints the original `def` including its decorator lines. The danger is a decorator that genuinely changes the call contract — it now advertises a lie, and the fix is to publish a corrected signature by setting `__signature__` on the wrapper.
code
python · 15 linesimport functools, inspect
def retry(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@retry
def read(sensor_id: str, window: int = 60) -> float:
return 0.0
print(inspect.signature(read))
print(inspect.signature(read, follow_wrapped=False))
print(inspect.unwrap(read).__name__)go deeper
Know that decorating a function normally destroys its introspectable name and parameters, and that functools.wraps is the standard remedy applied inside the decorator. Recognising the @functools.wraps(fn) line and what it is for is enough here.
Explain the mechanism rather than the ritual: wraps copies metadata and sets a link attribute on the wrapper, and inspect.signature follows that link by default. Be able to show both signatures of the same decorated function.
Diagnose the failure modes: a decorator that changes the call contract while advertising the old one, reflection reporting the wrong object, and introspection results cached on short-lived wrappers. Know the signature override and when to reach for it.
Set the house rule for decorators crossing reflective frameworks — always wraps, always publish a corrected signature when the contract changes — and decide whether handlers get introspected per call at all or resolved once at registration.
## What functools.wraps actually does A decorator normally returns a `wrapper(*args, **kwargs)` closure. Left alone, that wrapper has the wrong name, the wrong docstring, and a signature of `(*args, **kwargs)` — useless to anything that introspects it. `functools.wraps` fixes this by copying the names in `functools.WRAPPER_ASSIGNMENTS` onto the wrapper (`__module__`, `__name__`, `__qualname__`, `__doc__`, plus the annotation and type-parameter carriers), updating `__dict__`, and — the part that matters here — setting `wrapper.__wrapped__ = wrapped`. That single attribute is the whole mechanism. It turns a decorated callable into a linked list: wrapper → wrapper → original. ## Why signature follows it `inspect.signature(obj)` takes a keyword argument `follow_wrapped`, and its default is `True`. Before doing anything else it walks the `__wrapped__` chain to the end and describes what it finds there. So a function decorated three times still reports the parameters someone declared in the source, which is exactly what documentation generators, CLI builders, dependency injectors and test helpers need. Set `follow_wrapped=False` and you get the wrapper's own truth — typically `(*args, **kwargs)`, sometimes carrying the inner function's return annotation, because `wraps` copied the annotations across but not the parameters. `inspect.getsource` performs the same unwrapping, which is why calling it on a decorated function prints the original `def` *including* the decorator lines above it, rather than the wrapper body inside the decorator. ## inspect.unwrap `inspect.unwrap(func, *, stop=None)` is the chain-walker exposed directly. Call it when you need the underlying object itself, not a description of it: to compare identity, to use it as a stable cache key, to read attributes a decorator did not copy, or to decide whether two decorated handlers are the same function. `stop` is a predicate that receives each link and halts the walk when it returns true — you use it to stop at a particular decorator layer instead of running all the way down. `unwrap` also detects cycles and raises rather than looping forever. ## When the transparency becomes a lie Follow-the-chain is a heuristic about intent: it assumes the wrapper preserves the wrapped function's call contract. Plenty of decorators do not. A decorator that supplies an argument itself, or adds one of its own, changes the contract while `wraps` keeps advertising the old one — so an injector reads the inherited signature, tries to supply the parameter the decorator was going to inject, and the call fails with a duplicate-argument `TypeError` that points at completely the wrong place. The fix is to publish the truth. `inspect.signature` checks for an explicit `__signature__` attribute before it consults `__wrapped__`, so a decorator that changes the contract computes the corrected `Signature` — usually `sig.replace(parameters=[...])` with the consumed parameter removed — and assigns it to `wrapper.__signature__`. That is the difference between a decorator that composes with reflective frameworks and one that quietly breaks them. ## A production shape worth recognising A sensor-telemetry collector dispatches each incoming record through a decorated handler and, because `inspect.signature` is not free, memoises the result in a module-level dict keyed by the callable. At a 1,200-request-per-minute peak the process shows steady unbounded memory growth. The cause is the key: the code builds a fresh wrapper closure per request (a per-request decorator application, or a freshly-created partial), so every request is a cache miss that inserts a new entry, and each entry keeps its callable — plus the closure, the defaults and the annotation objects the `Signature` holds — alive forever. There are two honest fixes and they are both about identity. Key the cache on the *unwrapped* function, which is a stable module-level object, so all the per-request wrappers collapse onto one entry. Or compute the signature once at registration time and store it beside the handler, which removes the per-call introspection entirely. If you must cache by callable, bound the cache and use weak references so entries can be collected. The general lesson is that reflection results are cheap to cache only when your cache key is as long-lived as you think it is. ## What an interviewer is checking That you know reflection is following an attribute, not doing magic; that you can name the attribute; that you know the default is to follow it and how to turn that off; and that you have met the case where following it reports something untrue.
- What is the stop argument to inspect.unwrap for?It is a predicate called with each link in the chain; when it returns true the walk halts and that object is returned. You use it to stop at a specific decorator layer — for example, to unwrap past caching wrappers but stop before an authorisation wrapper whose attributes you need — instead of always descending to the innermost function.
- A decorator adds a parameter of its own. How do you keep reflection honest?Compute the corrected signature in the decorator and assign it to `wrapper.__signature__`. `inspect.signature` consults `__signature__` before it follows `__wrapped__`, so the wrapper advertises the contract it actually implements. Build it with `sig.replace(parameters=[...])`, adding or removing `Parameter` objects as needed.
- Why does inspect.getsource on a decorated function show the decorator lines?Because it unwraps first and then locates the source of the innermost function — and that function's source region in the file starts at its decorator lines. It is showing you the original definition as written, not the wrapper body, which is usually what you want and occasionally surprising.
- How does the wrapper chain interact with caching signature lookups?Cache keys must be as long-lived as you assume. Keying a signature cache on the decorated callable is safe only when that object is created once, at import time; if wrappers are built per call the cache misses every time and grows without bound. Key on the unwrapped function, or compute the signature once at registration.
saying these in an interview costs you the question
- Thinks functools.wraps rewrites the wrapper's parameters
- Cannot name the attribute the chain is built from
- Believes signature always describes the outermost callable
- Assumes a decorator's advertised signature is always truthful
- Says inspect.unwrap mutates or replaces the wrapper
- Caches signatures keyed on freshly created wrapper objects