Decorators in Python
Decorators are a powerful feature in Python that allows you to modify or enhance functions and methods without changing their core implementation. They work by wrapping a function within another function, enabling you to execute code before and after the wrapped function runs.
Decorator Fundamentals
Basic Decorator Structure
At its core, a decorator is a function that takes another function as an argument and returns a new function that usually extends the behavior of the original function.
# Basic decorator pattern def my_decorator(func): def wrapper(): print("Something happens before the function is called.") func() print("Something happens after the function is called.") return wrapper # Applying the decorator manually def say_hello(): print("Hello!") # This is what happens behind the scenes with the @ syntax say_hello = my_decorator(say_hello) say_hello() # Output: # Something happens before the function is called. # Hello! # Something happens after the function is called. # The same using decorator syntax @my_decorator def say_goodbye(): print("Goodbye!") say_goodbye() # Output is the same pattern as above
Decorators with Arguments
You can create decorators that handle functions with arguments by using *args and **kwargs.
# Decorator for functions with arguments def my_decorator(func): def wrapper(*args, **kwargs): print(f"Function {func.__name__} is about to be called with {args} and {kwargs}") result = func(*args, **kwargs) print(f"Function {func.__name__} returned {result}") return result return wrapper @my_decorator def add(a, b): return a + b result = add(3, 5) # Output: # Function add is about to be called with (3, 5) and {} # Function add returned 8
Preserving Metadata
When you decorate a function, the metadata of the original function (like name, docstring) is lost.
Use the @functools.wraps
decorator to preserve this information.
# Without wraps, metadata is lost def my_decorator(func): def wrapper(*args, **kwargs): """Wrapper function""" return func(*args, **kwargs) return wrapper @my_decorator def greet(name): """Greet a person by name""" return f"Hello, {name}!" print(greet.__name__) # Output: 'wrapper' print(greet.__doc__) # Output: 'Wrapper function' # Using functools.wraps to preserve metadata import functools def better_decorator(func): @functools.wraps(func) # Preserves metadata def wrapper(*args, **kwargs): """Wrapper function""" return func(*args, **kwargs) return wrapper @better_decorator def greet_better(name): """Greet a person by name""" return f"Hello, {name}!" print(greet_better.__name__) # Output: 'greet_better' print(greet_better.__doc__) # Output: 'Greet a person by name'
Common Use Cases
Timing Functions
A common use for decorators is to measure the execution time of functions.
import time
import functools
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"{func.__name__} ran in {end_time - start_time:.6f} seconds")
return result
return wrapper
@timer
def slow_function(delay=1.0):
"""Simulates a slow function"""
time.sleep(delay)
return "Function executed"
slow_function(0.5)
# Output: slow_function ran in 0.500123 seconds
Logging and Debugging
Decorators can be used to add logging behavior to functions without cluttering the function's core logic.
import functools import logging # Set up basic logging logging.basicConfig(level=logging.INFO) def log_function_call(func): @functools.wraps(func) def wrapper(*args, **kwargs): args_repr = [repr(a) for a in args] kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()] signature = ", ".join(args_repr + kwargs_repr) logging.info(f"Calling {func.__name__}({signature})") result = func(*args, **kwargs) logging.info(f"{func.__name__} returned {result!r}") return result return wrapper @log_function_call def calculate_area(length, width): area = length * width return area result = calculate_area(10, width=5) # Logs: # INFO:root:Calling calculate_area(10, width=5) # INFO:root:calculate_area returned 50
Authentication and Authorization
Decorators can enforce authentication and authorization checks before executing a function.
def require_auth(func): @functools.wraps(func) def wrapper(user, *args, **kwargs): if not user.is_authenticated: raise PermissionError("Authentication required") return func(user, *args, **kwargs) return wrapper # Simulated User class class User: def __init__(self, name, is_authenticated=False, is_admin=False): self.name = name self.is_authenticated = is_authenticated self.is_admin = is_admin # Decorator for admin permissions def admin_only(func): @functools.wraps(func) def wrapper(user, *args, **kwargs): if not user.is_admin: raise PermissionError("Admin privileges required") return func(user, *args, **kwargs) return wrapper # Using both decorators @require_auth @admin_only def delete_user(current_user, user_to_delete): print(f"{current_user.name} deleted user {user_to_delete}") return True # Try with different users admin_user = User("Admin", is_authenticated=True, is_admin=True) regular_user = User("Regular", is_authenticated=True, is_admin=False) guest_user = User("Guest", is_authenticated=False) # This will work delete_user(admin_user, "John") # This will raise PermissionError: Admin privileges required # delete_user(regular_user, "John") # This will raise PermissionError: Authentication required # delete_user(guest_user, "John")
Caching and Memoization
Decorators can add caching functionality to avoid redundant computations.
import functools def memoize(func): """Cache the results of a function based on its arguments""" cache = {} @functools.wraps(func) def wrapper(*args, **kwargs): # Create a key that uniquely identifies the function call key = str(args) + str(kwargs) if key not in cache: cache[key] = func(*args, **kwargs) print(f"Calculated result for {key}: {cache[key]}") else: print(f"Using cached result for {key}: {cache[key]}") return cache[key] return wrapper @memoize def fibonacci(n): """Calculate the Fibonacci number recursively""" if n <= 1: return n return fibonacci(n-1) + fibonacci(n-2) # First call - computes and caches results print(fibonacci(5)) # Second call - uses cached results print(fibonacci(5))
Advanced Decorator Patterns
Decorators with Parameters
You can create decorators that accept their own parameters by adding another layer of nesting.
def repeat(num_times):
"""Decorator that repeats the function call `num_times` times"""
def decorator_repeat(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
results = []
for _ in range(num_times):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator_repeat
@repeat(3)
def greet(name):
return f"Hello, {name}!"
print(greet("World"))
# Output: ['Hello, World!', 'Hello, World!', 'Hello, World!']
Class-based Decorators
Decorators can also be implemented as classes, using the __call__
method to make the class instance callable.
class CountCalls: """Class decorator that counts how many times a function has been called""" def __init__(self, func): functools.update_wrapper(self, func) self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 print(f"{self.func.__name__} has been called {self.count} times") return self.func(*args, **kwargs) @CountCalls def say_hello(): return "Hello!" # Call the function multiple times say_hello() # Output: say_hello has been called 1 times say_hello() # Output: say_hello has been called 2 times say_hello() # Output: say_hello has been called 3 times
Stateful Decorators
Decorators can maintain state between function calls.
def rate_limiter(max_calls, period): """Limit function calls to max_calls per period seconds""" calls = [] def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): current_time = time.time() # Remove old calls outside the period while calls and current_time - calls[0] > period: calls.pop(0) # Check if we've hit the rate limit if len(calls) >= max_calls: time_to_wait = period - (current_time - calls[0]) print(f"Rate limit exceeded. Try again in {time_to_wait:.2f} seconds.") return None # Add current call timestamp calls.append(current_time) return func(*args, **kwargs) return wrapper return decorator # Allow 2 calls per 5 seconds @rate_limiter(max_calls=2, period=5) def fetch_data(): return "Data fetched successfully" # Try calling multiple times in quick succession print(fetch_data()) # Works print(fetch_data()) # Works print(fetch_data()) # Rate limit message
Decorator Stacking
Multiple decorators can be stacked on a single function, executing from the innermost to the outermost.
def uppercase(func): @functools.wraps(func) def wrapper(*args, **kwargs): result = func(*args, **kwargs) return result.upper() return wrapper def split_words(func): @functools.wraps(func) def wrapper(*args, **kwargs): result = func(*args, **kwargs) return result.split() return wrapper # Decorators are applied from bottom to top @uppercase @split_words def greet(name): return f"hello {name}" print(greet("world")) # First 'hello world' is split into ['hello', 'world'] # Then uppercase is applied: ['HELLO', 'WORLD']
Best Practices and Patterns
Decorator Best Practices
- Always use
functools.wraps
to preserve the original function's metadata - Make decorators robust by handling *args and **kwargs
- Keep decorators focused on a single responsibility
- Document how the decorator modifies the function's behavior
- Consider making decorators optional with sensible defaults
- Be careful with stateful decorators in multi-threaded environments
Common Patterns
Pattern | Use Case |
---|---|
Pre/Post Processing | Run code before/after a function (logging, validation) |
Function Registration | Register functions in a registry (routes in web frameworks) |
Result Modification | Transform the result of a function (caching, formatting) |
Retry Logic | Retry a function call on failure with backoff |
Context Management | Set up and tear down resources around function calls |
Example: Retry Decorator
Here's a practical example of a retry decorator with exponential backoff:
import time import random import functools def retry(max_tries=3, delay_seconds=1, backoff=2, exceptions=(Exception,)): """Retry decorator with exponential backoff""" def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): mtries, mdelay = max_tries, delay_seconds while mtries > 0: try: return func(*args, **kwargs) except exceptions as e: mtries -= 1 if mtries == 0: raise print(f"Function {func.__name__} failed with {e}. Retrying in {mdelay} seconds...") time.sleep(mdelay) mdelay *= backoff return func(*args, **kwargs) return wrapper return decorator # Example usage with a flaky function @retry(max_tries=3, delay_seconds=1, backoff=2, exceptions=(ValueError,)) def flaky_function(): """This function randomly fails""" if random.random() < 0.7: # 70% chance of failure raise ValueError("Random failure!") return "Success!" # Try calling the flaky function try: result = flaky_function() print(result) except ValueError as e: print(f"Function failed after retries: {e}")
Real-World Examples
Python Standard Library Decorators
The Python standard library includes useful decorators that you can use in your code:
@classmethod
- Defines a method that operates on the class rather than instances@staticmethod
- Defines a method that doesn't require access to the class or instance@property
- Creates a getter method for an attribute, enabling property-like access@functools.lru_cache
- Adds least-recently-used caching to functions@functools.singledispatch
- Enables function overloading based on argument types@contextlib.contextmanager
- Creates a context manager from a generator function
# Example of @property decorator class Circle: def __init__(self, radius): self._radius = radius @property def radius(self): """Get the radius of the circle""" return self._radius @radius.setter def radius(self, value): """Set the radius of the circle""" if value < 0: raise ValueError("Radius cannot be negative") self._radius = value @property def area(self): """Calculate the area of the circle""" import math return math.pi * self._radius ** 2 # Usage circle = Circle(5) print(circle.radius) # 5 print(circle.area) # 78.54... circle.radius = 10 print(circle.radius) # 10 print(circle.area) # 314.16... # This will raise a ValueError # circle.radius = -1
Decorators in Web Frameworks
Web frameworks like Flask and Django make heavy use of decorators for route definitions, authentication, and more:
# Example of Flask route decorators from flask import Flask, request, jsonify app = Flask(__name__) # Route decorator defines URL patterns @app.route('/api/items', methods=['GET']) def get_items(): return jsonify({"items": ["item1", "item2"]}) # Authentication decorator def login_required(func): @functools.wraps(func) def wrapper(*args, **kwargs): auth_header = request.headers.get('Authorization') if not auth_header: return jsonify({"error": "Authentication required"}), 401 # Here you would validate the token # For this example, we just check if the token is "valid_token" token = auth_header.split(" ")[1] if len(auth_header.split(" ")) > 1 else "" if token != "valid_token": return jsonify({"error": "Invalid token"}), 401 return func(*args, **kwargs) return wrapper # Using both decorators @app.route('/api/protected', methods=['GET']) @login_required def protected_endpoint(): return jsonify({"message": "You've accessed the protected endpoint"})
Practice Exercises
Try These:
- Create a decorator that measures and prints the execution time of a function.
- Write a decorator that validates that all arguments to a function are of a specific type (e.g., strings).
- Implement a decorator that limits the rate at which a function can be called (e.g., only once per second).
- Create a caching decorator that saves function results to a file and loads them on subsequent calls.
- Build a decorator that retries a function with exponential backoff if it raises specific exceptions.