importlib.metadata -- 存取套件的元資料

Added in version 3.8.

在 3.10 版的變更: importlib.metadata 不再是暂定的。

原始碼:Lib/importlib/metadata/__init__.py

importlib.metadata 是一个提供对已安装的 分发包 的元数据的访问的库,如其入口点或其顶层名称 (导入包, 模块等,如果存在的话)。 这个库部分构建于 Python 的导入系统之上,其目标是取代 pkg_resources 的中的 entry point APImetadata API。 配合 importlib.resources ,这个包使得较老旧且低效的 pkg_resources 包不再必要。

importlib.metadatapip 等工具安装到 Python 的 site-packages 目录的第三方 分发包 进行操作。 具体来说,适用的分发包应带有可发现的 dist-infoegg-info 目录,以及 核心元数据规范说明 定义的元数据。

重要

它们 不一定 等同或 1:1 对应于可在 Python 代码中导入的顶层 导入包 名称。一个 分发包 可以包含多个 导入包 (和单个模块),如果是命名空间包,一个顶层 导入包 可以映射到多个 分发包。您可以使用 packages_distributions() 来获取它们之间的映射。

分发包元数据默认可存在于 sys.path 下的文件系统或 zip 归档文件中。通过一个扩展机制,元数据可以存在于几乎任何地方。

也參考

https://importlib-metadata.readthedocs.io/

importlib_metadata 的文档,它向下移植了 importlib.metadata。它包含该模块的类和函数的 API 参考,以及针对 pkg_resources 现有用户的 迁移指南

概述

让我们假设你想要获取你使用 pip 安装的某个 分发包 的版本字符串。我们首先创建一个虚拟环境并在其中安装一些软件包:

$ python -m venv example
$ source example/bin/activate
(example) $ python -m pip install wheel

你可以通过运行以下代码得到 wheel 的版本字符串:

(example) $ python
>>> from importlib.metadata import version  
>>> version('wheel')  
'0.32.3'

你还能得到可通过 EntryPoint 的属性 (通常为 'group' 或 'name') 来选择的入口点多项集,比如 console_scripts, distutils.commands。 每个 group 包含一个由 EntryPoint 对象组成的多项集。

你可以获得 分发的元数据

>>> list(metadata('wheel'))  
['Metadata-Version', 'Name', 'Version', 'Summary', 'Home-page', 'Author', 'Author-email', 'Maintainer', 'Maintainer-email', 'License', 'Project-URL', 'Project-URL', 'Project-URL', 'Keywords', 'Platform', 'Classifier', 'Classifier', 'Classifier', 'Classifier', 'Classifier', 'Classifier', 'Classifier', 'Classifier', 'Classifier', 'Classifier', 'Classifier', 'Classifier', 'Requires-Python', 'Provides-Extra', 'Requires-Dist', 'Requires-Dist']

你也可以获得 分发包的版本号,列出它的 构成文件,并且得到分发包的 分发包的依赖 列表。

函数式 API

这个包的公开 API 提供了以下功能。

入口点

entry_points() 函数返回入口点的字典。入口点表现为 EntryPoint 的实例;每个 EntryPoint 对象都有 .name.group.value 属性,用于解析值的 .load() 方法, 来自 .value 属性的对应部分的 .module, .attr.extras 属性。

查询所有的入口点:

>>> eps = entry_points()  

entry_points() 函数返回一个 EntryPoints 对象,即由带有 namesgroups 属性的全部 EntryPoint 对象组成的多项集以方便使用:

>>> sorted(eps.groups)  
['console_scripts', 'distutils.commands', 'distutils.setup_keywords', 'egg_info.writers', 'setuptools.installation']

EntryPointsselect 方法用于选择匹配特性的入口点。要选择 console_scripts 组中的入口点:

>>> scripts = eps.select(group='console_scripts')  

你也可以向 entry_points 传递关键字参数 "group" 以实现相同的效果:

>>> scripts = entry_points(group='console_scripts')  

选出命名为 “wheel” 的特定脚本(可以在 wheel 项目中找到):

>>> 'wheel' in scripts.names  
True
>>> wheel = scripts['wheel']  

等价地,在选择过程中查询对应的入口点:

>>> (wheel,) = entry_points(group='console_scripts', name='wheel')  
>>> (wheel,) = entry_points().select(group='console_scripts', name='wheel')  

检查解析得到的入口点:

>>> wheel  
EntryPoint(name='wheel', value='wheel.cli:main', group='console_scripts')
>>> wheel.module  
'wheel.cli'
>>> wheel.attr  
'main'
>>> wheel.extras  
[]
>>> main = wheel.load()  
>>> main  
<function main at 0x103528488>

groupname 是由包作者定义的任意值并且通常来说客户端会想要解析特定 group 的所有入口点。 请参阅 the setuptools docs 了解有关入口点,其定义和用法的更多信息。

在 3.12 版的變更: "selectable" 入口点是在 importlib_metadata 3.6 和 Python 3.10 中引入的。 在这项改变之前,entry_points 不接受任何形参并且总是返回一个由入口点组成的字典,字典的键为分组名。 在 importlib_metadata 5.0 和 Python 3.12 中,entry_points 总是返回一个 EntryPoints 对象。 请参阅 backports.entry_points_selectable 了解相关兼容性选项。

在 3.13 版的變更: EntryPoint 对象不再提供类似于元组的接口(__getitem__())。

分发的元数据

每个 分发包 都包括一些元数据,你可以使用 metadata() 函数来获取:

>>> wheel_metadata = metadata('wheel')  

返回的数据结构 PackageMetadata 的键代表元数据的关键字,而值从分发的元数据中不被解析地返回:

>>> wheel_metadata['Requires-Python']  
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'

PackageMetadata 也提供了按照 PEP 566 将所有元数据以 JSON 兼容的方式返回的 json 属性:

>>> wheel_metadata.json['requires_python']
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'

備註

metadata() 所返回的对象的实际类型是一个实现细节并且应当只能通过 PackageMetadata 协议 所描述的接口来访问。

在 3.10 版的變更: 当有效载荷中包含时,Description 以去除续行符的形式被包含于元数据中。

添加了 json 属性。

分发包的版本

version() 函数可以最快捷地以字符串形式获取一个 分发包 的版本号:

>>> version('wheel')  
'0.32.3'

分发包的文件

你还可以获取包含在分发包内的全部文件的集合。 files() 函数接受一个 分发包 名称并返回此分发包所安装的全部文件。 每个返回的文件对象都是一个 PackagePath,即带有由元数据指明的额外 dist, sizehash 特征属性的派生自 pathlib.PurePath 的对象。 例如:

>>> util = [p for p in files('wheel') if 'util.py' in str(p)][0]  
>>> util  
PackagePath('wheel/util.py')
>>> util.size  
859
>>> util.dist  
<importlib.metadata._hooks.PathDistribution object at 0x101e0cef0>
>>> util.hash  
<FileHash mode: sha256 value: bYkw5oMccfazVCoYQwKkkemoVyMAFoR34mmKBx8R1NI>

当你获得了文件对象,你可以读取其内容:

>>> print(util.read_text())  
import base64
import sys
...
def as_bytes(s):
    if isinstance(s, text_type):
        return s.encode('utf-8')
    return s

你也可以使用 locate 方法来获得文件的绝对路径:

>>> util.locate()  
PosixPath('/home/gustav/example/lib/site-packages/wheel/util.py')

当列出包含文件的元数据文件(RECORD 或 SOURCES.txt)不存在时, files() 函数将返回 None 。调用者可能会想要将对 files() 的调用封装在 always_iterable 中,或者用其他方法来应对目标分发元数据存在性未知的情况。

分发包的依赖

要获取一个 分发包 的完整需求集合,请使用 requires() 函数:

>>> requires('wheel')  
["pytest (>=3.0.0) ; extra == 'test'", "pytest-cov ; extra == 'test'"]

将导入映射到分发包

解析每个提供可导入的最高层级 Python 模块或 导入包 对应的 分发包 名称(对于命名空间包可能有多个名称)的快捷方法:

>>> packages_distributions()
{'importlib_metadata': ['importlib-metadata'], 'yaml': ['PyYAML'], 'jaraco': ['jaraco.classes', 'jaraco.functools'], ...}

某些可编辑的安装 没有提供最高层级名称,因而此函数不适用于这样的安装。

Added in version 3.10.

分发包对象

以上 API 是最常见且便捷的用法,但你也可以通过 Distribution 类来获得所有信息。 Distribution 是一个代表 Python 分发包 元数据的抽象对象。 你可以这样获取 Distribution 实例:

>>> from importlib.metadata import distribution  
>>> dist = distribution('wheel')  

因此,可以通过 Distribution 实例获得版本号:

>>> dist.version  
'0.32.3'

Distribution 实例具有所有可用的附加元数据:

>>> dist.metadata['Requires-Python']  
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'
>>> dist.metadata['License']  
'MIT'

对于可编辑包,origin 属性可能表示 PEP 610 元数据:

>>> dist.origin.url
'file:///path/to/wheel-0.32.3.editable-py3-none-any.whl'

此处并未描述可用元数据的完整集合。详见 核心元数据规格说明

Added in version 3.13: 增加了 .origin 特征属性。

分发包的发现

在默认情况下,这个包针对文件系统和 zip 文件 分发包 的元数据发现提供了内置支持。 这个元数据查找器的搜索目标默认为 sys.path,但它对来自其他导入机制行为方式的解读会略有变化。 特别地:

  • importlib.metadata 不会识别 sys.path 上的 bytes 对象。

  • importlib.metadata 将顺带识别 sys.path 上的 pathlib.Path 对象,即使这些值会被导入操作所忽略。

扩展搜索算法

因为 分发包 元数据不能通过 sys.path 搜索,或是通过包加载器直接获得,一个分发包的元数据是通过导入系统的 查找器 找到的。 要找到分发包的元数据,importlib.metadata 将在 sys.meta_path 上查询 元路径查找器 的列表。

在默认情况下 importlib.metadata 会安装在文件系统中找到的分发包的查找器。 这个查找器无法真正找出任何 分发包,但它能找到它们的元数据。

抽象基类 importlib.abc.MetaPathFinder 定义了 Python 导入系统期望的查找器接口。 importlib.metadata 通过寻找 sys.meta_path 上查找器可选的 find_distributions 可调用的属性扩展这个协议,并将这个扩展接口作为 DistributionFinder 抽象基类提供,它定义了这个抽象方法:

@abc.abstractmethod
def find_distributions(context=DistributionFinder.Context()):
    """Return an iterable of all Distribution instances capable of
    loading the metadata for packages for the indicated ``context``.
    """

DistributionFinder.Context 对象提供了指示搜索路径和匹配名称的属性 .path.name ,也可能提供其他相关的上下文。

这在实践中意味着要支持在文件系统外的其他位置查找分发包的元数据,你需要子类化 Distribution 并实现抽象方法,之后从一个自定义查找器的 find_distributions() 方法返回这个派生的 Distribution 实例。

示例

例如,考虑一个从数据库中加载 Python 模块的自定义查找器:

class DatabaseImporter(importlib.abc.MetaPathFinder):
    def __init__(self, db):
        self.db = db

    def find_spec(self, fullname, target=None) -> ModuleSpec:
        return self.db.spec_from_name(fullname)

sys.meta_path.append(DatabaseImporter(connect_db(...)))

该导入器现在大概可以从数据库中导入模块,但它不提供元数据或入口点。这个自定义导入器如果要提供元数据,它还需要实现 DistributionFinder

from importlib.metadata import DistributionFinder

class DatabaseImporter(DistributionFinder):
    ...

    def find_distributions(self, context=DistributionFinder.Context()):
        query = dict(name=context.name) if context.name else {}
        for dist_record in self.db.query_distributions(query):
            yield DatabaseDistribution(dist_record)

这样一来,query_distributions 就会返回数据库中与查询匹配的每个分发包的记录。例如,如果数据库中有 requests-1.0find_distributions 就会为 Context(name='requests') 或``Context(name=None)`` 产生 DatabaseDistribution

为简单起见,本例忽略了 context.pathpath 属性默认为 sys.path,是搜索中考虑的导入路径集。一个 DatabaseImporter 可以在不考虑搜索路径的情况下运作。假设导入器不进行分区,那么 "path" 就无关紧要了。为了说明 path 的作用,示例需要展示一个更复杂的 DatabaseImporter,它的行为随 sys.path/PYTHONPATH 而变化。在这种情况下,find_distributions 应该尊重 context.path,并且只产生与该路径相关的 Distribution

那么,DatabaseDistribution 看起来就像是这样:

class DatabaseDistribution(importlib.metadata.Distributon):
    def __init__(self, record):
        self.record = record

    def read_text(self, filename):
        """
        Read a file like "METADATA" for the current distribution.
        """
        if filename == "METADATA":
            return f"""Name: {self.record.name}
Version: {self.record.version}
"""
        if filename == "entry_points.txt":
            return "\n".join(
              f"""[{ep.group}]\n{ep.name}={ep.value}"""
              for ep in self.record.entry_points)

    def locate_file(self, path):
        raise RuntimeError("This distribution has no file system")

这个基本实现应当为由 DatabaseImporter 进行服务的包提供元数据和入口点,假定 record 提供了适当的 .name, .version.entry_points 属性。

DatabaseDistribution 还可能提供其他元数据文件,比如 RECORD (对 Distribution.files 来说需要) 或重写 Distribution.files 的实现。 请参看源代码深入了解。