Nested Functions and Wrapper Skeletons¶
Page Maps¶
graph LR
family["Python Programming"]
program["Python Meta-Programming"]
section["Function Wrappers Transparent Decorators"]
page["Nested Functions and Wrapper Skeletons"]
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"]
Module 04 becomes much easier to trust once decorators stop looking like syntax magic and start looking like ordinary higher-order functions.
The first sentence to make ordinary is:
a decorator is fundamentally a callable that takes a function and returns another callable.
That returned callable is usually built with a nested function that closes over the original function and any wrapper state.
That is the first mental reset for learners who have only seen decorators as syntax:
before @name ever enters the picture, there is already a plain runtime object story
happening. One function receives another function, builds a nested callable, and returns
it. If that mechanical story is not clear yet, every later page in the module will feel
more magical than it really is.
The sentence to keep¶
When you meet a decorator, ask:
what is the original function, what nested wrapper was built around it, and what state does that wrapper close over?
That question keeps the mechanics visible before policy or style enters.
It also prevents one common beginner mistake: describing the wrapper as if it were a special decorator-only mechanism instead of an ordinary closure-backed function.
The wrapper skeleton¶
At the most basic level, a decorator looks like this:
This is not yet useful, but it shows the whole structure:
decoratorreceives the original functionwrapperis nested inside itwrapperdelegates to the original functiondecoratorreturns the wrapper
That is the entire mechanical basis of function-wrapping decorators.
It is worth stating what this skeleton does not prove yet:
- it does not preserve the visible signature
- it does not preserve metadata automatically
- it does not prove the wrapper is thin
- it does not prove the wrapper is safe to stack with other behavior
Closures make the wrapper remember the original function¶
The nested wrapper keeps access to func through closure semantics:
funclives in the outer scopewrappercloses over it- later calls to
wrapperstill reach the original function through that closure
That is why no global variable is required. The wrapper carries the original callable with it as runtime state.
For self-study, this is the point where you should be able to answer a concrete question:
if the original function name is later rebound, what reference still lets the wrapper call the original behavior?
If the answer is not "the closed-over func reference," pause here before moving on.
Inspect the shipped closure instead of imagining it¶
Run the Module 04 program and isolate the lifecycle packet:
$ make wrapper-lab |
python3 -c 'import json, sys; print(json.load(sys.stdin)["lifecycle"])'
{'call_events': ['enter:outer', 'enter:inner', 'body:INC-42:critical', 'exit:inner', 'exit:outer'], 'definition_events': ['factory:outer', 'factory:inner', 'decorate:inner:render_status', 'decorate:outer:render_status'], 'outer_wrapper_closure': ['events', 'function', 'label'], 'result': 'INC-42:critical', 'visible_signature': "(incident_id: 'str', *, severity: 'str' = 'warning') -> 'str'", 'wrapper_depth': 2}
The outer_wrapper_closure field comes from
inspect.getclosurevars(render_status).nonlocals. It reports three names owned by that
wrapper:
function: the already-wrapped callable it delegates tolabel: definition-time configuration for this layerevents: the observation sink shared by the trace
That is stronger than saying the wrapper "remembers things." It identifies the actual
runtime ownership. The outer wrapper does not reach the raw function directly; it closes
over the inner wrapper. Following __wrapped__ twice reaches the raw
render_status, which is why the packet reports a wrapper depth of two.
Trace the object graph¶
Read tracing_decorator in labs/wrapper_runtime/evidence.py, then draw:
name render_status
-> outer wrapped function
closure function -> inner wrapped function
closure function -> raw render_status function
For each node, record:
- when the function object was created
- which function it will call
- which nonlocal values it owns
- which public signature
inspect.signaturereports
This is the smallest useful wrapper review. It connects closure mechanics, wrapper stacking, and inspection without requiring decorator folklore.
Failure route: delegate through the rebound name¶
Temporarily replace function(*args, **kwargs) inside the tracing wrapper with
render_status(*args, **kwargs). The name points at the outer wrapper after decoration,
so the call recurses through the wrong edge.
Do not keep that edit. Restore delegation through the closed-over function and run:
The repair is architectural, not stylistic: a wrapper owns the callable passed into its decorator. It should not rediscover that callable through a name whose binding decoration has already changed.
One picture of the structure¶
graph TD
decorator["decorator(func)"]
func["original function"]
wrapper["nested wrapper(*args, **kwargs)"]
closure["closure remembers func and wrapper state"]
decorator --> func
decorator --> wrapper --> closure
Caption: the wrapper is not magic replacement code; it is an ordinary function carrying a closed-over reference to the original callable.
A simple example¶
def simple_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
def greet(name):
return f"Hello, {name}!"
greet_wrapped = simple_decorator(greet)
print(greet_wrapped("Alice"))
That example already teaches several important things:
- the wrapper can do work before delegation
- the original return value can still flow through
- the wrapped callable is just another function object
It also teaches an important review limit:
- printing a message before delegation does not make the wrapper transparent by itself
- the wrapper is still only a mechanical shell until later pages inspect timing, metadata, and policy drift
State can live in the closure too¶
Wrappers do not only remember the original function. They can also remember wrapper-local state.
def counter_decorator(func):
count = 0
def wrapper(*args, **kwargs):
nonlocal count
count += 1
print(f"Call {count} to {func.__name__}")
return func(*args, **kwargs)
return wrapper
This is one of the first moments where wrapper design starts changing semantics:
- the wrapper is no longer only forwarding
- the wrapper now owns state across calls
That is why later pages in this module will distinguish thin wrappers from stateful policy-carrying wrappers.
Use the counter example as a boundary test:
| Question | Thin forwarding answer | Stateful wrapper answer |
|---|---|---|
| does this wrapper remember earlier calls? | no | yes |
| can the same call behave differently later? | usually no | yes, because state has changed |
| does review now need reset or inspection surfaces? | often no | often yes |
Returning the wrapper is not optional¶
One of the simplest decorator bugs is forgetting to return the nested function:
def broken_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# forgot: return wrapper
If that function is used as a decorator, the original function name gets rebound to
None, and the next call fails immediately.
This is a good reminder that decoration is ordinary rebinding. If the decorator returns the wrong thing, the name now points at the wrong thing.
That is one of the cleanest debugging routes in this module: if a decorated function
suddenly becomes None or a non-callable object, look at the decorator's return path
before looking for something more exotic.
Delegation should target the closed-over original function¶
Another common bug is accidentally calling a global name that may already have been rebound instead of calling the closed-over original function.
The safe mental model is:
- the wrapper should delegate to the
funcit closed over - not to a global name that could now refer to the wrapper itself or another layer
That rule prevents recursion mistakes and keeps the wrapper path explicit.
Common wrapper-skeleton failure modes¶
These bugs appear before any "advanced" decorator design enters:
| Failure mode | What actually went wrong | Review repair |
|---|---|---|
forgot return wrapper |
the decorator returned None instead of a callable |
trace the rebinding result directly |
| delegated to the global function name | the wrapper stopped trusting the closed-over callable | call the captured func instead |
mutated outer state without nonlocal |
Python treated the name as a new local binding | make the state boundary explicit with nonlocal |
| assumed the skeleton preserves transparency | forwarding was mistaken for identity preservation | postpone the transparency claim until functools.wraps and signature review |
*args, **kwargs are a forwarding convenience, not a full story¶
Most simple wrappers start with:
That is fine as a forwarding skeleton, but it does not preserve the visible signature by itself. Later in the module and the next module, that distinction matters a lot.
For now, the important point is mechanical:
*args, **kwargslet the wrapper accept arbitrary calls- signature transparency is a separate concern
That distinction matters because many learners incorrectly conclude:
"the wrapper can accept any call, so tooling will still understand the original callable."
That is false. Accepting calls and preserving the visible callable contract are separate problems.
Let exceptions propagate unless the wrapper advertises a change¶
Thin wrappers should normally preserve the original error behavior:
- if the original function raises, the wrapper should usually let it raise
- catching or rewriting errors changes semantics and review cost
This is a useful early wrapper rule: if you change exception behavior, that is no longer "just logging" or "just timing." It becomes policy.
That one sentence is worth carrying into every later decorator review:
- observation around failure can stay thin
- ownership of failure behavior is already policy
Review rules for wrapper skeletons¶
When reviewing a decorator's basic structure, keep these questions close:
- what original callable is being closed over?
- what wrapper-local state is being captured?
- does the wrapper delegate to the closed-over function or to a rebinding-prone name?
- does the decorator actually return a callable wrapper?
- is the wrapper preserving exception behavior unless it explicitly documents a change?
Exit check for this page¶
Before you leave this page, make sure you can do all of these without guessing:
- point to the exact closed-over reference the wrapper will call later
- explain why forgetting
return wrapperis a rebinding bug rather than a syntax bug - distinguish closure-held state from global state
- say why the wrapper skeleton alone is not yet a transparency claim
What to practice from this page¶
Try these before moving on:
- Write one bare logging decorator without
@syntax yet. - Add closure-held state with a counter and explain why
nonlocalis required. - Break a decorator by forgetting
return wrapper, then explain what name rebinding went wrong.
If those feel ordinary, the next step is to make the rebinding explicit through
@decorator syntax and stacked decoration.
Continue through Module 04¶
- Previous: Overview
- Next: Decorator Syntax and Definition-Time Rebinding
- Practice: Exercises
- Terms: Glossary