跳轉至

為什麼選擇 t-string

把一個值放進可翻譯訊息的四種做法,以同一則訊息互相比較。四者都替佔位符命名, 也都容許譯者調換順序;它們的差別在於翻譯出錯時會發生什麼、目錄能碰到你程式的 多少部分,以及採用它們要付出什麼代價。

表格擺在最前面,這樣你可以先找到自己在意的那一列,再只讀它背後的那一節。

每一則翻譯訊息都經手三方

目錄就是裝著翻譯的檔案——由人編輯時是 .po,編譯成 .mo 給應用程式載入 (教學兩者都走過一遍)。每一則訊息都會經手三方:開發者寫下 來源字串,譯者編輯目錄——往往是在外部平台上,離任何一次程式碼審查都很遠—— 然後應用程式在執行階段把兩者渲染在一起。以下每一種格式風格,對同一個問題 給出了不同的答案:目錄究竟能掌控格式語言的多少部分? 在這些範例裡,_ 是 翻譯函式的慣用名稱,而 tr 是本函式庫的。

並排比較

當譯者出錯的時候。 一份目錄會經過許多人的手,而其中出的差錯多半是無心的:

%(name)s .format() flufl.i18n $name t"…"
翻譯漏掉一個佔位符——會渲染出什麼? 值無聲無息地消失 值無聲無息地消失 值無聲無息地消失 來源訊息,外加一則警告(預設
翻譯多加一個未知佔位符——會渲染出什麼? 一個例外 一個例外 佔位符以文字形式留著可見 來源訊息,外加一則警告(預設
翻譯改寫了某個佔位符的格式——會渲染出什麼? 目錄要求的格式,或者在型別字母不再配得上那個值時拋出例外 目錄要求的格式 $-string 表達不出來 來源訊息,外加一則警告
佔位符會在渲染時被檢查嗎? 不會 不會 不會 會(見下文)

目錄握有多大的權限。 一則翻譯是來自你儲存庫之外的資料,而每一種風格交到 它手上的權力並不一樣:

%(name)s .format() flufl.i18n $name t"…"
值從哪裡來? 一份明確的對應表 明確傳入的引數 呼叫端的 local 與 global 變數,外加選用的 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

關於渲染時的檢查:單數訊息會被檢查佔位符是否完全相符。複數訊息同樣會被檢查,依據 的是那條讓目標語言的複數形式得以不同於來源語言的聯集/交集規則;更嚴格 的逐形式檢查則在目錄編譯時執行(擷取)。

格式旗標那一列談的是「能否做佔位符感知的驗證」,而不是目錄相容性。的意思是 標準的 gettext 工具照樣讀得懂也編譯得了這則訊息,只是 msgfmt --check-format 沒有可套用的 $ 佔位符文法。

相容性與成熟度

上一張表的前兩列才是真正決定要不要採用的關鍵,所以值得直接說清楚,而不是塞在 格子裡。

%-format 與 .format() 內建於 Python,完全不需要任何相依套件。 flufl.i18n 是一個成熟的套件,已正式發行且用於正式環境,在 Python 3.10 以上都能跑。gettext-tstrings 還是 alpha,而且需要 Python 3.14 以上,因為 t-string 是 3.14 才有的新語法——它沒有回移版本, 也不可能有。它的規範是其中穩定的那一部分;Python API 在 1.0 之前 仍有可能改動。

四者都不必付出的代價則是目錄相容性。它們全都產出普通的 POT/PO/MO 檔案,任何 PO 編輯器、翻譯平台與 GNU gettext 工具都早已讀得懂,所以底下這個選擇是可逆的,換掉 目錄格式可就不是。遷移談的是如何搬動一個既有專案。

以下各節會逐一展開每一種做法的取捨細節。

%-format

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

會出什麼錯:壞掉的佔位符會變成執行階段的例外,除非目錄驗證先一步攔下它。

目錄字串攜帶著 printf 語法,包括結尾那個型別字母——%(name)s 裡的 s——它既容易 被忽略,也容易被弄壞:

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

在 PO 編輯器裡改一個字元,除非目錄驗證先攔下來,否則就成了執行階段的例外。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 key——決定讀了什麼的是目錄,不是你的 程式碼。目錄不是程式碼,但它像資料一樣流動:送出去到翻譯平台、經過好幾雙手、以 .po 回來、編譯成 .mo,有時甚至整份是從專案外部取得的。.format() 讓這趟旅程 的每一站,都對你傳進去的物件擁有屬性存取權。

$-string 與 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 是從呼叫端的 globals 與 locals 建出替換用的命名空間——呼叫點存在的任何變數,訊息都拿得到。另有一個選用的 extras 對應表,優先權高於前兩者。它面向譯者的語法沒有結尾的型別字母,也沒有格式規格, 而佔位符仍可自由調換順序。

替換不到值時並不會拋出例外。在 name = "Ada" 而呼叫端命名空間裡沒有 nombre 的 情況下,目錄裡 Hello $nombre 這則翻譯會渲染成 Hello $nombre:無法解析的佔位符 就這麼留著可見。那份有明文記載的行為保住了翻譯訊息的其餘 部分,而不是讓這次呼叫失敗。至於解析屬性或轉換值時拋出的例外,仍然可能往外傳。

flufl.i18n 在一個切題的面向上比裸的 string.Template 更有能耐。它的 自訂 Template 接受 $settings.api_key 這類帶點號的佔位符,而它的 translator 會沿著這些路徑對呼叫端的值求解。一個被翻譯出來的佔位符,可以指名任何 取得得到的呼叫端 local 或 global,並且透過點號語法穿行它的屬性。當一則訊息需要某個 屬性時,這很方便,同時也把呼叫端的 frame 納入了目錄的替換命名空間。這裡的比較描述 的是 flufl.i18n 6.0.0,而不是 string.Template 的所有可能用法。

它同時也回答了另外兩種格式化風格完全丟給應用程式的一個問題:目前是哪一個語言, 以及要怎麼換掉它。一個應用程式物件持有一疊語言,_.push(code)_.pop() 推動它,with _.using(code): 可以巢狀,而一套策略負責依語言 代碼找出目錄,於是應用程式自己從不必碰目錄物件。那種在單一個工作單位之內就得產出 一種以上語言文字的伺服器——給讀者的一個頁面,加上給某個帳號設定成別種語言的人的一則 通知——正是這套機制存在的理由。

那疊語言就掛在那個應用程式物件上,而整個行程共用它。因此兩個彼此重疊的請求會共用 同一疊,於是那些在時間上並非嚴格巢狀的區塊,就會把錯的語言交到對方手上:

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 裡,而不是一個共用的堆疊,所以上面那種交錯會逐任務各自解出正確答案。對應的寫法在 同時處理多種語言。它沒有提供的是「語言代碼到 目錄」的查詢:你要傳入一個 translations 物件,常見情況下就是一次 gettext.translation() 呼叫,而解析後的目錄由標準函式庫快取起來。

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 版本要求之外,上述這一切的代價就是一條規則:插值必須是一個單純的 名稱。

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 是怎麼走到今天這一步的——相隔十年的兩份 PEP,以及那場沒有答案就結束的 標準函式庫討論——專案背景有連同出處的完整敘述。