Django's translation system is built on top of GNU gettext tooling. This tooling works with two types of translation files:

  • Portable Objects (POs), plain-text message catalogues. These are the files that developers and translators work with. Their plain-text form lends itself to source control, allowing generic tools for diffs and merges to work without further effort.

  • Message Objects (MOs), binary-compiled catalogues. These are built from the POs, typically at package build or deployment time. These are loaded by the application at runtime to provide the translations to the user. In Django, they are built using django-admin compilemessages.

Django currently loads translations only from MO files at runtime, meaning that django-admin compilemessages has to be rerun every time they are updated. This isn't very costly, but does represent an extra step that slows iterating on translations, checking them in the UI, and running tests that rely on them -- or can lead to confusion when the developer (especially me!) forgets to rerun it.

We've introduced a hack in our codebase that monkey-patches Django's translation code in order to have the development environment load the PO files at runtime instead -- and reload them if modified. This avoids the need for rerunning the compilation step manually:

License information
Copyright (c) Django Software Foundation and individual contributors.
Copyright (c) Personalkollen
All rights reserved.

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

    1. Redistributions of source code must retain the above copyright notice,
       this list of conditions and the following disclaimer.

    2. Redistributions in binary form must reproduce the above copyright
       notice, this list of conditions and the following disclaimer in the
       documentation and/or other materials provided with the distribution.

    3. Neither the name of Django nor the names of its contributors may be used
       to endorse or promote products derived from this software without
       specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from gettext import GNUTranslations
from io import BytesIO
from pathlib import Path

from asgiref.local import Local
from django.utils.translation import trans_real
from django.utils.translation.trans_real import DjangoTranslation

# Package containing our code
import pk

SOURCES = [
    Path(pk.__file__).parent / "locale",
]


class PoTranslation(DjangoTranslation):
    """
    Hacky class which compiles .po files on the fly and reloads them
    when updated, allowing us to avoid compiling messages during
    development.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._reload_cache = {}

    def check_cache(self):
        import polib

        """
        Reload translations if the PO files in question have changed
        (according to filesystem metadata) since the last reload.
        """
        for source in SOURCES:
            path = source / Path(f"{self.language()}/LC_MESSAGES/{self.domain}.po")
            if not path.exists():
                continue
            modified = path.stat().st_mtime
            if self._reload_cache.get(path, None) != modified:
                po = polib.pofile(path)
                mo = po.to_binary()
                catalog = GNUTranslations(BytesIO(mo))
                self.merge(catalog)
                self._reload_cache[path] = modified

    def gettext(self, message):
        self.check_cache()
        return super().gettext(message)


def install() -> None:
    """
    Monkey-patch Django's translation machinery to use the
    PoTranslation class which loads and compiles PO files (and
    recompiles them on-the-fly when updated) instead of loading
    precompiled .mo files once.
    """
    # Some inspiration taken from django.utils.translation.reloader.
    trans_real.DjangoTranslation = PoTranslation
    trans_real._translations.clear()
    trans_real._default = None
    trans_real._active = Local()

We then enable this in our development settings file (not in production!):

from pk.utils import dev_translation
dev_translation.install()

This has a number of rough edges, which we are fine with or have mitigations for, but are worth considering if you want to use this:

  • Paths to the PO files for auto-reload need to be specified manually. This is not a problem for our use case, as we know exactly where they live and this won't be changing.

  • This performs an unnecessary amount of filesystem access (a stat on the PO files for each translation string lookup). There's plenty of room for improvement here, but so far this has not made a measurable difference in how long our tests take to run or web application performance -- and we're only using this in development environment, so it won't affect production.

  • There is some reliance on implementation details of Django's translation machinery, as is to be expected with monkey-patching. If an update to Django breaks our assumptions, this should be caught by our test suite.

  • There is more divergence between dev and production code paths.

Overall, we think the reduction in mental load and hidden context makes these tradeoffs worth it (especially when onboarding new developers) -- each step we can remove between "clone our repo" and "have a working development environment" is valuable.