CPython 资源访问抽象层importlib.resources.abc详解:ResourceReader、Traversable 与 TraversableResources
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
本篇文章围绕 CPython 标准库模块Doc/library/importlib.resources.abc.rst展开,系统讲解从包(package)中读取“资源”(如随包分发的数据文件)所需的三套抽象基类/协议:已被取代的ResourceReader、pathlib.Path风格的Traversable,以及现役推荐接口TraversableResources。无论包与数据文件是存放在普通文件系统、zip 归档,还是命名空间包的多个目录中,这些 ABC 都向上层importlib.resources.files()提供统一的访问入口;读者读完本文后,既能理解标准 loader 的既有实现,也能照着接口编写自己的资源加载器。
模块定位与设计目标
importlib.resources.abc是 Python 3.11 新增的标准库模块(见 abc.py),其__all__仅导出三个名字:
__all__ = ["ResourceReader", "Traversable", "TraversableResources"]整个模块解决的问题非常集中:屏蔽“包及其资源存储方式”的差异。从该 ABC 的视角看,一个resource(资源)是指随包一起分发的二进制工件(binary artifact),典型形态是与包的__init__.py相邻的数据文件。调用方不关心这个包是装在 zip 文件里,还是散落在文件系统目录中——ResourceReader的职责正是让这种读取方式上的差异对用户透明。
与纯 Python 实现相关,其 C 语言孪生/引导逻辑可进一步参见 Lib/importlib/readers.py(公开的
importlib.readers入口)与下层引导模块 Lib/importlib/_bootstrap_external.py。
使用本模块需要满足一个前提:导入系统已经能正常加载该包。资源读取依赖“先 import 包,再读包内文件”的次序;若想访问尚未被导入的包的内容,请先通过importlib.import_module()完成导入。
关键抽象:包即“目录”,资源即“文件名”
在理解三个类之前,必须先掌握本模块贯穿始终的隐喻:
- 包(package)扮演“目录”的角色,实例化的 reader 被要求一对一直接对应某个具体包(而不是一个模块,也不能同时代表多个包);
- 资源(resource)在方法参数层面只表示一个“文件名”,参数应当是一个 path-like object;
- 资源参数不允许包含子目录路径,因为包的存放位置本身就充当着顶层“目录”,子路径访问交由 3.11 后引入的
Traversable.joinpath()/Traversable树形遍历去完成。
ResourceReader:面向“扁平资源”的旧版抽象基类
ResourceReader是这一组 ABC 中历史最久的成员。它让 loader 具备“读取资源”的能力,但从 3.12 起被标记为deprecated,官方明确要求改用TraversableResources。
从文档与源码均可看到其废弃声明:`.. deprecated:: 3.12`(Use `TraversableResources` instead)。**新代码一律实现 `TraversableResources`,无需再直接实现 `ResourceReader`。**与加载器的契约:get_resource_reader(fullname)
希望支持资源读取的 loader 需要提供get_resource_reader(fullname)方法,返回一个实现了本 ABC 接口的对象,契约包括:
| 情况 | 返回值要求 |
|---|---|
fullname指定的模块是包 | 返回实现了ResourceReader(或其子类)接口的对象 |
fullname指定的模块不是包 | 返回None |
在实现层面,该契约可参考 Lib/importlib/resources/_common.py 的get_resource_reader()辅助函数:它通过getattr(spec.loader, 'get_resource_reader', None)探测 loader 能力,再以spec.name调用之。
四个抽象方法的语义
| 方法 | 语义与错误约定 |
|---|---|
open_resource(resource) | 返回一个已打开、用于二进制读取的 file-like 对象;找不到资源时抛FileNotFoundError |
resource_path(resource) | 返回资源对应的文件系统路径;若资源并不真实存在于文件系统,抛FileNotFoundError |
is_resource(path) | 判断指定path是否被视为资源,返回True/False;path不存在时抛FileNotFoundError(历史版本中该参数名为name,后更名为path) |
contents() | 返回一个字符串迭代器,遍历包的(顶层)内容 |
对contents()有两个重要约束值得展开:
- 不要求迭代出的每个名字都是真正的资源——允许返回那些对
is_resource()而言为False的名字; - 之所以如此宽松,是为了照顾“存储方式已知”的场景:例如当确定包与资源都存放在文件系统上时,允许把子目录名也返回出来,调用方可以直接拼接这些名字去访问真实路径。而“抽象方法返回空迭代器”只是基类给出的默认占位行为。
源码中的默认实现细节
ResourceReader在 Lib/importlib/resources/abc.py 中的四个抽象方法并非raise NotImplementedError,而是刻意抛FileNotFoundError:
@abc.abstractmethod def open_resource(self, resource: Text) -> BinaryIO: ... # This deliberately raises FileNotFoundError instead of # NotImplementedError so that if this method is accidentally called, # it'll still do the right thing. raise FileNotFoundError源码注释解释得很清楚:一旦这些“应被覆盖却未被覆盖”的方法被意外调用,抛FileNotFoundError能让调用方得到符合语义的错误,而不是令人困惑的“未实现”异常。
Traversable:pathlib.Path 风格的可遍历句柄
Traversable是文档所称的、“具备 pathlib.Path 方法子集的、适合遍历目录与打开文件的对象”。它是从 3.11 起支撑importlib.resources.files()返回值的核心抽象。代码中它是一个使用@runtime_checkable声明的Protocol(参见 Lib/importlib/resources/abc.py),因此可以在运行时用isinstance(obj, Traversable)做检查。
若需要把
Traversable对象“落回”文件系统,请使用importlib.resources.as_file(详情见下文“as_file 与临时文件”小节)。
接口成员一览
Traversable要求实现方提供以下能力:
| 成员 | 类型/语义 |
|---|---|
name | 只读属性,对象的基名(不含任何父级路径引用) |
iterdir() | 产出本对象下的Traversable子对象 |
is_dir() | 返回True表示自身是目录 |
is_file() | 返回True表示自身是文件 |
joinpath(*pathsegments) | 按路径段向下遍历,返回Traversable结果 |
__truediv__(child) | return self.joinpath(child),与joinpath等价 |
open(mode='r', *args, **kwargs) | 打开句柄用于读取,行为同pathlib.Path.open |
派生实现不需要自己编写读取文本/字节的代码,因为协议基类提供了模板方法式的默认实现:
def read_bytes(self) -> bytes: with self.open('rb') as strm: return strm.read() def read_text(self, encoding=None, errors=None) -> str: with self.open(encoding=encoding, errors=errors) as strm: return strm.read()joinpath的多段参数与兼容性注意事项
joinpath是 3.11 的增强点(.. versionchanged:: 3.11):
- 现在接受多个pathsegments参数;
- 每个段内允许包含以正斜杠
/(即posixpath.sep)分隔的多级名字,因此下面两种写法等价:
files.joinpath('subdir', 'subsubdir', 'file.txt') files.joinpath('subdir/subsuddir/file.txt')注意原文档示例保留了笔误写法
'subsuddir',实际目标目录名请以真实文件系统为准。
- 兼容性提醒:部分
Traversable实现尚未升级到协议最新版(即早期只接受单一child参数)。为与这类旧实现兼容,每个joinpath调用应只传一个不含路径分隔符的段,通过链式调用逐层下钻:
files.joinpath('subdir').joinpath('subsubdir').joinpath('file.txt')open的模式与文本编码参数
open()的mode只允许两种取值:
'r':以文本模式打开;'rb':以二进制模式打开。
当以文本模式打开时,它接受io.TextIOWrapper支持的编码类关键字参数(例如encoding=...、errors=...),这些参数会被原样透传。正因为如此,协议基类中的read_text签名是read_text(encoding=None, errors=None)。
源码中joinpath的默认遍历实现
值得单独指出:尽管文档示例把它们当作抽象接口,实际 Lib/importlib/resources/abc.py 中joinpath与__truediv__带有默认实现,可被任何只实现了iterdir()/name的Traversable直接复用:
def joinpath(self, *descendants): if not descendants: return self names = itertools.chain.from_iterable( path.parts for path in map(pathlib.PurePosixPath, descendants) ) target = next(names) matches = (t for t in self.iterdir() if t.name == target) try: match = next(matches) except StopIteration: raise TraversalError("Target not found during traversal.", target, list(names)) return match.joinpath(*names)其内部先把各段用pathlib.PurePosixPath按/切分,再逐级在iterdir()结果中按name匹配;若途中找不到目标会抛TraversalError(该异常类型同样定义于 Lib/importlib/resources/abc.py)。多个未知子目录/zip 位置被合并时该行为也有特殊处理(见下文MultiplexedPath)。
TraversableResources:面向files()的新资源读取接口
TraversableResources是现役推荐的资源读取 ABC,其与ResourceReader的关系非常简洁:
- 它继承
ResourceReader; - 它为
ResourceReader的全部抽象方法提供了基于files()的具体实现; - 因此,任何提供了
TraversableResources的 loader 也必然等价于提供了ResourceReader(超集关系); - 新接口只需要实现一个抽象方法:
files(),返回加载的包对应的Traversable对象。
ResourceReader四个方法在 Lib/importlib/resources/abc.py 中的默认实现为:
class TraversableResources(ResourceReader): @abc.abstractmethod def files(self) -> "Traversable": ... def open_resource(self, resource: StrPath) -> BinaryIO: return self.files().joinpath(resource).open('rb') def resource_path(self, resource: Any) -> NoReturn: raise FileNotFoundError(resource) def is_resource(self, path: StrPath) -> bool: return self.files().joinpath(path).is_file() def contents(self) -> Iterator[str]: return (item.name for item in self.files().iterdir())逐条对读即可发现设计意图:
open_resource→files().joinpath(resource).open('rb'):把“扁平文件名”转换为Traversable树中的路径再以二进制打开;is_resource→joinpath(path).is_file():文件即资源;contents→ 由files().iterdir()各子项的name组成生成器;resource_path→无条件抛FileNotFoundError:因为Traversable未必真实存在于文件系统(例如 zip 内部),无法给出可靠的本地路径——这是与FileReader这类“文件系统原生”实现的关键差异(后者会重写resource_path返回真实路径,避免上层as_file做临时拷贝)。
CPython 内置 loader 的标准实现
标准库已在真实场景中把上述接口“跑通”,对应三个典型 loader,均在各自的get_resource_reader()中返回读者实现类:
| Loader | get_resource_reader()返回 | 源码位置 |
|---|---|---|
FileLoader/SourceFileLoader/SourcelessFileLoader(文件系统) | FileReader | Lib/importlib/_bootstrap_external.py |
zipimporter(zip 归档) | ZipReader | Lib/zipimport.py |
NamespaceLoader(命名空间包) | NamespaceReader | Lib/importlib/_bootstrap_external.py |
这些 reader 的实现全部位于 Lib/importlib/resources/readers.py,其类关系清晰对应上面的 ABC 设计:
FileReader:文件系统的“零拷贝”捷径
class FileReader(abc.TraversableResources): def __init__(self, loader): self.path = pathlib.Path(loader.path).parent def resource_path(self, resource): # 返回真实文件系统路径, # 从而避免 resources.path() 创建临时副本。 return str(self.path.joinpath(resource)) def files(self): return self.pathfiles()直接返回pathlib.Path——因为pathlib.Path天然实现了Traversable所需子集。特别地它重写了resource_path:既然文件本来就在磁盘上,直接给出路径即可,让上层resources.path()免去_tempfile()的临时文件开销(注释与实现对应可见 Lib/importlib/resources/readers.py)。
ZipReader:处理 zip 内路径前缀与边界情形
class ZipReader(abc.TraversableResources): def __init__(self, loader, module): self.prefix = loader.prefix.replace('\\', '/') if loader.is_package(module): _, _, name = module.rpartition('.') self.prefix += name + '/' self.archive = loader.archiveZipReader需要把 loader 的prefix与包名拼接成 zip 内部目录前缀,并用zipfile.Path(self.archive, self.prefix)作为files()的返回值。它另外做了两处健壮性修正(Lib/importlib/resources/readers.py):
open_resource捕获底层KeyError并转译为FileNotFoundError;is_resource增加target.exists()判断,规避zipfile.Path.is_file()对不存在路径仍返回True的历史怪癖。
NamespaceReader 与 MultiplexedPath:多宿命名空间包
命名空间包可同时“散落”在多个目录甚至 zip 中,因此NamespaceReader用MultiplexedPath把多个Traversable合并为一个对外一致的目录视图:
- 它以
name排序并分组各子路径的iterdir()结果,同名条目再决定是返回单个对象、还是再次合并为MultiplexedPath(见 Lib/importlib/resources/readers.py); MultiplexedPath.joinpath在TraversalError时会“降级”返回第一个子路径的拼接结果,保证调用方拿到的是一个确定不存在的对象而非崩溃;- 其
_resolve_zip_path还会对形如/foo/baz.zip/inner_dir的路径反向尝试解析出 zip 内部的zipfile.Path,说明一个Traversable完全可以指向 zip 内部节点。
对不支持files()的旧 loader 的适配
为了让“只实现ResourceReader旧接口”的 loader 也能被files()使用,CPython 在 Lib/importlib/resources/_adapters.py 提供TraversableResourcesLoader:它的get_resource_reader()通过CompatibilityFiles把旧 reader 包装成具备files()能力的对象(SpecPath/ChildPath/OrphanPath三种路径分别代表“包根/资源子项/悬空路径”)。这是理解“为什么只要实现ResourceReader的 loader 在 3.11+ 上依然可用”的关键。
上层调用链:files()与as_file如何依赖这些 ABC
importlib.resources.files()(API 文档见 Doc/library/importlib.resources.rst)是普通用户每天打交道最多的函数,其内部正好贯穿了上述所有抽象,调用链如下(实现见 Lib/importlib/resources/_common.py):
files(anchor=None)通过resolve()解析包对象(字符串会被import_module,None则从调用栈推断调用者所在模块);from_package(package)先做_assert_spec校验(对__main__且__spec__ is None的情况给出清晰报错),再经wrap_spec()适配后调用spec.loader.get_resource_reader(spec.name);- 最终返回
reader.files(),即一个Traversable。
而as_file(path)则解决了“拿到真实文件路径”的需求(Lib/importlib/resources/_common.py):
- 若传入的
path本身就是pathlib.Path,直接yield原对象(“退化行为”,零拷贝); - 若它是存在于文件系统的
Traversable目录,则递归把整棵目录树复制进临时目录(_write_contents); - 其余情况(如 zip 内部)则把
path.read_bytes()写到tempfile.mkstemp()创建的临时文件再yield,并保证上下文退出时清理。
顺带一提:FileReader/NamespaceReader重写resource_path的注释之所以反复强调“避免临时副本”,正是因为 as_file 的内部实现会走这条_tempfile路径。
实战:如何为自定义包加载器接入资源读取
要在自己的 loader 上支持importlib.resources.files(),只需两步:实现一个TraversableResources子类,再让 loader 的get_resource_reader()返回它。下面是一个最小可运行示例,结构与标准测试 Lib/test/test_importlib/resources/test_custom.py 中的做法一致:
from importlib.resources import abc class MyPackageReader(abc.TraversableResources): """把某个磁盘目录视作包目录的资源读取器。""" def __init__(self, directory): self.directory = directory def files(self): # pathlib.Path 已实现 Traversable 协议所需的方法子集, # 因此可以直接返回它。 return self.directory class MyLoader: """极简 loader:只负责提供资源读取器。""" def __init__(self, directory): self.directory = directory def get_resource_reader(self, fullname): # 非包返回 None;这里假设 fullname 一定是包。 return MyPackageReader(self.directory)接入后即可统一使用高层 API:
import importlib.resources as resources files = resources.files("mypkg") # -> MyPackageReader.files() 返回的 Traversable data = files.joinpath("data", "table.dat").read_bytes() # 等价 files/'data'/'table.dat' text = (files / "README.md").read_text(encoding="utf-8")测试端亦可复用仓库现有用例思路:Lib/test/test_importlib/resources/test_custom.py中的SimpleLoader把一个ResourceReader实例直接塞给get_resource_reader,而MagicResources(TraversableResources)子类仅靠files()返回self.path便完成了整个接口。仓库还提供了面向“极简低层 reader”的桥接实现 Lib/importlib/resources/simple.py:其SimpleReader只要求package/children/resources/open_binary四个成员,而TraversableReader(TraversableResources, SimpleReader)自动把SimpleReader适配成TraversableResources——如果你的资源来自远程、压缩流或自定义容器,这是最省力的接入模板。
需要补充的实现细节(同样有源码与测试佐证):
files()返回的Traversable的open(mode='r')会透传io.TextIOWrapper参数,文本读取务必显式给出encoding;iterdir()产出的子项不保证全是“资源”,调用is_resource()/is_file()前请容忍目录等非资源项;- 当路径无法被遍历到时,默认
joinpath会抛TraversalError(abc内定义),上层捕获它的典型场景是MultiplexedPath; - 从 3.11 起
Traversable是runtime_checkable的Protocol,可用isinstance(x, abc.Traversable)安全探测。
版本演进与迁移建议
| 版本 | 变化 |
|---|---|
| Python 3.10 | ResourceReader.is_resource()的参数由name更名为path(历史变更记录于该模块文档的versionchanged条目) |
| Python 3.11 | 新增importlib.resources.abc模块;Traversable.joinpath()支持多段参数与/分隔;importlib.resources.files()成为主力 API |
| Python 3.12 | 弃用ResourceReader,统一改用TraversableResources |
迁移建议非常直接:
- 如果实现的是 loader:让
get_resource_reader()返回TraversableResources(或直接返回实现了Traversable的pathlib.Path/zipfile.Path),不要再手工实现ResourceReader的四个旧方法; - 如果调用方遇到
DeprecationWarning:检查是否在依赖老式ResourceReader接口,改用files()/as_file高层 API 即可; - 兼容期行为由 _adapters.py 与 readers.py 中的包装层兜底:老 loader 仍可通过
CompatibilityFiles工作,但旧代码终将随废弃周期结束而移除。
小结
ResourceReader是资源读取能力的“旧约”:扁平文件名、四个抽象方法、get_resource_reader(fullname)契约,3.12 起废弃;Traversable是“pathlib.Path 子集”:提供name/iterdir()/is_dir()/is_file()/open(),并免费获得read_bytes()/read_text()/joinpath()/__truediv__;TraversableResources是“新约”:唯一抽象方法files(),其余方法全部基于files()具体化,是 3.12 后所有 loader 应实现的接口;- CPython 内置的
FileReader、ZipReader、NamespaceReader/MultiplexedPath及_adapters.py适配层共同印证了三层接口在“文件系统、zip、命名空间包”三种真实存储形态下的通用性; - 对普通开发者而言,最终体验收敛为一行代码:
files("mypkg").joinpath(...).read_text(...)——其背后正是这套 ABC 在支撑。
如需继续深入,建议按顺序阅读官方文档 Doc/library/importlib.resources.abc.rst 与配套的 Doc/library/importlib.resources.rst,再对照核心实现 Lib/importlib/resources/abc.py、Lib/importlib/resources/readers.py 与测试用例 Lib/test/test_importlib/resources/ 逐行印证。
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考