Python 基础体系 · 第 106/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 国际化:gettext、Locale、日期数字、消息目录和回退

国际化通常写作 I18N,因为英文单词 internationalization 从首字母 I 到末字母 N 之间有 18 个字母。它指的是:在不为每种语言重写业务代码的前提下,让程序具备支持多种语言和地区规则的结构。

本地化通常写作 L10N,指的是针对某个具体语言、地区和文化习惯进行适配,例如:

  • Delete 翻译成“删除”;
  • 1,234.56 显示为 1.234,56
  • 把日期显示为 2026/09/0101/09/20262026年9月1日
  • 使用不同的货币符号、货币小数位和正负号位置;
  • 针对同一个词在不同上下文中选择不同译法。

Python 标准库把这些问题拆成了不同机制:

问题 主要机制
用户可见消息的翻译 gettext.po.mo
当前进程的语言和地区约定 locale
日期和时间对象 datetimetime.strftime()
数字和货币格式 locale.format_string()locale.currency()
多种语言同时存在 gettext.translation() 返回的翻译对象
缺少翻译时继续运行 NullTranslationsadd_fallback()、源文回退

最容易犯的错误,是把“语言翻译”和“地区格式化”当成同一个问题。实际上:

gettext:把一条消息从一种自然语言转换成另一种自然语言

locale:告诉程序某个地区如何书写数字、日期、货币和排序规则

两者可以协同工作,但互相不能替代。


一、先区分文本、编码、语言和地区

1. Unicode 解决“字符是什么”,gettext 解决“说什么语言”

Python 3 的 str 表示 Unicode 文本,bytes 表示编码后的字节序列。国际化处理通常应当遵循这样的边界:

外部字节
    ↓ decode
Python str / Unicode 文本
    ↓ gettext
目标语言的 str
    ↓ 格式化日期、数字、货币
最终展示文本
    ↓ encode 或由协议编码
输出字节

例如:

raw = b"\xe4\xb8\xad\xe6\x96\x87"
text = raw.decode("utf-8")

此时:

text == "中文"

gettext 接收和返回的也是 Unicode 字符串。消息目录中的消息标识和翻译结果都会被解析为 Unicode 文本,而不是 bytes。(docs.python.org)

因此,下面两件事必须分开诊断:

# 编码问题:字节无法按 UTF-8 解码
data.decode("utf-8")

# 翻译问题:字符串在消息目录中没有对应译文
translator.gettext("File not found")

如果屏幕上出现 UnicodeDecodeError 或乱码,通常首先检查编码边界;如果显示的是英文原文而不是目标语言,才检查消息目录、语言选择和回退链。

2. 语言不是地区

enzhfr 是语言代码;en_USen_GBzh_CNzh_TW 则包含地区信息。

同一种语言可能具有不同的地区规则:

en_US:month/day/year
en_GB:day/month/year
zh_CN:year/month/day

同一个地区也可能存在不同语言。语言决定词汇和语法,地区决定数字、日期、货币、排序等文化约定。

POSIX locale 名称通常遵循:

语言_地区.编码@修饰符

例如:

de_DE.UTF-8
zh_CN.UTF-8
en_US.UTF-8

但 locale 名称具有平台和系统配置依赖性,Linux、macOS、Windows 上可用的名称并不完全相同。(docs.python.org)


二、gettext 的核心模型:消息标识、翻译和目录

1. 消息标识不是数组下标,而是源文字符串

gettext 的基本关系可以表示为:

(message_id, context, plural_rule, language)
        ↓
translated_message

最简单的代码是:

from gettext import gettext as _

print(_("File not found"))

这里的 "File not found"消息标识,也叫 msgid。程序运行时把它交给翻译对象:

msgid = "File not found"
language = "zh_CN"
lookup(msgid, language)
    → "文件未找到"

源码中保留源语言文本有两个实际原因:

  1. 没有翻译时可以直接显示源文;
  2. 消息标识稳定、可读,提取工具可以从源码中发现它。

Python 文档把国际化过程分为四步:标记可翻译字符串、生成原始消息目录、填写各语言翻译、运行时使用 gettext 查询翻译。(docs.python.org)

2. .po 是人工可读目录,.mo 是运行时目录

一个典型的项目结构如下:

demo/
├── app.py
└── locale/
    ├── zh_CN/
    │   └── LC_MESSAGES/
    │       └── demo.mo
    └── fr/
        └── LC_MESSAGES/
            └── demo.mo

.po 文件是给人和翻译工具使用的文本文件:

msgid ""
msgstr ""
"Language: zh_CN\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=1; plural=0;\n"

#: app.py:8
msgid "File not found"
msgstr "文件未找到"

.mo 文件是编译后的机器可读消息目录,Python 运行时读取的是 .mo,不是 .po。官方文档描述的标准目录布局是:

localedir/language/LC_MESSAGES/domain.mo

其中:

  • localedir 是语言目录的根;
  • language 是语言选择,例如 zh_CN
  • LC_MESSAGES 是消息类别目录;
  • domain 是消息域名;
  • .mo 是编译后的消息目录文件。(docs.python.org)

domain 类似命名空间。例如:

demo.mo
admin.mo
plugin_x.mo

同一个应用可以使用多个域,避免不同模块使用相同 msgid 时互相覆盖。

3. 生成和编译消息目录

可以使用 GNU gettext 工具:

xgettext \
  --language=Python \
  --from-code=UTF-8 \
  --keyword=_ \
  --output=locale/demo.pot \
  app.py

这里:

  • --language=Python 表示输入是 Python 源码;
  • --from-code=UTF-8 表示源码编码;
  • --keyword=_ 表示提取 _() 调用中的字符串;
  • .pot 是待翻译模板。

随后为某种语言建立 .po

mkdir -p locale/zh_CN/LC_MESSAGES
cp locale/demo.pot locale/zh_CN/demo.po

编辑 locale/zh_CN/demo.po

msgid ""
msgstr ""
"Language: zh_CN\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=1; plural=0;\n"

msgid "File not found"
msgstr "文件未找到"

再编译:

msgfmt \
  -o locale/zh_CN/LC_MESSAGES/demo.mo \
  locale/zh_CN/demo.po

如果系统没有安装 GNU gettext,Python 发行版有时会提供纯 Python 的 pygettext.pymsgfmt.py,但它们是否位于当前环境的 PATH 中取决于具体发行版和安装方式。Python 文档明确说明,pygettext.py 只能处理 Python 源码,而 msgfmt.py 用于生成与 GNU gettext 兼容的 .mo 文件。(docs.python.org)


三、gettext 的两种 API:全局函数和翻译对象

1. 全局 API 会修改进程范围内的状态

全局用法如下:

import gettext

gettext.bindtextdomain("demo", "/path/to/locale")
gettext.textdomain("demo")

_ = gettext.gettext

print(_("File not found"))

调用关系是:

bindtextdomain("demo", localedir)
    ↓
domain demo 绑定到某个目录

textdomain("demo")
    ↓
设置当前全局 domain

gettext.gettext(message)
    ↓
根据当前全局语言和目录查找翻译

gettext.install() 甚至会把 _() 安装到 builtins

gettext.install("demo", "/path/to/locale")
print(_("File not found"))

这种方式适合语言在整个进程生命周期内固定的单语言应用,例如命令行程序启动时根据环境变量确定语言。

但它有两个边界:

  1. 全局 domain 是进程范围状态;
  2. 安装到 builtins 会影响整个 Python 应用。

因此,库代码不应擅自调用 gettext.install()。官方文档建议模块使用类 API,把 _ 绑定到模块自己的全局变量,而不是污染内建命名空间。(docs.python.org)

2. 类 API 允许多个翻译对象并存

更适合服务端和可切换语言场景的写法是:

import gettext
from pathlib import Path

LOCALE_DIR = Path(__file__).parent / "locale"

zh = gettext.translation(
    "demo",
    localedir=LOCALE_DIR,
    languages=["zh_CN"],
    fallback=True,
)

fr = gettext.translation(
    "demo",
    localedir=LOCALE_DIR,
    languages=["fr"],
    fallback=True,
)

print(zh.gettext("File not found"))
print(fr.gettext("File not found"))

这里 zhfr 是两个独立的翻译对象:

zh.gettext("File not found") → 中文
fr.gettext("File not found") → 法文

请求处理时可以根据用户语言选择对象:

def render_error(translator, path):
    message = translator.gettext("File not found")
    return f"{message}: {path}"

翻译对象不会要求你修改进程的全局语言状态。gettext.translation() 根据域名、目录和语言列表查找 .mo,并对相同 .mo 文件进行缓存;如果提供多个语言,后面的语言可以作为前面语言的回退。(docs.python.org)

3. 不要把 _() 的调用结果过早保存为最终文本

错误写法:

# 模块导入时就决定了语言
TITLE = _("Settings")

如果语言由请求决定,TITLE 可能在第一个请求执行时就固定成某种语言。

更可靠的写法是保存消息标识:

TITLE_MSGID = "Settings"

def render_title(translator):
    return translator.gettext(TITLE_MSGID)

或者在请求范围内调用:

def render_page(translator):
    return {
        "title": translator.gettext("Settings"),
    }

国际化消息应该尽量在“已经知道目标语言、即将展示给用户”的位置翻译,而不是在模块导入、数据库写入或后台任务创建时翻译。


四、语言选择和消息目录查找

1. languages 参数优先于隐式环境

显式指定语言:

translator = gettext.translation(
    "demo",
    localedir="/srv/app/locale",
    languages=["zh_CN", "zh", "en"],
    fallback=True,
)

这表示依次尝试:

zh_CN
    ↓ 找不到时
zh
    ↓ 找不到时
en

gettext.find() 的查找过程大致是:

  1. 读取语言列表;
  2. 对语言名进行扩展和规范化;
  3. 按顺序构造 .mo 路径;
  4. 返回第一个存在的文件。

没有显式传入 languages 时,Python 会参考 LANGUAGELC_ALLLC_MESSAGESLANG 等环境变量;环境变量中的语言可以用冒号分隔。(docs.python.org)

例如:

LANGUAGE=zh_CN:zh:en python app.py

其语义不是“把三种语言同时显示”,而是“按这个顺序寻找可用翻译”。

2. languagedomainlocaledir 任何一个错了都会导致原文回退

假设实际文件是:

/srv/app/locale/zh_CN/LC_MESSAGES/demo.mo

则必须满足:

gettext.translation(
    domain="demo",
    localedir="/srv/app/locale",
    languages=["zh_CN"],
)

以下任一变化都可能找不到文件:

domain="application"       # 文件名不再匹配 demo.mo
localedir="/srv/app"       # 根目录多了一层或少了一层
languages=["zh"]           # 不一定自动等价于 zh_CN

生产环境中应使用明确的绝对目录,而不要依赖标准库默认目录。Python 文档指出,gettext 使用的默认目录是解释器相关目录,而不是自动适配所有操作系统的系统目录;因此显式传入绝对路径更可靠。(docs.python.org)

3. 语言回退和消息回退是两层不同的机制

下面有两种“回退”:

语言层回退

languages=["zh_CN", "zh", "en"]

含义是:

先找 zh_CN 的目录
没有则找 zh 的目录
没有则找 en 的目录

消息层回退

即使找到了 zh_CN/demo.mo,其中也可能没有某个消息:

zh_CN/demo.mo 存在
但 msgid = "Network timeout" 不存在

这时翻译对象会继续查找消息级 fallback;如果没有 fallback,最终返回 msgid 本身。

4. fallback=True 和“翻译失败”不是一回事

translator = gettext.translation(
    "demo",
    localedir="/srv/app/locale",
    languages=["zh_CN"],
    fallback=True,
)

如果整个 .mo 文件不存在,fallback=True 会返回 NullTranslations;它的 gettext() 会原样返回输入消息。

如果 fallback=False,找不到 .mo 时会抛出 OSError。默认值就是 False。(docs.python.org)

因此:

# 启动时严格校验
translator = gettext.translation(
    "demo",
    localedir="/srv/app/locale",
    languages=["zh_CN"],
    fallback=False,
)

适合把缺少目录视为发布错误。

而:

# 允许部分语言包缺失
translator = gettext.translation(
    "demo",
    localedir="/srv/app/locale",
    languages=["zh_CN"],
    fallback=True,
)

适合允许降级运行的工具或可选语言包。

要注意,fallback=True 会掩盖部署错误。如果生产系统要求中文必须完整,应在启动检查阶段显式验证目录和关键消息,而不是把所有问题都转换成英文原文。


五、复数:不能用 n == 1 拼接句子

1. 复数是语言规则,不是 Python 的二分判断

错误写法:

count = len(files)

if count == 1:
    text = f"There is {count} file"
else:
    text = f"There are {count} files"

这段代码把英语语法硬编码进了业务逻辑。不同语言的复数规则不同,有些语言不止单数和复数两种形式。

应使用 ngettext()

count = 3

text = translator.ngettext(
    "There is %(num)d file",
    "There are %(num)d files",
    count,
) % {"num": count}

print(text)

ngettext() 接收:

  • 单数消息;
  • 复数消息;
  • 数量 n

翻译目录的头部包含复数规则,运行时根据规则计算应该选择哪一个复数索引。Python 文档明确说明,某些语言拥有两个以上的复数形式,具体公式来自消息目录头部。(docs.python.org)

2. 为什么占位符应放在翻译之后替换

推荐:

text = translator.ngettext(
    "There is %(num)d file",
    "There are %(num)d files",
    count,
) % {"num": count}

不推荐:

text = translator.gettext(f"There are {count} files")

原因是消息提取工具需要看到稳定的字符串字面量。下面这个 f-string 在运行时已经变成了不同字符串:

f"There are {count} files"

count 分别为 123 时,消息标识也不同,翻译目录无法为它建立稳定条目。

占位符还必须在所有语言版本中语义一致:

msgid "There is %(num)d file"
msgid_plural "There are %(num)d files"
msgstr[0] "有 %(num)d 个文件"

如果翻译人员删除了 %(num)d,运行时可能得到语法错误、格式化错误或信息缺失。发布阶段应对占位符进行校验。

3. ngettext() 的回退也有规则

如果消息目录中没有对应条目,GNUTranslations.ngettext() 会在 n == 1 时返回单数消息,否则返回复数消息。这个回退规则本身是英语式二分逻辑,因此它只是“没有翻译时的保底行为”,不能当作多语言复数实现。(docs.python.org)

对于中文,通常不会在自然语言中区分名词单复数,但数量仍然可能影响句式:

msgid "You have %(num)d message"
msgid_plural "You have %(num)d messages"
msgstr[0] "你有 %(num)d 条消息"

即使目标语言只需要一个复数形式,也应使用目录头部声明的规则,而不是在 Python 代码中自行判断。


六、上下文:同一个英文词可能需要不同译法

1. gettext("Open") 信息不足

“Open”可能表示:

  • 打开文件;
  • 营业;
  • 打开的状态;
  • 一个按钮文本。

如果所有地方都使用相同的 msgid

translator.gettext("Open")

翻译人员无法知道上下文。

使用 pgettext()

button_text = translator.pgettext("file-menu", "Open")
status_text = translator.pgettext("business-status", "Open")

消息上下文参与查找:

(context="file-menu", msgid="Open")
(context="business-status", msgid="Open")

两个位置可以拥有不同译文。

pgettext()npgettext() 及其域版本在 Python 3.8 中加入。(docs.python.org)

2. 上下文不是给翻译人员看的注释

pgettext() 的上下文会成为消息查找键的一部分;而 # translator comment 只是给翻译工具或翻译人员看的说明。

可以同时使用:

# Translators: Text on the file-opening button.
label = translator.pgettext("file-menu", "Open")

上下文解决“程序如何区分同名消息”,注释解决“人如何理解这条消息”。

3. 复数和上下文可以组合

text = translator.npgettext(
    "inbox",
    "%(num)d unread message",
    "%(num)d unread messages",
    unread_count,
) % {"num": unread_count}

查找键同时包括:

context = "inbox"
singular = "%(num)d unread message"
plural  = "%(num)d unread messages"
n        = unread_count

这比先翻译一段无上下文的英文,再在外面拼接数量更加稳定。


七、日期:datetime 保存时间,locale 决定某些显示形式

1. datetime 本身不等于本地化日期

from datetime import date

d = date(2026, 9, 1)

print(d.isoformat())

输出是:

2026-09-01

date.isoformat() 是机器友好的 ISO 8601 表示,不依赖当前 locale。它适合:

  • JSON;
  • 数据库;
  • 日志;
  • API 字段;
  • 文件名;
  • 稳定的内部协议。

而:

print(d.strftime("%x"))

%x 表示当前 locale 适合的日期形式,输出顺序可能随 locale 改变。%A%a%B%b 等星期和月份名称也受 locale 影响。(docs.python.org)

2. locale 日期格式的最小示例

import locale
from datetime import date

d = date(2026, 9, 1)

old = locale.setlocale(locale.LC_TIME)

try:
    locale.setlocale(locale.LC_TIME, "C")
    print(d.strftime("%x"))
    print(d.strftime("%A, %B %d, %Y"))
finally:
    locale.setlocale(locale.LC_TIME, old)

C locale 下,输出通常接近:

09/01/26
Tuesday, September 01, 2026

但具体格式仍受到平台 C 库实现影响。Python 的 strftime() 调用底层平台实现,完整格式代码集合和部分行为可能因操作系统而异。(docs.python.org)

3. strptime() 解析也可能依赖 locale

import locale
from datetime import datetime

locale.setlocale(locale.LC_TIME, "C")

value = datetime.strptime(
    "Tuesday, September 01, 2026",
    "%A, %B %d, %Y",
)

print(value.date())

%A%B 是语言相关的名称。如果输入是法文或德文文本,必须在解析时使用相应的 LC_TIME,否则可能得到 ValueError

因此,机器接口不要传:

September 01, 2026

而应传:

2026-09-01

用户界面才根据用户的显示语言和地区进行格式化。

4. 时间点、时区和显示地区是三个不同维度

一个有时区的 datetime 表示时间点:

from datetime import datetime, timezone

created_at = datetime.now(timezone.utc)

随后应先转换到用户时区,再格式化:

数据库时间点
    ↓
用户时区
    ↓
用户 locale 的日期格式
    ↓
翻译后的句子

不要因为用户使用 zh_CN 就推断其时区一定是中国时区;语言、地区和时区是不同配置项。


八、数字:locale 只影响指定的格式化函数

1. 普通格式化不会自动使用 locale

下面的格式化使用 Python 自己的格式规则:

value = 1234567.89

print(f"{value:,.2f}")

输出通常是:

1,234,567.89

它不会因为调用了 locale.setlocale() 就自动变成其他地区格式。

要使用当前 LC_NUMERIC

import locale

locale.setlocale(locale.LC_ALL, "")
value = 1234567.89

print(locale.format_string("%.2f", value, grouping=True))

locale.format_string() 按当前 LC_NUMERIC 格式化数字;grouping=True 才会启用分组分隔符。其他普通数值格式化操作不受 LC_NUMERIC 影响。(docs.python.org)

2. 解析数字也需要明确规则

import locale

locale.setlocale(locale.LC_NUMERIC, "")

value = locale.atof("1234.56")
print(value)

locale.atof() 会依据当前 LC_NUMERIC 去除本地化分隔符,再调用指定的转换函数,默认是 float。(docs.python.org)

这意味着同一个输入字符串在不同 locale 下可能有不同解释:

1,234.56

在一种规则下可能表示一千二百三十四点五六;在另一种规则下,逗号可能是小数点。

API 和数据库输入不应直接依赖用户机器的 locale。边界层应规定格式,例如:

金额:整数分
比例:十进制定点数
时间:ISO 8601
数字接口字段:JSON number 或明确的十进制字符串

3. 货币格式属于 LC_MONETARY

import locale

locale.setlocale(locale.LC_ALL, "")

print(locale.currency(1234.5, grouping=True))
print(locale.currency(1234.5, grouping=True, international=True))

locale.currency() 使用 LC_MONETARY,可以控制:

  • 货币符号;
  • 小数位;
  • 千位分组;
  • 货币符号位于数值前还是后;
  • 使用本地货币符号还是国际货币符号。

它不能在 C locale 下工作,必须先设置可用的货币 locale。(docs.python.org)

更重要的是,货币的计算和货币的显示必须分离:

from decimal import Decimal

amount = Decimal("1234.50")  # 业务值

不要把已经格式化的字符串作为金额继续计算:

"$1,234.50"  # 展示文本,不是金额

locale.currency() 负责展示约定,不负责金额精度、汇率、税率或舍入策略。


九、locale 是进程级状态,不能当作请求级语言开关

1. setlocale() 改的是整个进程

import locale

locale.setlocale(locale.LC_ALL, "de_DE.UTF-8")

这不是给当前函数创建一个局部环境,而是改变当前进程的 locale 类别。Python 文档明确指出,setlocale() 在大多数系统上不是线程安全的;locale 本身是程序范围属性。(docs.python.org)

下面的服务端模式有竞态风险:

def handle_request(user_locale, value):
    old = locale.setlocale(locale.LC_ALL)
    try:
        locale.setlocale(locale.LC_ALL, user_locale)
        return locale.format_string("%.2f", value, grouping=True)
    finally:
        locale.setlocale(locale.LC_ALL, old)

两个线程交错执行时,可能发生:

线程 A:设置 de_DE
线程 B:设置 en_US
线程 A:继续格式化,但实际读到 en_US
线程 B:恢复旧值
线程 A:恢复旧值

结果可能是错误的数字和日期格式,而且问题通常无法通过单次测试稳定复现。

2. 安全的进程模型

如果整个进程只服务一种 locale,可以在启动阶段设置一次:

import locale

locale.setlocale(locale.LC_ALL, "")

然后不再修改。Python 文档也说明,如果 locale 设置后不再变化,多线程使用通常不会因此产生问题。(docs.python.org)

如果一个进程同时服务多个用户语言,则应:

  • 使用 gettext.translation() 为不同语言建立翻译对象;
  • 不在请求处理中调用 setlocale()
  • 使用不会修改进程全局 locale 的格式化方案;
  • 或者把不同 locale 的格式化放到隔离进程中。

在多进程模型中,通常应在创建工作线程之前完成固定 locale 的设置。若采用 fork,应明确父进程设置、子进程继承和后续是否修改之间的关系;不要让请求处理路径依赖隐式的全局状态。


十、一个可运行的端到端示例

下面的示例只使用 Python 标准库运行时 API,语言包通过 GNU msgfmt 编译。

1. 创建源码

app.py

from pathlib import Path
import gettext
from datetime import date


BASE_DIR = Path(__file__).parent
LOCALE_DIR = BASE_DIR / "locale"


def get_translator(language: str) -> gettext.NullTranslations:
    return gettext.translation(
        domain="demo",
        localedir=LOCALE_DIR,
        languages=[language, "en"],
        fallback=True,
    )


def render(translator, count: int) -> str:
    title = translator.gettext("Report")
    message = translator.ngettext(
        "There is %(num)d file",
        "There are %(num)d files",
        count,
    ) % {"num": count}

    # 这里使用稳定的 ISO 日期,避免把接口格式和用户显示格式混在一起。
    generated = date(2026, 9, 1).isoformat()

    return f"{title}\n{message}\nGenerated: {generated}"


if __name__ == "__main__":
    zh = get_translator("zh_CN")
    en = get_translator("en")

    print("[zh_CN]")
    print(render(zh, 3))

    print()
    print("[en]")
    print(render(en, 3))

2. 创建中文消息目录

locale/zh_CN/demo.po

msgid ""
msgstr ""
"Language: zh_CN\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=1; plural=0;\n"

msgid "Report"
msgstr "报告"

msgid "There is %(num)d file"
msgid_plural "There are %(num)d files"
msgstr[0] "有 %(num)d 个文件"

编译:

mkdir -p locale/zh_CN/LC_MESSAGES
msgfmt \
  -o locale/zh_CN/LC_MESSAGES/demo.mo \
  locale/zh_CN/demo.po

3. 创建英文消息目录

英文可以不创建 .mo,因为代码中的英文就是源文。为了演示语言级回退,也可以创建:

locale/en/demo.po

msgid ""
msgstr ""
"Language: en\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

msgid "Report"
msgstr "Report"

msgid "There is %(num)d file"
msgid_plural "There are %(num)d files"
msgstr[0] "There is %(num)d file"
msgstr[1] "There are %(num)d files"

编译:

mkdir -p locale/en/LC_MESSAGES
msgfmt \
  -o locale/en/LC_MESSAGES/demo.mo \
  locale/en/demo.po

4. 运行

python app.py

预期输出:

[zh_CN]
报告
有 3 个文件
Generated: 2026-09-01

[en]
Report
There are 3 files
Generated: 2026-09-01

这个例子中有三个独立步骤:

translator.gettext("Report")
    → 普通消息查询

translator.ngettext(...)
    → 根据数量和目标语言的复数规则查询

date(...).isoformat()
    → 生成稳定的机器格式

如果把最后一行改成:

generated = date(2026, 9, 1).strftime("%x")

它就会依赖当前进程的 LC_TIME,而不是依赖 translator 的语言选择。也就是说,即使 translator 是中文对象,日期仍可能按照进程当前 locale 的规则输出。这正是 gettextlocale 两套状态彼此独立的表现。


十一、消息目录的回退链如何工作

假设配置为:

languages=["zh_TW", "zh", "en"]

并且目录状态如下:

zh_TW/demo.mo:存在,但没有 "Save"
zh/demo.mo:存在,有 "Save" → "保存"
en/demo.mo:存在,有 "Save" → "Save"

对:

translator.gettext("Save")

查找过程可以表示为:

1. zh_TW/demo.mo
   找不到 msgid "Save"

2. zh/demo.mo
   找到 "Save"
   返回 "保存"

3. en/demo.mo
   不再查询

如果 zh_TW/demo.mo 整个文件不存在,则语言目录层面直接跳到 zh

如果三个目录都没有该消息,则翻译对象最终返回:

"Save"

这条路径可以画成:

flowchart TD
    A[请求 gettext msgid] --> B{zh_TW/demo.mo 存在?}
    B -- 否 --> C{zh/demo.mo 存在?}
    B -- 是 --> D{zh_TW 是否有 msgid?}
    D -- 是 --> E[返回 zh_TW 翻译]
    D -- 否 --> C
    C -- 是 --> F{zh 是否有 msgid?}
    C -- 否 --> G{en/demo.mo 存在?}
    F -- 是 --> H[返回 zh 翻译]
    F -- 否 --> G
    G -- 是 --> I{en 是否有 msgid?}
    G -- 否 --> J[返回源文 msgid]
    I -- 是 --> K[返回 en 翻译]
    I -- 否 --> J

要区分:

文件缺失:目录级回退
消息缺失:消息级回退
所有目录都没有:返回源文

gettext.translation() 在多个消息目录存在时,会把后面的翻译对象设置为前面的 fallback;如果没有任何 .mo 文件,fallback=False 抛出 OSErrorfallback=True 返回 NullTranslations。(docs.python.org)


十二、延迟翻译:保存消息标识,不保存目标语言文本

有些数据结构需要在业务层先保存可翻译条目,稍后在展示层翻译。

错误写法:

errors = [
    _("File not found"),
    _("Permission denied"),
]

如果这段代码运行时的语言不是用户最终语言,后续再切换 translator 已经无法恢复原始 msgid

可以保存消息标识:

errors = [
    "File not found",
    "Permission denied",
]

def render_errors(translator, errors):
    return [translator.gettext(message_id) for message_id in errors]

需要复数时,保存结构而不是已经拼接好的文本:

notification = {
    "singular": "%(num)d unread message",
    "plural": "%(num)d unread messages",
    "count": 3,
}

def render_notification(translator, notification):
    text = translator.ngettext(
        notification["singular"],
        notification["plural"],
        notification["count"],
    )
    return text % {"num": notification["count"]}

这里保存的是翻译所需的语义输入,翻译发生在用户语言已经确定的展示边界。

Python 文档还展示了 N_() 这种“只标记、不立即翻译”的模式:它先原样返回字符串,提取工具通过额外关键字识别这些字符串,真正展示时再调用 _()。(docs.python.org)


十三、常见失败表现和诊断顺序

1. 显示源文,不一定是翻译器坏了

逐步检查:

from pathlib import Path
import gettext

localedir = Path("/srv/app/locale")
language = ["zh_CN"]

print(localedir.exists())
print(gettext.find("demo", localedir=localedir, languages=language))

translator = gettext.translation(
    "demo",
    localedir=localedir,
    languages=language,
    fallback=True,
)

print(type(translator).__name__)
print(translator.info())
print(translator.gettext("File not found"))

重点观察:

gettext.find(...) 是否返回 .mo 路径
translator 是否是 GNUTranslations
translator.info() 是否包含正确的语言元数据
msgid 是否与源码完全一致

find() 返回 None,先检查路径结构和域名;若文件存在但仍返回原文,检查 msgid、上下文、复数条目和 .mo 是否由最新 .po 编译。

2. 修改 .po 后没有重新编译

运行时读取的是 .mo

.po 修改
    ↓
必须重新 msgfmt
    ↓
.mo 更新
    ↓
进程重新加载或重新创建 translator

只修改 .po 而不生成新的 .mo,程序不会自动看到翻译变化。

3. 用动态字符串破坏提取

错误:

key = "File not found"
translator.gettext(key)

这段代码运行时可以工作,但静态提取工具未必知道 key 的值是可翻译消息。

错误:

translator.gettext(f"File {filename} not found")

正确:

translator.gettext("File %(filename)s not found") % {
    "filename": filename,
}

提取工具最容易识别的是字符串字面量;动态拼接会使目录难以维护,也让翻译人员无法重新排列句子成分。

4. 翻译后再拼接自然语言

错误:

text = translator.gettext("You have") + f" {count} " + translator.gettext("messages")

不同语言的词序、格变化和数量表达可能不同。应该把完整句子作为一个消息:

text = translator.ngettext(
    "You have %(num)d message",
    "You have %(num)d messages",
    count,
) % {"num": count}

5. 日期格式和翻译语言不一致

可能出现:

中文界面:报告
日期:09/01/26

这并不一定是 gettext 错误,因为日期由 strftime() 和当前 LC_TIME 决定,而不是由 translator 决定。

应分别记录和诊断:

print("translator language:", "zh_CN")
print("LC_TIME:", locale.setlocale(locale.LC_TIME))

如果业务要求“每个请求拥有独立的日期地区”,标准库的进程级 locale 不适合作为请求级状态容器。


十四、生产交付中的消息目录治理

消息目录是运行时依赖,不是源代码注释。发布包至少应验证:

目标语言目录存在
domain.mo 文件存在
文件可以被 GNUTranslations 解析
关键 msgid 存在
复数条目完整
占位符集合一致

可以写一个启动检查:

from pathlib import Path
import gettext

REQUIRED_MESSAGES = [
    "Report",
    "There is %(num)d file",
]

def check_catalog(locale_dir: Path, language: str) -> None:
    translator = gettext.translation(
        "demo",
        localedir=locale_dir,
        languages=[language],
        fallback=False,
    )

    for message_id in REQUIRED_MESSAGES:
        translated = translator.gettext(message_id)
        if translated == message_id:
            raise RuntimeError(
                f"missing translation: language={language!r}, "
                f"msgid={message_id!r}"
            )

这个检查不能证明翻译质量,只能证明:

  • 文件可加载;
  • 消息能够被查询;
  • 查询结果不是源文。

对于允许部分翻译的产品,不应把“结果等于源文”一律视为错误,因为有些语言可能确实保留相同拼写。更稳妥的做法是基于目录工具提供的未翻译条目、占位符检查和人工审核流程进行校验。

1. 进程启动和热更新

翻译对象通常在进程启动时创建:

TRANSLATORS = {
    "zh_CN": get_translator("zh_CN"),
    "en": get_translator("en"),
}

这样可以避免每个请求重新读取目录。

如果发布新 .mo 后需要热更新,应明确处理:

新文件写入临时路径
    ↓
原子替换目标文件
    ↓
重新创建 translator
    ↓
切换应用持有的引用

不要在文件尚未完整写入时让工作线程加载 .mo。如果加载失败,应保留旧 translator,而不是把整个进程切换到不可用状态。

2. 缓存和并发

gettext.translation() 会缓存相同 .mo 文件对应的翻译数据;多个实例可能共享缓存中的实际数据,但 fallback 链由实例组织。(docs.python.org)

工程上可以把 translator 看作:

只读目录数据 + 实例级 fallback 关系

请求处理时读取一个已经准备好的 translator,通常比修改全局 _() 或全局 locale 更容易控制。

但不能因此推断所有自定义翻译类都是线程安全的。如果通过 class_= 注入自定义 Translations 实现,应自行保证其状态和缓存策略。


十五、规范保证、平台实现和工程选择

Python 标准库保证或明确提供的能力

  • gettext 支持 GNU .mo 消息目录;
  • GNUTranslations 负责解析 .mo
  • gettext() 支持普通消息;
  • ngettext() 支持目录定义的复数规则;
  • pgettext()npgettext() 支持上下文;
  • gettext.translation() 支持语言列表和 fallback;
  • locale.format_string()locale.currency() 支持基于当前 locale 的数字和货币格式化;
  • datetime.strftime() 支持按格式生成日期文本。

常见平台实现限制

  • locale 名称和可用 locale 依赖操作系统;
  • strftime() 的完整指令集合依赖平台 C 库;
  • setlocale() 在大多数系统上不是线程安全的;
  • LC_MESSAGES 在某些非 POSIX 系统上不可用;
  • locale.currency() 不能在 C locale 下使用;
  • 系统没有安装目标 locale 时,setlocale() 会抛出 locale.Error。(docs.python.org)

工程上必须自行决定的部分

  • 用户语言从 HTTP 头、账户设置还是命令行参数取得;
  • 是否允许缺少翻译时显示源文;
  • 是否在启动阶段拒绝缺少语言包;
  • 日期、数字和货币是否只支持进程级 locale;
  • 是否需要多用户并发使用不同地区格式;
  • 翻译目录如何审核、版本化、发布和回滚。

核心边界可以归纳为:

稳定数据:
    date.isoformat()
    datetime.isoformat()
    Decimal
    UTC 时间点
    msgid / 消息结构

用户展示:
    translator.gettext()
    translator.ngettext()
    translator.pgettext()
    locale-aware formatter

进程配置:
    setlocale()
    环境变量
    语言包路径

只要不把展示文本当成业务数据、不把请求语言写入进程级 locale、不把复数和句子拼接硬编码在 Python 中,gettextlocale、日期数字格式化、消息目录和回退就能保持清晰的职责边界。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。