Decorators
Decorators in Python are a powerful feature that allows you to modify or extend the behavior of functions and classes without permanently modifying them. They provide a clean, readable way to add functionality like logging, caching, authentication, and more.
Why Use Decorators?
Section titled “Why Use Decorators?”Without decorators (repetitive code):
def greet(name: str) -> str: print("Function called: greet") print(f"Arguments: name={name}") start_time = time.time()
result = f"Hello, {name}!"
end_time = time.time() print(f"Execution time: {end_time - start_time:.4f} seconds") return result
def calculate_sum(a: int, b: int) -> int: print("Function called: calculate_sum") print(f"Arguments: a={a}, b={b}") start_time = time.time()
result = a + b
end_time = time.time() print(f"Execution time: {end_time - start_time:.4f} seconds") return result
# Problem: Same logging code repeated in every function!With decorators (clean and reusable):
import timefrom functools import wraps
def log_execution(func): """Decorator to log function execution""" @wraps(func) def wrapper(*args, **kwargs): print(f"Function called: {func.__name__}") print(f"Arguments: {args}, {kwargs}") start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time() print(f"Execution time: {end_time - start_time:.4f} seconds") return result return wrapper
@log_executiondef greet(name: str) -> str: return f"Hello, {name}!"
@log_executiondef calculate_sum(a: int, b: int) -> int: return a + b
# Clean, reusable, and maintainable!Understanding How Decorators Work
Section titled “Understanding How Decorators Work”Basic Decorator Structure
Section titled “Basic Decorator Structure”A decorator is essentially a function that:
- Takes a function as input
- Returns a new function (usually a wrapper)
- The wrapper calls the original function with added behavior
def my_decorator(func): """Basic decorator structure""" def wrapper(*args, **kwargs): # Code to execute BEFORE the function print("Before function execution")
# Call the original function result = func(*args, **kwargs)
# Code to execute AFTER the function print("After function execution")
return result
return wrapper
@my_decoratordef say_hello(name: str): print(f"Hello, {name}!")
# Usagesay_hello("Alice")# Output:# Before function execution# Hello, Alice!# After function executionPreserving Function Metadata
Section titled “Preserving Function Metadata”When creating decorators, it’s important to preserve the original function’s metadata (name, docstring, etc.):
from functools import wraps
def my_decorator(func): """Decorator that preserves function metadata""" @wraps(func) # Preserves function metadata def wrapper(*args, **kwargs): print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapper
@my_decoratordef greet(name: str) -> str: """Greet someone""" return f"Hello, {name}!"
# Without @wraps:print(greet.__name__) # Would print "wrapper"print(greet.__doc__) # Would print None
# With @wraps:print(greet.__name__) # Prints "greet"print(greet.__doc__) # Prints "Greet someone"Common Decorator Patterns
Section titled “Common Decorator Patterns”1. Timing Decorator
Section titled “1. Timing Decorator”Measure how long a function takes to execute:
import timefrom functools import wraps
def timing(func): """Decorator to measure function execution time""" @wraps(func) def wrapper(*args, **kwargs): start_time = time.time() result = func(*args, **kwargs) end_time = time.time() print(f"{func.__name__} took {end_time - start_time:.4f} seconds") return result return wrapper
@timingdef slow_function(): """Simulate a slow operation""" time.sleep(1) return "Done"
@timingdef fast_function(): """A fast operation""" return "Quick!"
# Usageslow_function() # Prints: slow_function took 1.0001 secondsfast_function() # Prints: fast_function took 0.0001 seconds2. Logging Decorator
Section titled “2. Logging Decorator”Log function calls and arguments:
import loggingfrom functools import wraps
logging.basicConfig(level=logging.INFO)logger = logging.getLogger(__name__)
def log_calls(func): """Decorator to log function calls""" @wraps(func) def wrapper(*args, **kwargs): logger.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}") try: result = func(*args, **kwargs) logger.info(f"{func.__name__} returned: {result}") return result except Exception as e: logger.error(f"{func.__name__} raised {type(e).__name__}: {e}") raise return wrapper
@log_callsdef divide(a: float, b: float) -> float: """Divide two numbers""" return a / b
# Usagedivide(10, 2) # Logs: Calling divide with args=(10, 2), kwargs={}divide(10, 0) # Logs error and raises ZeroDivisionError3. Caching Decorator
Section titled “3. Caching Decorator”Cache function results to avoid recomputation:
from functools import wraps
def simple_cache(func): """Simple caching decorator""" cache = {}
@wraps(func) def wrapper(*args, **kwargs): # Create cache key from arguments key = str(args) + str(sorted(kwargs.items()))
if key in cache: print(f"Cache hit for {func.__name__}") return cache[key]
print(f"Cache miss for {func.__name__}, computing...") result = func(*args, **kwargs) cache[key] = result return result
return wrapper
@simple_cachedef expensive_computation(n: int) -> int: """Expensive computation that we want to cache""" print(f"Computing for n={n}...") return n * n
# Usageexpensive_computation(5) # Cache miss, computesexpensive_computation(5) # Cache hit, returns cached resultexpensive_computation(3) # Cache miss, computesexpensive_computation(3) # Cache hit, returns cached result4. Retry Decorator
Section titled “4. Retry Decorator”Automatically retry a function if it fails:
import timefrom functools import wraps
def retry(max_attempts: int = 3, delay: float = 1.0): """Decorator to retry a function on failure""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception as e: if attempt == max_attempts - 1: raise # Last attempt, raise the exception print(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay}s...") time.sleep(delay) return wrapper return decorator
@retry(max_attempts=3, delay=1.0)def unreliable_function(): """Function that might fail""" import random if random.random() < 0.7: # 70% chance of failure raise ValueError("Random failure!") return "Success!"
# Usage - will retry up to 3 timesunreliable_function()5. Validation Decorator
Section titled “5. Validation Decorator”Validate function arguments:
from functools import wraps
def validate_positive(func): """Decorator to validate that arguments are positive""" @wraps(func) def wrapper(*args, **kwargs): # Check all positional arguments for arg in args: if isinstance(arg, (int, float)) and arg < 0: raise ValueError(f"Argument {arg} must be positive")
# Check all keyword arguments for key, value in kwargs.items(): if isinstance(value, (int, float)) and value < 0: raise ValueError(f"Argument {key}={value} must be positive")
return func(*args, **kwargs) return wrapper
@validate_positivedef calculate_area(length: float, width: float) -> float: """Calculate area of a rectangle""" return length * width
# Usagecalculate_area(5, 4) # Works finecalculate_area(-5, 4) # Raises ValueErrorDecorators with Arguments
Section titled “Decorators with Arguments”Sometimes you need decorators that accept arguments. This requires an extra layer:
from functools import wraps
def repeat(times: int): """Decorator that repeats function execution""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): results = [] for _ in range(times): result = func(*args, **kwargs) results.append(result) return results return wrapper return decorator
@repeat(times=3)def greet(name: str) -> str: """Greet someone""" return f"Hello, {name}!"
# Usageresult = greet("Alice")print(result) # ['Hello, Alice!', 'Hello, Alice!', 'Hello, Alice!']Class Decorators
Section titled “Class Decorators”Decorators can also be applied to classes:
def add_methods(cls): """Class decorator that adds methods to a class""" def new_method(self): return f"New method in {self.__class__.__name__}"
cls.new_method = new_method return cls
@add_methodsclass MyClass: def __init__(self, value: int): self.value = value
# Usageobj = MyClass(42)print(obj.new_method()) # Prints: "New method in MyClass"Singleton Pattern with Class Decorator
Section titled “Singleton Pattern with Class Decorator”def singleton(cls): """Class decorator to make a class a singleton""" instances = {}
def get_instance(*args, **kwargs): if cls not in instances: instances[cls] = cls(*args, **kwargs) return instances[cls]
return get_instance
@singletonclass DatabaseConnection: def __init__(self): print("Creating database connection...") self.connection_id = id(self)
# Usagedb1 = DatabaseConnection() # Prints: Creating database connection...db2 = DatabaseConnection() # No print, returns same instanceprint(db1.connection_id == db2.connection_id) # True - same instance!Built-in Decorators
Section titled “Built-in Decorators”Python provides several useful built-in decorators:
@property, @setter, @deleter
Section titled “@property, @setter, @deleter”Create properties with getters, setters, and deleters:
class Circle: def __init__(self, radius: float): self._radius = radius
@property def radius(self) -> float: """Get the radius""" return self._radius
@radius.setter def radius(self, value: float): """Set the radius with validation""" if value < 0: raise ValueError("Radius must be positive") self._radius = value
@radius.deleter def radius(self): """Delete the radius""" del self._radius
@property def area(self) -> float: """Calculate area (read-only property)""" return 3.14159 * self._radius ** 2
# Usagecircle = Circle(5.0)print(circle.radius) # 5.0print(circle.area) # 78.53975
circle.radius = 10.0 # Uses setterprint(circle.area) # 314.159
circle.radius = -5 # Raises ValueError@staticmethod and @classmethod
Section titled “@staticmethod and @classmethod”class MathUtils: PI = 3.14159
@staticmethod def add(a: int, b: int) -> int: """Static method - doesn't need class or instance""" return a + b
@classmethod def get_pi(cls) -> float: """Class method - receives class as first argument""" return cls.PI
def instance_method(self): """Instance method - receives instance as first argument""" return f"Instance of {self.__class__.__name__}"
# Usageprint(MathUtils.add(5, 3)) # 8 - called on classprint(MathUtils.get_pi()) # 3.14159 - called on classprint(MathUtils().instance_method()) # Instance of MathUtils - needs instance@functools.lru_cache
Section titled “@functools.lru_cache”Cache function results with least-recently-used eviction:
from functools import lru_cache
@lru_cache(maxsize=128)def fibonacci(n: int) -> int: """Calculate Fibonacci number with caching""" if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2)
# Usageprint(fibonacci(30)) # Fast due to cachingprint(fibonacci.cache_info()) # Shows cache statistics@functools.wraps
Section titled “@functools.wraps”Preserve function metadata (we’ve seen this already):
from functools import wraps
def my_decorator(func): @wraps(func) # Preserves name, docstring, etc. def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapperReal-World Examples
Section titled “Real-World Examples”1. Rate Limiting Decorator
Section titled “1. Rate Limiting Decorator”import timefrom functools import wrapsfrom collections import defaultdict
def rate_limit(calls_per_second: float): """Decorator to rate limit function calls""" min_interval = 1.0 / calls_per_second last_called = defaultdict(float)
def decorator(func): @wraps(func) def wrapper(*args, **kwargs): now = time.time() elapsed = now - last_called[func]
if elapsed < min_interval: time.sleep(min_interval - elapsed)
last_called[func] = time.time() return func(*args, **kwargs) return wrapper return decorator
@rate_limit(calls_per_second=2.0) # Max 2 calls per seconddef api_call(): """Simulate an API call""" return "API response"
# Usage - calls are automatically rate-limitedfor _ in range(5): print(api_call()) time.sleep(0.1) # Without decorator, would call 5 times quickly2. Authentication Decorator
Section titled “2. Authentication Decorator”from functools import wraps
# Simulated user databaseusers = { "alice": {"password": "secret123", "role": "admin"}, "bob": {"password": "password", "role": "user"}}
current_user = None
def login(username: str, password: str) -> bool: """Simulate login""" global current_user if username in users and users[username]["password"] == password: current_user = username return True return False
def requires_auth(func): """Decorator to require authentication""" @wraps(func) def wrapper(*args, **kwargs): if current_user is None: raise PermissionError("Authentication required") return func(*args, **kwargs) return wrapper
def requires_role(role: str): """Decorator to require specific role""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): if current_user is None: raise PermissionError("Authentication required") if users[current_user]["role"] != role: raise PermissionError(f"Role '{role}' required") return func(*args, **kwargs) return wrapper return decorator
@requires_authdef get_profile(): """Get user profile - requires authentication""" return f"Profile for {current_user}"
@requires_role("admin")def delete_user(username: str): """Delete user - requires admin role""" return f"Deleted user {username}"
# Usagelogin("alice", "secret123")print(get_profile()) # Worksprint(delete_user("bob")) # Works (alice is admin)
login("bob", "password")print(get_profile()) # Worksprint(delete_user("alice")) # Raises PermissionError (bob is not admin)3. Timing and Logging Combined
Section titled “3. Timing and Logging Combined”import timeimport loggingfrom functools import wraps
logging.basicConfig(level=logging.INFO)logger = logging.getLogger(__name__)
def timing(func): """Timing decorator""" @wraps(func) def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) duration = time.time() - start logger.info(f"{func.__name__} took {duration:.4f}s") return result return wrapper
def log_calls(func): """Logging decorator""" @wraps(func) def wrapper(*args, **kwargs): logger.info(f"Calling {func.__name__} with args={args}") return func(*args, **kwargs) return wrapper
@timing@log_callsdef process_data(data: list) -> list: """Process data with timing and logging""" time.sleep(0.1) # Simulate processing return [x * 2 for x in data]
# Usageresult = process_data([1, 2, 3, 4, 5])# Logs:# Calling process_data with args=([1, 2, 3, 4, 5],)# process_data took 0.1001sCommon Mistakes to Avoid
Section titled “Common Mistakes to Avoid”Mistake 1: Forgetting @wraps
Section titled “Mistake 1: Forgetting @wraps”# ❌ Bad - loses function metadatadef bad_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper
# ✅ Good - preserves function metadatafrom functools import wraps
def good_decorator(func): @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapperMistake 2: Not Handling Exceptions
Section titled “Mistake 2: Not Handling Exceptions”# ❌ Bad - exceptions break the decoratordef bad_decorator(func): @wraps(func) def wrapper(*args, **kwargs): print("Before") result = func(*args, **kwargs) # If this raises, "After" never prints print("After") return result return wrapper
# ✅ Good - handles exceptions properlydef good_decorator(func): @wraps(func) def wrapper(*args, **kwargs): print("Before") try: result = func(*args, **kwargs) print("After") return result except Exception as e: print(f"Error: {e}") raise return wrapperMistake 3: Modifying Mutable Default Arguments
Section titled “Mistake 3: Modifying Mutable Default Arguments”# ❌ Bad - shared mutable statedef bad_decorator(func): cache = {} # Shared across all decorated functions! @wraps(func) def wrapper(*args, **kwargs): key = str(args) + str(kwargs) if key in cache: return cache[key] result = func(*args, **kwargs) cache[key] = result return result return wrapper
# ✅ Good - separate cache per functiondef good_decorator(func): cache = {} # Each decorated function gets its own cache @wraps(func) def wrapper(*args, **kwargs): key = str(args) + str(kwargs) if key in cache: return cache[key] result = func(*args, **kwargs) cache[key] = result return result wrapper.cache = cache # Attach cache to wrapper return wrapperBest Practices
Section titled “Best Practices”- Always use
@wraps(func)- Preserves function metadata - Handle exceptions properly - Don’t let decorators hide errors
- Keep decorators simple - One decorator, one responsibility
- Use type hints - Makes decorators more maintainable
- Document decorators - Explain what they do and any side effects
- Test decorators - Ensure they work with various function signatures
Key Takeaways
Section titled “Key Takeaways”- Decorators are functions that wrap other functions or classes
- They provide a clean way to add cross-cutting concerns
- Use
@wraps(func)to preserve function metadata - Decorators can accept arguments (decorator factories)
- Decorators can be applied to both functions and classes
- Python provides built-in decorators like
@property,@staticmethod,@lru_cache - Multiple decorators can be stacked (execute bottom to top)
- Always handle exceptions properly in decorators
Remember: Decorators let you enhance functions and classes without modifying their core logic - write once, enhance everywhere! ✨