跑一次 uv run,看看锁文件会不会动
“Talk is cheap. Show me the code.” — Linus Torvalds
跑一次 uv run,看看锁文件会不会动
从 pytest 前的一小段隐形工作,分清“解析依赖”“同步环境”和“重建环境”三件常被混成一件的事。
打开一个带 pyproject.toml 的项目,运行:
uv run --group dev pytest
先别看测试结果。看一眼 uv.lock 有没有变化,再看 .venv 里是否真的有 pytest。
很多人以为这条命令的意思是:“生成锁文件,然后跑测试。”
不是。
它更像是在每次执行前问一句:你现在要运行的命令,是否站在锁文件声明的那个环境里? 如果不是,先把环境拉回去;如果是,尽量什么也不做。
本文的承诺很具体:读完后,你能判断 uv run 何时会更新锁文件、何时只会同步环境;能在项目里正确选择 --group、--extra、--locked 与 --frozen;也能在重建虚拟环境时避免把一个失败悄悄带到下一步。
最反直觉的一点在这里:uv run 的日常工作不是重新安装,而是证明“不需要重新安装”。
先用一个玩具模型,把三件事拆开
把依赖管理想成三份不同的东西:
pyproject.toml ──解析──> uv.lock ──同步──> .venv ──执行──> pytest
想要什么 已决定什么 实际有什么 跑什么
这条链上有三个动作,名字很像,后果完全不同:
| 命令 | 改动的主要对象 | 不做什么 | 适合什么时候用 |
|---|---|---|---|
uv lock |
uv.lock |
不安装、不执行命令 | 你改了依赖,只想更新解析结果 |
uv sync --group dev |
.venv |
不运行测试 | 你要把环境明确同步到锁定状态 |
uv run --group dev pytest |
必要时同步 .venv,再执行 pytest |
锁一致时不会重解依赖 | 日常开发和 CI 中直接运行命令 |
锁文件回答“该装什么”;虚拟环境回答“现在装了什么”。两者不是同一份状态。
这也是 uv run 容易被误读的原因。它可以在锁文件缺失、或项目声明已经变化时触发解析并更新锁文件;但那是为了完成“跑在正确环境里”的目标,不是它每次执行的固定前戏。
uv run 在 pytest 前做了什么
以项目目录中的这条命令为例:
uv run --group dev pytest
可以把它理解成下面这个较小、但足够接近真实行为的程序:
找到项目配置
↓
检查锁文件是否存在、是否仍能对应项目声明
↓
计算所选依赖集合:默认集合 + dev group
↓
检查现有 .venv 是否已收敛到该集合
↓
不一致:同步环境;一致:跳过昂贵工作
↓
在该环境中执行 pytest,并返回 pytest 的退出码
它从哪里开始找项目
uv 会从当前工作目录向上寻找项目配置。因此,同一条 uv run 在仓库根目录和某个无关目录执行,未必落在同一个项目上下文中。命令短,不代表上下文短;它依赖你所在的位置,以及那里能否找到 pyproject.toml。
它为什么通常不慢
热路径的关键不是“Rust 很快”这句口号,而是避开了不必要的解析与安装。
当 uv.lock 已存在,且项目依赖声明没有要求更新解析结果时,uv 使用锁文件作为既定答案。它检查环境是否与答案一致;一致就跳过同步。缺少锁文件、项目声明改变,或环境缺包时,才需要做更多工作。
包缓存也在这里发挥作用。已下载、已构建的包可以从全局缓存复用到项目环境;具体会使用硬链接、写时复制克隆或复制,取决于文件系统能力。重点不是某一种文件系统技巧,而是:第二个项目不必把同一份包再从网络下载一遍。
所以,别把 uv run 理解成每次都等价于 pip install。更准确的说法是:它在运行前做一致性检查,只有发现偏离时才付出同步成本。
它不会吞掉测试失败
uv run 负责准备执行环境,不替你粉饰命令结果。pytest 以非零状态退出,uv run 也会以对应的失败状态结束,CI 应该照样失败。
这点看似普通,却很重要:环境工具可以参与前置条件,不应该篡改业务命令的成败语义。
--group dev 和 --extra dev:名字相同,边界不同
下面这两种写法都可能叫 dev,但不是一回事:
# 面向安装者的可选功能集合
[project.optional-dependencies]
dev = ["pytest", "pytest-cov"]
# 项目自己的依赖组
[dependency-groups]
dev = ["pytest>=8", "pytest-cov", "pytest-mock"]
--extra dev 选择的是 [project.optional-dependencies]。这类 extra 会进入项目发布元数据,因此外部使用者可以安装类似 your-package[dev] 的组合。
--group dev 选择的是 PEP 735 定义的 [dependency-groups]。它描述的是项目维护时需要的依赖组,不是包对外承诺的一部分。测试、格式化、静态检查这类工具,通常更适合放在 group 里。
我的立场很明确:测试依赖默认应放进 dependency group,而不是为了本地开发把它们伪装成对外 extra。
最强的反对意见也成立:如果你确实希望外部贡献者、插件作者或集成测试使用者通过 your-package[test] 获得一套受支持的测试集合,那么 extra 是合理的公共接口。问题不在 extra 本身,而在于不要把“本地要用”误当成“发布给所有安装者的能力”。
日常命令可以写成:
uv run --group dev pytest
如果你故意请求一个不存在的 group 或 extra,应当让命令失败,而不是静默忽略。需要全部 extra 时可显式选择 --all-extras;group 与 extra 也可以组合选择。显式失败比“少装了东西但测试碰巧没跑到”可靠得多。
CI 要锁的是解析结果,不是祈祷
在开发机上,依赖声明变了,允许工具更新锁文件往往方便。在 CI 或关键流水线中,便利和可审计性是两种不同目标。
uv run --locked --group dev pytest
--locked 的语义是:锁文件必须已经与项目声明同步;如果需要更新锁文件,失败,而不是替你改掉它。
另一种运行方式是:
uv run --frozen --group dev pytest
--frozen 会使用现有锁文件,并跳过 --locked 所做的锁文件新鲜度检查。它因此更严格地避免运行时更新锁文件,却更弱于发现锁文件已与项目声明脱节。它适合锁文件已经作为受审查产物提交、流水线只负责消费该产物的场景;如果你的 CI 要在执行前发现过期锁文件,应选择 --locked。实际采用哪一个,要看你想防的是“锁文件过期”还是“运行时修改锁文件”;在关键 CI 中,把这层选择写出来,不要依赖默认行为。
锁文件的价值也不止版本号。它通常记录了解析结果以及分发文件的完整性信息,使安装过程可以校验获取到的文件是否符合锁定记录。它不能替代代码审查、可信索引或构建隔离,但能减少“传递依赖在另一次安装时漂了”的空间。
对低延迟或关键系统而言,uv 不在请求热路径上。它更适合作为构建期工具:根据审查过的锁文件构建虚拟环境或 wheel,放进不可变镜像;生产节点不联网、不解析。部署完成后,环境应像封进琥珀,而不是继续随网络上的包版本呼吸。
pylock.toml 不是 uv.lock 的替身
Python 长期有一个不体面的现实:每个工具都有自己的锁文件方言。uv.lock、poetry.lock、Pipfile.lock 各自能表达各自的解析结果,但工具之间未必能直接交换。
PEP 751 定义了 pylock.toml,试图提供一个标准化的锁文件交换格式。它的目标是让锁定的包、版本、来源、哈希和环境条件可以被不同工具理解,而不是让每个团队永远绑在一个锁文件实现上。
一个锁文件大致会包含这样的信息;下面是结构示意,不是可直接安装的完整文件:
lock-version = "1.0"
requires-python = ">=3.12"
environments = ["sys_platform == 'linux'", "sys_platform == 'win32'"]
created-by = "<tool>"
[[packages]]
name = "<package-name>"
version = "<resolved-version>"
sdist = { url = "<source-url>", hashes = { sha256 = "<sha256>" } }
wheels = [{ url = "<wheel-url>", hashes = { sha256 = "<sha256>" } }]
它有三个直接好处:人能读的 TOML 便于审计;安装端拿到完整锁定结果后无需重新求解依赖;文件哈希让完整性校验有据可依。平台标记则允许锁定结果表达不同系统、不同解释器条件下的差异。
但这里必须分清“标准格式”和“某工具的主工作格式”。对 uv 项目而言,uv.lock 仍是日常解析与同步所依赖的主锁文件;pylock.toml 更适合跨工具交接、向只接受标准锁格式的环境导出,或交付给审计方。
可以把两者看成母带与交付介质:母带保留制作工具需要的细节;交付介质解决互通。不要因为有了通用格式,就假设所有工具、所有平台、所有依赖特性已经完全互操作。
尤其要审慎对待仍标为实验性的实现。某些工具生成的 pylock.toml 可能只适用于生成它时的 Python 版本与平台,也可能尚不覆盖 extras 或 dependency groups。格式标准化是进展,不是魔法传送门。
重建 .venv 时,分隔符就是故障边界
环境不对时,最干脆的动作是重建:
uv venv --clear && uv sync --group dev
uv venv 用 --clear 表达“清空目标目录后重新创建虚拟环境”。不要臆测存在一个叫 --force 的等价选项;这里该用的就是 --clear。
还有两个容易混淆的选项:
| 选项 | 对已有非空目录的意图 | 风险判断 |
|---|---|---|
--clear |
清空后重建 | 适合明确要核平环境 |
--allow-existing |
保留现有内容并继续写入 | 可能留下旧解释器或旧文件痕迹 |
较新的 uv 行为已经避免把已有环境无声清空:面对非空目标目录,工具会要求你明确选择清空或保留。这种“麻烦”是对的。虚拟环境并不只是一个可随手覆盖的缓存目录;里面可能藏着你尚未意识到的解释器与包状态。
更阴的坑在 shell:
# 不推荐:即使前一步失败,后一步仍会执行
uv venv --clear ; uv sync --group dev
# 推荐:前一步成功,才允许下一步开始
uv venv --clear && uv sync --group dev
; 只表示“接着执行”,不表示“前面成功了再执行”。如果创建环境因为磁盘、权限或解释器问题失败,后面的同步仍可能冲向旧环境或半残环境。你最后看到的也许不是第一处红字,而是一个更晚、更迷惑人的症状。
让失败停在它发生的地方,后面的步骤才不会替它化妆。
这里也要纠正一个常见误会:uv sync 的默认依赖选择会受项目配置和当前 uv 行为影响;在支持 dev dependency group 的常见 uv 工作流中,dev group 默认会被包含,--no-dev 才是显式排除。若你的目标是让脚本意图永远不靠默认值猜,重建后仍应写明 --group dev,尤其是这套环境马上要跑测试时。
把核对放在命令入口
uv run 的价值在于,它把“环境是否正确”的检查放进命令入口,而不是放进某份团队规范的第七条。人的纪律会波动,入口条件不会。
这个做法也有边界。频繁同步并不能弥补不可信的依赖来源、没有审查的锁文件更新,或宿主系统层面的不确定性;它只解决“当前环境是否收敛到已声明、已锁定的依赖集合”这一层问题。
📌 可截图检查表:
- 改依赖:用
uv lock,审查uv.lock。- 装环境:用
uv sync --group dev。- 跑测试:用
uv run --group dev pytest。- 跑关键 CI:明确加
--locked或--frozen。- 环境损坏:用
uv venv --clear && uv sync --group dev,不要用;。
下次你在 CI 里看到 uv run,不妨做个小实验:先故意删除一个只属于 dev group 的包,再运行测试。它是补回了缺失的环境,还是直接把错误留给你?这个答案,正好说明你的“能跑”究竟依赖锁文件、依赖默认值,还是依赖某个幸存下来的旧 .venv。