Decorators - Wrapping Callables at Engineering Depth
Reading time: ~35 minutes | Level: Intermediate → Engineering
Before reading further, predict every output:
def decorator(func):
def wrapper(*args, **kwargs):
print("before")
result = func(*args, **kwargs)
print("after")
return result
return wrapper
@decorator
def greet(name):
return f"Hello, {name}"
print(greet.__name__) # ?
print(greet("Alice")) # ?
Show Answer
Output:
wrapper
before
after
Hello, Alice
greet.__name__ is "wrapper", not "greet". The @decorator syntax replaces greet with wrapper. The wrapper function has its own __name__ attribute set to "wrapper" - the original function's metadata is lost.
greet("Alice") prints "before", then "after", and the return value "Hello, Alice" is printed by the outer print().
This metadata loss is not cosmetic. It breaks help(greet), inspect.signature(greet), logging that uses func.__name__, and test frameworks that report by function name. The fix is functools.wraps - and in production code it is non-optional.
Now consider: @app.get("/") in FastAPI, @pytest.fixture, @property, @classmethod, @lru_cache - these are all decorators. Understanding decorators at this depth means understanding a fundamental Python design pattern that appears in virtually every non-trivial Python codebase.
What You Will Learn
- What a decorator is: a callable that takes a callable and returns a callable
- The
@syntax desugared and why it is just assignment functools.wraps- why it is non-optional in production code- Decorators with arguments: the three-level nesting pattern (decorator factory)
- Class-based decorators:
__init__+__call__, when to prefer them - Stacking decorators: application order and call order
- Production patterns: timing, retry with exponential backoff, caching, rate limiting
- How FastAPI and Flask route decorators work under the hood
- Import-time execution: why decorator side effects run once per module load
Prerequisites
- Lesson 03: Generators and
yield- generator functions return iterators - Lesson 04: Iterator Protocol - closures and enclosing scope familiarity
- Comfortable writing classes with
__init__and__call__
Part 1 - What a Decorator Is
Functions as First-Class Objects
Before decorators, understand the foundation: in Python, functions are objects. They can be passed as arguments, returned from functions, and assigned to variables:
def add(a, b):
return a + b
# Assign to a variable
operation = add
print(operation(2, 3)) # 5
# Pass as an argument
def apply(func, x, y):
return func(x, y)
print(apply(add, 10, 20)) # 30
# Return from a function
def make_adder(n):
def adder(x):
return x + n
return adder # returning a function
add5 = make_adder(5)
print(add5(10)) # 15
A decorator is a function that takes a callable, wraps it, and returns a new callable:
def my_decorator(func):
def wrapper(*args, **kwargs):
# Do something before
result = func(*args, **kwargs)
# Do something after
return result
return wrapper
The @ Syntax Desugared
The @ syntax is pure syntactic sugar:
@my_decorator
def greet(name):
return f"Hello, {name}"
is exactly equivalent to:
def greet(name):
return f"Hello, {name}"
greet = my_decorator(greet) # greet is now wrapper
The decorator runs once at definition time (when the @ line executes, not when greet() is called). After decoration, greet refers to the wrapper function returned by my_decorator.
Part 2 - functools.wraps - Non-Optional in Production
The Problem
Without functools.wraps, the wrapper replaces the original function's metadata:
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def calculate(x: int, y: int) -> int:
"""Adds two integers and returns the result."""
return x + y
print(calculate.__name__) # 'wrapper' ← wrong
print(calculate.__doc__) # None ← docstring lost
import inspect
print(inspect.signature(calculate)) # (*args, **kwargs) ← wrong signature!
This breaks:
help(calculate)- shows wrapper's empty docstringinspect.signature(calculate)- shows*args, **kwargsinstead of the real signature- Logging using
func.__name__- logs"wrapper"not"calculate" - Test frameworks - may report by function name
- FastAPI/Flask - use
__name__to detect duplicate route names
The Fix: functools.wraps
import functools
def my_decorator(func):
@functools.wraps(func) # copies __name__, __doc__, __annotations__, __dict__, __module__, __qualname__
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def calculate(x: int, y: int) -> int:
"""Adds two integers and returns the result."""
return x + y
print(calculate.__name__) # 'calculate' ← preserved
print(calculate.__doc__) # 'Adds two...' ← preserved
print(calculate.__wrapped__) # <function calculate> ← added by wraps
import inspect
print(inspect.signature(calculate)) # (x: int, y: int) -> int ← preserved
functools.wraps also adds __wrapped__, pointing to the original unwrapped function. This lets inspect.unwrap() peel back decoration layers.
Decoration Time vs Call Time
What functools.wraps Preserves
:::warning Forgetting functools.wraps Breaks More Than You Think
The consequences of omitting functools.wraps are not just cosmetic. In real production systems:
- FastAPI uses
__name__to register routes; two decorated endpoints with the same__name__="wrapper"cause a conflict error pytestuses__name__in test discovery and failure reportingloggingusingfunc.__name__logs"wrapper"for every decorated function - log analysis becomes impossiblesphinxdocumentation generation uses__doc__- no docstring means no docsinspect.signature()returns(*args, **kwargs)instead of real parameters - breaks IDE tooling and runtime argument validation
Always use functools.wraps. No exceptions.
:::
:::tip Always Use *args, **kwargs in Wrapper to Preserve Flexibility
The wrapper should pass through all arguments unchanged. Using *args, **kwargs ensures the wrapper works for any function regardless of its signature:
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs): # catch-all: works for any signature
return func(*args, **kwargs)
return wrapper
@my_decorator
def f(a, b, c=10, *, keyword_only=False):
...
# Works with any combination of arguments
f(1, 2)
f(1, 2, c=30)
f(1, b=2, keyword_only=True)
If you hardcode specific parameters in the wrapper, the decorator only works for functions with that exact signature. :::
Part 3 - Decorators with Arguments (Decorator Factory)
The Three-Level Nesting Pattern
A plain decorator is called once with the function as its argument. A decorator with arguments requires an extra level: the outermost function receives the arguments and returns the actual decorator:
import functools
# Level 1: factory - called with decorator arguments
def repeat(times: int):
# Level 2: actual decorator - called with the function
def decorator(func):
# Level 3: wrapper - called when the function is invoked
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def say(message: str) -> None:
print(message)
say("hello")
# hello
# hello
# hello
The desugaring makes the three levels explicit:
# @repeat(times=3) def say: is exactly:
say = repeat(times=3)(say)
# Step 1: repeat(times=3) → returns decorator (Level 2 function)
# Step 2: decorator(say) → returns wrapper (Level 3 function)
# Step 3: say = wrapper
Production: Retry with Exponential Backoff
import functools
import time
import random
import logging
logger = logging.getLogger(__name__)
def retry(
max_attempts: int = 3,
exceptions: tuple = (Exception,),
base_delay: float = 0.5,
backoff_factor: float = 2.0,
jitter: bool = True,
):
"""
Retry a function on failure with exponential backoff.
Args:
max_attempts: Maximum number of attempts (including the first call)
exceptions: Tuple of exception types that trigger a retry
base_delay: Initial delay in seconds before the first retry
backoff_factor: Multiply delay by this factor on each retry
jitter: Add random jitter to prevent thundering herd
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
delay = base_delay
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt == max_attempts:
logger.error(
f"{func.__name__} failed after {max_attempts} attempts: {e}"
)
raise
sleep_time = delay
if jitter:
sleep_time += random.uniform(0, delay * 0.1)
logger.warning(
f"{func.__name__} attempt {attempt}/{max_attempts} failed: {e}. "
f"Retrying in {sleep_time:.2f}s"
)
time.sleep(sleep_time)
delay *= backoff_factor
return wrapper
return decorator
@retry(max_attempts=4, exceptions=(ConnectionError, TimeoutError), base_delay=0.1)
def fetch_data(url: str) -> dict:
"""Fetches data from a URL. Retries on connection errors with backoff."""
if random.random() < 0.5:
raise ConnectionError(f"Could not connect to {url}")
return {"url": url, "data": "..."}
Part 4 - Class-Based Decorators
__init__ + __call__
A decorator can also be a class that stores state in __init__ and acts as a callable via __call__:
import functools
import time
class TimingDecorator:
"""
A class-based decorator that measures and records execution time.
Exposes call history as a public attribute for inspection.
"""
def __init__(self, func):
functools.update_wrapper(self, func) # equivalent to @functools.wraps
self._func = func
self.call_count = 0
self.total_time = 0.0
self.timings: list[float] = []
def __call__(self, *args, **kwargs):
start = time.perf_counter()
result = self._func(*args, **kwargs)
elapsed = time.perf_counter() - start
self.call_count += 1
self.total_time += elapsed
self.timings.append(elapsed)
return result
@property
def average_time(self) -> float:
if not self.timings:
return 0.0
return self.total_time / self.call_count
def reset(self) -> None:
self.call_count = 0
self.total_time = 0.0
self.timings.clear()
def __repr__(self) -> str:
return (
f"TimingDecorator({self._func.__name__!r}, "
f"calls={self.call_count}, avg={self.average_time:.4f}s)"
)
@TimingDecorator
def compute(n: int) -> int:
"""Sum 0..n-1."""
return sum(range(n))
compute(10_000)
compute(100_000)
compute(1_000_000)
print(compute.call_count) # 3
print(f"{compute.average_time:.6f}s")
print(repr(compute))
# TimingDecorator('compute', calls=3, avg=0.012000s)
When to Use Class-Based vs Function-Based
Use a class-based decorator when:
- You need to expose public methods or properties (like
compute.call_count,compute.reset()) - You need
__repr__for debugging the decorated function itself - You want the decorator to be subclassable or overridable
- The state management grows complex enough to benefit from a class
Use a function-based decorator for everything else - they are simpler and require less boilerplate.
Part 5 - Stacking Decorators
Application Order: Bottom-Up; Call Order: Top-Down
When you stack decorators, they apply from bottom to top (innermost first) at decoration time. At call time, execution flows top-down through the wrapper stack:
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("bold: start")
result = func(*args, **kwargs)
print("bold: end")
return result
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("italic: start")
result = func(*args, **kwargs)
print("italic: end")
return result
return wrapper
@bold
@italic
def render(text):
print(f" render: {text}")
return text
render("hello")
Output:
bold: start
italic: start
render: hello
italic: end
bold: end
The desugaring:
# @bold @italic def render:
# Step 1 (bottom): render = italic(render) → render is italic's wrapper
# Step 2 (top): render = bold(render) → render is bold's wrapper, calling italic's wrapper
Stacking Order Matters
Order determines which logic runs first. In authentication + rate limiting:
@require_auth # outermost: auth check runs first - reject unauthenticated before rate check
@rate_limit(100) # innermost: rate check only runs for authenticated users
def get_resource():
...
# vs.
@rate_limit(100) # outermost: counts unauthenticated requests against rate limit
@require_auth # innermost: runs only after rate limit passes
def get_resource():
...
Real-World Stacking in FastAPI
@app.get("/users/{user_id}") # outermost: route registration
@require_auth # middle: authentication check
@rate_limit(max_calls=100) # innermost: rate limiting (runs just before the handler)
async def get_user(user_id: int):
...
# Decoration order (bottom-up):
# 1. rate_limit wraps get_user
# 2. require_auth wraps the rate-limited version
# 3. app.get registers the fully-wrapped function and returns it unchanged
# Call order (top-down):
# app.get dispatches → require_auth → rate_limit → get_user
Part 6 - Production Decorator Patterns
Timing Decorator with Threshold Warning
import functools
import time
import logging
logger = logging.getLogger(__name__)
def timed(func=None, *, threshold_ms: float = 0.0):
"""
Log execution time. Optionally warn when execution exceeds threshold_ms.
Supports both @timed and @timed(threshold_ms=100) usage.
"""
# Handle both @timed and @timed(threshold_ms=100)
if func is None:
return functools.partial(timed, threshold_ms=threshold_ms)
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = func(*args, **kwargs)
return result
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
if threshold_ms > 0 and elapsed_ms > threshold_ms:
logger.warning(
f"{func.__name__} took {elapsed_ms:.1f}ms "
f"(threshold: {threshold_ms}ms)"
)
else:
logger.debug(f"{func.__name__} took {elapsed_ms:.1f}ms")
return wrapper
@timed
def fast_operation():
return sum(range(1_000))
@timed(threshold_ms=50)
def slow_operation():
time.sleep(0.1)
return "done"
Memoization Cache
import functools
def memoize(func):
"""Cache results of a function based on its arguments."""
cache: dict = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
wrapper.cache = cache # expose for inspection
wrapper.cache_clear = cache.clear
return wrapper
@memoize
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(35)) # fast - no repeated computation
print(len(fibonacci.cache)) # 36 cached values
fibonacci.cache_clear()
For production use, prefer functools.lru_cache or functools.cache (Python 3.9+), which handle thread safety and edge cases.
Rate Limiter
import functools
import time
from collections import deque
def rate_limit(max_calls: int, period: float = 1.0):
"""Limit a function to max_calls calls within period seconds."""
def decorator(func):
call_times: deque = deque()
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.monotonic()
# Remove calls outside the sliding window
while call_times and now - call_times[0] >= period:
call_times.popleft()
if len(call_times) >= max_calls:
raise RuntimeError(
f"{func.__name__}: rate limit exceeded "
f"({max_calls} calls per {period}s)"
)
call_times.append(now)
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(max_calls=5, period=1.0)
def api_call(endpoint: str) -> str:
return f"Response from {endpoint}"
Part 7 - How Framework Decorators Work
FastAPI and Flask Route Decorators Under the Hood
Route decorators like @app.get("/") register functions in a routing table - they add side effects at decoration time and typically return the function unchanged. Here is a stripped-down implementation showing exactly what happens:
import functools
from typing import Callable
class MiniApp:
"""A stripped-down router demonstrating how @app.get works."""
def __init__(self):
self._routes: dict[tuple[str, str], Callable] = {}
def get(self, path: str):
"""Decorator factory - returns a decorator that registers the route."""
def decorator(func: Callable) -> Callable:
route_key = ("GET", path)
if route_key in self._routes:
raise ValueError(
f"Duplicate route GET {path}. "
f"Existing: {self._routes[route_key].__name__}, "
f"New: {func.__name__}"
# Without functools.wraps, func.__name__ would be
# 'wrapper' for all decorated handlers - causing false conflicts!
)
# Side effect: register in routing table at decoration time
self._routes[route_key] = func
# Return function unchanged (or lightly wrapped)
return func
return decorator
def dispatch(self, method: str, path: str, **kwargs):
handler = self._routes.get((method, path))
if handler is None:
return {"error": 404, "message": "Not Found"}
return handler(**kwargs)
app = MiniApp()
@app.get("/users")
def list_users():
return {"users": ["Alice", "Bob"]}
@app.get("/items/{item_id}")
def get_item(item_id: int):
return {"item_id": item_id}
print(app.dispatch("GET", "/users"))
# {'users': ['Alice', 'Bob']}
print(app.dispatch("GET", "/items/{item_id}", item_id=42))
# {'item_id': 42}
:::danger Decorators Run at Import Time - Side Effects Execute Once Per Module Load
A decorator's outer code runs when the @ line is processed - which happens when the module is imported. This has critical implications:
import time
def log_registration(func):
# This print runs at import time, not at call time
print(f"Registering: {func.__name__} at {time.time()}")
return func
@log_registration # runs when this module is first imported
def handle_request():
return "ok"
# Consequences:
# - print() in decorator → executes during test collection, slowing test startup
# - Database connections opened in decorator → open before config is loaded
# - Heavy computation in decorator → slows every import of the module
Keep decorator body logic minimal. Move expensive work inside the wrapper so it runs only at call time.
:::
:::note @property, @classmethod, @staticmethod Are All Decorators
The built-in descriptor decorators are standard decorators implemented in C. They follow the same @name desugaring:
class MyClass:
@property # is: value = property(value)
def value(self): ...
@classmethod # is: create = classmethod(create)
def create(cls): ...
@staticmethod # is: helper = staticmethod(helper)
def helper(): ...
Understanding decorators means understanding how these work - property, classmethod, and staticmethod are callable objects whose __get__ makes them descriptors. The @ syntax is not special syntax for built-ins; it is the same protocol for all decorators.
:::
Part 8 - Putting It Together: A Production Decorator Stack
import functools
import time
import logging
import hashlib
import json
from typing import Any
logger = logging.getLogger(__name__)
def validate_types(func):
"""Validate argument types against annotations at runtime."""
import inspect
hints = func.__annotations__
sig = inspect.signature(func)
@functools.wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for param_name, value in bound.arguments.items():
if param_name in hints and param_name != "return":
expected = hints[param_name]
if not isinstance(value, expected):
raise TypeError(
f"{func.__name__}: parameter '{param_name}' "
f"expected {expected.__name__}, got {type(value).__name__}"
)
return func(*args, **kwargs)
return wrapper
def cache_result(ttl_seconds: float = 60.0):
"""Cache function results with a time-to-live."""
def decorator(func):
_cache: dict[str, tuple[Any, float]] = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = hashlib.md5(
json.dumps((args, sorted(kwargs.items())), default=str).encode()
).hexdigest()
if key in _cache:
result, cached_at = _cache[key]
if time.monotonic() - cached_at < ttl_seconds:
logger.debug(f"{func.__name__} cache hit for key {key[:8]}")
return result
result = func(*args, **kwargs)
_cache[key] = (result, time.monotonic())
return result
wrapper.cache = _cache
return wrapper
return decorator
# Stack: validate_types runs innermost (closest to function), cache_result outermost
@cache_result(ttl_seconds=300)
@validate_types
def get_user_profile(user_id: int, include_history: bool = False) -> dict:
"""
Fetch a user profile. Results are cached for 5 minutes.
Types are validated before the cache is checked.
"""
return {"id": user_id, "name": "Alice", "history": [] if not include_history else [1, 2, 3]}
print(get_user_profile(42)) # DB call, cached
print(get_user_profile(42)) # cache hit
print(get_user_profile(42, True)) # different args - new call, cached separately
try:
get_user_profile("42") # TypeError: expected int, got str
except TypeError as e:
print(e)
Common Mistakes
Mistake 1 - Forgetting functools.wraps
# Wrong - metadata lost, breaks frameworks
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# Right
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Mistake 2 - Calling the Wrapper Instead of Returning It
# Wrong - calls wrapper() immediately during decoration
def outer(func):
def wrapper():
return func()
return wrapper() # BUG: calling wrapper(), not returning it
# Right
def outer(func):
def wrapper():
return func()
return wrapper # return the function object
Mistake 3 - Returning the Wrong Level in a Factory
# Wrong - repeat() returns wrapper (skipping the decorator level)
def repeat(times):
@functools.wraps(func) # NameError: func not in scope
def wrapper(*args, **kwargs):
for _ in range(times): func(*args, **kwargs)
return wrapper # repeat(3) tries to return wrapper - but func is undefined
# Right: three levels
def repeat(times):
def decorator(func): # decorator receives func
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times): func(*args, **kwargs)
return wrapper
return decorator # repeat(3) returns decorator
Mistake 4 - Stacking Order Confusion
# Order: bottom decorator applied first
@A # applied second: A(B(func)) - A is outermost
@B # applied first: B(func) - B is innermost
def func(): ...
# At call time: A's wrapper → B's wrapper → func
# If A = auth check, B = logging:
# auth runs first (before logging) - useful if you want to avoid logging failed auth attempts
Graded Practice Challenges
Level 1 - Predict the Output
Question 1: What does this print?
import functools
def debug(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@debug
def multiply(a, b, factor=1):
return a * b * factor
multiply(3, 4)
multiply(2, 5, factor=3)
print(multiply.__name__)
Show Answer
Output:
Calling multiply with args=(3, 4), kwargs={}
multiply returned 12
Calling multiply with args=(2, 5), kwargs={'factor': 3}
multiply returned 30
multiply
functools.wraps preserves __name__, so multiply.__name__ is "multiply" not "wrapper". The kwargs dict only contains keyword arguments - positional arguments appear in args.
Question 2: What does this print?
def make_decorator(label):
def decorator(func):
print(f"Decorating {func.__name__!r} with label={label!r}")
def wrapper(*args, **kwargs):
print(f"[{label}] calling {func.__name__!r}")
return func(*args, **kwargs)
return wrapper
return decorator
print("--- before decorating ---")
@make_decorator("A")
@make_decorator("B")
def greet():
print("hello")
print("--- before calling ---")
greet()
Show Answer
Output:
--- before decorating ---
Decorating 'greet' with label='B'
Decorating 'wrapper' with label='A'
--- before calling ---
[A] calling 'wrapper'
[B] calling 'greet'
hello
Decoration happens bottom-up: make_decorator("B") runs first, printing "Decorating 'greet'". It returns a wrapper. Then make_decorator("A") runs on that wrapper - note the name is now "wrapper" because there is no functools.wraps. At call time (top-down): [A] prints first (outermost), then [B], then "hello".
Question 3: What does this print?
import functools
def once(func):
"""Ensures a function is called only once. Returns the first result on all subsequent calls."""
called = False
cached_result = None
@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal called, cached_result
if not called:
cached_result = func(*args, **kwargs)
called = True
return cached_result
return wrapper
@once
def initialize():
print("initializing...")
return 42
print(initialize())
print(initialize())
print(initialize())
Show Answer
Output:
initializing...
42
42
42
The first call executes the original function, prints "initializing...", stores 42, and sets called = True. Subsequent calls skip the function body and return the cached 42. The closure variables called and cached_result persist across calls because they live in the enclosing scope of wrapper.
Question 4: What does this print?
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.num_calls = 0
def __call__(self, *args, **kwargs):
self.num_calls += 1
return self.func(*args, **kwargs)
@CountCalls
def add(a, b):
return a + b
print(add(1, 2))
print(add(3, 4))
print(add.num_calls)
print(add.__name__)
Show Answer
Output:
3
7
2
add
CountCalls is a class-based decorator. @CountCalls calls CountCalls(add), creating an instance where self.func = add. Each call to add(...) calls CountCalls.__call__, which increments num_calls and delegates to the original function. functools.update_wrapper(self, func) copies __name__ and other attributes onto the instance, so add.__name__ is "add".
Question 5: What does this print?
import functools
def prefix(text):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f"{text}: {result}"
return wrapper
return decorator
@prefix("INFO")
@prefix("LOG")
def message():
return "server started"
print(message())
Show Answer
Output:
INFO: LOG: server started
Decoration order (bottom-up): prefix("LOG") wraps message first. Then prefix("INFO") wraps log_wrapper. At call time (top-down): info_wrapper calls log_wrapper() → which calls message() → returns "server started" → log_wrapper returns "LOG: server started" → info_wrapper returns "INFO: LOG: server started".
Level 2 - Debug Challenge
Find and fix all bugs:
import functools
import time
def retry(max_attempts, delay=0.1):
def decorator(func):
def wrapper(*args, **kwargs): # bug 1
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts: # bug 2
raise
time.sleep(delay)
return wrapper
return decorator
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.3f}s")
# bug 3: missing return
return wrapper
@timed
@retry(max_attempts=3, delay=0.0)
def unstable():
import random
if random.random() < 0.5:
raise ValueError("flaky!")
return "success"
print(unstable())
Show Solution
Bugs:
-
wrapperinretryis missing@functools.wraps(func)- function name and docstring are lost after decoration. -
if attempt == max_attempts- this condition is never true.range(max_attempts)yields0tomax_attempts - 1. The last attempt value ismax_attempts - 1, nevermax_attempts. Should beattempt == max_attempts - 1. -
The
timedwrapper does not returnresult. The decorated function's return value is computed and discarded - alltimed-decorated functions silently returnNone.
Fixed version:
import functools
import time
def retry(max_attempts: int, delay: float = 0.1):
def decorator(func):
@functools.wraps(func) # fix 1: add functools.wraps
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1: # fix 2: correct boundary
raise
time.sleep(delay)
return wrapper
return decorator
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return result # fix 3: return the result
return wrapper
@timed
@retry(max_attempts=3, delay=0.0)
def unstable():
import random
if random.random() < 0.5:
raise ValueError("flaky!")
return "success"
print(unstable()) # 'success' (after 0–2 retries)
Level 3 - Design Challenge
Design a @validate decorator factory that:
- Accepts keyword argument validators:
@validate(age=lambda x: x >= 0, name=lambda x: len(x) > 0) - Validates the specified arguments before calling the function
- Raises
ValueErrorwith a descriptive message when validation fails - Does not affect unmentioned arguments
- Preserves the original function's signature and docstring
- Raises
ValueErrorat decoration time if a validator key does not match any parameter name (fail fast)
Show Reference Solution
import functools
import inspect
from typing import Any, Callable
def validate(**validators: Callable[[Any], bool]):
"""
Decorator factory that validates specific function arguments.
Usage:
@validate(age=lambda x: x >= 0, name=lambda x: len(x.strip()) > 0)
def create_user(name: str, age: int) -> dict:
...
"""
def decorator(func: Callable) -> Callable:
sig = inspect.signature(func)
param_names = set(sig.parameters)
# Fail fast at decoration time: catch typos in validator keys
unknown = set(validators) - param_names
if unknown:
raise ValueError(
f"@validate: {func.__name__} has no parameters: {unknown}. "
f"Valid parameters: {param_names}"
)
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Bind arguments to parameter names (handles positional + keyword)
try:
bound = sig.bind(*args, **kwargs)
except TypeError as e:
raise TypeError(f"{func.__name__}: {e}") from e
bound.apply_defaults()
# Run each validator
for param_name, validator in validators.items():
if param_name in bound.arguments:
value = bound.arguments[param_name]
if not validator(value):
raise ValueError(
f"{func.__name__}: argument '{param_name}' "
f"failed validation with value {value!r}"
)
return func(*args, **kwargs)
return wrapper
return decorator
# Usage
@validate(
name=lambda x: isinstance(x, str) and len(x.strip()) > 0,
age=lambda x: isinstance(x, int) and 0 <= x <= 150,
email=lambda x: "@" in x and "." in x.split("@")[-1],
)
def create_user(name: str, age: int, email: str) -> dict:
"""Create a new user record with validated inputs."""
return {"name": name, "age": age, "email": email}
# {'name': 'Alice', 'age': 30, 'email': '[email protected]'}
try:
except ValueError as e:
print(e)
# create_user: argument 'age' failed validation with value -1
# Metadata is preserved
print(create_user.__name__) # create_user
print(create_user.__doc__) # Create a new user record...
print(inspect.signature(create_user)) # (name: str, age: int, email: str) -> dict
# Typo caught at decoration time
try:
@validate(nme=lambda x: len(x) > 0) # 'nme' is not a parameter
def f(name): ...
except ValueError as e:
print(e)
# @validate: f has no parameters: {'nme'}. Valid parameters: {'name'}
Key Takeaways
- A decorator is a callable that takes a callable and returns a callable - the
@namesyntax is shorthand forfunc = name(func) - Decoration happens at import time (when the
@line is processed), not at call time - side effects in decorator bodies execute once per module load functools.wrapsis non-optional in production - it preserves__name__,__doc__,__annotations__, and adds__wrapped__for introspection- Without
functools.wraps, frameworks (FastAPI, Flask), test runners, logging, andinspectall see"wrapper"as the function name - causing silent and hard-to-diagnose bugs - Decorator factories use three-level nesting: outer function receives arguments, middle function receives the callable, inner function is the wrapper
- Class-based decorators use
__init__to receive the function and__call__to wrap invocations - prefer them when you need state, public methods, or__repr__ - Stacking decorators applies bottom-up at decoration time; call order is top-down at runtime - the topmost decorator's wrapper executes first
- Always use
*args, **kwargsin wrappers to preserve signature flexibility for any decorated function @property,@classmethod,@staticmethod, and@functools.lru_cacheare all decorators implemented in C - the@pattern is universal- Production decorator patterns - timing, retry with backoff, caching, rate limiting, validation - are direct and common applications of this pattern
What's Next
Lesson 06 covers closures in depth - how Python captures free variables, what CPython's cell objects are, the nonlocal keyword, and the loop variable capture bug. Every decorator creates a closure; understanding closures means understanding how decorators actually work at the CPython level.
