Python 项目迁移到 uv:经验小结与可复用工作流
迁移前要确定的事
- Python 版本区间:项目实际验证过的版本
- 非默认来源:私有源地址、
--extra-index-url、--trusted-host、裸 wheel URL - 当前版本快照:
pip freeze > baseline-freeze.txt,迁移后拿它做对照,出问题时也能回滚 - 搞清楚两个容易混的概念:
.python-version和requires-python.python-version:由uv python pin生成,决定uv run、uv sync用哪个本地解释器requires-python:写在pyproject.toml里,决定uv lock解析时覆盖的版本区间。区间越窄,锁文件越小
初始化 uv 项目
已有 pyproject.toml 则跳过 uv init
1 | uv init --no-readme |
1 | [project] |
最好把 .python-version 提交,用 git 管理。
一个容易踩的坑:uv pip install 不读 requires-python,只认当前激活环境或当前目录下的 .venv。所以用 uv pip 系列命令之前,先跑一遍 uv sync 把 .venv 建好。
导入 requirements.txt
requirements.txt 中的关键包最好锁定精确版本:
1 | # 不推荐 |
为什么要精确?一是私有源上的包往往没有严格的 semver 保证,版本号跳动的含义跟 PyPI 上的包不是一回事;二是如果内部包跟 PyPI 上某个公开包重名,不锁精确版本时解析器可能会取到完全没预料到的来源。这类问题排查起来很费时间,因为表现出来就是”依赖版本对不上”,但根源是包来源搞错了。
导入命令:
1 | uv add -r requirements.txt |
uv add -r 会重写 pyproject.toml 并重新解析锁文件。uv sync 只按现有锁文件同步环境,不能替代迁移导入。
私有源踩过的坑
举一个真实场景。配好私有源之后,uv add 突然报这样的错:
1 | No solution found when resolving dependencies: |
第一反应往往是”这个包是不是被删了”,但其实包还在,只是版本不全——原因出在 uv 默认的 index-strategy = "first-index" 策略上:uv 按索引顺序查找,包名一旦在某个索引里找到,就只从这个索引解析这个包,不会再去后面的索引找更全的版本。这里的坑是,内部镜像代理了 psutil,但只同步了部分版本,uv 找到包名就停手了,根本不会意识到后面还有一个版本更全的索引。
索引配置长这样:
1 | [[tool.uv.index]] |
几点要注意:
default = true会禁用 PyPI,并把这个索引放到已配置索引里优先级最低的位置。- requirements.txt 里的
--trusted-host对应的是allow-insecure-host,不是索引配置里的trusted字段——这两个名字太像,很容易配错地方。 - 能配出有效 TLS 证书的话,优先修证书,
allow-insecure-host只应该是长期方案里的例外,不是常态。
解决刚才那个 psutil 问题,有两种思路:
一种是全局关闭 first-index 策略:
1 | [tool.uv] |
这样会跨所有索引找最高版本,但风险也最大——只有当你配置的所有索引都完全可信时才应该这么做,否则相当于把供应链安全性拱手让出去。
更推荐的做法是单包 pin 来源,只解决出问题的那一个包:
1 | [tool.uv.sources] |
URL 依赖
uv 不允许 URL 依赖仅作为传递依赖存在,必须在 dependencies 中显式声明,并在 [tool.uv.sources] 里配置来源:
1 | [project] |
如果第三方包内部也用 URL 声明了同一个依赖,两边必须完全一致(版本、URL、hash),否则会报类似这样的冲突:
1 | error: Requirements contain conflicting URLs for package `xxx-engine-alarm`: |
看着像是同一个 URL,但差一个字符(比如内部包里写的是旧版本号,或者 hash 对不上)都会触发。遇到这种报错,先去翻第三方包自己声明的依赖来源,跟你项目里写的逐字比对。
过渡期兜底
如果时间紧,可以先用两条命令临时把依赖跑起来:
1 | uv add -r requirements.txt --frozen # 跳过锁文件更新,临时导入依赖声明 |
这两条本质上相同——都是”先让代码跑起来,锁文件的事后面再说”。--frozen 不会生成可信的 uv.lock;uv pip install 干脆不走项目解析这条路,执行前记得确认 .venv 已经建好。这两条命令用完之后,一定要回头补上正式的 uv lock。
收尾验证
重新生成锁文件:
1 | uv lock |
干净环境复现:
1 | # Windows |
对照 baseline-freeze.txt 抽查关键包版本
在 CI 或另一台干净机器上再跑一次 uv sync,确认不依赖本机缓存。最终再执行一遍工程的测试或者服务,确保没有报错,这才是根本的。
如果要回滚
迁移过程中如果卡住,回滚比继续排查更划算的情况不少见。核心是保留住两样东西:baseline-freeze.txt 和原来的 requirements.txt。
1 | rm -rf .venv |
回滚不需要动 pyproject.toml 和 uv.lock——留着它们,下次再迁移时可以从上次中断的地方继续。
uv.lock 体积过大
最常见的原因是 requires-python 区间过宽。修复方式是收窄区间后重新生成锁文件:
1 | [project] |
1 | uv lock |
另一个不那么明显的原因是索引配置太多:配置的源越多,解析器给每个包做候选校验时要跨的源就越多,锁文件里记录的候选信息也会跟着膨胀。如果收窄 requires-python 之后体积还是没降下来,可以回头看看 [[tool.uv.index]] 是不是配多了,有没有可以合并或去掉的。
uv 常用命令速查
下面按“项目接口”和“uv pip 兼容接口”分类。日常项目优先使用项目接口:它会一起维护 pyproject.toml、uv.lock 和项目虚拟环境;uv pip 则直接操作某个虚拟环境,更适合迁移旧的 requirements.txt 或兼容原有 pip 工作流。
1. 安装、版本与帮助
1 | uv --version # 查看 uv 版本 |
2. Python 版本管理
1 | uv python list # 查看可用/已安装的 Python |
uv python pin 只固定本地默认解释器;项目支持哪些 Python 版本,仍由 pyproject.toml 中的 requires-python 决定。
3. 创建项目和虚拟环境
1 | uv init # 在当前目录创建项目 |
已有 pyproject.toml 时不需要重复执行 uv init,直接补齐 [project] 配置即可。
4. 项目依赖管理
1 | uv add requests # 添加运行时依赖 |
常用选项:
--upgrade:允许升级依赖,而不是尽量沿用锁文件中的版本。--no-sync:修改项目依赖或锁文件,但暂不更新.venv。--locked:要求uv.lock已经是最新,否则报错。--frozen:不检查或更新锁文件,适合明确知道后果的临时场景。
5. 锁文件、依赖树和运行命令
1 | uv lock # 解析并更新 uv.lock |
uv run 会在运行前检查锁文件和项目环境是否需要同步;CI 中可使用 uv sync --locked,让锁文件不一致时直接失败。
6. 导出、构建和发布
1 | uv export --format requirements.txt --output-file requirements.txt |
如果项目以 uv.lock 作为唯一锁文件,通常不需要同时长期维护一份手写的 requirements.txt;只有部署系统或旧工具要求时再使用 uv export 导出。
7. uv pip 兼容接口
这组命令直接面向虚拟环境,不会像项目接口那样自动维护项目的 pyproject.toml 和 uv.lock:
1 | uv pip install -r requirements.txt # 安装,不会主动删除多余包 |
如果使用这组命令,建议显式指定目标解释器(例如 uv pip install --python .venv ...),或先创建并确认当前目录的 .venv,避免误装到其他环境。