コンテンツにスキップ

t-stringを選ぶ理由

翻訳対象のメッセージへ値を埋め込む4つの方法を、同じメッセージで比較します。 4つとも、プレースホルダーに名前を付け、翻訳者による並べ替えを許します。 違いが出るのは、翻訳が誤っていたときに何が起こるか、カタログがプログラムの どこまで手を伸ばせるか、そして採用に何がかかるかです。

まず表を置きます。気になる行を見つけて、その裏にある節だけを読めるように するためです。

翻訳されるメッセージには3者が関わる

カタログとは翻訳のファイルです。人が編集する間は.poで、 アプリケーションが読み込むために.moへコンパイルされます (チュートリアルで両方を扱います)。すべてのメッセージには 3者が関わります。開発者がソース文字列を書き、翻訳者がカタログを 編集し — 多くの場合、コードレビューから遠く離れた外部プラットフォーム上で です — アプリケーションが実行時に両者を組み合わせてレンダリングします。 以下の各書式スタイルは、同じ問いに異なる答えを出します。 カタログにフォーマット言語のどこまでを制御させるのか? 例中の_は翻訳関数の慣習的な名前で、trはこのライブラリの関数です。

比較

翻訳者がミスをしたとき。 カタログは多くの人の手を渡り、そこで起こる問題の ほとんどは偶発的なものです。

%(name)s .format() flufl.i18n $name t"…"
翻訳がプレースホルダーを削除した場合、何がレンダリングされるか? 値は何も表示せず消える 値は何も表示せず消える 値は何も表示せず消える ソースメッセージ+警告(既定では
翻訳が未知のプレースホルダーを追加した場合、何がレンダリングされるか? 例外 例外 プレースホルダーがテキストとして見える形で残る ソースメッセージ+警告(既定では
翻訳がプレースホルダーの書式を変えた場合、何がレンダリングされるか? カタログが指示したとおり。型文字が値に合わなくなれば例外 カタログが指示したとおり $文字列では表現できない ソースメッセージ+警告
プレースホルダーはレンダリング時に検査されるか? いいえ いいえ いいえ はい(下記参照)

カタログにどこまでの権限を与えるか。 翻訳はリポジトリの外から来るデータで あり、スタイルごとに渡す力の大きさが違います。

%(name)s .format() flufl.i18n $name t"…"
値はどこから来るか? 明示的なマッピング 明示的な引数 呼び出し元のローカル変数とグローバル変数(任意のextrasを追加可能) t-string内に取り込まれた値
カタログは値の書式を変えられるか? はい はい いいえ いいえ
カタログはオブジェクトの中へ届くか(属性アクセス)? いいえ はい はい(ドット付きの名前) いいえ
「現在の言語」はどこに置かれるか? アプリケーションが置いた場所 アプリケーションが置いた場所 共有アプリケーションオブジェクト上にある言語コードのスタック ContextVar(タスクまたはリクエストごと)

導入に何がかかるか。 ツールが噛み合えば上記はすべて無償で手に入ります。 噛み合わない可能性があるのがここです。

%(name)s .format() flufl.i18n $name t"…"
必要な最小のPython 制限なし 制限なし 3.10 3.14
成熟度 標準ライブラリ 標準ライブラリ 安定版リリース alpha
通常のPO/MOカタログを使用するか? はい はい はい はい
独自のソース抽出器が必要か? いいえ いいえ いいえ 現時点では、はい
既存ツールの検証用にBabelが推論するPOフラグは? python-format python-brace-format なし python-brace-format

レンダリング時の検査について:単数形メッセージはプレースホルダーの完全一致を 検査します。複数形メッセージも、対象言語の複数形がソース言語と異なることを 許す和集合/積集合の規則に基づいて検査されます。formごとのより 厳密な検査は、カタログのコンパイル時に実行されます(抽出)。

書式フラグの行は、カタログの互換性ではなく、プレースホルダーを認識した検証に 関するものです。「なし」でも、標準のgettextツールはメッセージを読み取り、 コンパイルできますが、msgfmt --check-formatには適用できる $プレースホルダーの文法がありません。

互換性と成熟度

最後の表の最初の2行は採用の可否を決める行なので、セルの中ではなくはっきりと 文章で述べておきます。

%形式と.format()はPythonに組み込まれており、依存パッケージは一切要りません。 flufl.i18nは成熟したパッケージで、リリース済みかつ本番で使われて おり、Python 3.10以降で動作します。gettext-tstringsalpha版であり、 Python 3.14以降を必要とします。t-stringが3.14の新しい構文であるためで、 バックポートは存在せず、あり得ません。安定しているのは仕様の部分で、 Python APIは1.0までまだ動く可能性があります。

どれについても発生しないのが、カタログの互換性という費用です。4つとも通常の POT/PO/MOファイルを生成し、あらゆるPOエディタ、翻訳プラットフォーム、GNU gettextツールがすでにそれを読めます。ですからここでの選択は、カタログの 形式を変える場合とは違って、後から取り消せます。既存プロジェクトを移す話は 移行で扱います。

以下の各節では、方式ごとにトレードオフを詳しく見ていきます。

%形式

_("Hello %(name)s") % {"name": name}

何が起こり得るか:壊れたプレースホルダは、カタログの検証が先に捕まえなければ 実行時例外になります。

カタログ文字列にはprintf構文が含まれます。そこには%(name)ssという、 見落としやすく壊しやすい末尾の型文字も含まれます。

>>> "Hello %(name)" % {"name": "Ada"}  # the trailing "s" was deleted
Traceback (most recent call last):
  ...
ValueError: incomplete format

POエディターでの1文字の変更が、カタログ検証で先に捕まらない限り、実行時の 例外になります。GNU msgfmt --check-format はこれを検出できますが、 メッセージにpython-format フラグがあり、かつカタログがアプリケーションへ 届くまでに実際にmsgfmtを通る場合に限られます。

str.format

_("Hello {name}").format(name=name)

末尾の型文字がなくなり、名前が付いて自由に並べ替えられるプレースホルダーは 維持されます。何が起こり得るかは、やり取りの反対側へ移ります。翻訳が あなたのオブジェクトに対する力を得るのです。

str.format は小さな式言語であり、文字列に対して 呼び出すことは、その文字列へ式言語を使う権限を与えることになります。

>>> "{name.__class__.__mro__}".format(name="Ada")
"(<class 'str'>, <class 'object'>)"
>>> settings.api_key = "sk-live-…"
>>> "{conf.api_key}".format(conf=settings)
'sk-live-…'

次に、これらの文字列リテラルを_()が返すものに置き換えてみてください。 Hello {name}の翻訳が{conf.api_key}として返ってくれば、レンダリングは あなたのAPIキーを出力します。何が読み取られるかを決めたのは、コードではなく カタログです。カタログはコードではありませんが、データとして移動します。 翻訳プラットフォームへ渡り、複数の人の手を経て.poとして戻り、.moへ コンパイルされ、ときには外部プロジェクトからそのまま取り込まれます。 .format() は、この経路のすべての段階に、渡されたオブジェクトの属性へ アクセスできる文字列を置くことになります。

$文字列とflufl.i18n

from flufl.i18n import initialize

_ = initialize("example")

name = "Ada"
print(_("Hello $name"))  # Hello Ada — the value came from the caller's locals

標準ライブラリのstring.Template$nameという補間言語を提供しますが、 それ自体は翻訳APIではありません。flufl.i18nは、この形式とgettextの カタログ検索を組み合わせます。値がどこにも渡されていないことに注目してください。 flufl.i18nは置換に使う名前空間を呼び出し元のグローバル変数とローカル変数から 構築します。呼び出し箇所に存在する変数はすべてメッセージから利用できます。 任意のextrasマッピングはその両方より優先されます。 翻訳者が扱う構文には末尾の型文字や書式指定がなく、プレースホルダーも自由に 並べ替えられます。

置換値が見つからなくても例外にはなりません。name = "Ada"で、呼び出し元の 名前空間にnombreがないとき、カタログの翻訳Hello $nombreHello $nombreとレンダリングされ、未解決のプレースホルダーが見える形で残ります。 この文書化された挙動は、呼び出しを失敗させず、翻訳済み メッセージの残りを保持します。ただし、属性の解決中や値の変換中に発生した例外は 伝播することがあります。

関連する点で、flufl.i18nは素のstring.Templateより高機能です。 その独自Template$settings.api_keyのようなドット付き プレースホルダーを受け付け、translatorは呼び出し元の値に対してそのパスを 解決します。翻訳側のプレースホルダーは、利用可能なローカル変数やグローバル変数を 指定でき、ドット構文ならその属性もたどれます。メッセージに属性が必要な場合には 便利ですが、呼び出し元のフレームもカタログの置換名前空間の一部になります。 ここでの比較はflufl.i18n 6.0.0を対象とし、string.Templateのあらゆる使い方を 表すものではありません。

さらにflufl.i18nは、他の2つの書式スタイルが完全にアプリケーション任せに している問いにも答えます。どの言語がいま有効で、それをどう切り替えるか、という 問いです。アプリケーションオブジェクトが言語のスタックを保持し、 _.push(code)_.pop()がそれを動かし、with _.using(code):が入れ子を作ります。 そしてstrategyが言語コードからカタログを見つけるため、アプリケーション自身が カタログオブジェクトを扱うことはありません。1つの作業単位のあいだに複数の言語で テキストを生成しなければならないサーバー — 読み手向けのページと、設定言語の異なる 誰かへの通知 — こそ、この仕組みが存在する理由です。

そのスタックはアプリケーションオブジェクト上にあり、プロセス全体がそれを共有します。 したがって重なり合う2つのリクエストは1つのスタックを共有し、時間的に厳密な 入れ子になっていないブロックは、互いに誤った言語を渡し合います。

async def greet(code, delay):
    with _.using(code):
        await asyncio.sleep(delay)
        return _("Hello $name")


async def main():
    return await asyncio.gather(greet("fr", 0.01), greet("ja", 0.02))
>>> asyncio.run(main())  # "fr" entered first and left first, so it read "ja" off the top
['こんにちは Ada', 'Bonjour Ada']

このライブラリは同じ機能 — 束縛は同じように入れ子になり、同じように巻き戻ります — を、共有スタックではなくContextVarの中に置きます。そのため上記の交錯はタスクごとに 解決されます。対応する書き方は 複数の言語を同時に扱うにあります。 提供しないのは言語コードからカタログへの検索です。渡すのは翻訳オブジェクトで、 よくある場合はgettext.translation()の呼び出し1回で得られ、解析済みカタログは 標準ライブラリがキャッシュします。

t-string

tr(t"Hello {name}")

カタログには引き続きHello {name}が渡され、通常のPO/MOカタログを利用できます。 違いは、翻訳が何を言うことを許されるかと、それを誰が検査するかです。

このライブラリは、レンダリングの前にすべての翻訳を元メッセージの プレースホルダーと照合して検証します。受け付けるのは単純な名前だけです。 t"Hello {name}"に対しては次のようになります。

翻訳に含まれるもの 拒否理由
{name.__class__.__mro__} placeholder {name.__class__.__mro__} must be a plain name, copied from the source message unchanged
{name!r} placeholder {name} adds formatting; write {name} on its own, because the source message decides how the value is formatted
{0} placeholder {0} must be a plain name, copied from the source message unchanged
{nombre} translation does not match the source placeholders: {name} is missing; {nombre} is not in the source message

拒否はクラッシュを意味しません。既定ではライブラリが警告をログに記録し、 ソースメッセージをレンダリングするため、不正なカタログがアプリケーションを 停止させることはありません — gettext自身が守っているのと同じ契約です。

書式指定は、それが書かれた場所、つまりコードに置かれたままです。

amount = 1234.5
tr(t"Total: {amount:,.2f}")  # msgid is "Total: {amount}"

:,.2f はカタログへ届きません。翻訳が変更することも、翻訳者が目にすることも ありません。ただしこれは固定の書式であって、ローカライズされた書式では ありません。言語ごとに桁と区切り記号を選ぶのは 呼び出しの前に行うBabelの仕事です。

もう一つの違いはツールです。t-stringは新しい構文なので、.potへの抽出には 現在のところt-string対応の抽出器が必要です。このパッケージが Babel向けに提供するものはその一つです。

制約の代償

Pythonのバージョン要件を別にすれば、ここまでの代償は規則1つです。補間は単純な 名前でなければなりません。

tr(t"Hello {user.name}")  # raises InvalidTemplateError at the call site
name = user.name  # compute it first
tr(t"Hello {name}")

これは実際の制約であり、同時に、上に挙げた保証を生んでいるのもこの制約です。 ソース側での値の結び付けや実行時のプレースホルダー検査と組み合わせることで、 カタログ文字列による式の評価を防ぎ、プレースホルダー名を、それを翻訳する人に とって意味のあるものに保ちます。

f-stringをこの方法で使うことはできません。ライブラリが受け取る時点では完成した 文字列なので、翻訳すると文の断片を翻訳することになります。t-string(PEP 750)は f-stringに似た構文と明示的な値の結び付けを保ちながら、静的なテキストと値を 分離したまま保持します。

Pythonがどのようにしてここへ辿り着いたのか — 10年を隔てた2つのPEP、 そして答えのないまま閉じられた標準ライブラリの議論 — は、 背景で出典とともに語られています。