Let’s talk about Python decorators!

If you’ve developed web apps using Flask or Django, you’re probably familiar with the handy @ functions above your views, enforcing certain rules like making sure a user is logged in before rendering the view, or checking whether the HTTP method being used is allowed.

A Flask(ish) example

The documentation links above give specific examples of decorators for those frameworks. Let’s look at an example view similar to the one from the Flask docs:

@app.route('/secret_page')
@login_required
def secret_page():
    return "OK", 200

There are two decorators on the secret_page view:

  • @app.route tells Flask which URL the view is associated with
  • @login_required checks whether a user is logged in before proceeding.

@app.route is actually just another function. If we weren’t using decorators, we could register the route with a long-winded function like this:

def secret_page():
    return "OK", 200 

def register_route(url, view_func, **):
    # Let’s pretend Flask stores URLs as a big list of tuples!
    my_big_list_of_global_app_urls.append(
        (url, view_func, **)
    )

register_route('/secret_page', secret_page)

Behold, the magic of functional programming! We are passing the entire secret_page function to register_route as the view_func argument. It’s functions all the way down, hashtag Inception etc.

(The ** refers to any arguments that secret_page might take.)

@login_required would look a little different when written out as a function:

def secret_page():
    return "OK", 200 

def login_required(view_func, **):
    # Again, this is a huge simplification of what happens
    if session.user_is_logged_in:
        return view_func(**)
    return “You need to log in to view this page”, 403

response, status_code = login_required(secret_page)

When we call login_required, it’s actually returning the result of view_func (assuming we’re logged in, of course). login_required is acting as an outer protective shell, wrapping secret_page and bailing out with a 403 if it fails the login check.

The @app.route decorator doesn’t work as a wrapper in quite the same way as @login_required - Flask calls it when the app is initialised, to build its big list of available routes.

This is why @app.route is always placed above other custom decorators on a view. What would happen if the decorators were the other way around?

Ordering decorators

Let’s flip the ordering:

@login_required
@app.route('/secret_page')
def secret_page():
    return "OK", 200

Flask will still discover the view and add it to its big list of routes. But it won’t be registered as a login_required route, because decorators are evaluated ‘from the inside out’ (the closest decorator to the def gets called first).

So @app.route gets called first, and adds the URL to the big Flask route list. But the view_func that gets passed to it doesn’t know anything about login_required yet! So when Flask later evaluates the route and sends traffic to /secret_page, that route is associated with the bare, unprotected view. Yikes.

Defining a decorator

Actual decorators don’t look like the ‘flat’ functions I’ve written out above. We need to declare something like this:

def login_required(view_func):
    @wraps
    def my_inner_func(*args, **kwargs):
        # Here’s where we do our custom stuff
        if session.user_is_logged_in:
            return view_func(*args, **kwargs)
        return “You need to log in to view this page”, 403
    return my_inner_func

Our outermost layer is login_required: this is the name we’ll use to decorate our view functions. It takes view_func as an argument and returns another function, my_inner_func. The key thing to note is that it is not returning the result of my_inner_func;, it’s returning the definition of that function.

my_inner_func is where any custom logic or checks will happen. It has access to view_func from the outer scope, and this example either returns the result of view_func, or an error.

So having defined @login_required above, we know it returns a function definition. When we use it to decorate the secret_page view, we are wrapping that view definition with our decorator definition, without actually evaluating anything yet (unlike our ‘flat’ functions above). Hurray!

A quick note about @wraps: it is a sanity-preserving, built-in helper (and yet another decorator!) that allows the innermost function (your originalsecret_page view) to bubble up seamlessly through all the layers without odd side effects. The python docs have more info on how this works.

Top tips

Decorators can all be a bit mind boggling, especially when you are debugging. My top tips for dealing with them:

  • Remember to work from the ‘inside out’
  • Remember that returning a function definition is not the same as returning a function result
  • Think very carefully before writing your own decorator from scratch, it is almost never worth bothering with on small projects
  • If you do decide to write your own, pay careful attention to what gets evaluated when. Read up on pickling!