Python: 将可验证的依赖描述与构建环境详情作为可重复发布合约的分离部分进行存储
在 pyproject.toml 的 [project.dependencies] 中存储运行时依赖,在 [build-system.requires] 中存储构建要求,随后在独立的 requirements 文件中通过 sha256 哈希值锁定精确版本,从而创建一个字节级可验证的发布合约。
本文内容
核心观点
在 pyproject.toml 文件中,将运行时依赖定义在 [project.dependencies] 节中,将构建时要求定义在 [build-system.requires] 节中。通过在一个单独的 requirements 文件中锁定精确版本并添加 --hash=sha256 条目,可以创建一个可验证的、字节级可重复的发布合约。
在 pyproject.toml 中将运行时依赖与构建系统要求分离
pyproject.toml 规范定义了两个截然不同的依赖层。 [build-system] 表声明了运行项目构建系统所需的 Python 级依赖,例如 setuptools 或 flit。requires 键在该表中是强制性的,用于列出构建时所需的软件包。而由 PEP 621 引入的 [project] 表则包含 dependencies 键,用于列出消费者在安装时所需的软件包(即运行时依赖)。这些依赖会被映射到软件包元数据中的 Requires-Dist 条目中。
保持这两个层级的分离对于建立可重复的发布合约至关重要。构建环境必须是可重复的,以便产生相同的分发产物;而运行时环境必须是可重复的,以确保安装后的行为一致。通过在 pyproject.toml 中同时声明这两者,你使合约变得明确且可验证:任何人都可以检查构建所需的工具以及运行所需的库。规范指出,工具不应要求 [build-system] 表必须存在;如果缺失,则使用默认值,而默认值在不同环境下可能有所不同。因此,明确地陈述这两个层级可以消除歧义。
为什么锁定版本与哈希检查针对的是不同的信任边界
在 requirements 文件中使用 == 锁定软件包版本可以防止上游版本变更引入 Bug 或不兼容性。然而,这种方式仍然信任软件包索引(如 PyPI)和 TLS 证书链。如果攻击者攻破了索引或进行了中间人攻击,可能会提供一个版本号相同但包含恶意代码的软件包。
哈希检查模式通过启用 --require-hashes 和 --hash=sha256:... 条目,将下载的产物与已知摘要进行逐字节验证。这可以防御索引被篡改、证书链攻击以及在允许此类操作的索引上发生的无版本升级的静默重新上传。pip 文档指出,哈希检查是运行私有索引服务器的一种省力替代方案:它消除了上传软件包、维护访问控制列表(ACL)和保留审计跟踪的需要,而版本控制系统(VCS)则为 requirements 文件提供了该跟踪记录。其代价是维护成本:每当依赖版本发生变化时,必须重新生成并审核哈希值。这种方法非常适合环境受控的自动化服务器部署。
具体示例:包含两个层级的发布合约
一个极简的 pyproject.toml 声明了 build-system.requires(例如 setuptools 和 wheel)以及 project.dependencies(例如 requests 和 pydantic)。配套的 requirements-pinned.txt 文件由 pip-compile 或 pip freeze 生成,使用精确版本和 sha256 哈希值锁定每一个传递依赖。这两个文件共同构成了可验证的合约。
安装时,运行 pip install --require-hashes -r requirements-pinned.txt。这将强制要求每个下载的软件包必须与记录的哈希值匹配。构建系统要求在从源码构建项目时使用;如果仅安装预构建的 wheel 文件,则在安装时不需要它们,但对于任何需要重新构建的人来说,它们仍然是合约的一部分。
build-system.requires 列表确保了构建工具的可重复性;project.dependencies 声明了安装后的应用程序所需内容。配套的 requirements 文件锁定精确版本并为所有传递依赖提供哈希值,在与 pip install --require-hashes -r requirements-pinned.txt 结合使用时,形成了可验证的发布合约。示例中的哈希值是截断的占位符;实际哈希必须使用 pip-compile 或 pip freeze 生成,然后手动验证或提交到版本控制系统。关注点分离意味着更新运行时依赖不需要更改构建系统规范,反之亦然。仅凭 pyproject.toml 无法强制实现可重复性,只有将其与锁定的、带哈希的 requirements 文件结合,才能创建合约。build-system 表对于可重复性是强制性的,因为它锁定了构建工具;省略它将依赖于可能在不同环境下有所不同的工具特定默认值,从而破坏合约。
# pyproject.toml
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "myapp"
version = "1.0.0"
dependencies = ["requests>=2.31", "pydantic>=2.5"]
# requirements-pinned.txt (generated by pip-compile or pip freeze)
requests==2.31.0 --hash=sha256:58cd2187c8b806c9970b3f8a8e7c1d2e4f6a8b9c0d1e2f3a4b5c6d7e8f9a0b1
pydantic==2.5.0 --hash=sha256:7f4f1b1b0d8d4e8c9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2
urllib3==2.1.0 --hash=sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
annotated-types==0.6.0 --hash=sha256:b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3
pydantic-core==2.14.1 --hash=sha256:c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4
typing-extensions==4.9.0 --hash=sha256:d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5
# Install command enforcing hash verification:
pip install --require-hashes -r requirements-pinned.txt此方法的局限性
包含编译后 wheel 文件的 Wheelhouse 捆绑包是特定于操作系统和架构的,因此无法在不同机器之间移植。哈希检查不能像私有索引或供应商库那样保证软件包的可用性;如果软件包从索引中删除,即使已知哈希值,安装也会失败。[build-system] 表是可选的;当缺失时,工具会回退到默认设置,这在不同环境下可能有所不同,从而破坏可重复性。使用哈希锁定仍然信任初始的分辨过程,且无法防止锁定版本本身内部包含的恶意代码。最后,维护哈希值会增加开销:每次依赖更新都需要重新生成并审核哈希条目。
适用条件
- 你的项目是否在 pyproject.toml 中同时使用了 [build-system] 和 [project] 表?
- 你是否愿意在依赖版本发生变化时重新生成哈希条目?
- 目标环境是否足够同质化,使得编译后的 wheel 文件(如果有)是可移植的,或者你可以在每台机器上重新构建它们?
- 你是否有一套生成和审核锁定 requirements 文件的流程(例如使用 pip-compile 或 pip freeze)?
- 你能否接受为了安全补丁和升级而更新哈希值所带来的维护成本?
- 对于你的可用性要求而言,缺失私有索引或供应商库是否是可以接受的?
适用范围
包含编译后 wheel 文件的 Wheelhouse 捆绑包是特定于操作系统和架构的,因此无法在不同机器之间移植。哈希检查不能像私有索引或供应商库那样保证软件包的可用性。 [build-system] 表是可选的;当缺失时,工具会回退到默认设置,这在不同环境下可能有所不同。使用哈希锁定仍然信任初始的分辨过程,且无法防止锁定版本本身内部包含的恶意代码。