Decorators I: what @ really does

Part of the free LogicWiz Python course, Module 11: Functions in depth. The examples and practice run in the app.

Canteen Counter has twelve functions, and Shankar bhaiya wants every call logged. Aarav could edit all twelve, or write one decorator and put @log_calls above each.

You'll learn to

  • Explain that `@deco` above a def means `f = deco(f)`
  • Write decorators like `@log_calls` and `@timer` that work for any arguments
  • Keep the function's name and docstring with `functools.wraps`
  • Predict the order of stacked decorators

Learn

A decorator is a function that wraps a function

A decorator takes a function and returns a new function, usually one that does something extra before or after calling the original.

You already have both pieces:

  • functions can be passed in and returned (Lesson 11.2)
  • *args, **kwargs forward any arguments (Lesson 11.1)

Let's build one by hand first, with no @.

Wrapping by hand

def shout(fn):
    def wrapper():
        return fn().upper() + "!"
    return wrapper

def greet():
    return "namaste"

greet = shout(greet)     # replace greet with the wrapped one
print(greet())

After the swap, the name greet points at wrapper, and wrapper remembers the old greet as fn.

@ is just a shortcut

These two are exactly the same:

@shout
def greet():
    return "namaste"
def greet():
    return "namaste"
greet = shout(greet)

The decorator runs once, when the def runs. The wrapper runs on every call. To work with any function, the wrapper takes *args, **kwargs, passes them on, and returns the result.

@log_calls

def log_calls(fn):
    def wrapper(*args, **kwargs):
        print(f"calling {fn.__name__}{args}")
        result = fn(*args, **kwargs)
        print(f"{fn.__name__} returned {result}")
        return result
    return wrapper

@log_calls
def item_total(price, qty):
    return price * qty

total = item_total(15, 4)
print("total is", total)

@timer

import time
from functools import wraps

def timer(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{fn.__name__} took {elapsed:.4f} s")
        return result
    return wrapper

@timer
def daily_total(n):
    return sum(i * 15 for i in range(n))

print(daily_total(200000))

The time is different on every run and every phone, so we never ask you to predict it.

Why @wraps matters

from functools import wraps

def plain(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

def careful(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@plain
def gst(amount):
    "Add 5% GST."
    return amount * 1.05

@careful
def tip(amount):
    "Add a 10% tip."
    return amount * 1.1

print(gst.__name__, gst.__doc__)
print(tip.__name__, tip.__doc__)

Stacking: nearest decorator wraps first

def bold(fn):
    def wrapper():
        return "<b>" + fn() + "</b>"
    return wrapper

def italic(fn):
    def wrapper():
        return "<i>" + fn() + "</i>"
    return wrapper

@bold
@italic
def menu_title():
    return "Today's Specials"

print(menu_title())     # menu_title = bold(italic(menu_title))

Two classic slips:

  • Forgetting return result in the wrapper. The decorated function then returns None.
  • Forgetting @wraps(fn). The function's name becomes wrapper and its docstring disappears, which breaks help(), logs and test reports.

"What is a decorator? Write a timer decorator." is one of the most asked Python interview questions. Say it in one line: a decorator is a function that takes a function and returns a new function that adds behaviour; @d above def f means f = d(f). Then write @timer with *args, **kwargs and @wraps.

In one line

@d above def f means f = d(f); the wrapper takes *args, **kwargs, returns the result, and uses @wraps(fn); stacked decorators apply bottom-up and run top-down.

Interview corner

What is a decorator in Python?

A decorator is a callable that takes a function and returns a new function adding behaviour around it, without changing its code. @timer above def f is just f = timer(f), run once at definition time. Typical uses: logging, timing, caching, access checks, retries.

Write a decorator that times a function.

import time
from functools import wraps
def timer(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        print(fn.__name__, time.perf_counter() - start)
        return result
    return wrapper

Mention *args, **kwargs (works for any signature), return result, and @wraps.

Why use functools.wraps?

Without it the decorated function takes the wrapper's identity: __name__ becomes "wrapper" and the docstring is lost. @wraps(fn) copies __name__, __doc__, __module__, __qualname__ and __dict__, and sets __wrapped__ to the original, so help(), logging, debuggers and test tools keep working.

In what order do stacked decorators run?

They are applied bottom-up: @a @b def f is f = a(b(f)). When called, the outermost wrapper (a) runs first and calls b's wrapper, which calls f. So code before the call runs top-down and code after it runs bottom-up.