一. 前言:裝好了,為什麼還是舊版本? #
拍拍君曾經看過一種很迷惑的除錯現場:
工具管理器說「安裝成功」,終端機卻繼續印舊版本。
有人於是再裝一次、再加一次 --force,最後連自己用了哪一份都說不清。
問題往往不在安裝,而在兩條不同的選擇路徑: uv 決定工具環境,shell 決定裸指令會跑哪個檔案。 它們不是同一個問題,也不一定得到同一個答案。
這篇不重講 uv 入門 的專案建立流程。 我們只追蹤一個 CLI:從臨時執行、持久安裝,到升級、回退與移除。 例子使用 Ruff,但不討論 lint 規則;想學規則請看 Ruff 教學。
二. 先把三個入口分開 #
| 入口 | 主要用途 | 環境歸誰管理 |
|---|---|---|
uvx ruff |
執行獨立工具 | uv 工具介面,可能重用已安裝工具 |
uv tool install ruff 後執行 ruff |
提供持續可用的命令 | uv 持久工具環境;shell 找入口 |
uv run ruff |
使用專案約定的檢查器 | 專案環境與依賴契約 |
uvx 是 uv tool run 的別名,不是另一套安裝器。
「臨時」也不等於每次重新下載,更不等於執行完立即清空磁碟。
未安裝的工具可以使用 uv cache 裡的可丟棄環境;持久安裝則放在工具目錄。
工具不會因此變成專案依賴。
但環境隔離不是安全沙箱:工具仍可讀寫你有權存取的檔案。
跑 formatter 時,它依然可能改動目前工作目錄中的原始碼。
我們的實驗只用 --version,不碰真實專案。
這些定位可對照 uv 工具概念文件。 下面則用實際命令驗證,不靠「應該是這樣」猜測。
三. 建立不會弄亂日常工具的實驗室 #
以下以 macOS / Linux 的 Bash 執行,整篇程式區塊依序放在同一個 shell。 需要預先安裝 uv、Python 3.12 與 3.11;後者用來證明環境分離。 若缺少 Python,先完成 uv Python 管理 的準備。 本文實測 uv 0.10.2、CPython 3.12.12 / 3.11.14;不是宣稱它們是最新版本。 Ruff 0.14.0 與 0.14.1 是刻意選的歷史測試版本,不是更新推薦。
set -euo pipefail
LAB="$(mktemp -d /tmp/pypy-tool-lab.XXXXXX)"
UV_CMD="$(command -v uv)"
export UV_TOOL_DIR="$LAB/tools"
export UV_TOOL_BIN_DIR="$LAB/bin"
export UV_CACHE_DIR="$LAB/cache"
export UV_PYTHON="$("$UV_CMD" python find --system 3.12)"
export UV_PYTHON_DOWNLOADS=never
export UV_NO_CONFIG=1
BASE_PATH="$PATH"
mkdir -p "$UV_TOOL_BIN_DIR"
export PATH="$UV_TOOL_BIN_DIR:$BASE_PATH"
cd "$LAB"
"$UV_CMD" --version
"$UV_CMD" tool dir
"$UV_CMD" tool dir --bin
三個專用目錄各有責任:
UV_TOOL_DIR:持久工具環境與管理資訊。UV_TOOL_BIN_DIR:讓 shell 搜尋的 executable 入口。UV_CACHE_DIR:這次操作自己的下載及可丟棄環境快取。
UV_NO_CONFIG=1 避免讀進日常專案或使用者 uv 設定。
UV_PYTHON_DOWNLOADS=never 則讓缺少直譯器時明確失敗,不偷偷下載補齊。
這不是完整環境封鎖;套件仍會從設定的套件來源取得,並會連網。
有企業索引政策的環境,請先確認來源設定,而不是直接照抄到正式機。
四. 臨時執行:不要把 Python 版本當工具版本 #
第一步先執行指定的工具版本:
"$UV_CMD" tool run --from 'ruff==0.14.0' ruff --version
"$UV_CMD" tool list
第一行應印 ruff 0.14.0;第二行此時不應列出持久安裝的 Ruff。
它有下載、有環境,卻沒有變成「已安裝工具」。
之後同規格的執行可以利用 cache;不要把 cache 存在誤判為正式安裝。
這裡刻意使用完整的 uv tool run 寫法,方便固定 uv executable 來源。
平常也可以寫 uvx ruff@0.14.0 --version。
--from 明確分開「提供套件」和「要執行的命令」;兩者名稱不一定相同。
例如 HTTPie 套件提供的常用入口叫 http,不叫 httpie。
還有一個常見誤會:uvx --python 3.12 ruff 固定的是工具環境的 Python,
不是 Ruff 套件版本。要控制 Ruff,仍須指定 ruff==... 或 ruff@...。
把兩種版本分開記錄,才能談得上重現。
五. 持久安裝:入口和環境要一起盤點 #
現在把同一版本安裝成長期可呼叫的工具:
"$UV_CMD" tool install 'ruff==0.14.0'
"$UV_CMD" tool list
command -v ruff
ruff --version
"$UV_CMD" tool run ruff --version
python3 - <<'PY'
import os
from pathlib import Path
entry = Path(os.environ["UV_TOOL_BIN_DIR"]) / "ruff"
target = entry.resolve(strict=True)
print("入口:", entry)
print("實體:", target)
assert target.is_relative_to(Path(os.environ["UV_TOOL_DIR"]).resolve())
PY
此時 ruff 和 uv tool run ruff 都應得到 0.14.0。
在 Unix 上,入口通常是連往工具環境的 symlink;Windows 的入口處理不同。
本例只驗證 Unix,不把路徑及 shell 指令當成跨平台保證。
裝過工具之後,未指定版本的 uvx 預設會重用已安裝版本。 因此「我用 uvx,所以一定拿到最新版」並不成立。 沒安裝時,未固定版本的執行也可能重用先前的 cache。
需要探索最新版可用 uvx ruff@latest --version,
但那不是固定結果的團隊契約。本篇不執行它,避免隨文章閱讀日期漂移。
臨時換版,則明確提出規格:
"$UV_CMD" tool run --from 'ruff==0.14.1' ruff --version
ruff --version
兩行應依序印出 0.14.1、0.14.0。
這正是我們要的邊界:試跑另一版本,不改寫持久安裝。
--isolated 可以忽略已安裝工具,但它本身不是「固定最新版」或版本鎖。
六. PATH 遮蔽:做一次可控的故障 #
現在故意放一個同名入口在前面。 它只是印一行字,不會執行真正的 Ruff。
mkdir -p "$LAB/shadow"
cat > "$LAB/shadow/ruff" <<'SH'
#!/bin/sh
printf '%s\n' 'shadow-entry-not-ruff'
SH
chmod +x "$LAB/shadow/ruff"
export PATH="$LAB/shadow:$UV_TOOL_BIN_DIR:$BASE_PATH"
hash -r
type -a ruff
ruff --version
"$UV_TOOL_BIN_DIR/ruff" --version
"$UV_CMD" tool run ruff --version
export PATH="$UV_TOOL_BIN_DIR:$BASE_PATH"
hash -r
三次版本查詢的意義不同:
| 查詢 | 本例結果 | 在問誰 |
|---|---|---|
ruff --version |
shadow-entry-not-ruff |
shell 找到的第一個入口 |
"$UV_TOOL_BIN_DIR/ruff" --version |
ruff 0.14.0 |
明確指定的持久入口 |
uv tool run ruff --version |
ruff 0.14.0 |
uv 選定的工具環境 |
這時重新安裝並不能修正前面那個假的入口。
--force 也不會把別的 PATH 目錄、shell alias 或 function 全部清乾淨。
先看 type -a;若有 alias 或 function,要另外檢查其定義。
hash -r 在 Bash 清除命令位置快取;zsh 可用 rehash。
真實機器上若工具目錄根本不在 PATH,先看 uv tool dir --bin。
確認要加入的目錄後,才使用 uv tool update-shell 並重新開 shell。
它可能修改 shell 設定,所以本實驗只改當前程序的 PATH,不執行它。
七. 專案工具與個人工具,不要共用一個猜測 #
建立一個小型專案環境:Python 3.11、Ruff 0.14.1。 個人工具仍維持 Python 3.12、Ruff 0.14.0。
mkdir -p "$LAB/project"
"$UV_CMD" venv --python 3.11 "$LAB/project/.venv"
"$UV_CMD" pip install --python "$LAB/project/.venv/bin/python" 'ruff==0.14.1'
(
source "$LAB/project/.venv/bin/activate"
hash -r
command -v ruff
ruff --version
"$UV_CMD" tool run ruff --version
)
"$LAB/project/.venv/bin/python" -c 'import sys; print(sys.version_info[:2]); print(sys.prefix)'
"$UV_TOOL_DIR/ruff/bin/python" -c 'import sys; print(sys.version_info[:2]); print(sys.prefix)'
在子 shell 啟用專案環境後,裸 ruff 應跑 0.14.1;
uv tool run ruff 仍跑個人工具的 0.14.0。
兩份 Python 回報則應分別是 (3, 11) 與 (3, 12),prefix 也不同。
本例已用 UV_PYTHON 明確要求工具直譯器,不依賴自動探索的偶然結果。
正式專案若已用 uv 管理,請把檢查器列進專案依賴並以 uv run 執行。
像 pytest 需要 import 專案套件,更不能把獨立 uvx 環境當成專案環境。
Ruff 雖多半直接讀檔,不需要 import 專案,規則和版本仍應由團隊約定。
另外,工具環境隔離不代表設定檔隔離。 在專案目錄執行 Ruff,仍可能讀取該目錄的 Ruff 設定; 「用哪份 Python/套件」和「讀哪份設定/資料」要分別追蹤。
八. 升級前先問:當初裝的是什麼規格? #
先對精確固定的 0.14.0 執行一般升級:
"$UV_CMD" tool upgrade ruff
test "$("$UV_TOOL_BIN_DIR/ruff" --version)" = 'ruff 0.14.0'
==0.14.0 仍是需求,升級不應偷偷跨越它。
這不是升級失敗,而是遵守先前契約。
若當初安裝的是允許一段版本的需求,一般升級可以在範圍內挑選較新版本。
不過主套件固定,也不代表所有間接依賴都已鎖定;本例 Ruff 恰好很單純。
接著用 install 變更為只容許兩個版本的範圍。 為了讓升級可觀察,先以最低直接依賴策略選到 0.14.0,再改回最高版本策略:
"$UV_CMD" tool install 'ruff>=0.14.0,<0.14.2' --resolution lowest-direct
test "$("$UV_TOOL_BIN_DIR/ruff" --version)" = 'ruff 0.14.0'
"$UV_CMD" tool upgrade ruff --resolution highest
"$UV_CMD" tool list
"$UV_TOOL_BIN_DIR/ruff" --version
test "$("$UV_TOOL_BIN_DIR/ruff" --version)" = 'ruff 0.14.1'
lowest-direct 只是本次重現升級的實驗安排,不是日常更新建議。
要直接切換到指定版本,可用 uv tool install 'ruff==0.14.1'。
別假設 upgrade 'ruff==0.14.1' 能解除舊的 ==0.14.0;
本次實測這樣會產生互斥需求錯誤,應由 install 明確改變原需求。
不要為了升級一個工具直接 uv tool upgrade --all,把故障範圍放大。
真實驗收不只看版本:還要對固定 fixture 跑檢查,記錄退出碼與輸出差異。 這裡只驗證管理生命週期,不宣稱兩版 Ruff 的所有 lint 行為等價。 官方 Using tools 指南 另有版本選擇與升級說明。
九. 回退不是「撤銷剛才所有事情」 #
若升版後不符合預期,明確重新指定舊版本:
"$UV_CMD" tool install 'ruff==0.14.0'
test "$("$UV_TOOL_BIN_DIR/ruff" --version)" = 'ruff 0.14.0'
"$UV_CMD" tool run ruff --version
這是工具套件版本的回退,不是檔案內容的回復。 若新 formatter 已改寫原始碼,降版不會把內容改回來;要用 Git 審核及還原。 若工具還有外部資料格式、plugin 或額外依賴,也要另做相容性檢查。
本例沒有備份 uv 私有管理檔,也沒有手改工具環境中的 site-packages。
把 pip install 塞進工具 venv,會讓實際環境與管理資訊逐漸不一致。
日常應交給 uv tool install / upgrade / uninstall 管理。
十. 移除:入口消失,和「所有痕跡消失」不同 #
最後移除這次持久安裝,但保留專案環境作為對照:
"$UV_CMD" tool uninstall ruff
"$UV_CMD" tool list
test ! -e "$UV_TOOL_BIN_DIR/ruff"
test ! -L "$UV_TOOL_BIN_DIR/ruff"
test ! -d "$UV_TOOL_DIR/ruff"
"$LAB/project/.venv/bin/ruff" --version
"$UV_CMD" tool run --from 'ruff==0.14.0' ruff --version
"$UV_CMD" tool list
應看到:持久入口與工具環境消失,專案 Ruff 仍是 0.14.1; 之後明確規格的臨時執行仍可跑 0.14.0,但不重新出現在持久工具清單。 移除不是 cache 清除,也不是卸載其他工具管理器或專案裡的同名工具。
實驗結束後可先 cd 回原目錄,關閉這個 shell,還原本次環境變數。
$LAB 留著供稽核,確認路徑與內容後再用自己的垃圾桶工具清理。
不要在日常環境為了這個實驗執行全域 cache 清除或工具全部移除。
十一. 這次實驗實際驗證了什麼? #
本文十個 Bash 區塊已依序在新的隔離目錄執行完成,並核對以下觀察:
| 邊界 | 實際結果 |
|---|---|
| 未安裝時指定版本執行 | 可跑 0.14.0,持久清單仍為空 |
| 臨時指定另一版 | 跑 0.14.1,持久入口仍為 0.14.0 |
| 同名 PATH 入口遮蔽 | 裸命令跑假入口,絕對入口與 uv 工具仍正確 |
| 啟用專案環境 | 專案 3.11 / 0.14.1,工具 3.12 / 0.14.0 |
| 精確 pin 後一般升級 | 維持 0.14.0 |
| 受限範圍升級與回退 | 0.14.0 → 0.14.1 → 0.14.0 |
| 移除持久工具 | 入口消失,專案與臨時執行不受影響 |
這些是指定版本、指定平台的行為驗收,不是速度測試。 未驗證 Windows shell、所有 plugin、所有 uv 版本或外部工具的完整相容性。
十二. 一份能交接的工具紀錄 #
拍拍君會為常駐 CLI 保存這幾項,而不是只截圖「安裝成功」:
- uv 版本、作業系統及架構。
- 工具套件的需求規格,以及觀察到的實際版本。
- 工具 Python 版本、入口位置與最終 executable 位置。
- 附加依賴、套件來源政策與必要設定。
- 升級前後 fixture、退出碼及明確的回退命令。
這不是完整依賴 lock,也沒有把公開套件名稱視為來源真實性的證明。 若要完整重建有複雜 plugin 的工具,應另外保存解析結果、artifact 與驗收資料。 CLI 版本固定只是起點,不是可重現性的全部。
結語 #
uvx 解決「怎麼方便地執行」,uv tool install 解決「怎麼持續提供入口」。
shell 的 PATH 則決定你直接輸入工具名稱時,到底會碰到哪個入口。
把這三件事分開,很多「明明裝了卻沒更新」就不神祕了。
下次升級前,先記錄規格和來源;升級後,驗收同一份 fixture。
有明確的回退入口,比把 --force 當幸運符可靠多了。