那就把版本 pin 死吧。 Pin 對了,問題可能暫時消失;pin 錯了,只是把真正的衝突藏進下一次升級。 今天拍拍君要把 uv resolver 當成一個可以觀測、縮小、驗證的系統來除錯。
一. 前言:Resolver 不是在跟你作對 #
假設 API 專案想同時安裝:
[project]
name = "resolver-lab"
version = "0.1.0"
requires-python = ">=3.11,<3.13"
dependencies = [
"numpy>=2",
"numba==0.59.0",
]
執行:
uv lock
uv 會拒絕產生解答,因為 numba==0.59.0 需要 numpy>=1.22,<1.27, 但專案又要求 numpy>=2。 這不是 resolver 壞掉,而是集合交集為空:
專案:numpy >= 2
Numba:numpy >= 1.22, < 1.27
交集:∅
真正的任務不是「讓 uv 閉嘴」,而是回答四個問題:
- 哪兩條需求形成矛盾?
- 衝突只發生在某個 Python、平台或 extra 嗎?
- 應該修改直接依賴、上游 metadata,還是暫時加政策?
- 修正後,lockfile 到底改了什麼?
如果你要先熟悉一般
uv init、uv add與uv sync, 可以先看 uv 完全教學; 若要整理 dev、test、docs 分組,則看 uv Dependency Groups。
二. 建立最小重現:先把雜訊拿掉 #
不要直接在五年歷史的大型 repo 裡亂改二十個版本。 先建一個乾淨目錄:
mkdir resolver-lab
cd resolver-lab
uv init --bare --name resolver-lab --python ">=3.11,<3.13"
逐步加入疑似衝突的套件:
uv add "numpy>=2"
uv add "numba==0.59.0"
第二步應該失敗,而且錯誤鏈會比原專案短很多。 最小重現至少要固定這些輸入:
uv --version
uv python find
python -VV
uname -a
若問題跟 private index 有關,也要記錄「index 名稱與優先順序」, 但不要把 token、帳號或完整含憑證 URL 貼進 issue。
先移除不參與解析的東西 #
建立重現時,先拿掉:
- application source code
- 測試資料與 notebook
- 不相關的 dependency groups
- 已知不影響問題的 extras
- 本機
.venv保留: pyproject.toml- 必要的 workspace members
- index 設定的最小安全版本
- 能重現問題的指令
- 完整錯誤輸出 這樣才能分辨是 dependency metadata、平台選擇,還是環境殘留。
三. 讀錯誤訊息:從結論往回走 #
uv 的 resolution error 常用「Because A depends on B」串出推論。 閱讀順序可以反過來:
- 先找最後一句
requirements are unsatisfiable - 找出造成空集合的 package
- 往上追兩條互斥的版本範圍
- 回到
pyproject.toml找需求來源 不要只截最上面三行。 真正有用的是完整因果鏈,例如:
Because numba==0.59.0 depends on numpy>=1.22,<1.27
and your project depends on numpy>=2,
the project's requirements are unsatisfiable.
遇到資訊不足時,加上 verbose:
uv lock -v
如果還需要 resolver 細節:
RUST_LOG=uv=debug uv lock
Debug log 可能很長,也可能含 index URL。 分享前先搜尋並遮掉 credentials。
四. Dependency Chain:誰把它帶進來的? #
專案已經有可用 lockfile 時,先看 tree:
uv tree
只追某個 package:
uv tree --package numpy
反向查「誰依賴它」:
uv tree --invert --package numpy
如果同一個 package 從多條路徑出現:
uv tree --invert --package numpy --no-dedupe
注意:新解析已經失敗時,tree 不能憑空展示不存在的解答。 此時可先:
- 用舊的
uv.lock查既有 dependency chain - 回退剛修改的單一需求
- 再用
uv tree --invert找上游 - 最後回到最小重現重做失敗 Tree 是「已解析結果」的視角;錯誤鏈則是「為何無解」的視角。 兩者要一起看。
五. Markers:衝突可能只存在某個環境 #
Python 依賴不只看版本,也會看 environment markers。 例如不同 Python 版本選不同的 NumPy:
[project]
requires-python = ">=3.10,<3.14"
dependencies = [
"numpy>=1.26,<2; python_version < '3.12'",
"numpy>=2; python_version >= '3.12'",
]
Marker 的引號、邊界與覆蓋範圍都要檢查。 最常見的錯誤包括:
python_version邊界留了一個洞- 兩條 marker 在同一環境同時成立
- 只測目前電腦,忘了 lockfile 是 universal resolution
- 把
sys_platform和platform_system混用 - 上游 wheel 根本沒有支援目標平台 可以對不同目標看 tree:
uv tree --python-version 3.11
uv tree --python-version 3.12
uv tree --python-platform linux
uv tree --python-platform macos
想看 lockfile 包含的全部分支:
uv tree --universal
Marker 要描述事實,不要掩護猜測 #
好的 marker 是:
"uvloop>=0.21; sys_platform != 'win32'"
因為 uvloop 的平台限制是真實契約。 危險的 marker 是:
"some-package<2; python_version < '3.13'"
如果沒確認上游 metadata,這可能只是把今天的失敗藏起來。
六. Constraints:限制被選中的版本 #
uv project 可以在 tool.uv.constraint-dependencies 放 constraint:
[tool.uv]
constraint-dependencies = [
"urllib3<2.3",
]
Constraint 的語意是:
如果 dependency graph 裡需要 urllib3,就只能從
<2.3選。 它不會主動把urllib3加進 dependencies。 適合用在:
- 暫避已知 regression
- 統一 transitive dependency 的安全上限
- 等待上游 release 修正 metadata
- 讓多個 workspace members 遵守同一政策 不適合用在:
- 隱藏真正應該升級的 direct dependency
- 永久取代上游正確的版本宣告
- 看不懂錯誤時隨便試一個版本 每個臨時 constraint 最好附上理由:
[tool.uv]
constraint-dependencies = [
# Remove after upstream issue #123 is released.
"urllib3<2.3",
]
七. Overrides:有意識地改寫需求 #
override-dependencies 比 constraint 更強。
[tool.uv]
override-dependencies = [
"urllib3==2.2.3",
]
它會用你的規格取代 dependency graph 中該 package 的宣告。 這代表即使上游寫了不相容範圍,你也要求 resolver 忽略原限制。 所以 override 應該視為「帶風險的例外」,不是普通 pin:
- 先確認衝突來自錯誤或過度保守的 metadata
- 用測試證明實際相容
- 記錄上游 issue 或 release 條件
- 設定移除期限
- 在 CI 覆蓋受影響路徑
Constraint 與 Override 的差別 #
| 工具 | 保留上游需求嗎 | 會新增套件嗎 | 風險 |
|---|---|---|---|
| direct dependency | 是 | 是 | 低到中 |
| constraint | 是 | 否 | 中 |
| override | 否,會改寫 | 視設定而定 | 高 |
| 如果 constraint 已經導致空集合,override 也許能「解開」解析; 但能解析不等於能執行。 |
八. Resolution Forks:一份 Lockfile 可以有多條路 #
uv 會為不同 Python 版本、平台或 marker 建立 resolution forks。 例如:
[project]
requires-python = ">=3.10,<3.14"
dependencies = [
"importlib-metadata>=8; python_version < '3.12'",
]
Python 3.10/3.11 需要 backport;較新的 Python 可能不需要。 這不是「一份 lockfile 不夠乾淨」,而是 lockfile 在記錄多環境契約。 可用下列指令觀察:
uv tree --universal
uv lock --dry-run
如果支援環境太寬,resolver 工作量與 lockfile 複雜度都會增加。 先問產品真正支援什麼:
[project]
requires-python = ">=3.11,<3.13"
不要為了「看起來相容」宣告一個從未測過的巨大區間。 uv 也提供 fork strategy:
uv lock --fork-strategy requires-python
uv lock --fork-strategy fewest
requires-python:偏好每個 Python 範圍中較新的相容版本fewest:偏好用較少版本覆蓋更多環境 這是選擇策略,不是修復無解衝突的魔法。
九. Lockfile Diff:把修正當成可審查變更 #
不要在調整版本後直接 git add uv.lock。 先做 dry run:
uv lock --dry-run
只升級目標 package:
uv lock --upgrade-package httpx
然後檢查:
git diff -- pyproject.toml uv.lock
uv lock --check
uv sync --locked
Review lockfile 時,至少看:
- 哪些 package 新增或移除
- direct dependency 是否只改了預期範圍
- transitive dependency 有沒有大範圍洗牌
- marker 與 resolution fork 是否增加
- source/index 是否意外改變
- sdist/wheel artifacts 是否符合目標平台
用小工具摘要版本變化 #
uv.lock 是 TOML,可以用標準庫讀:
from pathlib import Path
import tomllib
def versions(path: str) -> dict[str, str]:
data = tomllib.loads(Path(path).read_text())
return {
package["name"]: package["version"]
for package in data.get("package", [])
if "version" in package
}
before = versions("uv.lock.before")
after = versions("uv.lock")
for name in sorted(before.keys() | after.keys()):
if before.get(name) != after.get(name):
print(f"{name}: {before.get(name)} -> {after.get(name)}")
同名 package 在 universal lockfile 可能有多個版本; 大型專案可把值改成 set[str],避免摘要遺漏 fork。
十. CI Evidence Bundle:讓別人能重現 #
Resolver 問題最怕只有一張紅字截圖。 拍拍君建議 CI 失敗時保留:
set -eu
uv --version
python -VV
uv lock --check
uv tree --universal > uv-tree.txt
若 uv lock --check 失敗,後面的 tree 可能不會執行。 可以分開收集:
uv lock --check 2>&1 | tee uv-lock-check.txt
status=${PIPESTATUS[0]}
uv tree --frozen --universal > uv-tree.txt 2>&1 || true
exit "$status"
Evidence bundle 可以包含:
uv --version- Python 版本與平台
- 最小化
pyproject.toml - 已遮密的 index 設定
- 完整 resolver error
- 舊 lockfile 的 reverse tree
pyproject.toml與uv.lockdiff 這比「本機可以、CI 不行」有用太多。 完整的 GitHub Actions cache 與 matrix 寫法, 請搭配 uv + GitHub Actions 實戰。
十一. 一套不容易迷路的診斷順序 #
遇到 resolution failure 時,照這個順序:
- 保存完整錯誤:不要只截最後一行
- 固定工具與環境:uv、Python、OS、architecture
- 縮成最小重現:只留衝突的 direct dependencies
- 畫出兩條需求鏈:誰要求下限,誰要求上限
- 檢查 markers:Python、平台、extras 是否重疊
- 查舊 tree:用
--invert找 transitive source - 先改 direct dependency:優先升級真正的上游
- 必要時加 constraint:保留上游契約
- 最後才用 override:附測試、issue 與期限
- 審查 lockfile diff:確認沒有意外洗牌
- 跨環境驗證:測支援的 Python 與平台
- 留下 evidence bundle:讓下一個人不用重猜
結語 #
Resolver error 其實是一份「為什麼條件無法同時成立」的證明。 把它拆成版本集合、dependency chain、markers 與 resolution forks, 問題就會從一團紅字,變成幾個可以逐一驗證的假設。 拍拍君最推薦記住三個原則:
- 先縮小問題,再修改版本
- Constraint 是政策,override 是帶風險的例外
- 每次修正都要用 lockfile diff 與跨環境測試驗證 下次 uv 說「No solution found」,先別急著刪 lockfile。 它多半已經把答案藏在 dependency chain 裡了。🧩