Aller au contenu

En production

Le tutoriel fait tourner la boucle une fois, seul, sur un programme à un seul message. Sur un vrai projet, la boucle ne s'arrête plus : des messages changent après avoir été traduits, le traducteur travaille ailleurs et à son propre rythme, et un catalogue compilé accompagne chaque release. Cette page décrit cette pratique — ce qui reste dans le dépôt, ce qui voyage, ce que la CI doit verrouiller et où le runtime lie une langue.

Tout cela se ramène à six contrôles ; les voici d'emblée, et chaque section ci-dessous en met un en place.

  • pybabel update --check passe — aucun message n'a changé sans que les catalogues en aient été avertis.
  • pybabel compile conditionne le build à son statut de sortie.
  • Les entrées fuzzy restantes sont voulues — chacune se rend en texte source jusqu'à ce qu'un traducteur la confirme.
  • La suite de tests rend une fois chaque langue livrée avec strict=True.
  • L'artefact de production contient les fichiers .mo et pas Babel.
  • Le logger gettext_tstrings est raccordé à la supervision.

La forme d'un projet

myapp/
├── babel.cfg
├── pyproject.toml
├── src/
│   └── myapp/
└── locales/
    ├── messages.pot
    ├── ja/LC_MESSAGES/messages.po
    └── de/LC_MESSAGES/messages.po

Committez babel.cfg, le modèle .pot et chaque .po — ce sont les sources du build de traduction, et leurs diffs sont votre outil de revue des changements de traduction. Les fichiers .mo compilés sont des artefacts de build : produisez-les en CI ou au moment du packaging plutôt que de les committer, afin qu'un .po et son .mo ne puissent jamais diverger sur ce qui est livré.

Un fichier joue un rôle dans chaque direction : le .pot transporte vos messages vers les traducteurs, les fichiers .po rapportent les traductions en retour. Le reste de cette page est ce qui circule entre les deux.

flowchart LR
  code["code source<br>sites d'appel t-string"] -->|"pybabel extract"| pot["messages.pot"]
  pot -->|"pybabel update"| po["un .po par langue"]
  po --> tr["traducteur<br>ou plateforme"]
  tr --> po
  po -->|"pybabel compile (CI)"| mo["fichiers .mo"]
  mo --> app["application<br>à l'exécution"]

Le cycle après la première traduction

Le pybabel init du tutoriel s'exécute normalement une seule fois, à l'ajout d'une langue. À partir de là, le cycle de travail est extraire → mettre à jour → traduire → compiler, et son centre est pybabel update, qui fusionne un modèle frais dans les catalogues existants sans jeter les traductions qu'ils contiennent déjà.

Supposons que la salutation Hello {name} — déjà traduite en こんにちは {name} — soit reformulée dans le code en Welcome back, {name}. Extrayez puis mettez à jour :

$ pybabel extract -F babel.cfg -c "Translators:" -o locales/messages.pot .
extracting messages from app.py (encoding="utf-8")
writing PO template file to locales/messages.pot
$ pybabel update -i locales/messages.pot -d locales
updating catalog locales/ja/LC_MESSAGES/messages.po based on locales/messages.pot

Le catalogue japonais contient désormais :

#. gettext-tstrings
#: app.py:4
#, fuzzy, python-brace-format
msgid "Welcome back, {name}"
msgstr "こんにちは {name}"

Babel a remarqué que le nouveau msgid ressemblait à un msgid supprimé et l'a apparié avec l'ancienne traduction — mais en marquant la paire fuzzy : la supposition d'une machine en attente d'un humain. Ce flag change ce qui est compilé : pybabel compile exclut les entrées fuzzy du .mo, donc tant qu'un traducteur n'a pas confirmé la paire, l'application rend le nouveau texte anglais plutôt qu'un japonais périmé :

$ pybabel compile -d locales
compiling catalog locales/ja/LC_MESSAGES/messages.po to locales/ja/LC_MESSAGES/messages.mo
$ python app.py
Welcome back, Ada

Un message modifié se dégrade donc de la même façon qu'un message cassé — vers la langue source, jamais vers une traduction obsolète. La part du traducteur dans le cycle est de réviser le msgstr et de supprimer le flag fuzzy ; la compilation suivante reprend l'entrée.

Les noms des marqueurs font partie de l'identité du message

Le msgid est la clé du catalogue, et le nom du marqueur en fait partie — renommer une variable dans le code (nameuser_name) change donc le msgid et renvoie la traduction de chaque langue dans le cycle fuzzy. Nommez les variables interpolées avec des mots qu'un traducteur comprendra, et ne les renommez que pour une bonne raison.

Le formatage est l'image inverse : !r et :.2f ne font pas partie du msgid, donc resserrer {amount:,.2f} en {amount:,.0f} ne change rien dans aucun catalogue. Reformuler la phrase, bien sûr, est un vrai changement — c'est le cycle ci-dessus.

Ce que la CI verrouille

Trois échecs méritent un build rouge : les catalogues ont pris du retard sur le code, une traduction a cassé un marqueur, ou une entrée cassée s'est glissée jusqu'au runtime. Une étape par échec :

- run: pybabel extract -F babel.cfg -c "Translators:" -o locales/messages.pot .
- run: pybabel update -i locales/messages.pot -d locales --check
- run: pybabel compile -d locales
- run: pytest

pybabel update --check ne réécrit rien et sort avec un statut non nul quand un catalogue est en retard sur le modèle fraîchement extrait — la garde contre la fusion d'un code dont personne n'a réextrait les messages. pybabel compile exécute les contrôles de marqueurs de Babel et du checker enregistré de ce paquet.

Babel 2.18.0 : --check ne peut pas verrouiller un catalogue qui utilise des contextes

Sur Babel 2.18.0, pybabel update --check signale chaque catalogue contenant un msgctxt comme en retard, à chaque exécution, quel que soit son degré d'actualité. Une barrière définitivement rouge est pire que pas de barrière, parce qu'une équipe finit par la désactiver — donc si vous utilisez ne serait-ce qu'une fois pgettext ou npgettext, remplacez cette étape plutôt que de vivre avec. Lire le modèle et chaque catalogue avec babel.messages.pofile.read_po et comparer {(m.context, m.id) for m in catalog if m.id} est tout le contrôle, et c'est ce que fait le build de ce site. La cause est détaillée sur Pièges.

Vérifiez le statut de sortie, pas le journal

pybabel compile signale chaque erreur de marqueur, sort avec un statut non nul — et écrit quand même le .mo. Une pipeline qui compile puis copie locales/ dans une image livre le catalogue cassé, sauf si le statut non nul l'arrête réellement. Laisser cette étape faire échouer le build, comme ci-dessus, est tout le correctif.

La dernière ligne est votre suite de tests ordinaire, avec une habitude en plus : quelque part en son sein, rendez au moins un message par langue livrée à travers un traducteur strict —

import gettext

from gettext_tstrings import Translator


def test_catalogs_render(language: str) -> None:
    translations = gettext.translation("messages", localedir="locales", languages=[language])
    _ = Translator(translations, strict=True)
    name = "Ada"
    assert _(t"Welcome back, {name}")

— parce que strict=True lève là où la production retomberait silencieusement, et qu'un rendu à l'exécution est le seul contrôle qui voit le catalogue exactement comme l'application le verra, .mo compris.

Travailler avec les traducteurs et les plateformes

Le fichier .po est le format d'échange de tout le monde gettext, et c'est la raison pour laquelle cette bibliothèque le réutilise : confier la traduction, c'est remettre un fichier, que le destinataire soit un collègue avec un éditeur PO ou une plateforme comme Weblate ou Crowdin. Trois choses font que la passation fonctionne bien :

Dites à quoi sert le message. Un commentaire dans le code voyage avec le message — c'est ce que collecte le flag -c "Translators:" :

from gettext_tstrings import tr

name = "Ada"
# Translators: shown on the dashboard right after sign-in
print(tr(t"Welcome back, {name}"))
#. Translators: shown on the dashboard right after sign-in
#. gettext-tstrings
#: app.py:5
#, python-brace-format
msgid "Welcome back, {name}"
msgstr ""

Un traducteur voit ce commentaire dans son éditeur, à côté du message, à l'autre bout du monde. C'est le levier de qualité le moins cher de tout le workflow. Pour un mot qui est son propre homonyme — « Open » le bouton contre « Open » l'état — donnez au message un contexte avec pgettext, qui devient un msgctxt visible dans le catalogue.

Laissez la plateforme valider les marqueurs. Chaque message extrait d'une t-string porte le flag python-brace-format, et cette seule ligne est ce qui active le contrôle qualité des marqueurs dans des outils que vous ne contrôlez pas — Weblate documente le contrôle, les plateformes commerciales appuient les leurs sur le même flag, et msgfmt --check-format l'applique dans toute pipeline GNU. Les détails, et ce que le checker fourni attrape au-delà, sont sur la page Extraction.

Faites confiance au filet de sécurité, mais pas plus loin qu'il ne va. Ce qui revient d'une plateforme reste des données qui entrent dans votre build ; les barrières de CI ci-dessus sont ce qui transforme « la plateforme a probablement vérifié » en « ceci ne peut pas être livré cassé ».

Lier une langue à l'exécution

Tout ce qui précède produit des catalogues. La décision restante est l'endroit où l'application en sélectionne un. Liez une fois par portée d'une langue — le processus pour un CLI, la requête pour un service web.

Un outil en ligne de commande ou une application de bureau lit l'environnement de l'utilisateur une fois, au démarrage. Ne pas passer languages= laisse la bibliothèque standard négocier depuis LANGUAGE, LC_ALL, LC_MESSAGES et LANG ; fallback=True renvoie un catalogue nul — le texte source — plutôt que de lever quand aucun d'eux ne correspond à un catalogue que vous livrez.

import gettext

from gettext_tstrings import Translator

_ = Translator(gettext.translation("messages", localedir="locales", fallback=True))

name = "Ada"
print(_(t"Welcome back, {name}"))

Une application web décide par requête. Chargez chaque catalogue une fois à l'import, puis liez celui qui a été négocié au contexte avant l'exécution de la vue — set_translations est local au contexte, donc des requêtes concurrentes dans des langues différentes ne voient jamais la liaison l'une de l'autre.

import gettext

from flask import Flask, request

from gettext_tstrings import set_translations, tr

LANGUAGES = ("en", "ja", "de")
CATALOGS = {
    language: gettext.translation(
        "messages", localedir="locales", languages=[language], fallback=True
    )
    for language in LANGUAGES
}

app = Flask(__name__)


@app.before_request
def bind_language() -> None:
    language = request.accept_languages.best_match(LANGUAGES) or "en"
    set_translations(CATALOGS[language])


@app.get("/")
def home() -> str:
    name = "Ada"
    return tr(t"Welcome back, {name}")

Sous les frameworks async — FastAPI, Starlette et tout ce qui est ASGI — enveloppez la requête dans use_translations : la liaison vit dans une ContextVar, que la commutation des tâches async préserve par requête.

import gettext

from fastapi import FastAPI, Request

from gettext_tstrings import tr, use_translations

LANGUAGES = ("en", "ja", "de")
CATALOGS = {
    language: gettext.translation(
        "messages", localedir="locales", languages=[language], fallback=True
    )
    for language in LANGUAGES
}

app = FastAPI()


@app.middleware("http")
async def bind_language(request: Request, call_next):
    language = negotiate_language(request.headers.get("accept-language"), LANGUAGES)
    with use_translations(CATALOGS[language]):
        return await call_next(request)

negotiate_language représente votre analyse d'Accept-Language — la plupart des frameworks ou leur écosystème en fournissent une ; ce qui compte ici est la liaison autour de call_next.

Deux habitudes d'exécution complètent le tableau. Les chaînes créées à l'import — l'étiquette d'un formulaire, le nom d'affichage d'une enum — ne doivent pas capturer la langue active pendant l'import ; définissez-les avec lazy_gettext et elles se rendront dans la langue active à l'utilisation. Et routez le logger gettext_tstrings vers un endroit qu'un humain regarde : ses avertissements sont le mode indulgent signalant une traduction qui a franchi toutes les barrières, une ligne par message cassé plutôt qu'une par rendu.

Livrer

La production a besoin du paquet, des fichiers .mo, et de rien d'autre. Babel est une dépendance de développement et de CI — gardez gettext-tstrings[babel] hors de l'image de production et installez-y le paquet nu ; le rendu tourne avec la seule bibliothèque standard. Compilez les catalogues dans le même build que celui qui produit l'artefact déployé, afin que les .mo qu'il contient soient exactement les .po relus, et que rien de compilé sur le portable de quelqu'un ne soit jamais livré.

La façon dont ils voyagent dépend de ce que vous déployez. Une wheel les transporte comme données de paquet, ce qui impose aux catalogues de vivre à l'intérieur du répertoire du paquet — src/myapp/locales/, et non un locales/ de premier niveau — et impose d'indiquer au backend de build d'inclure des fichiers que .gitignore masque normalement :

[tool.hatch.build]
# .mo files are build output, so they are gitignored; name them or the
# wheel ships without a single translation.
artifacts = ["src/myapp/locales/**/*.mo"]
[tool.setuptools.package-data]
myapp = ["locales/*/LC_MESSAGES/*.mo"]

Relisez-les à travers le paquet plutôt que par un chemin relatif à l'arbre source, qui cesse d'exister dès que la wheel est installée :

import gettext
from importlib.resources import as_file, files

with as_file(files("myapp") / "locales") as localedir:
    translations = gettext.translation("messages", localedir=localedir, languages=["ja"])

Une image de conteneur a la tâche plus facile : compilez pendant l'étape de build et copiez le résultat, en laissant Babel derrière dans cette étape.

FROM python:3.14-slim AS build
COPY . /src
RUN cd /src && python -m pip install ".[babel]" \
    && pybabel compile -d src/myapp/locales

FROM python:3.14-slim
COPY --from=build /src /src
RUN python -m pip install /src   # no [babel]: rendering needs the stdlib only

Avant une release, la checklist à laquelle cette page se réduit :

  • pybabel update --check passe — aucun message n'a changé sans que les catalogues en soient informés.
  • pybabel compile conditionne le build à son statut de sortie.
  • Les entrées fuzzy restantes sont intentionnelles — chacune se rend comme texte source tant qu'un traducteur ne l'a pas confirmée.
  • La suite de tests rend chaque langue livrée une fois avec strict=True.
  • L'artefact de production contient les fichiers .mo et aucun Babel.
  • Le logger gettext_tstrings est routé vers la supervision.

Pour continuer

  • Extraction — la référence de la moitié outillage de cette page : options de mapping, noms de fonctions personnalisés, mode strict et chaque checker.
  • Guide — la moitié exécution : pluriels, contextes, chaînes différées et les modes d'échec en détail.
  • Fonctionnement — pourquoi le msgid a la forme qu'il a, et ce que la validation vérifie réellement.