跳轉至

正式環境實務

教學把這個循環跑了一次,獨自一人,對象是一支只有一則訊息的程式。 在真實專案裡,這個循環會一直轉下去:訊息在被翻譯之後還會改動,譯者在別的地方、 按自己的節奏工作,而每一次發行都會帶著一份編譯好的目錄出貨。本頁講的就是那份實務—— 什麼留在儲存庫裡、什麼會往外流動、CI 必須把守什麼,以及執行階段在哪裡繫結語言。

這一切加起來就是六項檢查,所以先把它們擺在最前面;底下每一節各自把其中一項架設起來。

  • pybabel update --check 通過——沒有訊息改動而目錄卻毫不知情。
  • pybabel compile 以其離開狀態碼把守建置。
  • 剩下的 fuzzy 條目都是刻意留著的——在譯者確認之前,每一筆都渲染成原文。
  • 測試套件對每一種出貨語言都以 strict=True 渲染過一次。
  • 正式環境產物裡有 .mo 檔,而且沒有 Babel。
  • gettext_tstrings logger 已導向監控系統。

一個專案的形態

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

babel.cfg.pot 樣板,以及每一份 .po 都提交進版控——它們是翻譯建置的來源, 而它們的 diff 就是你審查翻譯異動的方式。編譯出來的 .mo 檔則是建置產物:請在 CI 或 打包時產生它們,而不要提交進版控,這樣一份 .po 和它的 .mo 就絕不可能對「到底出貨 了什麼」各說各話。

有一個檔案在兩個方向上各有其角色:.pot 把你的訊息帶出去給譯者,.po 檔則把翻譯 帶回來。本頁接下來講的,就是在這兩者之間流動的東西。

flowchart LR
  code["原始碼<br>t-string 呼叫點"] -->|"pybabel extract"| pot["messages.pot"]
  pot -->|"pybabel update"| po["每種語言一份 .po"]
  po --> tr["譯者<br>或平台"]
  tr --> po
  po -->|"pybabel compile (CI)"| mo[".mo 檔"]
  mo --> app["應用程式<br>執行階段"]

第一次翻譯之後的週期

教學裡的 pybabel init 通常只在新增一種語言時跑一次。從那之後,日常運轉的週期就是 擷取 → 更新 → 翻譯 → 編譯,而它的核心是 pybabel update:它把一份新的樣板併進 既有目錄,同時不丟掉裡面已經有的翻譯。

假設那句招呼語 Hello {name}——已經被翻成 こんにちは {name}——在程式碼裡被改寫成 Welcome back, {name}。擷取並更新:

$ 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

日文目錄現在含有:

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

Babel 注意到這個新的 msgid 和某個被移除的很像,就把它和舊的翻譯配成了一對——但把這 一對標上了 fuzzy:一台機器的猜測,等著人來確認。這個旗標會改變編譯出來的東西。 pybabel compile把 fuzzy 條目排除在 .mo 之外,所以在譯者確認這一對之前, 應用程式渲染的是新的英文文字,而不是過時的日文:

$ 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

因此一則被改動的訊息,降級的方式和一則壞掉的訊息完全相同——降到來源語言,絕不降到 一則過時的翻譯。譯者在這個週期裡的部分,就是修訂 msgstr 並刪掉 fuzzy 旗標; 下一次編譯就會把這筆條目收進去。

佔位符名稱是訊息身分的一部分

msgid 就是目錄的 key,而佔位符的名稱就在它裡面——所以在程式碼裡把一個變數改名 (nameuser_name)就會改變 msgid,並把每一種語言對它的翻譯都送回 fuzzy 週期一趟。請把被插值的變數命名成譯者看得懂的詞,並且只在有理由時才改名。

格式設定則是它的鏡像:!r:.2f 不是 msgid 的一部分, 所以把 {amount:,.2f} 收緊成 {amount:,.0f} 不會改動任何目錄裡的任何東西。 當然,改寫句子本身是一項真正的變更——那就是上面那個週期。

CI 把守什麼

有三種失敗值得讓建置變紅:目錄落後於程式碼、某則翻譯弄壞了佔位符,或一筆壞掉的條目 一路溜到了執行階段。一種失敗對應一個步驟:

- 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 什麼都不會改寫,而當某份目錄與剛擷取出來的樣板不同步時, 它會以非零狀態結束——這正是防止有人合併了訊息卻沒人重新擷取的那道防線。 pybabel compile 則會跑 Babel 與本套件 所註冊的檢查器兩邊的 佔位符檢查。

Babel 2.18.0:--check 沒辦法把守使用了上下文的目錄

在 Babel 2.18.0 上,pybabel update --check 會把每一份含有 msgctxt 的目錄 都報成過期,每次執行都如此,不管它其實多麼新。一道永遠失敗的關卡比沒有關卡還糟, 因為團隊會把它關掉——所以只要你有用到 pgettextnpgettext,就請換掉這個步驟, 而不要將就著用。用 babel.messages.pofile.read_po 讀進樣板與每一份目錄,再比較 {(m.context, m.id) for m in catalog if m.id},這就是整項檢查的全部,而 本站自己的建置做的正是這件事。成因 寫在常見陷阱頁

看離開狀態碼,不要看日誌

pybabel compile 會回報每一個佔位符錯誤、以非零狀態結束——然後還是照樣把 .mo 寫出來。一條先編譯、再把 locales/ 複製進映像檔的流水線,除非那個非零離開狀態 真的把它擋下來,否則就會把壞掉的目錄送出去。像上面那樣讓這個步驟弄垮建置,就是 全部的解法。

最後一行是你平常的測試套件,只多加一個習慣:在其中某處,用一個 strict 的 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}")

——因為 strict=True 會在正式環境會默默回退的地方拋出例外, 而一次執行階段的渲染,是唯一一項會以應用程式看到目錄的方式去看它的檢查,連 .mo 在內,一切照實。

與譯者及平台協作

.po 檔是整個 gettext 世界的交換格式,而這正是本函式庫沿用它的理由:把翻譯工作交出去 就等於交出一個檔案,不論收件人是拿著 PO 編輯器的同事,還是 Weblate 或 Crowdin 這樣的 平台。有三件事能讓這次交接運作良好:

說清楚這則訊息是做什麼用的。 程式碼裡的一則註解會跟著訊息一起走——那正是 -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 ""

譯者會在自己的編輯器裡、就在訊息旁邊、在地球的另一端看見那則註解。它是整條工作流程裡 最便宜的一根品質槓桿。對於一個本身就是同形異義詞的字——按鈕的「Open」對上狀態的 「Open」——請用 pgettext 給那則訊息一個上下文,它會 成為目錄裡看得見的 msgctxt

讓平台去驗證佔位符。 每一則從 t-string 擷取出來的訊息都帶著 python-brace-format 旗標,而正是那一行,打開了你管不到的那些工具裡的佔位符 QA——Weblate 記載了這項檢查、 商用平台以同一個旗標觸發自家的檢查,而 msgfmt --check-format 則在任何 GNU 流水線裡 執行它。細節,以及內附檢查器在這之外還抓到些什麼,都在擷取頁

信任這張安全網,但只信到它真正能及的地方。 從平台回來的東西,仍然是進入你建置流程 的資料;上面那些 CI 關卡,才是把「平台大概檢查過了」變成「這東西不可能壞著出貨」的 那件事。

在執行階段繫結語言

到此為止的一切產出的都是目錄。剩下的決定是應用程式在哪裡挑選其中一份。每一個語言的 作用範圍繫結一次——CLI 是整個行程,Web 服務則是每個請求。

命令列工具或桌面應用程式在啟動時讀一次使用者的環境。不傳 languages= 會讓標準 函式庫從 LANGUAGELC_ALLLC_MESSAGESLANG 去協商;而 fallback=True 會在其中沒有任何一個對應到你出貨的目錄時,回傳一份空目錄—— 也就是原文——而不是拋出例外。

import gettext

from gettext_tstrings import Translator

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

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

Web 應用程式是逐請求決定的。在 import 時把每一份目錄各載入一次,然後在 view 執行 之前,把協商出來的那一份繫結到上下文—— set_translations 是上下文區域性的,所以不同 語言的並行請求絕不會看見彼此的繫結。

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

在非同步 framework 之下——FastAPI、Starlette,以及其他任何 ASGI——請用 use_translations 把請求包起來:繫結存放在一個 ContextVar 裡,而非同步的任務切換會逐請求保住它。

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 代表的是你自己的 Accept-Language 剖析——大多數 framework 或它們的生態系都有現成的;這裡真正要緊的是圍繞 call_next 的那個繫結。

還有兩個執行階段的習慣能把整幅圖補完。在 import 時建立的字串——表單標籤、列舉的顯示 名稱——絕不能把 import 期間恰好生效的語言捕捉下來;請改用 lazy_gettext 定義它們,它們就會以被使用當下生效的 語言渲染。另外,請把 gettext_tstrings logger 導向有人會看的地方:它的警告就是寬鬆 模式在回報一則溜過了所有關卡的翻譯,而且是每則壞掉的訊息一行,不是每次渲染一行。

出貨

正式環境需要的是這個套件、那些 .mo 檔,別無其他。Babel 是開發與 CI 的相依套件—— 請把 gettext-tstrings[babel] 留在正式環境映像檔之外,在那裡只裝裸的套件;渲染只靠 標準函式庫就能跑。請在產生你所部署的那份產物的同一次建置裡編譯目錄,這樣裡面的 .mo 檔就恰好是被審查過的那些 .po 檔,而在誰的筆電上編譯出來的東西都不會出貨。

它們怎麼跟著走,取決於你部署的是什麼。wheel 是以套件資料的形式攜帶它們,這表示目錄 必須放在套件目錄裡面——是 src/myapp/locales/,不是頂層的 locales/——而且還得 告訴建置後端把那些平常被 .gitignore 藏起來的檔案一併收進去:

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

讀取時請透過套件本身,而不是透過相對於原始碼樹的路徑——wheel 一裝好,那條路徑就不 存在了:

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

容器映像檔的工作比較簡單:在建置階段編譯,然後把結果複製過去,把 Babel 留在那個階段 裡就好。

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

發行之前,本頁可以收斂成這份檢查清單:

  • pybabel update --check 通過——沒有訊息改動而目錄卻毫不知情。
  • pybabel compile 以其離開狀態碼把守建置。
  • 剩下的 fuzzy 條目都是刻意留著的——在譯者確認之前,每一筆都渲染成原文。
  • 測試套件對每一種出貨語言都以 strict=True 渲染過一次。
  • 正式環境產物裡有 .mo 檔,而且沒有 Babel。
  • gettext_tstrings logger 已導向監控系統。

接下來

  • 擷取 — 本頁工具那一半的參考:mapping 選項、自訂函式名稱、strict 模式,以及每一個檢查器。
  • 指南 — 執行階段那一半:複數、上下文、延遲字串,以及各種失敗模式的細節。
  • 運作原理 — msgid 為什麼長成那樣,以及驗證到底檢查了什麼。