Chapter 41: Import Machinery
导入机制要回答的核心问题是:给定一个模块名,Python 如何定位加载计划、创建模块对象、执行模块代码,并把结果写回运行时状态。本章把 import acme.plugins.image 作为贯穿材料,追踪它从字符串名字进入 sys.meta_path、ModuleSpec、loader、sys.modules 和父包属性绑定的完整路径。
读完本章后,读者应能定位一个导入问题处在搜索阶段、加载阶段、初始化阶段还是缓存阶段;能判断 finder、loader、ModuleSpec、sys.path_hooks 和 namespace package 各自改变了哪一段路径;也能解释延迟导入为什么会改变错误暴露时机和模块副作用发生时机。
本章以 CPython 3.14 公开文档中的 import system 和 importlib 模型为主,语义判断参考 Python import system 文档 与 importlib 文档。涉及 namespace package 时,按 PEP 420 的隐式 namespace package 模型理解;涉及透明 lazy imports 时,按 PEP 690 的 rejected 状态标注边界。
贯穿材料使用如下目录状态。两个路径都在 sys.path 中,acme 和 plugins 目录下都没有 __init__.py,因此它们会按 namespace package 处理;image.py 是最终要执行的普通源文件模块。
# sys.path 中存在两个入口:
# /srv/app
# /opt/extensions
# /srv/app/acme/report.py
# /opt/extensions/acme/plugins/image.py
import acme.plugins.image
这段代码表面上只有一条 import 语句,运行时会拆成三个名字的处理:acme、acme.plugins、acme.plugins.image。前两个名字负责形成 package 搜索范围,最后一个名字才对应源文件执行。后续每一节都会回到这条路径,说明每个 import machinery 角色在何处接手。
41.1 Finder, loader, and ModuleSpec
Finder、loader 和 ModuleSpec 是 import machinery 的三段式分工。Finder 负责回答“这个 fullname 能否在当前搜索范围内找到”,loader 负责回答“找到后如何创建或执行模块”,ModuleSpec 负责把 finder 的发现结果交给加载阶段。这个分工从 Python 3.4 的 PEP 451 模型开始成为现代导入协议的核心形状。
在贯穿材料中,acme.plugins.image 是 fullname,也就是导入系统使用的完整模块名。处理顶层 acme 时,finder 收到的 path 参数是 None,因为顶层模块从 sys.path 开始搜索。处理 acme.plugins 时,path 参数来自 acme.__path__。处理 acme.plugins.image 时,path 参数来自 acme.plugins.__path__。同一个 fullname 在不同 path 上搜索,结果会不同,因此 finder 的输入必须同时看 fullname 和 path。
Finder 的输出通常是 ModuleSpec。这个对象不是模块本身,它是加载计划。一个 spec 至少包含 name 和 loader,并可能包含 origin、cached、submodule_search_locations、loader_state 等字段。name 说明要加载哪个模块,loader 说明由谁执行加载,origin 说明来源位置,submodule_search_locations 说明这个模块是否作为 package 继续提供子模块搜索范围。
可以用 importlib.util.find_spec() 观察这条路径的一部分。这个函数适合回答“按当前导入配置能否找到目标模块”,但对带点号的子模块名,它可能导入父包以获得父包的 __path__。
import importlib.util
spec = importlib.util.find_spec("acme.plugins.image")
print(spec.name)
print(type(spec.loader).__name__)
print(spec.origin)
print(spec.submodule_search_locations)
对贯穿材料来说,acme.plugins.image 的 spec 通常会显示一个源文件 loader,例如 SourceFileLoader,origin 指向 /opt/extensions/acme/plugins/image.py,submodule_search_locations 为 None,表示它是普通模块。对 acme 或 acme.plugins 这类 namespace package,origin 通常为 None,submodule_search_locations 会包含一个用于继续搜索子模块的位置集合。
Loader 接手 spec 后执行加载。现代 loader 的关键方法是 create_module(spec) 和 exec_module(module)。前者允许 loader 自己创建模块对象;返回 None 时,import machinery 会创建普通 ModuleType 对象。后者负责把模块代码执行到 module.__dict__ 中。源文件 loader 会编译并执行 .py 文件,扩展模块 loader 会进入扩展模块初始化路径,namespace package 的 loader 则主要提供包对象的导入属性。
这三个对象的工程判断顺序很稳定。先看 finder 是否返回 spec,再看 spec 的 loader 和 origin,再看 submodule_search_locations 是否表示 package,最后看 loader 的执行阶段是否成功。导入异常、模块来源异常、namespace package 合并异常和延迟加载异常,都可以先放到这条顺序里定位。
41.2 Meta path and path hook
sys.meta_path 和 sys.path_hooks 解决两个不同层级的搜索问题。sys.meta_path 是导入请求进入搜索协议后的第一层 finder 列表;sys.path_hooks 是 PathFinder 在处理某个路径入口时,用来把路径字符串转换成 path entry finder 的工厂列表。前者决定“谁有资格接管 fullname”,后者决定“某个路径入口由哪种 finder 搜索”。
一次普通导入先查 sys.modules。目标名已经存在时,导入系统直接使用缓存中的模块对象。目标名缺失时,导入系统遍历 sys.meta_path。每个 meta path finder 都会收到 find_spec(fullname, path, target) 调用。fullname 是完整模块名,path 对顶层模块为 None,对子模块为父包的 __path__,target 主要在 reload 场景中出现。
CPython 默认配置中,meta path 通常包含处理内建模块的 finder、处理 frozen 模块的 finder,以及处理路径搜索的 PathFinder。PathFinder 本身也是 meta path finder,但它的策略是继续进入 sys.path 或 package 的 __path__。这解释了一个常见现象:自定义 meta path finder 放在 PathFinder 前面时,可以抢先处理某些模块名;自定义 path hook 只能影响被 PathFinder 遍历到的路径入口。
对贯穿材料,导入 acme 时,PathFinder 会遍历 sys.path 中的 /srv/app 和 /opt/extensions。它会发现两个入口下都有 acme 目录,并且这些目录没有 __init__.py。这会形成 namespace package 的 portions,导入系统把多个目录合成同一个逻辑包名。随后处理 acme.plugins 时,搜索范围收窄到 acme.__path__ 中的 portions;处理 acme.plugins.image 时,搜索范围再收窄到 acme.plugins.__path__。
sys.path_hooks 在每个路径入口第一次被处理时介入。PathFinder 会先查 sys.path_importer_cache。缓存命中时,直接复用对应的 path entry finder。缓存缺失时,它按顺序调用 sys.path_hooks 中的 hook。文件系统目录通常由 FileFinder.path_hook(...) 接管,zip 文件由 zip import 相关 finder 接管。找到 finder 后,结果写入 sys.path_importer_cache,后续同一路径入口的搜索会复用它。
下面的图把这两层搜索关系压缩成一条可追踪路径。图中没有展开 sys.modules 的所有缓存细节,重点是区分 meta path 和 path hook 的边界。
当需要扩展导入系统时,选择入口要按控制范围判断。虚拟模块、加密模块、远程模块、插件注册表这类想接管 fullname 解释规则的场景,通常放在 meta path finder。新的路径载体,例如某种目录索引、归档格式或资源定位方式,通常放在 path hook,让它只影响特定 path entry。把这两类扩展混在一起,会让导入优先级、缓存失效和安全边界都变得难以判断。
41.3 importlib and namespace package
importlib 是 Python 暴露 import machinery 的标准库接口。它提供 import_module()、find_spec()、module_from_spec()、invalidate_caches()、loader ABC、machinery 类以及若干工具函数。对工程代码来说,importlib 的价值在于把导入从语法动作变成可观察、可组合、可测试的运行时步骤。
importlib.import_module("acme.plugins.image") 会返回最终的 acme.plugins.image 模块对象。相比之下,直接调用内置 __import__() 时,返回值会受调用参数影响,常见 import acme.plugins.image 语义还要配合名字绑定规则理解。工程代码需要动态导入最终模块时,优先使用 importlib.import_module(),这样调用方拿到的对象更贴近目标 fullname。
Namespace package 是 import machinery 自动构造 package 搜索范围的能力。根据 PEP 420,多个目录可以共同贡献同一个 package 的 portions,而每个 portion 都可以继续提供子模块。贯穿材料中,/srv/app/acme 和 /opt/extensions/acme 都贡献 acme。导入系统创建 acme 模块对象时,不执行 acme/__init__.py,因为这个文件不存在;它设置 acme.__path__,让后续导入 acme.plugins 时继续在所有 portions 中搜索。
这会带来一个清晰的判断:普通 package 的初始化入口是 __init__.py,namespace package 的核心状态是 __path__。普通 package 可以在初始化时写入名字、注册插件或修改包状态;namespace package 更适合把分散目录合成一个逻辑包名。需要包级副作用、包级常量或显式初始化顺序时,普通 package 更直接;需要多个发行包共享同一顶层命名空间时,namespace package 更匹配。
Namespace package 的 __path__ 不是普通固定列表的语义。官方文档把它描述为可在父路径变化后重新搜索 portions 的对象。工程上这意味着:安装新插件、临时修改 sys.path、运行时生成模块文件之后,导入系统可能受到 finder 缓存和目录 stat 粒度影响。动态创建文件后调用 importlib.invalidate_caches(),可以让支持缓存失效的 finder 清理已记住的目录状态。
下面的代码展示一种受控的动态导入写法。它先找 spec,再创建模块,再写入 sys.modules,最后交给 loader 执行。这个写法接近 importlib 文档中的示例,用来说明 import machinery 的分层;普通业务代码通常直接调用 importlib.import_module()。
import importlib.util
import sys
module_name = "acme.plugins.image"
spec = importlib.util.find_spec(module_name)
if spec is None:
raise ModuleNotFoundError(module_name)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
这段代码的关键点是 module_from_spec(spec) 会按 spec 初始化尽量多的导入相关属性,sys.modules[module_name] = module 要发生在执行模块代码之前,exec_module(module) 才是模块顶层代码运行的位置。真实 import machinery 还会处理锁、异常回滚、父包属性绑定和已有缓存命中等细节,因此这段代码适合解释路径,不能代替完整导入系统。
41.4 Module initialization and lazy import
模块初始化是把加载计划转成运行时对象状态的阶段。对于普通源文件模块,导入系统会创建 module 对象,设置导入相关属性,把模块对象放入 sys.modules,然后调用 loader 的 exec_module(module) 执行顶层代码。执行完成后,模块的全局 namespace 已经填入函数、类、常量和导入副作用。
sys.modules 的写入时机影响循环导入。模块代码执行前先进入 sys.modules,可以让递归导入同名模块时看到同一个 module 对象,从而阻止无限递归和重复加载。这个设计也会让循环导入暴露半初始化状态:另一个模块可能在目标模块顶层代码完成前,读到已经存在但名字尚未写全的 module 对象。
加载失败时,导入系统会清理失败模块的缓存项。官方 import system 文档的近似伪代码显示:exec_module() 抛出异常后,导入系统尝试删除 sys.modules[spec.name],然后继续抛出原异常。已经成功加载的旁路模块保留在缓存中。这条规则能解释“失败导入后部分依赖已经留在进程里”的现象,也能解释下一次导入为什么会重新搜索失败模块。
模块属性也在初始化阶段建立。module.__spec__ 指向 spec,module.__loader__ 指向 loader,module.__package__ 表示包上下文,module.__file__ 和 module.__cached__ 取决于来源类型,package 还会有 __path__。这些属性是排查导入来源、包边界和重载问题的主要证据。__spec__.origin 与 __file__ 通常接近,但文档明确说明二者没有自动同步关系;运行时修改其中一个,不会自动更新另一个。
子模块导入完成后,父包 namespace 会出现对应属性。导入 acme.plugins.image 后,sys.modules 中应有 acme、acme.plugins、acme.plugins.image 三个键;同时 acme 上有 plugins 属性,acme.plugins 上有 image 属性。这个父子绑定规则让 acme.plugins.image 既能通过 sys.modules 定位,也能通过包属性链访问。
Lazy import 把“找到模块”和“执行模块”之间的时间距离拉开。标准库提供 importlib.util.LazyLoader,它包装已有 loader,把模块执行推迟到第一次属性访问。这个机制适合启动时间敏感、某些重型依赖在常见路径中很少被用到的应用,但它会让导入错误、顶层副作用和循环导入症状推迟到使用点出现。
LazyLoader 有明确限制。它要求底层 loader 支持 exec_module();loader 的 create_module() 返回值必须允许导入系统调整模块对象类型;模块执行时替换 sys.modules 中对象的模式无法安全支持。PEP 690 曾提出解释器级透明 lazy imports,但该 PEP 状态为 Rejected;因此在常规 CPython 中,顶层 import 仍按 eager 模型执行,透明延迟导入需要显式工具或框架约定。
对贯穿材料,如果 acme.plugins.image 使用 lazy loader 包装,import acme.plugins.image 之后可以先得到 module 对象和父包属性绑定,但 image.py 的顶层代码可能尚未执行。第一次读取 acme.plugins.image.SOME_NAME 时,loader 才执行模块代码。排查这类问题时,时间线要从“导入语句执行完成”改成“第一次属性访问完成”。
41.5 Import machinery checklist
排查 import machinery 问题时,稳定顺序是从名字到缓存、从搜索到 spec、从 spec 到 loader、从 loader 到 module state。这个顺序能把同一条 import 语句拆成可观察证据,而不把所有错误都归为路径错误。
第一步,确认 fullname。import acme.plugins.image 的加载路径会依次处理 acme、acme.plugins、acme.plugins.image,最后才进入 image.py 对应的源文件模块。任意父包缺失、父包不是 package、父包 __path__ 异常,都会让最终模块加载失败。
第二步,检查 sys.modules。目标键已经存在时,导入语句会直接使用已有对象。键存在且值为 None 时,导入会触发 ModuleNotFoundError。键缺失时,才进入 finder 搜索。循环导入、测试隔离、reload 和手动删除缓存都要先看这一步。
第三步,检查 sys.meta_path。自定义 finder 的顺序决定它是否先于默认 PathFinder 接管 fullname。一个 finder 返回 spec 后,后续 finder 通常没有机会处理同一个请求。安全沙箱、插件系统、打包器和运行时补丁经常在这里改变默认行为。
第四步,检查 path 输入。顶层模块搜索 sys.path,子模块搜索父包的 __path__。对 acme.plugins.image 来说,acme.__path__ 决定 plugins 能否被找到,acme.plugins.__path__ 决定 image 能否被找到。namespace package 问题通常集中在这一层。
第五步,检查 path entry finder 和缓存。PathFinder 会使用 sys.path_importer_cache 和 sys.path_hooks 把路径入口转成具体 finder。动态创建文件、修改路径、替换目录结构后,调用 importlib.invalidate_caches() 可以让支持该协议的 finder 清理缓存。文件刚生成后立刻导入失败时,这一步尤其值得检查。
第六步,检查 ModuleSpec。spec.name 要等于 fullname,spec.loader 决定执行方式,spec.origin 说明来源位置,spec.submodule_search_locations 决定它是否是 package。普通源文件模块、namespace package、built-in module、frozen module 和 extension module 都会在这些字段上呈现不同形状。
第七步,检查加载阶段。create_module(spec) 决定模块对象创建,module_from_spec(spec) 会初始化导入相关属性,exec_module(module) 执行顶层代码。异常发生在 finder 阶段、模块对象创建阶段还是顶层代码执行阶段,修复方向完全不同。
第八步,检查父包属性绑定和可观察状态。导入子模块成功后,父包上应出现子模块属性。若 sys.modules 中存在子模块,但父包属性缺失,说明导入流程被手写加载器、异常中断或非标准操作破坏。若属性存在但模块内容不完整,重点转向循环导入、lazy loader 或顶层执行异常。
这套顺序可以直接套回贯穿材料:先确认 acme.plugins.image 的三个 fullname 阶段,再确认 acme 和 acme.plugins 是否作为 namespace package 形成 __path__,再看 image 的 spec 是否指向 /opt/extensions/acme/plugins/image.py,最后看 SourceFileLoader.exec_module() 是否已经执行并写入模块 namespace。
最小自检任务
观察下面的目录和代码,判断导入完成后哪些对象会出现,哪些代码会执行,以及如果动态新增 video.py 后立刻导入失败,应优先检查哪一层。
# sys.path 中存在两个入口:
# /srv/app
# /opt/extensions
# /srv/app/acme/report.py
# /opt/extensions/acme/plugins/image.py
# /opt/extensions/acme/plugins/video.py # 这个文件由程序运行时动态生成
# acme 和 plugins 目录下都没有 __init__.py
import acme.plugins.image
要求回答四点:acme 和 acme.plugins 属于哪类 package;image.py 的执行发生在哪个 loader 阶段;sys.modules 和父包属性链中应出现哪些状态;动态生成 video.py 后导入失败时,应该优先检查哪类缓存。
答案要点
acme 和 acme.plugins 会按 namespace package 处理。它们没有 __init__.py,导入系统会通过 portions 构造 package 模块对象,并设置用于继续搜索子模块的 __path__。它们本身没有包初始化文件可执行,因此不会运行 acme/__init__.py 或 acme/plugins/__init__.py。
acme.plugins.image 是普通源文件模块。finder 为它返回的 spec 会带有源文件 loader,origin 指向 /opt/extensions/acme/plugins/image.py,submodule_search_locations 为 None。模块对象创建并写入 sys.modules 后,SourceFileLoader.exec_module(module) 执行 image.py 顶层代码,执行结果写入 module.__dict__。
导入成功后,sys.modules 中应出现 acme、acme.plugins、acme.plugins.image。父包属性链也应建立:acme.plugins 指向 sys.modules["acme.plugins"],acme.plugins.image 指向 sys.modules["acme.plugins.image"]。如果这些状态不一致,应沿父包绑定、手写加载流程、异常中断和缓存修改继续排查。
动态生成 video.py 后立刻导入失败时,优先检查 path entry finder 的目录缓存和 sys.path_importer_cache。文件系统 finder 可能缓存目录状态,并受 stat 时间粒度影响。调用 importlib.invalidate_caches() 后再导入,可以让支持缓存失效协议的 finder 重新观察路径入口。
本章知识点总结
- 导入主线:导入机制把 fullname 转成模块对象,路径经过缓存、finder、spec、loader、模块初始化和父包绑定。
- Finder 职责:finder 根据 fullname、path 和 target 判断能否定位模块,并返回描述加载计划的
ModuleSpec。 - Loader 职责:loader 根据 spec 创建或执行模块对象,现代加载入口集中在
create_module()和exec_module()。 - Spec 作用:
ModuleSpec在 finder 和 loader 之间传递导入状态,包含名称、loader、来源、缓存位置和子模块搜索范围。 - Meta path:
sys.meta_path决定导入请求由哪些 meta path finder 按顺序处理,PathFinder只是其中一个默认角色。 - Path hook:
sys.path_hooks把路径入口转换成 path entry finder,影响sys.path和 package__path__的搜索方式。 - Namespace package:namespace package 由多个 portions 贡献同一逻辑包名,核心状态是自动构造的
__path__。 - importlib 边界:
importlib暴露导入系统的可编程接口,适合动态导入、spec 观察、缓存失效和受控加载。 - 初始化顺序:模块对象会先写入
sys.modules,再执行顶层代码,这让循环导入看到半初始化模块成为可能。 - 失败回滚:
exec_module()失败时,导入系统会清理失败模块的sys.modules项,已经成功加载的旁路模块保留。 - 父包绑定:子模块导入成功后,父包 namespace 中应出现对应属性,属性链和
sys.modules应保持一致。 - LazyLoader:
LazyLoader推迟模块执行到首次属性访问,能降低启动阶段加载开销,也会推迟错误和副作用暴露时间。 - PEP 690 边界:解释器级透明 lazy imports 的 PEP 690 已被拒绝,常规 CPython 顶层导入仍按 eager 模型执行。
- 排查顺序:导入问题应按 fullname、
sys.modules、sys.meta_path、path 输入、path finder 缓存、spec、loader、父包属性链逐层定位。