PEP 517 – 构建系统无关的源代码树格式
- 作者:
- Nathaniel J. Smith <njs at pobox.com>,Thomas Kluyver <thomas at kluyver.me.uk>
- BDFL代表:
- Alyssa Coghlan <ncoghlan at gmail.com>
- 讨论列表:
- Distutils-SIG 列表
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 主题:
- 打包
- 创建日期:
- 2015年9月30日
- 修订历史:
- 2015年10月1日,2015年10月25日,2017年5月19日,2017年9月11日
- 决议:
- Distutils-SIG 消息
摘要
虽然 distutils
/ setuptools
已经让我们走得很远,但它们存在三个严重问题:(a)它们缺少重要的功能,例如可用的构建时依赖项声明、自动配置,甚至像 DRY 兼容的版本号管理等基本的人体工程学细节,以及(b)扩展它们很困难,因此虽然确实存在解决上述问题的各种解决方案,但它们通常很古怪、脆弱且维护成本高,然而(c)很难使用其他任何东西,因为 distutils/setuptools 提供了用户和安装工具(如 pip
)期望的标准软件包安装接口。
之前的尝试(例如 distutils2 或 setuptools 本身)试图解决问题 (a) 和/或 (b)。本提案旨在解决 (c)。
本 PEP 的目标是让 distutils-sig 不再充当 Python 构建系统的守门人。如果你想使用 distutils,很好;如果你想使用其他东西,那么应该可以使用标准化的方法轻松做到这一点。与 distutils 交互的难度意味着目前没有多少这样的系统,但为了让你了解我们正在考虑什么,请参阅 flit 或 bento。幸运的是,wheel 现在已经解决了这里许多难题——例如,构建系统不再需要了解所有可能的安装配置——因此我们实际上真正需要的构建系统仅仅是它具有一些方法来输出符合标准的 wheel 和 sdist。
因此,我们建议为安装工具(如 pip
)与软件包源代码树和源代码分发交互提供一个新的、相对最小的接口。
术语和目标
源代码树类似于 VCS 检出。我们需要一个标准的接口来从此格式安装,以支持诸如 pip install some-directory/
这样的用法。
源代码分发是表示某个源代码特定版本的静态快照,例如 lxml-3.4.4.tar.gz
。源代码分发用途广泛:它们构成版本的存档记录,它们为想要摄取和处理大量代码(可能用多种语言编写)(例如代码搜索)的工具提供了一个非常简单的实际标准,它们充当 Debian/Fedora/Conda/… 等下游打包系统的输入,等等。在 Python 生态系统中,它们还扮演着特别重要的角色,因为像 pip
这样的打包工具能够使用源代码分发来满足二进制依赖项,例如,如果存在一个分发文件 foo.whl
声明对 bar
的依赖,那么我们需要支持 pip install bar
或 pip install foo
自动定位 bar
的 sdist、下载它、构建它并安装生成的软件包的情况。
源代码分发简称为 sdist。
构建前端是用户可能运行的工具,它接受任意源代码树或源代码分发并从中构建 wheel。实际的构建由每个源代码树的 构建后端 完成。在像 pip wheel some-directory/
这样的命令中,pip 充当构建前端。
集成前端是用户可能运行的工具,它接受一组软件包需求(例如 requirements.txt 文件)并尝试更新工作环境以满足这些需求。这可能需要定位、构建和安装 wheel 和 sdist 的组合。在像 pip install lxml==2.4.0
这样的命令中,pip 充当集成前端。
源代码树
存在一个现有的、传统的源代码树格式,涉及 setup.py
。我们不尝试进一步指定它;它的实际规范编码在 distutils
、setuptools
、pip
和其他工具的源代码和文档中。我们将将其称为 setup.py
样式。
在这里,我们定义了一种新的源代码树样式,它基于 PEP 518 中定义的 pyproject.toml
文件,通过在该文件中的 [build-system]
表中添加一个额外的键 build-backend
来扩展它。以下是如何使用它的示例
[build-system]
# Defined by PEP 518:
requires = ["flit"]
# Defined by this PEP:
build-backend = "flit.api:main"
build-backend
是一个字符串,命名一个将用于执行构建的 Python 对象(有关详细信息,请参见下文)。它遵循与 setuptools
入口点相同的 module:object
语法进行格式化。例如,如果字符串是 "flit.api:main"
(如上例所示),则将通过执行等效于以下内容来查找此对象:
import flit.api
backend = flit.api.main
省略 :object
部分也是合法的,例如:
build-backend = "flit.api"
其作用类似于:
import flit.api
backend = flit.api
正式地,字符串应该满足以下语法
identifier = (letter | '_') (letter | '_' | digit)*
module_path = identifier ('.' identifier)*
object_path = identifier ('.' identifier)*
entry_point = module_path (':' object_path)?
然后,我们导入 module_path
,然后查找 module_path.object_path
(如果 object_path
缺失,则只查找 module_path
)。
在导入模块路径时,我们不会在包含源代码树的目录中查找,除非该目录本身就在 sys.path
上(例如,因为它在 PYTHONPATH 中指定)。尽管 Python 在某些情况下会自动将工作目录添加到 sys.path
,但用于解析后端的代码不应受此影响。
如果 pyproject.toml
文件不存在,或者 build-backend
键不存在,则源代码树未使用此规范,工具应恢复为运行 setup.py
的传统行为(直接运行或通过隐式调用 setuptools.build_meta:__legacy__
后端)。
如果存在 build-backend
键,则此键优先,源代码树遵循指定后端的格式和约定(因此,除非后端需要,否则不需要 setup.py
)。项目可能仍希望包含一个 setup.py
以与不使用此规范的工具兼容。
本 PEP 还定义了一个 backend-path
键,用于在 pyproject.toml
中使用,请参见下面的“树内构建后端”部分。此键将按如下方式使用
[build-system]
# Defined by PEP 518:
requires = ["flit"]
# Defined by this PEP:
build-backend = "local_backend"
backend-path = ["backend"]
构建需求
本 PEP 对 pyproject.toml
中的“构建需求”部分提出了一些其他要求。这些要求旨在确保项目不会在其构建需求中创建无法满足的条件。
- 项目构建需求将定义一个需求的有向图(项目 A 需要 B 来构建,B 需要 C 和 D,等等)。此图**不得**包含循环。如果(例如,由于项目之间缺乏协调)存在循环,则前端**可以**拒绝构建项目。
- 如果构建需求可以作为 wheel 获得,则前端**应该**在实际情况下使用这些 wheel,以避免深度嵌套的构建。但是,前端**可以**具有不考虑 wheel 来定位构建需求的模式,因此项目**不得**假设发布 wheel 足以打破需求循环。
- 前端**应该**明确检查需求循环,并在发现循环时以信息性消息终止构建。
特别要注意的是,对没有需求循环的要求意味着希望自托管(即,构建后端的 wheel 使用该后端进行构建)的后端需要做出特殊规定以避免导致循环。通常,这将涉及将自身指定为树内后端,并避免外部构建依赖项(通常是通过将其包含在内)。
构建后端接口
构建后端对象预计将具有提供部分或所有以下钩子的属性。常见的 config_settings
参数在各个钩子之后进行了描述。
强制钩子
build_wheel
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
...
必须构建一个 .whl 文件,并将其放置在指定的 wheel_directory
中。它必须返回其创建的 .whl
文件的基本名称(而不是完整路径),作为 Unicode 字符串。
如果构建前端之前已经调用了 prepare_metadata_for_build_wheel
并且依赖于此调用生成的 wheel 的元数据与之前的调用匹配,那么它应该将创建的 .dist-info
目录的路径作为 metadata_directory
参数提供。如果提供了此参数,则 build_wheel
**必须**生成一个具有相同元数据的 wheel。构建前端传入的目录**必须**与 prepare_metadata_for_build_wheel
创建的目录相同,包括它创建的任何无法识别的文件。
不提供 prepare_metadata_for_build_wheel
钩子的后端可以**静默忽略** build_wheel
的 metadata_directory
参数,或者在将其设置为除 None
之外的任何值时引发异常。
为了确保来自不同来源的 wheel 以相同的方式构建,前端可以先调用 build_sdist
,然后在解压后的 sdist 中调用 build_wheel
。但是,如果后端指示它缺少创建 sdist 的某些需求(见下文),则前端将回退到在源目录中调用 build_wheel
。
源目录可能是只读的。因此,后端应该准备好无需在源目录中创建或修改任何文件即可进行构建,但它们可以选择不处理这种情况,在这种情况下,错误将对用户可见。前端**不**负责对只读源目录进行任何特殊处理。
后端可以在缓存位置或临时目录中存储中间工件。任何缓存的存在或不存在都不应该对构建的最终结果产生重大影响。
build_sdist
def build_sdist(sdist_directory, config_settings=None):
...
必须构建一个 .tar.gz 源代码分发包,并将其放置在指定的 sdist_directory
中。它必须返回其创建的 .tar.gz
文件的基本名称(而不是完整路径),作为 Unicode 字符串。
一个 .tar.gz 源代码分发包 (sdist) 包含一个名为 {name}-{version}
(例如 foo-1.0
)的单个顶级目录,其中包含包的源文件。此目录还必须包含构建目录中的 pyproject.toml
文件,以及一个包含 PEP 345 中描述格式的元数据的 PKG-INFO 文件。虽然历史上 zip 文件也用作 sdist,但此钩子应该生成一个 gzip 压缩的 tarball。这已经是 sdist 的更常见格式,并且拥有一个一致的格式使得工具更简单。
生成的 tarball 应该使用现代的 POSIX.1-2001 pax tar 格式,该格式指定基于 UTF-8 的文件名。这还不是 Python 3.6 附带的 tarfile 模块的默认设置,因此使用 tarfile 模块的后端需要显式传递 format=tarfile.PAX_FORMAT
。
某些后端可能对创建 sdist 有额外的需求,例如版本控制工具。但是,某些前端可能更喜欢在生成 wheel 时制作中间 sdist,以确保一致性。如果后端由于缺少依赖项或其他充分理解的原因而无法生成 sdist,则它应该引发特定类型的异常,并将其作为后端对象上的 UnsupportedOperation
提供。如果前端在将 sdist 作为 wheel 的中间构建时遇到此异常,则它应该回退到直接构建 wheel。如果后端永远不会引发此异常,则它不需要定义此异常类型。
可选钩子
get_requires_for_build_wheel
def get_requires_for_build_wheel(config_settings=None):
...
此钩子**必须**返回一个包含字符串的额外列表,这些字符串包含 PEP 508 依赖项规范,这些规范超出了 pyproject.toml
文件中指定的规范,在调用 build_wheel
或 prepare_metadata_for_build_wheel
钩子时将安装这些依赖项。
示例
def get_requires_for_build_wheel(config_settings):
return ["wheel >= 0.25", "setuptools"]
如果未定义,则默认实现等效于 return []
。
prepare_metadata_for_build_wheel
def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None):
...
必须在指定的 metadata_directory
中创建一个包含 wheel 元数据的 .dist-info
目录(即,创建一个类似于 {metadata_directory}/{package}-{version}.dist-info/
的目录)。此目录**必须**是一个有效的 .dist-info
目录,如 wheel 规范中所定义,但它不需要包含 RECORD
或签名。此钩子还可以在此目录中创建其他文件,并且构建前端**必须**保留这些文件,但在其他方面忽略它们;这里的目的是,在元数据依赖于构建时决策的情况下,构建后端可能需要以某种方便的格式记录这些决策,以便实际的 wheel 构建步骤重用。
这必须返回其创建的 .dist-info
目录的基本名称(而不是完整路径),作为 Unicode 字符串。
如果构建前端需要此信息并且该方法未定义,则它应该调用 build_wheel
并直接查看生成的元数据。
get_requires_for_build_sdist
def get_requires_for_build_sdist(config_settings=None):
...
此钩子**必须**返回一个包含字符串的额外列表,这些字符串包含 PEP 508 依赖项规范,这些规范超出了 pyproject.toml
文件中指定的规范。在调用 build_sdist
钩子时将安装这些依赖项。
如果未定义,则默认实现等效于 return []
。
注意
可编辑安装
此 PEP 最初指定了另一个钩子 install_editable
,用于执行可编辑安装(如 pip install -e
)。由于主题的复杂性,它已被删除,但可能会在以后的 PEP 中指定。
简而言之,需要回答的问题包括:实现“可编辑安装”有哪些合理的方法?后端还是前端应该选择如何进行可编辑安装?如果前端执行此操作,它需要后端提供什么才能做到这一点。
配置设置
config_settings
此参数传递给所有钩子,它是一个任意字典,作为“逃生舱”供用户将临时配置传递到各个包构建中。构建后端**可以**为此字典分配任何它们喜欢的语义。构建前端**应该**提供一些机制,供用户指定要放入此字典中的任意字符串键/字符串值对。例如,它们可能支持某些语法,例如 --package-config CC=gcc
。如果用户提供了重复的字符串键,则构建前端**应该**将相应的字符串值组合成一个字符串列表。构建前端还可以为用户提供任意其他机制来将条目放入此字典中。例如,pip
可以选择将现代和旧版命令行参数的混合映射为
pip install \
--package-config CC=gcc \
--global-option="--some-global-option" \
--build-option="--build-option1" \
--build-option="--build-option2"
进入一个类似于以下内容的 config_settings
字典:
{
"CC": "gcc",
"--global-option": ["--some-global-option"],
"--build-option": ["--build-option1", "--build-option2"],
}
当然,用户有责任确保他们传递的选项对他们正在构建的特定构建后端和包有意义。
可以对钩子调用位置参数或关键字参数,因此实现钩子的后端应注意确保其签名与上述参数的顺序和名称都匹配。
所有钩子都在设置为源树根目录的工作目录中运行,并且**可以**在 stdout 和 stderr 上打印任意信息文本。它们**不得**从 stdin 读取,并且构建前端**可以**在调用钩子之前关闭 stdin。
构建前端可以捕获来自后端的 stdout 和/或 stderr。如果后端检测到输出流不是终端/控制台(例如 not sys.stdout.isatty()
),则它**应该**确保写入该流的任何输出都是 UTF-8 编码的。如果捕获的输出不是有效的 UTF-8,则构建前端**不得**失败,但它**可能**不会保留所有信息(例如,它可能会使用 Python 中的 *replace* 错误处理程序进行解码)。如果输出流是终端,则构建后端负责准确地呈现其输出,就像在终端中运行的任何程序一样。
如果钩子引发异常或导致进程终止,则表示错误。
构建环境
构建前端的职责之一是在构建后端将运行的 Python 环境中进行设置。
我们不要求使用任何特定的“虚拟环境”机制;构建前端可以使用 virtualenv 或 venv,或者根本不使用任何特殊机制。但无论使用哪种机制,**必须**满足以下条件
- 项目构建需求指定的全部需求都必须能够从 Python 中导入。特别是
get_requires_for_build_wheel
和get_requires_for_build_sdist
钩子在包含pyproject.toml
文件中指定的引导需求的环境中执行。prepare_metadata_for_build_wheel
和build_wheel
钩子在包含来自pyproject.toml
的引导需求以及get_requires_for_build_wheel
钩子指定的那些需求的环境中执行。build_sdist
钩子在包含来自pyproject.toml
的引导需求以及get_requires_for_build_sdist
钩子指定的那些需求的环境中执行。
- 即使对于构建环境生成的新的 Python 子进程,这也必须保持为真,例如以下代码
import sys, subprocess subprocess.check_call([sys.executable, ...])
必须生成一个可以访问项目所有构建需求的 Python 进程。这对于构建后端在子进程中运行旧版
setup.py
脚本等情况是必要的。 - 构建所需软件包提供的全部命令行脚本都必须存在于构建环境的 PATH 中。例如,如果一个项目声明对 flit 有构建依赖,那么以下方法必须能够运行 flit 命令行工具。
import subprocess import shutil subprocess.check_call([shutil.which("flit"), ...])
构建后端**必须**能够在满足上述条件的任何环境中运行。特别是,**不得**假设它可以访问除了标准库中存在或显式声明为构建依赖项的任何软件包。
前端应该在新的子进程中调用每个钩子,以便后端可以自由地更改进程全局状态(例如环境变量或工作目录)。将提供一个 Python 库,前端可以使用它以这种方式轻松调用钩子。
构建前端建议(非规范性)
构建前端**可以**使用任何机制来设置满足上述条件的构建环境。例如,简单地将所有构建依赖项安装到全局环境中就足以构建任何兼容的软件包——但这由于多种原因而并非最佳选择。本节包含给前端实现者的非规范性建议。
构建前端**应该**默认情况下为每个构建创建一个隔离的环境,其中仅包含标准库和任何显式请求的构建依赖项。这有两个好处。
- 它允许使用单个安装运行来构建具有矛盾构建依赖项的多个软件包。例如,如果 package1 构建依赖于 pbr==1.8.1,而 package2 构建依赖于 pbr==1.7.2,那么这两个软件包不能同时安装到全局环境中——当用户请求
pip install package1 package2
时,就会出现问题。或者,如果用户已经在其全局环境中安装了 pbr==1.8.1,而某个软件包构建依赖于 pbr==1.7.2,那么降级用户的版本将非常不礼貌。 - 它充当一种公共卫生措施,以最大限度地提高实际声明准确构建依赖项的软件包数量。我们可以向软件包作者发出所有措辞强烈的警告,但如果构建前端默认情况下不强制执行隔离,那么我们最终不可避免地会在 PyPI 上获得大量软件包,这些软件包在原始作者的机器上构建良好,但在其他地方却无法构建,这是一个没有人需要的麻烦。
但是,在某些情况下,构建依赖项也会以各种方式出现问题。例如,尽管我们尽了最大努力,但软件包作者可能会意外地遗漏一些关键的依赖项;或者,软件包可能会声明对 foo >= 1.0
的构建依赖,当 1.0 是最新版本时,它运行良好,但现在 1.1 发布了,它存在一个严重错误;或者,用户可能决定针对 numpy==1.7 构建软件包——覆盖软件包首选的 numpy==1.8——以确保生成的构建在 C ABI 级别与旧版本的 numpy 兼容(即使这意味着生成的构建不受上游支持)。因此,构建前端**应该**提供一些机制供用户覆盖上述默认值。例如,构建前端可以有一个 --build-with-system-site-packages
选项,该选项会导致在创建构建环境时将 --system-site-packages
选项传递给 virtualenv 或等效工具,或者一个 --build-requirements-override=my-requirements.txt
选项,该选项覆盖项目的正常构建依赖项。
这里的总体原则是,我们希望强制执行软件包**作者**的卫生,同时仍然允许**最终用户**在必要时打开引擎盖并应用胶带。
树内构建后端
在某些情况下,项目可能希望将构建后端的源代码直接包含在源代码树中,而不是通过 requires
键引用后端。预计会出现以下两种具体情况。
- 后端本身,它们希望使用自己的功能来构建自身(“自托管后端”)。
- 特定于项目的后端,通常由围绕标准后端的自定义包装器组成,其中包装器过于特定于项目,不值得单独分发(“树内后端”)。
项目可以通过在 pyproject.toml
中包含 backend-path
键来指定其后端代码托管在树内。此键包含一个目录列表,前端将在加载后端并运行后端钩子时将其添加到 sys.path
的开头。
backend-path
键的内容有两个限制。
backend-path
中的目录被解释为相对于项目根目录,并且**必须**引用源代码树中的位置(在解析相对路径和符号链接后)。- 后端代码**必须**从
backend-path
中指定的目录之一加载(即,不允许指定backend-path
并且*没有*树内后端代码)。
第一个限制是为了确保源代码树保持自包含,并且不能引用源代码树外部的位置。前端**应该**检查此条件(通常是通过将位置解析为绝对路径并解析符号链接,然后将其与项目根目录进行比较),并在违反此条件时失败并显示错误消息。
backend-path
功能旨在支持树内后端的实现,而不是允许配置现有后端。上述第二个限制专门用于确保此功能的使用方式。前端**可以**强制执行此检查,但不强制执行。这样做通常需要将后端的 __file__
属性与 backend-path
中的位置进行比较。
源代码分发
我们继续使用旧的 sdist 格式,并添加了一些新的限制。此格式大多未定义,但基本上归结为:一个名为 {NAME}-{VERSION}.{EXT}
的文件,它解压缩到一个名为 {NAME}-{VERSION}/
的可构建源代码树中。传统上,这些始终包含 setup.py
样式的源代码树;我们现在允许它们也包含 pyproject.toml
样式的源代码树。
集成前端要求名为 {NAME}-{VERSION}.{EXT}
的 sdist 将生成一个名为 {NAME}-{VERSION}-{COMPAT-INFO}.whl
的 wheel。
由 PEP 517 后端构建的 sdist 的新限制是。
- 它们将是 gzip 压缩的 tar 档案,扩展名为
.tar.gz
。目前不允许使用 zip 档案或 tarball 的其他压缩格式。 - tar 档案必须以现代 POSIX.1-2001 pax tar 格式创建,该格式使用 UTF-8 作为文件名。
- sdist 中包含的源代码树预计将包含
pyproject.toml
文件。
演变说明
这里的一个目标是尽可能简单地将旧样式的 sdist 转换为新样式的 sdist。(例如,这是支持动态构建依赖项的一个动机。)理想情况下,将有一个唯一的静态 pyproject.toml
可以放入任何“版本 0”的 VCS 检出中以将其转换为新的闪亮版本。这可能不是 100% 可行,但我们可以接近,并且跟踪我们有多接近非常重要……因此有了这一部分。
一个粗略的计划是:创建一个构建系统软件包(setuptools_pypackage
或其他)来了解如何使用我们想出的任何钩子语言,并将它们转换为对 setup.py
的调用。这可能需要对 setuptools 进行某种钩子或猴子补丁,以提供一种在需要时提取 setup_requires=
参数的方法,并提供 sdist 命令的新版本以生成新样式的格式。所有这些似乎都是可行的,并且足以满足很大一部分软件包的需求(尽管显然我们希望在最终确定任何内容之前对这样的系统进行原型设计)。(或者,这些更改可以对 setuptools 本身进行,而不是进入单独的软件包。)
但仍然存在两个障碍,这意味着我们可能无法自动将软件包升级到新格式。
- 目前存在一些软件包,它们坚持在执行 setup.py 之前在其环境中提供特定的软件包。这意味着如果我们决定在隔离的类似 virtualenv 的环境中执行构建脚本,那么项目将需要检查它们是否执行此操作,如果是,则在升级到新系统时,它们将必须开始显式声明这些依赖项(通过
setup_requires=
或通过pyproject.toml
中的静态声明)。 - 目前存在一些软件包,它们没有声明一致的元数据(例如,
egg_info
和bdist_wheel
可能获得不同的install_requires=
)。在升级到新系统时,项目将需要评估这是否适用于它们,如果是,则它们需要停止这样做。
被拒绝的选项
- 我们讨论过使 wheel 和 sdist 钩子构建包含与其各自档案相同内容的解压缩目录。在某些情况下,这可以避免打包和解压缩档案的需要,但这似乎是过早优化。工具使用档案作为规范的交换格式是有利的(尤其是对于 wheel,其档案格式已经标准化)。严格控制档案创建对于可重复构建非常重要。并且目前尚不清楚需要解压缩分发的任务是否会比需要档案的任务更常见。
- 我们考虑了一个额外的钩子,在调用
build_wheel
之前将文件复制到构建目录。查看现有的构建系统,我们发现将构建目录传递到build_wheel
对许多工具来说比预先将文件复制到构建目录更有意义。 - 然后,将构建目录传递给
build_wheel
的想法也被认为是不必要的复杂化。构建工具可以使用临时目录或缓存目录来存储构建期间的中间文件。如果需要,将来可以添加前端控制的缓存目录。 - 为了使
build_sdist
能够因预期原因而发出失败信号,我们对各种选项进行了长时间的讨论,包括抛出NotImplementedError
以及返回NotImplemented
或None
。请不要在没有 *非常* 充分理由的情况下尝试重新开启此讨论,因为我们已经对此感到厌倦了。 - 允许从源代码树中的文件导入后端,这与 Python 的导入方式更加一致。但是,不允许这样做可以防止由于模块名称冲突而导致的令人困惑的错误。此 PEP 的初始版本没有提供一种方法来允许从源代码树中的文件导入后端,但在下一个修订版本中添加了
backend-path
键,以允许项目在需要时选择此行为。
PEP 517 的更改摘要
在 pip 19.0 中发布初始参考实现之后,对本 PEP 进行了以下更改。
- 明确禁止构建需求中的循环依赖。
- 通过在
[build-system]
表格中引入backend-path
键,添加了对树内后端和后端自托管的支持。 - 澄清了
setuptools.build_meta:__legacy__
PEP 517 后端是直接调用setup.py
的可接受替代方案,适用于未明确指定build-backend
的源代码树。
附录 A:与 PEP 516 的比较
PEP 516 是一个竞争性的提案,用于指定构建系统接口,现已拒绝,转而支持本 PEP。主要区别在于,我们的构建后端是通过基于 Python 的钩子接口定义的,而不是基于命令行的接口。
本附录记录了本 PEP 相对于 PEP 516 的优势论点。
我们 *不* 认为仅仅指定 Python 钩子而不是命令行接口就能降低调用后端的复杂性,因为构建前端无论如何都希望在子进程中运行钩子——这对于隔离构建前端本身与后端代码以及更好地控制构建后端的执行环境非常重要。因此,在这两种方案下,都需要在 pip
中编写一些代码来生成子进程并与某种命令行/IPC 接口通信,并且需要在子进程中编写一些代码来了解如何解析这些命令行参数并调用实际的构建后端实现。因此,此图同样适用于所有提案。
+-----------+ +---------------+ +----------------+
| frontend | -spawn-> | child cmdline | -Python-> | backend |
| (pip) | | interface | | implementation |
+-----------+ +---------------+ +----------------+
两种方法之间的关键区别在于这些接口边界如何映射到项目结构。
.-= This PEP =-.
+-----------+ +---------------+ | +----------------+
| frontend | -spawn-> | child cmdline | -Python-> | backend |
| (pip) | | interface | | | implementation |
+-----------+ +---------------+ | +----------------+
|
|______________________________________| |
Owned by pip, updated in lockstep |
|
|
PEP-defined interface boundary
Changes here require distutils-sig
.-= Alternative =-.
+-----------+ | +---------------+ +----------------+
| frontend | -spawn-> | child cmdline | -Python-> | backend |
| (pip) | | | interface | | implementation |
+-----------+ | +---------------+ +----------------+
|
| |____________________________________________|
| Owned by build backend, updated in lockstep
|
PEP-defined interface boundary
Changes here require distutils-sig
通过将 PEP 定义的接口边界移入 Python 代码中,我们获得了三个关键优势。
**首先**,由于构建前端的数量可能很少(pip
,以及……可能还有其他几个?),而自定义构建后端的数量可能会很多(因为每个包都会根据其特定的构建需求单独选择后端),因此实际的图表可能更像是
.-= This PEP =-.
+-----------+ +---------------+ +----------------+
| frontend | -spawn-> | child cmdline | -Python+> | backend |
| (pip) | | interface | | | implementation |
+-----------+ +---------------+ | +----------------+
|
| +----------------+
+> | backend |
| | implementation |
| +----------------+
:
:
.-= Alternative =-.
+-----------+ +---------------+ +----------------+
| frontend | -spawn+> | child cmdline | -Python-> | backend |
| (pip) | | | interface | | implementation |
+-----------+ | +---------------+ +----------------+
|
| +---------------+ +----------------+
+> | child cmdline | -Python-> | backend |
| | interface | | implementation |
| +---------------+ +----------------+
:
:
也就是说,本 PEP 导致整个生态系统中的代码总量减少。特别是,它降低了创建新构建系统的入门门槛。例如,这是一个完整的、可工作的构建后端
# mypackage_custom_build_backend.py
import os.path
import pathlib
import shutil
import tarfile
SDIST_NAME = "mypackage-0.1"
SDIST_FILENAME = SDIST_NAME + ".tar.gz"
WHEEL_FILENAME = "mypackage-0.1-py2.py3-none-any.whl"
#################
# sdist creation
#################
def _exclude_hidden_and_special_files(archive_entry):
"""Tarfile filter to exclude hidden and special files from the archive"""
if archive_entry.isfile() or archive_entry.isdir():
if not os.path.basename(archive_entry.name).startswith("."):
return archive_entry
def _make_sdist(sdist_dir):
"""Make an sdist and return both the Python object and its filename"""
sdist_path = pathlib.Path(sdist_dir) / SDIST_FILENAME
sdist = tarfile.open(sdist_path, "w:gz", format=tarfile.PAX_FORMAT)
# Tar up the whole directory, minus hidden and special files
sdist.add(os.getcwd(), arcname=SDIST_NAME,
filter=_exclude_hidden_and_special_files)
return sdist, SDIST_FILENAME
def build_sdist(sdist_dir, config_settings):
"""PEP 517 sdist creation hook"""
sdist, sdist_filename = _make_sdist(sdist_dir)
return sdist_filename
#################
# wheel creation
#################
def get_requires_for_build_wheel(config_settings):
"""PEP 517 wheel building dependency definition hook"""
# As a simple static requirement, this could also just be
# listed in the project's build system dependencies instead
return ["wheel"]
def build_wheel(wheel_directory,
metadata_directory=None, config_settings=None):
"""PEP 517 wheel creation hook"""
from wheel.archive import archive_wheelfile
path = os.path.join(wheel_directory, WHEEL_FILENAME)
archive_wheelfile(path, "src/")
return WHEEL_FILENAME
当然,这是一个 *糟糕的* 构建后端:它要求用户手动在 src/mypackage-0.1.dist-info/
中设置 wheel 元数据;当版本号更改时,必须在多个地方手动更新……但它确实有效,并且可以逐步添加更多功能。大量的经验表明,大型成功的项目通常起源于快速开发的原型(例如,Linux——“只是一个爱好,不会变得庞大而专业”;IPython/Jupyter——一个研究生级的 $PYTHONSTARTUP 文件),因此,如果我们的目标是鼓励充满活力的优秀构建工具生态系统的增长,那么降低入门门槛非常重要。
**其次**,由于 Python 提供了一个更简单但更丰富的结构来描述接口,我们消除了规范中不必要的复杂性——规范是最不应该存在复杂性的场所,因为更改规范需要在众多利益相关者之间进行痛苦的共识构建。在命令行接口方法中,我们必须想出一些临时的方法来将多种不同类型的输入映射到单个线性命令行中(例如,我们如何避免用户指定的配置参数与 PEP 定义的参数之间的冲突?如何指定可选参数?使用 Python 接口时,这些问题都有简单明了的答案)。在生成和管理子进程时,必须正确处理许多繁琐的细节,存在细微的跨平台差异,并且一些最明显的方法——例如,使用标准输出返回 build_requires
操作的数据——可能会产生意想不到的陷阱(例如,当计算构建需求需要生成一些子进程,并且这些子进程偶尔会将错误消息打印到标准输出时会发生什么?显然,一个谨慎的构建后端作者可以避免此问题,但定义 Python 接口的最明显方法完全消除了这种可能性,因为钩子返回值被明确地界定)。
总的来说,将构建后端隔离到其自己的进程中的需要意味着我们无法完全消除 IPC 复杂性——但通过将 IPC 通道的两端都置于单个项目的控制之下,我们使修复 IPC 接口中的错误变得更加容易,而不是修复错误需要整个生态系统中的协调一致的协议和协调一致的更改。
**第三**,也是最关键的一点,Python 钩子方法为我们提供了更多强大的选项来在未来发展此规范。
为了更具体地说明,假设明年我们添加了一个新的 build_sdist_from_vcs
钩子,它提供了当前 build_sdist
钩子的替代方案,其中前端负责将版本控制跟踪元数据传递给后端(包括指示所有磁盘文件是否都被跟踪),而不是各个后端必须自己查询这些信息。为了管理过渡,我们希望构建前端能够在可用时透明地使用 build_sdist_from_vcs
,并在其他情况下回退到 build_sdist
;并且我们希望构建后端能够定义这两种方法,以与旧版和新版构建前端兼容。
此外,我们的机制还应实现另外两个目标:(a)如果例如 pip
和 flit
的新版本都更新为支持新接口,那么这应该足以使用它;特别是,它 *不* 需要每个使用 flit
的项目更新其单独的 pyproject.toml
文件。(b)我们不希望仅仅为了执行此协商而生成额外的进程,因为在某些平台(Windows)上部署大型多包堆栈时,进程生成很容易成为瓶颈。
在此处描述的接口中,所有这些目标都易于实现。因为 pip
控制在子进程中运行的代码,所以它可以很容易地编写代码来执行以下操作
command, backend, args = parse_command_line_args(...)
if command == "build_sdist":
if hasattr(backend, "build_sdist_from_vcs"):
backend.build_sdist_from_vcs(...)
elif hasattr(backend, "build_sdist"):
backend.build_sdist(...)
else:
# error handling
在将公共接口边界放置在子进程调用处的替代方案中,这是不可能的——要么我们需要生成一个额外的进程仅仅为了查询支持哪些接口(如 PEP 516 的早期草案中所包含的,作为此提案的替代方案),要么我们完全放弃自动协商(如该 PEP 的当前版本中所做的那样),这意味着接口的任何更改都需要 N 个单独的包更新其 pyproject.toml
文件,然后才能进行任何更改,并且任何更改都必然限于新版本。
这方面的一个具体后果是,在本 PEP 中,我们能够使 prepare_metadata_for_build_wheel
命令可选。在我们的设计中,这可以通过构建前端轻松处理,构建前端可以在其子进程运行器中放入如下代码
def dump_wheel_metadata(backend, working_dir):
"""Dumps wheel metadata to working directory.
Returns absolute path to resulting metadata directory
"""
if hasattr(backend, "prepare_metadata_for_build_wheel"):
subdir = backend.prepare_metadata_for_build_wheel(working_dir)
else:
wheel_fname = backend.build_wheel(working_dir)
already_built = os.path.join(working_dir, "ALREADY_BUILT_WHEEL")
with open(already_built, "w") as f:
f.write(wheel_fname)
subdir = unzip_metadata(os.path.join(working_dir, wheel_fname))
return os.path.join(working_dir, subdir)
def ensure_wheel_is_built(backend, output_dir, working_dir, metadata_dir):
"""Ensures built wheel is available in output directory
Returns absolute path to resulting wheel file
"""
already_built = os.path.join(working_dir, "ALREADY_BUILT_WHEEL")
if os.path.exists(already_built):
with open(already_built, "r") as f:
wheel_fname = f.read().strip()
working_path = os.path.join(working_dir, wheel_fname)
final_path = os.path.join(output_dir, wheel_fname)
os.rename(working_path, final_path)
os.remove(already_built)
else:
wheel_fname = backend.build_wheel(output_dir, metadata_dir=metadata_dir)
return os.path.join(output_dir, wheel_fname)
从而向前端的其余部分公开完全统一的接口,无需额外的子进程调用、重复构建等。但显然,这是您只希望作为私有项目内部接口的一部分编写的代码(例如,给定的示例要求工作目录在两个调用之间共享,但与任何其他 wheel 构建都不共享,并且元数据帮助器函数的返回值将被传递回 wheel 构建函数)。
(当然,使 metadata
命令可选是降低开发新后端入门门槛的一部分,如上所述。)
其他差异
除了上面描述的关键命令行与 Python 钩子差异之外,此提案中还有一些其他差异。
- 元数据命令是可选的(如上所述)。
- 我们将元数据作为目录返回,而不是单个 METADATA 文件。这更符合 wheel 元数据在实践中分布在多个文件(例如入口点)中的方式,并且为我们提供了更多未来的选择。(例如,与其遵循 PEP 426 的建议将 METADATA 的格式切换为 JSON,我们可能会决定保留现有的 METADATA 以保持向后兼容性,同时在同一目录中添加新的 JSON“侧车”文件。或者可能不会;重点是它使我们的选择更加开放。)
- 我们提供了一种在元数据步骤和轮子构建步骤之间传递信息的方法。我想每个人都可能会同意这是一个好主意吧?
- 我们提供了关于构建环境的更详细的建议,但这些建议无论如何都不是规范性的。
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0517.rst