CPython importlib.resources 深度指南:包内资源的读取、打开与访问
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
导读
importlib.resources是 CPython 标准库(自 Python 3.7 引入,Doc/library/importlib.resources.rst)中专门用于访问包内资源的模块。所谓"资源",指的是随 Python 包一起分发的非 Python 文件,例如配置模板、图片、数据表、证书等——包作者在pip install之后依然需要以编程方式读取的那些文件。本文以 CPython 官方文档为骨架,结合本仓库中 Lib/importlib/resources/ 的真实源码与 Lib/test/test_importlib/resources/ 的测试用例,系统讲解files()与as_file()的 Traversable 体系、函数式 API 的每个函数用法、loader 侧实现ResourceReader的契约,以及 zipimport / 命名空间包等特殊场景的行为差异。读完本文,你将能够在自己的包中正确、跨分发形态地读取文本与二进制资源,并理解其在文件系统包、zip 包、内存包三种载入形态下分别是如何工作的。
什么是"资源"(Resources)
importlib.resources借助 Python 的 import 机制来提供对包(package)内部资源的访问。这里的"资源"是与某个模块或包关联的、类似文件的对象。官方文档给出了一个清晰的定义范围:
- 资源可能直接位于包内、位于包内某个子目录中,或者位于包外部但与模块相邻;
- 资源既可以是文本也可以是二进制;
- 从技术的角度讲,包内的
.py源码、__pycache__编译产物、安装产物(例如目录中的系统保留文件名,见os.path.isreserved)都算是该包的 de-facto 资源;但在实践中,资源主要指包作者有意暴露的非 Python 文件(例如 Lib/test/test_importlib/resources/ 测试数据里用到的utf-8.file这类样例数据); - 资源既可以二进制方式打开,也可以文本方式打开。
一个关键思想是:"资源像目录里的文件"只是一种类比。资源和包并不一定要在文件系统中以真实文件/目录存在——例如使用zipimport时,包和它的资源可以直接从 zip 文件中导入。正因为如此,访问资源不应依赖真实文件路径,这正是importlib.resources存在的意义。
官方文档同时给出了两个重要提醒:
- 安全模型:
importlib.resources与内建open函数遵循相同的安全模型,把不可信输入传给本模块的函数是不安全的; - Loader 扩展点:希望支持资源读取的 Loader 应实现
get_resource_reader(fullname)方法,其规格由importlib.resources.abc.ResourceReader定义(详见下文"Loader 侧的扩展机制"一节)。
面向对象 API:files()与as_file()
自 Python 3.9 起,importlib.resources提供了以 Traversable 协议为核心的推荐 API。相比下面的函数式 API,它更接近pathlib的操作习惯,且功能更丰富。
Anchor:资源定位的锚点
Anchor表示资源的锚定对象,要么是一个模块对象(types.ModuleType),要么是模块名字符串,其类型定义为Union[str, ModuleType](源码中见 Lib/importlib/resources/_common.py#L14-L15)。
files(anchor=None)
importlib.resources.files(anchor: Optional[Anchor] = None) -> Traversable返回一个代表资源容器(可类比目录)及其资源的Traversable对象。一个 Traversable 还可以包含其他容器(可类比子目录)。anchor的解析规则:
- 若
anchor是包,则从该包解析资源; - 若
anchor是(非包的)模块,则从与该模块相邻的位置(同一包内或包根目录)解析资源; - 若省略
anchor,则使用调用者的模块作为锚点。
版本沿革对接口演进非常重要:
- 3.9 版本新增;
- 3.12 中参数
package更名为anchor(package仍被接受但已弃用); - 3.15 中
package参数被完全移除;anchor现在可以是非包的模块;省略时默认使用调用者的模块;需要兼容旧版本 Python 时,可考虑使用importlib_resources >= 5.10(backport 独立包)提供的兼容接口。
从源码看,resolve 是一个functools.singledispatch分发函数:字符串通过importlib.import_module变成模块对象,None则通过_infer_caller()遍历调用栈、找出第一个不在本模块(且不是 singledispatchwrapper)的调用者来推断调用方模块名。随后 from_package 会通过wrap_spec(package)适配 spec/loader,调用spec.loader.get_resource_reader(spec.name)获得 reader,并返回reader.files()。也就是说,files()最终返回的 Traversable 完全由该包 loader 提供的 reader 决定。
as_file(traversable)
with importlib.resources.as_file(traversable) as path: # path 是一个 pathlib.Path 对象给定一个代表文件或目录的Traversable(通常来自files()),返回一个可用于with语句的上下文管理器,其产出是一个pathlib.Path对象。退出上下文管理器时,会清理为了从 zip 等非文件系统来源解压资源而创建的临时文件或临时目录。
当 Traversable 提供的方法(read_text等)不够用、而确实需要一个真实文件系统路径时(例如调用pathlib.Path.stat()、把路径传给只接受真实路径的第三方 C 库),才应使用as_file。
关键实现细节(Lib/importlib/resources/_common.py):
- 如果传入的本来就是
pathlib.Path(例如普通文件系统包由FileReader提供),as_file 走 degenerate 分支,直接原样返回该 Path,不创建任何临时文件——这正是 Lib/test/test_importlib/resources/test_path.py#L27-L34 中test_natural_path所验证的:"file-system-backed resources do not get the tempdir treatment"; - 如果传入的是目录,则递归地把整棵目录树写入临时目录再返回(
_temp_dir/_write_contents); - 如果传入的是 zip 内的文件,则用
tempfile.mkstemp建立临时文件、写入内容(_tempfile),并在退出with时调用os.remove清理(即使文件在 with 块内被提前删除,FileNotFoundError也会被吞掉,见 test_path.py 的test_remove_in_context_manager)。 - 3.12 起支持
traversable代表目录的情形。
Traversable 协议与路径操作
Traversable 是标注了@runtime_checkable的 Protocol,实现了pathlib.Path的、适用于目录遍历和文件打开的子集。其成员包括:
iterdir():产出其中的 Traversable;read_bytes()/read_text(encoding=None, errors=None):直接读取内容;is_dir()/is_file():判断是容器还是文件;joinpath(*descendants):拼接子路径。每个 descendant 是相对自身的路径片段,各片段可以包含以/(posixpath.sep)分隔的多级。它通过遍历iterdir()逐级匹配名称实现;找不到目标时抛出TraversalError;__truediv__:因此resources.files(pkg) / 'data' / 'file.txt'的写法与joinpath等价(测试中大量使用这种写法);open(mode='r', ...):模式支持'r'或'rb',文本模式接受encoding等参数,语义同pathlib.Path.open;name属性:不含父级引用的基本名称。
组合使用示例
from importlib import resources # 枚举包内的顶层条目(文件与目录名,str) for name in resources.files("my_pkg").iterdir(): print(name.name) # 用 '/' 风格拼接子路径并直接读取 data = resources.files("my_pkg").joinpath("data", "config.json").read_text(encoding="utf-8") # 等价写法 data = (resources.files("my_pkg") / "data" / "config.json").read_text(encoding="utf-8")函数式 API(Functional API)
为了向后兼容,importlib.resources提供了一组简化的、向后兼容的辅助函数,每个常见操作一次函数调用即可完成。它们在 Lib/importlib/resources/_functional.py 中实现,并被 Lib/importlib/resources/init.py 统一导出。
公共约定
对所有下列函数成立:
anchor是一个Anchor,语义与files()相同;但不同于files,这里不能省略 anchor(源码中 anchor 为None会直接抛出TypeError: anchor must be module or string, got None,见 Lib/importlib/resources/_functional.py#L81-L84);path_names是资源路径名的组成部分,相对于 anchor。例如读取名为info.txt的文本:
importlib.resources.read_text(my_module, "info.txt")与Traversable.joinpath相同,各组成部分之间应使用正斜杠/作为路径分隔符。例如下面两种写法是等价的:
importlib.resources.read_binary(my_module, "pics/painting.png") importlib.resources.read_binary(my_module, "pics", "painting.png")- 兼容性限制:出于向后兼容原因,如果传入了多个
path_names,读取文本的函数要求显式给出encoding参数。例如读取info/chapter1.txt需写作:
importlib.resources.read_text(my_module, "info", "chapter1.txt", encoding='utf-8')在源码层面,这个限制由 _get_encoding_arg 实现:当encoding缺省且path_names多于 1 个时抛出TypeError("'encoding' argument required with multiple path names")。该限制计划在 Python 3.15 移除。
所有函数内部最终都落到files(anchor).joinpath(*path_names)返回的 Traversable 上(_get_resource),因此在 zip 包上同样可用。自 3.13 起,这些函数均支持多个path_names。
open_binary(anchor, *path_names)
以二进制读取方式打开指定资源,返回typing.BinaryIO(二进制读取流)。大致等价于:
files(anchor).joinpath(*path_names).open('rb')open_text(anchor, *path_names, encoding='utf-8', errors='strict')
以文本读取方式打开指定资源。默认按严格 UTF-8读取;encoding与errors的含义与内建open一致。返回typing.TextIO。大致等价于:
files(anchor).joinpath(*path_names).open('r', encoding=encoding)向后兼容的注意点:存在多个path_names时encoding必须显式给出;且自 3.13 起,encoding和errors必须以关键字参数形式提供。该限制计划在 Python 3.15 移除。
read_binary(anchor, *path_names)
读取并返回指定资源的全部内容,结果为bytes。大致等价于:
files(anchor).joinpath(*path_names).read_bytes()read_text(anchor, *path_names, encoding='utf-8', errors='strict')
读取并返回指定资源的全部内容,结果为str。默认严格 UTF-8;encoding/errors语义同内建open。存在多个path_names时encoding必须显式给出(计划 3.15 移除该限制)。大致等价于:
files(anchor).joinpath(*path_names).read_text(encoding=encoding)path(anchor, *path_names)
提供资源对应的真实文件系统路径。返回一个上下文管理器(用于with),其产出的pathlib.Path即真实路径;退出时清理为从 zip 等来源解压创建的临时文件。例如pathlib.Path.stat需要真实路径,可以这样用:
with importlib.resources.path(anchor, "resource.txt") as fspath: result = fspath.stat()该函数本质上就是:
as_file(files(anchor).joinpath(*path_names))注意:与as_file一样,当资源天然就在文件系统中时,path不会产生临时副本(普通文件系统包的FileReader.resource_path会直接返回文件系统路径以避免临时复制,见 Lib/importlib/resources/readers.py#L21-L34)。
is_resource(anchor, *path_names)
若指定资源存在则返回True,否则False。目录不被视为资源。大致等价于:
files(anchor).joinpath(*path_names).is_file()源码实现还通过捕获TraversalError兜底返回False,保证对不存在路径的查询不抛异常(Lib/importlib/resources/_functional.py#L40-L48)。
contents(anchor, *path_names)
返回对包内或路径内命名条目的可迭代对象;可迭代对象以str形式产出资源名(如文件)和非资源名(如目录),并且不递归进子目录。大致等价于:
for resource in files(anchor).joinpath(*path_names).iterdir(): yield resource.name弃用警告:自Python 3.11起
contents已被弃用,官方建议改用上面的iterdir()写法——它对结果有更多控制权、功能更丰富。源码在调用时会发出DeprecationWarning(Lib/importlib/resources/_functional.py#L51-L63)。本文前文的files()API 一节即推荐用Traversable.iterdir()取代它。
实际调用链小结
下表汇总各函数式 API 与其面向对象实现的对应关系,便于查阅:
| 函数式 API | 底层等价实现 |
|---|---|
open_binary(a, *n) | files(a).joinpath(*n).open('rb') |
open_text(a, *n, ...) | files(a).joinpath(*n).open('r', ...) |
read_binary(a, *n) | files(a).joinpath(*n).read_bytes() |
read_text(a, *n, ...) | files(a).joinpath(*n).read_text(...) |
path(a, *n) | as_file(files(a).joinpath(*n)) |
is_resource(a, *n) | files(a).joinpath(*n).is_file() |
contents(a, *n) | 对files(a).joinpath(*n).iterdir()取.name |
Loader 侧的扩展机制:ResourceReader与get_resource_reader
要实现完整的资源读取支持,包依赖的 loader 是关键。官方文档明确:希望支持资源读取的 loader 应实现get_resource_reader(fullname),其规格见importlib.resources.abc.ResourceReader。
ResourceReader抽象基类
Lib/importlib/resources/abc.py#L24-L63 中定义的ResourceReader(metaclass=abc.ABCMeta)要求实现四个方法:
open_resource(resource):返回用于二进制读取的已打开 file-like 对象。resource仅代表一个文件名;找不到时抛FileNotFoundError(基类刻意抛FileNotFoundError而非NotImplementedError);resource_path(resource):返回指定资源在文件系统上的路径;文件系统上不存在则抛FileNotFoundError;is_resource(path):命名路径是否是资源——文件是资源,目录不是;contents():返回包内条目的可迭代对象。
TraversableResources与内置实现
TraversableResources(abc.py#L169-L189)是提供 traversable 资源的接口:额外要求files()返回包的 Traversable,其余方法都以files()为基准实现;FileReader(readers.py#L21-L34):文件系统 loader 的 reader,files()直接返回包目录对应的pathlib.Path;ZipReader(readers.py#L37-L60):zip 包 loader 的 reader,files()返回zipfile.Path(archive, prefix),从而让 API 在 zipimport 场景下同样可用;它还对zipfile.Path.is_file在"路径不存在"时返回True的怪癖做了修正(is_resource同时检查is_file()与exists());NamespaceReader/MultiplexedPath(readers.py#L63-L185):处理命名空间包可能"多宿主(multihomed)"在多个目录/zip 的情况——对每个条目依次尝试解析为文件系统目录或 zip 内部路径,再把多个 Traversable 合并成一个逻辑视图。
在 CPython 当前实现里,from_package 会先通过 _adapters.py 的wrap_spec把包的 spec 包装为SpecLoaderAdapter,使其拥有TraversableResourcesLoader提供的get_resource_reader;若底层 loader 本身没有 reader 或 reader 不支持files(),则由CompatibilityFiles适配出一个基于ResourceReader四方法的兼容视图(并区分可读的SpecPath/ChildPath与不可读的OrphanPath)。
对于自定义 loader,接入方式即实现get_resource_reader:
class MyLoader(importlib.abc.Loader): def get_resource_reader(self, fullname): # 返回一个实现了 TraversableResources / ResourceReader 接口的对象 return MyResourceReader(...)标准库自带 loader 之外,主流打包工具(例如 setuptools 构建的普通文件系统包)默认走文件系统路径,天然被FileReader支持,无需额外工作。
跨分发形态:文件系统、zip 与内存包
仓库测试 Lib/test/test_importlib/resources/ 把使用场景按载入形态分成DiskSetup(磁盘文件系统)、ZipSetup(zip 导入)、以及内存包三类。理解importlib.resources的价值,核心在于:无论资源以何种形态分发,调用方的代码都不需要变化。
- 文件系统包:普通
pip install后即是此形态。files()返回pathlib.Path,所有读写直通文件系统,as_file/path零拷贝; - zip 包:通过
zipimport把.zip加入sys.path即可导入。此时资源没有真实文件系统路径,read_*直接在 zip 成员上工作;只有需要真实路径时(as_file/path)才会把内容解压到tempfile创建的临时位置,退出with即清理; - 内存包:loader 把包内容保存在内存对象中。测试中的
PathMemoryTests用io.BytesIO构造一个"只存在于内存"的包(__spec__.origin = None、has_location = False),验证资源读取 API 在无文件位置的情况下依然可用——这印证了文档中"资源不必是物理文件"的论断。
这要求包作者遵循一条实践准则:不要假设资源一定有可用的__file__路径,而是始终经由importlib.resources的抽象层访问资源文件。对于更老版本 Python 的兼容,官方文档建议参考独立 backport 包importlib_resources(其文档还包含从pkg_resources迁移的专门指引),本模块源码头部也注明与 PyPI 上的importlib_resources共享同一套代码库(见 Lib/importlib/resources/init.py 顶部注释)。
实战建议与常见误区
- 在函数/方法内部省略 anchor:
files()省略anchor时使用"调用者的模块"作为锚点,即引用定义处所在模块同目录的资源;在使用前应确认该模块已通过 import 系统加载(拥有非None的__spec__)。若对象的__spec__为None(例如直接运行__main__的某些场景),源码中的_assert_spec会给出明确报错提示(Lib/importlib/resources/_common.py#L74-L85)。 - 目录不是资源:
is_resource对目录返回False,Traversable.is_file()同理;要判断某个容器/文件是否存在,请综合使用is_file()、is_dir()或捕获TraversalError。 - 不要手动拼接真实文件路径:一旦包被塞进 zip 或以其他非常规方式分发,基于
__file__的路径拼接就会失效;应始终经由files(anchor).joinpath(...)或函数式 API。 - 需要真实路径时用上下文管理器:
path/as_file可能创建临时文件/目录,务必用with语句包裹,确保退出即清理;如果资源本来就在文件系统上,则不会有额外开销。 - 使用
/而非os.sep:path_names与joinpath内部通过PurePosixPath解析、统一以正斜杠分隔(见 abc.py#L114-L137),跨平台写代码时不要引入平台相关分隔符。 - 新代码优先使用 Traversable API:
files()+iterdir()/read_text()等是现行推荐接口;contents()已弃用,package位置参数自 3.15 起不复存在。
延伸阅读
- 模块公共导出与
__all__:本模块实际导出的名称(Anchor、Traversable等)见 Lib/importlib/resources/init.py; - Traversable 协议与 ResourceReader 契约:可继续阅读其定义模块 Lib/importlib/resources/abc.py;
- 各分发形态的行为验证:仓库内置了覆盖磁盘、zip、内存、自定义 loader、命名空间包等场景的完整测试集,位于 Lib/test/test_importlib/resources/(例如 test_files.py、test_path.py、test_reader.py),是学习边界行为的最佳教材;
- 资源打包与发布的配套机制:本仓库另有 Lib/zipimport.py(zip 导入实现)、Modules/zipimport.c 与打包工具的 zip_safe/包数据配置,可与本文主题配合阅读。
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考