Course outline · 0% complete

0/27 lessons0%

Course overview →

Decorators

lesson 5-2 · ~15 min · 14/27

Wrapping a function with extra behavior

You cannot read a modern Python codebase without decorators: web routes, test fixtures, caching, and permission checks are all spelled with an @ line. The good news is that a decorator is nothing but the closure pattern from lesson 5-1 plus a little syntax.

Suppose you want several functions to announce when they run. Editing each one is repetitive. Instead, write a function that takes a function and returns a wrapped version:

def announce(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

This is just lesson 5-1's closure pattern. wrapper remembers func, and the *args, **kwargs from lesson 2-1 let it forward any arguments. A function that transforms another function like this is a decorator, and Python gives it dedicated syntax:

@announce
def greet(name):
    return f"hi {name}"

@announce means exactly greet = announce(greet). Nothing more.

wrapperoriginal addadd(2, 3)*args, **kwargs5return valuethe name add now points at the wrapperadd = announce(add)
A decorator replaces the name with a wrapper that surrounds the original function, forwarding arguments in and the return value back out.

One decorator, two functions

Both decorated functions gain the announcement without a line of change inside them, and their arguments and return values pass straight through the wrapper.

def announce(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@announce
def add(a, b):
    return a + b

@announce
def greet(name):
    return f"hi {name}"

print(add(2, 3))
print(greet("Ada"))

Output

calling add
5
calling greet
hi Ada

The *args, **kwargs pair from lesson 2-1 is doing the heavy lifting. add takes two positional arguments and greet takes one, yet a single wrapper handles both, because it gathers whatever it is given and forwards it unchanged. The return in front of func(*args, **kwargs) is essential: drop it and every decorated function would start returning None.

Practical decorators you will actually meet

  • Timing: record time.perf_counter() before and after func, print the difference.
  • Caching: store (args) → result in a dict inside the closure, skip recomputation on repeat calls. The standard library ships this as functools.lru_cache, coming in lesson 6-3.
  • Access control and logging: web frameworks like Flask use decorators such as @app.route("/") to register functions.

One piece of hygiene: wrapping replaces the function's name and docstring (the optional help text in triple quotes right under def, which help() displays) with the wrapper's. Put @functools.wraps(func) on the wrapper to copy the original's identity over. Do that in any decorator you write for real code.

Attaching state to the wrapper

A decorator can hang data on the wrapper function it returns. Here count_calls stores a counter on wrapper itself, which makes ping.calls readable from outside. Real profiling and rate-limiting decorators use this same attach-state trick.

def count_calls(func):
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

@count_calls
def ping():
    return "pong"

ping()
ping()
print(ping())
print(ping.calls)

Output

pong
3

wrapper.calls = 0 runs once, at decoration time, and functions are objects so they can carry attributes like any other object. After decoration the name ping refers to the wrapper, which is why ping.calls reaches the counter. Only the third call is printed, so pong appears once while the count has reached 3.

retry_twice

retry_twice(func) calls the wrapped function, and if it raises ValueError it prints retrying and calls it one more time. flaky fails on its first call only, so the retry succeeds.

attempts = 0

def retry_twice(func):
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except ValueError:
            print("retrying")
            return func(*args, **kwargs)
    return wrapper

@retry_twice
def flaky():
    global attempts
    attempts += 1
    if attempts == 1:
        raise ValueError("boom")
    return "success on attempt " + str(attempts)

print(flaky())

Output

retrying
success on attempt 2

The skeleton is identical to announce: an inner def wrapper(*args, **kwargs), and return wrapper at the end. The only new part is the body, where the first attempt sits inside a try and the except ValueError branch prints and calls again. try and except come from Python for Beginners, and unit 8 goes considerably deeper on them.

Retrying is only safe for operations that can be repeated without doing damage. Retrying a failed read is fine, retrying a payment is not.

The @ line desugared

Writing @announce above def greet(...) is exactly equivalent to greet = announce(greet) placed just after the definition.

The @ syntax is pure convenience. Python defines greet normally, passes that function to announce, and rebinds the name greet to whatever comes back, which is normally the wrapper closure. Once you can see that desugared line in your head, every decorator you meet becomes readable, including ones that take their own arguments.

The types inside a wrapper

Inside def wrapper(*args, **kwargs), kwargs is a dict of the keyword arguments, just as args is a tuple of the positional ones, following the same rule from lesson 2-1.

That pairing is precisely why *args, **kwargs can forward absolutely any call signature to the wrapped function. Two stars collect everything passed by name, one star collects everything passed by position, and between them nothing is left behind.