Python Decorators — Function Enhancement Patterns
Decorators modify or enhance functions without changing their code. They are used extensively in frameworks, testing, logging, and caching.
Learning Objectives
- Understand how decorators work under the hood
- Create decorators with and without arguments
- Apply decorators to classes and methods
- Use common decorator patterns in production
- Build a decorator library for real-world use
Basic Decorator
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
return "done"
slow_function() # slow_function took 1.0012s
How It Works
# @syntax is equivalent to:
def slow_function():
time.sleep(1)
return "done"
slow_function = timer(slow_function) # Decorator applied manually
# The decorated function is now 'wrapper'
print(slow_function.__name__) # "wrapper" — metadata lost!
Preserving Function Metadata
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
"""Greet someone."""
return f"Hello, {name}!"
print(greet.__name__) # "greet" (not "wrapper")
print(greet.__doc__) # "Greet someone."
print(greet.__wrapped__) # Original function reference
# functools.wraps copies: __name__, __doc__, __module__, __dict__, __wrapped__
Decorator with Arguments
def retry(max_attempts=3, delay=1):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
import random
if random.random() < 0.7:
raise ConnectionError("Network error")
return {"status": "ok"}
Alternative: functools.partial
from functools import partial
def retry(func=None, *, max_attempts=3, delay=1):
if func is None:
return partial(retry, max_attempts=max_attempts, delay=delay)
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
time.sleep(delay)
return wrapper
# Works with or without arguments
@retry
def func1(): pass
@retry(max_attempts=5)
def func2(): pass
Class Decorators
def singleton(cls):
instances = {}
@functools.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.connection = "connected"
db1 = Database()
db2 = Database()
print(db1 is db2) # True
# Class decorator that adds methods
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
p = Point(1, 2)
print(p) # Point(x=1, y=2)
Stacking Decorators
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
@bold
@italic
def greet(name):
return f"Hello, {name}"
print(greet("Alice")) # <b><i>Hello, Alice</i></b>
# Execution order: bold wraps italic wraps greet
# bold(italic(greet))
Common Production Decorators
# Caching (built-in since Python 3.8)
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
# Cache info
print(fibonacci.cache_info())
# Deprecation warning
import warnings
def deprecated(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
warnings.warn(f"{func.__name__} is deprecated", DeprecationWarning, stacklevel=2)
return func(*args, **kwargs)
return wrapper
@deprecated
def old_function():
pass
# Type checking decorator
def type_check(*types, return_type=None):
def decorator(func):
@functools.wraps(func)
def wrapper(*args):
for arg, expected in zip(args, types):
if not isinstance(arg, expected):
raise TypeError(f"Expected {expected}, got {type(arg)}")
result = func(*args)
if return_type and not isinstance(result, return_type):
raise TypeError(f"Return type expected {return_type}, got {type(result)}")
return result
return wrapper
return decorator
@type_check(int, int, return_type=int)
def add(a, b):
return a + b
# Input validation
def validate(**validators):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for key, validator in validators.items():
if key in kwargs:
if not validator(kwargs[key]):
raise ValueError(f"Invalid value for {key}: {kwargs[key]}")
return func(*args, **kwargs)
return wrapper
return decorator
@validate(age=lambda x: x >= 0, name=lambda x: len(x) > 0)
def create_person(name, age):
return {"name": name, "age": age}
Real-World: Decorator Library
import functools
import time
import logging
from typing import Callable, Any
logger = logging.getLogger(__name__)
def timer(func: Callable) -> Callable:
"""Measure execution time."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
logger.info(f"{func.__name__} completed in {elapsed:.4f}s")
return result
return wrapper
def retry(max_attempts: int = 3, delay: float = 1.0,
exceptions: tuple = (Exception,)) -> Callable:
"""Retry on failure."""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == max_attempts:
raise
logger.warning(f"Attempt {attempt} failed: {e}. Retrying...")
time.sleep(delay)
return wrapper
return decorator
def cache(ttl: float = 300) -> Callable:
"""Cache with time-to-live expiration."""
def decorator(func: Callable) -> Callable:
cache_dict = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
now = time.time()
if key in cache_dict:
result, timestamp = cache_dict[key]
if now - timestamp < ttl:
return result
result = func(*args, **kwargs)
cache_dict[key] = (result, now)
return result
wrapper.cache_clear = cache_dict.clear
return wrapper
return decorator
def rate_limit(calls_per_second: float) -> Callable:
"""Rate limit function calls."""
min_interval = 1.0 / calls_per_second
last_called = [0.0]
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
last_called[0] = time.time()
return func(*args, **kwargs)
return wrapper
return decorator
# Usage
@timer
@retry(max_attempts=3, delay=0.5)
@cache(ttl=60)
def fetch_user(user_id: int) -> dict:
return {"id": user_id, "name": f"User_{user_id}"}
Method Decorators
class MyClass:
@staticmethod
def static_method():
"""No access to class or instance."""
return "static"
@classmethod
def class_method(cls):
"""Access to class, not instance."""
return cls.__name__
@property
def value(self):
"""Computed attribute."""
return self._value
@value.setter
def value(self, val):
self._value = val
@functools.lru_cache(maxsize=32)
def expensive_method(self, n):
"""Cached method (note: uses self as key)."""
return n ** 2
# Custom method decorator
def log_method_call(func):
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
logger.info(f"Calling {func.__name__} on {self.__class__.__name__}")
result = func(self, *args, **kwargs)
logger.info(f"{func.__name__} returned {result}")
return result
return wrapper
class Service:
@log_method_call
def process(self, data):
return data * 2
Common Mistakes
# Mistake 1: Forgetting @functools.wraps
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad_decorator
def my_func():
"""My docstring."""
pass
print(my_func.__name__) # "wrapper" — lost metadata!
# Fix: always use @functools.wraps
def good_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# Mistake 2: Not handling *args and **kwargs
def bad_decorator(func):
def wrapper(a, b): # Only works for 2 positional args!
return func(a, b)
return wrapper
# Fix: use *args and **kwargs
def good_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# Mistake 3: Decorator with arguments confusion
# @retry # Calls retry(func) — works if func is callable
# def func(): pass
# @retry(3) # Calls retry(3) — returns decorator, then calls decorator(func)
# def func(): pass
# Fix: use the dual-mode pattern shown earlier
# Mistake 4: Decorator modifies class incorrectly
# class Decorator:
# def __call__(self, func):
# @functools.wraps(func)
# def wrapper(*args, **kwargs):
# return func(*args, **kwargs)
# return wrapper
# @Decorator # This replaces the class with 'wrapper' function!
# class MyClass: pass
Key Takeaways
- Decorators wrap functions to add behavior
- Always use
@functools.wrapsto preserve metadata - Decorator factories use nested functions for arguments
@lru_cacheis built-in caching- Common patterns: retry, timer, type check, deprecation, singleton
- Use
*args, **kwargsin wrapper functions for flexibility - Class decorators can modify classes, not just functions