Skip to main content

Decorators with arguments & functools.wraps

Level 4: Level 4: Engineeringhard20 mindecoratorsfunctoolsclosuresmetaprogramming

Write a parameterized, well-behaved decorator that preserves the wrapped function.

Decorators that take arguments

Real decorators almost always need configuration. @retry(times=3), @lru_cache(maxsize=128), and Flask's @app.route("/users") all take arguments, because you cannot hardcode a retry count or a route into the decorator itself. A decorator that accepts arguments is called a decorator factory, and it is the pattern you reach for whenever the behavior you are adding needs to be tuned per function.

The three-layer model

A plain decorator is one function: it takes fn and returns a replacement. A parameterized decorator adds one outer layer that takes the config and returns that plain decorator. So there are three nested callables, each taking a different thing:

def multiply_by(factor):              # factory: takes the config
    def decorator(fn):                # decorator: takes the function
        def wrapper(*args, **kwargs): # wrapper: takes the call
            return fn(*args, **kwargs) * factor
        return wrapper
    return decorator
Check yourself
You drop a print('building') as the first line inside decorator(fn), above the def wrapper line. Three functions are decorated with @multiply_by(3), and each one is then called ten times. How many times does 'building' print?

Read @multiply_by(3) written above add as two steps:

add = multiply_by(3)(add)
# step 1: multiply_by(3) runs and returns `decorator`
# step 2: decorator(add) runs and returns `wrapper`, rebound to the name `add`
Table
The highlighted column explains the classic mistake. Two layers run once when the module loads and only the third runs per call, so writing @multiply_by without the parentheses skips the factory entirely and hands your function in as factor.
LayerTakesReturnsRuns
multiply_by(factor)the config, 3the decoratoronce, at the @ line
decorator(fn)the function being decoratedthe wrapperonce, at the @ line
wrapper(*args, **kwargs)the call argumentsfn's result, times factoron every call
The highlighted column explains the classic mistake. Two layers run once when the module loads and only the third runs per call, so writing @multiply_by without the parentheses skips the factory entirely and hands your function in as factor.

factor stays reachable inside wrapper because wrapper is a closure over the factory's scope. *args, **kwargs in wrapper forwards any call signature through untouched, so multiply_by works on add, on a one-argument function, or on anything else.

Check yourself
add is decorated with @multiply_by(3). A logging line prints add.__name__. What shows up in the logs?

functools.wraps

wrapper is a brand-new function object, so by default it reports itself, not the function it replaced:

print(add.__name__)   # 'wrapper'  (wrong: this corrupts logs, help(), and tracebacks)

@functools.wraps(fn) copies fn's __name__, __qualname__, __doc__, and __module__ onto wrapper, updates wrapper.__dict__, and sets wrapper.__wrapped__ = fn so introspection tools can still find the original. With it in place, the demo below prints add, which is exactly what the second print verifies. Add functools.wraps to every real decorator you write.

Check yourself
In a hurry you write @multiply_by with no parentheses above def add(a, b). What is the first thing that goes wrong?

Pitfall: forgetting the parentheses

A factory must be called. @multiply_by(3) is correct; @multiply_by is not. If you drop the (3), Python runs multiply_by(add), binds factor = add, and replaces add with the inner decorator. Nothing fails at definition time, so the bug hides until the next call:

@multiply_by          # missing (3)
def add(a, b):
    return a + b

add(2, 3)   # TypeError: multiply_by.<locals>.decorator() takes 1 positional argument but 2 were given

The plain @name form (no call) is only for decorators that take a function directly. Anything parameterized needs the call.

Interview nuance: know when each layer runs. The factory and the decorator run exactly once, at definition time, when @multiply_by(3) is evaluated. Only wrapper runs on every call. So expensive setup (compiling a regex, opening a connection) belongs in the outer layers where it happens once, not inside wrapper. Each call to multiply_by also creates a fresh scope, so multiply_by(3) and multiply_by(5) capture independent factor values. The classic late-binding closure bug, where every closure shares one variable, only bites when you reuse the same name, for example building decorators inside a for loop.

Check yourself
You are writing @retry(times=3), and deciding where to compile the regex that matches retryable error messages. Compiling is not free. Which layer should own it?
Worked example (Python)
import functools


def multiply_by(factor):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            return fn(*args, **kwargs) * factor

        return wrapper

    return decorator


@multiply_by(3)
def add(a, b):
    return a + b


print(add(2, 3))      # 15
print(add.__name__)   # add

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement multiply_by(factor), a decorator factory that multiplies a function's result by factor. It's applied to add below, and the run driver calls it.

run(2, 3) should be 15 (because (2 + 3) * 3).

3 hints and 3 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement multiply_by(factor) in decorators/wrappers.py: a decorator factory whose wrapper multiplies the wrapped function's result by factor, using functools.wraps to preserve the function's name. The read-only math_ops.py applies it. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.