Decorators 101.

It is underappreciated that Python decorators are syntactic sugar for function wrappers. People new to Python usually encounter them first by writing like @foo above a function bar and observing that bar behaves differently than it used to.

More tangibly, using the @foo decorator on bar looks like this.

@foo
def bar():
    print("hello!")

To understand decorators is to understand function wrappers, but understanding those requires being comfortable with first class objects, so we’ll start there. We will then explain function wrappers, using an example time_it that determines how long it takes for a function to complete. We then use decorator syntax to apply our wrapper in a clean, elegant way.

First Class Objects

First, let’s consider Python functions as first class objects. This means they can be passed around like variables and given as arguments to function calls.

def take5():
    return "take five"

def greet(name, greet_func):
    greeting = greet_func()
    return f"{name}, {greeting}"

We pass the take5 function as an argument when we call greet, which looks like this.

>>> greet("james", take5)
james, take five

Wrapping A Function

Let’s say we want to understand how long two functions fac and meow take to run. We could write a function that checks the time, calls fac or meow, and checks the time again when they’re done to print how long the function took to run.

First, fac and meow. Two functions that take some time to run.

def fac(n):
    return n * fac(n-1) if n > 0 else 1

def meow(n):
    return "meow" * n

And a function that times how long other functions take.

from datetime import datetime

def time_it(func, *args):
    start = datetime.now()  # record time before calling func
    output = func(*args)    # call func and store output
    end = datetime.now()    # record time after calling func
    print(end - start)      # print the difference
    return output           # give the output from func to caller

Let’s time fac(100)

>>> result = time_it(fac, 100)
0:00:00.000073
>>> result
93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000

And let’s time meow(100)

>>> result = time_it(meow, 100)
0:00:00.000012
>>> result
'meowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeow'
>>>

Working With Wrappers

To wrap a function is to call the function you want by calling some intermediary that adds logic and calls the function you want. We can do this in Python with code that looks similar to the snippet below.

def outer(func):
    def wrapper(arg):
        # ...
        # some code that runs before func
        # ...
        
        output = func(arg)
        
        # ...
        # some code that runs after func
        # ...

        # return whatever we got when we called func
        return output

    # return the wrapped function
    return wrapper

Let’s convert time_it into a wrapper.

def time_it(func):
    def wrapper(args):
        start = datetime.now()  # record time before func
        output = func(args)
        end = datetime.now()    # record time after func
        print(end - start)      # print the difference
        return output
    return wrapper

Using this, we can wrap fac and meow to make timed_fac and timed_meow.

>>> timed_fac = time_it(fac)
>>> timed_meow = time_it(meow)

And calling them is the same as if they weren’t wrapped.

>>> result = timed_fac(100)
0:00:00.000075
>>> result
93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000
>>> result = timed_meow(100)
0:00:00.000012
>>> result
'meowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeow'

We’re almost at a clean system. The model we’ve described so far would require code changes, from meow to timed_meow and versa vice to enable/disable the wrapper.

We can keep things simple by overwriting the meow function with the wrapped meow function. Callers wouldn’t know the difference, because they call a function named meow if the wrapper is on or if it’s off.

Here is the function before wrapping it.

>>> def meow(n):
>>>     return "meow" * n
>>>
>>> result = meow(100)
>>> result
'meowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeow'

Next, we’ll wrap it and see that it works the same way.

>>> meow = time_it(meow)
>>> result = meow(100)
0:00:00.000014
>>> result
'meowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeowmeow'

Decorator Syntax

Here is the meow function again, with the wrapper applied immediately after definition.

def meow(n):
    return "meow" * n

meow = time_it(meow)

Believe it or not, we have already done all the work necessary to use decorator syntax, eg @time_it.

@time_it
def meow(n):
    return "meow" * n

To implement a decorator you write a callable that returns a callable, and you choose to use decorator syntax to apply it. That’s it.

Stacking Decorators

You can stack them too. This approach uses decorators as syntax for middleware.

@time_it
@is_authenticated
@return_500_on_error
def some_web_handler(request, context):
    ...

The order of the stack works bottom-up, so return_500_on_error would wrap the function, then is_authenticated wraps the wrapped function, etc.

Here is a more tangible example.

>>> def A(func):
...     print("A is wrapping")
...     def wrapper():
...         print("A")
...         func()
...         print("A")
...     return wrapper
... 
>>> 
>>> def B(func):
...     print("B is wrapping")
...     def wrapper():
...         print("B")
...         func()
...         print("B")
...     return wrapper
... 
>>> 
>>> # Note that B is above A here
>>> @B
... @A
... def foo():
...     print("foo")
... 
A is wrapping
B is wrapping
>>> 
>>> foo()
B
A
foo
A
B

I hope you feel that some of the mystery of decorators has gone away and I hope that you already have ideas for using decorators in your work.