Im Produktivbetrieb¶
Das Tutorial durchläuft die Schleife einmal, allein, an einem Programm mit einer einzigen Nachricht. In einem echten Projekt dreht sich die Schleife weiter: Nachrichten ändern sich, nachdem sie übersetzt wurden, die übersetzende Person arbeitet woanders und nach eigenem Zeitplan, und mit jedem Release wird ein kompilierter Katalog ausgeliefert. Diese Seite ist diese Praxis — was im Repository bleibt, was auf Reisen geht, was CI absichern muss und wo die Laufzeit eine Sprache bindet.
Unterm Strich sind es sechs Prüfungen, deshalb stehen sie hier zuerst; jeder Abschnitt weiter unten richtet eine davon ein.
pybabel update --checkläuft durch — keine Nachricht hat sich geändert, ohne dass die Kataloge davon erfahren haben.pybabel compilemacht den Build von seinem Exit-Status abhängig.- Verbliebene
fuzzy-Einträge sind gewollt — jeder rendert als Quelltext, bis eine übersetzende Person ihn bestätigt. - Die Testsuite rendert jede ausgelieferte Sprache einmal mit
strict=True. - Das Produktionsartefakt enthält
.mo-Dateien und kein Babel. - Der Logger
gettext_tstringsist an das Monitoring angeschlossen.
Die Gestalt eines Projekts¶
myapp/
├── babel.cfg
├── pyproject.toml
├── src/
│ └── myapp/
└── locales/
├── messages.pot
├── ja/LC_MESSAGES/messages.po
└── de/LC_MESSAGES/messages.po
Committe babel.cfg, die .pot-Vorlage und jede .po — sie sind die Quellen
des Übersetzungs-Builds, und ihre Diffs sind der Weg, Übersetzungsänderungen
zu reviewen. Die kompilierten .mo-Dateien sind Build-Artefakte: Erzeuge sie
in CI oder beim Paketieren, statt sie zu committen, damit eine .po und ihre
.mo nie uneins darüber sein können, was ausgeliefert wird.
Eine Datei hat in jede Richtung eine Rolle: Die .pot trägt deine Nachrichten
hinaus zu den Übersetzenden, die .po-Dateien tragen Übersetzungen
zurück. Der Rest dieser Seite ist das, was sich zwischen ihnen bewegt.
flowchart LR
code["Quellcode<br>t-string-Aufrufstellen"] -->|"pybabel extract"| pot["messages.pot"]
pot -->|"pybabel update"| po["eine .po pro Sprache"]
po --> tr["Übersetzende<br>oder Plattform"]
tr --> po
po -->|"pybabel compile (CI)"| mo[".mo-Dateien"]
mo --> app["Anwendung<br>zur Laufzeit"]
Der Zyklus nach der ersten Übersetzung¶
Das pybabel init des Tutorials läuft normalerweise einmal, wenn eine Sprache
hinzukommt. Von da an lautet der Arbeitszyklus extrahieren → aktualisieren →
übersetzen → kompilieren, und sein Zentrum ist pybabel update, das eine
frische Vorlage in die vorhandenen Kataloge einarbeitet, ohne die bereits
enthaltenen Übersetzungen zu verwerfen.
Angenommen, die Begrüßung Hello {name} — bereits als こんにちは {name}
übersetzt — wird im Code zu Welcome back, {name} umformuliert. Extrahieren
und aktualisieren:
$ 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
Der japanische Katalog enthält jetzt:
#. gettext-tstrings
#: app.py:4
#, fuzzy, python-brace-format
msgid "Welcome back, {name}"
msgstr "こんにちは {name}"
Babel hat bemerkt, dass die neue msgid einer entfernten ähnelt, und sie mit
der alten Übersetzung gepaart — das Paar aber als fuzzy markiert: die
Vermutung einer Maschine, die auf einen Menschen wartet. Das Flag ändert, was
kompiliert wird: pybabel compile schließt fuzzy-Einträge aus der .mo
aus — bis eine übersetzende Person das Paar bestätigt, rendert die Anwendung
also den neuen englischen Text statt eines veralteten japanischen:
$ 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
Eine geänderte Nachricht degradiert daher genauso wie eine fehlerhafte — zur
Quellsprache, nie zu einer veralteten Übersetzung. Der Part der übersetzenden
Person in diesem Zyklus ist, das msgstr zu überarbeiten und das
fuzzy-Flag zu löschen; das nächste Kompilieren nimmt den Eintrag wieder
auf.
Platzhalternamen sind Teil der Identität einer Nachricht
Die msgid ist der Katalogschlüssel, und der Name des Platzhalters
steckt darin — wer also eine Variable im Code umbenennt
(name → user_name), ändert die msgid und schickt die Übersetzung
jeder Sprache erneut durch den fuzzy-Zyklus. Benenne interpolierte
Variablen als Wörter, die eine übersetzende Person versteht, und benenne
sie nur aus gutem Grund um.
Die Formatierung ist das Spiegelbild: !r und :.2f sind
nicht Teil der msgid, sodass das
Verschärfen von {amount:,.2f} zu {amount:,.0f} in keinem Katalog
etwas ändert. Den Satz umzuformulieren ist natürlich eine echte
Änderung — das ist der Zyklus oben.
Was CI absichert¶
Drei Fehlschläge sind einen roten Build wert: Die Kataloge sind hinter den Code zurückgefallen, eine Übersetzung hat einen Platzhalter beschädigt, oder ein fehlerhafter Eintrag hat sich bis zur Laufzeit durchgeschlichen. Ein Schritt pro Fehlschlag:
- 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 schreibt nichts um und endet mit einem Exitstatus
ungleich null, wenn ein Katalog gegenüber der frisch extrahierten Vorlage
veraltet ist — die Schranke gegen das Mergen von Code, dessen Nachrichten
niemand neu extrahiert hat. pybabel compile führt die Platzhalterprüfungen
von Babel und dem
registrierten Checker
dieses Pakets aus.
Babel 2.18.0: --check kann keinen Katalog absichern, der Kontexte verwendet
Unter Babel 2.18.0 meldet pybabel update --check jeden Katalog, der
ein msgctxt enthält, bei jedem Lauf als veraltet, wie aktuell er auch sein
mag. Eine dauerhaft fehlschlagende Schranke ist schlimmer als gar keine,
weil ein Team sie abschaltet — wenn du also pgettext oder npgettext
überhaupt verwendest, ersetze diesen Schritt, statt mit ihm zu leben. Die
Vorlage und jeden Katalog mit babel.messages.pofile.read_po einzulesen und
{(m.context, m.id) for m in catalog if m.id} zu vergleichen ist die ganze
Prüfung — und genau das tut
der eigene Build dieser Website. Die Ursache ist
bei den Fallstricken beschrieben.
Prüfe den Exitstatus, nicht das Log
pybabel compile meldet jeden Platzhalterfehler, endet mit einem Status
ungleich null — und schreibt die .mo trotzdem. Eine Pipeline, die
kompiliert und danach locales/ in ein Image kopiert, liefert den
fehlerhaften Katalog aus, sofern der Exitstatus ungleich null sie nicht
tatsächlich stoppt. Den Schritt den Build fehlschlagen zu lassen, wie
oben, ist die ganze Lösung.
Die letzte Zeile ist deine gewöhnliche Testsuite, um eine Gewohnheit ergänzt: Irgendwo darin wird mindestens eine Nachricht pro ausgelieferter Sprache durch einen strikten Übersetzer gerendert —
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}")
— denn strict=True
löst dort aus, wo die Produktion stillschweigend zurückfallen würde,
und ein Rendern zur Laufzeit ist die eine Prüfung, die den Katalog genau so
sieht, wie die Anwendung es tun wird, .mo inklusive.
Zusammenarbeit mit Übersetzenden und Plattformen¶
Die .po-Datei ist das Austauschformat der gesamten gettext-Welt — genau
deshalb verwendet diese Bibliothek sie weiter: Übersetzung zu übergeben
heißt, eine Datei zu übergeben, ob die Empfängerin eine Kollegin mit
PO-Editor ist oder eine Plattform wie Weblate oder Crowdin. Drei Dinge machen
die Übergabe gut:
Sag, wozu die Nachricht dient. Ein Kommentar im Code reist mit der
Nachricht — genau das sammelt das Flag -c "Translators:" ein:
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 ""
Eine übersetzende Person sieht diesen Kommentar in ihrem Editor, neben der
Nachricht, am anderen Ende der Welt. Es ist der günstigste Qualitätshebel im
gesamten Workflow. Für ein Wort, das sein eigenes Homonym ist — „Open“ der
Button gegenüber „Open“ der Zustand —, gib der Nachricht mit pgettext
einen Kontext, der im Katalog als sichtbares
msgctxt erscheint.
Lass die Plattform Platzhalter validieren. Jede aus einer t-string
extrahierte Nachricht trägt das Flag python-brace-format, und diese eine
Zeile schaltet die Platzhalter-QA in Werkzeugen ein, die du nicht
kontrollierst — Weblate dokumentiert die Prüfung, kommerzielle Plattformen
knüpfen ihre eigene an dasselbe Flag, und msgfmt --check-format erzwingt
sie in jeder GNU-Pipeline. Die Details, und was der mitgelieferte Checker
darüber hinaus erkennt, stehen auf der
Extraktionsseite.
Vertrau dem Sicherheitsnetz genau so weit, wie es reicht. Was von einer Plattform zurückkommt, sind weiterhin Daten, die in deinen Build gelangen; erst die CI-Schranken oben machen aus „die Plattform hat das vermutlich geprüft“ ein „das kann nicht defekt ausgeliefert werden“.
Eine Sprache zur Laufzeit binden¶
Alles bisher erzeugt Kataloge. Die verbleibende Entscheidung ist, wo die Anwendung einen auswählt. Binde einmal pro Geltungsbereich einer Sprache — den Prozess bei einem CLI, die Anfrage bei einem Webservice.
Ein Kommandozeilenwerkzeug oder eine Desktop-Anwendung liest die Umgebung
der Nutzerin einmal, beim Start. Ohne languages= verhandelt die
Standardbibliothek über LANGUAGE, LC_ALL, LC_MESSAGES und LANG;
fallback=True liefert einen Null-Katalog — Quelltext — statt eine
Exception auszulösen, wenn keine davon zu einem ausgelieferten Katalog
passt.
Eine Webanwendung entscheidet pro Anfrage. Lade jeden Katalog einmal beim
Import und binde dann den ausgehandelten an den Kontext, bevor die View
läuft — set_translations ist
kontextlokal, sodass gleichzeitige Anfragen in verschiedenen Sprachen nie
die Bindung der jeweils anderen sehen.
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}")
Unter async-Frameworks — FastAPI, Starlette und allem anderen, was ASGI
spricht — umschließe die Anfrage mit
use_translations: Die Bindung lebt in
einer ContextVar, die async-Taskwechsel pro Anfrage bewahren.
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 steht für dein Accept-Language-Parsing — die meisten
Frameworks oder ihre Ökosysteme bringen eines mit; worauf es hier
ankommt, ist die Bindung um call_next.
Zwei Laufzeitgewohnheiten vervollständigen das Bild. Strings, die zur
Importzeit entstehen — ein Formularlabel, der Anzeigename eines Enums —,
dürfen nicht die Sprache einfangen, die während des Imports gerade aktiv
war; definiere sie mit lazy_gettext, und
sie rendern in der Sprache, die bei der Nutzung aktiv ist. Und leite den
Logger gettext_tstrings dorthin, wo ein Mensch hinschaut: Seine Warnungen
sind der nachsichtige Modus, der eine Übersetzung meldet, die an jeder
Schranke vorbeigerutscht ist — eine Zeile pro defekter Nachricht statt einer
pro Rendern.
Ausliefern¶
Die Produktion braucht das Paket, die .mo-Dateien und sonst nichts. Babel
ist eine Entwicklungs- und CI-Abhängigkeit — halte gettext-tstrings[babel]
aus dem Produktions-Image heraus und installiere dort das nackte Paket; das
Rendern läuft allein mit der Standardbibliothek. Kompiliere Kataloge in
demselben Build, der das auszuliefernde Artefakt erzeugt, damit die
.mo-Dateien darin exakt den reviewten .po-Dateien entsprechen und nie
etwas ausgeliefert wird, das auf irgendeinem Laptop kompiliert wurde.
Wie sie mitreisen, hängt davon ab, was du auslieferst. Ein Wheel trägt sie als
Paketdaten, was bedeutet, dass die Kataloge innerhalb des Paketverzeichnisses
liegen müssen — src/myapp/locales/, nicht ein locales/ auf oberster Ebene —
und dem Build-Backend gesagt werden muss, dass es Dateien einschließen soll,
die .gitignore normalerweise verbirgt:
Lies sie über das Paket zurück und nicht über einen Pfad relativ zum Quellbaum, der in dem Moment aufhört zu existieren, in dem das Wheel installiert ist:
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"])
Ein Container-Image hat es leichter: im Build-Stage kompilieren und das Ergebnis kopieren, sodass Babel in dieser Stage zurückbleibt.
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
Vor einem Release lautet die Checkliste, auf die sich diese Seite reduziert:
pybabel update --checkläuft durch — keine Nachricht hat sich geändert, ohne dass die Kataloge davon erfahren haben.pybabel compileschrankt den Build über seinen Exitstatus.- Verbleibende
fuzzy-Einträge sind beabsichtigt — jeder rendert als Quelltext, bis eine übersetzende Person ihn bestätigt. - Die Testsuite rendert jede ausgelieferte Sprache einmal mit
strict=True. - Das Produktionsartefakt enthält
.mo-Dateien und kein Babel. - Der Logger
gettext_tstringsist ans Monitoring angebunden.
Wie es weitergeht¶
- Extraktion — die Referenz für die Werkzeughälfte dieser Seite: Mapping-Optionen, eigene Funktionsnamen, strikter Modus und jeder Checker.
- Anleitung — die Laufzeithälfte: Pluralformen, Kontexte, verzögerte Strings und die Fehlermodi im Detail.
- Funktionsweise — warum die msgid so aussieht, wie sie aussieht, und was die Validierung tatsächlich prüft.