《Python编程入门》15.3 版本约束、锁定与可复现构建

锁定依赖是可复现构建的地基。本节先拆 pip freeze 为何不可复现,再对比 uv/poetry/pdm/pip-tools 的锁文件分工,用 packaging 与 pip 实测 diamond dependency 冲突,演示 require-hashes 哈希校验与离线安装,最后落到容器镜像中的可复现要点。

本节目标:理解为什么「能装」不等于「装得一样」,掌握锁定文件、哈希校验与冲突检测,让同一项目在任何机器上装出一致环境。
适用版本: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://...@mainmain 会漂移,且需网络与 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-toolsrequirements.txt(编译产物)最轻量,pip-compile 生成带哈希的锁定未装
uvuv.lockRust 实现,极快,一个工具管环境+Python+锁未装
Poetrypoetry.lock老牌,配置在 [tool.poetry]未装
PDMpdm.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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时