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。幸运的是,轮子(wheels)现在已经解决了许多难题——例如,构建系统不再需要了解所有可能的安装配置——所以我们真正需要构建系统的就是它能够以某种方式输出符合标准的轮子和源码分发(sdists)。
因此,我们提出了一个相对最小的新接口,供 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,下载它,构建它,并安装生成的包的情况。
源码分发也简称为 **sdists**。
**构建前端**是用户可能运行的工具,它接收任意源码树或源码分发并从中构建轮子。实际的构建由每个源码树的**构建后端**完成。在 pip wheel some-directory/ 等命令中,pip 充当构建前端。
**集成前端**是用户可能运行的工具,它接收一组包需求(例如 requirements.txt 文件)并尝试更新工作环境以满足这些需求。这可能需要定位、构建和安装轮子和 sdists 的组合。在 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 还定义了 pyproject.toml 中使用的 backend-path 键,详见下文“树内构建后端”一节。此键将按如下方式使用
[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 等)。此图**绝不能**包含循环。如果(例如,由于项目之间缺乏协调)存在循环,前端**可以**拒绝构建项目。
- 如果构建要求以轮子(wheels)形式提供,前端**应该**在实际情况下使用它们,以避免深层嵌套的构建。然而,前端**可以**有模式,在定位构建要求时不安考虑轮子,因此项目**绝不能**假定发布轮子足以打破依赖循环。
- 前端**应该**明确检查依赖循环,如果发现循环,则以信息性消息终止构建。
特别注意,没有循环的要求意味着希望自托管的后端(即,为后端构建轮子使用该后端进行构建)需要进行特殊规定以避免导致循环。通常这会涉及将自身指定为树内后端,并避免外部构建依赖(通常通过打包它们)。
构建后端接口
构建后端对象预计具有提供以下部分或全部钩子的属性。通用的 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 并且依赖于此调用产生的轮子具有与之前调用匹配的元数据,则应提供所创建 .dist-info 目录的路径作为 metadata_directory 参数。如果提供了此参数,则 build_wheel **必须**生成具有相同元数据的轮子。构建前端传入的目录**必须**与 prepare_metadata_for_build_wheel 创建的目录完全相同,包括它创建的任何无法识别的文件。
未提供 prepare_metadata_for_build_wheel 钩子的后端可以默默地忽略 build_wheel 的 metadata_directory 参数,或者在它被设置为 None 以外的值时引发异常。
为了确保来自不同源的轮子以相同方式构建,前端可以先调用 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 文件也被用作 sdists,但此钩子应生成一个 gzip 压缩的 tarball。这已经是 sdists 更常见的格式,并且具有一致的格式使工具更简单。
生成的 tarball 应该使用现代 POSIX.1-2001 pax tar 格式,该格式指定了基于 UTF-8 的文件名。Python 3.6 中附带的 tarfile 模块目前还不是默认格式,因此使用 tarfile 模块的后端需要显式传递 format=tarfile.PAX_FORMAT。
某些后端可能对创建 sdists 有额外的要求,例如版本控制工具。然而,某些前端可能更喜欢在生成轮子时创建中间 sdists,以确保一致性。如果后端由于缺少依赖项或其他已知原因而无法生成 sdist,则应引发一个特定类型的异常,该异常在后端对象上作为 UnsupportedOperation 提供。如果前端在构建 sdist 作为轮子的中间产物时遇到此异常,则应退回到直接构建轮子。如果后端从不引发此异常类型,则无需定义它。
可选钩子
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 内创建一个包含轮子元数据的 .dist-info 目录(即,创建一个类似 {metadata_directory}/{package}-{version}.dist-info/ 的目录)。此目录**必须**是轮子规范中定义的有效 .dist-info 目录,除了它不需要包含 RECORD 或签名。钩子**可以**在此目录中创建其他文件,构建前端**必须**保留这些文件,但否则应忽略它们;这里的意图是,在元数据取决于构建时决策的情况下,构建后端可能需要以某种方便的格式记录这些决策,以便在实际的轮子构建步骤中重复使用。
它必须返回所创建 .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 中的*替换*错误处理程序进行解码)。如果输出流是终端,构建后端负责准确地呈现其输出,就像在终端中运行的任何程序一样。
如果一个钩子引发异常或导致进程终止,则表示出现错误。
构建环境
构建前端的职责之一是设置构建后端将运行的 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"), ...])
构建后端**必须**准备好在任何符合上述条件的环境中运行。特别是,它**绝不能**假设它可以访问除 stdlib 中存在或明确声明为构建要求的任何包之外的任何包。
前端应该在新的子进程中调用每个钩子,以便后端可以自由地更改进程全局状态(例如环境变量或工作目录)。将提供一个 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 发布了,它有一个致命的 bug;或者,用户可能决定构建一个针对 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 的轮子。
由 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 检出中以将其转换为新样式。这可能无法百分之百实现,但我们可以接近,并且跟踪我们接近的程度很重要……因此有了这一节。
一个粗略的计划是:创建一个构建系统包(setuptools_pypackage 或其他),它知道如何说出我们想出的任何钩子语言,并将它们转换为对 setup.py 的调用。这可能需要对 setuptools 进行某种钩子或猴子补丁,以提供一种在需要时提取 setup_requires= 参数的方法,并提供一个生成新格式 sdist 的新版本 sdist 命令。这一切似乎都是可行的,并且足以满足绝大多数包(尽管显然我们会在最终确定任何内容之前原型化这样一个系统)。(或者,这些更改可以直接在 setuptools 本身中进行,而不是放入单独的包中。)
但是仍然存在两个障碍,这意味着我们可能无法自动将包升级到新格式
- 目前存在一些包,它们坚持在执行 setup.py 之前在环境中提供特定的包。这意味着如果我们决定在隔离的 virtualenv 样环境中执行构建脚本,那么项目将需要检查它们是否这样做,如果是,那么在升级到新系统时,它们将不得不开始明确声明这些依赖项(无论是通过
setup_requires=还是通过pyproject.toml中的静态声明)。 - 目前存在一些包,它们不声明一致的元数据(例如,
egg_info和bdist_wheel可能会得到不同的install_requires=)。在升级到新系统时,项目将不得不评估这是否适用于它们,如果是,它们将需要停止这样做。
被拒绝的选项
- 我们讨论了让 wheel 和 sdist 钩子构建包含与其各自归档文件相同内容的未打包目录。在某些情况下,这可以避免打包和解包归档文件的需要,但这似乎是过早优化。工具使用归档文件作为规范的交换格式是有利的(特别是对于轮子,其归档格式已经标准化)。严格控制归档文件的创建对于可重现的构建很重要。而且,目前尚不清楚需要未打包分发的任务是否会比需要归档文件的任务更常见。
- 我们考虑过在调用
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 后端是未明确指定build-backend的源码树直接调用setup.py的可接受替代方案。
附录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/ 中设置轮子元数据;当版本号更改时,必须在多个位置手动更新……但它确实有效,并且可以逐步添加更多功能。许多经验表明,大型成功的项目通常最初只是快速的修补程序(例如,Linux——“只是一个爱好,不会变得庞大和专业”;IPython/Jupyter——一个研究生的 $PYTHONSTARTUP 文件),因此,如果我们的目标是鼓励一个充满活力的良好构建工具生态系统的发展,那么最大限度地降低进入门槛非常重要。
**其次**,因为 Python 为描述接口提供了更简单但更丰富的结构,我们消除了规范中不必要的复杂性——而规范是复杂性最糟糕的地方,因为更改规范需要许多利益相关者之间痛苦的共识建立。在命令行接口方法中,我们必须想出临时的方法将多种不同类型的输入映射到单个线性命令行(例如,我们如何避免用户指定配置参数和 PEP 定义参数之间的冲突?我们如何指定可选参数?当使用 Python 接口时,这些问题有简单、显而易见的答案)。在生成和管理子进程时,有许多必须正确处理的细微细节、微妙的跨平台差异,以及一些最明显的方法——例如,使用 stdout 返回 build_requires 操作的数据——可能会产生意想不到的陷阱(例如,当计算构建要求需要生成一些子进程,并且这些子进程偶尔会在 stdout 上打印错误消息时会发生什么?显然,一个细心的构建后端作者可以避免这个问题,但定义 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 的替代方案),要么完全放弃自动协商(如该 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)
因此,向前端的其余部分暴露一个完全统一的接口,无需额外的子进程调用,无需重复构建等。但显然,这是你只想作为私有项目内部接口一部分编写的代码(例如,给定的示例要求工作目录在两次调用之间共享,但不能与其他轮子构建共享,并且元数据辅助函数的返回值将传递回轮子构建函数)。
(当然,使 metadata 命令可选是降低开发新后端门槛的一部分,如上所述。)
其他差异
除了上述命令行与 Python 钩子的关键差异之外,本提案还有一些其他差异
- 元数据命令是可选的(如上所述)。
- 我们将元数据作为目录返回,而不是单个 METADATA 文件。这与实际中轮子元数据分布在多个文件(例如入口点)的方式更吻合,并为我们将来提供了更多选择。(例如,我们可能决定不遵循 PEP 426 提案将 METADATA 格式更改为 JSON,而是为了向后兼容性保持现有 METADATA 的原样,同时在同一目录中添加新的扩展作为 JSON“附带”文件。或者可能不会;重点是它使我们的选择更加开放。)
- 我们提供了一种在元数据步骤和轮子构建步骤之间传递信息的机制。我想每个人可能都会同意这是一个好主意?
- 我们提供了关于构建环境的更详细建议,但这些建议无论如何都不是规范性的。
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0517.rst