本节目标:理解为什么「能装」不等于「装得一样」,掌握锁定文件、哈希校验与冲突检测,让同一项目在任何机器上装出一致环境。
适用版本:Python 3.12+(实测 3.14.6)
15.3 版本约束、锁定与可复现构建
15.1 学会了写版本范围,15.2 学会了打包发布。但一个真实项目里,直接依赖往往只有几个,传递依赖却可能有几十个。只要有一个传递依赖被上游更新,你的环境就变了。这一节解决「可复现」:同一份代码,今天和半年后、本机和 CI,装出的环境应当一致。
15.3.1 为什么 pip freeze 不可复现
pip freeze 会打印当前环境已装包及版本,看似能当锁定文件:
python -m pip freeze
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
build==1.6.1
coverage==7.16.2
但它有几种情形根本不可复现:
| 情形 | 表现 | 后果 |
|---|---|---|
| 可编辑安装 | -e /path/to/pkg | 换台机器路径不存在 |
| VCS 安装 | pkg @ git+https://...@main | main 会漂移,且需网络与 Git |
| 本地路径 | pkg @ file:///home/me/... | 路径不存在 |
| 平台标记缺失 | 混入本机专属包 | 别的平台装不上 |
| 只冻结当前环境 | 少装了某依赖 | 换机补不上 |
| 依赖不递归 | 手工写的 requirements | 传递依赖没锁 |
最根本的问题:pip freeze 记录的是「我这台机器上碰巧装了什么」,而不是「这个项目需要什么」。它还会把环境里的无关包(编辑器插件、临时调试库)一起冻结进去。
15.3.2 == 精确锁定的代价
于是很多人用 == 把所有版本钉死:
httpx==0.28.1
pydantic==2.13.5
这确实可复现了,但代价不小:你再也收不到安全补丁。pydantic==2.13.5 之后即使发布 2.13.6 修了漏洞,你的环境也不会升。而且手动维护几十行 == 极容易漏项、冲突。
正确的分工是:pyproject.toml 声明「能接受的版本范围」,锁定文件记录「这次实际用的精确版本」。
15.3.3 语义化版本与现实
语义化版本(SemVer)约定 MAJOR.MINOR.PATCH:MAJOR 破坏兼容、MINOR 加功能、PATCH 修 bug。按这个约定,~=1.4.2(即 <1.5.0)应当只允许 PATCH 与 MINOR 升级,是安全的。
但 Python 生态并不严格遵守:很多库在 MINOR 版本里塞破坏性改动,甚至有 0.x 阶段任意破坏的惯例。所以:
- 依赖库自己发布时,要严格守 SemVer,这是对用户的基本尊重;
- 依赖库消费别人时,别盲信 SemVer,用锁定文件 + 测试兜底。
15.3.4 锁文件与 pyproject.toml 的分工
| 文件 | 写什么 | 谁维护 | 进版本库吗 |
|---|---|---|---|
pyproject.toml | 直接依赖 + 版本范围 | 人手写 | 是 |
| 锁文件 | 全部依赖(含传递)+ 精确版本 + 哈希 | 工具生成 | 是(应用项目)/ 可选(库) |
库项目通常不提交锁文件——你希望用户拿到尽量宽松的范围。应用项目(部署上线的服务)必须提交锁文件——你要的是「每次部署完全一致」。这个区别是理解整个依赖管理的关键。
15.3.5 锁文件工具对比
| 工具 | 锁文件 | 特点 | 本机实测 |
|---|---|---|---|
| pip-tools | requirements.txt(编译产物) | 最轻量,pip-compile 生成带哈希的锁定 | 未装 |
| uv | uv.lock | Rust 实现,极快,一个工具管环境+Python+锁 | 未装 |
| Poetry | poetry.lock | 老牌,配置在 [tool.poetry] | 未装 |
| PDM | pdm.lock | 遵循 PEP 621,配置在 [project] | 未装 |
四者的共同点:读 pyproject.toml → 解析出完整依赖图 → 写出一份精确到版本(部分含哈希)的锁文件。选型看团队,功能上大同小异;本节重点讲底层机制,工具只是外壳。
以最轻量的 pip-tools 为例,它的工作流是「声明 → 编译 → 同步」两步走:
# requirements.in:只写直接依赖与范围
# httpx>=0.27,<1
pip-compile requirements.in # 生成 requirements.txt(含全部传递依赖与哈希)
pip-sync requirements.txt # 让当前环境精确等于锁定文件
pip-compile 的产物就是 15.3.4 说的锁定文件——把范围解析成精确版本;pip-sync 则更激进:它会卸载环境里不在锁定文件中的包,让环境与锁文件完全对齐。这正是「可复现」想要的效果。
说明:pip-tools 未在本机预装,上述命令未实测,仅说明工作流。其余三款工具(uv/poetry/pdm)思路一致,只是把「编译」与「同步」封装进各自的子命令。
要不要给依赖加上界? 一个常见争论:写 httpx>=0.27 还是 httpx>=0.27,<1。下界保证功能可用,上界防止未来大版本破坏。库项目倾向于宽松(只写下界,让用户自己组合),应用项目倾向于收紧(写下界 + 上界,再用锁文件定死具体版本)。没有绝对对错,取决于你能接受多大的「上游漂移」。
15.3.6 依赖解析与冲突检测
依赖解析要回答的问题:是否存在一组版本,同时满足所有约束? 经典的失败形态是 diamond dependency——两个库依赖同一个底层库,但要求不相容。用 packaging 实测(版本 26.3):
from packaging.requirements import Requirement
reqs = {"libA": "urllib3>=1.26,<2", "libB": "urllib3>=2.0"}
specs = {k: Requirement(v).specifier for k, v in reqs.items()}
for k, s in specs.items():
print(f"{k} 要求 urllib3 {s}")
candidates = ["1.26.18", "1.26.19", "2.0.7", "2.2.1"]
ok = [v for v in candidates if all(s.contains(v) for s in specs.values())]
print("同时满足两者的版本:", ok if ok else "(空集 -> 无法解析)")
libA 要求 urllib3 <2,>=1.26
libB 要求 urllib3 >=2.0
同时满足两者的版本: (空集 -> 无法解析)
libA 要 <2.0,libB 要 >=2.0,区间没有交集。真实 pip 会直接报解析失败:
python -m pip install --dry-run "urllib3<2" "urllib3>=2"
ERROR: Cannot install urllib3<2 and urllib3>=2 because these package versions have conflicting dependencies.
ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
遇到 ResolutionImpossible,出路通常是:升级其中一个库、用版本范围错开、或者隔离到不同进程。别用 --force-reinstall 硬装——那只会得到一个运行时才炸的环境。
15.3.7 哈希校验与离线安装
锁定到版本还不够——同一个版本号的包理论上可以被换内容(PyPI 不允许覆盖已发布文件,但私有源或镜像未必)。加哈希校验能锁死内容:
packaging==26.3 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
哈希用 pip hash 生成:
python -m pip hash packaging-26.3-py3-none-any.whl
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
带 --require-hashes 安装时,哈希不匹配会被直接拒绝(实测输出):
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
Expected sha256 e00000008e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
Got d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
配合 pip download 可以把包提前拉到本地,实现离线安装:
python -m pip download --dest offline --only-binary=:all: "packaging==26.3"
python -m pip install --no-index --find-links offline --require-hashes -r reqs.txt
--no-index 禁用网络索引、--find-links 从本地目录找包,两者叠加就是一次完全离线、可校验的安装——这是内网与 CI 缓存的常用组合。
15.3.8 pip check 与 uv 的现代工作流
环境装完后,pip check 用来验证「已装依赖之间没有版本冲突」:
python -m pip check
No broken requirements found.
它只检查已装包的一致性,不联网、不修复,适合放进 CI 作为健康检查。
uv 是近年流行的现代工作流,把「建环境 + 装包 + 锁版本」合成一条命令链:
uv init myapp # 生成 pyproject.toml
uv add httpx # 加依赖并更新 uv.lock
uv lock # 只解析、只写锁文件
uv sync # 严格按 uv.lock 装环境
说明:
uv未在本机预装,上述命令未实测,只作用法说明。它的核心概念与前文的锁文件机制一致——uv.lock就是 15.3.4 所说的「锁定文件」,uv sync就是「按锁文件还原环境」。
15.3.9 容器镜像里的可复现构建
Docker 里做可复现构建,要点是利用缓存分层 + 先锁后装:
FROM python:3.14-slim
WORKDIR /app
# 先只复制锁定文件:依赖没变时这层缓存不失效
COPY requirements.txt .
RUN pip install --no-cache-dir --require-hashes -r requirements.txt
# 再复制源码:改代码不会触发重装依赖
COPY . .
CMD ["python", "-m", "myapp"]
三个关键点:--require-hashes 保证内容一致;--no-cache-dir 减小镜像体积;先复制锁定文件再复制源码,让改动源码不会使依赖层缓存失效。此外固定基础镜像标签(别用 python:latest),镜像本身才可复现。
查可用版本时,pip index versions 能列出某个包的全部版本,便于挑选锁定目标:
python -m pip index versions requests
requests (2.34.2)
Available versions: 2.34.2, 2.34.1, 2.34.0, 2.33.1, ...
小结
pip freeze记录的是「本机现状」而非「项目需求」,在可编辑安装、VCS 依赖、本地路径、平台差异下都不可复现。- 分工原则:pyproject.toml 声明范围,锁定文件记录精确版本与哈希;库项目一般不提交锁文件,应用项目必须提交。
- SemVer 约定
MAJOR.MINOR.PATCH,但 Python 生态并非人人遵守,锁定 + 测试才是可靠兜底。 - diamond dependency 冲突会触发
ResolutionImpossible;packaging与pip都能提前暴露,别用强制安装掩盖。 --require-hashes校验内容、pip download+--no-index --find-links支持离线安装,二者是可复现构建的核心手段。pip check检查已装依赖一致性;uv把建环境、锁版本、同步合成一条链,但本机未装、未实测。
依赖环境锁定之后,第 15 章关于「打包发布」的闭环就完成了。接下来第 16 章进入 Web 服务开发,从 HTTP 协议与 WSGI/ASGI 讲起——你构建、锁定、部署的正是那一类服务。
阅读导航:上一节:15.2 构建 wheel、入口点与发布 PyPI · 下一节:16.1 HTTP 与 WSGI / ASGI 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。