Wraps and Signature Transparency¶
Page Maps¶
graph LR
family["Python Programming"]
program["Python Meta-Programming"]
section["Function Wrappers Transparent Decorators"]
page["Wraps and Signature Transparency"]
capstone["Capstone evidence"]
family --> program --> section --> page
page -.applies in.-> capstone
flowchart LR
orient["Orient on the page map"] --> read["Read the main claim and examples"]
read --> inspect["Inspect the related code, proof, or capstone surface"]
inspect --> verify["Run or review the verification path"]
verify --> apply["Apply the idea back to the module and capstone"]
By the time Module 04 reaches this page, one rule should feel non-negotiable:
if a decorator is meant to stay transparent, it must preserve callable identity and inspection surfaces honestly.
That is why functools.wraps is not a style flourish. It is part of correctness.
This page needs one boundary stated early: wraps preserves important identity and
inspection surfaces, but it does not erase semantic drift. A stateful or policy-owning
wrapper can still be non-transparent even when its metadata is preserved well.
That distinction is one of the easiest places for learners to overclaim. They see a good
__name__, a recovered signature, and a __wrapped__ chain, then conclude the wrapper
is "transparent now." Sometimes that is only half true.
The sentence to keep¶
When reviewing a decorator, ask:
after wrapping, what will tools and reviewers now see when they inspect this callable?
If the answer is "just wrapper(*args, **kwargs) and a lost docstring," the decorator has
already damaged transparency.
If the answer is "the metadata looks fine, but the wrapper still changes retries, execution, or caching policy," then the wrapper has preserved identity better than it has preserved semantics. That distinction matters.
Use this page to keep those two claims separate:
- "tools can still see the logical callable"
- "callers still experience almost the same behavior"
The first claim is about metadata transparency. The second is about semantic
transparency. wraps helps the first claim directly. It does not prove the second.
What bare wrappers lose¶
Without preservation, a wrapped callable often exposes the wrapper's identity instead of the original callable's identity:
__name__becomes"wrapper"__doc__may disappear- annotations may disappear
- signature reporting may degrade to
(*args, **kwargs) - unwrapping tools lose the original function path
That is real breakage for:
inspect.signature- documentation tooling
- debuggers
- stack traces
- reviewers trying to understand what was wrapped
Use this as a quick repair prompt: if a wrapper claims to stay transparent, ask first what tools now see, then ask what runtime behavior has still changed despite that preserved view.
That two-part check is stronger than asking only "did you use wraps?"
functools.wraps restores the important metadata¶
functools.wraps(wrapped) is the standard way to preserve the original callable's visible
identity on the wrapper.
In practice, it copies or updates metadata such as:
__module____name____qualname____doc____annotations____dict__
and, importantly, sets:
__wrapped__
That __wrapped__ chain is what lets inspection tools recover the logical callable under
the wrapper.
For learners who completed Module 03 first, this is the bridge back to signature
evidence: a preserved __wrapped__ path gives inspection tools a better chance to recover
the logical callable contract instead of stopping at a generic forwarding shell.
Bare versus preserved wrapping¶
import functools
import inspect
def bare_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def preserved_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bare_decorator
def bare_func(x):
"""Bare doc."""
return x
@preserved_decorator
def preserved_func(x):
"""Preserved doc."""
return x
print(bare_func.__name__)
print(preserved_func.__name__)
print(inspect.signature(bare_func))
print(inspect.signature(preserved_func))
That contrast is the entire lesson:
- the bare wrapper changes what tools see
- the preserved wrapper keeps the original callable legible
Executable audit: logical contract versus wrapper shell¶
Run:
$ make wrapper-lab |
python3 -c 'import json, sys; print(json.load(sys.stdin)["metadata"])'
{'bare': {'doc': None, 'has_wrapped': False, 'implementation_signature': "(*args: 'Any', **kwargs: 'Any') -> 'ResultT'", 'name': 'wrapped', 'signature': "(*args: 'Any', **kwargs: 'Any') -> 'ResultT'"}, 'preserved': {'doc': 'Render one incident.', 'has_wrapped': True, 'implementation_signature': "(*args, **kwargs) -> 'str'", 'name': 'preserved_handler', 'signature': "(incident_id: 'str', *, severity: 'str' = 'warning') -> 'str'", 'unwrap_is_original': True}}
The preserved record contains two different, simultaneously true signatures:
signatureis the logical callable contract recovered wheninspect.signaturefollows__wrapped__implementation_signatureis the forwarding shell reported byinspect.signature(..., follow_wrapped=False)
functools.wraps does not rewrite the nested function's code parameters. It gives tools
an honest route back to the logical callable. It also copies the original annotations;
because those annotations name incident_id and severity rather than the shell's
args and kwargs, the shell view retains the copied return annotation but shows
unannotated variadic parameters. That slightly odd mixed view is another reason to use
the normal unwrapping route for the logical public contract.
The correct claim is:
the wrapper preserves recoverability of the original contract
not:
the wrapper itself now has the original code shape
The bare wrapper exposes only the shell because it has no __wrapped__ edge to follow.
Its name and docstring are lost for the same reason: no preservation step copied them.
Inspect both layers yourself¶
Use this sequence on preserved_handler inside a local experiment:
inspect.signature(preserved_handler)
inspect.signature(preserved_handler, follow_wrapped=False)
inspect.unwrap(preserved_handler)
preserved_handler.__wrapped__
Then answer:
- Which object receives the call first?
- Which object owns the logical contract?
- Which edge lets tools move between them?
- Which result would documentation tooling normally want?
This separates execution ownership from contract ownership. The wrapper executes first, while the original callable remains the source of the logical signature.
Failure route: copy labels without preserving the edge¶
Manually assign __name__ and __doc__ in the bare decorator but do not set
__wrapped__. The surface looks slightly better, yet signature recovery and
inspect.unwrap still stop at the forwarding shell.
Restore functools.wraps and run make wrapper-lab-test. The focused proof checks the
name, docstring, logical signature, shell signature, __wrapped__, and unwrapping result
together. No single metadata field is sufficient evidence.
It is useful to separate what wraps usually repairs from what it does not:
| Surface or concern | wraps usually helps? |
Why |
|---|---|---|
| name, docstring, annotations, module, qualname | yes | metadata is copied or updated onto the wrapper |
__wrapped__ path for unwrapping tools |
yes | the wrapper gets an explicit pointer to the logical original callable |
| signature recovery through unwrapping | often yes | tools can follow __wrapped__ instead of stopping at *args, **kwargs |
| wrapper shell's code parameters | no | follow_wrapped=False still reveals the forwarding implementation |
| hidden cache, retry, or fallback policy | no | metadata preservation does not undo semantic changes |
| altered return-value or exception behavior | no | transparent labels cannot cancel non-transparent runtime behavior |
Metadata transparency versus semantic transparency¶
Keep this comparison nearby whenever review language starts to blur:
| Question | Metadata transparency | Semantic transparency |
|---|---|---|
| What are you checking? | what tools and readers can still inspect honestly | what runtime behavior still matches the original callable closely |
| Common evidence | __name__, __doc__, __annotations__, __wrapped__, inspect.signature |
result behavior, exception flow, retry rules, cache rules, side effects |
What wraps helps directly |
strongly | not directly |
| Typical false conclusion | "the signature looks right, so the wrapper is safe" | "the behavior is narrow, so metadata does not matter" |
The course needs both columns because production reviews need both columns.
One picture of preserved transparency¶
Without wraps:
wrapped function -> visible as generic wrapper
With wraps:
wrapped function -> visible as original name/doc/annotations
plus __wrapped__ -> original callable for unwrapping and signature recovery
This is why wraps belongs in the definition-time part of the decorator, not as an
optional cleanup later.
That is also why "we can add wraps later" is weak review language. If the wrapper's
public surface is already misleading during development or testing, the damage is already
happening where tools and readers rely on it.
It also explains why a late-added wraps call can repair some inspection damage without
repairing the earlier design mistake that turned the wrapper into hidden policy.
__wrapped__ is especially important¶
The copied name and docstring are helpful. The __wrapped__ chain is what keeps later
inspection honest.
It supports tools that need to:
- recover signatures
- unwrap stacked decorators
- document the original callable
- reason about what was transformed
That makes __wrapped__ a practical inspection surface, not an obscure implementation
detail.
When a decorator stack grows, __wrapped__ is often the difference between:
- a review that can still trace logical ownership
- a review that gets stuck at anonymous forwarding layers
That is why __wrapped__ belongs in your evidence packet whenever the wrapper claims to
stay reviewable.
Custom preservation is possible, but the standard tool should be the default¶
You can write your own metadata-preservation helper. In rare cases, you may need custom control.
But the right default is still:
That default is simple, well understood, and aligned with Python's introspection tools.
If a custom helper is used, the review burden goes up because the team now has to verify that the custom preservation is truly equivalent where it matters.
That makes the default rule simple:
- use
functools.wrapsunless a concrete requirement forces something else - if something else is used, prove which surfaces it preserves and why the standard tool was insufficient
Add one more rule to that default:
- if the wrapper still owns policy, say so explicitly instead of letting preserved metadata imply harmlessness
Signature transparency matters to later modules¶
This page also closes the loop with Module 03:
- signatures are strong runtime evidence
- decorators can damage that evidence
wrapshelps preserve the evidence path by keeping__wrapped__intact
That is why the course teaches functools.wraps before heavier decorator policy. If
transparency is weak here, everything built on inspection later becomes less trustworthy.
Module 03 supplied strong signature evidence. This core shows how a wrapper can preserve the route to that evidence while still introducing a different executable shell. Module 05 can then discuss typed decorator interfaces without pretending runtime metadata alone solves static typing.
Capstone transfer: audit the policy-owning action wrapper¶
Run:
$ make capstone-action-wrapper |
python3 -c 'import json, sys; p=json.load(sys.stdin); w=p["wrapper"]; print({"constructed": p["constructed"], "executed": p["executed"], "depth": w["wrapper_depth"], "visible": w["visible_signature"], "shell": w["chain"][0]})'
{'constructed': False, 'executed': False, 'depth': 1, 'visible': "(self, *, title: 'str', severity: 'str', summary: 'str') -> 'str'", 'shell': {'depth': 0, 'implementation_signature': "(self, *, title: 'str', severity: 'str', summary: 'str') -> 'str'", 'name': 'deliver', 'owns_action_spec': True, 'owns_explicit_signature': True}}
The capstone shell differs deliberately from the course's thin preserved wrapper:
| Surface | Course observing wrapper | Capstone @action wrapper |
|---|---|---|
__wrapped__ |
yes | yes |
explicit __signature__ |
no | yes |
follow_wrapped=False |
reveals *args, **kwargs shell |
reports the deliberately assigned action signature |
| attached policy metadata | none | ActionSpec on the wrapper |
| call-time policy | outcome events only | argument binding and successful-call history |
The audit reports owns_explicit_signature so identical visible and shell strings do not
hide their origin. The outer action wrapper owns the explicit override and action spec;
the original method owns neither.
The route remains observational. A test plugin records constructor and action events, and the audit leaves both lists empty. A separate failure test proves the call-time policy:
- the exact exception object propagates
- a failed action creates no history record
- a successful action records bound arguments and result type
This makes the capstone connection specific. @action preserves metadata, but it is not
semantically thin: it owns binding and history rules at the callable boundary.
Transparency claims that still need caution¶
Even after wraps, a reviewer should still ask:
| Claim | Why caution is still needed |
|---|---|
| "the wrapper is transparent" | preserved metadata does not prove preserved semantics |
| "the signature looks right" | the visible contract may still hide changed retries, caching, or fallback behavior |
| "tools can unwrap it" | unwrapping helps inspection, but it does not remove runtime side effects or state |
Review ladder for transparency claims¶
When someone says "the decorator is transparent," walk up this ladder instead of accepting the sentence at face value:
- did the wrapper preserve callable identity surfaces?
- can tools still unwrap to the logical callable?
- did return-value and exception behavior remain close to the original call?
- did the wrapper introduce cross-call state or control-flow policy anyway?
If the answer reaches step 4, the wrapper may still be inspectable but no longer deserves an unqualified transparency claim.
Common overclaims to reject¶
Reject these sentences when you hear them in review:
| Overclaim | What to say instead |
|---|---|
| "we used wraps, so it is transparent" | "we preserved metadata; now we still need to review behavior" |
| "the signature looks right, so callers are safe" | "the visible contract is better, but runtime policy may still have changed" |
"only tools care about __wrapped__" |
"reviewers and debugging workflows depend on it too" |
| "the wrapper is still small" | "size does not answer whether retries, caching, or fallback semantics changed" |
Review rules for transparency preservation¶
When reviewing a decorator, keep these questions close:
- does the wrapper use
functools.wrapsby default? - what metadata will callers and tools see after wrapping?
- is
__wrapped__preserved so unwrapping and signature recovery still work? - has the decorator changed the callable contract in ways
wrapsalone cannot repair? - if a custom preservation helper exists, is there a real reason not to use the standard tool?
- does the transparency claim apply to metadata only, or to semantics too?
Exit check for this page¶
Before moving on, make sure you can do all of these:
- name at least two inspection surfaces that
wrapspreserves directly - explain one important thing
wrapsdoes not fix - describe why
__wrapped__matters toinspect-based tooling - reject the claim that preserved metadata automatically means thin semantics
What to practice from this page¶
Try these before moving on:
- Compare a bare wrapper with a
functools.wraps-based wrapper using__name__,__doc__, andinspect.signature. - Use
inspect.unwrapor__wrapped__to trace one decorated function back to its original callable. - Write down one reason
functools.wrapsis part of correctness and not only style. - Write down one reason a metadata-transparent wrapper can still be semantically non-transparent.
If those feel ordinary, the worked example can stress-test transparency and state together inside a deliberately limited cache decorator.
Continue through Module 04¶
- Previous: Stateful Wrappers and Semantic Drift
- Next: Worked Example: Building a Bounded Cache Decorator
- Practice: Exercises
- Terms: Glossary