从 requirements.txt 到 pylock.toml:Python 依赖管理与供应链安全

Chen Xi
Chen Xi

很多 Python 项目一开始只有一个 requirements.txt。项目小的时候,它看起来足够简单:列几个包,执行一次 pip install -r requirements.txt,程序就能启动。

真正让人头疼的时刻通常在几个月以后:开发机能跑,CI 失败;昨天还能安装,今天解析出了另一组依赖;一个间接依赖出了漏洞,却没人知道它是通过哪条路径进来的。文件没有消失,问题却从“装哪些包”变成了“我能不能证明这次构建到底装了什么”。

这篇文章把依赖管理拆成四件事:声明项目需要什么、锁定解析结果、在干净环境里安装,以及留下可以审计的组件清单。重点不是鼓吹某一个工具,而是把 pyproject.toml、锁文件、哈希、SBOM 和 CI 之间的边界讲清楚。

image

先区分四种文件的职责

文件或信息 解决的问题 是否应该手工编辑
pyproject.toml 项目声明、最低 Python 版本、直接依赖和可选功能 是,作为源文件维护
uv.lock uv 项目的完整解析结果和跨平台选择 否,由 uv 管理
pylock.toml 工具无关的、用于可复现安装的标准锁文件 通常否,由工具生成
CycloneDX SBOM 这次构建实际包含了哪些组件 否,由流水线生成并归档

pyproject.toml 是“我希望项目依赖什么”的声明;锁文件是“在某个解析条件下最后选中了什么”的结果;SBOM 则是“这次构建产物里实际有什么”的清单。把它们都叫作“依赖文件”,后面一定会混乱。

pyproject.toml 负责声明意图

Python Packaging User Guide 建议使用标准的 pyproject.toml 保存项目元数据和依赖。[project] 中的 dependencies 使用 PEP 508 依赖说明符,可以表达版本范围、可选 extra 和平台条件。PyPA pyproject.toml 规范

一个小项目可以从这里开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[project]
name = "research-notebook"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27,<1",
"pandas>=2.2,<3",
]

[project.optional-dependencies]
dev = [
"pytest>=8,<9",
"ruff>=0.6,<1",
]

版本范围要表达兼容边界,而不是假装它已经锁定了版本。httpx>=0.27,<1 的意思是允许解析器在这个范围内选择,不能保证每次安装得到同一个 wheel。想让安装可复现,需要另外生成锁定结果。

平台条件也应该写在声明层,而不是散落在 CI 脚本里:

1
2
3
4
dependencies = [
"uvloop>=0.19; sys_platform != 'win32'",
"pywin32>=306; sys_platform == 'win32'",
]

这类环境标记是依赖规范的一部分。PyPA 在 2026 年更新的依赖说明中还特别区分了发布工具、锁定工具和安装工具各自应该多严格,说明“能被某个工具解析”不等于“声明就是正确的”。Dependency Specifiers

requirements.txt 迁移,不要一次把所有版本换掉

老项目通常已经有 requirements.in 或一个手工维护的 requirements.txt。迁移时我会先保存当前环境,再把直接依赖搬到 pyproject.toml,最后让解析器生成锁文件。不要一上来就升级所有包,否则出了问题很难判断是工具变化还是版本变化。

使用 uv 的一个保守流程是:

1
2
3
4
uv init
uv add -r requirements.in
uv lock
uv sync

如果旧的 requirements.txt 已经是经过验证的锁定结果,可以把它作为约束输入,尽量保留原有版本:

1
2
uv add -r requirements.in -c requirements.txt
uv lock

uv 的项目锁文件 uv.lock 是跨平台解析结果,能表达比传统 requirements.txt 更多的信息;它是 uv 的工具格式,不应该手工修改。uv 项目布局与锁文件

uv.lockpylock.toml 不是同一个东西

这两个名字很容易被混为一谈。

uv.lock 是 uv 项目接口使用的锁文件,适合在一个 uv 项目里管理 workspace、索引和平台标记。pylock.toml 则来自 PEP 751,是一个标准化、工具无关的解析输出格式,目标是让不同安装工具能够识别同一份可复现依赖描述。PEP 751

可以把 uv 的解析结果导出成标准锁文件:

1
2
uv export --format pylock.toml --output-file pylock.toml
uv pip sync pylock.toml

PyPA 规定锁文件名应为 pylock.toml,如果需要多份环境,也可以使用 pylock.<name>.toml 的形式。pylock.toml 规范

我的理解是:项目内部可以继续使用 uv.lock,发布给更广泛的安装环境时,可以额外导出 pylock.toml。不要为了追求“标准”而把工具特有的能力全部砍掉,也不要因为某个工具现在能读 uv.lock,就假设其他工具永远会读。

安装必须是同步,而不是“往环境里再加几个包”

很多部署脚本使用 pip install -r requirements.txt。它会尽量满足当前文件,但环境中原来多出来的包不一定被清掉。这样会造成一个很隐蔽的问题:锁文件说有 30 个包,镜像里实际留下了 36 个。

如果目标是让环境严格等于锁文件,应使用同步语义:

1
uv pip sync pylock.toml

在 uv 项目模式下,CI 可以要求锁文件和声明保持一致:

1
2
uv sync --locked
uv run --locked pytest

--locked 的含义是如果锁文件需要更新就直接失败,不在 CI 里悄悄改写依赖;--frozen 则跳过锁文件新旧检查,适合你已经明确知道输入不变的场景。日常开发可以自动更新,发布流水线应该更严格。uv 锁定与同步

哈希、索引和直接 URL

锁定版本只是第一层。即使包名和版本相同,下载到的文件也可能来自不同索引或被替换。对于需要更强可验证性的构建,我会同时记录:

  • 包名和精确版本;
  • 目标 Python、操作系统和架构;
  • 下载来源和索引;
  • wheel 或 sdist 的 SHA-256;
  • 构建工具及其版本。

直接 URL 依赖和 Git 分支依赖尤其要小心。一个指向 main 的 Git URL 不是固定版本;uv 的解析器会在锁定时记录具体 commit,这比把分支地址直接写进安装脚本可靠得多。uv 缓存与 Git 依赖

构建阶段也应该锁住 build dependency。比如使用约束文件和哈希检查 setuptools:

1
2
setuptools==68.2.2 \
--hash=sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a
1
uv build --build-constraint constraints.txt --require-hashes

这不是说所有项目都必须立即做到极限安全,而是要知道“运行依赖”和“构建依赖”都能改变最终产物。

SBOM:把“装了什么”变成机器可读的清单

SBOM(Software Bill of Materials)不是漏洞扫描器,也不是安全认证。它首先是一份清单:项目直接依赖了什么,间接依赖了什么,版本和来源是什么。

uv 可以把锁文件导出为 CycloneDX 1.5 JSON:

1
2
New-Item -ItemType Directory -Force artifacts | Out-Null
uv export --format cyclonedx1.5 --output-file artifacts/sbom.json

官方文档把这个功能标为 preview,因此生成结果和命令行参数仍可能变化;但 CycloneDX 本身是被安全扫描和软件成分分析工具广泛使用的机器可读格式。uv SBOM 导出

SBOM 最适合放进构建产物里,并与提交、镜像 digest 和发布时间绑定:

1
2
3
4
commit: 8f1e...
image: sha256:...
sbom: artifacts/sbom.json
built_at: 2026-08-20T00:40:00Z

这样某个依赖后来爆出漏洞时,第一步不是猜“我们是不是用过”,而是查历史构建的 SBOM。

发行证明比“我从 GitHub Actions 发布”更具体

如果项目要发布到 PyPI,还可以继续往前走。PyPI 的数字证明机制会把发布文件的摘要绑定到可信发布者身份,支持 PyPI Publish 和 SLSA Provenance 等证明类型。PyPI Attestations

这和 SBOM 是两件事:

  • SBOM 说明产物包含哪些组件;
  • Attestation 说明某个文件由哪个身份、哪个工作流产生;
  • 哈希说明下载到的文件是否还是当时那一份。

三者放在一起,才能回答“里面是什么”“谁构建的”“有没有被替换”三个不同问题。

一条适合 CI 的最小流水线

我会把 CI 分成解析、安装、测试、清单四步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name: verify-python

on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv sync --locked
- run: uv run --locked pytest
- run: uv export --locked --format cyclonedx1.5 --output-file sbom.json
- uses: actions/upload-artifact@v4
with:
name: python-sbom
path: sbom.json

示例只表达流程,实际项目还要固定 action 的版本或 commit、限制网络访问、配置私有索引凭据,并把 SBOM 保存到有权限控制的构建存储中。安全文件不应该因为“方便下载”而暴露内部索引地址和 token。

依赖升级应该是一个小变更

锁文件的价值不是永远不升级,而是让升级变成可审查的差异。一个舒服的升级流程应该是:

  1. 选择一个直接依赖;
  2. uv lock --upgrade-package 包名 更新它;
  3. 查看锁文件中被连带更新的包;
  4. 运行测试和 SBOM 生成;
  5. 在 PR 描述里写清楚版本变化、漏洞修复和兼容性影响。

不要把“每周全部升级”当作唯一的安全策略。小步升级更容易定位回归,也更容易把安全修复和功能变化分开。

最后留一张检查表

在我看来,一个值得维护的 Python 项目至少应该做到:

  • 直接依赖写在 pyproject.toml,而不是只存在某台电脑的虚拟环境里;
  • 锁文件进入版本控制,CI 用严格模式而不是自动改写;
  • 构建依赖也有约束,必要时检查哈希;
  • 发布镜像或 wheel 同时保存 SBOM;
  • 依赖升级是可审查的小 PR;
  • 私有索引、token 和内部路径不会写入公开产物;
  • 出现漏洞时,能从 SBOM 反查受影响的历史构建;
  • 发布包时使用可信发布和数字证明,而不是长期静态 token。

最后的话

依赖管理的终点不是把 requirements.txt 换成另一个文件名,而是让一次安装可以被解释、被复现、被审计。

pyproject.toml 负责表达项目意图,锁文件负责固定解析结果,严格同步负责还原环境,SBOM 负责记录构建内容,数字证明负责说明产物从哪里来。每一层解决一个问题,组合起来才是一条完整的供应链。

这篇文章中的工具命令会随 uv 和 Python 打包规范演进,正式接入前应锁定工具版本并在自己的平台上验证。SBOM 也不会自动消除恶意包、零日漏洞或不安全的构建脚本,它只是让这些风险不再完全隐藏在环境里。

参考资料