Python 项目迁移到 uv:经验小结与可复用工作流

Python 项目迁移到 uv:经验小结与可复用工作流

一、核心知识点

1. Python 版本:requires-python 与 .python-version 是两件不同的事

1
2
[project]
requires-python = ">=3.12,<3.13"
1
uv python pin 3.12   # 生成 .python-version 并建议提交进仓库
  • requires-python:声明项目支持的版本范围,同时决定 uv lock 做通用解析(universal resolution)时覆盖的版本区间——区间越窄,锁文件里为不同 Python 版本保留的 wheel 记录就越少。
  • .python-version:决定 uv runuv sync 等命令创建 .venv 时用哪个本地解释器,不影响 uv.lock 的解析范围。

uv 没有 [tool.uv] python-version 这个配置项,不要混用。

不同命令对 Python 版本的选择规则不同:

命令 是否读取 requires-python
uv add / uv sync / uv run 会。没有 .python-version 时,在满足 requires-python 的本机/uv 管理的解释器中选第一个匹配的,找不到会自动下载
uv pip install 不会。走 pip 兼容路径:用当前激活的虚拟环境,或当前/父目录下的 .venv(不管是否满足 requires-python),都没有则报错,需 --python / --system 显式指定

结论:只用 uv add/uv sync 时,只写 requires-python 够用;混用 uv pip install(比如下面第 7 点的兜底方案)时,建议先跑 uv sync/uv venv 建好 .venv,或者把 .python-version 也提交进仓库,避免命令之间用到不同解释器。

2. URL 依赖必须声明为直接依赖

uv 不允许 URL 依赖仅作为传递依赖存在。如果包 A 间接依赖了一个 URL 包 B,必须在 pyproject.toml 中:

  • dependencies 列表里显式声明 B
  • [tool.uv.sources] 中配置 B 的 URL
1
2
3
4
5
[project]
dependencies = ["atc-engine-alarm"]

[tool.uv.sources]
atc-engine-alarm = { url = "http://.../atc_engine_alarm-0.0.3-py3-none-any.whl" }

注意:[tool.uv.sources] 只对已经出现在 dependencies(或 dev 依赖组)里的包名生效,先声明依赖、再配置来源,顺序不能反。

3. uv sync 与 uv add 的职责不同

命令 行为
uv sync 根据 pyproject.toml 检查或更新 uv.lock,再让项目环境与锁文件一致;使用 --frozen 时不检查或更新锁文件
uv add -r 把 requirements 文件中的依赖写入 pyproject.toml,并重新解析锁文件;导入过程中会暴露依赖声明、来源和版本冲突

如果项目已有一个旧的 uv.lock,一次普通的 sync 可能只是沿用或更新现有锁文件;迁移 requirements.txt 时仍应通过 uv add -r(必要时配合约束文件)重新导入并验证依赖声明。

4. 第三方包的内部 URL 依赖导致冲突

当依赖的第三方包在其自身元数据里通过 URL 声明了某个依赖,而你在 pyproject.toml 里也为同一个包声明了 URL 或版本,只要两边不完全一致(版本号、URL、hash 不同),uv 就会认为同一个包有两个不同来源,报 URL 冲突。若两边完全一致,通常只需按第 2 点显式声明即可。

这类冲突根源在第三方包内部,无法修改其元数据。解决思路:

  • 确保声明的版本/URL 与第三方包内部要求的完全一致
  • 若仍无法解决,先修复或重新发布上游包;短期内可用 uv pip install -r requirements.txt 绕过项目解析直接安装(见第 7 点)

5. 版本不锁定导致解析不稳定

requirements.txt 中的关键包(尤其是内部私有包)务必锁定精确版本:

1
2
3
4
5
# 不推荐
vlt-python-aws

# 推荐
vlt-python-aws==0.1.53

6. uv.lock 是跨平台锁文件,版本范围过宽可能导致文件变大

uv.lock 默认是跨平台、跨 Python 版本的通用锁文件,会记录不同环境标记下可能使用的候选分发包。requires-python 区间越宽,通常需要保留的版本和 wheel 记录越多,体积也可能越大。

修复:先根据项目实际支持范围收窄 requires-python(见第 1 点),然后重新生成锁文件:

1
uv lock

--python 3.12 只指定本次命令使用的 Python 解释器,不等于把锁文件限制为 3.12;真正决定项目支持范围的是 requires-python。如果项目只支持 Python 3.12,可使用 requires-python = ">=3.12,<3.13"

7. 实用降级方案(仅用于过渡期)

1
2
3
uv add -r requirements.txt --frozen      # 跳过锁文件更新;仅用于临时导入依赖声明
# 或
uv pip install -r requirements.txt # 完全绕开项目解析,走 pip 兼容路径

uv add -r requirements.txt --frozen 的含义是跳过锁文件更新,不是生成一个可信的 uv.lock;如果项目没有现成锁文件,不能把它当作正常迁移方案。uv pip install -r requirements.txt 则完全绕过项目锁文件,适合临时安装或继续维护 requirements 工作流。两者都只适合过渡期,不建议作为项目终态。用 uv pip install 前确认 .venv 已按 requires-python 建好(见第 1 点表格)。

8. 多个 –index-url / –extra-index-url / –trusted-host 的迁移

原 requirements.txt:

1
2
3
4
5
6
--index-url http://xxo-private-pip.vivo.lan:9090
--extra-index-url http://172.xx.xxx.229:8899/simple/
--extra-index-url http://xxp-pip.xx.lan/simple
--trusted-host xxx-private-pip.vivo.lan
--trusted-host 172.xx.22.xxx
--trusted-host xxx-pip.vivo.lan

转换为 pyproject.toml

1
2
3
4
5
6
7
8
9
10
11
12
[[tool.uv.index]]
name = "vivo-private"
url = "http://xxo-private-pip.vivo.lan:9090/simple"
default = true # 禁用 PyPI 默认索引,并将该索引作为默认索引

[[tool.uv.index]]
name = "internal-wheels"
url = "http://172.xx.xxx.229:8899/simple/"

[[tool.uv.index]]
name = "vivo-p-pip"
url = "http://xxp-pip.xx.lan/simple"

--trusted-host 不是 [[tool.uv.index]] 中的 trusted = true。对于 HTTP 或证书不受信任的内部源,应在项目配置中显式允许对应主机:

1
2
3
4
5
6
[tool.uv]
allow-insecure-host = [
"xxo-private-pip.vivo.lan",
"172.xx.xxx.229",
"xxp-pip.xx.lan",
]

能为内部源配置有效 TLS 证书时,应优先修复证书,而不是长期依赖 allow-insecure-host

注意:default = true 有特殊语义——它会禁用 PyPI 默认索引,并把该索引放到已配置索引的最低优先级;它不是“保持 requirements.txt 中 --index-url 的原始顺序”。如果需要严格控制某个包的来源,应使用 explicit = true 配合 [tool.uv.sources]

**关键机制:uv 默认 index-strategy = "first-index"**(不同于 pip)——按索引定义顺序查找,包名一旦在某个索引找到,就只从这个索引解析该包的候选版本,不跨索引合并同名包版本。这是为了防止依赖混淆攻击,但迁移时容易踩坑:索引顺序会影响实际生效优先级,如果内部关键包在排序靠前的索引里版本滞后,可能导致解析失败(见第 9 点的实际案例)。

加固建议:给必须来自内部源的关键包用 explicit + tool.uv.sources 显式 pin,不完全依赖顺序:

1
2
3
4
5
6
7
[tool.uv.sources]
atc-engine-alarm = { index = "internal-wheels" }

[[tool.uv.index]]
name = "internal-wheels"
url = "http://172.xx.xxx.229:8899/simple/"
explicit = true # 只有被 tool.uv.sources 显式指定的包才能从这里装

requirements.txt 里保留这些行的注意事项uv add -r 可以临时读取 --index-url/--extra-index-url,但这只是本次命令的参数,不会自动写进 pyproject.tomluv sync/uv lock/uv run 也不会从 requirements.txt 继承这些配置。--trusted-host 应改为 [tool.uv] allow-insecure-host,而不是写成索引字段。正确做法是把索引配置迁移进 pyproject.toml[[tool.uv.index]][tool.uv.pip] 只影响 uv pip 系列子命令,不影响项目接口的 uv add/uv sync/uv lock。迁移完成后,requirements.txt 只保留纯依赖列表,或直接删除并以 uv.lock 作为项目锁文件。

9. 实际案例:精确版本在优先索引里不存在

报错:

1
2
3
4
5
6
x No solution found when resolving dependencies:
`-> Because there is no version of psutil==6.1.0 and your project depends on psutil==6.1.0, ...
hint: `psutil` was found on http://vivo-pip.vivo.lan/simple, but not at the requested version (psutil==6.1.0).
A compatible version may be available on a subsequent index (e.g. http://vivo-private-pip.vivo.lan:9090/).
By default, uv will only consider versions that are published on the first index that contains a given package,
to avoid dependency confusion attacks.

原因:requirements.txt 把 psutil 锁死成精确版本 psutil==6.1.0。排序靠前的索引(很可能是内部 PyPI 镜像/代理)也代理了 psutil 这个包名,但同步不及时、没有 6.1.0 这个具体版本。按 first-index 策略,uv 在这个索引找到包名后就不再去后面的索引找其他版本,即使后面的索引真的有 6.1.0。

方案一(简单粗暴,有代价):全局关闭 first-index 防护

1
2
[tool.uv]
index-strategy = "unsafe-best-match"

会跨所有索引找最高版本,解决问题,但对所有包、所有索引生效,如果索引里混了不完全可信的公网镜像,存在依赖混淆风险。适合”几个索引本来就是同一批人维护、互相信任”的场景。

方案二(推荐,更精确):只 pin 这一个包的来源,不动全局策略

1
2
3
4
5
6
[tool.uv.sources]
psutil = { index = "vivo-private" }

[[tool.uv.index]]
name = "vivo-private"
url = "http://vivo-private-pip.vivo.lan:9090/simple"

只有 psutil 从指定索引解析,其余包仍受 first-index 防护。后续再遇到同类”某包在镜像源版本滞后”的问题,可以用同样的模式逐个 pin。

二、常见报错 → 处理方式对照表

报错特征 根因 处理方式
Ignoring unsupported option ... --trusted-host 项目接口不会把 requirements.txt 中的 --trusted-host 持久化为项目配置 改为 [tool.uv] allow-insecure-host = ["host"];优先修复 HTTPS 证书
URL dependencies must be expressed as direct requirements 某包的传递依赖是裸 URL 把该 URL 提升为直接依赖 + [tool.uv.sources]
同一个包报”两个不同来源”冲突 你声明的版本/URL 和第三方包内部要求的不完全一致 核对版本号/hash,改到完全一致;改不了就走 --frozenuv pip install 兜底
uv add 报错但 uv sync 能跑 项目可能沿用了已有锁文件,迁移导入时则触发了新的依赖解析 重新检查 pyproject.toml、依赖来源和约束;不要把 sync 成功当作迁移完成
uv.lock 体积异常大 requires-python 区间过宽,通用解析需要覆盖更多环境 收窄 requires-python 后执行 uv lock 重新生成
Python 版本没有如预期被选中 没有 .python-version,或 uv pip 使用了另一个解释器/虚拟环境 uv python pin;对 uv pip 使用 --python,或改用项目接口的 uv add/uv sync
No solution found,提示某包”found on X, but not at requested version” 精确锁定的版本不在 first-index 命中的索引上,后面索引虽有但被跳过 全局 index-strategy = "unsafe-best-match"(图省事),或 tool.uv.sources 精确 pin 该包索引(更安全)

三、可复用迁移工作流

阶段一:迁移前摸底(不动手改任何文件)

  1. 跑一遍现有安装命令,记录所有非默认 PyPI 来源:私有源地址、--trusted-host--extra-index-url、裸 wheel URL 依赖。
  2. 导出当前环境精确版本快照做迁移后比对:pip freeze > baseline-freeze.txt(不进版本库)。
  3. 确定目标 Python 版本区间——是项目实际验证过能跑的版本,不是”最新版”。

阶段二:搭骨架

  1. 初始化项目文件:uv init --no-readme(已有 pyproject.toml 则手动补 [project] 表)。
  2. requires-python,收窄成单一版本区间。
  3. uv python pin 3.12 固定本地解释器,生成 .python-version 并提交仓库。
  4. 按第一部分第 8 点,把 --index-url/--extra-index-url 转成 [[tool.uv.index]],把 --trusted-host 转成 [tool.uv] allow-insecure-host

阶段三:迁移依赖

  1. 如果只有一个已经锁定精确版本的 requirements.txt,直接执行 uv add -r requirements.txt;如果同时有未锁定的 requirements.in 和旧的 requirements.txt 快照,则执行 uv add -r requirements.in -c requirements.txt,尽量避免迁移时发生无意升级。
  2. 按报错类型对照表逐类修复,不要零散地一条条改。
  3. 重复第 8 步,直到能跑通,或收敛到”仅剩内部包 URL/版本冲突”——这类问题不要死磕,转阶段五兜底。

阶段四:验证与固化关键包

对已知会反复出问题的内部包(元数据带 URL 依赖的、多个索引都有同名包的),用 explicit + tool.uv.sources 显式 pin,不完全依赖索引顺序。

阶段五:兜底方案(仅用于过渡期,不作为终态)

uv add -r requirements.txt --frozenuv pip install -r requirements.txt,见第一部分第 7 点。

阶段六:收尾验证

  1. 生成精简锁文件并提交:
    1
    2
    uv lock
    git add pyproject.toml uv.lock .python-version
  2. 用干净环境复现:
    1
    2
    3
    # Unix/macOS:rm -rf .venv;Windows PowerShell:Remove-Item -Recurse -Force .venv
    uv sync
    uv run python -c "import sys; print(sys.version)"
    对照 baseline-freeze.txt 抽查关键包版本,确认没有静默升级/降级。
  3. 在 CI 或另一台干净机器上跑一遍同样的 uv sync,确认不依赖本机缓存或已存在的 .venv——这是判断迁移是否真正可复用的关键测试。