importlib.metadata – 访问包元数据

在 3.8 版本加入。

在 3.10 版本发生变更: importlib.metadata 不再是临时性质的。

源代码: Lib/importlib/metadata/__init__.py

importlib.metadata 是一个库,它提供了对已安装分发包的元数据的访问,例如其入口点或其顶层名称(导入包、模块,如果有的话)。这个库部分建立在 Python 的导入系统之上,旨在取代 pkg_resourcesentry point APImetadata API 的类似功能。与 importlib.resources 一起,这个包可以让你不再需要使用陈旧且效率较低的 pkg_resources 包。

importlib.metadata 操作的是通过 pip 等工具安装到 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_scriptsdistutils.commands 等。每个组都包含一个 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']

你还可以获取分发包的版本号、列出其组成文件,并获取该分发包的依赖项列表。

exception importlib.metadata.PackageNotFoundError

ModuleNotFoundError 的子类,当查询当前 Python 环境中未安装的分发包时,本模块中的几个函数会引发此异常。

函数式 API

该包通过其公共 API 提供以下功能。

入口点

importlib.metadata.entry_points(**select_params)

返回一个描述当前环境中入口点的 EntryPoints 实例。任何给定的关键字参数都会传递给 select() 方法,用于与单个入口点定义的属性进行比较。

注意:目前无法根据入口点的 EntryPoint.dist 属性来查询入口点(因为不同的 Distribution 实例即使具有相同的属性,目前也无法比较相等)。

class importlib.metadata.EntryPoints

已安装入口点集合的详细信息。

还提供了一个 .groups 属性,用于报告所有已识别的入口点分组,以及一个 .names 属性,用于报告所有已识别的入口点名称。

class importlib.metadata.EntryPoint

已安装入口点的详细信息。

每个 EntryPoint 实例都具有 .name.group.value 属性,以及一个用于解析值的 .load() 方法。还有 .module.attr.extras 属性,用于获取 .value 属性的各个组成部分,以及 .dist 属性,用于获取提供该入口点的分发包的相关信息。

查询所有入口点

>>> eps = entry_points()

entry_points() 函数返回一个 EntryPoints 对象,它是所有 EntryPoint 对象的集合,为方便起见,它还带有 namesgroups 属性

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

EntryPoints 有一个 select() 方法,用于选择匹配特定属性的入口点。选择 console_scripts 组中的入口点

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

同样地,由于 entry_points() 将关键字参数传递给 select

>>> 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 是包作者定义的任意值,通常客户端会希望解析特定组的所有入口点。阅读 setuptools 文档以获取更多关于入口点、其定义和用法的信息。

在 3.12 版本发生变更: “可选择”的入口点是在 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__())。

分发包元数据

importlib.metadata.metadata(distribution_name)

返回与指定分发包名称对应的分发包元数据,以 PackageMetadata 实例的形式。

如果指定的发行包未在当前 Python 环境中安装,则会引发 PackageNotFoundError

class importlib.metadata.PackageMetadata

PackageMetadata 协议的一个具体实现。

除了提供定义的协议方法和属性之外,对实例进行下标操作等同于调用 get() 方法。

每个分发包都包含一些元数据,你可以使用 metadata() 函数提取这些元数据

>>> wheel_metadata = metadata('wheel')

返回的数据结构的键是元数据关键字的名称,其值是从分发包元数据中未经解析返回的

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

PackageMetadata 还提供了一个 json 属性,它会根据 PEP 566 以 JSON 兼容的格式返回所有元数据

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

此处未描述所有可用的元数据。请参阅 PyPA 核心元数据规范以获取更多详细信息。

在 3.10 版本发生变更: 现在,Description 在通过有效负载呈现时包含在元数据中。行连续字符已被移除。

添加了 json 属性。

分发包版本

importlib.metadata.version(distribution_name)

为指定分发包返回已安装分发包的版本

如果指定的发行包未在当前 Python 环境中安装,则会引发 PackageNotFoundError

version() 函数是获取分发包版本号(以字符串形式)的最快方式

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

分发包文件

importlib.metadata.files(distribution_name)

返回指定分发包中包含的完整文件集。

如果指定的发行包未在当前 Python 环境中安装,则会引发 PackageNotFoundError

如果找到了分发包,但报告与该分发包关联的文件的安装数据库记录缺失,则返回 None

class importlib.metadata.PackagePath

一个派生自 pathlib.PurePath 的对象,带有额外的 distsizehash 属性,这些属性对应于该分发包安装元数据中关于该文件的信息。

files() 函数接受一个分发包名称,并返回该分发包安装的所有文件。每个文件都以 PackagePath 实例的形式报告。例如

>>> 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')

在列出文件的元数据文件(RECORDSOURCES.txt)缺失的情况下,files() 将返回 None。如果目标分发包不确定是否包含元数据,调用者可能希望将对 files() 的调用包装在 always_iterable 中,或者采取其他措施来防止这种情况。

分发包的依赖

importlib.metadata.requires(distribution_name)

返回指定分发包声明的依赖说明符。

如果指定的发行包未在当前 Python 环境中安装,则会引发 PackageNotFoundError

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

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

映射导入包与分发包

importlib.metadata.packages_distributions()

返回一个映射,该映射将通过 sys.meta_path 找到的顶层模块和导入包名称,映射到提供相应文件的分发包名称(如果有的话)。

为了支持命名空间包(其成员可能由多个分发包提供),每个顶层导入名称都映射到一个分发包名称列表,而不是直接映射到单个名称。

一个方便的方法,用于解析提供每个可导入的顶层 Python 模块或导入包分发包名称(在命名空间包的情况下可能是多个名称)

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

一些可编辑安装模式不提供顶层名称,因此该函数在这种安装模式下并不可靠。

在 3.10 版本加入。

分发包

importlib.metadata.distribution(distribution_name)

返回一个描述指定分发包的 Distribution 实例。

如果指定的发行包未在当前 Python 环境中安装,则会引发 PackageNotFoundError

class importlib.metadata.Distribution

已安装分发包的详细信息。

注意:不同的 Distribution 实例目前不会比较为相等,即使它们涉及同一个已安装的分发包并因此具有相同的属性。

虽然上面描述的模块级 API 是最常见和最方便的用法,但你可以从 Distribution 类中获取所有这些信息。Distribution 是一个抽象对象,代表了 Python 分发包的元数据。你可以通过调用 distribution() 函数来获取已安装分发包的具体 Distribution 子类实例

>>> from importlib.metadata import distribution
>>> dist = distribution('wheel')
>>> type(dist)
<class 'importlib.metadata.PathDistribution'>

因此,获取版本号的另一种方法是通过 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'

此处未描述所有可用的元数据。请参阅 PyPA 核心元数据规范以获取更多详细信息。

在 3.13 版本加入: 添加了 .origin 属性。

分发包的发现

默认情况下,本包为文件系统和 zip 文件中的分发包提供了元数据发现的内置支持。此元数据查找器默认搜索 sys.path,但它对这些值的解释方式与其他导入机制略有不同。特别是:

  • importlib.metadata 不会处理 sys.path 上的 bytes 对象。

  • importlib.metadata 会顺带处理 sys.path 上的 pathlib.Path 对象,尽管这些值在导入时会被忽略。

实现自定义提供程序

importlib.metadata 涉及两个 API 层面,一个用于消费者,另一个用于提供者。大多数用户是消费者,消费由包提供的元数据。但是,也存在其他用例,用户希望通过其他机制(例如,与自定义导入器一起)公开元数据。这样的用例需要一个自定义提供者

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

该实现已将钩子集成到 PathFinder 中,为在文件系统上找到的分发包提供元数据。

抽象类 importlib.abc.MetaPathFinder 定义了 Python 导入系统期望查找器遵循的接口。importlib.metadata 扩展了此协议,它会在 sys.meta_path 的查找器上寻找一个可选的 find_distributions 可调用对象,并将这个扩展接口呈现为 DistributionFinder 抽象基类,该基类定义了以下抽象方法:

@abc.abstractmethod
def find_distributions(context=DistributionFinder.Context()) -> Iterable[Distribution]:
    """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.0 在数据库中,那么对于 Context(name='requests')Context(name=None)find_distributions 将产生一个 DatabaseDistribution

为了简单起见,此示例忽略了 context.pathpath 属性默认为 sys.path,并且是搜索中要考虑的导入路径集合。一个 DatabaseImporter 可能无需关心搜索路径即可运行。假设导入器不做任何分区,那么“路径”将是无关紧要的。为了说明 path 的目的,该示例需要展示一个更复杂的 DatabaseImporter,其行为会根据 sys.path/PYTHONPATH 而变化。在这种情况下,find_distributions 应该遵循 context.path,并且只产生与该路径相关的 Distributions。

然后,DatabaseDistribution 看起来会是这样

class DatabaseDistribution(importlib.metadata.Distribution):
    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")

假设 record 提供了适当的 .name.version.entry_points 属性,这个基本实现应该能为 DatabaseImporter 提供的包提供元数据和入口点。

DatabaseDistribution 还可以提供其他元数据文件,例如 RECORDDistribution.files 所需),或者重写 Distribution.files 的实现。请参阅源代码以获取更多灵感。