Guide de traduction Django

L’internationalisation de Django de bout en bout : marqueurs gettext, traduction lazy, pluriels, balises de template, middleware de locale et flux de travail makemessages.

L’internationalisation de Django repose sur GNU gettext, ce qui la rend mature, bien comprise, et un peu plus cérémonieuse que les frameworks qui lisent de simples fichiers de données.

Activation

python
# settings.py
USE_I18N = True
USE_TZ = True

LANGUAGE_CODE = "en-us"
LANGUAGES = [
    ("en", "English"),
    ("fr", "Français"),
    ("de", "Deutsch"),
]

LOCALE_PATHS = [BASE_DIR / "locale"]

MIDDLEWARE = [
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",   # after session, before common
    "django.middleware.common.CommonMiddleware",
]

La position de LocaleMiddleware est structurelle. Il doit venir après SessionMiddleware, car il lit la langue depuis la session, et avant CommonMiddleware, car la résolution d’URL dépend de la langue active. Mal placé, il semble fonctionner, puis échoue sur des chemins spécifiques.

Notez aussi la distinction qui piège les gens : LANGUAGE_CODE utilise une balise en minuscules avec trait d’union (en-us, fr-ca), tandis que les répertoires de locale utilisent la forme POSIX avec underscore (locale/fr_CA/LC_MESSAGES/). Les deux sont corrects, dans le même projet, par conception — voir en-GB vs en_GB.

Marquer les chaînes

En Python :

python
from django.utils.translation import gettext as _

def dashboard(request):
    message = _("Welcome back")

Dans les modules évalués au moment de l’import — modèles, formulaires, admin — utilisez la variante lazy :

python
from django.utils.translation import gettext_lazy as _

class Project(models.Model):
    name = models.CharField(_("name"), max_length=100)

    class Meta:
        verbose_name = _("project")
        verbose_name_plural = _("projects")

Cette distinction est la chose la plus importante à bien comprendre. Au moment de l’import, il n’y a ni requête ni locale active, donc gettext se résout par rapport à ce qui se trouve être la valeur par défaut, et fige ce résultat pour toujours. gettext_lazy renvoie une promesse qui se résout au rendu, ce qui est ce que l’on veut.

La règle : au niveau du module ou dans le corps d’une classe → lazy. À l’intérieur d’une fonction appelée par requête → eager.

Les chaînes lazy ne sont pas de vraies chaînes, ce qui surprend parfois. Les concaténer avec + échoue ; utilisez format_lazy ou interpolez au moment du rendu.

Interpolation

Utilisez toujours des placeholders nommés :

python
_("Welcome back, %(name)s") % {"name": user.first_name}

N’utilisez jamais de %s positionnel dans une chaîne traduisible. Les traducteurs ne peuvent pas réordonner des arguments positionnels, et l’ordre des mots diffère entre les langues — voir la fiche mémo des formats de placeholder.

Pluriels

python
from django.utils.translation import ngettext

ngettext(
    "%(count)d message",
    "%(count)d messages",
    count,
) % {"count": count}

Vous fournissez le singulier et le pluriel anglais ; gettext sélectionne la bonne forme pour chaque langue en utilisant l’en-tête Plural-Forms du fichier PO :

"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"

Deux conséquences à bien retenir. D’abord, l’expression de pluriel de gettext renvoie un index, pas un nom de catégorie, donc msgstr[0] correspond à ce que l’expression associe à zéro — c’est le singulier par convention, mais pas nécessairement. Ensuite, le nombre de formes est une propriété de la langue cible : le polonais en a besoin de quatre, l’arabe de six. makemessages écrit l’en-tête correct pour les langues connues, mais un PO édité à la main avec un nplurals erroné échoue silencieusement pour les nombres qu’il ne couvre pas. Voir les règles de pluriel par langue.

Templates

django
{% load i18n %}

<h1>{% translate "Dashboard" %}</h1>

{% blocktranslate with name=user.first_name %}
  Welcome back, {{ name }}
{% endblocktranslate %}

{% blocktranslate count counter=messages|length %}
  There is {{ counter }} message.
{% plural %}
  There are {{ counter }} messages.
{% endblocktranslate %}

translate gère les chaînes simples ; blocktranslate gère tout ce qui contient des variables ou des pluriels. À l’intérieur de blocktranslate, vous ne pouvez utiliser que des variables simples — pas de filtres, pas d’appels de méthode — donc calculez les valeurs dans la vue ou liez-les avec with.

{% translate %} et {% blocktranslate %} ont remplacé {% trans %} et {% blocktrans %} dans Django 3.1. Les deux orthographes fonctionnent encore ; utilisez les nouvelles.

Le flux de travail makemessages

bash
# Analyser le code et écrire/mettre à jour locale/fr/LC_MESSAGES/django.po
django-admin makemessages -l fr

# Analyser aussi le JavaScript
django-admin makemessages -d djangojs -l fr

# Compiler le .po vers le .mo que Django lit réellement
django-admin compilemessages

compilemessages est l’étape qu’on oublie. Django lit le binaire compilé, donc un .po à jour avec un .mo obsolète produit exactement le symptôme « je l’ai traduit et rien n’a changé ». Exécutez-la dans votre build, pas à la main.

Ne committez pas les fichiers .mo. Ce sont des artefacts de build, ils entrent constamment en conflit dans Git, et ils ne contiennent rien que le .po n’ait déjà.

Deux options de makemessages à connaître : --no-obsolete supprime les chaînes qui ne sont plus présentes dans la source plutôt que de les laisser commentées, et --no-location retire les commentaires de fichier et de ligne qui produisent sinon d’énormes diffs à chaque déplacement de code.

Entrées fuzzy

Quand une chaîne source change légèrement, gettext marque la traduction existante #, fuzzy et la garde comme point de départ. Django ignore les entrées fuzzy à l’exécution, revenant à la langue source, donc une traduction fuzzy équivaut fonctionnellement à une traduction manquante jusqu’à ce qu’un traducteur la confirme.

C’est un comportement raisonnable, et il surprend les personnes qui voient une traduction présente dans le fichier PO et ne comprennent pas pourquoi la page affiche de l’anglais.

URLs et changement de langue

python
from django.conf.urls.i18n import i18n_patterns

urlpatterns = i18n_patterns(
    path("dashboard/", views.dashboard, name="dashboard"),
    prefix_default_language=False,
)

Cela produit /dashboard/ pour la langue par défaut et /fr/dashboard/ pour le français. prefix_default_language=False garde vos URLs canoniques sans préfixe, ce qui est généralement ce que vous voulez pour le SEO — associez cela à des alternates hreflang pour que les moteurs de recherche comprennent la relation entre les deux.

Synchroniser les traductions

Les fichiers PO sont le format d’échange, donc ils vont directement vers une plateforme de traduction :

bash
wti push      # locale/en/LC_MESSAGES/django.po envoyé
wti pull      # fichiers PO traduits reçus en retour
wti diff      # ce que changerait un push

Puis compilemessages dans votre build. Push au merge sur votre branche désignée, pull chaque nuit dans une pull request — voir les flux de travail de localisation basés sur Git.

Marqueurs contextuels et notes aux traducteurs

Deux fonctionnalités de gettext que Django expose, qui résolvent une ambiguïté réelle, et que la plupart des projets n’utilisent jamais.

pgettext désambiguïse des chaînes source identiques. Le mot Open utilisé comme libellé de bouton et Open utilisé comme statut sont la même chaîne, donc gettext les fusionne en une seule entrée et une seule traduction — ce qui est incorrect dans la plupart des langues :

python
from django.utils.translation import pgettext

pgettext("verb, button label", "Open")
pgettext("adjective, ticket status", "Open")

Elles deviennent des entrées PO séparées avec un msgctxt, ce qui permet à un traducteur de les rendre différemment. Dans les templates :

django
{% translate "Open" context "verb, button label" %}

Les commentaires aux traducteurs attachent une note à la chaîne extraite :

python
# Translators: affiché en cas d'échec de paiement. À formuler sans culpabiliser.
message = _("We could not process your card")

makemessages copie tout commentaire commençant par Translators: dans le fichier PO, là où le traducteur le voit réellement. Le préfixe est obligatoire — un commentaire ordinaire n’est pas extrait.

Ensemble, ces deux mécanismes couvrent la plupart des besoins d’un traducteur et coûtent quelques secondes au moment où la chaîne est écrite, quand le contexte est encore présent à l’esprit. Voir le contexte visuel pour les traducteurs pour comprendre pourquoi on ne le reconstitue jamais plus tard.

Tests

Vérifiez le comportement plutôt que le texte traduit, et utilisez override pour fixer la locale :

python
from django.test import TestCase
from django.utils.translation import override

class DashboardTests(TestCase):
    def test_renders_in_french(self):
        with override("fr"):
            response = self.client.get("/dashboard/")
        self.assertEqual(response.status_code, 200)

La vérification qui vaut la peine d’être ajoutée en CI est la complétude : chaque .po possède une traduction pour chaque msgid, aucune entrée ne reste fuzzy, et nplurals correspond à ce que la langue exige réellement. Le fait que Django ignore les entrées fuzzy à l’exécution rend ce dernier point plus important qu’il ne paraît — un fichier PO peut être « traduit » à 100 % et malgré tout servir de l’anglais.

Erreurs courantes

  • gettext là où gettext_lazy est nécessaire, figeant la langue par défaut dans les définitions de modèles et de formulaires.
  • Oublier compilemessages, si bien que les traductions existent mais ne servent à rien.
  • LocaleMiddleware mal positionné.
  • Committer les fichiers .mo.
  • %s positionnel dans des chaînes traduisibles.
  • Supposer qu’une entrée fuzzy est active. Ce n’est pas le cas.
  • Éditer Plural-Forms à la main et se tromper sur nplurals.

Frequently asked questions

Quelle est la différence entre gettext et gettext_lazy dans Django ?
gettext traduit immédiatement en utilisant la locale active. gettext_lazy diffère la traduction jusqu’au rendu de la chaîne. Utilisez lazy pour tout ce qui est évalué au moment de l’import — champs de modèle, labels de formulaire, choix — car au moment de l’import il n’y a ni requête ni locale active.
Comment créer des fichiers de traduction dans Django ?
Exécutez django-admin makemessages -l fr pour scanner votre code à la recherche de chaînes traduisibles et générer locale/fr/LC_MESSAGES/django.po. Une fois la traduction faite, exécutez compilemessages pour produire le binaire .mo que Django lit réellement à l’exécution.
Pourquoi mes traductions Django ne s’affichent-elles pas ?
Les causes habituelles sont l’oubli de compilemessages, un LOCALE_PATHS qui n’inclut pas votre répertoire de locale, LocaleMiddleware absent ou mal placé, ou USE_I18N à False. Vérifiez compilemessages en premier — un .po à jour avec un .mo obsolète est le cas le plus fréquent.
Django prend-il en charge ICU MessageFormat ?
Pas nativement. Django utilise gettext, dont la gestion des pluriels repose sur une expression d’index numérique plutôt que sur des catégories CLDR nommées, et qui n’a pas d’équivalent au select ou au formatage imbriqué d’ICU. Les équipes qui ont besoin d’ICU ajoutent généralement une bibliothèque séparée pour ces messages.

Keep reading

Translate your app without the spreadsheet round-trip

WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.