Python Decorators — Wrapping Callables at: Practice Problems & Exercises
Practice: Decorators — Wrapping Callables at Engineering Depth
← Back to lessonEasy
Write a log_calls decorator that prints before and after calling the wrapped function. Use func.__name__ to get the function name.
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done {func.__name__}")
return result
return wrapper
@log_calls
def greet(name):
print(f"Hello, {name}!")
greet("Alice")Solution
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done {func.__name__}")
return result
return wrapper
@log_calls
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
Explanation: @log_calls is syntactic sugar for greet = log_calls(greet). After decoration, greet refers to wrapper. When called, wrapper runs the before/after logic and delegates to the original function via the closure variable func. *args, **kwargs makes the wrapper signature-agnostic — it forwards all arguments correctly.
def log_calls(func):
# Wrap func so it prints "Calling <name>" before
# and "Done <name>" after each call.
pass
@log_calls
def greet(name):
print(f"Hello, {name}!")
greet("Alice")Expected Output
Calling greet\nHello, Alice!\nDone greetHints
Hint 1: Define an inner wrapper(*args, **kwargs) function inside log_calls.
Hint 2: Print before calling func(*args, **kwargs), print after, then return the result.
Hint 3: Return the wrapper function (not its return value).
Add @functools.wraps(func) to the log_calls decorator from problem 1 so that the wrapped function retains its original metadata.
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done {func.__name__}")
return result
return wrapper
@log_calls
def add(a, b):
"""Return the sum of a and b."""
return a + b
print(add.__name__)
print(add.__doc__)
print(add(2, 3))Solution
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done {func.__name__}")
return result
return wrapper
@log_calls
def add(a, b):
"""Return the sum of a and b."""
return a + b
print(add.__name__) # add
print(add.__doc__) # Return the sum of a and b.
print(add(2, 3)) # 5
Explanation: Without @functools.wraps, add.__name__ would be 'wrapper' and add.__doc__ would be None. This breaks help(), inspect.signature(), pytest introspection, and many frameworks. wraps also sets add.__wrapped__ = func, allowing tools to unwrap the decorator chain and access the original function.
import functools
def log_calls(func):
# Same as problem 1, but preserve __name__, __doc__, __module__
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done {func.__name__}")
return result
return wrapper
@log_calls
def add(a, b):
"""Return the sum of a and b."""
return a + b
print(add.__name__) # add (NOT wrapper)
print(add.__doc__) # Return the sum of a and b.
print(add(2, 3))Expected Output
add\nReturn the sum of a and b.\nCalling add\nDone add\n5Hints
Hint 1: @functools.wraps(func) copies __name__, __doc__, __module__, __qualname__, __annotations__, and __wrapped__ from func to wrapper.
Hint 2: Apply @functools.wraps(func) to the inner wrapper function.
Hint 3: Without it, add.__name__ would be "wrapper" — confusing in tracebacks and help().
Implement a timer decorator that measures and prints execution time in milliseconds. Exact timing output will vary — the result value is fixed.
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"{func.__name__} took {elapsed_ms:.2f} ms")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
result = slow_sum(1_000_000)
print(f"Result: {result}")Solution
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"{func.__name__} took {elapsed_ms:.2f} ms")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
result = slow_sum(1_000_000)
print(f"Result: {result}")
Explanation: time.perf_counter() is the highest-resolution timer available — more precise than time.time() which only gives wall-clock seconds. Always measure both sides of the function call, not the whole decorator body. Production timing decorators often accumulate stats (min, max, p99) rather than printing each call.
import functools
import time
def timer(func):
# Print how long func takes to run (in milliseconds)
@functools.wraps(func)
def wrapper(*args, **kwargs):
pass # implement
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
result = slow_sum(1_000_000)
print(f"Result: {result}")Expected Output
slow_sum took 12.34 ms\nResult: 499999500000Hints
Hint 1: Record start = time.perf_counter() before the call.
Hint 2: Record end = time.perf_counter() after.
Hint 3: Elapsed milliseconds: (end - start) * 1000
Hint 4: Print the timing, then return the result.
Implement require_int which raises TypeError if any positional argument to the wrapped function is not an int.
import functools
def require_int(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for arg in args:
if not isinstance(arg, int):
raise TypeError(f"Argument {arg!r} is not an int")
return func(*args, **kwargs)
return wrapper
@require_int
def multiply(a, b):
return a * b
print(multiply(3, 4))
try:
multiply(3, '4')
except TypeError as e:
print(e)Solution
import functools
def require_int(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for arg in args:
if not isinstance(arg, int):
raise TypeError(f"Argument {arg!r} is not an int")
return func(*args, **kwargs)
return wrapper
@require_int
def multiply(a, b):
return a * b
print(multiply(3, 4)) # 12
try:
multiply(3, '4')
except TypeError as e:
print(e) # Argument '4' is not an int
Explanation: Using isinstance rather than type(arg) == int correctly handles subclasses (e.g., bool is a subclass of int — isinstance(True, int) is True). The !r format spec in the f-string calls repr(), adding quotes around string values in the error message, which helps users distinguish between 42 (an int) and '42' (a string).
import functools
def require_int(func):
# Raise TypeError if any positional argument is not an int
@functools.wraps(func)
def wrapper(*args, **kwargs):
pass # implement
return wrapper
@require_int
def multiply(a, b):
return a * b
print(multiply(3, 4)) # 12
try:
multiply(3, '4')
except TypeError as e:
print(e) # Argument '4' is not an intExpected Output
12\nArgument '4' is not an intHints
Hint 1: Iterate over args and check isinstance(arg, int).
Hint 2: If any fails, raise TypeError with a helpful message including the bad value.
Hint 3: Use repr(arg) in the message so strings show their quotes: repr('4') == "'4'"
Medium
Implement a retry(max_attempts, delay, exceptions) decorator factory. The decorator should retry the wrapped function up to max_attempts times, waiting delay seconds between retries, re-raising on final failure.
import functools
import time
def retry(max_attempts=3, delay=0.1, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exc = e
if attempt < max_attempts:
time.sleep(delay)
raise last_exc
return wrapper
return decorator
call_count = 0
@retry(max_attempts=3, delay=0, exceptions=(ValueError,))
def flaky():
global call_count
call_count += 1
if call_count < 3:
raise ValueError("not ready")
return "success"
print(flaky())
print(call_count)Solution
import functools
import time
def retry(max_attempts=3, delay=0.1, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exc = e
if attempt < max_attempts:
time.sleep(delay)
raise last_exc
return wrapper
return decorator
Explanation: The three-level nesting is the canonical decorator factory pattern: the outer function (retry) accepts configuration and returns decorator; decorator accepts the function and returns wrapper; wrapper implements the runtime logic. The exceptions tuple is used directly in except exceptions as e — Python accepts a tuple of exception types there. Re-raising last_exc (not using bare raise) preserves the correct traceback.
import functools
import time
def retry(max_attempts=3, delay=0.1, exceptions=(Exception,)):
# Decorator factory: returns a decorator that retries func up to max_attempts times.
# Wait delay seconds between retries (no actual sleep needed in tests — use delay=0).
# Re-raise the last exception if all attempts fail.
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
pass # implement
return wrapper
return decorator
call_count = 0
@retry(max_attempts=3, delay=0, exceptions=(ValueError,))
def flaky():
global call_count
call_count += 1
if call_count < 3:
raise ValueError("not ready")
return "success"
print(flaky()) # success
print(call_count) # 3Expected Output
success\n3Hints
Hint 1: Three levels: retry() returns decorator, decorator wraps func, wrapper runs the retry loop.
Hint 2: Loop from 1 to max_attempts. On each attempt call func(*args, **kwargs) in a try/except.
Hint 3: If exception matches exceptions tuple and attempts remain, wait and continue.
Hint 4: If last attempt fails, re-raise. If successful, return the result.
Implement a memoize decorator that caches results keyed by function arguments. Show that each unique argument is computed exactly once.
import functools
def memoize(func):
cache = {}
@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]
return wrapper
call_count = 0
@memoize
def fib(n):
global call_count
call_count += 1
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(10))
print(call_count)Solution
import functools
def memoize(func):
cache = {}
@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]
return wrapper
call_count = 0
@memoize
def fib(n):
global call_count
call_count += 1
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(10)) # 55
print(call_count) # 11
Explanation: The cache is a dict closed over by wrapper. tuple(sorted(kwargs.items())) makes keyword arguments hashable and order-independent. fib(10) triggers 11 unique calls (fib(0) through fib(10)); all subsequent calls for already-seen values hit the cache. functools.lru_cache(maxsize=None) is the production equivalent — backed by a C implementation that is ~10x faster and handles cache eviction.
import functools
def memoize(func):
# Cache results keyed by (args, frozen kwargs).
# Must work for any hashable arguments.
cache = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
pass # implement
return wrapper
call_count = 0
@memoize
def fib(n):
global call_count
call_count += 1
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(10)) # 55
print(call_count) # 11 (each unique n computed once)Expected Output
55\n11Hints
Hint 1: Cache key: (args, tuple(sorted(kwargs.items())))
Hint 2: If key is in cache, return cache[key]. Otherwise compute, store, return.
Hint 3: functools.lru_cache does this more efficiently — but implement it manually here.
Implement CallCounter as a class-based decorator that tracks invocation count. The count should be accessible as decorated_func.call_count.
import functools
class CallCounter:
def __init__(self, func):
functools.update_wrapper(self, func)
self._func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
return self._func(*args, **kwargs)
@CallCounter
def add(a, b):
return a + b
print(add(1, 2))
print(add(3, 4))
print(add.call_count)Solution
import functools
class CallCounter:
def __init__(self, func):
functools.update_wrapper(self, func)
self._func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
return self._func(*args, **kwargs)
@CallCounter
def add(a, b):
return a + b
print(add(1, 2)) # 3
print(add(3, 4)) # 7
print(add.call_count) # 2
Explanation: Class-based decorators are preferred when the decorator needs persistent mutable state (counters, caches, connection pools). __init__ receives the function (replacing the outer factory call) and __call__ replaces the wrapper. functools.update_wrapper(self, func) is the class-based equivalent of @functools.wraps — it copies __name__, __doc__, etc. onto the instance.
import functools
class CallCounter:
"""
A class-based decorator that tracks how many times
the wrapped function has been called.
Access the count via func.call_count.
"""
def __init__(self, func):
pass
def __call__(self, *args, **kwargs):
pass
@CallCounter
def add(a, b):
return a + b
print(add(1, 2)) # 3
print(add(3, 4)) # 7
print(add.call_count) # 2Expected Output
3\n7\n2Hints
Hint 1: __init__ receives the function — store it and initialise call_count = 0.
Hint 2: __call__ is invoked when the decorated function is called — increment and delegate.
Hint 3: Apply functools.update_wrapper(self, func) in __init__ to copy metadata.
Apply the three decorators in the correct order so the output is <b><i><u>Hello</u></i></b>. Confirm you understand the bottom-up application order.
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
def underline(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<u>{func(*args, **kwargs)}</u>"
return wrapper
@bold
@italic
@underline
def greet():
return "Hello"
print(greet())Solution
@bold
@italic
@underline
def greet():
return "Hello"
print(greet()) # <b><i><u>Hello</u></i></b>
Explanation: Python applies stacked decorators from bottom to top: greet = bold(italic(underline(greet))). At call time, execution flows top to bottom through the wrapper chain: bold.wrapper calls italic.wrapper, which calls underline.wrapper, which calls the original greet(). The return value bubbles back up: "Hello" → "<u>Hello</u>" → "<i><u>Hello</u></i>" → "<b><i><u>Hello</u></i></b>".
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
def underline(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<u>{func(*args, **kwargs)}</u>"
return wrapper
# Apply all three: result should be <b><i><u>Hello</u></i></b>
@bold
@italic
@underline
def greet():
return "Hello"
print(greet())Expected Output
<b><i><u>Hello</u></i></b>Hints
Hint 1: Decorators are applied bottom-up: the one closest to the function runs first on the value.
Hint 2: @bold @italic @underline means bold(italic(underline(greet))).
Hint 3: underline wraps "Hello" first, then italic wraps that, then bold wraps the result.
Hard
Implement a rate_limit(calls_per_second) decorator factory. It should raise RuntimeError if the function is called too quickly, showing the required wait time.
import functools
import time
def rate_limit(calls_per_second):
min_interval = 1.0 / calls_per_second
def decorator(func):
last_called = [0.0]
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.perf_counter()
elapsed = now - last_called[0]
if elapsed < min_interval:
remaining = min_interval - elapsed
raise RuntimeError(
f"Rate limit exceeded: must wait {remaining:.2f}s between calls"
)
last_called[0] = now
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(calls_per_second=2)
def fetch(url):
return f"fetched {url}"
print(fetch("https://api.example.com/1"))
time.sleep(0.6)
print(fetch("https://api.example.com/2"))
try:
fetch("https://api.example.com/3")
except RuntimeError as e:
print(e)Solution
import functools
import time
def rate_limit(calls_per_second):
min_interval = 1.0 / calls_per_second
def decorator(func):
last_called = [0.0]
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.perf_counter()
elapsed = now - last_called[0]
if elapsed < min_interval:
remaining = min_interval - elapsed
raise RuntimeError(
f"Rate limit exceeded: must wait {remaining:.2f}s between calls"
)
last_called[0] = now
return func(*args, **kwargs)
return wrapper
return decorator
Explanation: The last_called = [0.0] trick: Python closures close over the variable name, not the value. A plain last_called = 0.0 float cannot be mutated inside wrapper (assignment would create a new local). Wrapping it in a list allows last_called[0] = now to mutate the existing object. Alternatively, Python 3 allows nonlocal last_called with a float. Production rate limiters use a token bucket algorithm for burst support.
import functools
import time
def rate_limit(calls_per_second):
# Decorator factory: raise RuntimeError if the function is called
# more than calls_per_second times per second.
# Track the last call timestamp in the wrapper's closure.
min_interval = 1.0 / calls_per_second
def decorator(func):
last_called = [0.0] # use a list so we can mutate inside closure
@functools.wraps(func)
def wrapper(*args, **kwargs):
pass # implement
return wrapper
return decorator
@rate_limit(calls_per_second=2)
def fetch(url):
return f"fetched {url}"
import time
print(fetch("https://api.example.com/1")) # ok
time.sleep(0.6) # wait > 0.5s
print(fetch("https://api.example.com/2")) # ok
try:
fetch("https://api.example.com/3") # too fast
except RuntimeError as e:
print(e)Expected Output
fetched https://api.example.com/1\nfetched https://api.example.com/2\nRate limit exceeded: must wait 0.50s between callsHints
Hint 1: min_interval = 1.0 / calls_per_second is the required gap in seconds.
Hint 2: last_called is a list with one element so it can be mutated inside the nested closure.
Hint 3: On each call: elapsed = time.perf_counter() - last_called[0].
Hint 4: If elapsed < min_interval, raise RuntimeError with a message showing the remaining wait.
Hint 5: Otherwise update last_called[0] and proceed.
Run the provided code and confirm you understand how the optional-argument decorator pattern works. The implementation is already correct — trace through both call paths.
import functools
def repeat(_func=None, *, times=2):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
if _func is not None:
return decorator(_func)
return decorator
@repeat
def say_hi():
print("hi")
@repeat(times=3)
def say_hello():
print("hello")
say_hi()
say_hello()Solution
import functools
def repeat(_func=None, *, times=2):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
if _func is not None:
return decorator(_func)
return decorator
Explanation: There are two call paths. @repeat (no parentheses): Python passes say_hi as _func; the check if _func is not None is True, so decorator(_func) is called immediately and the result (the wrapper) is returned. @repeat(times=3): Python calls repeat(times=3), so _func=None; decorator is returned; Python then calls decorator(say_hello). The * in the signature forces times to be keyword-only, preventing accidental positional use of times.
import functools
def repeat(_func=None, *, times=2):
# Can be used as @repeat or @repeat(times=3)
# Calls the function 'times' times and returns the last result.
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
if _func is not None:
# Called as @repeat with no parentheses
return decorator(_func)
# Called as @repeat(times=3)
return decorator
@repeat
def say_hi():
print("hi")
@repeat(times=3)
def say_hello():
print("hello")
say_hi()
say_hello()Expected Output
hi\nhi\nhello\nhello\nhelloHints
Hint 1: The trick is the optional first positional argument _func.
Hint 2: When @repeat is used without (), Python passes the function as _func.
Hint 3: When @repeat(times=3) is used, _func is None and decorator is returned.
Hint 4: The keyword-only * forces times to always be a keyword argument.
Implement the Router class with get(path) and post(path) decorator methods that register handlers, and dispatch(method, path) that calls the matching handler.
import functools
class Router:
def __init__(self):
self._routes = {}
def _register(self, method, path):
def decorator(func):
self._routes[(method.upper(), path)] = func
return func # return unchanged — no wrapping needed
return decorator
def get(self, path):
return self._register('GET', path)
def post(self, path):
return self._register('POST', path)
def dispatch(self, method, path, **kwargs):
key = (method.upper(), path)
if key not in self._routes:
raise KeyError(f"No route for {method} {path}")
return self._routes[key](**kwargs)
router = Router()
@router.get("/users")
def list_users():
return "listing users"
@router.post("/users")
def create_user(name="unknown"):
return f"created {name}"
print(router.dispatch("GET", "/users"))
print(router.dispatch("POST", "/users", name="Alice"))Solution
import functools
class Router:
def __init__(self):
self._routes = {}
def _register(self, method, path):
def decorator(func):
self._routes[(method.upper(), path)] = func
return func
return decorator
def get(self, path):
return self._register('GET', path)
def post(self, path):
return self._register('POST', path)
def dispatch(self, method, path, **kwargs):
key = (method.upper(), path)
if key not in self._routes:
raise KeyError(f"No route for {method} {path}")
return self._routes[key](**kwargs)
Explanation: @router.get("/users") calls router.get("/users"), which calls self._register('GET', '/users'), which returns a decorator. Python then calls decorator(list_users), which stores the function in self._routes and returns func unchanged. Returning func without wrapping is intentional — the decorator's purpose is registration as a side effect, not runtime wrapping. This is precisely how FastAPI, Flask, and Django register route handlers.
import functools
class Router:
"""
A minimal FastAPI-style router.
@router.get("/path") registers a handler for GET /path.
@router.post("/path") registers a handler for POST /path.
router.dispatch(method, path) calls the matching handler.
"""
def __init__(self):
self._routes = {} # {(method, path): handler}
def get(self, path):
pass # return a decorator that registers the handler under ('GET', path)
def post(self, path):
pass # return a decorator that registers the handler under ('POST', path)
def _register(self, method, path):
pass # return a decorator factory helper
def dispatch(self, method, path, **kwargs):
key = (method.upper(), path)
if key not in self._routes:
raise KeyError(f"No route for {method} {path}")
return self._routes[key](**kwargs)
router = Router()
@router.get("/users")
def list_users():
return "listing users"
@router.post("/users")
def create_user(name="unknown"):
return f"created {name}"
print(router.dispatch("GET", "/users"))
print(router.dispatch("POST", "/users", name="Alice"))Expected Output
listing users\ncreated AliceHints
Hint 1: get(path) and post(path) are decorator factories — they return a decorator.
Hint 2: The decorator should store func in self._routes[(method, path)] and return func unchanged.
Hint 3: Returning func unchanged (not a wrapper) is correct here — registration is the side effect.
Hint 4: You can implement _register(method, path) as a shared helper called by get() and post().
