Python 基础体系 · 第 64/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 依赖解析与锁定:版本约束、Lock、哈希和供应链

Python 项目中的“安装依赖”并不是把若干目录复制到 site-packages。一个完整过程至少包含:

  1. 根据项目声明收集直接依赖;
  2. 读取每个候选发行版的元数据;
  3. 展开传递依赖;
  4. 根据版本约束、Python 版本、操作系统和 CPU 架构筛选候选;
  5. 在依赖图中寻找一个相互兼容的解;
  6. 为每个包选择具体发行文件,例如 wheel 或 source distribution;
  7. 下载、校验、构建并安装;
  8. 记录这次安装究竟使用了哪些包、哪些文件以及哪些来源。

因此,依赖管理至少要区分四个问题:

  • 允许安装什么版本:版本约束;
  • 本次实际选择了什么版本:解析结果;
  • 下次是否复用同一个解析结果:Lock;
  • 下载到的文件是否就是预期文件:哈希;
  • 这个文件和构建过程是否值得信任:供应链安全。

这几个问题相互关联,但不能互相替代。版本锁定不能证明文件未被替换,哈希校验不能证明包没有恶意代码,虚拟环境隔离也不能证明依赖来源可信。


一、先建立对象模型:项目、发行版、导入包和文件

1. distribution 不等于 import package

在 Python 包管理语境中,需要区分三个对象:

  • 项目(project):一个可发布、可被依赖的逻辑项目,例如 requests
  • 发行版(distribution):某个具体版本的可安装发布物,例如 requests-2.32.3-py3-none-any.whl
  • 导入包(import package):代码运行时通过 import 使用的模块或包,例如 requests

项目声明的是“我依赖哪个发行版项目”,安装工具最终获取的是某个发行文件,解释器运行的则是文件中的导入包。

这三个名字甚至可以不同。例如:

import PIL

对应的发行版项目通常是:

Pillow

所以,下面这条命令中的 Pillow 是发行版项目名,而不是 import 名:

python -m pip install Pillow

2. 发行版文件才是哈希校验对象

一个项目版本通常可以发布多个发行文件:

example-1.4.0-py3-none-any.whl
example-1.4.0-cp314-cp314-manylinux_2_28_x86_64.whl
example-1.4.0.tar.gz

它们可能具有相同的项目名和版本号,但内容不同:

  • 通用 wheel 适合纯 Python 代码;
  • 平台相关 wheel 可能包含编译扩展;
  • source distribution 需要在安装时构建;
  • 不同平台可能需要不同的 wheel。

因此:

项目名 + 版本号

可以确定一个发行版候选,但不一定能唯一确定最终下载的文件。

而哈希校验通常针对的是:

具体发行文件的字节内容

这就是为什么一个包可能需要记录多个哈希:不同平台、Python 版本或安装策略可能选择不同 wheel。


二、版本不是字符串:规范化和比较顺序

Python 包版本遵循 Python Packaging Authority 的版本规范。规范版本一般形如:

[N!]N(.N)*[{a|b|rc}N][.postN][.devN]

其中可以包含:

  • N!:epoch,用于改变版本比较的时代;
  • N(.N)*:release segment,例如 3.14.1
  • aNbNrcN:预发布版本;
  • .postN:post release;
  • .devN:开发版本;
  • +label:本地版本标识。

版本比较不是字符串排序,而是按规范化后的各个组成部分比较。规范定义了开发版、alpha、beta、RC、正式版和 post release 的顺序。(packaging.python.org)

例如:

1.0.dev1
1.0a1
1.0b1
1.0rc1
1.0
1.0.post1

满足:

1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1

1. 1.01.0.0 等价

release segment 的比较会对较短版本补零:

1.0     -> (1, 0, 0)
1.0.0   -> (1, 0, 0)

因此在版本比较意义上:

1.0 == 1.0.0

这不是字符串相等,而是规范版本相等。

2. 预发布版本不会自动等同于正式版本

以下版本都属于不同阶段:

2.0a1
2.0b1
2.0rc1
2.0

通常安装工具不会在没有明确要求时自动选择预发布版本。若项目声明:

demo >= 2.0

它通常表达的是“正式版 2.0 或更高版本”,而不是优先使用 2.0rc1

如果确实需要预发布版本,应显式表达意图,例如:

python -m pip install --pre "demo>=2.0"

3. 本地版本不是普通发布版本

本地版本可以写成:

1.2.3+acme.1

它用于区分下游重新构建或打补丁的版本。它可能与上游 1.2.3 API 和 ABI 兼容,但不表示字节内容相同。

这对供应链非常重要:

上游版本相同

不等于:

安装文件相同

哈希仍然需要针对具体文件进行校验。


三、版本约束:从一个字符串到一个集合

1. 版本约束的形式化定义

设某个发行版项目为 P,其可用版本集合为:

V(P) = {v1, v2, v3, ...}

一个版本约束 C 定义了一个允许集合:

S(C) = {v ∈ V(P) | v 满足 C}

例如:

>= 2.0,<3.0

表示:

S(C) = {v | v >= 2.0 且 v < 3.0}

注意,版本约束通常不是安装结果。它只是告诉解析器“哪些版本允许被选择”。

2. 常见运算符

demo == 1.4.2

精确匹配一个版本。

demo != 1.4.2

排除一个版本。

demo >= 1.4
demo < 2.0

分别表示下界和上界。

demo ~= 1.4.2

表示兼容发布范围。它通常等价于:

demo >= 1.4.2,==1.4.*

而:

demo ~= 1.4

通常等价于:

demo >= 1.4,<2.0

~= 的关键不是“自动升级到任何新版本”,而是使用兼容发布段确定上界。

3. 通配符只适用于特定形式

例如:

demo == 1.4.*

表示选择 1.4 系列中的版本。

但下面的表达式不是常规的“模糊匹配”:

demo >= 1.4.*

版本约束中的每个运算符都有自己的语义,不能把 * 任意附加到版本后面。

4. 多个约束是交集

假设根项目声明:

demo >= 1.0

library-a 声明:

demo < 2.0

library-b 声明:

demo >= 1.5

则最终约束是:

demo ∈ [1.0,∞)
demo ∈ (-∞,2.0)
demo ∈ [1.5,∞)

求交集:

demo ∈ [1.5,2.0)

所以候选版本:

1.4.9  不满足 >= 1.5
1.5.0  满足
1.9.3  满足
2.0.0  不满足 < 2.0

解析器可以从中选择某一个具体版本,例如 1.9.3

如果依赖约束变成:

library-a: demo < 2.0
library-b: demo >= 2.1

则:

(-∞,2.0) ∩ [2.1,∞) = ∅

不存在解,安装应失败,而不是强行选择某个版本。


四、依赖解析:解析器究竟在解决什么问题

1. 依赖是一个有条件的图

可以把依赖关系表示为有向图:

节点:项目及其候选版本
边:某个版本声明的依赖
边条件:版本约束、extras、环境标记

例如:

app
├── web-framework >= 4.0
│   └── routing >= 2.0,<3.0
└── cli-tool >= 1.0
    └── routing >= 2.5

routing 的最终约束是:

routing >= 2.0,<3.0
routing >= 2.5

求交集后得到:

routing >= 2.5,<3.0

解析器不仅要满足根项目的约束,还要递归读取每个候选版本的依赖元数据。

2. 候选不是只有版本

对于 Python 3.14 项目,某个发行版候选还必须满足:

  • Requires-Python
  • wheel 的 Python tag;
  • ABI tag;
  • 操作系统和架构 tag;
  • 当前启用的 extras;
  • 当前环境标记;
  • index 或本地源是否提供该文件。

例如,一个 wheel:

demo-1.0.0-cp314-cp314-manylinux_2_28_x86_64.whl

可以拆解为:

cp314       CPython 3.14
cp314       CPython 3.14 ABI
manylinux…  Linux 平台兼容标签
x86_64      CPU 架构

它并不适用于 Windows,也不一定适用于 PyPy 或 ARM64 Linux。

反过来:

demo-1.0.0-py3-none-any.whl

通常表示纯 Python wheel,可以跨多个 Python 3 版本和平台使用,但最终仍以包本身的 Requires-Python 和实际代码兼容性为准。

3. Python 3.14 不是一个普通版本约束

项目可以在 pyproject.toml 中声明:

[project]
requires-python = ">=3.14,<3.15"
dependencies = [
  "httpx>=0.28,<1.0",
  "pydantic>=2.10,<3.0",
]

这里的 requires-python 约束的是解释器环境,而 dependencies 中的约束约束的是发行版版本

它们是两个不同层次:

解释器约束:当前 Python 是否能运行这个项目
包版本约束:依赖图中允许哪些发行版版本

如果项目要求:

requires-python = ">=3.14"

而某个依赖只支持:

Requires-Python = "<3.14"

则在 Python 3.14 环境中该依赖候选会被淘汰。即使它的版本号看起来满足其他约束,也不能安装。

4. 环境标记会使依赖图随环境变化

依赖声明可以附带环境标记:

dependencies = [
  "colorama>=0.4; sys_platform == 'win32'",
  "uvloop>=0.21; sys_platform != 'win32' and python_version >= '3.11'",
]

环境标记表达式在目标环境中求值:

sys_platform == "win32"

在 Windows 上为真,在 Linux 上为假。

因此,同一个项目在不同环境中的依赖集合可能不同:

Linux + Python 3.14:
  uvloop
  no colorama

Windows + Python 3.14:
  colorama
  no uvloop

环境标记中的 andor 和括号会影响语义。例如:

sys_platform == "linux" and python_version >= "3.14"

与:

sys_platform == "linux" or python_version >= "3.14"

不是同一个条件。依赖规范定义了环境标记的字段、比较方式和求值规则。(packaging.python.org)


五、extras:同一个项目的可选依赖集合

extras 是发行版提供的可选依赖集合。例如:

python -m pip install "demo[postgres,redis]"

这里安装的不只是 demo,还包括 postgresredis extra 声明的依赖。多个 extras 的依赖会合并。(packaging.python.org)

可以把一个项目的依赖看成:

D(demo, extras=∅)

启用 extras 后变成:

D(demo, extras={"postgres", "redis"})

实际依赖集合是:

D(demo)
∪ D(demo[postgres])
∪ D(demo[redis])

因此,以下两个安装结果可能不同:

python -m pip install demo
python -m pip install "demo[postgres]"

如果 Lock 文件没有记录启用的 extras,安装工具就无法确定应该纳入哪一部分依赖。现代 lock 格式通常需要把 extras 作为环境选择的一部分处理。


六、解析算法:为什么会回溯

1. 贪心选择会失败

假设有以下依赖:

app -> A >= 1.0
app -> B >= 1.0

可用版本和依赖关系如下:

A 2.0 -> C >= 2.0
A 1.0 -> C < 2.0

B 2.0 -> C < 2.0
B 1.0 -> C >= 1.0

如果解析器先选择最新版本:

A = 2.0
B = 2.0

则得到:

C >= 2.0
C < 2.0

约束冲突。

但这不意味着整个项目无解。解析器可以回溯:

第一次选择:
A = 2.0
B = 2.0
结果:冲突

回溯 B:
A = 2.0
B = 1.0
结果:C >= 2.0 且 C >= 1.0
选择 C = 2.x

或者:

回溯 A:
A = 1.0
B = 2.0
结果:C < 2.0 且 C < 2.0
选择 C = 1.x

现代 pip 的解析器会在发现当前假设无法满足全部约束时回溯到此前选择,并尝试其他候选。下载多个版本以读取元数据或验证路径,可能是回溯的正常表现。(pip.pypa.io)

2. 依赖解析本质上是约束求解

可以将解析目标写成:

为每个项目 P 选择一个版本 vP
使得:
  1. vP 属于 P 的可用候选;
  2. vP 满足所有依赖者提出的版本约束;
  3. vP 满足 Python、平台和 ABI 条件;
  4. 所有被选版本的传递依赖也满足同样条件。

形式化地说,若项目集合为 P,每个项目的选择为 vP,目标是寻找赋值:

{P1 = v1, P2 = v2, ...}

使所有约束同时成立。

这类问题的难点不只是版本数量,还在于:

  • 某个候选的依赖元数据要读取后才知道;
  • 某个平台可能只有 source distribution;
  • 某个版本可能只支持部分 Python 版本;
  • extras 会改变依赖图;
  • 不同候选可能引入不同的依赖集合。

pip 文档明确指出,依赖解析在一般情况下具有很高的计算复杂度,实际实现使用基于回溯的解析策略。(pip.pypa.io)

3. ResolutionImpossible 的含义

当所有可尝试的候选都无法满足约束时,pip 可能报告:

ERROR: Cannot install ...
because these package versions have conflicting dependencies.
ERROR: ResolutionImpossible

这表示:

在当前可见候选集合和当前约束下没有解

它不一定表示某个包损坏,也不一定表示 pip 有 bug。常见原因包括:

  • 顶层依赖互相排斥;
  • 当前 Python 版本不在依赖支持范围;
  • 当前平台没有可用 wheel;
  • 某个包只发布了不兼容的 source distribution;
  • constraints 文件把传递依赖限制得过窄;
  • 使用了错误的 index 或镜像。

诊断时,首先要区分:

没有满足约束的候选

和:

有候选,但目标平台没有可安装文件

前者是版本约束冲突,后者是分发文件兼容性问题。


七、pyproject.toml、requirements 和 constraints 的职责

1. pyproject.toml 是项目声明

现代 Python 项目通常在 pyproject.toml 中声明项目元数据和直接依赖:

[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"

[project]
name = "demo-service"
version = "1.0.0"
requires-python = ">=3.14,<3.15"
dependencies = [
  "httpx>=0.28,<1.0",
  "pydantic>=2.10,<3.0",
]

[project.optional-dependencies]
test = [
  "pytest>=8.0,<9.0",
]

这里表达的是项目的兼容范围

httpx 允许 0.28 系列中的兼容版本
pydantic 允许 2.x 系列中的兼容版本

它通常不是部署时的完整安装清单,因为传递依赖仍由解析器根据当前环境计算。

2. requirements 文件是安装输入

requirements.txt 可以用于描述一次安装要使用的依赖,常见内容包括:

httpx==0.28.1
pydantic==2.10.6

也可以包含哈希:

httpx==0.28.1 \
    --hash=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

requirements 文件是 pip 的输入格式之一,但它本身没有统一承担“项目元数据声明”和“跨工具 lock 文件”的全部职责。

3. constraints 文件只限制,不主动引入

例如:

# constraints.txt
urllib3==2.3.0

执行:

python -m pip install -c constraints.txt "requests>=2.32"

constraints 文件的作用是限制解析器允许选择的版本。它不会因为出现了 urllib3 就单独把 urllib3 安装进环境。

可以把二者区分为:

requirements:我要安装这些依赖
constraints:如果这些依赖被需要,只允许使用这些版本

这使 constraints 适合统一控制组织内部的传递依赖版本,但它不是完整 lock 文件:它通常不记录具体下载 URL、发行文件、哈希、平台矩阵和来源信息。


八、什么是 Lock:把“重新解析”变成“按结果安装”

1. Lock 的核心语义

Lock 文件是一次依赖解析的可复用结果。它至少应回答:

在某组环境条件下:
- 哪些包必须安装?
- 每个包选择哪个版本?
- 使用哪个发行文件?
- 文件从哪里获取?
- 文件的大小和哈希是什么?
- 这个选择适用于哪些 Python 和平台环境?

普通版本约束:

httpx>=0.28,<1.0

表达的是一个集合:

{0.28.0, 0.28.1, 0.29.0, ...}

Lock 则把它收敛为具体选择:

httpx==0.28.1

甚至继续收敛到具体文件:

httpx-0.28.1-py3-none-any.whl
sha256=...

因此:

版本约束 = 可接受解的范围
Lock      = 某次解析选出的具体解
哈希      = 具体文件的内容指纹

2. Lock 不只是“把版本写成 ==”

如果只把依赖改写成:

httpx==0.28.1

仍可能存在多个发行文件:

httpx-0.28.1-py3-none-any.whl
httpx-0.28.1.tar.gz

安装工具还可能因为:

  • 平台变化;
  • Python 解释器变化;
  • wheel 优先级变化;
  • index 内容变化;
  • source build 过程变化;

而得到不同的安装行为。

高质量 Lock 应尽量记录发行文件层面的信息,而不只记录项目名和版本。

3. pylock.toml:可互操作的锁文件格式

PyPA 当前定义了 pylock.toml 规范,用于描述可复现安装所需的依赖。该格式最初由 PEP 751 定义,当前格式版本为:

lock-version = "1.0"

规范中可以记录:

  • requires-python
  • environments
  • 项目名和版本;
  • 依赖关系;
  • wheel 和 sdist;
  • URL 或本地路径;
  • 文件大小;
  • 哈希;
  • 发布时间;
  • 可用的 attestation identity;
  • 生成该锁文件的工具信息。

(packaging.python.org)

一个简化的 pylock.toml 片段如下:

lock-version = "1.0"
requires-python = ">=3.14,<3.15"
environments = [
  "sys_platform == 'linux' and platform_machine == 'x86_64'",
]

[[packages]]
name = "httpx"
version = "0.28.1"
requires-python = ">=3.8"

  [[packages.wheels]]
  name = "httpx-0.28.1-py3-none-any.whl"
  url = "https://example.invalid/httpx-0.28.1-py3-none-any.whl"
  size = 123456
  hashes = {sha256 = "..."}

[[packages]]
name = "pydantic"
version = "2.10.6"
requires-python = ">=3.8"

  [[packages.wheels]]
  name = "pydantic-2.10.6-py3-none-any.whl"
  url = "https://example.invalid/pydantic-2.10.6-py3-none-any.whl"
  size = 234567
  hashes = {sha256 = "..."}

上面的 URL 使用了示意域名,不能直接用于安装。重点在数据结构:锁文件区分了项目版本和具体 wheel 文件。

4. Lock 可以是单环境,也可以是多环境

单环境锁文件比较简单:

Python 3.14 + Linux x86_64

多环境锁文件可能同时包含:

Python 3.14 + Linux x86_64
Python 3.14 + Windows x86_64
Python 3.14 + macOS arm64

如果一个包有平台相关 wheel,Lock 可以记录多个 wheel:

[[packages]]
name = "native-lib"
version = "1.2.0"

  [[packages.wheels]]
  name = "native-lib-1.2.0-cp314-cp314-manylinux_2_28_x86_64.whl"
  hashes = {sha256 = "..."}

  [[packages.wheels]]
  name = "native-lib-1.2.0-cp314-cp314-win_amd64.whl"
  hashes = {sha256 = "..."}

安装时,工具应根据目标环境选择匹配的文件,而不是重新根据 index 当前内容随意寻找另一个文件。pylock.toml 的安装流程要求检查 Python 和环境条件,并在获取发行文件后根据大小和哈希验证。(packaging.python.org)


九、Lock 的生命周期:生成、审查、安装和更新

一个实际工作流可以表示为:

flowchart TD
    A[pyproject.toml 直接依赖] --> B[解析器读取元数据]
    B --> C[展开传递依赖]
    C --> D[应用版本约束与环境标记]
    D --> E[选择具体版本]
    E --> F[选择 wheel 或 sdist]
    F --> G[记录 URL、大小、哈希]
    G --> H[生成 Lock 文件]
    H --> I[代码审查与 CI 验证]
    I --> J[部署环境]
    J --> K[按 Lock 获取文件]
    K --> L[验证哈希与兼容标签]
    L --> M[安装]

1. 生成阶段

开发者修改:

dependencies = [
  "httpx>=0.28,<1.0",
]

然后由锁定工具执行解析。这个过程允许访问包索引,并可能读取多个候选版本的元数据。

生成阶段关注:

能否找到解
解是否符合 Python 3.14 和目标平台
是否启用了正确的 extras
是否包含传递依赖
是否记录了正确的发行文件

2. 审查阶段

Lock 文件进入版本控制后,代码审查不应只看:

-httpx==0.28.0
+httpx==0.28.1

还应关注:

  • 是否更换了下载源;
  • 是否从 wheel 变成 sdist;
  • 是否新增平台文件;
  • 哈希是否发生变化;
  • 是否引入了新的传递依赖;
  • 是否改变了 requires-python
  • 是否出现不熟悉的包名或命名空间;
  • 是否启用了新的 extra。

3. 安装阶段

部署时理想流程是:

读取 Lock
→ 检查当前解释器和平台是否匹配
→ 选择 Lock 中已记录的发行文件
→ 下载或从内部缓存读取
→ 校验大小和哈希
→ 安装

如果 Lock 只记录“项目名 + 版本”,安装工具可能仍需要访问 index 来决定具体文件。若 Lock 记录了文件 URL、哈希和平台条件,安装就更接近“按清单取件”,而不是“现场重新求解”。

4. 更新阶段

更新 Lock 不应等同于执行:

python -m pip install --upgrade everything

更可控的更新步骤是:

1. 修改直接依赖的允许范围;
2. 重新解析;
3. 比较 Lock 差异;
4. 查看新增、删除和升级的传递依赖;
5. 执行测试;
6. 执行安全扫描和许可证检查;
7. 合并 Lock 变更;
8. 在干净环境验证安装。

这样可以把“依赖升级”变成可审查的变更,而不是隐藏在某次部署中的环境漂移。


十、哈希:验证文件内容,而不是验证包名

1. 哈希校验解决什么问题

设下载文件字节序列为 F,使用 SHA-256 计算:

h = SHA256(F)

Lock 或 requirements 文件记录预期哈希:

h_expected

安装时重新计算:

h_actual = SHA256(F_downloaded)

只有:

h_actual == h_expected

才接受这个文件。

哈希可以检测:

  • 下载损坏;
  • 缓存内容被替换;
  • 镜像返回了不同文件;
  • 同名同版本文件内容发生变化;
  • 锁文件与实际文件不一致。

2. pip 的 --require-hashes

pip 支持在 requirements 文件中写入哈希,并使用:

python -m pip install --require-hashes -r requirements.txt

示例:

httpx==0.28.1 \
    --hash=sha256:1111111111111111111111111111111111111111111111111111111111111111

pydantic==2.10.6 \
    --hash=sha256:2222222222222222222222222222222222222222222222222222222222222222

这些哈希只是示例,不能用于真实安装。

启用 hash-checking mode 后,所有依赖都需要提供哈希,并且依赖通常还必须被固定到具体版本、URL 或路径,否则新版本出现时可能造成无法预期的哈希变化。pip 文档同时说明,可以为同一项目记录多个哈希,以覆盖不同平台的 wheel 或 source distribution。(pip.pypa.io)

3. 只有顶层包有哈希是不够的

以下配置是不完整的:

httpx==0.28.1 --hash=sha256:...

如果 httpx 的传递依赖没有哈希,攻击者可能通过未校验的传递依赖进入安装过程。

安全的 hash-checking 模式要求:

所有被安装的发行文件

都满足哈希校验,而不是只有手工列出的顶层依赖。

4. 一个版本可以有多个允许哈希

例如:

native-lib==1.2.0 \
    --hash=sha256:linux-wheel-hash \
    --hash=sha256:windows-wheel-hash \
    --hash=sha256:source-hash

这表达的是:

版本必须是 1.2.0,
并且最终文件必须是这些已知文件之一。

如果只记录 Linux wheel 的哈希,却在 Windows 上安装,可能因为没有匹配的已知哈希而失败。这是预期行为,不是安装工具“过于严格”。

5. 远程 URL 上的哈希和本地 Lock 哈希不同

index 页面可能提供:

https://.../package.whl#sha256=...

这个哈希可以帮助检测传输错误,但如果预期哈希本身是从不可信来源动态获取的,它不能独立证明文件没有被恶意替换。

安全模型是:

可信来源提供预期哈希
+
安装时重新计算文件哈希
=
检测文件内容是否与预期一致

如果攻击者同时替换了文件和预期哈希,哈希校验就失去意义。因此 Lock 文件、内部制品仓库或经审核的发布流程必须成为预期哈希的可信根之一。


十一、哈希不能解决的事情

哈希非常重要,但它的语义必须准确。

1. 哈希不能证明代码无恶意行为

如果发布者本来就上传了恶意代码,那么:

恶意文件

也可以有一个稳定、正确的 SHA-256。

哈希只回答:

下载到的字节是否等于预期字节?

它不回答:

预期字节是否安全?

2. 哈希不能证明发布者身份

哈希没有内建的签名者身份信息。它可以识别内容,但不能单独证明:

这个文件一定由某个组织发布

身份认证需要额外的机制,例如:

  • 可信的仓库访问控制;
  • 发布者身份认证;
  • 签名;
  • provenance;
  • attestation;
  • 内部制品仓库的审核流程。

pylock.toml 可以记录与发行文件关联的 attestation identity,但是否验证这些身份、如何验证,仍由安装和供应链工具的能力决定。规范把它作为可记录的发布证明身份信息,而不是把普通哈希自动提升为签名。(packaging.python.org)

3. 哈希不能覆盖构建过程

安装 source distribution 时,流程可能是:

下载 sdist
→ 创建构建环境
→ 安装构建依赖
→ 执行构建后端
→ 生成 wheel
→ 安装 wheel

哈希可以校验 sdist,但如果构建依赖、编译器、系统库或构建脚本发生变化,最终生成的 wheel 仍可能不同。

这就是为什么生产环境通常更偏向:

预构建且已审查的 wheel

而不是在每台部署机器上临时构建 source distribution。

不过,wheel 也不是绝对安全:wheel 中可能包含恶意 Python 代码或恶意原生扩展,哈希只保证它是预期的那个文件。


十二、供应链:依赖安装是一条代码进入系统的路径

Python 包安装不是纯数据操作。pip 文档明确指出,默认安装过程涉及从发行版中获取并运行代码,且默认不会对远程篡改提供完整保护;启用哈希模式和限制发行格式可以强化安装流程。(pip.pypa.io)

一条典型供应链可以表示为:

开发者源代码
   ↓
构建后端
   ↓
sdist / wheel
   ↓
包索引或内部仓库
   ↓
解析器选择候选
   ↓
下载与缓存
   ↓
构建或安装
   ↓
运行时 import

每一层都有不同风险。

1. 依赖混淆

假设公司内部使用:

acme-auth

构建脚本却允许从公共 index 和内部 index 同时寻找包。如果公共仓库中出现同名恶意包,解析器可能选择错误来源。

风险来自:

名称相同
+
来源优先级或配置不明确
+
开发者误以为名称天然属于内部组织

治理重点不是只锁版本,还要明确:

  • index 来源;
  • 包命名空间;
  • 内部包的发布权限;
  • 是否允许 --extra-index-url
  • 是否对来源进行审查和记录。

2. 依赖劫持和发布权限失控

如果攻击者获得包维护者账号权限,可以发布一个看似正常的新版本:

1.4.1

它可能满足原有约束:

demo >= 1.0,<2.0

因此仅使用宽范围约束不能阻止恶意版本进入解析结果。

Lock 可以防止部署时自动从 1.4.0 漂移到 1.4.1,但当团队主动重新生成 Lock 时,仍然需要审查新版本。

3. 传递依赖扩大攻击面

直接依赖可能只有:

web-framework
database-client

但完整依赖树可能包含几十个传递依赖。真正进入运行环境的代码集合是:

直接依赖 ∪ 传递依赖 ∪ 构建依赖

如果只审查 pyproject.toml,就看不到完整攻击面。Lock 的一个重要价值正是把传递依赖显式化。

4. 安装脚本和构建脚本

source distribution 的构建过程可能执行构建后端和构建依赖提供的代码。即使项目本身没有运行时恶意逻辑,构建阶段也可能接触:

  • 环境变量;
  • 网络;
  • 文件系统;
  • 编译器;
  • Secret;
  • CI 工作目录。

因此应将构建阶段视为代码执行边界,而不是“下载压缩包”。


十三、虚拟环境与 Lock 的关系

虚拟环境解决的是:

不同项目的安装目录和解释器路径相互隔离

例如:

python3.14 -m venv .venv
. .venv/bin/activate
python -m pip install ...

但虚拟环境不解决:

  • 依赖版本是否可复现;
  • 下载文件是否被替换;
  • 包是否来自正确仓库;
  • 包本身是否存在恶意代码;
  • 系统动态库是否一致;
  • 构建过程是否安全。

可以把环境隔离和依赖锁定看成两个维度:

venv:隔离安装状态
Lock:约束安装内容
hash:验证文件内容
供应链控制:验证来源与发布过程

1. 常见路径污染

在排查依赖问题时,应使用同一个解释器调用 pip:

python -m pip --version
python -c "import sys; print(sys.executable)"
python -c "import site; print(site.getsitepackages())"

预期结果应显示:

pip 位于当前项目的 .venv
sys.executable 指向 .venv/bin/python 或 .venv\Scripts\python.exe
site-packages 位于当前 .venv

如果执行:

pip install ...

和执行:

python -m pip install ...

使用的不是同一个解释器,就可能出现:

pip 显示安装成功
但当前 Python 无法 import

这属于解释器路径污染,不是依赖解析器选择错误。

2. pip freeze 不是完整 Lock

python -m pip freeze

可以输出当前环境中已安装的项目及版本,例如:

httpx==0.28.1
pydantic==2.10.6

但它通常不能完整表达:

  • 为什么这些包被安装;
  • 哪个是直接依赖;
  • 哪个是传递依赖;
  • 使用了哪个 wheel;
  • 文件哈希是什么;
  • 来源 URL 是什么;
  • 当前环境之外是否有可用候选;
  • extras 和环境标记如何参与解析。

因此,pip freeze 更像是安装状态的快照,不应自动等同于可验证的 Lock。


十四、一个可执行的 Python 3.14 示例

下面建立一个最小项目:

demo-project/
├── pyproject.toml
└── src/
    └── demo_app/
        └── __init__.py

pyproject.toml

[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"

[project]
name = "demo-app"
version = "0.1.0"
requires-python = ">=3.14,<3.15"
dependencies = [
  "httpx>=0.28,<1.0",
]

[project.optional-dependencies]
test = [
  "pytest>=8,<9",
]

创建 Python 3.14 虚拟环境:

python3.14 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip

检查解释器:

python -c "import sys; print(sys.version); print(sys.executable)"

预期输出应包含:

3.14...
.../demo-project/.venv/bin/python

安装当前项目及测试依赖:

python -m pip install -e ".[test]"

这里的 -e 是 editable install。它适合开发阶段,使源码修改通常可以直接被当前环境看到;生产环境一般更适合安装构建出来的 wheel,以便部署物明确、可归档、可校验。

查看已安装包:

python -m pip list

查看依赖关系时,可以使用具体工具提供的树形命令或检查安装元数据。诊断某个包来自哪里,可以查看:

python -m pip show httpx

需要注意,以上命令验证的是当前环境状态,不会自动生成一个具有完整来源和哈希信息的跨工具 Lock。


十五、使用哈希 requirements 的最小流程

可以先下载目标依赖而不安装:

python -m pip download \
  --only-binary=:all: \
  --dest wheelhouse \
  "httpx==0.28.1"

参数含义:

  • --only-binary=:all::只接受 wheel;
  • --dest wheelhouse:把发行文件保存到本地目录;
  • httpx==0.28.1:固定项目版本。

然后计算文件哈希:

python -m pip hash wheelhouse/*

输出类似:

wheelhouse/httpx-0.28.1-py3-none-any.whl:
--hash=sha256:...

把结果写入 requirements 文件:

httpx==0.28.1 \
    --hash=sha256:...

安装时:

python -m pip install \
  --require-hashes \
  --only-binary=:all: \
  -r requirements.txt

这条命令的安全含义是:

版本必须匹配;
文件必须是 wheel;
所有依赖必须有本地记录的哈希;
下载文件的实际哈希必须匹配。

但它仍然不保证:

包没有恶意行为;
包的维护者身份可信;
构建产物没有设计缺陷;
依赖许可证符合项目要求。

pip 的安全安装文档还特别指出,hash-checking mode 可以用于 pip downloadpip wheel,但如果一个包存在多个适用于不同平台的发行文件,就需要记录所有允许的哈希。(pip.pypa.io)


十六、Lock 安装为什么仍可能失败

即使 Lock 文件已经生成,安装也可能失败。失败路径包括:

1. Python 版本不匹配

Lock 声明:

requires-python = ">=3.14,<3.15"

却在 Python 3.13 环境中安装。此时应在早期检查阶段失败,而不是安装一半后才暴露错误。pylock.toml 规范要求安装工具检查解释器是否满足 requires-python。(packaging.python.org)

2. 环境不在 Lock 覆盖范围

Lock 只覆盖:

environments = [
  "sys_platform == 'linux' and platform_machine == 'x86_64'",
]

但实际部署在 Windows。此时不能简单假设 Lock 中的 Linux wheel 能在 Windows 使用。

3. 缺少目标平台 wheel

Lock 记录了:

cp314-cp314-manylinux_2_28_x86_64

实际环境却是:

cp314-cp314-musllinux_1_2_aarch64

这不是版本冲突,而是发行文件不兼容。

4. 哈希不匹配

可能原因包括:

  • 文件下载损坏;
  • 内部缓存污染;
  • URL 返回了不同内容;
  • Lock 文件与仓库中的文件不一致;
  • Lock 记录错误;
  • 供应链中的文件被替换。

正确恢复方式不是随手删除哈希,而是:

1. 保存失败文件和日志;
2. 重新从可信来源获取同名文件;
3. 独立计算哈希;
4. 与 Lock、仓库和发布记录比较;
5. 确认是否为合法发布变更;
6. 必要时重新生成并审查 Lock。

十七、可复现安装的边界

“可复现”不是单一强度的属性,至少有几个层次。

层次一:版本可复现

每个项目版本固定

例如:

httpx==0.28.1

它减少了解析漂移,但不一定固定具体文件。

层次二:发行文件可复现

项目版本 + wheel/sdist + 哈希固定

这可以确保下载到相同字节的文件。

层次三:安装环境可复现

还需要固定:

  • Python 3.14 的具体解释器构建;
  • 操作系统和架构;
  • 系统库;
  • 编译器;
  • 构建依赖;
  • 环境变量;
  • 安装顺序和构建参数。

层次四:运行行为可复现

即使字节完全相同,运行结果仍可能受:

  • 时区;
  • locale;
  • 系统时间;
  • 外部服务;
  • 数据库版本;
  • CPU 指令集;
  • 内核行为;

影响。

所以 Lock 能显著增强依赖安装的可重复性,但不等于整个系统自动具备确定性。


十八、生产环境如何选择 wheel、sdist 和内部仓库

1. wheel 优先

wheel 是已经构建好的二进制发行格式。对生产环境而言,使用已审查的 wheel 通常可以减少:

部署时编译失败
构建依赖漂移
编译器差异
系统头文件差异

但平台相关 wheel 必须匹配目标 Python、ABI、操作系统和架构。

2. sdist 适合需要本地构建的场景

sdist 保留源代码和构建元数据,适合:

  • 尚未提供目标平台 wheel 的包;
  • 需要本地编译;
  • 内部构建流程统一生成制品;
  • 需要对源码进行内部审计。

代价是构建阶段会引入额外代码执行和环境变量依赖。

3. 内部制品仓库和缓存

内部仓库的主要价值不是“网络更快”,而是把供应链控制点收回组织内部:

公共索引
  → 下载
  → 扫描与审核
  → 固定哈希
  → 内部仓库存档
  → 生产部署

如果生产部署始终从公共索引实时获取,Lock 即使记录了版本和哈希,也仍然依赖公共服务的可用性和组织对外部来源的持续信任。


十九、常见误解与反例

误解一:>= 已经足够稳定

httpx>=0.28

不是一个具体安装结果。只要发布了 0.29.00.30.0,它们可能继续满足约束。

它适合表达项目兼容范围,不适合作为生产部署的唯一复现机制。

误解二:== 就等于文件固定

httpx==0.28.1

仍可能对应多个发行文件。要固定文件,还需要:

具体文件选择
+
哈希

误解三:Lock 能阻止恶意代码

Lock 可以把选择结果固定下来,但如果锁入的版本本身就是恶意版本,Lock 只会更稳定地安装它。

误解四:哈希能验证来源身份

哈希能验证内容相等,不能单独验证发布者身份。身份需要签名、发布证明、仓库权限和审查链。

误解五:虚拟环境就是安全沙箱

虚拟环境主要提供路径和安装目录隔离。被安装的包仍然可以在进程权限范围内访问文件、网络和环境变量。运行不可信依赖需要操作系统级隔离、容器权限控制或专门的沙箱,而不是只创建一个 .venv

误解六:pip freeze 就是 Lock

pip freeze 记录当前已安装项目的版本快照,但不一定包含完整依赖来源、发行文件哈希、平台条件和生成过程。它可以作为排障材料,不能自动等同于可验证的 Lock。

误解七:升级一个直接依赖只会改变一个包

依赖解析是整体求解。升级一个顶层包可能改变:

它的传递依赖
其他包共享的依赖版本
平台 wheel 选择
构建依赖

因此审查 Lock 时必须查看整个差异,而不是只看命令行中指定的那个项目。


二十、诊断依赖问题的顺序

面对“本地能运行、CI 失败”或“昨天能安装、今天不能安装”,按以下顺序更容易定位。

第一步:确认解释器

python -c "import sys; print(sys.executable); print(sys.version)"
python -m pip --version

确认 pip 和 Python 属于同一个虚拟环境。

第二步:确认平台标签

python -c "import sysconfig; print(sysconfig.get_platform())"

再检查目标 wheel 的文件名是否匹配 Python 3.14、ABI、操作系统和架构。

第三步:确认直接依赖声明

检查:

pyproject.toml
requirements.in
requirements.txt
constraints.txt

区分:

直接依赖
传递依赖
约束文件
锁定结果

第四步:查看解析日志

python -m pip install -vvv ...

重点观察:

  • 哪些版本被发现;
  • 哪些候选因 Requires-Python 被排除;
  • 哪些 wheel 因平台标签被排除;
  • 是否发生回溯;
  • 最终冲突来自哪个依赖链。

第五步:校验发行文件

对于 Lock 或 requirements 中记录的文件:

sha256sum path/to/package.whl

Windows PowerShell 可以使用:

Get-FileHash .\package.whl -Algorithm SHA256

将结果与 Lock 或审核过的 requirements 文件比较。

第六步:在干净环境重放

不要直接在已经污染的环境中反复执行安装。创建新的 Python 3.14 虚拟环境,再使用同一个 Lock 或 requirements 重试:

python3.14 -m venv /tmp/demo-venv
/tmp/demo-venv/bin/python -m pip install ...

如果干净环境成功,而旧环境失败,问题很可能来自旧环境状态、残留包、editable install 或路径污染。


二十一、如何划分职责

一个清晰的依赖管理系统可以按以下边界组织:

pyproject.toml
  负责:项目身份、Python 范围、直接依赖和可选依赖

解析器
  负责:根据约束和环境寻找可行解

Lock 文件
  负责:保存可复用的具体解析结果和发行文件信息

哈希
  负责:验证下载文件内容

仓库与发布系统
  负责:控制来源、权限、审计和制品保存

虚拟环境
  负责:隔离解释器和安装目录

CI/CD
  负责:在干净环境中验证解析、安装、测试和安全策略

混淆这些职责会产生错误期待:

把 requirements 当项目声明
把 pip freeze 当完整 Lock
把版本固定当文件校验
把哈希校验当身份认证
把 venv 当安全沙箱
把能安装成功当供应链可信

结语

Python 依赖管理的核心不是“把版本写死”,而是建立一条可解释的选择链:

版本规范
→ 版本约束
→ 依赖图
→ 环境条件
→ 候选解析
→ 具体发行文件
→ Lock
→ 哈希校验
→ 来源和构建审查
→ 干净环境安装

版本约束定义允许范围,解析器寻找满足全部条件的解,Lock 保存一次经过选择的结果,哈希确认下载内容没有偏离预期,供应链控制则进一步回答“这个预期文件为什么值得信任”。

对于 Python 3.14 项目,可靠的生产安装至少应明确三件事:

使用哪个解释器和平台
选择哪个依赖解及发行文件
如何验证文件和来源

只有把这三层都记录并验证,依赖管理才从“安装命令集合”变成了可审计、可诊断、可恢复的工程系统。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。