Skip to content

Decorator Factories

Decorator factories create parameterized decorators, allowing you to customize decorator behavior.

Basic Factory Pattern

A decorator factory is a function that returns a decorator:

from functools import wraps

def repeat(times):
    """Decorator factory that repeats function execution."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def greet():
    print("Hello")

greet()  # Prints "Hello" 3 times

Three Levels of Nesting

def factory(param):           # Level 1: Factory (accepts parameters)
    def decorator(func):      # Level 2: Decorator (accepts function)
        @wraps(func)
        def wrapper(*args):   # Level 3: Wrapper (accepts call arguments)
            # Use param, func, and args
            return func(*args)
        return wrapper
    return decorator

Multiple Parameters

Configuration Decorator

from functools import wraps

def log(level="INFO", prefix=""):
    """Configurable logging decorator."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(f"[{level}] {prefix}{func.__name__} called")
            result = func(*args, **kwargs)
            print(f"[{level}] {prefix}{func.__name__} returned {result}")
            return result
        return wrapper
    return decorator

@log(level="DEBUG", prefix=">> ")
def add(a, b):
    return a + b

add(2, 3)
# [DEBUG] >> add called
# [DEBUG] >> add returned 5

Retry with Configuration

from functools import wraps
import time

def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
    """Retry decorator with configurable attempts and delay."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exception = e
                    if attempt < max_attempts:
                        print(f"Attempt {attempt} failed, retrying in {delay}s...")
                        time.sleep(delay)
            raise last_exception
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5, exceptions=(ConnectionError,))
def fetch_data():
    # May fail occasionally
    pass

Preserving Metadata with functools.wraps

The Problem

Without @wraps, the wrapper replaces the original function's metadata:

def decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@decorator
def greet():
    """Say hello."""
    pass

print(greet.__name__)  # 'wrapper' — Lost!
print(greet.__doc__)   # None — Lost!

The Solution

from functools import wraps

def decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@decorator
def greet():
    """Say hello."""
    pass

print(greet.__name__)  # 'greet' — Preserved!
print(greet.__doc__)   # 'Say hello.' — Preserved!

What @wraps Preserves

Attribute Description
__name__ Function name
__doc__ Docstring
__module__ Module where defined
__annotations__ Type hints
__qualname__ Qualified name
__dict__ Function attributes
__wrapped__ Reference to original function

Accessing the Original Function

from functools import wraps

def decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@decorator
def greet():
    """Say hello."""
    print("Hello")

# Access the original unwrapped function
original = greet.__wrapped__

Optional Parameters Pattern

Create decorators that work with or without parentheses:

from functools import wraps

def log(func=None, *, level="INFO"):
    """Decorator that works with or without arguments."""
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            print(f"[{level}] Calling {fn.__name__}")
            return fn(*args, **kwargs)
        return wrapper

    if func is not None:
        # Called without arguments: @log
        return decorator(func)
    # Called with arguments: @log(level="DEBUG")
    return decorator

# Both work:
@log
def func1():
    pass

@log(level="DEBUG")
def func2():
    pass

Factory with Validation

from functools import wraps

def validate_types(*types):
    """Validate argument types."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for arg, expected_type in zip(args, types):
                if not isinstance(arg, expected_type):
                    raise TypeError(
                        f"Expected {expected_type.__name__}, got {type(arg).__name__}"
                    )
            return func(*args, **kwargs)
        return wrapper
    return decorator

@validate_types(int, int)
def add(a, b):
    return a + b

add(2, 3)       # Works: 5
add("2", 3)     # TypeError: Expected int, got str

Summary

Concept Description
Decorator factory Function that returns a decorator
Three levels Factory → Decorator → Wrapper
@wraps(func) Preserves original function metadata
Optional params Support both @deco and @deco()

Best Practices: - Always use @wraps(func) in wrappers - Use keyword-only arguments for clarity - Document factory parameters - Consider optional parameter pattern for flexibility


Runnable Example: decorator_factory_strategy_example.py

"""
Decorator Factory with Strategy Pattern

This example demonstrates a decorator factory that accepts different
output strategies (console, file) for recording function execution.
This combines decorator factories with the strategy design pattern.

Topics covered:
- Decorator factories (decorators that accept parameters)
- Strategy pattern (swappable behavior via function arguments)
- functools.wraps for preserving function metadata
- Timing function execution

Based on concepts from Python-100-Days example09 and ch05/decorators materials.
"""

from functools import wraps
from time import time, sleep


# =============================================================================
# Example 1: Output Strategy Functions
# =============================================================================

def output_to_console(func_name: str, duration: float) -> None:
    """Strategy: print timing info to console."""
    print(f'  [{func_name}] completed in {duration:.3f}s')


def output_to_file(func_name: str, duration: float,
                   filename: str = 'timing_log.txt') -> None:
    """Strategy: append timing info to a log file."""
    with open(filename, 'a') as f:
        f.write(f'{func_name}: {duration:.3f}s\n')
    print(f'  [{func_name}] logged to {filename}')


# =============================================================================
# Example 2: Decorator Factory with Strategy Parameter
# =============================================================================

def record(output_strategy):
    """Decorator factory: create a timing decorator with a given output strategy.

    This is a three-level nesting pattern:
    1. record(strategy) -> returns the actual decorator
    2. decorator(func) -> wraps the target function
    3. wrapper(*args, **kwargs) -> executes and times the function

    Usage:
        @record(output_to_console)
        def my_function():
            ...
    """
    def decorator(func):
        @wraps(func)  # Preserve original function's __name__, __doc__, etc.
        def wrapper(*args, **kwargs):
            start = time()
            result = func(*args, **kwargs)
            duration = time() - start
            output_strategy(func.__name__, duration)
            return result
        return wrapper
    return decorator


# =============================================================================
# Example 3: Using the Decorator Factory
# =============================================================================

@record(output_to_console)
def simulate_api_call(endpoint: str, delay: float = 0.1) -> str:
    """Simulate an API call with artificial delay."""
    sleep(delay)
    return f"Response from {endpoint}"


@record(output_to_file)
def simulate_db_query(query: str, delay: float = 0.05) -> list:
    """Simulate a database query with artificial delay."""
    sleep(delay)
    return [{"id": 1, "data": query}]


# =============================================================================
# Example 4: Flexible Strategy with Lambda
# =============================================================================

@record(lambda name, dur: print(f'  >>> {name} took {dur*1000:.1f}ms'))
def quick_operation():
    """A fast operation with inline strategy."""
    return sum(range(10000))


# =============================================================================
# Example 5: Accessing __wrapped__ to Bypass Decorator
# =============================================================================

def demo_unwrap():
    """functools.wraps adds __wrapped__ attribute for accessing the original."""
    print("\n=== Bypassing Decorator ===")
    print(f"Decorated name: {simulate_api_call.__name__}")

    # Access original function without timing
    original = simulate_api_call.__wrapped__
    result = original("/api/test", delay=0.01)
    print(f"  Direct call (no timing): {result}")


# =============================================================================
# Example 6: Configurable Decorator Factory
# =============================================================================

def timed(*, threshold: float = 0.0, label: str = ""):
    """Decorator factory that only reports when execution exceeds threshold.

    This is a more practical version showing how decorator factories
    can accept keyword arguments for configuration.

    Usage:
        @timed(threshold=0.5, label="SLOW")
        def my_function():
            ...
    """
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            start = time()
            result = func(*args, **kwargs)
            duration = time() - start
            if duration >= threshold:
                tag = f" [{label}]" if label else ""
                print(f"  {func.__name__}{tag}: {duration:.3f}s "
                      f"(threshold: {threshold:.3f}s)")
            return result
        return wrapper
    return decorator


@timed(threshold=0.1, label="SLOW")
def fast_function():
    """This function is fast, so timing won't be reported."""
    return sum(range(100))


@timed(threshold=0.01, label="DB")
def slow_function():
    """This function is slow, so timing will be reported."""
    sleep(0.05)
    return "done"


# =============================================================================
# Main
# =============================================================================

if __name__ == '__main__':
    print("=== Decorator Factory with Strategy Pattern ===\n")

    print("--- Console Strategy ---")
    result = simulate_api_call("/api/users", delay=0.1)
    print(f"  Result: {result}")

    print("\n--- File Strategy ---")
    result = simulate_db_query("SELECT * FROM users")
    print(f"  Result: {result}")

    print("\n--- Lambda Strategy ---")
    result = quick_operation()
    print(f"  Result: {result}")

    demo_unwrap()

    print("\n=== Configurable Threshold Decorator ===")
    fast_function()   # Won't print (under threshold)
    slow_function()   # Will print (over threshold)
    print("  fast_function: no output (under threshold)")