Python 基础体系 · 第 64/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 依赖解析与锁定:版本约束、Lock、哈希和供应链
Python 项目中的“安装依赖”并不是把若干目录复制到 site-packages。一个完整过程至少包含:
- 根据项目声明收集直接依赖;
- 读取每个候选发行版的元数据;
- 展开传递依赖;
- 根据版本约束、Python 版本、操作系统和 CPU 架构筛选候选;
- 在依赖图中寻找一个相互兼容的解;
- 为每个包选择具体发行文件,例如 wheel 或 source distribution;
- 下载、校验、构建并安装;
- 记录这次安装究竟使用了哪些包、哪些文件以及哪些来源。
因此,依赖管理至少要区分四个问题:
- 允许安装什么版本:版本约束;
- 本次实际选择了什么版本:解析结果;
- 下次是否复用同一个解析结果: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;aN、bN、rcN:预发布版本;.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.0 和 1.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
环境标记中的 and、or 和括号会影响语义。例如:
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,还包括 postgres 和 redis 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;
- 生成该锁文件的工具信息。
一个简化的 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 download 和 pip 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.0、0.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 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 虚拟环境:venv、解释器隔离、路径和常见污染
- 下一篇:Python 包构建与发布:wheel、sdist、索引、签名和版本
- 延伸:Python 安全工程:输入、注入、反序列化、依赖、Secret 和沙箱
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论