本节目标:把 7.1 的三阶段协议用起来——写一个能拦截任意模块名的自定义导入器,并理清
.pth、sitecustomize、importlib.reload、.pyc缓存各自的触发时机与边界。
适用版本:Python 3.12+(实测 3.14.6)
7.2 元路径钩子与自定义导入器
导入系统的每一层都是可插拔的:sys.meta_path 决定「模块从哪来」,sys.path_hooks 决定「一个目录/压缩包怎么被解释」,.pth 与 sitecustomize 决定「解释器启动时预置什么」,importlib.reload 决定「运行时怎么刷新」。本节逐层实测。
一、sys.meta_path 是一次有序投票
7.1 说过,导入机制按顺序遍历 sys.meta_path,第一个返回非 None ModuleSpec 的 finder 获胜。这意味着插入位置就是优先级:
insert(0, finder):最高优先级,可以覆盖标准库或第三方模块;append(finder):兜底,只在其它 finder 都放弃时才轮到你。
很多「模块级补丁」库(打桩测试、gevent 猴子补丁、vcr 录制回放)正是靠 insert(0, ...) 抢在磁盘查找之前改写行为。
二、自定义 MetaPathFinder + Loader 实测
下面这个 finder 不读磁盘,直接把一段源码编译后当作模块交出。它演示了完整的 find_spec → create_module → exec_module 链路:
import sys
from importlib.abc import MetaPathFinder, Loader
from importlib.machinery import ModuleSpec
SOURCES = {"virtualmod": "value = 7\nprint('[virtualmod] executing')\n"}
class MemoryFinder(MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname in SOURCES:
print(f" find_spec({fullname!r})")
return ModuleSpec(fullname, MemoryLoader(fullname))
return None
class MemoryLoader(Loader):
def __init__(self, name): self.name = name
def create_module(self, spec):
print(" create_module -> None (default module)")
return None
def exec_module(self, module):
print(" exec_module")
code = compile(SOURCES[self.name], f"<{self.name}>", "exec")
exec(code, module.__dict__)
sys.meta_path.insert(0, MemoryFinder())
import virtualmod
print("value =", virtualmod.value)
print("__file__:", getattr(virtualmod, "__file__", "<absent>"))
find_spec('virtualmod')
create_module -> None (default module)
exec_module
[virtualmod] executing
value = 7
__file__: <absent>
实测结果里有两个关键点:其一,__file__ 不存在——因为 ModuleSpec 没有 origin,has_location 为假,导入机制就不会写这个属性;其二,virtualmod.__spec__ 与 virtualmod.__loader__ 仍然被自动注入,指向我们的 MemoryLoader。把源码换成从数据库、网络或加密容器里读出的字节,就是一套完整的「远程模块加载」。
三、优先级实测:拦截标准库
把 finder 放到 meta_path 首位,连 json 都能被顶替:
import sys
from importlib.abc import MetaPathFinder
from importlib.machinery import ModuleSpec
class Shadow(MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname == "json":
return ModuleSpec("json", ShadowLoader())
return None
class ShadowLoader:
def create_module(self, spec): return None
def exec_module(self, module):
module.dumps = lambda o: "SHADOWED"
module.__file__ = "<shadow>"
sys.meta_path.insert(0, Shadow())
sys.modules.pop("json", None) # 清掉已缓存的真 json
import json
print(json.dumps({"a": 1}), "|", json.__file__)
SHADOWED | <shadow>
注意 ShadowLoader 没有继承 Loader:导入机制只做鸭子类型检查,只要对象有 create_module 和 exec_module 即可。这既是灵活性,也是隐患——一个写错的 finder 若拦截了 sys 或 os,解释器可能在启动期就崩溃。
四、path_hooks 与 path_importer_cache
PathFinder 遍历 sys.path 时,对每个条目调用 sys.path_hooks 里的钩子,试出该条目的「路径入口 finder」,并把它缓存进 sys.path_importer_cache。可以自己插一个钩子,接管某个特定目录:
import sys, os, importlib
import importlib.machinery as m
extra = os.path.abspath("pthtest/extra")
sys.path.insert(0, extra)
def my_hook(path):
if os.path.abspath(path) == extra:
return m.FileFinder.path_hook((m.SourceFileLoader, [".py"]))(path)
raise ImportError # 交给下一个钩子
sys.path_hooks.insert(0, my_hook)
sys.path_importer_cache.clear()
importlib.invalidate_caches()
import plugmod
print(plugmod.NAME, "|", type(sys.path_importer_cache[extra]).__name__)
plugmod-from-pth | FileFinder
两点值得记住:钩子用 raise ImportError 表示「我不管这个目录」,让后面的钩子继续;sys.path_importer_cache 是目录 → finder 的缓存,改完 sys.path_hooks 必须 invalidate_caches() 并清缓存才会生效。默认钩子只有 zipimporter(处理 .zip/.egg)与 FileFinder.path_hook(处理普通目录)两个。
五、.pth 文件:启动期的路径注入
site 模块在解释器启动时扫描 site-packages(及 site.addsitedir 指定的目录)下的 .pth 文件。每一行的处理规则由 site.addpackage 实现:普通行会被当作路径加入 sys.path;以 import 开头的行会被直接执行。实测:
# pth2/onlyimport.pth 只有一行:
# import sys; sys._PTH_MARK = "executed-by-pth"
import site, sys
site.addsitedir("/tmp/.../pth2")
print(getattr(sys, "_PTH_MARK", None))
executed-by-pth
import 行能执行任意代码,这也是 .pth 既是「路径配置」又是「启动钩子」的原因,同时是供应链攻击的常见载体——一个恶意 .pth 无需任何显式 import 就能在每次启动时运行。审计第三方包时,site-packages/*.pth 值得逐行看过。
六、sitecustomize:启动期的最后一道钩子
site 在完成路径设置后,会尝试 import sitecustomize;只要它在 sys.path 上,就会被自动执行。实测(本机 Homebrew 的 Python 自带了标准库级 sitecustomize.py):
$ PYTHONPATH=/tmp/.../sctest python sctest/check2.py
[sitecustomize] ran at interpreter startup
sitecustomize.__file__: /tmp/.../sctest/sitecustomize.py
$ python sctest/check2.py # 未设 PYTHONPATH
sitecustomize.__file__: /opt/homebrew/.../lib/python3.14/sitecustomize.py
PYTHONPATH 上的 sitecustomize 排在标准库之前,因此会胜出。它是「不修改任何脚本就能注入全局行为」的正规入口——APM 探针、审计钩子、环境默认值都常挂在这里。顺序上:.pth 先被处理,sitecustomize 最后执行。
七、importlib.reload 的真实语义与局限
reload 最容易被误解。它不创建新模块,而是把源码重新执行到同一个模块对象的 __dict__ 里。用两个消费方实测:
# mymod.py 初版
VERSION = 1
def greet(): return f"v{VERSION}" # 调用时读模块全局
def tagged(tag="v1"): return tag # 默认值在 def 时绑定
# consumer.py
from mymod import greet, tagged
把 mymod.py 改成 VERSION = 2、tagged 默认值改 "v2",然后 importlib.reload(mymod):
mymod.greet() -> v2 mymod.tagged() -> v2
consumer.greet() -> v2 consumer.tagged() -> v1
greet object replaced: True
consumer.greet is mymod.greet: False
consumer.tagged is mymod.tagged: False
三条结论:
reload返回的是同一个模块对象(id不变),__dict__被复用(id不变);- 不会重绑其它模块里已
from mymod import ...的名字——consumer.greet仍是旧函数对象; - 旧函数对象因
__globals__指向被复用的同一份模块字典,读全局变量时会看到新值(greet返回 v2);但def时绑定的默认参数、闭包、类属性等「冻结」在对象上的东西保持旧值(tagged仍返回 v1)。
所以 reload 只适合「刷新当前进程里某个模块自己」的开发场景,无法替代重启,也处理不了模块删除(旧名字会残留在 __dict__ 里)。真正需要干净重载时,importlib.util.spec_from_file_location 重新加载 + 手动替换引用更可控。
八、__pycache__ 与 .pyc 校验(PEP 552)
模块首次导入会编译并写 __pycache__/*.pyc,下次导入直接反序列化,省掉编译。.pyc 的 16 字节头部决定「是否要重新编译」:
import sys, importlib.util, struct
p = sample.__cached__ # .../__pycache__/sample.cpython-314.pyc
raw = open(p, "rb").read()
print("magic:", raw[:4], "== MAGIC_NUMBER:", raw[:4] == importlib.util.MAGIC_NUMBER)
print("flags:", struct.unpack("<I", raw[4:8])[0])
mtime, size = struct.unpack("<II", raw[8:16])
print("stored mtime/size:", mtime, size)
magic: b'+\x0e\r\n' == MAGIC_NUMBER: True
flags: 0
stored mtime/size: 1791518637 12
头部结构是 magic(4) + flags(4) + 8 字节校验。flags=0 表示默认的时间戳模式:后 8 字节存源文件的 mtime 与大小,两者任一不符就重新编译。用 py_compile.PycInvalidationMode.CHECKED_HASH 编译时,flags 变成 3(bit0 表示哈希模式,bit1 表示同时校验源文件),后 8 字节改存源码的哈希——实测:
checked-hash pyc flags: 3 | header hex: 2b0e0d0a03000000f99da1bd0f546ce0
magic 与解释器版本绑定(本机 sys.implementation.cache_tag 为 cpython-314),所以升级解释器后旧 .pyc 会自动失效重建。哈希模式的好处是:mtime 变化但内容未变时不重新编译,适合在 CI 或容器里用只读源码目录。
九、-X importtime:把导入开销量化
python -X importtime 让解释器在每次导入后打印一行耗时。用一个 4000 行的模块对比「首次编译」与「命中 .pyc」:
$ python -X importtime run.py # 无 __pycache__
import time: 21804 | 21804 | bigmod
$ python -X importtime run.py # 已有 .pyc
import time: 7823 | 7823 | bigmod
self 列从 21804 微秒降到 7823 微秒,差额约 14 ms 就是省掉的编译成本(模块很小或很大时比例会变)。用 time.perf_counter() 在进程内直接量,首次导入中位数约 24 ms、命中 .pyc 后约 4 ms(4000 行模块,各跑 5 次)。对冷启动敏感的 CLI 与 Serverless,这类数字直接决定要不要把重依赖惰性化。
小结
sys.meta_path是有序投票,insert(0, ...)能覆盖任何磁盘模块;finder 只需实现find_spec(),loader 只需create_module()/exec_module()(鸭子类型,无需继承)。- 自定义 finder 不设
origin时,模块不会获得__file__,但__spec__/__loader__仍会被注入。 sys.path_hooks决定「目录/压缩包怎么解释」,sys.path_importer_cache缓存「目录 → finder」,改钩子后必须invalidate_caches()。.pth的普通行加入sys.path,import行会被直接执行;sitecustomize在启动末期自动导入——两者都是「零改动注入全局行为」的入口,也是安全审计重点。importlib.reload复用同一模块对象与__dict__,不重绑别处已导入的引用;读全局变量的旧函数会看到新值,def期绑定的默认值保持旧值。.pyc头部为magic + flags + 8 字节校验,默认按mtime+大小失效,可切成 PEP 552 哈希模式;-X importtime能把导入开销拆成self与cumulative。
下一节 命名空间包、zip 导入与冻结模块 会离开磁盘目录,看三类「非普通文件」的模块来源:跨目录合并的命名空间包、打包成 zip 的库,以及编译进解释器的冻结模块。
阅读导航:上一节:导入协议与 finder / loader · 下一节:命名空间包、zip 导入与冻结模块 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。