Kihagyás

Éles üzemben

Az oktatóanyag egyszer futtatja végig a ciklust, egyedül, egy egyetlen üzenetből álló programon. Valódi projektben a ciklus tovább forog: az üzenetek megváltoznak, miután lefordították őket, a fordító máshol és saját ütemben dolgozik, és minden kiadással szállítunk egy lefordított katalógust. Ez az oldal maga a gyakorlat — mi marad a tárolóban, mi utazik, mit kell a CI-nek kapuznia, és hol köti be a nyelvet a futásidő.

Mindez hat ellenőrzésre fut ki, ezért álljanak itt legelöl; az alábbi szakaszok mindegyike egyet-egyet állít be közülük.

  • A pybabel update --check átmegy — nem változott üzenet anélkül, hogy a katalógusok értesültek volna róla.
  • A pybabel compile a kilépési státuszával kapuzza a buildet.
  • A megmaradt fuzzy bejegyzések szándékosak — mindegyik forrásszövegként jelenik meg, amíg egy fordító meg nem erősíti.
  • A tesztkészlet minden kiszállított nyelvet lerenderel egyszer, strict=True mellett.
  • Az éles artefaktum .mo fájlokat tartalmaz, Babelt nem.
  • A gettext_tstrings naplózója a monitorozásba van vezetve.

Egy projekt alakja

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

Verziókövesd a babel.cfg fájlt, a .pot sablont és minden .po fájlt — ezek a fordítási build forrásai, és a diffjeik révén nézed át a fordítási változásokat. A lefordított .mo fájlok build-artefaktumok: a verziókövetésbe tétel helyett CI-ben vagy csomagoláskor állítsd elő őket, hogy egy .po és a hozzá tartozó .mo soha ne mondhasson mást arról, mi kerül kiszállításra.

Egy-egy fájlnak mindkét irányban szerepe van: a .pot viszi ki az üzeneteidet a fordítókhoz, a .po fájlok hozzák vissza a fordításokat. Az oldal további része az, ami e kettő között mozog.

flowchart LR
  code["forráskód<br>t-string hívási helyek"] -->|"pybabel extract"| pot["messages.pot"]
  pot -->|"pybabel update"| po["nyelvenként egy .po"]
  po --> tr["fordító<br>vagy platform"]
  tr --> po
  po -->|"pybabel compile (CI)"| mo[".mo fájlok"]
  mo --> app["alkalmazás<br>futásidőben"]

A kör az első fordítás után

Az oktatóanyag pybabel init parancsa rendszerint egyszer fut le, amikor egy nyelvet hozzáadunk. Ettől kezdve a munkakör: kinyerés → frissítés → fordítás → bináris fordítás, és ennek középpontja a pybabel update, amely úgy gyúrja bele a friss sablont a meglévő katalógusokba, hogy közben nem dobja el a bennük már meglévő fordításokat.

Tegyük fel, hogy a Hello {name} köszönést — amelynek fordítása már こんにちは {name} — a kódban Welcome back, {name} alakra fogalmazzuk át. Nyerd ki és frissíts:

$ 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

A japán katalógus most ezt tartalmazza:

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

A Babel észrevette, hogy az új msgid hasonlít egy eltávolítottra, és párosította a régi fordítással — de fuzzy jelzővel látta el a párost: egy gép tippje, amely emberre vár. A jelző megváltoztatja, mi fordul le. A pybabel compile kihagyja a fuzzy bejegyzéseket a .mo fájlból, így amíg egy fordító meg nem erősíti a párosítást, az alkalmazás az új angol szöveget jeleníti meg egy elavult japán helyett:

$ 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

Egy megváltozott üzenet tehát ugyanúgy degradálódik, ahogy egy elromlott — a forrásnyelvre, sosem egy elavult fordításra. A fordító feladata a körben az, hogy átdolgozza a msgstr értékét, és törölje a fuzzy jelzőt; a következő bináris fordítás már felveszi a bejegyzést.

A helyőrzők neve az üzenet azonosságának része

A msgid a katalógus kulcsa, és a helyőrző neve benne van — így egy változó átnevezése a kódban (nameuser_name) megváltoztatja a msgidet, és minden nyelv fordítását visszaküldi a fuzzy körbe. Az interpolált változókat olyan szavakkal nevezd el, amelyeket egy fordító is megért, és csak indokolt esetben nevezd át őket.

A formázás ennek a tükörképe: a !r és a :.2f nem része a msgidnek, így a {amount:,.2f} szigorítása {amount:,.0f} alakra egyetlen katalógusban sem változtat semmit. Magának a mondatnak az átfogalmazása persze valódi változás — az a fenti kör.

Mit kapuz a CI

Három hiba érdemli meg a piros buildet: a katalógusok lemaradtak a kód mögött, egy fordítás elrontott egy helyőrzőt, vagy egy hibás bejegyzés átcsúszott a futásidőig. Hibánként egy lépés:

- 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

A pybabel update --check semmit nem ír át, és nem nulla státusszal lép ki, ha egy katalógus elavult a frissen kinyert sablonhoz képest — ez az őre annak, hogy ne kerüljön be olyan kód, amelynek üzeneteit senki nem nyerte ki újra. A pybabel compile futtatja mind a Babel, mind ennek a csomagnak a helyőrző-ellenőrzéseit (regisztrált ellenőrző).

Babel 2.18.0: a --check nem tud kapuzni kontextust használó katalógust

A Babel 2.18.0 verzióján a pybabel update --check minden olyan katalógust elavultként jelent, amely msgctxt bejegyzést tartalmaz — minden futáskor, bármilyen naprakész is. A tartósan bukó kapu rosszabb, mint a kapu hiánya, mert a csapat kikapcsolja — tehát ha egyáltalán használsz pgettext-et vagy npgettext-et, inkább cseréld le ezt a lépést, mint hogy együtt élj vele. A sablon és minden katalógus beolvasása a babel.messages.pofile.read_po függvénnyel, majd a {(m.context, m.id) for m in catalog if m.id} halmazok összevetése — ennyi az egész ellenőrzés, és pontosan ezt teszi ennek a webhelynek a saját buildje. Az okot a Buktatók oldal írja le.

A kilépési státuszt nézd, ne a naplót

A pybabel compile jelent minden helyőrzőhibát, nem nulla státusszal lép ki — és mégis megírja a .mo fájlt. Az a folyamat, amely lefordít, majd bemásolja a locales/ könyvtárat egy image-be, kiszállítja a hibás katalógust, hacsak a nem nulla kilépés ténylegesen meg nem állítja. A teljes javítás annyi, hogy hagyod a lépést elbuktatni a buildet, mint fentebb.

Az utolsó sor a szokásos tesztkészleted, egyetlen szokással kiegészítve: valahol benne rendereld le legalább egy üzenetet minden kiszállított nyelven egy szigorú fordítón keresztül —

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

— mert a strict=True ott vált ki kivételt, ahol az éles üzem némán visszaesne, és egy futásidejű renderelés az egyetlen ellenőrzés, amely pontosan úgy látja a katalógust, ahogy az alkalmazás fogja, .mo-stul, mindenestül.

Munka fordítókkal és platformokkal

A .po fájl az egész gettext-világ csereformátuma, és ezért használja újra ez a könyvtár: a fordítás átadása azt jelenti, hogy átadsz egy fájlt — akár egy PO-szerkesztőt használó kollégának, akár egy platformnak, például a Weblate-nek vagy a Crowdinnak. Három dolog teszi jóvá ezt az átadást:

Mondd meg, mire való az üzenet. Egy kódbeli megjegyzés együtt utazik az üzenettel — ezt gyűjti be a -c "Translators:" kapcsoló:

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

A fordító ezt a megjegyzést látja a szerkesztőjében, az üzenet mellett, a világ másik felén. Ez a legolcsóbb minőségi emelő az egész munkafolyamatban. Az olyan szónál, amely önmaga homonimája — az „Open” mint gomb szemben az „Open” mint állapot —, adj az üzenetnek kontextust a pgettext segítségével, amelyből látható msgctxt lesz a katalógusban.

Hagyd, hogy a platform validálja a helyőrzőket. Minden t-stringből kinyert üzenet a python-brace-format jelzőt viseli, és épp ez az egyetlen sor kapcsolja be a helyőrző-QA-t azokban az eszközökben, amelyeket nem te irányítasz — a Weblate dokumentálja az ellenőrzést, a kereskedelmi platformok ugyanerre a jelzőre kötik a sajátjukat, és a msgfmt --check-format minden GNU-folyamatban kikényszeríti. A részletek, és hogy a mellékelt ellenőrző mit kap el ezeken túl, a kinyerési oldalon találhatók.

Bízz a biztonsági hálóban pontosan addig, ameddig ér. Bármi jön is vissza egy platformról, az továbbra is a buildedbe belépő adat; a fenti CI-kapuk azok, amelyek a „a platform valószínűleg ellenőrizte ezt” mondatot azzá alakítják, hogy „ez nem szállítható ki hibásan”.

Nyelv bekötése futásidőben

Minden eddigi katalógusokat állít elő. A hátralévő döntés az, hogy hol választ közülük az alkalmazás. Köss be egyszer egy nyelv hatóköre szerint — CLI-nél a folyamat, webszolgáltatásnál a kérés szintjén.

Egy parancssori eszköz vagy asztali alkalmazás egyszer, induláskor olvassa be a felhasználó környezetét. Ha nem adsz meg languages= paramétert, a standard könyvtár a LANGUAGE, az LC_ALL, az LC_MESSAGES és a LANG alapján egyezkedik; a fallback=True null katalógust — vagyis forrásszöveget — ad vissza kivétel helyett, ha egyik sem illeszkedik az általad szállított katalógusokra.

import gettext

from gettext_tstrings import Translator

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

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

Egy webalkalmazás kérésenként dönt. Töltsd be minden katalógust egyszer, importáláskor, majd köss be a kiegyezett katalógust a kontextusba, mielőtt a nézet lefut — a set_translations kontextuslokális, így a különböző nyelvű párhuzamos kérések soha nem látják egymás kötését.

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

Aszinkron keretrendszerek alatt — FastAPI, Starlette és minden más ASGI-alapú — csomagold a kérést use_translations blokkba: a kötés egy ContextVar változóban él, amelyet az aszinkron feladatváltás kérésenként megőriz.

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)

A negotiate_language az Accept-Language feldolgozásodat képviseli — a legtöbb keretrendszer vagy annak ökoszisztémája ad ilyet; itt a call_next köré tett kötés a lényeg.

Két futásidejű szokás teszi teljessé a képet. Az importáláskor létrehozott szövegek — egy űrlapfelirat, egy enum megjelenített neve — nem kaphatják el azt a nyelvet, amely az importálás alatt épp aktív volt; definiáld őket a lazy_gettext függvénnyel, és a használatkor aktív nyelven fognak megjelenni. Emellett irányítsd a gettext_tstrings naplózóját oda, ahová ember is néz: a figyelmeztetései az elnéző mód jelentései olyan fordításról, amely átcsúszott minden kapun — elromlott üzenetenként egy sor, nem renderelésenként egy.

Kiszállítás

Az éles üzemnek a csomagra, a .mo fájlokra és semmi másra van szüksége. A Babel fejlesztési és CI-függőség — a gettext-tstrings[babel] maradjon ki az éles image-ből, oda a csupasz csomagot telepítsd; a renderelés a standard könyvtáron egymagán fut. A katalógusokat ugyanabban a buildben fordítsd binárisra, amely a telepítendő artefaktumot előállítja, hogy a benne lévő .mo fájlok pontosan az átnézett .po fájlok legyenek, és soha ne kerüljön ki semmi, amit valakinek a laptopján fordítottak le.

Az, hogy hogyan utaznak, attól függ, mit telepítesz. Egy wheel csomagadatként viszi őket, ami azt jelenti, hogy a katalógusoknak a csomagkönyvtáron belül kell lenniük — src/myapp/locales/, nem pedig legfelső szintű locales/ —, a buildháttérnek pedig meg kell mondani, hogy olyan fájlokat is vegyen bele, amelyeket a .gitignore rendesen elrejt:

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

A visszaolvasás a csomagon keresztül történjen, ne a forrásfához képest relatív útvonalon, amely abban a pillanatban megszűnik létezni, ahogy a wheelt telepítik:

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

A konténerimage-nek könnyebb dolga van: fordítsd binárisra a build szakaszban, majd másold át az eredményt, a Babelt pedig hagyd abban a szakaszban.

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

Kiadás előtt az az ellenőrzőlista, amellyé ez az oldal összesűrűsödik:

  • A pybabel update --check átmegy — nincs üzenetváltozás, amelyről a katalógusok ne értesültek volna.
  • A pybabel compile a kilépési státusza alapján kapuzza a buildet.
  • A megmaradt fuzzy bejegyzések szándékosak — mindegyik forrásszövegként jelenik meg, amíg egy fordító meg nem erősíti.
  • A tesztkészlet minden kiszállított nyelvet lerenderel egyszer, strict=True mellett.
  • Az éles artefaktum .mo fájlokat tartalmaz, Babelt nem.
  • A gettext_tstrings naplózója a monitorozásba van irányítva.

Merre tovább

  • Kinyerés — ennek az oldalnak az eszközoldali fele részletesen: leképezési beállítások, saját függvénynevek, szigorú mód és minden ellenőrző.
  • Kézikönyv — a futásidejű fél: többes számok, kontextusok, késleltetett szövegek és a hibamódok részletesen.
  • Hogyan működik — miért néz ki így a msgid, és mit ellenőriz valójában a validálás.