Ga naar inhoud

In productie

De tutorial draait de lus één keer, alleen, op een programma met één bericht. In een echt project blijft de lus draaien: berichten veranderen nadat ze vertaald zijn, de vertaler werkt elders en volgens een eigen schema, en met elke release wordt een gecompileerde catalogus uitgeleverd. Deze pagina is die praktijk — wat in de repository blijft, wat reist, wat CI moet bewaken, en waar de runtime een taal bindt.

Waar het op neerkomt zijn zes controles, dus die staan hier eerst; elke sectie hieronder zet er één van op.

  • pybabel update --check slaagt — geen bericht is veranderd zonder dat de catalogi ervan gehoord hebben.
  • pybabel compile laat de build afhangen van zijn exitstatus.
  • Overgebleven fuzzy-entries zijn bedoeld — elk daarvan rendert als brontekst tot een vertaler hem bevestigt.
  • De testsuite rendert elke uitgeleverde taal één keer met strict=True.
  • Het productieartefact bevat .mo-bestanden en geen Babel.
  • De gettext_tstrings-logger is naar monitoring geleid.

De vorm van een project

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

Commit babel.cfg, het .pot-sjabloon en elke .po — zij zijn de bronnen van de vertaalbuild, en hun diffs zijn hoe je vertaalwijzigingen reviewt. De gecompileerde .mo-bestanden zijn build-artefacten: produceer ze in CI of bij het inpakken in plaats van ze te committen, zodat een .po en zijn .mo het nooit oneens kunnen zijn over wat er uitgeleverd wordt.

Eén bestand heeft in elke richting een rol: de .pot draagt je berichten naar buiten, naar vertalers, de .po-bestanden dragen vertalingen terug. De rest van deze pagina is wat er tussen die twee beweegt.

flowchart LR
  code["broncode<br>t-string-aanroepplekken"] -->|"pybabel extract"| pot["messages.pot"]
  pot -->|"pybabel update"| po["één .po per taal"]
  po --> tr["vertaler<br>of platform"]
  tr --> po
  po -->|"pybabel compile (CI)"| mo[".mo-bestanden"]
  mo --> app["applicatie<br>tijdens runtime"]

De cyclus na de eerste vertaling

De pybabel init uit de tutorial draait normaal gesproken één keer, wanneer een taal wordt toegevoegd. Vanaf dan is de werkcyclus extraheren → updaten → vertalen → compileren, en het middelpunt ervan is pybabel update, dat een vers sjabloon in de bestaande catalogi vouwt zonder de vertalingen die er al in staan weg te gooien.

Stel dat de begroeting Hello {name} — al vertaald als こんにちは {name} — in code wordt herschreven tot Welcome back, {name}. Extraheer en update:

$ 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

De Japanse catalogus bevat nu:

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

Babel merkte op dat de nieuwe msgid lijkt op een verwijderde en paarde hem met de oude vertaling — maar markeerde het paar fuzzy: de gok van een machine in afwachting van een mens. De vlag verandert wat er compileert. pybabel compile sluit fuzzy-entries uit van de .mo, zodat de applicatie, totdat een vertaler het paar bevestigt, de nieuwe Engelse tekst rendert in plaats van een verouderde Japanse:

$ 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

Een gewijzigd bericht degradeert dus op dezelfde manier als een kapot — naar de brontaal, nooit naar een verouderde vertaling. Het deel van de cyclus dat van de vertaler is: de msgstr herzien en de fuzzy-vlag verwijderen; de volgende compile pikt de entry op.

Placeholdernamen zijn deel van de identiteit van het bericht

De msgid is de catalogussleutel, en de naam van de placeholder zit erin — dus een variabele hernoemen in code (nameuser_name) verandert de msgid en stuurt de vertaling ervan in elke taal terug door de fuzzy-cyclus. Geef geïnterpoleerde variabelen namen die een vertaler zal begrijpen, en hernoem ze alleen met een reden.

Opmaak is het spiegelbeeld: !r en :.2f zijn geen deel van de msgid, dus {amount:,.2f} aanscherpen tot {amount:,.0f} verandert niets in welke catalogus dan ook. De zin herformuleren is natuurlijk wél een echte wijziging — dat is de cyclus hierboven.

Wat CI bewaakt

Drie mislukkingen zijn een rode build waard: de catalogi raakten achter op de code, een vertaling brak een placeholder, of een kapotte entry glipte door naar de runtime. Eén stap per mislukking:

- 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 herschrijft niets en eindigt met een niet-nul status wanneer een catalogus verouderd is ten opzichte van het vers geëxtraheerde sjabloon — de bewaking tegen het mergen van code waarvan niemand de berichten opnieuw extraheerde. pybabel compile draait de placeholdercontroles van zowel Babel als de geregistreerde checker van dit pakket.

Babel 2.18.0: --check kan geen catalogus bewaken die contexten gebruikt

Op Babel 2.18.0 rapporteert pybabel update --check elke catalogus die een msgctxt bevat als verouderd, bij elke run, hoe actueel hij ook is. Een permanent falende poort is erger dan geen poort, want een team zet hem uit — dus als je pgettext of npgettext überhaupt gebruikt, vervang deze stap dan liever dan ermee te leven. Het sjabloon en elke catalogus lezen met babel.messages.pofile.read_po en {(m.context, m.id) for m in catalog if m.id} vergelijken is de hele controle, en het is wat de eigen build van deze site doet. De oorzaak is uitgeschreven op Valkuilen.

Controleer de exitstatus, niet het log

pybabel compile rapporteert elke placeholderfout, eindigt niet-nul — en schrijft de .mo evengoed. Een pipeline die compileert en dan locales/ in een image kopieert, levert de kapotte catalogus uit, tenzij die niet-nul-exit hem daadwerkelijk stopt. De stap de build laten breken, zoals hierboven, is de hele oplossing.

De laatste regel is je gewone testsuite, met één gewoonte erbij: render ergens daarin ten minste één bericht per uitgeleverde taal via een strikte translator —

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}")

— want strict=True raist waar productie stilletjes zou terugvallen, en een runtime-render is de ene controle die de catalogus precies zo ziet als de applicatie hem zal zien, .mo en al.

Werken met vertalers en platforms

Het .po-bestand is het uitwisselingsformaat van de hele gettext-wereld, en dat is de reden dat deze bibliotheek het hergebruikt: vertaling uit handen geven betekent een bestand overhandigen, of de ontvanger nu een collega met een PO-editor is of een platform als Weblate of Crowdin. Drie dingen laten de overdracht goed werken:

Zeg waar het bericht voor dient. Een commentaar in de code reist mee met het bericht — dat is wat de vlag -c "Translators:" verzamelt:

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 ""

Een vertaler ziet dat commentaar in zijn editor, naast het bericht, aan de andere kant van de wereld. Het is de goedkoopste kwaliteitshendel in de hele workflow. Voor een woord dat zijn eigen homoniem is — "Open" de knop tegenover "Open" de toestand — geef het bericht een context met pgettext, die een zichtbare msgctxt in de catalogus wordt.

Laat het platform placeholders valideren. Elk bericht dat uit een t-string geëxtraheerd is, draagt de vlag python-brace-format, en die ene regel is wat placeholder-QA inschakelt in tools die jij niet beheert — Weblate documenteert de controle, commerciële platforms koppelen hun eigen aan dezelfde vlag, en msgfmt --check-format dwingt haar af in elke GNU-pipeline. De details, en wat de meegeleverde checker daarbovenop vangt, staan op de extractiepagina.

Vertrouw het vangnet precies zo ver als het reikt. Wat er ook van een platform terugkomt, het is nog steeds data die je build binnenkomt; de CI-poorten hierboven zijn wat "het platform heeft dit waarschijnlijk gecontroleerd" verandert in "dit kan niet kapot uitgeleverd worden".

Een taal binden tijdens runtime

Alles tot nu toe produceert catalogi. De resterende beslissing is waar de applicatie er een selecteert. Bind één keer per reikwijdte van een taal — het proces voor een CLI, het request voor een webservice.

Een commandoregeltool of desktopapplicatie leest de omgeving van de gebruiker één keer, bij het opstarten. Geen languages= doorgeven laat de standaardbibliotheek onderhandelen op basis van LANGUAGE, LC_ALL, LC_MESSAGES en LANG; fallback=True geeft een null-catalogus terug — brontekst — in plaats van te raisen wanneer geen ervan overeenkomt met een catalogus die je uitlevert.

import gettext

from gettext_tstrings import Translator

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

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

Een webapplicatie beslist per request. Laad elke catalogus één keer bij import, en bind dan de onderhandelde aan de context voordat de view draait — set_translations is context-lokaal, dus gelijktijdige requests in verschillende talen zien elkaars binding nooit.

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}")

Onder async frameworks — FastAPI, Starlette en al het andere dat ASGI is — wikkel het request in use_translations: de binding leeft in een ContextVar, die async taakwisseling per request bewaart.

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 staat voor jouw Accept-Language-parsing — de meeste frameworks of hun ecosystemen leveren er een; wat hier telt is de binding rond call_next.

Twee runtimegewoonten maken het plaatje compleet. Strings die bij importtijd worden aangemaakt — een formulierlabel, de weergavenaam van een enum — mogen niet vastleggen welke taal er tijdens de import toevallig actief was; definieer ze met lazy_gettext en ze renderen in de taal die actief is bij gebruik. En leid de gettext_tstrings-logger naar een plek waar een mens kijkt: zijn waarschuwingen zijn de milde modus die een vertaling rapporteert die langs elke poort glipte, één regel per kapot bericht in plaats van één per render.

Uitleveren

Productie heeft het pakket, de .mo-bestanden en verder niets nodig. Babel is een ontwikkel- en CI-dependency — houd gettext-tstrings[babel] buiten het productie-image en installeer daar het kale pakket; renderen draait op de standaardbibliotheek alleen. Compileer catalogi in dezelfde build die het artefact produceert dat je deployt, zodat de .mo-bestanden erin exact de gereviewde .po-bestanden zijn, en er nooit iets uitgeleverd wordt dat op iemands laptop gecompileerd is.

Hoe ze meereizen hangt af van wat je deployt. Een wheel draagt ze als package-data, wat betekent dat de catalogi binnen de packagemap moeten staan — src/myapp/locales/, niet een locales/ op het hoogste niveau — en dat de build-backend verteld moet worden om bestanden op te nemen die .gitignore normaal verbergt:

[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"]

Lees ze terug via het package in plaats van via een pad relatief aan de broncode-boom, dat ophoudt te bestaan zodra het wheel geïnstalleerd is:

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"])

Een container-image heeft het makkelijker: compileer tijdens de buildfase en kopieer het resultaat, en laat Babel in die fase achter.

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

Vóór een release, de checklist waartoe deze pagina zich laat samenvatten:

  • pybabel update --check slaagt — geen bericht veranderde zonder dat de catalogi ervan hoorden.
  • pybabel compile bewaakt de build via zijn exitstatus.
  • Overgebleven fuzzy-entries zijn opzettelijk — elk ervan rendert als brontekst totdat een vertaler het bevestigt.
  • De testsuite rendert elke uitgeleverde taal één keer met strict=True.
  • Het productieartefact bevat .mo-bestanden en geen Babel.
  • De gettext_tstrings-logger is naar monitoring geleid.

Waar nu heen

  • Extractie — de referentie voor de tooling-helft van deze pagina: mapping-opties, eigen functienamen, strikte modus en elke checker.
  • Handleiding — de runtime-helft: meervouden, contexten, uitgestelde strings en de faalmodi in detail.
  • Hoe het werkt — waarom de msgid eruitziet zoals hij eruitziet, en wat validatie werkelijk controleert.