I started a newsletter!
You can read more about it here.

Nik Kantar

Thursday, January 21, 2021

Django Site Dispatch, Improved

Turns out I like this problem so much I had to solve it again.

Note: This is a followup to my previous post, which you should read if you want this one to make any sense.


I know, I know, I’m way oversolving the problem at this point, but I can’t help myself! And to be fair, a big part of why I engage in all this not-for-work hackery is having the freedom to overdo things on a whim. Anyway, I got some ideas, so I tried them out.

Existing Solution

Just for reference, this is where we left off last time, sans any import statements and function docstring:

def dispatch(request, main_class=None, cms_class=None, short_class=None, **kwargs):
    classes = {
        settings.FULL_DOMAIN: main_class,
        f"www.{settings.FULL_DOMAIN}": main_class,
        settings.SHORT_DOMAIN: short_class,
        f"www.{settings.SHORT_DOMAIN}": short_class,
    }
    target_class = classes.get(request.site.domain, cms_class)

    if target_class is None:
        raise Http404()

    return target_class.as_view()(request, **kwargs)

Pretty simple stuff: use the domain as a dictionary key to figure out which class to use, and handle the lack thereof gracefully.

switch

My first new idea is a bit controversial in Python.

One of my favorite podcasters, Michael Kennedy, wanted a switch statement in the language, so he wrote it. I’ve known about it for a while, but have never had a good use case until now. I’ve been catching up on my Python Bytes backlog, and it came up in an episode I just listened to, so I thought I’d give it a shot. Here’s the switch based version:

def dispatch(request, main_class=None, cms_class=None, short_class=None, **kwargs):
    with switch(request.site.domain) as app:
        app.case(settings.FULL_DOMAINS, lambda: main_class)
        app.case(settings.SHORT_DOMAINS, lambda: short_class)
        app.default(lambda: cms_class)

    if app.result is None:
        raise Http404()

    return app.result.as_view()(request, **kwargs)

I think that’s easier to read than the above dictionary based implementation. The project’s README has direct comparisons with equivalent dictionary and if/elif/else versions for reference.

Forgiveness Instead of Permission

Sometimes you’ll hear Pythonistas say this:

It’s better to ask for forgiveness than permission.

This means that your code should behave as if it’s on the happy path, and handle errors gracefully. In this case, the None check before the return does the opposite. It was a pretty minor change, but I think it significantly improved the logic flow:

def dispatch(request, main_class=None, cms_class=None, short_class=None, **kwargs):
    with switch(request.site.domain) as app:
        app.case(settings.FULL_DOMAINS, lambda: main_class)
        app.case(settings.SHORT_DOMAINS, lambda: short_class)
        app.default(lambda: cms_class)

    try:
        return app.result.as_view()(request, **kwargs)

    except AttributeError:
        raise Http404()

Instead of trying to bail just one line before the return, might as well try returning instead, and dealing with the None case after. Furthermore, for performance reasons it makes more sense to evaluate the likeliest scenario first.

Neater and more Pythonic.

PageNotFoundView

Clearly in an overarchitecting mood, the last change I made was really more academic than practical. Everything up until now has required handling the None situation “manually”. What if that weren’t necessary? Well, it’s not:

class PageNotFoundView(View):
    def dispatch(self, request, *args, **kwargs):
        raise Http404()


def dispatch(
    request,
    main_class=PageNotFoundView,
    cms_class=PageNotFoundView,
    short_class=PageNotFoundView,
    **kwargs,
):
    with switch(request.site.domain) as app:
        app.case(settings.FULL_DOMAINS, lambda: main_class)
        app.case(settings.SHORT_DOMAINS, lambda: short_class)
        app.default(lambda: cms_class)

    return app.result.as_view()(request, **kwargs)

Oh, the cleverness of me! Since all dispatch ever actually does is try and pick the correct class based view for the current domain, the None default is a bit of an odd duck. We can’t exactly call None.as_view() and expect something reasonable to happen. But we can use a different default value instead!

PageNotFoundView is likely the simplest class based view I’ve ever written. All it does is take a shortcut to the 404 page. It encapsulates what previously had to be done explicitly for None into a wonderfully self documented class, simplifying the dispatch view without obfuscating any real complexity. Now dispatch can assume it’ll always encounter a usable view class and just call as_view().

The Final Form

To be completely honest, it’s entirely possible I’ll refactor this again in the future. It may even be likely, in fact. But for the time being, the full version looks pretty good to me:

from switchlang import switch

from django.conf import settings
from django.http import Http404
from django.views.generic import View


class PageNotFoundView(View):
    """
    Fallback view for the dispatch helper below.

    All this view does is return a 404, and its sole purpose is decluttering the
    dispatch helper view defined below.

    By using it as the default value for parameters to dispatch, dispatch doesn't have
    to explicitly account for omitted classes, and can just call the as_view method.
    """

    def dispatch(self, request, *args, **kwargs):
        raise Http404()


def dispatch(
    request,
    main_class=PageNotFoundView,
    cms_class=PageNotFoundView,
    short_class=PageNotFoundView,
    **kwargs,
):
    """
    Delegate to the correct class based on the current site.

    This helper view allows the user to delegate the request to the correct class based
    on the current site. This allows for different apps with their own needs to "share"
    URLs when appropriate.

    Each of the "_class" params corresponds to a view class for the desired app.
    """
    with switch(request.site.domain) as app:
        app.case(settings.FULL_DOMAINS, lambda: main_class)
        app.case(settings.SHORT_DOMAINS, lambda: short_class)
        app.default(lambda: cms_class)

    return app.result.as_view()(request, **kwargs)

Now place your bets on when the next set of thoroughly unnecessary ideas will come up…