Python 源码编码探测:写了 coding 注释,为什么 UTF-8 BOM 仍可能让文件报错
这是源码约定,不是猜任意文本编码
写源码检查工具时,直接用固定编码读取所有 py 文件,可能错过文件自己声明的编码;反过来,扫描任意一处 coding 注释也不符合解释器规则。tokenize.detect_encoding 针对 Python 源码入口检查开头标记,解决的是约定解析问题,不会统计全文字符来猜一份未知文档最可能使用什么编码。
下面在 Python 3.12.14 上运行四组自建字节样本,不读取项目文件,也不执行样本源码。全部输入通过 BytesIO 提供,方便看清开头几个字节的作用。把完整实验保存成 demo.py,用 python demo.py 运行;外层实验文件本身按 UTF-8 保存即可。
先探测,再从开头完整解码
前三组依次是不带声明的普通源码、第一行放解释器说明且第二行声明 Latin-1 的源码,以及带 UTF-8 BOM 的源码。每组既核对返回的编码名,也核对解码后的实际文字,避免仅凭一个名称就宣布读取正确。第四组让 BOM 与编码声明冲突,预期是明确拒绝。
detect_encoding 最多调用两次 readline,并返回已经读过的若干字节行,因此调用后原来的流位置可能前移。本例为了突出完整读取,用 seek(0) 回到开头再解码。实际处理非可定位流时,要正确保留已消耗内容;不要忽略这部分后只读取剩余字节,否则源码开头会凭空消失。
AI概念示意图:文件入口的两种编码标记在匹配通道汇合,冲突标记停在分叉处。图片只说明概念,不是运行截图。
import codecs
import io
import tokenize
cases = [
("default", b"value = 'ok'\n", "utf-8", "ok"),
("cookie", b"#!/usr/bin/env python3\n# coding: latin-1\nvalue = 'caf\xe9'\n",
"iso-8859-1", "caf\u00e9"),
("bom", codecs.BOM_UTF8 + "value = '\u4e91'\n".encode("utf-8"),
"utf-8-sig", "\u4e91"),
]
for label, raw, expected_encoding, expected_text in cases:
stream = io.BytesIO(raw)
encoding, consumed = tokenize.detect_encoding(stream.readline)
assert encoding == expected_encoding
assert consumed
stream.seek(0)
text = stream.read().decode(encoding)
assert expected_text in text
assert not text.startswith("\ufeff")
print(label + ":", encoding)
conflict = codecs.BOM_UTF8 + b"# coding: latin-1\nvalue = 1\n"
try:
tokenize.detect_encoding(io.BytesIO(conflict).readline)
except SyntaxError:
print("conflict: SyntaxError")
else:
raise AssertionError("conflicting declarations should fail")四行输出分别证明什么
输出是 default: utf-8、cookie: iso-8859-1、bom: utf-8-sig、conflict: SyntaxError。Latin-1 的返回名称经过规范化,不必与注释里的拼写逐字一致。带 BOM 的样本使用 utf-8-sig 解码,起始标记不会变成源码正文中的额外字符;实验特意用断言检查了这一点。
冲突样本不能靠先选一个编码继续读取来解释。UTF-8 BOM 已经表达了编码约定,另一个不相容声明会触发异常。把注释改成 UTF-8 与把文件真正重新编码也是两种动作,修改声明本身不会转换后面的字节。维护旧文件时,应先确认真实编码,再让标记与内容保持一致。
这里用异常类型作为稳定验收点,没有把完整报错措辞写死。不同维护版本的提示细节可能变化,但输入冲突应被拒绝这一要求不变,也更适合作为源码工具的自动检查条件。
使用范围和读取验收要一致
源码编码声明位于第一行或符合要求的第二行;第二行生效时,第一行必须满足仅有注释等前提。它不是可以放进任意函数体里的通用配置。对于实际磁盘源码,可以使用 tokenize.open 获得按规则解码的只读文本流,省去手工探测后回退位置的步骤;仍然应让读取异常保持可见。
探测成功只决定采用哪种解码方式,不证明全文所有字节合法,更不证明 Python 语法正确。后续完整读取仍可能发生解码异常,语法检查也需要另做。给源码工具增加回归样本时,可保留普通 UTF-8、兼容声明、带 BOM 与冲突这几类小文件,分别断言预期结果,避免把失败统统替换成乱码字符后继续处理。


