FreeCampus Python

Adding Behavior with Decorators

Build decorators from ordinary function reassignment, then preserve arguments, returned results, metadata, state, configuration, and stacked call order.
python-foundations functions-call-behavior decorators wrappers
Open in Colab
  • Level: Python Foundations · Unit 5
  • Estimated time: 3.5–5 hours
  • You will learn: Explain decorator syntax as function transformation and write wrappers that forward calls, preserve results and metadata, retain state, accept configuration, and stack predictably.
  • Practice in: Google Colab, JupyterLab, or a local editor

1. Repeated behavior can wrap several functions

Suppose several game actions need a small audit message:

def open_gate(code):
    print("calling open_gate")
    result = f"gate {code} opened"
    print("finished open_gate")
    return result


def reveal_map(region):
    print("calling reveal_map")
    result = f"map of {region} revealed"
    print("finished reveal_map")
    return result

The useful bodies differ, but the before/after behavior repeats. A decorator is a function transformation: it receives one function and returns replacement behavior, usually a wrapper that calls the original.

This lesson will answer:

  • When does the decorator run, and when does the wrapper run?
  • How does a wrapper preserve arbitrary call shapes and returned values?
  • Why does functools.wraps matter?
  • In what order do configured or stacked decorators operate?

2. Build a wrapper manually before using @

Start with a zero-argument function so the transformation is visible:

def add_log(function):
    def wrapper():
        print(f"calling {function.__name__}")
        result = function()
        print(f"finished {function.__name__}")
        return result

    return wrapper


def find_key():
    return "silver key"


find_key = add_log(find_key)
assert find_key() == "silver key"

Read the reassignment in three stages:

  1. The original find_key function becomes the argument to add_log.
  2. add_log creates and returns wrapper, whose closure remembers the original.
  3. The name find_key is rebound to that wrapper.

Later, calling find_key() actually calls wrapper(), which calls the original function from its enclosing scope.

3. @decorator is transformation syntax

The decorator form expresses the same rebinding:

@add_log
def read_compass():
    return "north"


assert read_compass() == "north"

Conceptually, Python performs:

define the original read_compass function
read_compass = add_log(read_compass)

The decorator expression runs when Python executes the def statement, often while a module is imported or a notebook definition cell runs. The wrapper body runs only when the decorated function is called later.

Definition time installs replacement behavior; call time travels through the wrapper to the original and back.

flowchart LR
  A["define original function"] --> B["decorator returns wrapper"]
  B --> C["public name refers to wrapper"]
  C -->|"later call"| D["wrapper before behavior"]
  D --> E["original function"]
  E --> F["wrapper after behavior and return"]

4. A wrapper must return the original result

This broken wrapper calls correctly but discards the result:

def broken_log(function):
    def wrapper():
        function()

    return wrapper


@broken_log
def secret_number():
    return 42


assert secret_number() is None

The wrapper reaches its end without return, so callers see None even though the original returned 42. A transparent decorator must capture and return the original result unless changing that result is its documented purpose.

The required data flow is:

result = function()
return result

Checkpoint: transformation and timing

5. Forward every supported argument

The first add_log wrapper accepts no arguments. It cannot decorate open_gate(code). Use variadic forwarding:

def add_log(function):
    def wrapper(*args, **kwargs):
        print(f"calling {function.__name__}")
        result = function(*args, **kwargs)
        print(f"finished {function.__name__}")
        return result

    return wrapper


@add_log
def open_gate(code, *, quiet=False):
    suffix = " quietly" if quiet else ""
    return f"gate {code} opened{suffix}"


assert open_gate("A7") == "gate A7 opened"
assert open_gate("B2", quiet=True) == "gate B2 opened quietly"

*args collects positional arguments passed to the wrapper. **kwargs collects keyword arguments. The original call function(*args, **kwargs) expands both collections, preserving the caller’s call shape.

A wrapper that forwards only positional arguments would lose quiet=True. A wrapper that forwards arguments but not the result still breaks the return contract. Both directions matter: arguments flow inward; results flow outward.

6. Preserve useful metadata with functools.wraps

Without help, the public function reports the wrapper’s name and docstring:

assert open_gate.__name__ == "wrapper"

Tools, help systems, and readers need the original public identity. Apply functools.wraps to the wrapper:

from functools import wraps


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

    return wrapper


@add_log
def reveal_map(region):
    """Return a message revealing one region."""
    return f"map of {region} revealed"


assert reveal_map("Crater") == "map of Crater revealed"
assert reveal_map.__name__ == "reveal_map"
assert reveal_map.__doc__ == "Return a message revealing one region."

wraps(function) is itself a decorator applied to wrapper. It copies key metadata and exposes a __wrapped__ link used by introspection tools. It does not fix lost arguments or results; your wrapper still owns those responsibilities.

7. A decorator closure can keep focused state

from functools import wraps


def count_calls(function):
    count = 0

    @wraps(function)
    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        result = function(*args, **kwargs)
        return count, result

    return wrapper


@count_calls
def collect_star(color):
    return f"{color} star"


assert collect_star("blue") == (1, "blue star")
assert collect_star("gold") == (2, "gold star")

The count binding belongs to this decorated function’s closure. A separately decorated function receives separate state. This decorator intentionally changes the result contract to (call_number, original_result), so its name and documentation should make that choice clear. A transparent audit decorator would instead log elsewhere and return only the original result.

Checkpoint: transparent wrappers

8. A configurable decorator adds one more function layer

Suppose logging should include a chosen label. The outer function receives configuration and returns the actual decorator:

from functools import wraps


def tagged(label):
    def decorate(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            result = function(*args, **kwargs)
            return "[" + label + "] " + result

        return wrapper

    return decorate


@tagged("TREASURE")
def describe_treasure(name):
    return f"found {name}"


assert describe_treasure("moonstone") == "[TREASURE] found moonstone"

Trace the three layers:

Function layer Receives Returns
tagged label configuration decorate
decorate original function wrapper
wrapper later call arguments decorated call result

At definition time, tagged("TREASURE") runs first and returns decorate. Python then applies that decorator to describe_treasure. The wrapper waits for later calls.

9. Stacked decorators apply inside out

from functools import wraps


def surround(left, right):
    def decorate(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            return left + function(*args, **kwargs) + right

        return wrapper

    return decorate


@surround("<", ">")
@surround("[", "]")
def rune():
    return "moon"


assert rune() == "<[moon]>"

Application follows ordinary nested calls:

rune = surround("<", ">")(
    surround("[", "]")(original_rune)
)

The decorator closest to def wraps the original first. At call time, the outer < > wrapper starts first, calls the inner [ ] wrapper, which calls the original. Results then return outward. Swapping the decorator lines changes the result to [<moon>].

10. Decorators should make the public contract clearer, not mysterious

Good foundational uses are narrow and visible: logging, counting, timing at a later stage, access checks in frameworks, caching, or registering functions. Every decorator changes the effective public callable, even when it tries to be transparent.

Avoid a decorator when:

  • a direct helper call would be clearer;
  • the wrapper silently changes the result type;
  • decoration depends on surprising changing global state;
  • several layers make call order difficult to explain; or
  • different functions require incompatible call contracts.

Decorator syntax hides a transformation behind one line. Use it only when that shared transformation is easier to understand than explicit repetition.

11. Lab: track game achievements

Create a configurable audit decorator that records one event per successful call while preserving the decorated function’s behavior and metadata:

from functools import wraps


def track_achievement(log, *, badge):
    """Return a decorator that appends successful call records to log."""
    raise NotImplementedError

The event must be a dictionary with badge, function, args, kwargs, and result. Append it after the original returns.

events = []


@track_achievement(events, badge="gatekeeper")
def open_gate(code, *, quiet=False):
    """Return an opened-gate message."""
    suffix = " quietly" if quiet else ""
    return f"gate {code} opened{suffix}"


@track_achievement(events, badge="cartographer")
def reveal_map(region):
    """Return a revealed-map message."""
    return f"map of {region} revealed"


assert open_gate("A7") == "gate A7 opened"
assert open_gate("B2", quiet=True) == "gate B2 opened quietly"
assert reveal_map("Crater") == "map of Crater revealed"
assert open_gate.__name__ == "open_gate"
assert open_gate.__doc__ == "Return an opened-gate message."
assert events == [
    {
        "badge": "gatekeeper",
        "function": "open_gate",
        "args": ("A7",),
        "kwargs": {},
        "result": "gate A7 opened",
    },
    {
        "badge": "gatekeeper",
        "function": "open_gate",
        "args": ("B2",),
        "kwargs": {"quiet": True},
        "result": "gate B2 opened quietly",
    },
    {
        "badge": "cartographer",
        "function": "reveal_map",
        "args": ("Crater",),
        "kwargs": {},
        "result": "map of Crater revealed",
    },
]

The decorator intentionally mutates the supplied log; that effect is explicit in its contract. The wrapped game functions still return their original strings.

Hint: write and test one layer at a time

The outer call remembers log and badge, then returns decorate. decorate receives function and returns a @wraps(function) wrapper. The wrapper forwards arguments, keeps the result, appends one dictionary, and returns the result.

Show one complete solution after attempting the lab
from functools import wraps


def track_achievement(log, *, badge):
    """Return a decorator that appends successful call records to log."""
    def decorate(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            result = function(*args, **kwargs)
            event = {
                "badge": badge,
                "function": function.__name__,
                "args": args,
                "kwargs": kwargs.copy(),
                "result": result,
            }
            log.append(event)
            return result

        return wrapper

    return decorate

Checkpoint: configuration and stacking

12. Explain definition time and call time separately

Using track_achievement, state:

  1. when configuration arguments are evaluated;
  2. when the original function is passed to decorate;
  3. what public name refers to after definition;
  4. what the wrapper receives on each later call;
  5. where the result travels; and
  6. which state changes deliberately.

Then apply @surround above a tracked function and predict both the returned text and recorded result. The outer wrapper sees the result of the complete inner decorated call; reversing the stack changes which result the audit records.

Key points

TipKey points
  • Decorator syntax passes a newly defined function to another function and rebinds the public name to the returned callable.
  • Decorator application occurs at definition time; wrapper bodies run at later call time.
  • Transparent wrappers forward *args and **kwargs and return the original result.
  • functools.wraps preserves useful public metadata but does not repair broken forwarding or missing returns.
  • Closures can retain small per-decorated-function state; nonlocal updates it deliberately.
  • A configurable decorator has three layers: configuration, decoration, and later wrapped calls.
  • Stacked decorators apply inside out and execute as nested wrappers, with results returning outward.

References

Back to top