実運用¶
チュートリアルでは、メッセージが1つのプログラムを対象に、1人で ループを一度だけ回しました。実際のプロジェクトではループは回り続けます。 翻訳済みのメッセージが後から変更され、翻訳者は別の場所で自分のスケジュールで 作業し、コンパイル済みカタログはリリースごとに出荷されます。このページは その実践を説明します。リポジトリに残すもの、行き来するもの、CIが防ぐべきもの、 そしてランタイムが言語を束縛する場所です。
まとめると6つの検査になります。先にその一覧を挙げておきます。以下の各節が、 そのうちの1つずつを組み立てていきます。
pybabel update --checkが通る — カタログの知らないメッセージ変更はない。pybabel compileの終了statusでビルドをゲートしている。- 残っている
fuzzyエントリは意図的なもの — 各エントリは翻訳者が確認する までソーステキストで表示される。 - テストスイートが、出荷する各言語を
strict=Trueで一度レンダリングしている。 - 本番成果物には
.moファイルが含まれ、Babelは含まれない。 gettext_tstringsloggerがmonitoringへルーティングされている。
プロジェクトの形¶
myapp/
├── babel.cfg
├── pyproject.toml
├── src/
│ └── myapp/
└── locales/
├── messages.pot
├── ja/LC_MESSAGES/messages.po
└── de/LC_MESSAGES/messages.po
babel.cfg、.pot template、そしてすべての.poをcommitします。これらは
翻訳ビルドのソースであり、そのdiffが翻訳の変更をレビューする手段です。
コンパイル済みの.moファイルはビルド成果物です。commitせず、CIまたは
パッケージング時に生成してください。そうすれば、.poとその.moが出荷内容に
ついて食い違うことはあり得ません。
1つのファイル形式が、それぞれの方向で役割を持ちます。.potはあなたの
メッセージを翻訳者へ運び出し、.poファイルは翻訳を持ち帰ります。
このページの残りは、その2つの間を動くものについての説明です。
flowchart LR
code["ソースコード<br>t-string呼び出し箇所"] -->|"pybabel extract"| pot["messages.pot"]
pot -->|"pybabel update"| po["言語ごとに1つの.po"]
po --> tr["翻訳者<br>またはプラットフォーム"]
tr --> po
po -->|"pybabel compile (CI)"| mo[".moファイル"]
mo --> app["アプリケーション<br>(実行時)"]
最初の翻訳の後のサイクル¶
チュートリアルのpybabel initは通常、言語を追加するときに一度だけ実行します。
それ以降の作業サイクルは抽出 → 更新 → 翻訳 → コンパイルであり、その中心は
pybabel updateです。新しいtemplateを既存のカタログへ折り込みながら、すでに
入っている翻訳を破棄しません。
挨拶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が削除された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であり、プレースホルダーの名前はその内側にあります。
そのため、コード内の変数名の変更(name → user_name)はmsgidを変え、
そのメッセージのすべての言語の翻訳をfuzzyサイクルへ送り返します。補間する
変数には翻訳者が理解できる語を名前として付け、変更するのは理由がある
ときだけにしてください。
書式指定はその鏡像です。!rや:.2fはmsgidに含まれない
ため、{amount:,.2f}を{amount:,.0f}へ絞っても、どのカタログも
変わりません。もちろん、文そのものの書き換えは実際の変更です。それが
上で説明したサイクルです。
CIで防ぐこと¶
ビルドを赤にする価値のある失敗は3つです。カタログがコードから遅れた、翻訳が プレースホルダーを壊した、壊れたエントリがランタイムまですり抜けた。失敗ごとに 1ステップを置きます。
- 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は何も書き換えず、抽出したてのtemplateに対して
カタログが古い場合に非ゼロで終了します。誰もメッセージを再抽出していない
コードのmergeを防ぐガードです。pybabel compileは、Babelとこのパッケージの
登録済みchecker
の両方のプレースホルダー検査を実行します。
Babel 2.18.0:--checkはコンテキストを使うカタログのガードにならない
Babel 2.18.0では、pybabel update --checkはmsgctxtを含むカタログを
すべて、どれだけ最新であっても、実行のたびに古いと報告します。常に赤い
ゲートは、ゲートが無いより悪いのです。チームがそれを切ってしまうからです。
そのため、pgettextやnpgettextを少しでも使うなら、このステップは我慢して
使うのではなく置き換えてください。templateと各カタログを
babel.messages.pofile.read_poで読み、
{(m.context, m.id) for m in catalog if m.id}を比較する — これが検査の
すべてであり、このサイト自身のビルドがしていることです。原因は
落とし穴のページに詳しく書いてあります。
ログではなく終了statusを確認する
pybabel compileは各プレースホルダーエラーを報告し、非ゼロで終了し、
それでも.moを書き出します。コンパイル後にlocales/をイメージへ
コピーするpipelineは、非ゼロ終了が実際にそれを止めない限り、壊れた
カタログを出荷します。上のように、このステップでビルドを失敗させることが
修正のすべてです。
最後の行は普段のテストスイートですが、習慣を1つ加えます。どこかで、出荷する 言語ごとに少なくとも1つのメッセージを、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のような
プラットフォームでも変わりません。この引き渡しをうまく機能させるのは
3つのことです。
メッセージの用途を伝える。 コード内のコメントはメッセージと一緒に旅を
します。-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フラグが付き、この1行こそが、
あなたの管理下にないツールでプレースホルダーQAを有効にするスイッチです。
Weblateはこの検査を文書化しており、商用プラットフォームも同じフラグに自らの
検査を紐付け、msgfmt --check-formatはあらゆるGNU pipelineでそれを強制します。
詳細と、同梱checkerがそれを超えて捕捉する内容は
抽出のページに
あります。
安全網は、その及ぶ範囲までしか信頼しない。 プラットフォームから返って くるものも、ビルドへ入るデータであることに変わりはありません。上のCIゲート こそが、「プラットフォームがおそらく検査した」を「壊れたまま出荷されることは あり得ない」へ変えるものです。
実行時に言語を束縛する¶
ここまでのすべてはカタログを生み出します。残る決定はアプリケーションが どこでカタログを選択するかです。言語のスコープごとに一度束縛します。 CLIならプロセス、Webサービスならリクエストです。
コマンドラインツールやデスクトップアプリケーションは、起動時に一度だけ
ユーザーの環境を読みます。languages=を渡さなければ、標準ライブラリが
LANGUAGE、LC_ALL、LC_MESSAGES、LANGからネゴシエーションします。
fallback=Trueは、どれも出荷するカタログに一致しない場合に、例外では
なくnullカタログ — ソーステキスト — を返します。
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や
そのecosystemが提供しています。ここで重要なのは、call_nextを囲む
束縛です。
ランタイムの習慣を2つ加えれば全体像が完成します。import時に作られる文字列 —
フォームラベルやenumの表示名 — は、import中に有効だった言語を捕捉しては
なりません。lazy_gettextで定義すれば、
使用時に有効な言語でレンダリングされます。そして、gettext_tstrings
loggerは人間が目にする場所へルーティングしてください。その警告は、lenient
モードが、すべてのゲートをすり抜けた翻訳を報告するものです。レンダリング
ごとに1行ではなく、壊れたメッセージごとに1行です。
出荷¶
本番に必要なのはパッケージと.moファイルだけで、他には何も要りません。
Babelは開発とCIの依存です。gettext-tstrings[babel]を本番イメージへ入れず、
そこには素のパッケージをインストールしてください。レンダリングは標準
ライブラリだけで動きます。カタログのコンパイルは、デプロイする成果物を
生成するのと同じビルドで行います。そうすれば、成果物内の.moファイルは
レビュー済みの.poファイルと厳密に一致し、誰かのラップトップでコンパイル
されたものが出荷されることはありません。
どうやって運ぶかは、何をデプロイするかで決まります。wheelはカタログを
パッケージデータとして運ぶので、カタログはパッケージディレクトリの内側 —
トップレベルのlocales/ではなくsrc/myapp/locales/— に置く必要があり、
さらに.gitignoreが通常は隠すファイルを含めるよう、ビルドバックエンドに
指示しなければなりません。
読み込むときは、ソースツリーからの相対パスではなくパッケージ経由にします。 相対パスは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の終了statusでビルドをゲートしている。- 残っている
fuzzyエントリは意図的なもの — 各エントリは翻訳者が確認する までソーステキストで表示される。 - テストスイートが、出荷する各言語を
strict=Trueで一度レンダリングしている。 - 本番成果物には
.moファイルが含まれ、Babelは含まれない。 gettext_tstringsloggerがmonitoringへルーティングされている。