Logo

Understanding Python Decorators thanks to Django

Master Python decorators by dissecting ones provided by Django

Published: 14 Oct, 2024


I hated decorators when I first tried to learn how to build my own because they didn’t make any sense. Some folks were also claiming that decorators need to be avoided because they’re syntactical sugar and add indirection to your code. There’s some truth to those as you’ll see shortly.

I learned about decorators in an unusual way - by reading source code containing decorators.

The book Fluent Python is a great resource for understanding how decorators work on a deeper level because it goes in depth on how closures work in Python. But I still had doubts in my head after reading the chapter about decorators because the book uses toy examples that didn’t apply to me.

Since I was working with Django at that time, I came across django.contrib.auth’s decorators.py module. I bookmark this Github page for whenever I need a refresher on decorators because I don’t write my own often and end up forgetting how they work eventually.

I like this module because it shows how to write decorators that accept their own arguments. Let’s go through it together.

login_required

The most common decorator in Django is login_required. Almost everyone who uses function based views will use it to protect the view from unauthenticated users. Go to line 72 to see how it’s defined.

It has three optional parameters:

  1. function.
  2. redirect_field_name.
  3. login_url

In the body, you can see that login_required isn’t actually a decorator, but a function that calls another function (user_passes_test) that returns the actual decorator (incidentally named actual_decorator in the code). Using the @ decorator syntax, you can use login_required in two different ways. Either you decorate your view directly, or you first call login_required and then decorate your view with the decorator that this call returns.

Look at how the first scenario works:

from django.contrib.auth import login_required

@login_required
def my_view(request):
    ...

In this case, the function parameter will receive the argument of my_view because you’re doing the same thing as my_view = login_required(my_view).

But you can also do this:

from django.contrib.auth import login_required

@login_required(login_url="/someurl")
def my_view(request):
    ...

You’d do this when you want to specify an alternative page that unauthenticated users should be redirected to. By default, they’re redirected to the url that LOGIN_URL holds in your settings.py file.

The syntax above is the same as my_view = login_required(login_url="/someurl")(my_view) or the following:

deco = login_required(login_url="/someurl")

my_view = deco(my_view)

Because remember that calling login_required(login_url="/someurl") returns a decorator because login_required isn’t actually a decorator. Are you confused yet?

user_passes_test

On line 22 you see the definition for the decorator returned by user_passes_test. Notice how this is where your view function is being passed.

Meaning, when you call login_required(), this is the decorator being returned according to line 79. When you decorate your views with @login_required, it’s almost as if you’re doing @decorator instead. By wrapping decorator inside user_passes_test, it’s able to have access to the login_url, and redirect_field_name variables thanks to closures.

Now you see why people say that decorators add indirection. The more complex decorators are, the more difficult they become to test and debug. When Django added asynchronous views, they also had to change the user_passes_test function to accomodate this new kind of view. It’s now more complex than before but still simple for users of the framework.

Overusing decorators

I’ve been thinking about how I would implement the same behavior as user_passes_test or login_required if decorators didn’t exist. For login_required, I came up with the following:

from django.conf import settings
from django.shortcuts import redirect

def my_view(request):
  if not request.user.is_authenticated:
    return redirect(settings.LOGIN_URL)

It looks simple but it requires typing more characters than @login_required.

Here’s how I would implement user_passes_test without decorators:

def my_test(user):
  return user.is_vip

def my_view(request):
  if not my_test(request.user):
    return redirect("somewhere")

Compared to decorating with user_passes_test:

def my_test(user):
  return user.is_vip

@user_passes_test(my_test)
def my_view(request):
  ...

To be honest, I don’t see the point of using the authentication decorators that Django provides because I can easily write my own conditional logic that are easy to understand. No need to understand that login_required calls a function that returns a decorator. Maybe I’m missing something but with this information, I feel like decorators are overuse. I need to investigate more to come to a conclusion about that.

Conclusion

To conclude, a decorator function is simply a function that will take your function as input and return another function. When you call your function, you will actually call the function that the decorator returns.

If your decorator needs arguments, you write a function that accepts the arguments you want, then you return an actual decorator that will accept the function that needs to be decorated as input.

On line 67, you see that it’s passing view_func to functools.wraps. I didn’t cover this part but it’s essentially copying metadata from your view function to the decorator. This Stackoverflow answer explains it perfectly.

Overall, decorators are interesting and powerful. I only write them if I absolutely have to because they can become too complex.


Email me if you have any questions about this post.

Subscribe to the RSS feed to keep updated.