23.5 Parametrized Decorators: Decorators That Accept Arguments
Parametrized decorators elevate the concept of decorators from simple function wrappers to powerful, reusable factories of decorator logic. While a standard decorator applies a fixed transformation, a parametrized decorator accepts arguments that customize the behavior of the transformation it applies. This is achieved by structuring the decorator as a function that returns a decorator.
The key to understanding this pattern lies in the three nested layers of function definitions:
- The outermost function accepts the decorator’s own parameters (e.g.,
n=2). - The middle function acts as the standard decorator, accepting the target function to be decorated.
- The innermost function is the actual wrapper that replaces the original function, implementing the customized logic using the parameters from the outermost scope.
The Three-Layer Structure
This structure might seem complex at first, but it arises naturally from Python’s scoping rules and execution model. When the interpreter encounters @decorator_factory(arg), it immediately calls decorator_factory(arg). This call must return a function that is itself a decorator—a function that takes a function and returns a wrapper. This is precisely the role of the middle function.
def repeat(n=2):
"""A parametrized decorator that repeats the function call `n` times."""
# Layer 1: `repeat` is the decorator factory. It accepts parameters.
def actual_decorator(func):
# Layer 2: `actual_decorator` is the actual decorator applied to the function.
def wrapper(*args, **kwargs):
# Layer 3: `wrapper` replaces the original function.
for _ in range(n): # `n` is captured from the outer (factory) scope
result = func(*args, **kwargs)
return result # Returns the result of the last call
return wrapper
return actual_decorator
# Usage: The syntax `@repeat(5)` first calls `repeat(5)`, which returns `actual_decorator`.
# Then, `@actual_decorator` is applied to `greet`.
@repeat(5)
def greet(name):
print(f"Hello, {name}!")
greet("World")
# Output:
# Hello, World!
# Hello, World!
# Hello, World!
# Hello, World!
# Hello, World!
Preserving Function Metadata
A critical pitfall when writing any decorator, parametrized or not, is the unintentional obscuring of the original function’s metadata (e.g., __name__, __doc__, __module__). The wrapper function, by default, inherits none of these attributes. This can break introspection tools and documentation generators. The solution is to use the functools.wraps decorator, but it must be applied correctly inside the decorator factory.
import functools
def repeat(n=2):
def actual_decorator(func):
@functools.wraps(func) # Applied to the wrapper, preserving `func`'s metadata
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return actual_decorator
@repeat(3)
def say_hello():
"""Prints a friendly greeting."""
print("Hello!")
print(say_hello.__name__) # Output: 'say_hello' (not 'wrapper')
print(say_hello.__doc__) # Output: 'Prints a friendly greeting.'
Decorator Factories with Optional Arguments
A more advanced pattern involves creating a decorator that can be used both with and without parentheses (e.g., @decorator and @decorator()). This requires inspecting whether the decorator was called with a function as its first argument. If it was, it means the decorator was used without parentheses, and we should apply the default behavior. If not, we return the decorator function as usual.
def repeat(_func=None, *, n=2): # Keyword-only argument after `*`
def actual_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
# This branch handles the no-parentheses case: @repeat
if _func is None:
return actual_decorator
# This branch handles the case where the first argument is the function itself: @repeat
else:
return actual_decorator(_func)
# Usage with parentheses and parameters
@repeat(n=4)
def func1():
print("Func1")
# Usage without parentheses (uses default n=2)
@repeat
def func2():
print("Func2")
func1() # Prints "Func1" 4 times
func2() # Prints "Func2" 2 times
Best Practices and Common Pitfalls
- Use
functools.wrapsReligiously: Always decorate your wrapper function with@functools.wraps(func). This is non-negotiable for creating professional, debuggable code. - Clarity over Cleverness: The three-layer structure can be confusing. Use clear, descriptive names for each layer (e.g.,
decorator_factory,decorator,wrapper) during development to keep the logic clear, even if you shorten them later. - Mind the Callable Types: Parametrized decorators are designed for functions. Applying them to classes requires the same three-layer structure, but the inner wrapper would typically return a class, not a function. The principles remain identical.
- State in Closures: Be cautious when the inner wrapper modifies data structures or variables captured from the outer scopes. This shared state can lead to subtle bugs if multiple decorated functions use the same decorator parameters, as they will share the same closure. For state that should be unique to each decorated function, initialize it inside the wrapper.