Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

PEP 725 – 在 pyproject.toml 中指定外部依赖项

作者:
Pradyun Gedam <pradyunsg at gmail.com>, Ralf Gommers <ralf.gommers at gmail.com>
讨论列表:
Discourse 帖子
状态:
草案
类型:
标准跟踪
主题:
打包
创建:
2023年8月17日
修订历史:
2023年8月18日

目录

摘要

本 PEP 指定了如何在 pyproject.toml 文件中编写项目的外部(或非 PyPI)构建和运行时依赖项,以便与打包相关的工具使用。

本 PEP 提出向 pyproject.toml 添加一个 [external] 表,其中包含三个键:“build-requires”、“host-requires”和“dependencies”。这些用于指定三种类型的依赖项

  1. build-requires,在构建机器上运行的构建工具
  2. host-requires,主机机器所需的构建依赖项,但在构建时也需要。
  3. dependencies,主机机器在运行时需要,但在构建时不需要。

通过区分构建和主机依赖项来考虑交叉编译。可选的构建时和运行时依赖项也得到支持,其方式类似于在 [project] 表中支持的方式。

动机

Python 包可能依赖于构建工具、库、命令行工具或 PyPI 上不存在的其他软件。目前,无法在标准化的元数据[1][2]中表达这些依赖项。本 PEP 的主要动机是:

  • 使工具能够自动将外部依赖项映射到其他打包存储库中的包,
  • 使包含必要的依赖项到 Python 包安装程序和构建前端发出的错误消息中成为可能,
  • 为包作者提供一个规范的位置来记录此依赖项信息。

像 Linux 发行版、Conda、Homebrew、Spack 和 Nix 这样的打包生态系统需要 Python 包的完整依赖项集,并拥有像pyp2spec(Fedora)、Grayskull(Conda)和dh_python(Debian)这样的工具,这些工具尝试从上游 Python 包的元数据中自动生成其自身包管理器所需的依赖项元数据。外部依赖项目前是手动处理的,因为 pyproject.toml 或任何其他标准位置都没有此元数据。启用此转换的自动化是本 PEP 的主要好处,使为发行版打包 Python 包变得更容易和更可靠。此外,作者设想其他类型的工具利用此信息,例如依赖项分析工具,如RepologyDependabotlibraries.io。软件物料清单 (SBOM) 生成工具也可能能够使用此信息,例如,用于标记 pyproject.toml 中列出的但未包含在 wheel 元数据中的外部依赖项可能在 wheel 中被捆绑。

具有外部依赖项的包通常难以从源代码构建,并且构建失败的错误消息对于最终用户来说往往难以理解。最终用户系统上缺少外部依赖项是构建失败最可能的原因。如果安装程序可以在其错误消息中显示所需的外部依赖项,这可能会为用户节省大量时间。

目前,有关外部依赖项的信息仅捕获在各个包的安装文档中。对于包作者来说,维护起来很困难,而且往往会过时。用户和发行版打包人员也很难找到它。拥有一个规范的位置来记录此依赖项信息将改善这种情况。

本 PEP 并非试图指定如何使用外部依赖项,也不是实现从发布在 PyPI 上的 Python 项目的规范名称到其他打包生态系统的名称的名称映射的机制。这些主题应在单独的 PEP 中解决。

基本原理

外部依赖项的类型

可以区分多种类型的外部依赖项

  • 可以通过名称识别并在另一个特定于语言的包存储库中具有规范位置的具体包。例如,crates.io 上的 Rust 包、CRAN 上的 R 包、npm 注册表 上的 JavaScript 包。
  • 可以通过名称识别但没有明确规范位置的具体包。这通常是针对用 C、C++、Fortran、CUDA 和其他低级语言编写的库和工具的情况。例如,Boost、OpenSSL、Protobuf、Intel MKL、GCC。
  • “虚拟”包,它们是概念、工具类型或接口的名称。这些通常有多个实现,这些实现具体包。例如,C++ 编译器、BLAS、LAPACK、OpenMP、MPI。

具体包易于理解,并且是几乎每个包管理系统中都存在的概念。虚拟包也是许多打包系统中的一个概念——但并非总是如此,并且其实现细节各不相同。

交叉编译

交叉编译尚未(截至 2023 年 8 月)得到标准库模块和 pyproject.toml 元数据的良好支持。但是,在将外部依赖项转换为其他打包系统的依赖项时(使用像 pyp2spec 这样的工具),这一点很重要。在本 PEP 中立即引入对交叉编译的支持比将来扩展 [external] 要容易得多,因此作者选择现在包含它。

术语

本 PEP 使用以下术语

  • 构建机器:执行包构建过程的机器
  • 主机机器:将生成工件安装和运行的机器
  • 构建依赖项:构建包所需的依赖项,在构建时需要存在,并且本身是为构建机器的操作系统和架构构建的
  • 主机依赖项:构建包所需的依赖项,在构建时需要存在,并且本身是为主机机器的操作系统和架构构建的

请注意,此术语在构建和打包工具之间并不一致,因此在比较 pyproject.toml 中的构建/主机依赖项与其他包管理器的依赖项时,必须小心。

请注意,本 PEP 中未使用“目标机器”或“目标依赖项”。这通常仅与交叉编译编译器或其他此类高级方案[3][4]相关——这超出了本 PEP 的范围。

最后,请注意,虽然“依赖项”是在构建时需要的包最广泛使用的术语,但在 pyproject.toml 中用于 PyPI 构建时依赖项的现有键是 build-requires。因此,本 PEP 在 [external] 下使用键 build-requireshost-requires 以保持一致性。

构建和主机依赖项

构建和目标平台定义相关元数据的清晰分离,而不是假设构建和目标平台始终相同,这一点很重要[5]

构建依赖项通常在构建过程中运行——它们可能是编译器、代码生成器或其他此类工具。如果使用构建依赖项意味着运行时依赖项,则不必显式声明该运行时依赖项。例如,当使用 gfortran 将 Fortran 代码编译成 Python 扩展模块时,该包可能会产生对 libgfortran 运行时库的依赖关系。不显式列出此类运行时依赖项的基本原理有两个方面:(1)它可能取决于编译器/链接器标志或构建环境的细节,依赖项是否存在,以及(2)这些运行时依赖项可以被像 auditwheel 这样的工具自动检测和处理。

主机依赖项通常不会在构建过程中运行,而仅用于链接。但这并不是一个规则——在模拟器下或通过像crossenv这样的自定义工具运行主机依赖项可能是可能的或必要的。当主机依赖项意味着运行时依赖项时,也不必声明该运行时依赖项,就像构建依赖项一样。

当声明主机依赖项并且工具没有跨编译感知能力,并且必须处理外部依赖项时,工具**可以**将host-requires列表合并到build-requires中。例如,当软件包从 sdist 构建失败时,如果像pip这样的安装程序开始将外部依赖项报告为构建失败的可能原因,则可能会发生这种情况。

指定外部依赖项

通过 PURL 指定具体包

这两种具体的包类型都受PURL(包 URL)的支持,PURL 实现了一种用于识别包的方案,旨在跨打包生态系统可移植。其设计是

scheme:type/namespace/name@version?qualifiers#subpath

scheme组件是一个固定字符串pkg,在其他组件中,只有typename是必需的。例如,PyPI 上requests软件包的包 URL 为

pkg:pypi/requests

采用 PURL 在pyproject.toml中指定外部依赖项可以同时解决许多问题——并且 Python 和多种语言中已经存在该规范的实现。PURL 也已得到 SPDX(参见SPDX 2.3 规范中的外部存储库标识符)、开源漏洞格式Sonatype OSS Index等依赖项相关工具的支持;无需等待数年才能获得此类工具的支持非常有价值。

对于没有规范包管理器可供参考的具体包,可以使用pkg:generic/pkg-name,或者直接引用维护该包的 VCS 系统(例如pkg:github/user-or-org-name/pkg-name)。哪种更合适取决于具体情况。本 PEP 建议当包名称明确且众所周知时使用pkg:generic(例如pkg:generic/gitpkg:generic/openblas),否则使用 VCS 作为 PURL 类型。

虚拟包规范

PURL 或其他标准中尚无现成的虚拟包支持。但是,此类依赖项的数量相对有限,采用类似于 PURL 但使用virtual:而不是pkg:方案似乎易于理解,并且可以很好地映射到具有虚拟包的 Linux 发行版以及 Conda 和 Spack 等。

两种已知的虚拟包类型是compilerinterface

版本控制

PURL 中对版本表达式和超出固定版本的范围的支持仍在等待中,请参阅“未解决的问题”部分。

依赖项说明符

可以在 PURL 后使用常规的 Python 依赖项指定符(最初在PEP 508中定义)。不能使用 PURL 限定符,PURL 限定符使用?后跟包类型特定的依赖项指定符组件。这样做的原因很实际:依赖项指定符已用于pyproject.toml中的其他元数据,任何与pyproject.toml一起使用的工具都可能已经具有强大的实现来解析它。并且我们预计不需要 PURL 限定符提供的额外可能性(例如,指定 Conan 或 Conda 通道或 RubyGems 平台)。

核心元数据字段的使用

核心元数据规范包含一个相关字段,即Requires-External。在核心元数据 2.1 中,它没有明确定义的语义;本 PEP 选择重用该字段表示外部运行时依赖项。核心元数据规范不包含pyproject.toml[build-system]表中任何元数据的字段。因此,build-requireshost-requires内容也不需要反映在核心元数据字段中。optional-dependencies内容来自[external],需要重用Provides-Extra或需要一个新的Provides-External-Extra字段。这两种方法似乎都不理想。

sdist 和 wheel 元数据之间的差异

wheel 可以提供其外部依赖项。这尤其发生在将 wheel 分发到 PyPI 或其他 Python 包索引时——auditwheeldelvewheeldelocate等工具会自动执行此过程。因此,sdist 中的Requires-External条目可能会从根据该 sdist 构建的 wheel 中消失。wheel 中也可能保留Requires-External条目,该条目保持不变或约束条件更严格。auditwheel默认情况下不会提供某些允许列出的依赖项,例如 OpenGL。此外,auditwheeldelvewheel允许用户通过--exclude--no-dll命令行标志手动排除依赖项。例如,这用于避免提供大型共享库,例如来自 CUDA 的共享库。

因此,wheel 中从pyproject.toml中的外部依赖项生成的Requires-External条目可以比相应 sdist 的条目更严格。它们不能更宽松,即约束条件不得允许 wheel 的依赖项版本,而该版本对于 sdist 不允许,也不得包含根本未列在 sdist 的元数据中的新依赖项。

依赖项的规范名称和-dev(el) 分割包

发行版将软件包拆分为两个或多个软件包的情况相当普遍。特别是,运行时组件通常与开发组件(头文件、pkg-config 和 CMake 文件等)分开安装。然后,后者通常在项目/库名称后附加-dev-devel。此拆分是每个发行版维护的责任,不应反映在[external]表中。无法以跨发行版有效的方式指定此内容,因此[external]中应仅使用规范名称。

使用 PURL 或虚拟依赖项的预期含义是“具有指定名称的完整包”。拆分是否相关将取决于使用元数据的上下文。例如,如果libffi是主机依赖项,并且工具想要准备一个用于构建 wheel 的环境,那么如果发行版已将libffi的头文件拆分到libffi-devel包中,则工具必须同时安装libffilibffi-devel

Python 开发头文件

Python 头文件和其他构建支持文件也可能被拆分。这与上一节中的情况相同(因为 Python 在发行版中只是一个常规包)。但是python-dev|devel依赖项是特殊的,因为在pyproject.toml中,Python 本身是隐式依赖项而不是显式依赖项。因此,此处需要做出选择——隐式添加python-dev,或者让每个包作者在[external]下显式添加它。为了保持 Python 依赖项和外部依赖项之间的一致性,我们选择隐式添加它。当[external]表包含一个或多个编译器包时,必须假定需要 Python 开发头文件。

规范

如果元数据指定不正确,则工具**必须**引发错误以通知用户其错误。

细节

请注意,pyproject.toml内容与PEP 621中的格式相同。

表名

工具**必须**在名为[external]的表中指定本 PEP 定义的字段。任何工具都不得向此表添加本 PEP 或后续 PEP 未定义的字段。缺少[external]表意味着软件包要么没有任何外部依赖项,要么它具有的依赖项被假定已存在于系统上。

build-requires/optional-build-requires

  • 格式:PURL字符串数组(build-requires)和一个包含PURL字符串数组值的表(optional-build-requires
  • 核心元数据:N/A

构建项目所需的(可选)外部构建要求。

对于build-requires,它是一个键,其值为字符串数组。每个字符串表示项目的构建要求,并且**必须**格式化为有效的PURL字符串或virtual:字符串。

对于optional-build-requires,它是一个表,其中每个键指定一组额外的构建要求,其值为字符串数组。数组的字符串**必须**是有效的PURL字符串。

host-requires/optional-host-requires

  • 格式:PURL字符串数组(host-requires)和一个包含PURL字符串数组值的表(optional-host-requires
  • 核心元数据:N/A

构建项目所需的(可选)外部主机要求。

对于host-requires,它是一个键,其值为字符串数组。每个字符串表示项目的构建要求,并且**必须**格式化为有效的PURL字符串或virtual:字符串。

对于optional-host-requires,它是一个表,其中每个键指定一组额外的构建要求,其值为字符串数组。数组的字符串**必须**是有效的PURL字符串。

dependencies/optional-dependencies

  • 格式:PURL 字符串数组(dependencies)和一个包含 PURL 字符串数组值的表格(optional-dependencies
  • 核心元数据Requires-External,N/A

项目的(可选)运行时依赖项。

对于 dependencies,它是一个键,其值是一个字符串数组。每个字符串表示项目的一个依赖项,并且必须格式化为有效的 PURL 字符串或 virtual: 字符串。每个字符串直接映射到 核心元数据 中的 Requires-External 条目。

对于 optional-dependencies,它是一个表格,其中每个键指定一个额外项,其值是一个字符串数组。数组中的字符串必须是有效的 PURL 字符串。可选依赖项不会映射到核心元数据字段。

示例

这些示例显示了预期多个包的 [external] 内容是什么。

cryptography 39.0

[external]
build-requires = [
  "virtual:compiler/c",
  "virtual:compiler/rust",
  "pkg:generic/pkg-config",
]
host-requires = [
  "pkg:generic/openssl",
  "pkg:generic/libffi",
]

SciPy 1.10

[external]
build-requires = [
  "virtual:compiler/c",
  "virtual:compiler/cpp",
  "virtual:compiler/fortran",
  "pkg:generic/ninja",
  "pkg:generic/pkg-config",
]
host-requires = [
  "virtual:interface/blas",
  "virtual:interface/lapack",  # >=3.7.1 (can't express version ranges with PURL yet)
]

Pillow 10.1.0

[external]
build-requires = [
  "virtual:compiler/c",
]
host-requires = [
  "pkg:generic/libjpeg",
  "pkg:generic/zlib",
]

[external.optional-host-requires]
extra = [
  "pkg:generic/lcms2",
  "pkg:generic/freetype",
  "pkg:generic/libimagequant",
  "pkg:generic/libraqm",
  "pkg:generic/libtiff",
  "pkg:generic/libxcb",
  "pkg:generic/libwebp",
  "pkg:generic/openjpeg",  # add >=2.0 once we have version specifiers
  "pkg:generic/tk",
]

NAVis 1.4.0

[project.optional-dependencies]
r = ["rpy2"]

[external]
build-requires = [
  "pkg:generic/XCB; platform_system=='Linux'",
]

[external.optional-dependencies]
nat = [
  "pkg:cran/nat",
  "pkg:cran/nat.nblast",
]

Spyder 6.0

[external]
dependencies = [
  "pkg:cargo/ripgrep",
  "pkg:cargo/tree-sitter-cli",
  "pkg:golang/github.com/junegunn/fzf",
]

jupyterlab-git 0.41.0

[external]
dependencies = [
  "pkg:generic/git",
]

[external.optional-build-requires]
dev = [
  "pkg:generic/nodejs",
]

PyEnchant 3.2.2

[external]
dependencies = [
  # libenchant is needed on all platforms but only vendored into wheels on
  # Windows, so on Windows the build backend should remove this external
  # dependency from wheel metadata.
  "pkg:github/AbiWord/enchant",
]

向后兼容性

这对向后兼容性没有影响,因为此 PEP 仅添加新的可选元数据。在没有此类元数据的情况下,对于包作者或打包工具而言,没有任何变化。

安全影响

没有直接的安全问题,因为此 PEP 涵盖了如何静态定义外部依赖项的元数据。任何安全问题都源于工具如何使用元数据并选择对其进行操作。

如何教授

外部依赖项以及是否以及如何对这些外部依赖项进行打包是 Python 包作者通常不详细了解的主题。我们打算从外部依赖项的定义方式开始,它可以依赖的不同方式——从使用 ctypessubprocess 调用进行运行时依赖,到它是链接到的构建依赖——然后深入探讨如何在元数据中声明外部依赖项。文档应明确说明哪些与包作者相关,哪些与发行版打包人员相关。

有关此主题的材料将添加到最相关的打包教程中,主要是 Python 打包用户指南。此外,我们预计任何添加对外部依赖项元数据支持的构建后端都将在其文档中包含相关信息,就像 auditwheel 等工具一样。

参考实现

此 PEP 包含元数据规范,而不是代码功能 - 因此不会有代码将元数据规范作为整体实现。但是,有些部分确实有参考实现。

  1. [external] 表格必须是有效的 TOML,因此可以使用 tomllib 加载。
  2. PURL 规范作为此规范的关键部分,拥有一个 Python 包,其中包含用于构建和解析 PURL 的参考实现:packageurl-python

一旦将元数据添加到 Python 包中,此元数据就会有多个可能的使用者和用例。可以在 rgommers/external-deps-build 中找到来自 PyPI 的前 150 个下载量最大的包的测试元数据,这些包发布了特定于平台的 wheel。这些元数据已通过在干净的 Docker 容器中使用修补了这些元数据的 sdist 构建 wheel 来进行验证。

被拒绝的想法

也打包在 PyPI 上的外部依赖项的特定语法

有一些非 Python 包打包在 PyPI 上,例如 Ninja、patchelf 和 CMake。通常希望使用这些包的系统版本,如果系统上不存在,则安装其 PyPI 包。作者认为,不需要(或过于复杂以至于不值得)为此场景提供特定的支持;外部依赖项的依赖项提供者可以将 PyPI 视为获取包的一个可能来源。

使用库和头文件名称作为外部依赖项

以前的 PEP 草案(“外部依赖项”(2015))建议使用特定的库和头文件名称作为外部依赖项。这过于细粒度;使用包名称是跨打包生态系统中已建立良好的模式,应优先考虑。

未解决的问题

PURL 的版本说明符

PURL 对版本表达式和范围的支持仍在等待。在 vers implementation for PURL 中的拉取请求似乎即将合并,届时此 PEP 可以采用它。

虚拟依赖项的版本控制

一旦 PURL 支持版本表达式,虚拟依赖项就可以使用相同的语法进行版本控制。但是,必须更好地指定版本方案是什么,因为对于虚拟依赖项来说,这不像 PURL 那样清晰(例如,可能存在多个实现,并且抽象接口可能无法明确地版本控制)。例如:

  • OpenMP:其标准具有常规的 MAJOR.MINOR 版本,因此看起来像 >=4.5
  • BLAS/LAPACK:应使用 Reference LAPACK 使用的版本控制,它定义了标准 API 是什么。使用 MAJOR.MINOR.MICRO,因此看起来像 >=3.10.0
  • 编译器:这些实现了语言标准。对于 C、C++ 和 Fortran,这些都是按年份版本控制的。为了使版本正确排序,我们选择使用完整年份(四位数字)。因此,“至少 C99”将是 >=1999,选择 C++14 或 Fortran 77 将分别为 ==2014==1977。其他语言可能使用不同的版本控制方案。在 pyproject.toml 中使用这些方案之前,应在某个地方对其进行描述。

一个后勤挑战是描述版本控制的位置 - 鉴于这会随着时间的推移而发展,此 PEP 本身并不是描述它的正确位置。相反,此 PEP 应该指向该(待创建)位置。

谁定义规范名称和规范包结构?

与版本控制相关的后勤问题类似的是关于允许哪些名称以及在何处描述它们的问题。然后是谁控制该描述并负责维护它。我们暂定的答案是:应该有一个虚拟依赖项和 pkg:generic PURL 的中央列表,作为 PyPA 项目维护。请参见 https://discuss.python.org/t/pep-725-specifying-external-dependencies-in-pyproject-toml/31888/62。待办事项:一旦该列表/项目被原型化,将其包含在 PEP 中并关闭此未解决的问题。

虚拟依赖项的语法

此 PEP 当前用于虚拟依赖项的语法是 virtual:type/name,这类似于但不是 PURL 规范的一部分。此未解决的问题讨论了在 PURL 中支持虚拟依赖项:purl-spec#222

是否应在[build-system]下添加host-requires键?

添加 host-requires 以用于 PyPI 上的主机依赖项,以便更好地支持名称映射到其他支持交叉编译的打包系统可能是有意义的。此问题 跟踪此主题,并提供了支持和反对在 [build-system] 下添加 host-requires 作为此 PEP 一部分的论点。

参考文献


来源:https://github.com/python/peps/blob/main/peps/pep-0725.rst

上次修改时间:2023-12-06 20:48:29 GMT