DEV Community

Cover image for The Parameter Laboratory: Decorators with Arguments
Aaron Rose
Aaron Rose

Posted on

The Parameter Laboratory: Decorators with Arguments

Timothy had mastered basic decorators, but the head librarian's next request revealed a limitation. "I need different logging levels for different functions—some should log detailed information, others just errors. Can your decorators handle configuration?"

Margaret led him deeper into the Function Modification Station, to a section labeled "The Parameter Laboratory" where decorators themselves could accept arguments.

The Configuration Problem

Timothy's basic logging decorator was inflexible:

@add_logging
def catalog_book(title):
    return f"Cataloged: {title}"
Enter fullscreen mode Exit fullscreen mode

Every function got the same logging behavior. But what if Timothy wanted to specify the logging level?

@add_logging(level="DEBUG")
def catalog_book(title):
    return f"Cataloged: {title}"

@add_logging(level="ERROR")
def delete_book(book_id):
    return f"Deleted: {book_id}"
Enter fullscreen mode Exit fullscreen mode

Margaret explained this required an extra layer of function nesting.

The Triple-Layer Pattern

"When a decorator needs arguments," Margaret said, "you need three layers instead of two."

def add_logging(level="INFO"):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(f"[{level}] Calling {func.__name__}")
            result = func(*args, **kwargs)
            print(f"[{level}] Completed {func.__name__}")
            return result
        return wrapper
    return decorator
Enter fullscreen mode Exit fullscreen mode

Timothy traced the logic:

  1. Outer function (add_logging) accepts configuration parameters and returns a decorator
  2. Middle function (decorator) accepts the function to be decorated and returns a wrapper
  3. Inner function (wrapper) executes the actual enhanced behavior

When Python encountered @add_logging(level="DEBUG"), it first called add_logging("DEBUG") which returned the actual decorator function, then applied that decorator to the target function.

The Execution Flow

Timothy visualized how the layers unwrapped:

@add_logging(level="DEBUG")
def search_catalog(query):
    return f"Searching: {query}"

# Equivalent to:
# temp_decorator = add_logging(level="DEBUG")
# search_catalog = temp_decorator(search_catalog)
Enter fullscreen mode Exit fullscreen mode

The parentheses after @add_logging triggered a function call that produced the actual decorator, which then wrapped the function below it.

The Retry Pattern

Margaret showed Timothy a practical example: a retry decorator that attempted failed operations multiple times.

def retry(attempts=3, delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as error:
                    if attempt == attempts - 1:
                        raise
                    print(f"Attempt {attempt + 1} failed, retrying...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(attempts=3, delay=2)
def fetch_remote_catalog():
    # Might fail due to network issues
    return fetch_data_from_server()
Enter fullscreen mode Exit fullscreen mode

The configuration parameters (attempts and delay) controlled the retry behavior without changing the decorator's core logic.

The Optional Arguments Pattern

Timothy discovered a challenge: what if arguments should be optional?

@add_logging  # No parentheses - should use defaults
def some_function():
    pass

@add_logging(level="DEBUG")  # With arguments
def other_function():
    pass
Enter fullscreen mode Exit fullscreen mode

Margaret showed him the solution using a conditional check:

def add_logging(func=None, *, level="INFO"):
    def decorator(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            print(f"[{level}] {f.__name__}")
            return f(*args, **kwargs)
        return wrapper

    if func is None:
        return decorator
    else:
        return decorator(func)
Enter fullscreen mode Exit fullscreen mode

The * forced level to be keyword-only, and checking whether func was provided determined whether arguments were used.

The Validation Decorator

Timothy created a decorator that validated function arguments:

def validate_range(min_value=0, max_value=100):
    def decorator(func):
        @wraps(func)
        def wrapper(value):
            if not min_value <= value <= max_value:
                raise ValueError(
                    f"Value {value} outside range [{min_value}, {max_value}]"
                )
            return func(value)
        return wrapper
    return decorator

@validate_range(min_value=1, max_value=999)
def set_book_count(count):
    return f"Setting count to {count}"
Enter fullscreen mode Exit fullscreen mode

The decorator's parameters configured the validation boundaries without cluttering the function logic.

The Rate Limiting Example

Margaret demonstrated a sophisticated pattern: rate limiting function calls.

def rate_limit(max_calls=10, time_window=60):
    def decorator(func):
        calls = []

        @wraps(func)
        def wrapper(*args, **kwargs):
            now = time.time()
            calls[:] = [call_time for call_time in calls 
                       if now - call_time < time_window]

            if len(calls) >= max_calls:
                raise Exception(
                    f"Rate limit exceeded: {max_calls} calls per {time_window}s"
                )

            calls.append(now)
            return func(*args, **kwargs)

        return wrapper
    return decorator

@rate_limit(max_calls=5, time_window=60)
def search_external_database(query):
    return perform_search(query)
Enter fullscreen mode Exit fullscreen mode

The decorator maintained state (the calls list) in its closure, using the parameters to configure the rate limiting thresholds.

The Class-Based Alternative

Timothy learned that decorators could also be implemented as classes:

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"Call {self.call_count} to {self.func.__name__}")
        return self.func(*args, **kwargs)

@CountCalls
def process_book(title):
    return f"Processing: {title}"
Enter fullscreen mode Exit fullscreen mode

The __init__ method received the function, and __call__ made instances callable, executing the wrapped behavior. For decorators with parameters, the pattern extended:

class LogLevel:
    def __init__(self, level="INFO"):
        self.level = level

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(f"[{self.level}] {func.__name__}")
            return func(*args, **kwargs)
        return wrapper

@LogLevel(level="DEBUG")
def catalog_item(item):
    return f"Cataloged: {item}"
Enter fullscreen mode Exit fullscreen mode

The Decorator Factory Pattern

Margaret emphasized the mental model: decorators with arguments were decorator factories.

# Without arguments - this IS the decorator
@simple_decorator
def func():
    pass

# With arguments - this CREATES the decorator
@decorator_factory(arg1, arg2)
def func():
    pass
Enter fullscreen mode Exit fullscreen mode

The factory accepted configuration and returned a customized decorator, which then wrapped the target function.

Timothy's Advanced Decorator Wisdom

Through exploring the Parameter Laboratory, Timothy learned key principles:

Three layers for arguments: Outer function accepts parameters, middle accepts the function, inner executes the wrapper.

Factories create decorators: @decorator(args) calls the factory to produce the actual decorator.

Closures hold configuration: Parameters from the outer function remain accessible in the wrapper.

Optional arguments need special handling: Check whether the function was provided directly or configuration was given.

Classes work too: Implement __call__ to make instances act as decorators.

State can be maintained: The decorator's closure can store data across calls.

Timothy's mastery of parameterized decorators revealed that decorators could be as configurable as needed. The three-layer pattern seemed complex at first, but it followed logically from the basic decorator pattern—just one more level of abstraction to handle configuration. The Parameter Laboratory transformed decorators from simple wrappers into sophisticated, configurable enhancement systems.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)