升级到最新版的 Python 3.15 后,知名开发者 Simon Willison 频繁撞上一类令人困惑的依赖报错,并在 2026 年 11 月 1 日写下一条经验法则:只要遇到疑问,先删掉本地的 uv.lock 文件。这个看似立竿见影的排障直觉,恰恰暴露了现代 Python 开发者在面对 uv 锁文件机制与 Rust 扩展生态时的双重认知误区。

那天拦住 Willison 的是一条具体的构建错误。他在本地运行 uv run pytest 时,watchfiles==1.1.1 构建失败,底层 maturin.build_wheel 抛出提示,指出当前配置的 Python 解释器版本(3.15)高于 PyO3 支持的最大版本(3.14)。他把目录里残留的 uv.lock 删掉重跑,测试立刻恢复正常,但这张被随手撕掉的锁文件里,其实藏着一条完整的工具链断案线索。

为什么 watchfiles 1.1.1 会在 Python 3.15 下死于源码编译

要看懂这个报错,得先拆开 watchfiles 的版本演进与元数据机制。

watchfiles 1.1.1 发布于 2025 年 10 月 14 日,当时的更新主要是补齐 Windows 平台下的 Python 3.14 构建,官方预编译二进制包(wheel)只覆盖到 Python 3.9 至 3.14,既没有提供 cp315 的预编译包,也没有开启跨版本兼容的 abi3 特性。直到后续发布的 watchfiles 1.2.0(通过 PR #369),项目才正式加入对 Python 3.15 开发构建的支持,同时放弃了 Python 3.9 并将最低 Rust 版本要求提升至 1.83。

真正让包管理器掉进陷阱的,是 watchfiles 1.1.1 元数据里写着开放式的 requires-python = ">=3.9",且构建后端约束为 maturin>=0.14.16,<2。当 Willison 切换到 Python 3.15 时,解析器读到 >=3.9,便认为 1.1.1 在语法上完全兼容 3.15;发现 PyPI 上没有对应 3.15 的 wheel 包后,uv 顺理成章地回退到本地源码构建(sdist)。

Python 3.15 下 watchfiles 1.1.1 的编译撞墙链路 1. 旧锁文件拦截 残留 uv.lock 锁定 watchfiles==1.1.1 2. 开放上界放行 声明 >=3.9 骗过解析 缺失 cp315 预编译包 3. 回退源码构建 拉取 sdist 源码包 调用 maturin 编译 4. PyO3 严格拒止 PyO3 0.26.0 上限 3.14 3.15 触发构建崩溃 核心症结:Python 包元数据的“宽松承诺”撞上了 Rust 绑定层的“严格安检” 修复路径:watchfiles 1.2.0(PR #369)跟进支持 Python 3.15,放弃 3.9 并将最低 Rust 要求升至 1.83

源码构建一启动,底层的 Rust 编译器就接管了现场。watchfiles 1.1.1 依赖的 PyO3 0.26.0 为了防范 CPython 每年大版本更新带来的 C API 变动风险,内置了硬性的解释器版本上限校验,最高只认到 Python 3.14。Python 社区习惯不设上限的元数据声明,与 Rust 工具链宁可报错也不冒险编译的严格上界,就这样在本地构建阶段迎头相撞。

换了 Python 解释器,为什么 uv 还在死磕旧版本

既然 PyPI 上已经有支持 3.15 的 watchfiles 1.2.0,为什么 Willison 运行 uv run pytest 时,uv 没有自动拉取新版?

这涉及 Astral 团队为 uv 设计的锁文件偏好策略。切换本地 Python 版本并不会触发 uv 自动升级 uv.lock 里的所有依赖;只要项目配置里的版本约束没有改变,uv 默认倾向于保留已锁定的版本,以此保证跨环境构建的一致性。

因此,直接删掉 uv.lock 属于推倒重来的粗暴重置。按照 uv 官方文档的设计机制,处理这类局部版本卡死的标准动作是运行 uv lock --upgrade,允许解析器在项目约束范围内重新评估并升级所有包;如果只想定点排障,执行 uv lock --upgrade-package watchfiles 就能在不惊动其他依赖的前提下,单独把卡壳的包推到兼容 3.15 的新版本。

  • 建议.升级 Python 大版本后若遇 C 或 Rust 扩展编译失败,优先运行 uv lock --upgrade-package 定点升级对应依赖,而非直接删除整个锁文件。

把 uv.lock 塞进 .gitignore,恰恰制造了幽灵缓存

比排障命令更值得讨论的,是为什么 Willison 的本地目录里会躺着一份没人管的旧 uv.lock。

他在博客中解释,自己的大多数项目都是供他人调用的 Python 库(library),为了不对下游使用者显得“不礼貌”(避免锁死别人的依赖版本),他从来不在仓库里包含 uv.lock,而是将其写进 .gitignore。早在 2025 年 8 月 6 日的 Hacker News 讨论中,他就表达过完全相同的观点,认为库项目锁定精确版本不可避免地会破坏其他依赖。

真正把开发者绊倒的,往往不是锁文件本身,而是被藏进 .gitignore 的本地幽灵。

但这一沿自早期工具链的直觉,在现代 Python 打包规范下其实是一个典型的认知误判。当时在 Hacker News 上就有开发者指出,锁文件只作用于库自身的开发环境;uv 的官方文档更是明确建议,即便是库项目也应当将 uv.lock 提交到版本控制中。

Python 库项目(Library)该如何对待 uv.lock? 经验主义做法:放入 .gitignore • 出发点:怕锁死下游用户的依赖版本 • 盲区:混淆了开发态锁与发布态元数据 • 副作用:uv run 仍会在本地生成锁文件 • 隐性代价:形成不受 Git 追踪的陈旧缓存 切到 Python 3.15 时静默复用旧版报错 uv 官方推荐:提交至 Git 仓库 • 隔离机制:发布 wheel/sdist 只读 pyproject.toml • 对外影响:仓库里的 uv.lock 对下游完全透明 • 团队收益:保证多人开发与 CI 环境可复现 • 下限兜底:配合 --resolution lowest 测试 显式验证依赖的最低兼容边界

原因在于开发态与发布态有着严格的物理隔离。当维护者把库打包成 wheel 或 sdist 发布到 PyPI 时,构建工具只读取 pyproject.toml 里的宽松依赖范围,代码仓库里的 uv.lock 根本不会进包,也绝无可能锁死下游用户。至于如何确保库在低版本依赖下不出错,uv 官方给出的方案是在 CI 中配合 --resolution lowest 或 --resolution lowest-direct 参数,专门测试直接或全部依赖的最低兼容下限。

反过来,把 uv.lock 塞进 .gitignore 却每天使用 uv run pytest,结果就是 uv 依然会在本地磁盘上悄悄生成并复用这份锁文件。因为它被 Git 忽略,开发者在 git status 里看不见它,久而久之就变成了一份无人维护的本地幽灵缓存。等到几个月后升级 Python 解释器时,这份隐身的旧锁文件就会突然跳出来拦路。

随着 pydantic-core、watchfiles 等越来越多高性能基础库转向 Rust 编写并依赖 PyO3 与 maturin,每逢 CPython 年度大版本更新,缺少预编译 wheel 触发源码构建失败的场景还会反复上演。对率先尝鲜 Python 3.15 的开发者来说,与其养成“遇事不决删锁文件”的条件反射,不如把 uv.lock 从 .gitignore 里请出来,让依赖升级重新回到阳光下受控运行。