Python Decorators, Explained

Python Decorators, Explained

Decorators in Python are a very powerful albeit mind-bending and under-documented tool. They are useful for many things, including changing how a function is executed (e.g. for denoting that an API endpoint requires authentication) or to register a function as a handler (e.g. to register different parser functions for different file types).

This article briefly explains how decorators work in Python and then shows different patterns of decorators that I have seen in the wild over the years, in the hope that that will make decorators slighly less confusing. Finally, it will go into some examples of how decorators can be used for different programming tasks.

What a Decorator is

Say you have a function definition for my_decorator like the following:

def my_decorator(f):
	print('function {} was passed to my_decorator'.format(f))
   	return f

You can then use this function as a decorator:

@my_decorator
def greet(name):
	print('hello, {}'.format(name))

# this will first print : "function <function greet at 0x....> was passed to my_decorator"
# and then it will print: "hello, world"
greet('world')

All that a decorator is is simply syntactic sugar for applying the decorator function to the decorated function and making the return value the new value for the function's name:

def greet(name):
	print('hello, {}'.format(name))

# this is what the decorator desugars to
greet = my_decorator(greet)

# this will first print : "function <function greet at 0x....> was passed to my_decorator"
# and then it will print: "hello, world"
greet('world')

Decorator Patterns

Now that you understand how decorators work, I want to show and explain the main patterns used for implementing decorators in the wild. While I am listing three different patterns in this article, you want to use pattern 2 in most situations since it gives the highest amount of flexibility. Pattern 1 is easier to understand and pattern "1/2" is a hacky solution for migrating from pattern 1 to pattern 2.

Pattern 1: Simple wrapping decorators

The "simple" decorator pattern takes a function f and wraps it with an inner function that does some magic (here: printing the arguments with which f was called) and then calls the original f:

import functools

###### Decorator definition

def decorator_simple(f):
    @functools.wraps(f)
    def inner(*args):
    	print('decorator_simple called on function with args: ()'.format(args)
        return f(*args)
        
    return inner

###### Functions using decorator

@decorator_simple
def fun_with_simple(name):
    print("Hello {}".format(name))

###### Usage examples

# decorator_simple called on function with args: ('world',)
# Hello world
fun_with_simple('world')

The decorator functools.wraps(f) (see docs) denotes that inner is a wrapped version of f and should inherit the attributes from f such as the function name, the function's docstring and the function's type annotation.

Pattern 2: Wrapping decorators with arguments

If you want to accept arguments within a decorator, there will be triply-nested functions: an "outer" function that takes the arguments, the actual decorator function that takes the function to be decorated, and the "inner" function that will be returned from the decorator:

import functools

###### Decorator definition

def decorator_with_args(decorator_argument="ASDF"):
    def the_decorator(f):
        @functools.wraps(f)
        def inner(*args):
            print("decorator_with_args with decorator argument {} saw args: {}".format(decorator_argument, args))
            return f(*args)
        return inner
    return the_decorator
    
###### Functions using decorator
    
@decorator_with_args()
def fun_with_decorator_args_default(name):
    print("Salutations {}".format(name))

@decorator_with_args('MORE ARGS!')
def fun_with_decorator_args_configured(name):
    print("Greetings {}".format(name))

###### Usage examples

# decorator_with_args with decorator argument ASDF saw args: ('earth',)
# Salutations earth
fun_with_decorator_args_default('earth')

# decorator_with_args with decorator argument MORE ARGS! saw args: ('universe',)
# Greetings universe
fun_with_decorator_args_configured('universe')

Pattern 1/2: "Double-use" decorators

One trouble with pattern 1 is that you will never be able to pass arguments to the decorator later on because @decorator(some_param) will be interpreted as invoking the inner function using the parameter some_param. If you have designed your decorator to use pattern 1 but now would like to accept parameters, there is the possibility to keep backwards compatibility while still supporting optional arguments to the decorator, though the solution is hacky:

import functools

###### Decorator definition

def decorator_doubleuse(decorator_argument="GHJK"):
    def the_decorator(f):
        @functools.wraps(f)
        def inner(*args):
            print("decorator_doubleuse with decorator argument {} saw args: {}".format(decorator_argument, args))
            return f(*args)
        return inner

    if callable(decorator_argument):
        # decorator_doubleuse was called as a bare identifier, not a function
        # the function-to-be-decorated was erroneously passed as decorator_argument
        # fidget things around to pretend that decorator_doubleuse was called as a function
        
        # assign the function to a properly-named variable
        f = decorator_argument
        
        # reset the argument to its default
        decorator_argument = 'GHJK'
        
        # return the decorated function
        return the_decorator(f)

    return the_decorator

###### Functions using decorator

@decorator_doubleuse
def fun_with_doubleuse1(name):
    print("Hi {}".format(name))

@decorator_doubleuse()
def fun_with_doubleuse2(name):
    print("Sup, {}".format(name))

@decorator_doubleuse('CONFIGURABLE!')
def fun_with_doubleuse3(name):
    print("Top of the morning {}".format(name))

###### Usage examples

# decorator_simple saw args: ('world',)
# Hello world
fun_with_simple('world')

# decorator_with_args with decorator argument ASDF saw args: ('earth',)
# Salutations earth
fun_with_decorator_args_default('earth')

# decorator_with_args with decorator argument MORE ARGS! saw args: ('universe',)
# Greetings universe
fun_with_decorator_args_configured('universe')


# decorator_doubleuse with decorator argument GHJK saw args: ('terra',)
# Hi terra
fun_with_doubleuse1('terra')

# decorator_doubleuse with decorator argument GHJK saw args: ('home',)
# Sup, home
fun_with_doubleuse2('home')

# decorator_doubleuse with decorator argument CONFIGURABLE! saw args: ('galaxy',)
# Top of the morning galaxy
fun_with_doubleuse3('galaxy')

Uses for Decorators

Now that we have seen some structures of how decorators are typically implemented, let's go into some use cases where I've found them useful in practice. You will notice that I am using "Pattern 2" for most cases since it allows me to extend the decorator at a later time.

Apps/APIs that require authentication

Say you have a Flask application or an API where you want to selectively control authentication and authorization to. A nice way of handling this is to write a decorator that can be added to authorization-requiring routes, only calling the route handler when authorization was successful and returning an error otherwise:

import functools
from flask import Flask, request
from werkzeug.exceptions import Unauthorized, Forbidden

app = Flask(__name__)

def requires_auth(valid_tokens={}):
    def decorator(f):
        @functools.wraps(f)
        def inner(*args, **kwargs):
            token = request.headers.get('Authorization', '').replace('Bearer ', '')
            if not token:
                raise Unauthorized('No bearer token provided')
			if token not in valid_tokens:
                raise Forbidden('Token is invalid')
               
            # everything OK, forward to route handler
            return f(*args, **kwargs)
           
@app.route('/secret-society')
@requires_auth(valid_tokens={'secret', 'shibboleth'})
def greet_secret():
    name = request.args.get('name', 'World')
    return f'Hello {escape(name)}!'


@app.route('/hacker-society')
@requires_auth(valid_tokens={'42', 'shibboleet'})
def greet_hacker():
    name = request.args.get('name', 'World')
    return f'How\'s hacking, {escape(name)}?'

A small remark on the order of decorator application: If multiple decorators are given to a function, they will be applied in an inside-out order. For example, the decorators for greet_secret desugar to:

greet_secret = requires_auth(valid_tokens={'secret', 'shibboleth'})(greet_secret)
greet_secret = app.route('/secret-society')(greet_secret)

Registering a function handler

Say you want to write a tool that can take different types of nested document formats such as JSON, YAML and TOML, query them and print their structure in a human-readable way akin to something like jq (an awesome tool that I highly recommend). A good way of registering different parser functions in a unified interface can be to use decorator in combination with a function to use for dispatching to the concrete implementation:

import os.path

# this part is the decorator
_PARSERS = {}
def parser(extension):
    """Register a parser for a file extension"""
    def inner(f):
        _PARSERS[extension] = f
        return f
    return inner

# this part is the dispatch function
def parse(filename):
    ext = filename.split('.')[-1].lower()
    parse_fn = _PARSERS.get(ext)

    if parse_fn is None:
        return None

    with open(filename, 'r') as f:
        return parse_fn(f)
The framework for defining and dispatching handler functions

You can now use the newly-defined parser decorator to register handler methods for different file formats:

@parser('json')
def parse_json(f):
    import json
    return json.load(f)

@parser('yaml')
def parse_yaml(f):
    import yaml
    return yaml.load(f, Loader=yaml.SafeLoader)

@parser('toml')
def parse_toml(f):
    import toml
    return toml.load(f)

With all this in place, you can use the parse dispatch function to automatically select the right handler depending on the file extension:

print(parse('data/test.json'))
print(parse('data/test.yaml'))
print(parse('data/test.toml'))

Conclusion

Decorators are a powerful meta-programming tool that can often times greatly clean up repetitive code and help you structure your applications better. With that being said, don't be too smart with the possibilities that this tool opens to you. I want to end this article with the famous quote:

Everyone knows that debugging is  twice as hard as writing a program in the first place. So if you're as  clever as you can be when you write it, how will you ever debug it?

- Brian W. Kernighan,
"The Elements of Programming Style", 2nd edition, chapter 2