email.py 撞名标准库:smtplib 报错

发信脚本突然报 ModuleNotFoundError,根因是目录里新增了 email.py,运行时把标准库 email 包覆盖了。重命名同名脚本、清理 pycache 并规范模块命名后恢复。

问题背景

运维组有一台跳板机,/opt/ops/ 下放着一批 Python 脚本:备份、巡检、日报。日报脚本每天定时把 Excel 报表用 smtplib 发到管理层邮箱,跑了两年多,一直稳定。

上周同事写了一个发告警邮件的小工具,图省事直接命名为 email.py,放在同一个目录里,供其他脚本 import email 调用。当天下午的日报任务失败,第二天早上的巡检也没发出来。登录机器手动执行,报错一模一样;把脚本拷到 /tmp 下运行,又正常了。

故障现象

  • 日报脚本执行到发信步骤抛异常,退出码非零,cron 没有发出任何邮件。
  • 报错核心是 ModuleNotFoundError: No module named 'email.message'; 'email' is not a package。
  • 同一个脚本在 /tmp 目录下运行正常,在 /opt/ops/ 下必现。
  • Python 版本、依赖包、邮箱配置都没有变过;最近唯一的变更就是新增了 email.py。
  • 其他目录下的 Python 脚本发信正常,只有 /opt/ops/ 里的脚本受影响。
  • 报错栈指向 smtplib 的导入语句,看起来像是标准库坏了,很容易往“Python 环境损坏”方向排查。

排查过程

第一步读完整报错。异常栈里 smtplib.py 执行 import email.message,随后抛出“email 不是包”。标准库的 email 明明是一个包,email.message 是它的子模块,正常情况下不可能导入失败;报错说明 Python 找到的 email 不是标准库那个。

第二步看 Python 实际导入了什么。在 /opt/ops/ 目录下执行 python -c "import email; print(email.__file__)",输出是 /opt/ops/email.py,不是 Python 安装目录下的 email/__init__.py。也就是说,本地这个同名文件把标准库的 email 包整个顶掉了。

第三步确认导入顺序。python -c "import sys; print(sys.path[:3])" 显示 sys.path 的第一项是脚本所在目录(或空字符串代表当前目录),标准库路径排在后面。Python 导入模块时按 sys.path 顺序查找,本地目录优先,所以 import email 先命中了 /opt/ops/email.py。这里还有一个容易混淆的点:python script.py 时 sys.path[0] 是脚本所在目录,python -m module 时是当前目录,交互式解释器则是空字符串;两种方式都可能把本地文件顶到标准库前面。另外,PYTHONPATH 里的路径也会插在标准库之前,排查时要一并看。

第四步解释报错为什么指向标准库。smtplib 内部要导入 email.message、email.utils 这些子模块;本地的 email.py 是一个普通模块文件,不是包,不能有子模块,于是 import email.message 失败,报“email is not a package”。错误信息里出现的是 smtplib 和 email,真正的“凶手”是那个同名的本地文件,所以第一眼很难联想到它。

第五步验证因果。把 email.py 重命名为 mail_helper.py,同时删掉目录下的 __pycache__(避免残留的 email.cpython-*.pyc 干扰),再执行 python -c "import email; print(email.__file__)",输出回到标准库路径;日报脚本立即恢复正常。把文件改回 email.py,故障复现,变量唯一。

第六步排查同类隐患。扫描 /opt/ops/ 下所有文件名,和 Python 标准库模块名比对,确认没有 logging.py、json.py、types.py、queue.py 这类同名文件;这几个名字一旦撞上,会分别把日志、JSON、类型、队列相关模块顶掉,症状各不相同,但根因一样。

解决方案

  1. 重命名同名文件:把 email.py 改成 mail_helper.py 之类的业务名,确保不与标准库顶层模块重名;改完删除 __pycache__,再验证 import email 指向标准库。
  2. 改成包结构:把运维脚本收进一个包,例如 ops/ 目录加 __init__.py,工具模块用 ops.mail_helper 导入;包内文件名不再参与顶层模块名解析,冲突面小很多。
  3. 加自动检查:在 CI 或 pre-commit 里扫描仓库中的 .py 文件名,与 sys.stdlib_module_names 求交集,命中就失败;也可以用 Ruff 的 A005(stdlib-module-shadowing)规则兜底。
  4. 统一命名规范:工具模块加业务前缀(ops_mail.py、ops_log.py),禁止用 email、logging、json、types、queue、select、socket 这类通用名做文件名。
  5. 排障手册补一条:遇到标准库导入异常,先执行 python -c "import 模块名; print(模块名.__file__)" 看实际加载路径,再 print(sys.path[:3]) 看搜索顺序,不要一上来就重装 Python。
  6. 验证:改完后跑一遍日报、巡检、备份三个脚本,确认发信、日志、JSON 解析都正常;再把检查脚本接入发布流程。

根因分析

直接根因是 /opt/ops/email.py 与标准库 email 包同名,且脚本目录在 sys.path 中最先被搜索,导致 import email 加载了本地文件;smtplib 需要 email.message 子模块,本地模块不是包,导入失败。

更深一层,是 Python 的导入机制把“文件名”直接当成了“模块名”。在大多数语言里,一个业务文件叫 email.py 不会影响系统库;在 Python 里,只要它和脚本在同一目录,就会参与顶层模块解析。命名在这里不是风格问题,而是命名空间问题。

还有一层是错误信息的误导性。报错栈指向 smtplib 和标准库 email,没有任何一行提到“本地有个 email.py”;如果不知道用 __file__ 和 sys.path 去验证实际加载路径,很容易把时间花在重装依赖、升级 Python 上。这个故障的排查成本,主要来自“看起来不像自己写的问题”。

预防措施

  • 建立文件名黑名单:标准库顶层模块名禁止用作脚本/工具文件名;新文件提交前用脚本比对 sys.stdlib_module_names。
  • CI 增加 lint 规则:Ruff A005 或自定义检查,发现 stdlib 同名文件直接失败。
  • 运维脚本统一包结构,工具模块加业务前缀;不要把所有脚本平铺在一个目录里靠通用名互相 import。
  • 排障手册固化“导入路径三连”:模块.__file__、sys.path[:3]、python -v 导入日志;先确认加载的是谁,再谈修复。
  • 发布流程里加一条冒烟测试:用干净环境执行一次核心脚本,确认标准库和第三方依赖没有被本地文件遮蔽。

总结

在 Python 里,文件名就是模块名,脚本目录就是搜索路径的第一站。给工具起名 email.py,等于在自家门口放了一个和邮局同名的信箱,所有寄往“email”的信都会先落到这里。这个故障不需要改代码逻辑、不需要重装环境,只需要把文件改名;难的是从“标准库坏了”的直觉里跳出来,先问一句:Python 实际加载的,到底是哪个文件?

延伸阅读

使用 Hugo 构建
主题 Stack 由 Jimmy 设计