info@altius-group.ch
Froideville, Waadt
DE

The day Thursday meant Thurgau

/ 12 Min. Lesezeit

names.legal ships in six languages: English, French, German, Italian, Spanish, Portuguese. English is the source language — the msgid strings in the code are English sentences, and LANGUAGE_CODE = "en".

Which makes locale/en/LC_MESSAGES/django.po a strange file. It exists, Django compiles it, gettext consults it — and it must not translate anything. Its only correct content is msgstr == msgid, every entry, forever.

makemessages does not know that. And on 27 July 2026 we found out what it had been doing about it:

« Thursday »  → « Thurgau »
« Saturday »  → « Featured »
« End »       → « and »
« API keys »  → « API access »

2 327 entries like that.

Why a source locale exists at all

The reflex is to delete it. If English translates nothing, why keep a catalogue?

Because i18n_patterns and the language switcher need en to be a language like the others. /en/pricing/ has to resolve, {% get_available_languages %} has to list it, activate('en') has to do something coherent, and modeltranslation is configured with English in the tuple:

MODELTRANSLATION_LANGUAGES = ("en", "fr", "de", "it", "es", "pt")
MODELTRANSLATION_FALLBACK_LANGUAGES = {
    'default': ('en',),
    'fr': ('en',),     # Si français manque → afficher anglais
    ...
}

English is the fallback for all five others. It is the most load-bearing locale in the project, and the one whose file nobody ever opens, because there is nothing to translate in it.

That last clause is the whole vulnerability.

What makemessages does, and why it is right to do it

When makemessages finds a msgid that is not in the catalogue, it does not simply add an empty entry. It looks for an existing entry with a similar msgid, copies that entry’s msgstr, and marks the result fuzzy.

In a translated locale this is a genuine kindness. You change "Save changes" to "Save your changes", and instead of losing the French, gettext hands the translator "Enregistrer les modifications" pre-filled and flagged for review. That is exactly what you want; it is why the feature exists.

In the source locale it is nonsense generation. There is no translation to preserve — every msgstr is a copy of its own msgid — so “similar existing entry” means “some other English string that shares letters with this one”. "Thursday" is textually close to "Thurgau", a Swiss canton that appears in this codebase because company registrations have a canton. gettext has no way to know that one is a weekday and the other is a place. It matches, it copies, it flags.

Run that over a codebase with a few thousand strings and you get two thousand three hundred of them.

Why nobody noticed

A fuzzy entry is ignored at runtime. gettext falls through to the msgid, the page shows Thursday, and everything looks correct — because everything is correct, in the only sense that matters to a user.

So this is not a bug. It is a landmine:

"""
2327 entrées de cette nature. Elles ne s'affichaient pas — une entrée fuzzy est
ignorée à l'exécution — mais c'est une mine : le jour où un outil retire les
drapeaux fuzzy en masse, l'anglais affiche « Thurgau » pour « Thursday ». Et
une entrée avait déjà perdu son drapeau en route : ``Pending`` sortait
« Sending... » sur quatre écrans réels.
"""

The day a tool strips fuzzy flags in bulk, English shows “Thurgau” for “Thursday”. And one entry had already lost its flag on the way: Pending was coming out as “Sending…” on four real screens.

That last sentence is the reason the whole thing got investigated. Someone reported that a status badge said Sending… where it should say Pending. One entry, one missing flag. The other 2 326 were sitting in the same file waiting for any of the ordinary events that clear fuzzy flags in bulk — a translation editor’s “accept all”, a msgattrib --clear-fuzzy, a merge tool, a well-meaning script.

The failure mode of this class of bug is worth naming precisely: the damage is already done, and the trigger is somebody else’s routine action, later.

Cleaning by hand does not work

The obvious repair — open the file, fix 2 327 entries, commit — is the one thing that definitely fails. The next makemessages rebuilds the same pile, because the behaviour that produced it is not a mistake to be corrected but a documented feature running against an input it was never meant for.

Anything that has to be redone after every makemessages has to be a command.

"""
Le nettoyage à la main ne tient pas : la prochaine passe de ``makemessages``
recrée le même tas. D'où une commande, à enchaîner systématiquement après
``makemessages`` — et un test (``core.tests.SourceLocaleTests``) qui échoue si
on l'oublie.

    python manage.py makemessages -a
    python manage.py sync_source_locale
    python manage.py compilemessages
"""

Three lines, in that order, and the middle one is not optional.

The command

The detection is a definition, not a heuristic. An entry in the source locale is wrong if it is flagged fuzzy, or if its msgstr is anything other than its own msgid:

def desynchronised(po):
    """Entrées dont le msgstr ne dit pas exactement ce que dit le msgid."""
    out = []
    for entry in po:
        if entry.obsolete:
            continue
        if "fuzzy" in entry.flags or entry.msgstr != _expected(entry)[0] or (
                entry.msgid_plural and entry.msgstr_plural != _expected(entry)[1]):
            out.append(entry)
    return out

Three cases fall out of that one condition, and they are genuinely different diseases with one cure: the wrongly matched entry (ThursdayThurgau), the correct but flagged entry (right text, ignored at runtime), and the never filled entry (empty msgstr, added by a makemessages that found nothing similar).

entry.obsolete is skipped because obsolete entries — the #~ block at the end of a .po — are gettext’s own archive of strings no longer in the code. Rewriting them would create noise in every diff and correct nothing.

Plurals need their own answer, and the comment explains the one decision in it:

def _expected(entry):
    """(msgstr, msgstr_plural) attendus pour une entrée de la locale source."""
    if not entry.msgid_plural:
        return entry.msgid, {}
    # Le nombre de formes vient de l'en-tête de la locale : `en` en déclare 2,
    # mais on suit ce que le fichier annonce plutôt qu'un 2 codé en dur.
    forms = len(entry.msgstr_plural) or 2
    plural = {i: (entry.msgid if i == 0 else entry.msgid_plural) for i in range(forms)}
    return "", plural

Form 0 is the singular msgid, every other form is the msgid_plural. English declares two forms; the code reads how many the file actually has and only falls back to 2 when there are none. The alternative — hard-coding 2 — works perfectly until the day the command is pointed at a source locale that is not English, and then produces a catalogue that compilemessages rejects.

That is what --locale is for, and why the default is a default rather than a constant:

parser.add_argument(
    "--locale", default="en",
    help="Code de la locale source (défaut : en).")

The repair itself is four lines:

def synchronise(entry):
    msgstr, plural = _expected(entry)
    if entry.msgid_plural:
        entry.msgstr_plural = plural
    else:
        entry.msgstr = msgstr
    entry.flags = [f for f in entry.flags if f != "fuzzy"]

Note that it strips fuzzy by filtering rather than assigning a fresh list. Other flags — python-format, no-python-format, c-format — carry meaning that compilemessages enforces, and clearing them wholesale would break the build in a way that is very hard to attribute back to this function. The next section is about exactly that.

The check mode, and the test that uses it

The command has a second personality:

parser.add_argument(
    "--check", action="store_true",
    help="N'écrit rien ; sort en erreur si le fichier est désynchronisé.")
if options["check"]:
    if drifted:
        self.stdout.write(self.style.ERROR(
            f"{locale} : {len(drifted)} entrée(s) désynchronisée(s). "
            f"Lancer « manage.py sync_source_locale »."))
        for entry in drifted[:10]:
            self.stdout.write(f"  {entry.msgid!r} -> {entry.msgstr!r}")
        if len(drifted) > 10:
            self.stdout.write(f"  … et {len(drifted) - 10} autres")
        raise CommandError("locale source désynchronisée")

Ten examples and a count, not 2 327 lines. The examples are what make the failure diagnosable in a CI log — 'Thursday' -> 'Thurgau' explains itself, and a bare number does not.

The same detector then backs a test, which is the part that actually holds:

class SourceLocaleTests(SimpleTestCase):
    """...
    Le nettoyage se refait avec `manage.py sync_source_locale`. Ce test est là
    pour qu'on ne l'oublie pas — un oubli n'a AUCUN symptôme jusqu'au jour où
    un drapeau saute.
    """

    def test_the_source_locale_says_exactly_what_its_msgids_say(self):
        path = source_locale_path("en")
        self.assertTrue(path.exists(), f"locale source absente : {path}")

        drifted = desynchronised(polib.pofile(str(path)))
        details = "\n".join(
            f"  {e.msgid!r} -> {e.msgstr!r}" for e in drifted[:10])
        self.assertEqual(
            drifted, [],
            f"{len(drifted)} entrée(s) de locale/en ne disent pas ce que dit leur "
            f"msgid. Lancer « manage.py sync_source_locale ».\n{details}")

An omission has NO symptom until the day a flag drops. A convention that depends on remembering a step is not a convention; it is a countdown. The test converts “we agreed to run the command” into “the build fails if you did not”.

A guard that cannot fail guards nothing

The second test in that class is the one I would point at if I had to defend this whole file:

def test_the_detector_actually_catches_a_drift(self):
    """Un garde-fou qui ne peut pas échouer ne garde rien : on lui montre
    les trois formes de dérive déjà observées en vrai."""
    po = polib.POFile()
    po.append(polib.POEntry(msgid="Pending", msgstr="Pending"))          # sain
    po.append(polib.POEntry(msgid="Thursday", msgstr="Thurgau"))         # apparié à tort
    po.append(polib.POEntry(msgid="Saturday", msgstr="Saturday",
                            flags=["fuzzy"]))                            # bon texte, mais ignoré
    po.append(polib.POEntry(msgid="API key", msgstr=""))                 # jamais rempli

    drifted = {e.msgid for e in desynchronised(po)}
    self.assertEqual(drifted, {"Thursday", "Saturday", "API key"})

The first test passes on a clean repository. It will keep passing if someone refactors desynchronised into return []. Every assertion of the form this file has no problems has that shape, and it is the standard way a guard rots: it goes green, stays green, and stops meaning anything.

So the detector is shown four hand-built entries — one healthy, three sick, each sickness one that was actually observed in the real file — and asked to name the sick ones. The set comparison catches both directions: a detector that stops seeing empty msgstr fails, and so does one that starts flagging the healthy entry.

The comments naming each case are doing real work too. # bon texte, mais ignoré is the case a reader would otherwise delete as redundant — Saturday maps to Saturday, what could be wrong? — and the answer, that a fuzzy flag makes a correct entry invisible, is the entire subject of the file.

The second bug: a per cent sign that is not a format

The same catalogue has a smaller problem with the same shape, and it is worth the section because the shape is what generalises.

Two source strings contain a literal per cent sign:

LITERAL_PERCENT = [
    "e.g. 30% deposit, Balance",
    "e.g., 'High demand', '15% growth'",
]
TRANSLATED_LOCALES = ["fr", "de", "it", "es", "pt"]

gettext scans msgids for printf-style conversions to decide whether to stamp #, python-format. In "30% deposit" it sees % d — per cent, space, d — and %d with an intervening space is a legal conversion specification. So it concludes the string is a format string and flags it.

Two things then go wrong, and neither is obvious from the flag:

"""
L'heuristique de gettext lit « % d » (dans « 30% deposit ») comme une
conversion et repose `python-format` à CHAQUE `makemessages`, ce qui
refuzzyfie les entrées — donc les affiche en anglais — et fait échouer
`compilemessages` sur les traductions qui écrivent « 30 % » avec l'espace
typographique.
"""

First, compilemessages validates format strings: a translation whose conversions do not match its msgid’s is an error, not a warning. French typography puts a non-breaking space before %, so the correct French translation is « 30 % d'acompte » — which gettext reads as containing % d', a different conversion from the one it thinks is in the msgid. The build fails, on a string that has no formatting in it at all, in a language whose typographic rules are the proximate cause.

Second, the entry gets re-fuzzied, and a fuzzy entry falls back to English. So the French page shows an English example string, silently.

The fix is a flag — no-python-format — which gettext honours. And then:

"""
Le drapeau `no-python-format` corrige, mais il est effacé à la passe
suivante : constaté deux fois dans la même session.
"""

The no-python-format flag fixes it, but is erased on the next pass: observed twice in the same session.

Which is the same story as the source locale, one size down. A repair that a routine command undoes is not a repair; it is a chore. And the response is the same — not a cleverer fix, but a tripwire:

def test_the_literal_percent_entries_stay_out_of_python_format(self):
    for locale in self.TRANSLATED_LOCALES:
        path = source_locale_path(locale)
        po = polib.pofile(str(path))
        for msgid in self.LITERAL_PERCENT:
            entry = po.find(msgid)
            if entry is None or entry.obsolete:
                continue
            self.assertNotIn(
                "python-format", entry.flags,
                f"{locale} : {msgid!r} remarqué `python-format` par makemessages — "
                f"reposer `no-python-format`.")
            self.assertNotIn(
                "fuzzy", entry.flags,
                f"{locale} : {msgid!r} est fuzzy, donc affiché en anglais.")

Five locales × two strings, and the failure message says what to do rather than what happened. if entry is None or entry.obsolete: continue keeps the test from failing when the string is legitimately removed from the code — the test guards a known hazard, and must not become an obstacle to deleting the copy that carries it.

source_locale_path is reused here for a locale that is not the source, which is a small naming lie the code lives with: the function builds locale/<code>/LC_MESSAGES/django.po and the name describes its first caller rather than its behaviour. Worth renaming; not worth pretending it is fine.

What carries over

  • A source locale is a locale, and tooling will treat it like one. If your msgids are English and English is in LANGUAGES, locale/en is a file that gettext will happily fill with garbage, and nothing will display wrong until something clears the flags.
  • fuzzy is not a warning, it is a hiding place. Damage under a fuzzy flag is invisible in production and invisible in review, because the file nobody reads is the one where every entry is supposed to be trivial.
  • If a command undoes your fix, the fix is a command. Then chain it, and test that it was run.
  • Test the detector, not only the state. Everything is fine is an assertion that passes when the checker breaks; feed it known-bad input taken from the incident.
  • Put the remedy in the failure message. Lancer « manage.py sync_source_locale » is the difference between a red build somebody fixes in a minute and one that gets an issue opened about it.
Bereit loszulegen?

Sprechen wir über Ihr Projekt

Erzählen Sie uns von Ihrem Bedarf in IoT, GIS oder individueller Entwicklung — wir melden uns innerhalb von 24 Stunden.