跳转到内容
新建笔记

从 imp 迁移到 importlib:模块加载、缓存与重载

imp 是历史导入接口:Python 3.4 起弃用,Python 3.12 已移除,最后包含它的版本系列是 Python 3.11。维护旧代码时应根据实际意图迁移到 importlib、types 或 sys,不能只把模块名前缀换掉。Python 3.12 移除说明

原稿中的 exec(spec.loader.exec_module(module)) 会先执行模块,再尝试执行通常为 None 的返回值,随后报错。正确流程只调用一次 exec_module();它执行模块代码,结果主要体现在模块命名空间中,不是返回一份等待 exec() 的源代码。

历史 imp 接口迁移方向不能忽略的差别
new_module(name)types.ModuleType(name);若有规格则用 module_from_spec()创建对象不等于执行源码或注册进 sys.modules
get_magic()importlib.util.MAGIC_NUMBER是当前字节码格式的标识,不是文件内容校验或可信证明
get_tag()sys.implementation.cache_tag是解释器缓存标签,可能为 None,不同于 .pyc 的魔数
cache_from_source()importlib.util.cache_from_source()返回缓存路径,不要求源码存在,也不创建 .pyc
source_from_cache()importlib.util.source_from_cache()需要符合缓存路径约定,不是对任意 .pyc 名称去掉一个字母
get_suffixes()importlib.machinery 的源码、字节码、扩展后缀列表新接口不是原来的 (后缀, 模式, 类型) 三元组列表
NullImporter审查原来的路径钩子/拒绝导入需求没有适合所有调用点的一行替换,不需要时移除旧适配层
load_source()建立文件规格、创建模块、登记并执行名称冲突、失败清理和包上下文需要处理
load_compiled()通常恢复源码并按普通导入;必要时审查字节码加载器.pyc 与解释器版本/实现相关,加载它同样会执行代码
load_package()优先 importlib.import_module() 与正常包路径按文件加载单模块不自动建立完整父包关系
load_module()以 ModuleSpec 和 exec_module() 为核心的新协议不要迁移到加载器上同样陈旧的 load_module() 方法
find_module()importlib.util.find_spec()返回规格而非旧三元组;查询子模块时可能导入父包
reload(module)importlib.reload(module)旧的外部引用和实例不会自动替换
init_builtin(name)通常使用 importlib.import_module(name)不需要把内置模块当普通源文件加载

正常按模块名称加载最简单:importlib.import_module("json") 返回该模块;加载子模块时返回指定子模块,而不是总返回顶层包。应先确认包安装在所选解释器环境中,参见 sys 与搜索路径。

只加载已选定的本地源文件

跳转到“只加载已选定的本地源文件”

下面给出一个范围明确的函数:单线程中,将可信 .py 文件加载成一个新的顶层模块名称;拒绝已有名称,不支持带点的包名,也不提供包内相对导入适配。它不会扫描或自动执行目录中其他文件。

import importlib.util
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
def load_source_file(name: str, filename):
if not isinstance(name, str) or not name.isidentifier():
raise ValueError("需要不含点的单个模块名称")
if name in sys.modules:
raise ValueError("模块名称已经存在")
filename = Path(filename).resolve(strict=True)
if filename.suffix != ".py" or not filename.is_file():
raise ValueError("需要一个已经存在的 .py 文件")
spec = importlib.util.spec_from_file_location(name, filename)
if spec is None or spec.loader is None:
raise ImportError("无法建立源文件加载规格")
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
try:
spec.loader.exec_module(module)
except BaseException:
if sys.modules.get(name) is module:
del sys.modules[name]
raise
return module
name = "_vitalogos_import_demo"
with TemporaryDirectory(prefix="vitalogos-import-") as directory:
filename = Path(directory) / "example.py"
filename.write_text(
"from __future__ import annotations\n"
"from dataclasses import dataclass\n"
"@dataclass\n"
"class Point:\n"
" x: int\n"
" y: int\n"
"def total(point):\n"
" return point.x + point.y\n",
encoding="utf-8",
)
module = load_source_file(name, filename)
try:
point = module.Point(2, 3)
assert module.total(point) == 5
assert sys.modules[name] is module
assert module.__spec__.origin == str(filename.resolve())
finally:
if sys.modules.get(name) is module:
del sys.modules[name]
print("source loaded once under its module name")

在执行前放入 sys.modules 有实际意义:执行期间可能出现按自身名称导入、装饰器检查模块或其他依赖模块身份的行为。上例含有 dataclass 与字符串化注解,因此也会覆盖这类需求。直接导入源文件的官方流程

失败时这里只清理由本函数登记的同一个模块对象,不回滚已写文件、已发请求或它导入的其他模块。加载的源文件必须来自调用方信任的位置;importlib 不提供隔离执行。示例也没有设计并发名称分配、模块自行替换 sys.modules 条目或生产插件生命周期。

加载单文件与安装包是两件事。带 from .helper import ... 的源码依赖父包上下文,应优先修复包结构并按名称导入;给它随意取个顶层名字并不能解决相对导入问题。包含原生扩展的包还可能有 ABI 与加载器约束。

创建模块对象与计算缓存路径

跳转到“创建模块对象与计算缓存路径”

这段代码只创建内存对象并计算缓存路径,不写 .pyc,也不执行外部源码:

import importlib.machinery
import importlib.util
from pathlib import Path
import sys
import types
name = "_vitalogos_empty_demo"
assert name not in sys.modules
module = types.ModuleType(name, "an in-memory example")
module.answer = 42
assert module.answer == 42
assert name not in sys.modules
source = str(Path("example.py"))
cache = importlib.util.cache_from_source(source)
assert importlib.util.source_from_cache(cache) == source
assert isinstance(importlib.util.MAGIC_NUMBER, bytes)
assert ".py" in importlib.machinery.SOURCE_SUFFIXES
assert ".pyc" in importlib.machinery.BYTECODE_SUFFIXES
print("cache tag:", sys.implementation.cache_tag)
print("cache path:", cache)

这份验证使用具有缓存标签的 CPython 3.11;没有缓存标签的实现可能不能使用相同的缓存路径操作。缓存文件路径通常包含 __pycache__ 与实现标签,不能把 source_from_cache("example.pyc") 当作适用于所有布局的逆变换。

.pyc 并不是可以跨 Python 版本随意搬运的通用二进制格式。一个文件具有正确魔数也不说明代码可信;从字节码加载仍然执行代码。源码可用时,保留源码并让当前解释器按自己的缓存规则处理,通常更容易维护。

importlib.reload() 重新执行模块代码,但不是应用级热更新。模块字典会被保留,因此新源码里删除的旧名称可能仍留在字典中;通过 from module import function 取得的旧引用、原类的旧实例也不会自动切换。重新导入已经在 sys.modules 中的名称,通常只是复用缓存,并不等同于 reload。

下面用一个自建临时模块展示这些差别;修改后令源码长度发生变化,并使查找器缓存失效,避免把测试是否命中旧字节码与 reload 的对象语义混在一起。所有搜索路径和模块登记在结束时恢复。

import importlib
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
name = "_vitalogos_reload_demo"
assert name not in sys.modules
original_path = list(sys.path)
with TemporaryDirectory(prefix="vitalogos-reload-") as directory:
filename = Path(directory) / (name + ".py")
filename.write_text(
"old_only = 1\n"
"class Value:\n"
" def read(self): return 10\n",
encoding="utf-8",
)
try:
sys.path.insert(0, directory)
importlib.invalidate_caches()
module = importlib.import_module(name)
old_class = module.Value
old_instance = old_class()
filename.write_text(
"# changed source length deliberately\n"
"class Value:\n"
" def read(self): return 200\n",
encoding="utf-8",
)
importlib.invalidate_caches()
reloaded = importlib.reload(module)
assert reloaded is module
assert module.Value is not old_class
assert old_instance.read() == 10
assert module.Value().read() == 200
assert module.old_only == 1
finally:
sys.path[:] = original_path
sys.modules.pop(name, None)
importlib.invalidate_caches()
assert sys.path == original_path
print("reload replaced definitions, not old references")

这是单线程的自建模块演示,不是业务代码的热更新方案。invalidate_caches() 使查找器重新发现文件,不负责删除所有字节码缓存,也不会清空 sys.modules。任意按文件加载的自定义模块也未必能由普通查找器再次找到,因而不能保证可直接 reload()。内置模块、原生扩展、长期运行服务和并发插件系统需要单独设计生命周期。importlib.reload() 语义