本节目标:搞清「声明范围」与「锁定版本」的分工,掌握依赖解析、约束文件、平台标记与哈希校验,让同一项目在任何机器上装出一致环境。
适用版本:Python 3.12+(实测 3.14.6);uv 0.12.23
2.1 依赖解析与锁文件
1.3 把代码质量守在了提交门禁上,但一个项目真正的「不可控」往往来自依赖:直接依赖只有几个,传递依赖却可能几十个。只要有一个传递依赖被上游悄悄更新,你的环境就变了。本节解决可复现构建里最核心的一环——把「这次实际用的精确版本」固定下来。
2.1.1 为什么 pip freeze 不可复现
pip freeze 打印当前环境已装包及版本,看上去像一份锁定文件。在本机 venv 上实测:
python -m pip freeze
aiosqlite==0.22.1
alembic==1.20.0
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
...
但它记录的是「我这台机器上碰巧装了什么」,而不是「这个项目需要什么」。几种情形下根本不可复现:
| 情形 | 表现 | 后果 |
|---|---|---|
| 可编辑安装 | -e /path/to/pkg | 换台机器路径不存在 |
| VCS 安装 | pkg @ git+https://...@main | main 会漂移,且需网络与 Git |
| 本地路径 | pkg @ file:///home/me/... | 路径不存在 |
| 平台标记缺失 | 混入本机专属包 | 别的平台装不上 |
| 依赖不递归 | 手工拼的 requirements | 传递依赖没锁 |
它还会把编辑器插件、临时调试库一起冻结进去。最根本的问题:freeze 是「现状快照」,不是「需求声明」。
正确的做法是在 pyproject.toml 里声明依赖范围,再用锁文件固定解析结果。关于 pyproject.toml 的每一个字段([project]、[build-system]、各工具配置),可延伸阅读 pyproject.toml 配置完全手册
。
2.1.2 依赖解析要回答什么
依赖解析只回答一个问题:是否存在一组版本,同时满足所有约束? 经典失败形态是 diamond dependency——两个上层库依赖同一个底层库,但要求不相容。用 packaging 26.3 实测:
from packaging.requirements import Requirement
from packaging.version import Version
constraints = {
"libA": Requirement("urllib3>=1.26,<2"),
"libB": Requirement("urllib3>=2.0"),
}
for name, req in constraints.items():
print(f"{name} 依赖 urllib3 {req.specifier}")
available = [Version(v) for v in ("1.26.18", "1.26.19", "2.0.7", "2.2.1")]
solvable = [
str(v) for v in available
if all(req.specifier.contains(v) for req in constraints.values())
]
print("同时满足所有约束的版本:", solvable or "空集 -> ResolutionImpossible")
libA 依赖 urllib3 <2,>=1.26
libB 依赖 urllib3 >=2.0
同时满足所有约束的版本: 空集 -> ResolutionImpossible
区间没有交集,真实解析器会直接报错。出路通常是升级其中一个库、用范围错开、或隔离到不同进程;别用 --force-reinstall 硬装,那只会得到一个运行时才炸的环境。
2.1.3 uv.lock 的结构
现代工具把解析结果写成锁文件。以 uv 0.12.23 为例,先声明范围再解析:
uv lock
Using CPython 3.14.6 interpreter at: /opt/homebrew/opt/python@3.14/bin/python3.14
Resolved 14 packages in 1.74s
生成的 uv.lock 是 TOML,顶部记录格式版本与 requires-python,之后每个包一段:
version = 1
revision = 5
requires-python = ">=3.12"
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
wheels = [
{ url = "https://files.pythonhosted.org/.../httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
三个关键信息:精确版本、来源索引、每个 wheel 的 sha256 与尺寸。注意 marker 字段会为跨平台依赖保留条件,例如 typing-extensions 带着 marker = "python_full_version < '3.15'"——锁文件是跨平台的,不是本机快照。查看解析出的树:
uv tree
demo-app v0.1.0
├── httpx v0.28.1
│ ├── anyio v4.15.1
│ │ ├── idna v3.20
│ │ └── typing-extensions v4.16.0
│ ├── certifi v2026.7.22
│ ├── httpcore v1.0.9
│ │ ├── certifi v2026.7.22
│ │ └── h11 v0.16.0
│ └── idna v3.20
└── pytest v9.1.1 (group: dev)
├── iniconfig v2.3.1
├── packaging v26.3
├── pluggy v1.6.0
└── pygments v2.21.0
2.1.4 uv 与 pip-tools 的机制对比
pip-tools 是最轻量的方案:requirements.in 写范围,pip-compile 解析成 requirements.txt,pip-sync 让环境精确等于锁文件(会卸载多余包)。uv 把同一套机制收进一个工具:
# pip-tools 风格:声明 -> 编译 -> 同步
pip-compile requirements.in # 生成 requirements.txt(含全部传递依赖)
pip-sync requirements.txt # 让环境精确对齐
# uv 等价物
uv pip compile requirements.in -o requirements.txt
uv pip sync requirements.txt
说明:pip-tools 本机未安装,其
pip-compile/pip-sync命令未实测,仅说明工作流;uv 的等价命令(uv pip compile、uv pip sync)已实测。
两者机制一致,差别在工程体验:
| 维度 | pip-tools | uv |
|---|---|---|
| 锁文件 | requirements.txt(文本) | uv.lock(TOML,含来源与 marker) |
| 实现 | 纯 Python,调 pip 解析 | Rust,自带解析器 |
| 速度 | 秒级到分钟级 | 毫秒到秒级 |
| 解释器管理 | 无 | uv python install 可管理 Python 本身 |
| 哈希 | 需 --generate-hashes | 锁文件默认记录 sha256 |
本机实测 uv pip compile requirements.in 解析 7 个包耗时 53ms;重复执行命中缓存后仅 6ms。选型看团队,但本节后续都以 uv 为例,因为它的锁文件把「来源 + 版本 + 哈希 + marker」一次写全。
2.1.5 约束文件与平台标记
**约束文件(constraints)**用来钉住传递依赖,而不把它们变成直接依赖。只写 anyio==4.15.1、certifi==2026.7.22 两行,再编译:
uv pip compile requirements.in -c constraints.txt
Resolved 7 packages in 31ms
# This file was autogenerated by uv via the following command:
# uv pip compile requirements.in -c constraints.txt
anyio==4.15.1
# via
# -c constraints.txt
# httpx
certifi==2026.7.22
# via
# -c constraints.txt
# httpcore
# httpx
输出的 # via -c constraints.txt 说明该版本是被约束文件钉住的,而非解析器自由选择。**平台标记(PEP 508 marker)**则描述「某依赖只在特定环境生效」,例如:
httpx>=0.27,<1
importlib-metadata>=7 ; python_version < "3.10"
colorama>=0.4 ; sys_platform == "win32"
默认编译只针对当前平台,条件不成立的包会被剔除。加 --universal 才会保留所有平台的 marker:
uv pip compile markers.in --universal
colorama==0.4.6 ; sys_platform == 'win32'
# via -r markers.in
typing-extensions==4.16.0 ; python_full_version < '3.15'
# via anyio
换平台时解析结果会变,实测对比 --python-platform:
uv pip compile requirements.in --python-platform windows # 含 colorama
uv pip compile requirements.in --python-platform linux # 不含 colorama
一个锁文件管所有平台靠的就是这些 marker;这也是 pip freeze 永远做不到的事。
2.1.6 锁文件的现代标准:PEP 751
uv 可以把内部锁文件导出成多种格式,uv export --format 支持 requirements.txt、pylock.toml、cyclonedx1.5。其中 pylock.toml 就是 PEP 751 定义的标准锁文件格式:
lock-version = "1.0"
created-by = "uv"
requires-python = ">=3.12"
[[packages]]
name = "colorama"
version = "0.4.6"
marker = "sys_platform == 'win32'"
index = "https://pypi.org/simple"
wheels = [{ url = "...", hashes = { sha256 = "4f1d9991..." } }]
cyclonedx1.5 导出的是 SBOM(软件物料清单,本机实测该格式带 experimental 警告),适合接供应链审计。导出成 requirements 时能带上哈希:
uv export --format requirements-txt --generate-hashes --no-emit-project
anyio==4.15.1 \
--hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \
--hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94
# via httpx
2.1.7 哈希锁死内容
锁定版本还不够——同一个版本号的包理论上可以被换内容(私有源或镜像未必像 PyPI 那样禁止覆盖)。哈希校验能锁死内容:
pip install --require-hashes -r requirements.txt
带 --require-hashes 时,任何一条哈希不匹配都会被直接拒绝。因此一份可信的 CI 流程是:uv lock 解析 → uv export --generate-hashes 产出带哈希的 requirements → CI 用 --require-hashes 安装。版本锁住「用哪个」,哈希锁住「是不是那个」。
小结
pip freeze记录的是「本机现状」而非「项目需求」,在可编辑安装、VCS 依赖、本地路径、平台差异下都不可复现。- 依赖解析回答「是否存在同时满足所有约束的版本」;diamond dependency 无解时会触发
ResolutionImpossible,不要用强制安装掩盖。 - 分工原则:
pyproject.toml声明范围,锁文件记录精确版本;uv.lock额外记录来源索引、sha256 与跨平台 marker。 - 约束文件(
-c)钉住传递依赖;--universal保留所有平台 marker,--python-platform切换目标平台。 - PEP 751 的
pylock.toml是标准锁文件格式;uv export还能产出带哈希的 requirements 与 CycloneDX SBOM。 - 版本锁住「用哪个」,
--require-hashes锁住「是不是那个」,两者叠加才是内容级可复现。
锁文件解决了「装哪一版」,但把锁文件变成一次真实安装,还要解决「从哪装、断网怎么办」。下一节转向私有源、镜像与离线安装——这正是内网与 CI 绕不开的一环。
阅读导航:上一节:1.3 代码规范、pre-commit 与提交门禁 · 下一节:2.2 私有源、镜像与离线安装 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。