Python · Syntax · Advanced
Decorators
Functions that wrap other functions to modify or extend their behaviour. Covers `@` syntax, decorators with arguments, stacking decorators, and `functools.wraps`.
Quick topic start and explanations before exercises (exercises below):
Decorator factories, stacking, class-based decorators
#Real patterns, common mistakes, when not to use, method decorators preview
#Exercises:
Timer decorator
#Write a decorator `timer` that measures how long the decorated function takes to run and prints the result in the format: `'greet took 0.0001s'`. Use `time.time()` for measurement.
import time
def timer(func):
pass
@timer
def greet(name):
return f'Hello, {name}!'
greet('Alice') # prints: greet took 0.0001s (approx)
Solution
import time
import functools
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f'{func.__name__} took {elapsed:.4f}s')
return result
return wrapper
@timer
def greet(name):
return f'Hello, {name}!'
greet('Alice')
log_calls decorator
#Write a decorator `log_calls` that prints the function name, its positional arguments, and return value each time it is called. Format: `'add called with (2, 3) -> 5'`.
def log_calls(func):
pass
@log_calls
def add(a, b):
return a + b
add(2, 3) # prints: add called with (2, 3) -> 5
add(10, 20) # prints: add called with (10, 20) -> 30
Solution
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f'{func.__name__} called with {args} -> {result}')
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(2, 3)
add(10, 20)
memoize decorator
#Write a decorator `memoize` that caches function results. On repeated calls with the same arguments, return the cached value without calling the function again. Store results in a dict keyed by `args`.
def memoize(func):
pass
@memoize
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
print(fib(10)) # 55
print(fib(30)) # 832040 (fast with cache)
Solution
import functools
def memoize(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
print(fib(10)) # 55
print(fib(30)) # 832040
count_calls decorator
#Write a decorator `count_calls` that tracks how many times the decorated function has been called. Store the count as a `.count` attribute on the wrapper so callers can read it.
def count_calls(func):
pass
@count_calls
def say_hello():
print('Hello!')
say_hello()
say_hello()
say_hello()
print(say_hello.count) # 3
Solution
import functools
def count_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
wrapper.count += 1
return func(*args, **kwargs)
wrapper.count = 0
return wrapper
@count_calls
def say_hello():
print('Hello!')
say_hello()
say_hello()
say_hello()
print(say_hello.count) # 3
validate_positive decorator
#Write a decorator `validate_positive` that checks every positional argument before calling the function. If any argument is not a positive number (≤ 0), raise `ValueError` with the message `'All arguments must be positive'`.
def validate_positive(func):
pass
@validate_positive
def area(width, height):
return width * height
print(area(3, 4)) # 12
print(area(-1, 5)) # ValueError: All arguments must be positive
Solution
import functools
def validate_positive(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if any(a <= 0 for a in args):
raise ValueError('All arguments must be positive')
return func(*args, **kwargs)
return wrapper
@validate_positive
def area(width, height):
return width * height
print(area(3, 4)) # 12
try:
area(-1, 5)
except ValueError as e:
print(e) # All arguments must be positive
once decorator
#Write a decorator `once` that ensures the decorated function executes only once. On the first call, run the function and store its result. On all subsequent calls, return the stored result without executing again.
def once(func):
pass
@once
def load_config():
print('Loading config...')
return {'debug': True}
c1 = load_config() # prints: Loading config...
c2 = load_config() # prints nothing
c3 = load_config() # prints nothing
print(c1 is c2) # True
Solution
import functools
def once(func):
sentinel = object()
result = sentinel
@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal result
if result is sentinel:
result = func(*args, **kwargs)
return result
return wrapper
@once
def load_config():
print('Loading config...')
return {'debug': True}
c1 = load_config()
c2 = load_config()
print(c1 is c2) # True
retry decorator factory
#Write a decorator factory `retry(n)` that retries the decorated function up to `n` times if it raises any exception. If all attempts fail, re-raise the last exception. Print `'Attempt X failed'` on each failure before retrying.
def retry(n):
pass
attempts = [0]
@retry(3)
def flaky():
attempts[0] += 1
if attempts[0] < 3:
raise RuntimeError('not ready')
return 'success'
print(flaky()) # Attempt 1 failed, Attempt 2 failed, then: 'success'
Solution
import functools
def retry(n):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, n + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == n:
raise
print(f'Attempt {attempt} failed')
return wrapper
return decorator
attempts = [0]
@retry(3)
def flaky():
attempts[0] += 1
if attempts[0] < 3:
raise RuntimeError('not ready')
return 'success'
print(flaky())
@property: Temperature class
#Create a `Temperature` class that stores temperature in Celsius internally. Use `@property` to expose a `celsius` property (getter and setter) and a `fahrenheit` property that also has a setter which converts from Fahrenheit to Celsius. Formula: `F = C * 9/5 + 32`.
class Temperature:
def __init__(self, celsius=0):
pass
# Add celsius property (getter + setter)
# Add fahrenheit property (getter + setter)
t = Temperature(100)
print(t.celsius) # 100
print(t.fahrenheit) # 212.0
t.fahrenheit = 32
print(t.celsius) # 0.0
Solution
class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self._celsius = (value - 32) * 5 / 9
t = Temperature(100)
print(t.celsius) # 100
print(t.fahrenheit) # 212.0
t.fahrenheit = 32
print(t.celsius) # 0.0
requires_role decorator factory
#Write a decorator factory `requires_role(role)` that protects a function. The decorated function always receives a `user` dict as its first argument. If `user['role']` does not equal `role`, raise `PermissionError` with `'Access denied'`. Otherwise, call the function normally.
def requires_role(role):
pass
@requires_role('admin')
def delete_record(user, record_id):
return f"Record {record_id} deleted by {user['name']}"
admin = {'name': 'Alice', 'role': 'admin'}
guest = {'name': 'Bob', 'role': 'guest'}
print(delete_record(admin, 42)) # Record 42 deleted by Alice
delete_record(guest, 42) # PermissionError: Access denied
Solution
import functools
def requires_role(role):
def decorator(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if user.get('role') != role:
raise PermissionError('Access denied')
return func(user, *args, **kwargs)
return wrapper
return decorator
@requires_role('admin')
def delete_record(user, record_id):
return f"Record {record_id} deleted by {user['name']}"
admin = {'name': 'Alice', 'role': 'admin'}
guest = {'name': 'Bob', 'role': 'guest'}
print(delete_record(admin, 42))
try:
delete_record(guest, 42)
except PermissionError as e:
print(e) # Access denied
Stacking decorators
#Stack the `timer` and `log_calls` decorators on the same function `compute(n)` that returns the sum of squares from 1 to n. Apply `@timer` on the outside and `@log_calls` on the inside. Verify the output shows both the log line and the timing line.
import time
import functools
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f'{func.__name__} took {time.time() - start:.4f}s')
return result
return wrapper
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f'{func.__name__} called with {args} -> {result}')
return result
return wrapper
# Stack both decorators on compute:
def compute(n):
return sum(i * i for i in range(1, n + 1))
compute(5)
Solution
import time
import functools
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f'{func.__name__} took {time.time() - start:.4f}s')
return result
return wrapper
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f'{func.__name__} called with {args} -> {result}')
return result
return wrapper
@timer
@log_calls
def compute(n):
return sum(i * i for i in range(1, n + 1))
compute(5)
# log_calls prints: compute called with (5,) -> 55
# timer prints: compute took 0.0000s