《Python编程实战》2.1 依赖解析与锁文件

锁定文件是可复现构建的地基。本节先拆解 pip freeze 为何不可复现,再对比 uv.lock 与 pip-tools 的解析与锁定机制,实测约束文件、平台标记与 PEP 751 pylock.toml,最后讲哈希校验如何把依赖内容彻底锁死。

本节目标:搞清「声明范围」与「锁定版本」的分工,掌握依赖解析、约束文件、平台标记与哈希校验,让同一项目在任何机器上装出一致环境。
适用版本: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://...@mainmain 会漂移,且需网络与 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-toolsuv
锁文件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 私有源、镜像与离线安装 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

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