本节目标:看清三类「不是普通目录」的模块来源——跨目录合并的命名空间包、打包成 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__ | SourceFileLoader | NamespaceLoader |
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.mod | pkg.mod | pkg | 成功 |
规则是:__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
两个值得注意的点:
origin是字符串'frozen'、loader是FrozenImporter,说明它不来自磁盘;- 但
__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 |
| 命名空间包 | None | None | NamespaceLoader |
| 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 任何路径,也能判断模块从哪来」的正确姿势。
小结
- 自 3.3(PEP 420)起
__init__.py可选:不含它的目录会成为命名空间包,多个目录可合并成同一个包。 - 命名空间包的
__path__是_NamespacePath(非常规包的list),__file__/origin为None,__loader__是NamespaceLoader;且_NamespacePath会随搜索路径变化动态重算。 - 相对导入的「点」解析成
__package__;直接运行脚本时__name__被改成__main__、__package__为None,于是报attempted relative import with no known parent package,改用-m即可。 zipimport让sys.path支持.zip,__file__是「zip 路径 + 内部路径」的虚拟串,读包内数据要用importlib.resources。- 冻结模块
origin='frozen'、loader 为FrozenImporter,但__file__仍指向源码以便回溯;-X frozen_modules=off可退回源码加载。 - 命名空间包、zip 模块、冻结模块、内置模块最终都被统一成
ModuleSpec,但origin/__file__的语义各不相同,不可一律当真实文件路径。
下一章 描述符协议与属性查找
会从导入系统转向对象模型:当写下 obj.attr 时,CPython 究竟按什么顺序在实例字典、类字典与描述符之间挑出一个答案。
阅读导航:上一节:元路径钩子与自定义导入器 · 下一节:描述符协议与属性查找 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。