Decorators
Decorators
A decorator is a function that takes a function and returns a replacement. The boring default is a thin wrapper that logs, times, or checks something, then calls the original. Always apply functools.wraps so the wrapper keeps the original name and docstring.
Mental model
@log
def open_table(n):
...means “define open_table, then set open_table = log(open_table).” The replacement is what callers invoke.
The wrapper should accept *args, **kwargs unless you know the exact signature. It should return whatever the original returns (or raise whatever it raises).
functools.wraps(fn) copies fn’s __name__, __doc__, and a few other attributes onto the wrapper. Without it, traces and help talk about wrapper.
Stacked decorators apply bottom first: @a then @b on f is f = a(b(f)).
Worked examples
Case 1: A function that wraps a function
Save as log_open.py. log prints the name and the arguments, then calls the original.
# log_open.py
def log(fn):
def wrapper(*args, **kwargs):
print(f"call {fn.__name__}{args}")
return fn(*args, **kwargs)
return wrapper
@log
def open_table(n):
return f"opened table {n}"
def main():
print(open_table(12))
if __name__ == "__main__":
main()Run:
uv run python log_open.pyOutput:
call open_table(12,)
opened table 12
That is the whole trick. No metaclass. No import magic.
Case 2: functools.wraps
Save as wrapped_open.py. After @wraps(fn), the public name is still open_table.
# wrapped_open.py
from functools import wraps
def log(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"call {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@log
def open_table(n):
"""Open one table and return a label."""
return f"opened table {n}"
def main():
print(open_table.__name__)
print(open_table.__doc__)
print(open_table(4))
if __name__ == "__main__":
main()Run:
uv run python wrapped_open.pyOutput:
open_table
Open one table and return a label.
call open_table
opened table 4
Keep @wraps even on internal tools. Tests and traces will thank you.
Case 3: Stacked decorators
Save as stacked.py. count_calls runs first (closer to the function). log wraps that result. Both use wraps.
# stacked.py
from functools import wraps
def log(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"log {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
def count_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
wrapper.calls += 1
print(f"count {wrapper.calls}")
return fn(*args, **kwargs)
wrapper.calls = 0
return wrapper
@log
@count_calls
def ping():
return "pong"
def main():
print(ping())
print(ping())
print("name", ping.__name__)
if __name__ == "__main__":
main()Run:
uv run python stacked.pyOutput:
log ping
count 1
pong
log ping
count 2
pong
name ping
Read the source from the bottom: ping is counted, then logged. Swap the two @ lines if you want the other order, and print again.
The trap
Without wraps, the object people import is named wrapper. help, traces, and a registry of __name__ all lie.
Save as bare_wrapper.py:
# bare_wrapper.py
def log(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@log
def open_table(n):
"""Open one table and return a label."""
return f"opened table {n}"
def main():
print(open_table.__name__)
print(open_table.__doc__)
print(open_table(12))
if __name__ == "__main__":
main()Run:
uv run python bare_wrapper.pyOutput:
wrapper
None
opened table 12
The return value still works. The metadata does not. Copy the decorator from wrapped_open.py — @wraps(fn) on the inner function — and __name__ becomes open_table again.
The boring rule
- A decorator is
def deco(fn):plus an innerwrapperplusreturn wrapper. - Put
@wraps(fn)on every wrapper you ship. - Forward with
*args, **kwargsand return the original result. - Stacked
@lines: bottom decorator runs first. - Do not decorate a function to look clever. Decorate when many functions share one extra behaviour (log, retry, require a role).
Try this
- In
log_open.py, makeopen_tabletakenandlabel, and log both. Callopen_table(12, label="patio"). - In
wrapped_open.py, printopen_table.__wrapped__(12)and confirm it skips the log line. - In
stacked.py, swap@logand@count_calls. Run it. Note which line prints first. - Fix
bare_wrapper.pywithfunctools.wrapsso__name__isopen_tableand__doc__is the docstring.