Decorator Factories with Arguments
Overview
A decorator factory is a function that returns a decorator. Standard decorators only accept one argument, which is the function being decorated. To pass custom arguments, you create an outer function that receives those arguments and returns the actual decorator. @retry(max_retries=3) def fetch_data(): pass This architecture requires exactly three nested levels of functions: - Factory: Receives the configuration arguments. - Decorator: Receives the target function. - Wrapper: Receives the target function's runtime arguments. Think of the factory as a machine that configures and builds a custom decorator based on the specs you give it. A sharp edge arises when you forget the parentheses on a factory, or try to support both @retry and @retry(max=3). If you omit the parentheses, Python passes the target function as the first configuration argument. @retry # Passes fetch_data into max_retries! def fetch_data(): pass Supporting both syntaxes requires checking if the first argument is callable(), which adds significant complexity to your factory.
It enables configurable decorators. Instead of writing separate decorators for different behaviors, you write one configurable factory.
Where used: Flask routing (@app.route('/login')), FastAPI endpoints (@app.get('/items')), Pytest parametrization (@pytest.mark.parametrize)
Why learn this
- Allows you to write dynamic and highly reusable decorators.
- Required to understand how modern Python web frameworks handle routing and configuration.
Code walkthrough
def multiply_result_by(factor):
def decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs) * factor
return wrapper
return decorator
@multiply_result_by(1.2)
def get_base_price(item_id):
return 50.0
print(get_base_price('item_42'))
Focus: return func(*args, **kwargs) * factor
Aha moment
def route(path):
print(f'Registering {path}')
def decorator(func):
print('Decorator attached')
def wrapper(*args, **kwargs):
print('Handling request')
return func(*args, **kwargs)
return wrapper
return decorator
@route('/home')
def home_view():
return 'Welcome'
print('App started')
Prediction: What is the exact order of the printed lines when this script runs?
Common guess: App started Registering /home Decorator attached Handling request
The factory and decorator run at module load time to register the route before the app even starts. The wrapper only runs when a client actually makes a request, which doesn't happen here.
Common mistakes
- Missing a level of nesting: Writing a factory with only two levels (factory and wrapper) instead of three. The factory must return a decorator, which in turn returns a wrapper.
- Applying the factory without calling it: Writing @retry instead of @retry(). Even if the factory has default arguments, you must call it with parentheses so it executes and returns the actual decorator.
Glossary
- decorator
- A special function that wraps another function to modify or enhance its behavior without changing its code, e.g. @retry(max_retries=3).
- wrapper
- An inner function that surrounds the original function, allowing it to run extra code before or after the original function runs, e.g. def wrapper(*args): return func(*args).
Recall questions
- Why is a third level of nesting required when creating a decorator that takes arguments?
- What does the outermost function in a decorator factory return?
Understanding checks
What happens when you run this code?
A `TypeError` is raised when `say_hello()` is called: `say_hello` is now bound to `decorator` (since `@prefix` passed the function itself as `p`), and calling `decorator()` with no arguments fails because `decorator` requires the `func` parameter.
The syntax @prefix means say_hello = prefix(say_hello). prefix is a factory expecting the string p, but it receives the function say_hello. It returns decorator, meaning say_hello is now the decorator function itself. Decorator factories must be called: @prefix('Hello').
When defining a decorator factory, why do we need three levels of nested functions instead of two?
Because the factory needs to take the configuration arguments, return a decorator that takes the target function, which in turn returns the wrapper that takes the runtime arguments.
Standard decorators take the function automatically. A factory function isn't a decorator itself; it's a function that returns a decorator. Thus, Level 1: take config arguments, Level 2: take target function, Level 3: take target function's arguments.
Practice tasks
Add Configuration to a Decorator
We have a basic `@repeat` decorator that repeats a function 3 times. Modify it into a decorator factory `repeat(times)` so we can configure the number of repeats per function.
Challenge
Rate Limiting Factory
Create a decorator factory `rate_limit` that accepts an integer `max_requests`. It should decorate a function representing an API endpoint. If the endpoint is called more than `max_requests` times, the wrapper should return the string `'Rate limit exceeded'`. Otherwise, it should increment a counter and return the function's result. You can use an attribute on the wrapper itself to store the call count.
Continue learning
Previous: Function Decorators
Previous: Closures
Previous: functools.wraps and preserving metadata