18 Jun 2020, Samuel Hinton

Handy Python Decorators


A short example on how to use decorators in your code to provide extra functionality

Decorators are something that are criminally underused in the analysis codes I have seen in academia. So, give me a few seconds to try and espouse their virtues. First, if you are new to decorators, how they work is simple: they are a function that returns a function which has wrapped another function. Simple!

It’s easier to explain in code.

def decorator(fn):
    def wrapper(*args, **kwargs):
        print("Look, I've added something here!")
        return fn(*args, **kwargs)
    return wrapper

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

add(1, 2)
Look, I've added something here!

3

So you can see what is happening here is that by decoratoring the add function, when we now call add, we actually hit the wrapper function, which prints a statement, and then it hits the original add function. So the @ syntax is the same for just a reassignment

# Does the same thing:
def add(a, b):
    return a + b
add = decorator(add)
add(1, 2)
Look, I've added something here!

3

So this allows us to do a bunch of things. We could use it for logging. For timing functions. For trying to detect or sanitise input. For caching results (see lru_cache for an implementation of this that is part of the base python libraries). For a ton of things. So what you could have in your code base, is a collection of decorators that you can throw on and off your functions when you need them. Things not running smoothly, put a @debug. Want to time the function, add a @timer. Want to make sure a function is only run once, add @run_once. Examples of all of this are below.

First, lets set up logging. I add the reload because if you run this in a notebook it will already have done the basic config for you, and you wont see any logging unless you reload and re-configure it.

import logging
import importlib
importlib.reload(logging)

logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.DEBUG, datefmt='%I:%M:%S')

Alright, so here is a useful debug decorator:

And now in my code, when something looks… funky… I can just throw a quick @debug on the most suspicious function and ensure that it is functioning properly.

add(1, 2)
10:03:37 DEBUG: Invoking add
10:03:37 DEBUG:   args: (1, 2)
10:03:37 DEBUG:   kwargs: {}
10:03:37 DEBUG:   returned 3

3

Now lets get a function to figure out how long execution takes!

import time

def timer(fn):
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter()
        result = fn(*args, **kwargs)
        end_time = time.perf_counter()
        duration = (end_time - start_time) * 1000
        logging.debug(f"  Took {duration:0.4f} ms")
        return result
    return wrapper

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

add(3, 4)
10:03:37 DEBUG:   Took 0.0008 ms

7

And note we can combine decorators! Want to debug and time the function?

@debug
@timer
def add(a, b):
    return a + b

add(5, 6)
10:03:37 DEBUG: Invoking wrapper
10:03:37 DEBUG:   args: (5, 6)
10:03:37 DEBUG:   kwargs: {}
10:03:37 DEBUG:   Took 0.0008 ms
10:03:37 DEBUG:   returned 11

11

Note the importance of the order. Debug goes first, so its wrapper executes first, then timers wrapper, then the original function. If you swap the order, you’ll note the time now goes up to multiple miliseconds because @timer is now also timing the logging!

Finally as another handy decorator, what if we want to ensure a function is only run once?

def run_once(fn):
    def wrapper(*args, **kwargs):
        if not wrapper.has_run:
            wrapper.has_run = True
            return fn(*args, **kwargs)
        else:
            print("NO! NOT ALLOWED!")
    wrapper.has_run = False
    return wrapper

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

print(add(6, 7))

print(add(7, 8))
13
NO! NOT ALLOWED!
None

In python functions are objects too, so we can attack attributes to them, like has_run, and then check the attribute status when executing. In the output, you can see the first add works fine, but then the second time we try we get a mean print message and a result of None. You could make this an exception if you want, I just need it to be able to execute for the write up!

Anyway, I hope this has given you some useful ideas. Having a decorators utility file to throw around can add value to any project you have!

Here’s the full code for convenience:

import importlib
import logging
import time

def decorator(fn):
    def wrapper(*args, **kwargs):
        print("Look, I've added something here!")
        return fn(*args, **kwargs)
    return wrapper

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

add(1, 2)

# Does the same thing:
def add(a, b):
    return a + b
add = decorator(add)
add(1, 2)

importlib.reload(logging)

logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.DEBUG, datefmt='%I:%M:%S')

def debug(fn):
    def wrapper(*args, **kwargs):
        logging.debug(f"Invoking {fn.__name__}")
        logging.debug(f"  args: {args}")
        logging.debug(f"  kwargs: {kwargs}")
        result = fn(*args, **kwargs)
        logging.debug(f"  returned {result}")
        return result
    return wrapper
    
@debug
def add(a, b):
    return a + b

add(1, 2)


def timer(fn):
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter()
        result = fn(*args, **kwargs)
        end_time = time.perf_counter()
        duration = (end_time - start_time) * 1000
        logging.debug(f"  Took {duration:0.4f} ms")
        return result
    return wrapper

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

add(3, 4)

@debug
@timer
def add(a, b):
    return a + b

add(5, 6)

def run_once(fn):
    def wrapper(*args, **kwargs):
        if not wrapper.has_run:
            wrapper.has_run = True
            return fn(*args, **kwargs)
        else:
            print("NO! NOT ALLOWED!")
    wrapper.has_run = False
    return wrapper

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

print(add(6, 7))

print(add(7, 8))