W produkcji¶
Samouczek przechodzi pętlę raz, w pojedynkę, na programie z jednym komunikatem. W prawdziwym projekcie pętla kręci się dalej: komunikaty zmieniają się po tym, jak zostały przetłumaczone, tłumacz pracuje gdzie indziej i według własnego harmonogramu, a skompilowany katalog wychodzi z każdym wydaniem. Ta strona jest tą praktyką — co zostaje w repozytorium, co podróżuje, co musi bramkować CI i gdzie środowisko uruchomieniowe wiąże język.
Wszystko to sprowadza się do sześciu kontroli, więc najpierw one; każda sekcja poniżej ustawia jedną z nich.
pybabel update --checkprzechodzi — żaden komunikat nie zmienił się bez wiedzy katalogów.pybabel compilebramkuje build swoim statusem wyjścia.- Pozostałe wpisy
fuzzysą zamierzone — każdy z nich renderuje się jako tekst źródłowy, dopóki tłumacz go nie potwierdzi. - Zestaw testów renderuje każdy wysyłany język raz z
strict=True. - Artefakt produkcyjny zawiera pliki
.moi nie zawiera Babel. - Logger
gettext_tstringsjest podpięty do monitoringu.
Kształt projektu¶
myapp/
├── babel.cfg
├── pyproject.toml
├── src/
│ └── myapp/
└── locales/
├── messages.pot
├── ja/LC_MESSAGES/messages.po
└── de/LC_MESSAGES/messages.po
Commituj babel.cfg, szablon .pot i każdy .po — to źródła builda
tłumaczeń, a ich diffy są sposobem przeglądania zmian w tłumaczeniach.
Skompilowane pliki .mo to artefakty builda: wytwarzaj je w CI albo przy
pakowaniu, zamiast je commitować, żeby .po i jego .mo nigdy nie mogły
się różnić co do tego, co wychodzi.
Jeden plik ma rolę w każdą stronę: .pot niesie Twoje komunikaty do
tłumaczy, pliki .po niosą tłumaczenia z powrotem. Reszta tej strony to
to, co kursuje między nimi.
flowchart LR
code["kod źródłowy<br>wywołania t-stringów"] -->|"pybabel extract"| pot["messages.pot"]
pot -->|"pybabel update"| po["jeden .po na język"]
po --> tr["tłumacz<br>lub platforma"]
tr --> po
po -->|"pybabel compile (CI)"| mo["pliki .mo"]
mo --> app["aplikacja<br>w czasie działania"]
Cykl po pierwszym tłumaczeniu¶
Samouczkowy pybabel init uruchamia się zwykle raz, przy dodawaniu języka. Od
tej pory roboczy cykl to ekstrakcja → aktualizacja → tłumaczenie →
kompilacja, a jego środkiem jest pybabel update, który wtapia świeży
szablon w istniejące katalogi bez odrzucania tłumaczeń już w nich obecnych.
Załóżmy, że powitanie Hello {name} — przetłumaczone już jako
こんにちは {name} — zostaje w kodzie przeredagowane na
Welcome back, {name}. Wyodrębnij i zaktualizuj:
$ 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
Japoński katalog zawiera teraz:
#. gettext-tstrings
#: app.py:4
#, fuzzy, python-brace-format
msgid "Welcome back, {name}"
msgstr "こんにちは {name}"
Babel zauważył, że nowy msgid przypomina usunięty, i sparował go ze starym
tłumaczeniem — ale oznaczył parę jako fuzzy: maszynowe przypuszczenie
czekające na człowieka. Ta flaga zmienia to, co się kompiluje. pybabel
compile wyklucza wpisy fuzzy z .mo, więc dopóki tłumacz nie potwierdzi pary, aplikacja
renderuje nowy angielski tekst, a nie przeterminowany japoński:
$ 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
Zmieniony komunikat degraduje się więc tak samo jak uszkodzony — do języka
źródłowego, nigdy do nieaktualnego tłumaczenia. Rolą tłumacza w cyklu jest
poprawić msgstr i usunąć flagę fuzzy; następna kompilacja podejmie wpis.
Nazwy symboli zastępczych są częścią tożsamości komunikatu
Msgid jest kluczem katalogu, a nazwa symbolu zastępczego jest w jego
środku — więc zmiana nazwy zmiennej w kodzie (name → user_name)
zmienia msgid i wysyła tłumaczenia we wszystkich językach z powrotem
przez cykl fuzzy. Nazywaj interpolowane zmienne słowami, które tłumacz
zrozumie, i zmieniaj ich nazwy tylko z powodu.
Formatowanie jest lustrzanym odbiciem: !r i :.2f nie są częścią
msgid, więc zaostrzenie
{amount:,.2f} do {amount:,.0f} nie zmienia niczego w żadnym
katalogu. Przeredagowanie zdania to oczywiście prawdziwa zmiana — to
cykl powyżej.
Co bramkuje CI¶
Trzy niepowodzenia są warte czerwonego builda: katalogi zostały w tyle za kodem, tłumaczenie zepsuło symbol zastępczy albo uszkodzony wpis prześlizgnął się do środowiska uruchomieniowego. Jeden krok na niepowodzenie:
- 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 niczego nie przepisuje i wychodzi z niezerowym
kodem, gdy katalog jest nieaktualny względem świeżo wyodrębnionego szablonu
— to strażnik przed scaleniem kodu, którego komunikatów nikt ponownie nie
wyodrębnił. pybabel compile uruchamia kontrole symboli zastępczych
zarówno Babel, jak i
zarejestrowanego checkera
tego pakietu.
Babel 2.18.0: --check nie potrafi bramkować katalogu używającego kontekstów
W Babel 2.18.0 pybabel update --check raportuje każdy katalog
zawierający msgctxt jako nieaktualny, przy każdym uruchomieniu,
niezależnie od tego, jak bardzo jest aktualny. Bramka, która zawsze
zawodzi, jest gorsza niż jej brak, bo zespół ją wyłącza — więc jeśli w
ogóle używasz pgettext albo npgettext, zastąp ten krok, zamiast z nim
żyć. Wczytanie szablonu i każdego katalogu za pomocą
babel.messages.pofile.read_po oraz porównanie
{(m.context, m.id) for m in catalog if m.id} to cała kontrola — i
dokładnie to robi własny build tej strony. Przyczyna jest
opisana w Pułapkach.
Sprawdzaj status wyjścia, nie log
pybabel compile raportuje każdy błąd symboli zastępczych, wychodzi z
niezerowym kodem — i mimo to zapisuje .mo. Potok, który kompiluje,
a potem kopiuje locales/ do obrazu, wysyła uszkodzony katalog, chyba
że niezerowy kod wyjścia faktycznie go zatrzyma. Pozwolenie temu
krokowi oblać build, jak powyżej, jest całą poprawką.
Ostatnia linia to Twój zwykły zestaw testów, z jednym dodanym nawykiem: gdzieś w nim wyrenderuj co najmniej jeden komunikat na każdy wysyłany język przez ścisły 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}")
— bo strict=True zgłasza wyjątek tam, gdzie produkcja po cichu by się
wycofała, a renderowanie w
czasie działania to jedyna kontrola, która widzi katalog dokładnie tak, jak
zobaczy go aplikacja, razem z .mo.
Praca z tłumaczami i platformami¶
Plik .po jest formatem wymiany całego świata gettext i to jest powód, dla
którego ta biblioteka go używa: przekazanie tłumaczenia oznacza przekazanie
pliku, niezależnie od tego, czy odbiorcą jest współpracownik z edytorem PO,
czy platforma jak Weblate lub Crowdin. Trzy rzeczy sprawiają, że to
przekazanie działa dobrze:
Powiedz, do czego służy komunikat. Komentarz w kodzie podróżuje z
komunikatem — to właśnie zbiera flaga -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 ""
Tłumacz widzi ten komentarz w swoim edytorze, obok komunikatu, po drugiej
stronie świata. To najtańsza dźwignia jakości w całym przepływie pracy. Dla
słowa będącego swoim własnym homonimem — „Open" jako przycisk kontra
„Open" jako stan — nadaj komunikatowi kontekst
przez pgettext, który staje się widocznym msgctxt w katalogu.
Pozwól platformie walidować symbole zastępcze. Każdy komunikat
wyodrębniony z t-stringa niesie flagę python-brace-format i ta jedna
linia włącza QA symboli zastępczych w narzędziach, których nie
kontrolujesz — Weblate dokumentuje tę kontrolę, platformy komercyjne
opierają na tej samej fladze swoje własne, a msgfmt --check-format
wymusza ją w każdym potoku GNU. Szczegóły — i to, co dostarczony checker
wychwytuje ponad nie — są na
stronie ekstrakcji.
Ufaj siatce bezpieczeństwa dokładnie na tyle, na ile sięga. Cokolwiek wraca z platformy, wciąż jest danymi wchodzącymi do Twojego builda; bramki CI powyżej są tym, co zamienia „platforma to pewnie sprawdziła" w „to nie może wyjść zepsute".
Wiązanie języka w czasie działania¶
Wszystko dotąd produkuje katalogi. Pozostała decyzja to gdzie aplikacja jeden z nich wybiera. Zwiąż raz na zakres języka — proces dla CLI, żądanie dla usługi webowej.
Narzędzie wiersza poleceń lub aplikacja desktopowa czyta środowisko
użytkownika raz, przy starcie. Pominięcie languages= pozwala
bibliotece standardowej negocjować z LANGUAGE, LC_ALL,
LC_MESSAGES i LANG; fallback=True zwraca pusty katalog — tekst
źródłowy — zamiast zgłaszać wyjątek, gdy żadna z nich nie pasuje do
katalogu, który wysyłasz.
Aplikacja webowa decyduje per żądanie. Wczytaj każdy katalog raz przy
imporcie, a potem zwiąż wynegocjowany z kontekstem, zanim uruchomi się
widok — set_translations jest
lokalny dla kontekstu, więc współbieżne żądania w różnych językach
nigdy nie widzą nawzajem swoich wiązań.
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}")
W frameworkach asynchronicznych — FastAPI, Starlette i wszystkim innym
na ASGI — opakuj żądanie w
use_translations: wiązanie żyje w
ContextVar, który przełączanie zadań async zachowuje per żądanie.
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 reprezentuje Twoje parsowanie Accept-Language —
większość frameworków lub ich ekosystemów jakieś dostarcza; tym, co się
tu liczy, jest wiązanie wokół call_next.
Dwa nawyki czasu działania dopełniają obraz. Łańcuchy tworzone w czasie
importu — etykieta formularza, wyświetlana nazwa enuma — nie mogą
przechwycić języka, który akurat był aktywny podczas importu; definiuj je
przez lazy_gettext, a wyrenderują się w
języku aktywnym przy użyciu. I kieruj logger gettext_tstrings tam,
gdzie patrzy człowiek: jego ostrzeżenia to tryb łagodny raportujący
tłumaczenie, które prześlizgnęło się przez każdą bramkę — jedna linia na
uszkodzony komunikat, a nie jedna na renderowanie.
Wysyłka¶
Produkcja potrzebuje pakietu, plików .mo i niczego więcej. Babel jest
zależnością deweloperską i CI — trzymaj gettext-tstrings[babel] poza
obrazem produkcyjnym i instaluj tam goły pakiet; renderowanie działa na
samej bibliotece standardowej. Kompiluj katalogi w tym samym buildzie,
który produkuje wdrażany artefakt, żeby pliki .mo w jego środku były
dokładnie zrecenzowanymi plikami .po i żeby nic skompilowanego na czyimś
laptopie nigdy nie wyszło.
To, jak podróżują, zależy od tego, co wdrażasz. Wheel niesie je jako dane
pakietu, co oznacza, że katalogi muszą leżeć wewnątrz katalogu pakietu —
src/myapp/locales/, nie locales/ na najwyższym poziomie — a backendowi
budowania trzeba powiedzieć, żeby dołączył pliki, które .gitignore
normalnie ukrywa:
Odczytuj je z powrotem przez pakiet, a nie przez ścieżkę względną wobec drzewa źródeł, które przestaje istnieć w chwili instalacji wheela:
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"])
Obraz kontenera ma łatwiejsze zadanie: skompiluj podczas etapu budowania i skopiuj wynik, zostawiając Babel w tamtym etapie.
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
Przed wydaniem — lista kontrolna, do której sprowadza się ta strona:
pybabel update --checkprzechodzi — żaden komunikat nie zmienił się bez wiedzy katalogów.pybabel compilebramkuje build swoim statusem wyjścia.- Pozostałe wpisy
fuzzysą zamierzone — każdy renderuje się jako tekst źródłowy, dopóki tłumacz go nie potwierdzi. - Zestaw testów renderuje każdy wysyłany język raz ze
strict=True. - Artefakt produkcyjny zawiera pliki
.moi żadnego Babel. - Logger
gettext_tstringsjest podpięty do monitoringu.
Co dalej¶
- Ekstrakcja — dokumentacja narzędziowej połowy tej strony: opcje mapowań, własne nazwy funkcji, tryb ścisły i każdy checker.
- Przewodnik — połowa czasu działania: liczba mnoga, konteksty, odroczone łańcuchy i tryby awarii w szczegółach.
- Jak to działa — dlaczego msgid wygląda tak, jak wygląda, i co naprawdę sprawdza walidacja.