Decorator Factories¶
Decorator factories create parameterized decorators, allowing you to customize decorator behavior.
Mental Model
A decorator factory is a function that returns a decorator -- it adds one extra layer of nesting so you can pass configuration arguments. When you write @repeat(3), Python first calls repeat(3) to get a decorator, then applies that decorator to your function. Three nested functions, three distinct jobs: configure, wrap, execute.
Basic Factory Pattern¶
A decorator factory is a function that returns a decorator:
```python 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¶
python
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¶
```python 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¶
```python 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:
```python 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¶
```python 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¶
```python 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:
```python 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¶
```python 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¶
```python """ 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)")
```
Exercises¶
Exercise 1.
Write a decorator factory tag(tag_name) that wraps the string return value of a function in an HTML tag. For example, @tag("b") applied to a function returning "hello" should produce "<b>hello</b>". Use @wraps to preserve metadata.
Solution to Exercise 1
from functools import wraps
def tag(tag_name):
"""Decorator factory that wraps return value in an HTML tag."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f"<{tag_name}>{result}</{tag_name}>"
return wrapper
return decorator
@tag("b")
def greet(name):
"""Return a greeting."""
return f"Hello, {name}"
print(greet("Alice")) # <b>Hello, Alice</b>
print(greet.__name__) # greet
print(greet.__doc__) # Return a greeting.
Exercise 2.
Create a decorator factory enforce_types that reads a function's type annotations and raises a TypeError if any argument does not match its annotation at call time. It should silently ignore parameters without annotations. Demonstrate it on a function def greet(name: str, times: int) -> str.
Solution to Exercise 2
from functools import wraps
import inspect
def enforce_types(func):
"""Decorator that enforces type annotations at call time."""
hints = func.__annotations__
sig = inspect.signature(func)
@wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for name, value in bound.arguments.items():
if name in hints and name != 'return':
expected = hints[name]
if not isinstance(value, expected):
raise TypeError(
f"Argument '{name}' must be {expected.__name__}, "
f"got {type(value).__name__}"
)
return func(*args, **kwargs)
return wrapper
@enforce_types
def greet(name: str, times: int) -> str:
return (name + "! ") * times
print(greet("Alice", 3)) # Alice! Alice! Alice!
try:
greet("Alice", "three") # TypeError
except TypeError as e:
print(e)
Exercise 3.
Write an optional-parameter decorator factory debug(func=None, *, show_args=True, show_result=True) that works both as @debug and @debug(show_result=False). When show_args is True, print the arguments before the call. When show_result is True, print the return value after the call.
Solution to Exercise 3
from functools import wraps
def debug(func=None, *, show_args=True, show_result=True):
"""Debug decorator that works with or without arguments."""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if show_args:
print(f"Calling {fn.__name__}({args}, {kwargs})")
result = fn(*args, **kwargs)
if show_result:
print(f"{fn.__name__} returned {result!r}")
return result
return wrapper
if func is not None:
return decorator(func)
return decorator
@debug
def add(a, b):
return a + b
@debug(show_result=False)
def multiply(a, b):
return a * b
add(2, 3) # prints args and result
multiply(4, 5) # prints args only