Gå till innehållet

I produktion

Handledningen kör kretsloppet en gång, ensam, på ett program med ett meddelande. I ett riktigt projekt fortsätter kretsloppet snurra: meddelanden ändras efter att de har översatts, översättaren arbetar någon annanstans och enligt sitt eget schema, och en kompilerad katalog levereras med varje release. Den här sidan är den praktiken — vad som stannar i förrådet, vad som reser, vad CI måste grinda, och var körmiljön binder ett språk.

Vad det summerar till är sex kontroller, så här är de först; varje avsnitt nedan sätter upp en av dem.

  • pybabel update --check går igenom — inget meddelande har ändrats utan att katalogerna fått veta det.
  • pybabel compile grindar bygget på sin avslutsstatus.
  • Kvarvarande fuzzy-poster är avsiktliga — var och en renderas som källtext tills en översättare bekräftar den.
  • Testsviten renderar varje levererat språk en gång med strict=True.
  • Produktionsartefakten innehåller .mo-filer och ingen Babel.
  • Loggern gettext_tstrings är kopplad till övervakningen.

Formen på ett projekt

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

Committa babel.cfg, .pot-mallen och varje .po — de är källorna till översättningsbygget, och deras diffar är hur du granskar översättningsändringar. De kompilerade .mo-filerna är byggartefakter: producera dem i CI eller vid paketering i stället för att committa dem, så att en .po och dess .mo aldrig kan bli oense om vad som levereras.

En fil har en roll i vardera riktningen: .pot-filen bär dina meddelanden ut till översättarna, .po-filerna bär översättningarna tillbaka. Resten av den här sidan är det som rör sig mellan dem.

flowchart LR
  code["källkod<br>t-string-anropsplatser"] -->|"pybabel extract"| pot["messages.pot"]
  pot -->|"pybabel update"| po["en .po per språk"]
  po --> tr["översättare<br>eller plattform"]
  tr --> po
  po -->|"pybabel compile (CI)"| mo[".mo-filer"]
  mo --> app["applikation<br>vid körning"]

Cykeln efter den första översättningen

Handledningens pybabel init körs normalt en gång, när ett språk läggs till. Från och med då är arbetscykeln extrahera → uppdatera → översätt → kompilera, och dess mittpunkt är pybabel update, som viker in en färsk mall i de befintliga katalogerna utan att kasta bort översättningarna som redan finns i dem.

Anta att hälsningen Hello {name} — redan översatt som こんにちは {name} — omformuleras i koden till Welcome back, {name}. Extrahera och uppdatera:

$ 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

Den japanska katalogen innehåller nu:

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

Babel märkte att den nya msgid:n liknar en borttagen och parade ihop den med den gamla översättningen — men flaggade paret fuzzy: en maskins gissning som väntar på en människa. Flaggan ändrar vad som kompileras. pybabel compile utesluter fuzzy-poster ur .mo-filen, så tills en översättare bekräftar paret renderar applikationen den nya engelska texten snarare än en inaktuell japansk:

$ 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

Ett ändrat meddelande degraderar därför på samma sätt som ett trasigt — till källspråket, aldrig till en föråldrad översättning. Översättarens del av cykeln är att revidera msgstr och radera fuzzy-flaggan; nästa kompilering plockar upp posten.

Platshållarnamn är en del av meddelandets identitet

Msgid:n är katalognyckeln, och platshållarens namn finns inuti den — så att byta namn på en variabel i koden (nameuser_name) ändrar msgid:n och skickar varje språks översättning av den tillbaka genom fuzzy-cykeln. Namnge interpolerade variabler som ord en översättare förstår, och byt namn på dem bara av ett skäl.

Formatering är spegelbilden: !r och :.2f är inte en del av msgid:n, så att skärpa {amount:,.2f} till {amount:,.0f} ändrar ingenting i någon katalog. Att omformulera meningen är förstås en verklig ändring — det är cykeln ovan.

Vad CI ska grinda

Tre fel är värda ett rött bygge: katalogerna halkade efter koden, en översättning bröt en platshållare, eller en trasig post slank igenom till körmiljön. Ett steg per fel:

- 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 skriver ingenting och avslutar med icke-noll när en katalog är inaktuell gentemot den nyextraherade mallen — vakten mot att sammanfoga kod vars meddelanden ingen extraherade om. pybabel compile kör platshållarkontrollerna från både Babel och detta pakets registrerade kontroll.

Babel 2.18.0: --check kan inte grinda en katalog som använder kontexter

På Babel 2.18.0 rapporterar pybabel update --check varje katalog som innehåller ett msgctxt som inaktuell, vid varje körning, hur aktuell den än är. En grind som alltid fallerar är sämre än ingen grind alls, eftersom ett team stänger av den — så om du över huvud taget använder pgettext eller npgettext, byt ut det här steget i stället för att leva med det. Att läsa mallen och varje katalog med babel.messages.pofile.read_po och jämföra {(m.context, m.id) for m in catalog if m.id} är hela kontrollen, och det är vad den här webbplatsens eget bygge gör. Orsaken är beskriven på Fallgropar.

Kontrollera avslutsstatusen, inte loggen

pybabel compile rapporterar varje platshållarfel, avslutar med icke-noll — och skriver .mo-filen ändå. En pipeline som kompilerar och sedan kopierar locales/ in i en avbild levererar den trasiga katalogen om inte den icke-noll-statusen faktiskt stoppar den. Att låta steget fälla bygget, som ovan, är hela lösningen.

Sista raden är din vanliga testsvit, med en vana tillagd: någonstans i den, rendera minst ett meddelande per levererat språk genom en strikt översättare —

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

— eftersom strict=True kastar där produktion tyst skulle falla tillbaka, och en rendering vid körning är den enda kontroll som ser katalogen exakt som applikationen kommer att göra, .mo och allt.

Arbeta med översättare och plattformar

.po-filen är utbytesformatet för hela gettext-världen, vilket är skälet till att det här biblioteket återanvänder den: att lämna över översättning innebär att lämna över en fil, oavsett om mottagaren är en kollega med en PO-redigerare eller en plattform som Weblate eller Crowdin. Tre saker får överlämningen att fungera väl:

Säg vad meddelandet är till för. En kommentar i koden reser med meddelandet — det är vad flaggan -c "Translators:" samlar in:

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

En översättare ser den kommentaren i sin redigerare, bredvid meddelandet, på andra sidan jorden. Det är den billigaste kvalitetsspaken i hela arbetsflödet. För ett ord som är sin egen homonym — "Open" som knapp mot "Open" som tillstånd — ge meddelandet en kontext med pgettext, som blir en synlig msgctxt i katalogen.

Låt plattformen validera platshållare. Varje meddelande som extraheras ur en t-string bär flaggan python-brace-format, och den enda raden är vad som slår på platshållar-QA i verktyg du inte kontrollerar — Weblate dokumenterar kontrollen, kommersiella plattformar nycklar sina egna på samma flagga, och msgfmt --check-format upprätthåller den i varje GNU-pipeline. Detaljerna, och vad den medföljande kontrollen fångar utöver dem, finns på extraheringssidan.

Lita på skyddsnätet exakt så långt det räcker. Vad som än kommer tillbaka från en plattform är fortfarande data på väg in i ditt bygge; CI-grindarna ovan är vad som förvandlar "plattformen kontrollerade nog det här" till "det här kan inte levereras trasigt".

Binda ett språk vid körning

Allt hittills producerar kataloger. Det återstående beslutet är var applikationen väljer en. Bind en gång per språkets giltighetsomfång — processen för ett CLI, förfrågan för en webbtjänst.

Ett kommandoradsverktyg eller en skrivbordsapplikation läser användarens miljö en gång, vid uppstart. Att inte skicka något languages= låter standardbiblioteket förhandla utifrån LANGUAGE, LC_ALL, LC_MESSAGES och LANG; fallback=True returnerar en nollkatalog — källtext — i stället för att kasta undantag när ingen av dem matchar en katalog du levererar.

import gettext

from gettext_tstrings import Translator

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

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

En webbapplikation bestämmer per förfrågan. Läs in varje katalog en gång vid import, bind sedan den framförhandlade till kontexten innan vyn körs — set_translations är kontextlokal, så samtidiga förfrågningar på olika språk ser aldrig varandras bindning.

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

Under asynkrona ramverk — FastAPI, Starlette och allt annat ASGI — omslut förfrågan med use_translations: bindningen bor i en ContextVar, som asynkrona uppgiftsväxlingar bevarar per förfrågan.

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 står för din Accept-Language-tolkning — de flesta ramverk eller deras ekosystem tillhandahåller en; det som spelar roll här är bindningen runt call_next.

Två körningsvanor fullbordar bilden. Strängar som skapas vid importtillfället — en formuläretikett, ett enums visningsnamn — får inte fånga vilket språk som råkade vara aktivt under importen; definiera dem med lazy_gettext så renderar de på det språk som är aktivt vid användning. Och dirigera loggern gettext_tstrings dit en människa tittar: dess varningar är det överseende läget som rapporterar en översättning som slunkit förbi varje grind, en rad per trasigt meddelande snarare än en per rendering.

Leverans

Produktion behöver paketet, .mo-filerna och ingenting annat. Babel är ett utvecklings- och CI-beroende — håll gettext-tstrings[babel] utanför produktionsavbilden och installera det rena paketet där; rendering körs på enbart standardbiblioteket. Kompilera kataloger i samma bygge som producerar artefakten du driftsätter, så att .mo-filerna inuti den är exakt de granskade .po-filerna, och ingenting som kompilerats på någons laptop någonsin levereras.

Hur de färdas beror på vad du driftsätter. Ett wheel bär dem som paketdata, vilket betyder att katalogerna måste ligga inuti paketkatalogen — src/myapp/locales/, inte en locales/ på toppnivå — och byggbackenden måste få veta att den ska inkludera filer som .gitignore normalt döljer:

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

Läs tillbaka dem genom paketet snarare än genom en sökväg relativ till källträdet, som slutar existera i samma stund som wheelet installeras:

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

En containeravbild har det lättare: kompilera under byggsteget och kopiera resultatet, och lämna Babel kvar i det steget.

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

Före en release, checklistan den här sidan kokar ner till:

  • pybabel update --check passerar — inget meddelande ändrades utan att katalogerna fick höra om det.
  • pybabel compile grindar bygget på sin avslutsstatus.
  • Kvarvarande fuzzy-poster är avsiktliga — var och en renderas som källtext tills en översättare bekräftar den.
  • Testsviten renderar varje levererat språk en gång med strict=True.
  • Produktionsartefakten innehåller .mo-filer och ingen Babel.
  • Loggern gettext_tstrings är dirigerad till övervakning.

Vart härnäst

  • Extrahering — referensen för verktygshalvan av den här sidan: mappningsalternativ, egna funktionsnamn, strikt läge och varje kontroll.
  • Guide — körningshalvan: pluralformer, kontexter, uppskjutna strängar och fellägena i detalj.
  • Så fungerar det — varför msgid:n ser ut som den gör, och vad valideringen faktiskt kontrollerar.