Skoči na vsebino

V produkciji

Vadnica zanko požene enkrat, sami in na programu z enim samim sporočilom. V resničnem projektu se zanka vrti naprej: sporočila se spremenijo, potem ko so že prevedena, prevajalec dela drugje in po svojem urniku, kompiliran katalog pa gre z vsako izdajo v svet. Ta stran je ta praksa — kaj ostane v repozitoriju, kaj potuje, kaj mora zapirati CI in kje se med izvajanjem veže jezik.

Vse skupaj se sešteje v šest preverjanj, zato so tu najprej; vsak spodnji razdelek eno od njih nastavi.

  • pybabel update --check uspe — nobeno sporočilo se ni spremenilo, ne da bi za to izvedeli katalogi.
  • pybabel compile gradnjo zapira po svojem izhodnem stanju.
  • Preostali vnosi fuzzy so namerni — vsak se izriše kot izvorno besedilo, dokler ga prevajalec ne potrdi.
  • Testna zbirka vsak odpremljeni jezik enkrat izriše s strict=True.
  • Produkcijski izdelek vsebuje datoteke .mo in nobenega Babela.
  • Dnevnik gettext_tstrings je usmerjen v nadzorni sistem.

Oblika projekta

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

V repozitorij dodajte babel.cfg, predlogo .pot in vsak .po — to so viri prevajalske gradnje in njihove razlike so način, kako pregledujete spremembe prevodov. Kompilirane datoteke .mo so gradbeni izdelki: proizvajajte jih v CI ali ob pakiranju, namesto da bi jih dodajali v repozitorij, tako da si .po in njegov .mo nikoli ne moreta biti neenotna o tem, kaj se odpremi.

Ena datoteka ima vlogo v vsako smer: .pot nosi vaša sporočila ven k prevajalcem, datoteke .po pa prevode nazaj. Preostanek te strani je to, kar se giblje med njima.

flowchart LR
  code["izvorna koda<br>klicna mesta s t-nizi"] -->|"pybabel extract"| pot["messages.pot"]
  pot -->|"pybabel update"| po["en .po na jezik"]
  po --> tr["prevajalec<br>ali platforma"]
  tr --> po
  po -->|"pybabel compile (CI)"| mo["datoteke .mo"]
  mo --> app["aplikacija<br>med izvajanjem"]

Cikel po prvem prevodu

pybabel init iz vadnice se navadno požene enkrat, ko se doda jezik. Od tam naprej je delovni cikel izvleci → posodobi → prevedi → kompiliraj, njegovo središče pa je pybabel update, ki svežo predlogo zloži v obstoječe kataloge, ne da bi zavrgel prevode, ki so že v njih.

Recimo, da je pozdrav Hello {name} — že preveden kot こんにちは {name} — v kodi preoblikovan v Welcome back, {name}. Izvlecite in posodobite:

$ 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

Japonski katalog zdaj vsebuje:

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

Babel je opazil, da je novi msgid podoben odstranjenemu, in ga je združil s starim prevodom — a je par označil kot fuzzy: strojna domneva, ki čaka na človeka. Zastavica spremeni to, kaj se kompilira. pybabel compile ohlapne vnose iz .mo izpusti, zato aplikacija, dokler prevajalec para ne potrdi, izriše novo angleško besedilo namesto zastarelega japonskega:

$ 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

Spremenjeno sporočilo se torej poslabša enako kot pokvarjeno — do izvornega jezika, nikoli do zastarelega prevoda. Prevajalčev delež v ciklu je, da msgstr popravi in zastavico fuzzy izbriše; naslednja kompilacija vnos pobere.

Imena ograd so del identitete sporočila

Msgid je katalogni ključ in ime ograde je v njem — zato preimenovanje spremenljivke v kodi (nameuser_name) spremeni msgid in pošlje njegov prevod v vsakem jeziku nazaj skozi ohlapni cikel. Interpolirane spremenljivke poimenujte z besedami, ki jih bo prevajalec razumel, in jih preimenujte samo z razlogom.

Oblikovanje je zrcalna slika: !r in :.2f nista del msgida, zato zaostritev {amount:,.2f} v {amount:,.0f} v nobenem katalogu ne spremeni ničesar. Preoblikovanje povedi pa je seveda resnična sprememba — in to je zgornji cikel.

Kaj zapira CI

Tri odpovedi so vredne rdeče gradnje: katalogi so zaostali za kodo, prevod je pokvaril ogrado ali pa se je pokvarjen vnos prebil vse do izvajanja. Po en korak na odpoved:

- 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 ničesar ne prepiše in se konča z neničelnim stanjem, kadar je katalog zastarel glede na sveže izvlečeno predlogo — to je zaščita pred združevanjem kode, katere sporočil ni nihče znova izvlekel. pybabel compile požene preverjanja ograd tako Babela kot registriranega preverjevalnika tega paketa.

Babel 2.18.0: --check ne more zapreti kataloga, ki uporablja kontekste

Na Babelu 2.18.0 pybabel update --check vsak katalog, ki vsebuje msgctxt, javi kot zastarel, ob vsakem teku, naj bo še tako svež. Trajno padajoča zaščita je slabša od nobene, ker jo ekipa izklopi — če torej pgettext ali npgettext sploh uporabljate, ta korak raje nadomestite, kot da živite z njim. Branje predloge in vsakega kataloga s babel.messages.pofile.read_po ter primerjava {(m.context, m.id) for m in catalog if m.id} je celotno preverjanje — in prav to počne gradnja tega spletišča. Vzrok je popisan med Pastmi.

Preverjajte izhodno stanje, ne dnevnika

pybabel compile javi vsako napako pri ogradah, konča se z neničelnim stanjem — .mo pa vseeno zapiše. Cevovod, ki kompilira in nato locales/ prekopira v sliko, odpremi pokvarjen katalog, razen če ga neničelno stanje res ustavi. Da korak podre gradnjo, kot zgoraj, je celoten popravek.

Zadnja vrstica je vaša običajna testna zbirka z eno dodano navado: nekje v njej izrišite vsaj eno sporočilo na odpremljeni jezik skozi strog prevajalnik —

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

— ker strict=True sproži izjemo tam, kjer bi se produkcija tiho vrnila na izvirnik, izris med izvajanjem pa je edino preverjanje, ki katalog vidi natanko tako, kot ga bo videla aplikacija, z .mo in vsem.

Delo s prevajalci in platformami

Datoteka .po je izmenjevalni format celotnega sveta gettexta in prav zato jo ta knjižnica ponovno uporablja: predati prevajanje pomeni predati datoteko, naj je prejemnik sodelavec z urejevalnikom PO ali platforma, kakršni sta Weblate in Crowdin. Predajo naredijo dobro tri stvari:

Povejte, čemu je sporočilo namenjeno. Komentar v kodi potuje s sporočilom — prav to zbira zastavica -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 ""

Prevajalec ta komentar vidi v svojem urejevalniku, ob sporočilu, na drugem koncu sveta. To je najcenejši vzvod kakovosti v celotnem delovnem procesu. Pri besedi, ki je sama sebi homonim — »Odpri« kot gumb proti »Odprto« kot stanje —, sporočilu dodajte kontekst s pgettext, ki v katalogu postane viden msgctxt.

Pustite platformi, da preveri ograde. Vsako sporočilo, izvlečeno iz t-niza, nosi zastavico python-brace-format in prav ta ena vrstica vklopi nadzor kakovosti ograd v orodjih, ki jih ne nadzorujete — Weblate to preverjanje dokumentira, komercialne platforme svojega vežejo na isto zastavico, msgfmt --check-format pa ga uveljavlja v vsakem cevovodu GNU. Podrobnosti in kaj priloženi preverjevalnik ujame vrh tega, so na strani o ekstrakciji.

Varnostni mreži zaupajte natanko toliko, kolikor sega. Kar koli se vrne s platforme, so še vedno podatki, ki vstopajo v vašo gradnjo; zgornje zaščite v CI so tisto, kar »platforma je to najbrž preverila« spremeni v »to se ne more odpremiti pokvarjeno«.

Vezava jezika med izvajanjem

Vse doslej proizvaja kataloge. Preostala odločitev je, kje aplikacija enega izbere. Vežite enkrat na obseg enega jezika — na proces pri orodju ukazne vrstice, na zahtevo pri spletni storitvi.

Orodje ukazne vrstice ali namizna aplikacija okolje uporabnika prebere enkrat, ob zagonu. Če languages= ne podate, se standardna knjižnica pogaja iz LANGUAGE, LC_ALL, LC_MESSAGES in LANG; fallback=True vrne ničelni katalog — izvorno besedilo —, namesto da bi sprožil izjemo, kadar se noben od njih ne ujema s katalogom, ki ga odpremljate.

import gettext

from gettext_tstrings import Translator

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

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

Spletna aplikacija se odloči za vsako zahtevo posebej. Vsak katalog naložite enkrat ob uvozu, nato pa izpogajanega vežite na kontekst, preden steče pogled — set_translations je krajevno vezan na kontekst, zato sočasne zahteve v različnih jezikih nikoli ne vidijo vezav druga druge.

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

Pod asinhronimi ogrodji — FastAPI, Starlette in kar koli drugega z ASGI — zahtevo ovijte v use_translations: vezava živi v ContextVar, ki ga preklapljanje asinhronih opravil ohrani za vsako zahtevo posebej.

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 predstavlja vaše razčlenjevanje glave Accept-Language — večina ogrodij ali njihovih ekosistemov ga ponuja; tukaj je pomembna vezava okoli call_next.

Sliko dopolnita dve navadi med izvajanjem. Nizi, ustvarjeni ob uvozu — oznaka obrazca, prikazno ime naštevnega tipa —, ne smejo ujeti jezika, ki je bil dejaven med uvozom; določite jih z lazy_gettext in izrisali se bodo v jeziku, dejavnem ob rabi. In dnevnik gettext_tstrings usmerite tja, kamor kdo gleda: njegova opozorila so prizanesljivi način, ki javlja prevod, izmuznjen vsem zaščitam, po eno vrstico na pokvarjeno sporočilo namesto po eno na izris.

Odprema

Produkcija potrebuje paket, datoteke .mo in nič drugega. Babel je odvisnost razvoja in CI — gettext-tstrings[babel] pustite zunaj produkcijske slike in tam namestite goli paket; izris teče izključno na standardni knjižnici. Kataloge kompilirajte v isti gradnji, ki proizvede izdelek, ki ga razmestite, tako da so datoteke .mo v njem natanko pregledane datoteke .po in da se ne odpremi nič, kar je bilo kompilirano na nekem prenosniku.

Kako potujejo, je odvisno od tega, kaj razmeščate. Datoteka wheel jih nosi kot podatke paketa, kar pomeni, da morajo katalogi živeti znotraj imenika paketa — src/myapp/locales/, ne v vrhnjem locales/ — gradbenemu zaledju pa je treba povedati, naj vključi datoteke, ki jih .gitignore sicer skriva:

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

Berite jih nazaj skozi paket in ne po poti, ki je relativna na izvorno drevo: ta neha obstajati v trenutku, ko je wheel nameščen:

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

Slika vsebnika ima lažje delo: kompilirajte med gradbeno stopnjo in prekopirajte rezultat, Babel pa pustite v tisti stopnji.

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

Pred izdajo je kontrolni seznam, na katerega se ta stran skrči:

  • pybabel update --check uspe — nobeno sporočilo se ni spremenilo, ne da bi za to izvedeli katalogi.
  • pybabel compile gradnjo zapira po svojem izhodnem stanju.
  • Preostali vnosi fuzzy so namerni — vsak se izriše kot izvorno besedilo, dokler ga prevajalec ne potrdi.
  • Testna zbirka vsak odpremljeni jezik enkrat izriše s strict=True.
  • Produkcijski izdelek vsebuje datoteke .mo in nobenega Babela.
  • Dnevnik gettext_tstrings je usmerjen v nadzorni sistem.

Kam naprej

  • Ekstrakcija — referenca za orodno polovico te strani: možnosti preslikav, lastna imena funkcij, strogi način in vsak preverjevalnik.
  • Vodnik — izvajalna polovica: množina, konteksti, odloženi nizi in načini odpovedi do podrobnosti.
  • Kako deluje — zakaj je msgid videti tako, kot je videti, in kaj preverjanje v resnici preveri.