Chapter 5 · URL routing

5.6 Language prefixes and translated routes

How an address can depend on the active language: i18n_patterns puts the entries it is given under a LocalePrefixPattern, a route may be a lazily translated string, each resolver keeps its tables for reversing once for each language, and translate_url uses all of it to find a page’s address in another language.

A site in several languages usually wants the language in the address, "/en/shop/" beside "/nl/shop/", and sometimes wants the words of the address translated too. Django offers the two separately, and both rest on one fact about the resolver: a matcher can ask which language is active at the moment it is used. Neither matching nor reversing is then a fixed function of the URLconf. The same path can resolve under one language and fail under another.

The examples use a second URLconf of the museum’s. The entrance has one address. The opening hours and the shop are served under a language’s prefix, and the route of the first is a translated string, for which the recording has a Dutch catalogue with one entry.

# museum/urls_languages.py
from django.utils.translation import gettext_lazy as _

urlpatterns = [
    path("", views.entrance, name="entrance"),
] + i18n_patterns(
    path(_("opening-hours/"), views.hours, name="hours"),
    path("shop/", views.shop, name="shop"),
)

i18n_patterns and the prefix

i18n_patterns takes entries and returns a list holding one URLResolver. The resolver’s list is those entries, and its matcher is a LocalePrefixPattern, the third class of matcher. With settings.USE_I18N false it returns the entries as a list, with no resolver around them.

A LocalePrefixPattern has no text of its own. Its language prefix is worked out each time it is wanted: the code of the language that is active, or of settings.LANGUAGE_CODE when none is, and a slash. LocalePrefixPattern.match succeeds when the path begins with exactly that.

So the resolver that i18n_patterns made answers for one language at a time, the active one, and a path under any other language’s prefix does not match:

museum.urls_languages: a prefix for the active language, and a route that is translated
    with the language en active
      reverse("hours", urlconf=LANGUAGES)  ->  '/en/opening-hours/'
      reverse("shop", urlconf=LANGUAGES)  ->  '/en/shop/'
      reverse("entrance", urlconf=LANGUAGES)  ->  '/'
      resolve("/en/opening-hours/", urlconf=LANGUAGES).url_name  ->  'hours'
      resolve("/nl/openingstijden/", urlconf=LANGUAGES).url_name  ->  raises Resolver404
      resolve("/nl/opening-hours/", urlconf=LANGUAGES).url_name  ->  raises Resolver404
    with the language nl active
      reverse("hours", urlconf=LANGUAGES)  ->  '/nl/openingstijden/'
      reverse("shop", urlconf=LANGUAGES)  ->  '/nl/shop/'
      reverse("entrance", urlconf=LANGUAGES)  ->  '/'
      resolve("/en/opening-hours/", urlconf=LANGUAGES).url_name  ->  raises Resolver404
      resolve("/nl/openingstijden/", urlconf=LANGUAGES).url_name  ->  'hours'
      resolve("/nl/opening-hours/", urlconf=LANGUAGES).url_name  ->  raises Resolver404

With English active the Dutch address is a 404, and the other way round. The last line of the second group fails for a different reason: under Dutch the prefix is right, and the words after it are the English ones.

Reversing follows the same rule from the other side. A reversed path gets the prefix of the active language, and the entrance, which is outside i18n_patterns, gets none.

With no language active at all, which is the state inside django.utils.translation.override given None, the prefix falls back to the setting’s language:

With no language active, the prefix is that of LANGUAGE_CODE
    inside translation.override(None), which deactivates translations
      translation.get_language()  ->  None
      settings.LANGUAGE_CODE  ->  'en'
      reverse("hours", urlconf=LANGUAGES)  ->  '/en/opening-hours/'
      resolve("/en/opening-hours/", urlconf=LANGUAGES).url_name  ->  'hours'

The prefix can be left off the default language. i18n_patterns takes the keyword argument prefix_default_language=False, and the prefix is then empty whenever the active language’s code, as get_language gives it, equals settings.LANGUAGE_CODE:

UNPREFIXED is a URLconf of what i18n_patterns(path("shop/", views.shop, name="shop"), prefix_default_language=False) returns
with the language en active
  reverse("shop", urlconf=UNPREFIXED)  ->  '/shop/'
with the language nl active
  reverse("shop", urlconf=UNPREFIXED)  ->  '/nl/shop/'

Who activates the language

Since the prefix matches only the active language, something has to read the language out of a path and activate it before the path is resolved. The resolver cannot be that something, because matching the prefix is the step that needs the answer. LocaleMiddleware is, in its process_request, which runs before the handler resolves anything (Choosing a language: LocaleMiddleware).

The middleware first asks whether the URLconf has a language prefix at all. is_language_prefix_patterns_used goes through the entries of the root resolver’s list, and no deeper, and returns two answers: whether one of them has a LocalePrefixPattern for its matcher, and whether that matcher gives the default language a prefix.

is_language_prefix_patterns_used(urlconf): is there a prefix, and has the default language one
    the entry i18n_patterns made: type(entry).__name__, type(entry.pattern).__name__  ->  ('URLResolver', 'LocalePrefixPattern')
    is_language_prefix_patterns_used(LANGUAGES)  ->  (True, True)
    is_language_prefix_patterns_used("museum.urls")  ->  (False, False)

For the URLconf above that leaves the default language without a prefix, the second answer is the other one:

is_language_prefix_patterns_used(UNPREFIXED)  ->  (True, False)

The function looks nowhere but in that one list, and include refuses a URLconf that would put a language prefix lower down: it looks through the list of the URLconf it is given and raises ImproperlyConfigured if one of the entries has a LocalePrefixPattern.

The function is wrapped in functools.cache, and a call answered from the cache runs none of its code. So that the call would show, the recording’s script emptied the cache before this request:

With LocaleMiddleware and ROOT_URLCONF = 'museum.urls_languages': GET /nl/openingstijden/
    the script empties the cache of is_language_prefix_patterns_used, so that its body runs when it is next called
    LocaleMiddleware.process_request
      is_language_prefix_patterns_used('museum.urls_languages')  ->  (True, True)
      activate('nl')
    BaseHandler.resolve_request
      URLResolver.resolve('/nl/openingstijden/')  (the root resolver)  ->  ResolverMatch(views.hours, kwargs={}, namespaces=[], route='nl/openingstijden/')
    views.hours(request)
    LocaleMiddleware.process_response  (given a 200)  ->  a 200
    start_response("200 OK", headers)
    the server sends the body: 'the opening hours'
    the server calls close() on what it was given

Told that the URLconf has a prefix, the middleware looked in the path for a language before anywhere else and activated Dutch. The handler’s resolution then ran with Dutch active and matched. The route recorded on the match is the Dutch one, prefix and all: a LocalePrefixPattern writes itself as its current prefix.

Where every language has a prefix, a path with no language in it matches none of the patterns under the prefix. The middleware picks that up on the way out:

With LocaleMiddleware and ROOT_URLCONF = 'museum.urls_languages': GET /shop/ with Accept-Language: nl
    LocaleMiddleware.process_request
      activate('nl')
    BaseHandler.resolve_request
      URLResolver.resolve('/shop/')  (the root resolver)  raises Resolver404
    LocaleMiddleware.process_response  (given a 404)
      is_valid_path('/nl/shop/', 'museum.urls_languages')
        URLResolver.resolve('/nl/shop/')  (the root resolver)  ->  ResolverMatch(views.shop, kwargs={}, namespaces=[], route='nl/shop/')
        -> the ResolverMatch
      -> a 302, Location '/nl/shop/'
    start_response("302 Found", headers), with Location: /nl/shop/
    the server sends the body: ''
    the server calls close() on what it was given

The language came from the browser’s header this time, since the path had none. Resolution failed, and when the 404 came back through LocaleMiddleware.process_response it tried the path again with the active language’s prefix in front, found that it resolves, and answered with a redirect to it. It asks the resolver through is_valid_path, as CommonMiddleware does for a missing slash. Both of the middleware’s hooks asked again whether the URLconf has a prefix, and neither call shows: both were answered from the cache.

Without that middleware nothing activates a language from the path, and the patterns under the prefix match only the prefix of whatever language the thread happens to have active.

A route that is a translated string

The route given to path or re_path need not be a str. It may be the lazy string that django.utils.translation.gettext_lazy returns, which has no text until it is asked for some and then gives the translation for the language active at that moment.

A matcher treats the two kinds of route differently at the point where it compiles its regular expression. The attribute regex of a RoutePattern is a descriptor, and a read of it looks at the type of the route. For a str it compiles once and stores the result on the instance, where every later read finds it without the descriptor. For anything else it asks the route for its text, expands that into an expression, compiles it, and keeps the result in a dictionary on the matcher under the active language’s code; a read under another language does all of that again. RegexPattern has a descriptor of the same design.

In this recording vars shows what is stored on a matcher itself, and the dictionary by language is its _regex_dict.

What is kept once, and what once for each language
    shop is the URLPattern of the route "shop/", a str; hours is the URLPattern of the translated route; resolver is the root resolver of museum.urls_languages
    type(shop.pattern._route).__name__  ->  'str'
    "regex" in vars(shop.pattern)  ->  True
    sorted(shop.pattern._regex_dict)  ->  []
    type(hours.pattern._route).__mro__[1].__name__  ->  'Promise'
    "regex" in vars(hours.pattern)  ->  False
    sorted(hours.pattern._regex_dict)  ->  ['en', 'nl']

The shop’s matcher has its one compiled expression on the instance and nothing in the dictionary. The matcher for the opening hours has nothing on the instance, and an entry in the dictionary for each of the two languages it has been used under.

One part of a translated route is not worked out again for each language. A RoutePattern takes its converters once, as it is made, from the route as it reads under the language active at that moment, and RoutePattern.match looks up the converter for each group the regular expression captured by the group’s name. So a translation has to keep the route’s parameters as they are, and change only the words between them.

Tables for each language

Reversing has the same dependence, and the tables of Reversing a name are built from regular expressions, so there cannot be one set of them. Each of a resolver’s three dictionaries is really a dictionary of dictionaries, keyed first by language code. URLResolver._populate files what it builds under the language that is active as it runs, and each of the three properties that read them calls it again when the active language has no entry yet.

sorted(resolver._reverse_dict)  ->  ['en', 'nl']

The first reverse under each language therefore pays for a pass over the whole tree, and a process keeps a copy of its tables for each language it has reversed under. In each copy, a form that came from under i18n_patterns begins with the language prefix of that copy’s language.

translate_url

translate_url is given a URL and a language code and returns the address of the same page in that language. It takes the URL’s path, decodes its percent escapes, and resolves it under the language now active, to learn which pattern serves it and with what arguments. Then, with the language asked for made active for the length of the call, it reverses that pattern’s name, namespaces included, with the same arguments. The scheme, host, query string and fragment of the URL it was given are put back around the new path.

translate_url(url, lang_code): the same view's address in another language
    with en active, and museum.urls_languages the current URLconf
      translate_url("/en/opening-hours/", "nl")  ->  '/nl/openingstijden/'
      translate_url("https://museum.example/en/opening-hours/?day=sunday#top", "nl")  ->  'https://museum.example/nl/openingstijden/?day=sunday#top'
      translate_url("/en/shop/", "nl")  ->  '/nl/shop/'
      translate_url("/", "nl")  ->  '/'
      translate_url("/en/nothing/", "nl")  ->  '/en/nothing/'
      translate_url("/nl/openingstijden/", "en")  ->  '/nl/openingstijden/'
    with nl active
      translate_url("/nl/openingstijden/", "en")  ->  '/en/opening-hours/'

It returns the URL unchanged when the path does not resolve or the name cannot be reversed, so a path that matches nothing is returned as it came. The entrance comes back the same for another reason: both steps succeed, and its address is the same in both languages. The last two questions show the condition that is easiest to miss, which is that the URL has to be an address in the active language. A Dutch address handed in while English is active does not resolve, and comes back untranslated.

The view set_language is its caller. A form posts the language a visitor chose and the address of the page they were on; the view translates that address for the new language and redirects to it. django/conf/urls/i18n.py is itself a small URLconf, of one pattern that routes to that view, for a project to include.