《Python高级编程》7.3 命名空间包、zip 导入与冻结模块

实测三类非普通目录的模块来源:PEP 420 命名空间包的 _NamespacePath 动态 __path__、相对导入靠 __package__ 解析与「no known parent package」报错复现、zipimport 从压缩包加载,以及冻结模块的 origin='frozen' 与 __file__ 归属。

本节目标:看清三类「不是普通目录」的模块来源——跨目录合并的命名空间包、打包成 zip 的库、编译进解释器的冻结模块——并复现相对导入失败的真正原因。
适用版本:Python 3.12+(实测 3.14.6)

7.3 命名空间包、zip 导入与冻结模块

7.1、7.2 讲的都是磁盘上的 .py。但 import 面对的模块来源远不止于此:一个包可以横跨多个安装目录、可以塞进一个 .zip、甚至根本不以文件形式存在(冻结模块)。这一节把它们逐个拆开。

一、包从哪来:__init__.py 不再是硬门槛

在 Python 3.2 及之前,「包 = 含 __init__.py 的目录」是铁律。PEP 420 之后,一个不含 __init__.py 的目录也能被导入为包,称为命名空间包(namespace package)。两种包在查找阶段的分叉点是:FileFinder 若在某目录下只找到子目录而找不到 __init__.py,会记录一个「可能是命名空间包的一部分」的候选;遍历完整个 sys.path 后,若没有任何常规包命中,就把所有候选合并成一个命名空间包。

因此同一个名字可能对应多个目录。构造两个目录各自贡献 acme 的一部分:

nstest/part1/acme/a.py     # 无 __init__.py
nstest/part2/acme/b.py     # 无 __init__.py

把两个目录都放进 sys.path,import acme 后 acme.a 与 acme.b 都能导入,且 acme.__path__ 里两个目录都在——这就是「把分散在不同位置的目录合并成一个逻辑包」。典型用途是插件生态:不同团队各自发布 mycompany.plugins.xxx,装到同一个虚拟环境后自动合并。

一个重要的规则是:常规包永远优先于命名空间包,与 sys.path 顺序无关。构造一个「同名目录,一处有 __init__.py、一处没有」的场景,并让命名空间候选目录排在更前面:

sys.path.insert(0, base + "/regdir")   # 含 mixpkg/__init__.py
sys.path.insert(0, base + "/nsdir")    # 只有 mixpkg/sub/,无 __init__.py
import mixpkg
print(getattr(mixpkg, "__file__", None), "| is regular:", hasattr(mixpkg, "REGULAR"))
/.../regdir/mixpkg/__init__.py | is regular: True

即便命名空间候选目录优先级更高,最终胜出的仍是常规包。原因是 PathFinder 会把命名空间候选暂存下来,只有当整条 sys.path 都找不到常规包时,才把候选合并成命名空间包。这条规则避免了「装了一个带 __init__.py 的包,却和别处的同名命名空间包意外合并」的事故。

二、__path__ 实测:list vs _NamespacePath

命名空间包与常规包最大的实现差异在 __path__ 的类型上。实测对比:

import acme, regpkg
print("namespace:", type(acme.__path__).__name__,
      "| __file__:", acme.__file__, "| origin:", acme.__spec__.origin)
print("regular  :", type(regpkg.__path__).__name__,
      "| __file__:", regpkg.__file__, "| origin:", regpkg.__spec__.origin)
namespace: _NamespacePath | __file__: None | origin: None
regular  : list           | __file__: /.../regpkg/__init__.py | origin: /.../regpkg/__init__.py
属性常规包命名空间包
__path__ 类型list_NamespacePath
__file____init__.py 路径None
__spec__.origin__init__.py 路径None
__loader__SourceFileLoaderNamespaceLoader
submodule_search_locations静态 list_NamespacePath

命名空间包没有 __init__.py,因此没有「模块代码」,origin/__file__ 都是 None,__loader__ 是一个只负责给出 __path__ 的 NamespaceLoader。用 hasattr(pkg, "__path__") 判断「是不是包」对两者都成立,但用 __file__ 判断会踩空。

三、_NamespacePath 是动态的

常规包的 __path__ 是一个固定 list,导入时算一次就不再变。命名空间包的 _NamespacePath 则会在每次访问时惰性重算:只要父目录的路径来源(sys.path 或上层包的 __path__)变了,它能感知到。实测:

import acme                       # 此时只找到 part1
print(list(acme.__path__))
sys.path.insert(0, part2)         # 追加第二个贡献目录
import importlib; importlib.invalidate_caches()
print(list(acme.__path__))        # 无需重新 import,路径已更新
['/private/tmp/.../part1/acme']
['/private/tmp/.../part2/acme', '/private/tmp/.../part1/acme']

这个「动态重算」是 PEP 420 刻意的设计:命名空间包要能反映运行期变化的搜索路径。代价是每次访问 __path__ 都可能有轻微开销,且必须在路径变化后 invalidate_caches(),否则缓存不会失效。

四、相对导入靠 __package__ 解析

相对导入(from . import x)的「点」不是一个路径,而是一个包名。CPython 把 . 解析成 __package__:. 指当前包,.. 指上一级。而 __package__ 的值取决于模块是怎么被加载的,与 __name__ 有关:

加载方式__name____package__相对导入
python pkg/mod.py(直接运行)__main__None失败
python -m pkg.mod__main__pkg成功
import pkg.modpkg.modpkg成功

规则是:__package__ = __name__ 去掉最后一段(顶层模块则为 '')。但被当作脚本直接运行时,__name__ 被改写成 __main__,无法反推它属于哪个包,于是 __package__ 为 None,相对导入失去参照。

五、复现 attempted relative import with no known parent package

# mypkg/mod.py
print("__name__    =", __name__)
print("__package__ =", repr(__package__))
from . import sibling

直接运行:

$ python mypkg/mod.py
__name__    = __main__
__package__ = None
ImportError: attempted relative import with no known parent package

用 -m 运行同一文件:

$ python -m mypkg.mod
__name__    = __main__
__package__ = 'mypkg'
sibling.WHO = sibling

注意两次的 __name__ 都是 __main__,区别只在 __package__:-m 会先把包结构导入进来,再以包内身份执行目标模块,于是 __package__ 被正确设为 'mypkg'。这解释了一条常见工程规则:含相对导入的模块只能用 -m 或 import 触发,不能用文件路径直接运行。

六、zip 导入:把包打进压缩包

sys.path 里可以直接放一个 .zip 文件,zipimporter(默认 sys.path_hooks 的第一个钩子)会把它当作只读目录来查。构造一个含 zpkg 的 zip:

python -m zipfile -c bundle.zip zpkg
import sys
sys.path.insert(0, "/tmp/.../bundle.zip")
import zpkg, zpkg.leaf
print(zpkg.__loader__.__class__.__name__, "|", zpkg.leaf.__file__)
zipimporter | /tmp/.../bundle.zip/zpkg/leaf.py

zipimporter 的 find_spec 返回的 spec 里,origin 是「zip 路径 + 内部路径」拼成的字符串,__file__ 也长这样——它不是一个真实存在的文件路径,所以任何 open(module.__file__) 的代码都会失败。要读取包内的数据文件,必须改用 importlib.resources(它能识别 zip 这类非文件系统的资源加载器):

from importlib import resources
print(sorted(p.name for p in resources.files("zpkg").iterdir()))
print(resources.files("zpkg").joinpath("leaf.py").read_text().splitlines()[0])
['__init__.py', 'leaf.py']
def f(): return "zip leaf"

zip 导入在单文件分发(zipapp、.pex)里很常见,代价是每次导入要解压。zipimport 能读取 zip 内预先存在的 .pyc,但无法在运行时把编译结果写回只读的 zip;所以若分发时没有预置 .pyc,模块每次导入都要重新编译——这也是 zipapp 场景下导入偏慢的原因。

七、冻结模块:编译进解释器

最后一类来源最特别——模块的字节码在构建解释器时就被静态编译进去,启动时直接执行,跳过「读文件 → 反序列化 → 建代码对象」的全部步骤。os、abc、stat 以及导入系统自身(importlib._bootstrap)都是冻结模块。实测它们的属性:

import sys
os = sys.modules["os"]
print(os.__spec__.origin, "|", os.__spec__.loader.__name__)
print(os.__file__)
frozen | FrozenImporter
/opt/homebrew/.../lib/python3.14/os.py

两个值得注意的点:

  1. origin 是字符串 'frozen'、loader 是 FrozenImporter,说明它不来自磁盘;
  2. 但 __file__ 依然指向原始 .py 源码路径——这是为了回溯(traceback)与调试器能显示正确的行号,不要据此以为模块是从那个文件加载的。

用 -X frozen_modules=off 可以关掉冻结,让这些模块退回源码加载,对比很明显:

$ python -X frozen_modules=off check.py
os: origin='/opt/homebrew/.../lib/python3.14/os.py' loader=SourceFileLoader

冻结的主要收益是启动更快(省掉编译与反序列化)、更抗篡改(标准库字节码不在可写磁盘上)。这也是 3.11 以来「Frozen imports / Static code objects」优化的核心手段。

八、三种来源的统一视角

回头看,7.1 的 ModuleSpec 就是统一它们的抽象:

来源origin__file__loader
常规包/模块文件路径文件路径SourceFileLoader
命名空间包NoneNoneNamespaceLoader
zip 内模块zip!/内部路径同 origin(非真实文件)zipimporter
冻结模块'frozen'原始 .py 路径FrozenImporter
内置模块'built-in'无BuiltinImporter

任何 origin/__file__ 都不能想当然地当成「可 open 的真实文件」——这正是许多「模块打包后路径读不到」问题的根因。

如果代码里确实需要根据来源做不同处理(比如打包后改走 importlib.resources),可以写一个小的分类函数,直接读 __spec__:

def classify(mod):
    spec = getattr(mod, "__spec__", None)
    if spec is None:
        return "legacy"
    origin = spec.origin
    if origin in ("built-in", "frozen"):
        return origin
    if origin is None:
        return "namespace"          # 无 origin 且是包
    if ".zip" in origin:
        return "zip"
    return "file"

import sys, json, email          # 确保它们已在 sys.modules 中
for name in ("sys", "os", "json", "email"):
    print(f"{name:8} -> {classify(sys.modules[name])}")
sys      -> built-in
os       -> frozen
json     -> file
email    -> file

这就是「不 open 任何路径,也能判断模块从哪来」的正确姿势。

小结

  1. 自 3.3(PEP 420)起 __init__.py 可选:不含它的目录会成为命名空间包,多个目录可合并成同一个包。
  2. 命名空间包的 __path__ 是 _NamespacePath(非常规包的 list),__file__/origin 为 None,__loader__ 是 NamespaceLoader;且 _NamespacePath 会随搜索路径变化动态重算。
  3. 相对导入的「点」解析成 __package__;直接运行脚本时 __name__ 被改成 __main__、__package__ 为 None,于是报 attempted relative import with no known parent package,改用 -m 即可。
  4. zipimport 让 sys.path 支持 .zip,__file__ 是「zip 路径 + 内部路径」的虚拟串,读包内数据要用 importlib.resources。
  5. 冻结模块 origin='frozen'、loader 为 FrozenImporter,但 __file__ 仍指向源码以便回溯;-X frozen_modules=off 可退回源码加载。
  6. 命名空间包、zip 模块、冻结模块、内置模块最终都被统一成 ModuleSpec,但 origin/__file__ 的语义各不相同,不可一律当真实文件路径。

下一章 描述符协议与属性查找 会从导入系统转向对象模型:当写下 obj.attr 时,CPython 究竟按什么顺序在实例字典、类字典与描述符之间挑出一个答案。

阅读导航:上一节:元路径钩子与自定义导入器 · 下一节:描述符协议与属性查找 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时