Decorators
In this chapter, we'll learn about decorators - a powerful Python feature for modifying function behavior. We'll cover:
- What are decorators?
- Creating function decorators
- Decorators with arguments
- Class decorators
@functools.wrapsfor preserving function metadata- Common built-in decorators
- Testing decorated functions
What is a decorator?
A decorator is a function that takes another function and extends its behavior without modifying it:
@decorator
def my_function():
pass
# Equivalent to:
def my_function():
pass
my_function = decorator(my_function)
Your first decorator
Write the test first
Let's create a decorator that logs function calls:
from decorators import log_calls
logged_calls = []
@log_calls
def greet(name):
return f"Hello, {name}!"
def test_log_calls():
result = greet("Alice")
assert result == "Hello, Alice!"
assert "greet" in logged_calls[-1]
assert "Alice" in logged_calls[-1]
Implementation
def log_calls(func):
def wrapper(*args, **kwargs):
call_info = f"Called {func.__name__} with args={args}, kwargs={kwargs}"
logged_calls.append(call_info)
return func(*args, **kwargs)
return wrapper
How it works:
log_callsreceives the original function- Returns a
wrapperthat logs then calls the original *args, **kwargspass any arguments through
Using functools.wraps
Without @functools.wraps, decorated functions lose their metadata:
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
"""Greet someone by name."""
return f"Hello, {name}!"
print(greet.__name__) # "wrapper" - wrong!
print(greet.__doc__) # None - wrong!
Fix with @functools.wraps:
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
"""Greet someone by name."""
return f"Hello, {name}!"
print(greet.__name__) # "greet" - correct!
print(greet.__doc__) # "Greet someone by name." - correct!
Testing metadata preservation
def test_decorator_preserves_metadata():
@log_calls
def example_func():
"""Example docstring."""
pass
assert example_func.__name__ == "example_func"
assert example_func.__doc__ == "Example docstring."
Practical decorators
Timing decorator
import time
from functools import wraps
def timing(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
duration = time.time() - start
print(f"{func.__name__} took {duration:.4f}s")
return result
return wrapper
Test:
def test_timing_decorator():
@timing
def slow_function():
time.sleep(0.1)
return "done"
result = slow_function()
assert result == "done"
Retry decorator
import time
from functools import wraps
def retry(max_attempts=3, delay=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_attempts - 1:
time.sleep(delay)
raise last_exception
return wrapper
return decorator
Test:
def test_retry_succeeds_eventually():
attempts = []
@retry(max_attempts=3, delay=0)
def flaky_function():
attempts.append(1)
if len(attempts) < 3:
raise ValueError("Not yet!")
return "success"
result = flaky_function()
assert result == "success"
assert len(attempts) == 3
Decorators with arguments
Decorators that accept arguments need an extra layer:
def repeat(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def say_hello():
print("Hello!")
return "done"
Testing parameterized decorators
def test_repeat_decorator():
calls = []
@repeat(times=3)
def track_calls():
calls.append(1)
return "ok"
result = track_calls()
assert result == "ok"
assert len(calls) == 3
Validation decorator
A common use case - validating function arguments:
from functools import wraps
def validate_positive(func):
@wraps(func)
def wrapper(*args, **kwargs):
for arg in args:
if isinstance(arg, (int, float)) and arg < 0:
raise ValueError(f"Arguments must be positive, got {arg}")
return func(*args, **kwargs)
return wrapper
@validate_positive
def square_root(n):
return n ** 0.5
Test:
import pytest
def test_validate_positive():
@validate_positive
def double(n):
return n * 2
assert double(5) == 10
def test_validate_positive_rejects_negative():
@validate_positive
def double(n):
return n * 2
with pytest.raises(ValueError, match="must be positive"):
double(-5)
Caching decorator
The @functools.lru_cache decorator caches results:
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Without cache: O(2^n)
# With cache: O(n)
Test:
def test_cached_fibonacci():
result = fibonacci(30)
assert result == 832040
# Verify caching
info = fibonacci.cache_info()
assert info.hits > 0
Class decorators
Decorators can modify classes:
def singleton(cls):
instances = {}
@wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class Database:
def __init__(self):
self.connected = True
Test:
def test_singleton():
db1 = Database()
db2 = Database()
assert db1 is db2
Adding methods with class decorators
def add_repr(cls):
def __repr__(self):
attrs = ", ".join(
f"{k}={v!r}"
for k, v in self.__dict__.items()
)
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__
return cls
@add_repr
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
Test:
def test_add_repr():
point = Point(3, 4)
assert repr(point) == "Point(x=3, y=4)"
Common built-in decorators
@property
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius must be positive")
self._radius = value
@staticmethod and @classmethod
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f):
return cls((f - 32) * 5 / 9)
@staticmethod
def is_freezing(celsius):
return celsius <= 0
@dataclass
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
def distance_from_origin(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
Stacking decorators
Multiple decorators apply bottom-up:
@decorator1
@decorator2
@decorator3
def my_function():
pass
# Equivalent to:
my_function = decorator1(decorator2(decorator3(my_function)))
Example:
@timing
@retry(max_attempts=3)
def fetch_data():
# Retry is applied first, then timing
pass
Testing decorated functions
Testing the wrapper directly
def test_log_calls_decorator():
calls = []
def capture_log(func):
@wraps(func)
def wrapper(*args, **kwargs):
calls.append((func.__name__, args, kwargs))
return func(*args, **kwargs)
return wrapper
@capture_log
def add(a, b):
return a + b
result = add(2, 3)
assert result == 5
assert calls == [("add", (2, 3), {})]
Testing decorated behavior
def test_retry_behavior():
attempt_count = 0
@retry(max_attempts=3, delay=0)
def failing_function():
nonlocal attempt_count
attempt_count += 1
raise ValueError("Always fails")
with pytest.raises(ValueError):
failing_function()
assert attempt_count == 3
Wrapping up
We've covered:
- Basic decorators - Functions that modify other functions
- @functools.wraps - Preserve function metadata
- Decorators with arguments - Add configuration to decorators
- Class decorators - Modify classes
- Built-in decorators -
@property,@staticmethod,@classmethod,@dataclass - Stacking decorators - Apply multiple decorators
Key takeaways
- Always use
@functools.wrapsin your decorators - Decorators with arguments need three levels of nesting
- Test both the decorator behavior and metadata preservation
- Stack decorators when you need multiple behaviors
- Use built-in decorators for common patterns
Decorators are a powerful tool for adding cross-cutting concerns like logging, caching, validation, and retry logic without cluttering your core business logic.