Chapter 31 — Decorators and Closures: Wrapping Functions with Extras

Series I4 — Advanced Python Language

NoteIn this chapter, you will:
  • Understand that functions are objects that can be stored and passed around
  • Build a closure: a function that remembers the variables around it
  • Write decorators, including ones with arguments and functools.wraps
  • Recognise a common trap and a ready-made decorator (functools.cache)
  • Add logging and retrying to project functions without changing what is inside them

All scripts for this chapter are in the kode/ch31/ folder. On the web version, you can also download them all at once (zip).

In Chapter 30, your project learned to read big files. Now imagine you have ten functions that fetch data from various places, and you want: (1) a log entry each time a function starts and finishes, (2) automatic repeating when the network drops. Copying the logging and repeating code into ten functions is boring and error-prone. Better to “wrap” each function in an extra layer, like wrapping a gift: the contents stay the same, but there is a layer on the outside. That is a decorator.

A decorator = wrapping layers around a function: the call passes through the layers, the result comes back out.

Functions are objects

The foundation of decorators: in Python, a function is just one kind of object, like a number or a piece of text. It can be given another name, stored in a dict, and passed to another function.

01_functions_are_objects.py
# Chapter 31 - Functions are objects
def biomass_kg(count, weight_g):
    return count * weight_g / 1000


tool = biomass_kg             # copies the "name", does not call it
print(tool(800, 540.5))
print(tool is biomass_kg)
print(type(biomass_kg).__name__)

# Functions can be stored in a dict and picked by name
formulas = {
    "biomass": biomass_kg,
    "area": lambda length, width: length * width,
}
print(formulas["area"](20, 15))


# Functions can be passed to other functions
def apply(function, *arguments):
    return function(*arguments)


print(apply(biomass_kg, 1000, 500))
432.4
True
function
300
500.0
  • tool = biomass_kg (without brackets) does not call the function; it merely adds another name for the same object. Proof: tool is biomass_kg is True.
  • The object’s type is function.
  • In the dict formulas, a function is picked by name: formulas["area"](20, 15) gives 300.
  • apply(biomass_kg, 1000, 500) passes a function as an ingredient. *arguments gathers the remaining arguments, and function(*arguments) unpacks them again. The result is 500.0.

Closures: a function that remembers

A function may be made inside another function. The inner function can use variables owned by the outer function, even after the outer function has finished. The combination “function + the variables it remembers” is called a closure.

02_closure.py
# Chapter 31 - Closure: a function that remembers
def make_counter(start=0):
    total = start

    def add(amount):
        nonlocal total          # without this, total would be a new variable
        total += amount
        return total

    return add


orchard_harvest = make_counter()
pond_harvest = make_counter(100)
print(orchard_harvest(10))
print(orchard_harvest(5))
print(pond_harvest(1))          # another counter, separate memory

content = orchard_harvest.__closure__[0].cell_contents
print("memory of orchard_harvest:", content)
10
15
101
memory of orchard_harvest: 15
  • make_counter() returns the function add. That function “carries” the variable total.
  • orchard_harvest and pond_harvest have separate memories: 10, then 15 for the orchard, while the pond starts from 100 and becomes 101.
  • nonlocal total is required when the inner function wants to change the outer variable. Without it, total += amount is treated as making a new local variable and raises an error (remember the scope discussion in Chapter 10).
  • __closure__[0].cell_contents shows that “memory” directly: 15.

Your first decorator

A decorator is a function that takes a function and returns a new function (the wrapper). The @ sign is only shorthand.

03_basic_decorator.py
# Chapter 31 - A first decorator (not perfect yet)
def log_call(function):
    def wrapper(*args, **kwargs):
        print(f"calling {function.__name__}")
        result = function(*args, **kwargs)
        print(f"done, result = {result}")
        return result
    return wrapper


@log_call
def biomass_kg(count, weight_g):
    """Pond biomass in kg."""
    return count * weight_g / 1000


biomass_kg(800, 540.5)

# @log_call is the same as: biomass_kg = log_call(biomass_kg)
print(biomass_kg.__name__)      # the real identity is lost
print(biomass_kg.__doc__)
calling biomass_kg
done, result = 432.4
wrapper
None
  • @log_call above def biomass_kg means the same as biomass_kg = log_call(biomass_kg).
  • Every call now goes through wrapper: prints “calling”, runs the real function, prints the result, then returns it.
  • *args, **kwargs lets the wrapper accept any arguments and pass them along unchanged.
  • There is a flaw: biomass_kg.__name__ has become wrapper and its docstring is gone (None). The function lost its identity.

functools.wraps: keeping the identity

The cure is one line: @wraps(function) on the wrapper.

04_wraps.py
# Chapter 31 - functools.wraps keeps the function's identity
from functools import wraps


def log_call(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        print(f"calling {function.__name__}")
        return function(*args, **kwargs)
    return wrapper


@log_call
def biomass_kg(count, weight_g):
    """Pond biomass in kg."""
    return count * weight_g / 1000


print(biomass_kg(800, 540.5))
print(biomass_kg.__name__)
print(biomass_kg.__doc__)
print(biomass_kg.__wrapped__ is not None)
calling biomass_kg
432.4
biomass_kg
Pond biomass in kg.
True

Now __name__ and __doc__ are back to the original, and __wrapped__ keeps a reference to the function before wrapping. Make it a habit: every decorator always uses @wraps. This matters for error messages, documentation, and tools such as pytest.

A decorator that takes arguments

When a decorator needs settings (for example “repeat 3 times”), one more layer is needed: the outer function takes the settings and returns the real decorator.

05_decorator_arguments.py
# Chapter 31 - A decorator that takes arguments
from functools import wraps


def repeat(times):
    """Decorator factory: repeats the call `times` times."""
    def decorator(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            result = None
            for i in range(times):
                result = function(*args, **kwargs)
            return result
        return wrapper
    return decorator


@repeat(3)
def greet(name):
    print("Hello,", name)


greet("Farmer")


# Stacking decorators: the one closest to def is applied first
def bold(f):
    return lambda: "<b>" + f() + "</b>"


def italic(f):
    return lambda: "<i>" + f() + "</i>"


@bold
@italic
def title():
    return "Harvest"


print(title())
Hello, Farmer
Hello, Farmer
Hello, Farmer
<b><i>Harvest</i></b>

Three levels: repeat(3) → returns decorator → which takes greet and returns wrapper. @repeat(3) runs the first level right away, and the result is applied as the decorator.

The bottom part shows stacked decorators: the one closest to def is applied first. @italic wraps title first, then @bold wraps that result, so the <b> tag ends up outside and <i> inside: <b><i>Harvest</i></b>.

A ready-made decorator and a trap

The standard library offers many decorators. One of the most useful is functools.cache, which remembers results so a call with the same arguments is not computed again.

06_built_in_cache.py
# Chapter 31 - A ready-made decorator: functools.cache
from functools import cache

calls = 0


@cache
def feed_price_per_kg(code):
    global calls
    calls += 1                 # counts how often the body really runs
    return {"P1": 8500, "P2": 9200}[code]


print(feed_price_per_kg("P1"))
print(feed_price_per_kg("P1"))   # taken from memory
print(feed_price_per_kg("P2"))
print("function body ran:", calls, "times")
8500
8500
9200
function body ran: 2 times

Called three times, but the function body ran only 2 times: the second call for "P1" came from memory. This suits slow functions whose result is always the same for the same input. Do not use it for functions whose result changes (for example reading today’s price).

Now the most common trap: forgetting the brackets on a decorator with arguments.

07_forgot_brackets.py
# Chapter 31 - Trap: a decorator with arguments used without brackets
def repeat(times):
    def decorator(function):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                function(*args, **kwargs)
        return wrapper
    return decorator


@repeat            # should be @repeat(3)
def greet():
    print("Hello")


greet()
Traceback (most recent call last):
  File "07_forgot_brackets.py", line 16, in <module>
    greet()
    ~~~~~^^
TypeError: repeat.<locals>.decorator() missing 1 required positional argument: 'function'

@repeat without (3) means greet = repeat(greet). The argument times now holds the function greet, and what comes back is decorator, not a wrapper. When greet() is called, it is really decorator() that runs, and it lacks the argument function. The error message (repeat.<locals>.decorator() missing 1 required positional argument: 'function') is the telltale sign.

WarningWARNING

A decorator with arguments is always written with brackets, even if the arguments are empty or default. If the error reads <locals>.decorator() missing ... 'function', you almost certainly forgot the brackets.

Running Project I4: logging and retrying

Now combine them: two decorators, log_call (logging) and retry (repeating on ConnectionError), applied to a rainfall-fetching function. A fake network: it fails twice, then succeeds. The log uses the logging module (Chapter 19) with a format without a clock, so the output is stable. The pause between attempts is deliberately tiny (0.01 seconds).

08_project_log_retry.py
# Chapter 31 - Running project I4: decorators for logging and retrying
import logging
import sys
import time
from functools import wraps

logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("field")


def log_call(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        log.info("start %s", function.__name__)
        result = function(*args, **kwargs)
        log.info("finish %s", function.__name__)
        return result
    return wrapper


def retry(maximum=3, pause=0.01, errors=(ConnectionError,)):
    def decorator(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            for attempt in range(1, maximum + 1):
                try:
                    return function(*args, **kwargs)
                except errors as e:
                    log.warning("attempt %d of %d failed: %s", attempt, maximum, e)
                    if attempt == maximum:
                        raise
                    time.sleep(pause)
        return wrapper
    return decorator


outage = {"left": 2}            # fake network: fails twice, then works


@log_call
@retry(maximum=3)
def fetch_rainfall(station):
    if outage["left"] > 0:
        outage["left"] -= 1
        raise ConnectionError("connection dropped")
    return {"station": station, "mm": 12.5}


print(fetch_rainfall("S1"))

outage["left"] = 5              # now the outage outlasts the retries
try:
    fetch_rainfall("S2")
except ConnectionError as e:
    print("giving up:", e)
INFO start fetch_rainfall
WARNING attempt 1 of 3 failed: connection dropped
WARNING attempt 2 of 3 failed: connection dropped
INFO finish fetch_rainfall
{'station': 'S1', 'mm': 12.5}
INFO start fetch_rainfall
WARNING attempt 1 of 3 failed: connection dropped
WARNING attempt 2 of 3 failed: connection dropped
WARNING attempt 3 of 3 failed: connection dropped
giving up: connection dropped

Follow along:

  • First call: start is logged, attempts 1 and 2 fail (WARNING), attempt 3 succeeds, then finish is logged. The result is {'station': 'S1', 'mm': 12.5}.
  • The decorator order matters: @log_call outside, @retry inside. So the start and finish logs appear only once per call, even though three attempts happen inside.
  • Second call: the outage is longer than three attempts, so the error is raised again (raise). Notice: there is no finish, because the function did not end well. The program catches it outside with try/except and prints “giving up”.
TipTIP

Retrying suits only temporary errors (dropped network, busy server). Do not repeat errors that are certain to repeat (bad data): it just wastes time. That is why retry catches only the error types you name.

Exercises

  1. Write a closure make_multiplier(factor) that returns a function multiplying a number by factor. Make times_three and times_ten, then try both.
  2. Write a decorator count_calls that stores the number of calls in a function attribute (wrapper.count). Apply it to one function, call it four times, and print count.
  3. Change 05_decorator_arguments.py: add @repeat(2) to another function that prints a pond name, and make sure its __name__ stays correct.
  4. Challenge: write a decorator at_least(limit) that raises ValueError if the function’s result is below limit. Apply it to the biomass function, and test with one input that passes and one that fails.

Chapter Summary

  • A function is an object: it can get another name, be stored, and be passed to other functions.
  • A closure = an inner function that remembers the outer function’s variables; change them with nonlocal.
  • A decorator = a function that takes a function and returns its wrapper; @name is shorthand.
  • Always use @wraps so the original function’s name and docstring survive.
  • A decorator with arguments needs three levels and must have brackets when applied.
  • Project I4: the data-fetching function now has logging and retrying without changing what is inside it.

Next, in Chapter 32: context managers, a way to make sure resources (files, connections) are always closed neatly.