Reusing Django include urls for index

Posted by Sjoerd Job on May 19, 2016, 3:02 p.m.

Often enough, you want to map the / URL to the root of a specific app. The trick I used to apply started giving warnings during ./manage.py check.

This is how I worked around it.

The problem

Suppose you have a per-app URL structure. What I often see in my URL configs is the following.

# project.urls

from django.conf.urls import url, include

import foo_app.urls
import foo_app.views
import bar_app.urls

urlpatterns = [
    url(r'^$', foo_app.views.index),
    url(r'^foo-app/', include(foo_app.urls)),
    url(r'^bar-app/', include(bar_app.urls)),
]

Here, the implicit assumption is that foo_app.urls also maps r'^$' to foo_app.views.index. But of course that can change, and then you also have to update the main URLs.

To make the problem a bit more clear, I wanted to achieve the following mapping

/                   -> foo_app.views.index
/foo-app/           -> foo_app.views.index
/foo-app/aap/       -> foo_app.views.aap
/foo-app/noot/      -> foo_app.views.noot
/foo-app/mies/      -> foo_app.views.mies
/bar-app/           -> bar_app.views.index
/bar-app/apples/    -> bar_app.views.apples
/bar-app/oranges/   -> bar_app.views.oranges

In particular, I do not want /mies to point to foo_app.views.mies, but be a 404 instead. To help me with that, I usually use the following trick:

# project.urls

from django.conf.urls import url, include

import foo_app.urls
import foo_app.views
import bar_app.urls

urlpatterns = [
    url(r'^$', include(foo_app.urls)),
    url(r'^foo-app/', include(foo_app.urls)),
    url(r'^bar-app/', include(bar_app.urls)),
]

Which works great. Except, Django added checks, and this solution caused a check to report the following warning.

?: (urls.W001) Your URL pattern '^$' uses include with a regex ending with a '$'. Remove the dollar from the regex to avoid problems including URLs.

Of course, I don't like warnings. And in this case, what Django (rightfully) sees as the problem is exactly what I want to accomplish.

The fix

Of course, the fix is easy. Just write a bunch of code to resolve the warning elegantly.

from django.conf.urls import include, url
from django.core.urlresolvers import RegexURLResolver


def lookup_root(urlconf):
    urlconf_module, app_name, namespace = include(urlconf)
    resolver = RegexURLResolver('^', urlconf_module, app_name=app_name, namespace=namespace)
    return resolver.resolve('').func

Isn't it pretty? Well, yes. I was really happy with myself having coded this. You can use it like this

urlpatterns = [
    url(r'^$', lookup_root(foo_app.urls)),
    url(r^foo-app/', include(foo_app.urls)),
    url(r^bar-app/', include(bar_app.urls)),
]

I only had to write a few lines of code, nothing really fancy, and it works the way I want it to. But is it the best solution?

The 'hack'

Now let's do it differently.

urlpatterns = [
    url(r'^(?!.)', include(foo_app.urls)),
    url(r^foo-app/', include(foo_app.urls)),
    url(r^bar-app/', include(bar_app.urls)),
]

Look mom! No dependencies!

The way it works is a bit tricky if you do not know regular expressions that will. Instead of using a $ sign to match the string ('beginning of string followed by end of string'), it works a bit differently. It is actually checking for 'beginning of string not followed by any character'. This is called a negative lookahead.

Now, this is actually not the best solution, but I decided to leave it in as it was the first workaround that came to my mind.

The improved 'hack'

An alternative (and better!) approach solution be

# (2016-06-03) Almost correct, check update below
urlpatterns = [
    url(r'^\Z', include(foo_app.urls)),
    url(r^foo-app/', include(foo_app.urls)),
    url(r^bar-app/', include(bar_app.urls)),
]

Which also works. This is because \Z also means 'end of string' (albeit in a somewhat different way).

The difference between $ and \Z is that $ also matches just before the last newline character in a string, and \Z only matches the exact end of the string. For a nice case where this difference actually matters, see Django's treatment on CVE-2015-5144.

In hindsight

One thing I would like to remark is that this is exactly the order I discovered the different solutions in. First, I started with the approach generating the warning. I did not like the warning, and looked under the hood of Django to see what was going on, and used that knowledge to introduce a workaround.

After having written the convoluted workaround, another workaround occurred to me: let's use my regex knowledge. Of course, normally the "now you have two problems" would apply, except that Django's URL matching is already regex based. This allowed me to use negative lookahead matching. But that felt even more like a hack.

Only during the writing of this post, it occurred to me that there was a simpler workaround: the \Z matching in regular expressions.

All three solutions are a hack. I think I'll use the \Z for now.

Update (2016-06-03)

After applying this method some more, I have found one draw-back: The reverse(...) does not work anymore. However, the fix is easy: just make sure the \Z-pattern is after the actual pattern.

urlpatterns = [
    url(r^foo-app/', include(foo_app.urls)),
    url(r'^\Z', include(foo_app.urls)),
    url(r^bar-app/', include(bar_app.urls)),
]