How to stack multiple decorators

PythonBeginner
Practice Now

Introduction

Python decorators are powerful tools that allow developers to modify or enhance functions without changing their core implementation. This tutorial explores the advanced technique of stacking multiple decorators, demonstrating how to layer different decorators to create more complex and flexible function behaviors in Python programming.

Decorator Basics

What is a Decorator?

In Python, a decorator is a powerful and flexible way to modify or enhance functions or classes without directly changing their source code. Essentially, decorators are functions that take another function as an argument and return a modified version of that function.

Basic Decorator Syntax

def my_decorator(func):
    def wrapper():
        print("Something before the function is called.")
        func()
        print("Something after the function is called.")
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

say_hello()

Key Characteristics of Decorators

Function Wrapping

Decorators wrap around existing functions, adding extra functionality before or after the original function execution.

graph LR
    A[Original Function] --> B[Decorator Wrapper]
    B --> C[Enhanced Functionality]

Types of Decorators

Decorator Type Description Example Use Case
Function Decorators Modify function behavior Logging, timing
Class Decorators Modify class behavior Singleton pattern
Method Decorators Modify method behavior Access control

Practical Example: Timing Decorator

import time

def timer_decorator(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"Function {func.__name__} took {end_time - start_time} seconds to execute.")
        return result
    return wrapper

@timer_decorator
def slow_function():
    time.sleep(2)
    print("Slow function completed")

slow_function()

Decorator with Arguments

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    print(f"Hello, {name}!")

greet("LabEx User")

Common Use Cases

  1. Logging
  2. Performance measurement
  3. Authentication
  4. Caching
  5. Input validation

By understanding these basics, you'll be well-prepared to explore more advanced decorator techniques in Python. LabEx recommends practicing these concepts to gain proficiency.

Chaining Decorators

Understanding Decorator Chaining

Decorator chaining allows multiple decorators to be applied to a single function. When multiple decorators are used, they are applied from bottom to top.

graph TD
    A[Original Function] --> B[Decorator 1]
    B --> C[Decorator 2]
    C --> D[Decorator 3]
    D --> E[Final Enhanced Function]

Basic Decorator Chaining Example

def decorator1(func):
    def wrapper(*args, **kwargs):
        print("Decorator 1 - Before function")
        result = func(*args, **kwargs)
        print("Decorator 1 - After function")
        return result
    return wrapper

def decorator2(func):
    def wrapper(*args, **kwargs):
        print("Decorator 2 - Before function")
        result = func(*args, **kwargs)
        print("Decorator 2 - After function")
        return result
    return wrapper

@decorator1
@decorator2
def example_function():
    print("Inside the original function")

example_function()

Practical Chaining Scenarios

Logging and Timing Decorator

import time
import functools

def log_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling function: {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

def timer_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"Function took {end_time - start_time:.4f} seconds")
        return result
    return wrapper

@log_decorator
@timer_decorator
def complex_calculation(n):
    return sum(i**2 for i in range(n))

complex_calculation(10000)

Decorator Chaining Considerations

Consideration Description Impact
Order Matters Decorators apply from bottom to top Changes function behavior
Performance Multiple decorators can add overhead Consider complexity
Readability Too many decorators can reduce code clarity Use judiciously

Advanced Chaining with Parameters

def validate_input(min_value=0, max_value=100):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for arg in args:
                if not (min_value <= arg <= max_value):
                    raise ValueError(f"Input must be between {min_value} and {max_value}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

@validate_input(min_value=10, max_value=50)
@log_decorator
def process_number(x):
    return x * 2

process_number(30)  ## Works
## process_number(5)  ## Raises ValueError

Best Practices

  1. Use functools.wraps to preserve function metadata
  2. Keep decorators focused and single-purpose
  3. Be mindful of performance implications
  4. Document complex decorator chains

LabEx recommends practicing decorator chaining to master this powerful Python technique.

Practical Use Cases

Authentication and Authorization

def require_auth(role):
    def decorator(func):
        def wrapper(*args, **kwargs):
            ## Simulated authentication check
            current_user = {
                'username': 'admin',
                'role': 'admin'
            }
            if current_user['role'] == role:
                return func(*args, **kwargs)
            else:
                raise PermissionError("Unauthorized access")
        return wrapper
    return decorator

@require_auth('admin')
def delete_user(user_id):
    print(f"Deleting user {user_id}")

delete_user(123)  ## Works for admin

Caching Mechanism

def memoize(func):
    cache = {}
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(100))  ## Efficient computation

Performance Monitoring

import time
import functools

def performance_tracker(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"{func.__name__} executed in {end_time - start_time:.4f} seconds")
        return result
    return wrapper

@performance_tracker
def heavy_computation(n):
    return sum(i**2 for i in range(n))

heavy_computation(1000000)

Rate Limiting

from functools import wraps
import time

def rate_limit(max_calls, time_frame):
    calls = []
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            now = time.time()
            calls[:] = [c for c in calls if now - c < time_frame]

            if len(calls) >= max_calls:
                raise Exception("Rate limit exceeded")

            calls.append(now)
            return func(*args, **kwargs)
        return wrapper
    return decorator

@rate_limit(max_calls=3, time_frame=60)
def api_call():
    print("API called successfully")

## Demonstrates rate limiting
for _ in range(4):
    try:
        api_call()
    except Exception as e:
        print(e)

Logging and Debugging

import logging
import functools

def log_calls(logger=None):
    if logger is None:
        logger = logging.getLogger(__name__)

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            logger.info(f"Calling {func.__name__}")
            try:
                result = func(*args, **kwargs)
                logger.info(f"{func.__name__} completed successfully")
                return result
            except Exception as e:
                logger.error(f"Error in {func.__name__}: {e}")
                raise
        return wrapper
    return decorator

@log_calls()
def divide(a, b):
    return a / b

divide(10, 2)
## divide(10, 0)  ## Would log an error

Use Case Comparison

Use Case Primary Purpose Key Benefits
Authentication Control access Security
Caching Optimize performance Reduced computation
Monitoring Track performance Insights
Rate Limiting Prevent abuse Resource protection
Logging Track function calls Debugging

Advanced Decorator Patterns

graph TD
    A[Decorator Patterns] --> B[Functional Decorators]
    A --> C[Class Decorators]
    A --> D[Parametrized Decorators]
    A --> E[Chained Decorators]

Best Practices

  1. Keep decorators focused
  2. Use functools.wraps to preserve metadata
  3. Handle exceptions gracefully
  4. Consider performance implications

LabEx recommends exploring these practical use cases to enhance your Python programming skills.

Summary

By understanding how to stack multiple decorators in Python, developers can create more modular, reusable, and sophisticated code. This technique enables precise function modification, allows combining different decorators with ease, and provides a clean, readable approach to extending function capabilities without compromising the original function's core logic.