Python Decorators Masterclass: From Basics to Advanced Patterns

!DOCTYPE html> Python Decorators Masterclass: From Basics to Production Patterns

Python Decorators Masterclass: From Basics to Production Patterns

Decorators are one of Python's most powerful features -- used in every major framework. Master them completely, from first principles to real-world patterns like caching, retries, and rate limiting.

How Decorators Work Internally

A decorator is simply a callable that takes a function, wraps it, and returns a new callable. The @ syntax is pure syntactic sugar.

# These two are 100% identical
@my_decorator
def greet():
    print("Hello")

# is exactly the same as:
def greet():
    print("Hello")
greet = my_decorator(greet)

Building one from scratch:

def timer(func):
    import time
    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 expensive_operation(n):
    return sum(i**2 for i in range(n))

expensive_operation(1_000_000)
# expensive_operation took 0.1823s

functools.wraps -- Why It Matters

Without functools.wraps, your decorated function loses its identity -- name, docstring, signature all get replaced by the wrapper's. This breaks introspection, docs, and debugging.

from functools import wraps

def timer(func):
    @wraps(func)   # <- preserves __name__, __doc__, __annotations__, __module__
    def wrapper(*args, **kwargs):
        ...
    return wrapper

@timer
def process(): 
    """Processes data."""
    pass

print(process.__name__)  # "process"   [YES] (without wraps: "wrapper" [NO])
print(process.__doc__)   # "Processes data." [YES]

Decorators with Arguments

To pass arguments to a decorator, you need a third level of nesting -- a factory function that returns the actual decorator.

from functools import wraps
import time

def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
    """Retry a function on failure with configurable attempts and delay."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exc = e
                    print(f"Attempt {attempt}/{max_attempts} failed: {e}")
                    if attempt < max_attempts:
                        time.sleep(delay * attempt)  # exponential-ish backoff
            raise last_exc
        return wrapper
    return decorator

@retry(max_attempts=5, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def fetch_api_data(url: str) -> dict:
    import requests
    resp = requests.get(url, timeout=5)
    resp.raise_for_status()
    return resp.json()

Class-Based Decorators

Use a class decorator when you need to maintain state between calls (e.g., call counts, caches).

from functools import wraps

class RateLimiter:
    """Allow at most `calls` per `period` seconds."""
    def __init__(self, calls: int, period: float):
        self.calls = calls
        self.period = period
        self.timestamps: list[float] = []

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            import time
            now = time.monotonic()
            # Remove timestamps outside the window
            self.timestamps = [t for t in self.timestamps if now - t < self.period]
            if len(self.timestamps) >= self.calls:
                sleep_for = self.period - (now - self.timestamps[0])
                time.sleep(max(0, sleep_for))
            self.timestamps.append(time.monotonic())
            return func(*args, **kwargs)
        return wrapper

@RateLimiter(calls=5, period=1.0)
def call_api(endpoint: str):
    print(f"Calling {endpoint}")

# Will automatically throttle to 5 calls/second

Stacking Multiple Decorators

Decorators apply bottom-up. The decorator closest to the function executes first.

@timer          # applied second (outer)
@retry(3)       # applied first (inner)
def fetch(url):
    ...

# Equivalent to: timer(retry(3)(fetch))

10 Production-Ready Decorator Recipes

1. Memoize / LRU Cache

from functools import lru_cache

@lru_cache(maxsize=256)
def fibonacci(n: int) -> int:
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# Built-in, thread-safe, O(1) lookup

2. Singleton

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 DatabaseConnection:
    def __init__(self): 
        self.conn = create_connection()

3. Type-Check Arguments

def validate_types(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        hints = func.__annotations__
        bound = func.__code__.co_varnames
        for i, (arg, name) in enumerate(zip(args, bound)):
            if name in hints and not isinstance(arg, hints[name]):
                raise TypeError(f"{name}: expected {hints[name].__name__}, got {type(arg).__name__}")
        return func(*args, **kwargs)
    return wrapper

@validate_types
def add(x: int, y: int) -> int:
    return x + y

add(1, 2)    # OK
add(1, "2")  # TypeError: y: expected int, got str

4. Log Every Call

import logging, functools

def log_calls(logger=None):
    def decorator(func):
        _log = logger or logging.getLogger(func.__module__)
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            _log.debug(f"CALL {func.__qualname__}({args!r}, {kwargs!r})")
            result = func(*args, **kwargs)
            _log.debug(f"RETURN {func.__qualname__} -> {result!r}")
            return result
        return wrapper
    return decorator

5. Deprecation Warning

import warnings, functools

def deprecated(reason: str):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            warnings.warn(
                f"{func.__name__} is deprecated: {reason}",
                DeprecationWarning, stacklevel=2
            )
            return func(*args, **kwargs)
        return wrapper
    return decorator

@deprecated("Use new_function() instead")
def old_function(): ...

Async Decorators

import asyncio, time, functools

def async_timer(func):
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = await func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter()-start:.4f}s")
        return result
    return wrapper

def async_retry(max_attempts=3, delay=1.0):
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return await func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    await asyncio.sleep(delay * attempt)
        return wrapper
    return decorator

@async_timer
@async_retry(max_attempts=3, delay=0.5)
async def fetch(url: str) -> dict:
    async with aiohttp.ClientSession() as s:
        async with s.get(url) as r:
            return await r.json()

Key Takeaways: Always use @functools.wraps. Use class-based decorators for stateful behaviour. Layer decorators carefully -- order matters. Build your production toolkit: retry, rate-limit, cache, log, validate.

PythonDecoratorsDesign PatternsfunctoolsProduction PythonAdvanced Python

Post a Comment

0 Comments