Python 基础体系 · 第 106/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 国际化:gettext、Locale、日期数字、消息目录和回退
国际化通常写作 I18N,因为英文单词 internationalization 从首字母 I 到末字母 N 之间有 18 个字母。它指的是:在不为每种语言重写业务代码的前提下,让程序具备支持多种语言和地区规则的结构。
本地化通常写作 L10N,指的是针对某个具体语言、地区和文化习惯进行适配,例如:
- 把
Delete翻译成“删除”; - 把
1,234.56显示为1.234,56; - 把日期显示为
2026/09/01、01/09/2026或2026年9月1日; - 使用不同的货币符号、货币小数位和正负号位置;
- 针对同一个词在不同上下文中选择不同译法。
Python 标准库把这些问题拆成了不同机制:
| 问题 | 主要机制 |
|---|---|
| 用户可见消息的翻译 | gettext、.po、.mo |
| 当前进程的语言和地区约定 | locale |
| 日期和时间对象 | datetime、time.strftime() |
| 数字和货币格式 | locale.format_string()、locale.currency() |
| 多种语言同时存在 | gettext.translation() 返回的翻译对象 |
| 缺少翻译时继续运行 | NullTranslations、add_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. 语言不是地区
en、zh、fr 是语言代码;en_US、en_GB、zh_CN、zh_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)
→ "文件未找到"
源码中保留源语言文本有两个实际原因:
- 没有翻译时可以直接显示源文;
- 消息标识稳定、可读,提取工具可以从源码中发现它。
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.py 和 msgfmt.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"))
这种方式适合语言在整个进程生命周期内固定的单语言应用,例如命令行程序启动时根据环境变量确定语言。
但它有两个边界:
- 全局 domain 是进程范围状态;
- 安装到
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"))
这里 zh 和 fr 是两个独立的翻译对象:
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() 的查找过程大致是:
- 读取语言列表;
- 对语言名进行扩展和规范化;
- 按顺序构造
.mo路径; - 返回第一个存在的文件。
没有显式传入 languages 时,Python 会参考 LANGUAGE、LC_ALL、LC_MESSAGES 和 LANG 等环境变量;环境变量中的语言可以用冒号分隔。(docs.python.org)
例如:
LANGUAGE=zh_CN:zh:en python app.py
其语义不是“把三种语言同时显示”,而是“按这个顺序寻找可用翻译”。
2. language、domain 和 localedir 任何一个错了都会导致原文回退
假设实际文件是:
/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 分别为 1、2、3 时,消息标识也不同,翻译目录无法为它建立稳定条目。
占位符还必须在所有语言版本中语义一致:
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 的规则输出。这正是 gettext 和 locale 两套状态彼此独立的表现。
十一、消息目录的回退链如何工作
假设配置为:
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 抛出 OSError,fallback=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()不能在Clocale 下使用;- 系统没有安装目标 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 中,gettext、locale、日期数字格式化、消息目录和回退就能保持清晰的职责边界。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 定时任务:时间语义、调度器、重复执行、锁和补偿
- 下一篇:Python 可观测性:日志、指标、Trace、Context 和故障定位
- 延伸:Python 字符串、bytes 与 Unicode:编码、解码和文本边界
- 延伸:Python 生产交付:进程模型、容量、配置、迁移、灰度和回滚
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论