一、前言:小工具也需要可重現 #
嗨,這裡是拍拍君。Python 很適合寫小工具:檢查 API、整理 CSV、掃描檔案、產生報表、幫 repo 做一些手動維護。 這些工具常常只有一個檔案。 可是只要需要第三方套件,就會開始出現小麻煩:
python -m venv .venv
source .venv/bin/activate
pip install httpx rich typer
python check_status.py
腳本本身很好分享,但執行環境沒有跟著走。 你可以另外放 requirements.txt。 但如果只是 80 行的維護腳本,為它多開一個依賴檔,有時候又顯得太重。 uv scripts 解決的就是這件事。 你把依賴寫在 Python 檔案開頭,然後這樣執行:
uv run check_status.py
uv 會讀取腳本內的 metadata,自動建立隔離環境、安裝依賴、執行程式。 不用手動 activate。 不用叫同事猜要 pip install 什麼。 也不用把一個小工具硬升級成完整 package。 上一篇拍拍君寫過 Python uv 進階:workspace、lockfile、script 與專案管理。 那篇主要看專案工作流。 今天這篇只看一件事:
如何用 PEP 723 和 uv,把單檔 Python 腳本做得乾淨、可重現、好分享。
二、PEP 723 的 script metadata #
PEP 723 定義了一種寫在 Python 註解裡的 metadata 格式。 最小範例長這樣:
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "httpx>=0.28.0",
# "rich>=13.9.0",
# ]
# ///
import httpx
from rich.console import Console
console = Console()
response = httpx.get("https://example.com", timeout=10)
console.print({"status_code": response.status_code})
這段雖然是 Python 註解,但內容對 uv 來說是 TOML metadata。 uv 會知道三件事:
- 這個腳本需要 Python 3.12 以上
- 這個腳本需要 httpx
- 這個腳本需要 rich 所以 .py 檔不只是程式碼。 它也帶著自己的執行說明。 這跟傳統 requirements.txt 最大的差異是位置。 requirements.txt 是「旁邊有一份依賴說明」。 PEP 723 是「腳本自己說明自己需要什麼」。 對小工具來說,這個差異很實用。 檔案移動、貼給同事、放進 scripts 目錄,都不容易漏掉依賴資訊。
三、安裝 uv,然後不要 uv init #
如果還沒有 uv,可以用官方 installer:
curl -LsSf https://astral.sh/uv/install.sh | sh
macOS 也可以用 Homebrew:
brew install uv
確認版本:
uv --version
接著建立練習目錄:
mkdir uv-script-demo
cd uv-script-demo
這篇先不要 uv init。 這點很重要。 我們不是在建立專案。 我們是在寫「可以獨立執行的單檔工具」。 如果你的需求已經需要 pyproject.toml、uv.lock、多個模組和測試,那就應該回到 project mode。 uv scripts 很好用,但它不是拿來假裝大型專案不存在的魔法。 #
四、第一個範例:檢查網站狀態 #
建立 check_status.py:
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "httpx>=0.28.0",
# "rich>=13.9.0",
# ]
# ///
import sys
import httpx
from rich.console import Console
from rich.table import Table
def check(url: str) -> tuple[int, float]:
with httpx.Client(timeout=10, follow_redirects=True) as client:
response = client.get(url)
elapsed_ms = response.elapsed.total_seconds() * 1000
return response.status_code, elapsed_ms
def main() -> None:
url = sys.argv[1] if len(sys.argv) > 1 else "https://example.com"
status_code, elapsed_ms = check(url)
table = Table(title="HTTP status")
table.add_column("URL")
table.add_column("Status", justify="right")
table.add_column("Elapsed", justify="right")
table.add_row(url, str(status_code), f"{elapsed_ms:.1f} ms")
Console().print(table)
if __name__ == "__main__":
main()
執行:
uv run check_status.py https://dailypypy.org
第一次跑時,uv 會解析 metadata、建立快取環境、下載套件。 第二次通常會快很多,因為環境和套件都已經在 uv cache 裡。 你可以把這個檔案丟給同事。 只要對方有 uv,就可以直接跑。 這比「請先建立 venv,然後 pip install 這三個套件」清楚很多。 #
五、用 uv add –script 管理依賴 #
metadata 可以手寫。 但如果你常常加減依賴,建議用 uv 指令改。 先建立空腳本:
touch fetch_json.py
加入依賴:
uv add --script fetch_json.py httpx rich
uv 會自動把 PEP 723 區塊寫進檔案。 接著補上程式:
# /// script
# dependencies = [
# "httpx",
# "rich",
# ]
# ///
import sys
import httpx
from rich import print
def main() -> None:
url = sys.argv[1]
data = httpx.get(url, timeout=10).json()
print(data)
if __name__ == "__main__":
main()
執行:
uv run fetch_json.py https://api.github.com/repos/astral-sh/uv
如果後來不需要 rich:
uv remove --script fetch_json.py rich
這個工作流的好處是,依賴清單不會跟 import 慢慢分離。 你修改腳本時,也順手修改它自己的 metadata。 這比「程式碼在 A,依賴記錄在 B,README 又寫了 C」可靠很多。 #
六、版本範圍怎麼寫比較剛好? #
個人一次性腳本可以很放鬆:
# dependencies = ["httpx", "rich"]
如果腳本會留在 repo 裡,拍拍君建議至少寫下限:
# dependencies = [
# "httpx>=0.28.0",
# "rich>=13.9.0",
# ]
如果是團隊會反覆使用的維護工具,可以加上合理上限:
# dependencies = [
# "httpx>=0.28.0,<1",
# "rich>=13.9.0,<14",
# ]
這不是因為每個套件都一定會破壞相容性。 而是單檔腳本通常沒有完整測試。 少一點驚喜,對維運工具是好事。 拍拍君會這樣分:
- 個人臨時:不鎖或只寫套件名
- repo 維護腳本:寫下限,必要時寫上限
- 正式部署服務:不要用單檔腳本硬扛,請建立專案並提交 lockfile 工具選擇本身也是設計。
七、把 scripts 目錄變乾淨 #
常見做法是把這類工具放進 scripts 目錄:
your-repo/
scripts/
check_api.py
export_users.py
cleanup_tmp.py
src/
tests/
pyproject.toml
這些腳本可以跟主專案分開管理依賴。 例如主專案是 FastAPI,但 scripts/check_api.py 只需要 httpx 和 rich。 你就不用為了維護腳本污染 production dependencies。 範例:
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "httpx>=0.28,<1",
# "rich>=13.9,<14",
# ]
# ///
from __future__ import annotations
import argparse
import httpx
from rich.console import Console
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", required=True)
parser.add_argument("--token", required=True)
return parser.parse_args()
def main() -> None:
args = parse_args()
headers = {"Authorization": f"Bearer {args.token}"}
response = httpx.get(f"{args.base_url}/health", headers=headers, timeout=10)
response.raise_for_status()
Console().print("[green]API is healthy[/green]")
if __name__ == "__main__":
main()
執行:
uv run scripts/check_api.py --base-url https://api.example.com --token "$TOKEN"
這個 pattern 很適合內部工具。 主專案保持乾淨,小工具也保持可重現。 如果這支腳本未來長大到需要設定檔、資料庫、測試和 CI,那就把它升級成正式專案。 不要硬塞。 #
八、實戰範例:CSV 轉 JSONL #
再做一個資料轉換工具。 需求很單純:
- 讀取 CSV
- 清掉空白欄位
- 每列輸出成 JSONL
- 用 typer 做 CLI
- 用 orjson 做輸出 建立 csv_to_jsonl.py:
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "orjson>=3.10,<4",
# "typer>=0.15,<1",
# ]
# ///
from __future__ import annotations
import csv
from pathlib import Path
from typing import Annotated
import orjson
import typer
app = typer.Typer(no_args_is_help=True)
def clean_row(row: dict[str, str]) -> dict[str, str]:
return {
key.strip(): value.strip()
for key, value in row.items()
if key and value and value.strip()
}
@app.command()
def convert(
input_path: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
output_path: Annotated[Path, typer.Argument(dir_okay=False)],
) -> None:
with input_path.open(newline="", encoding="utf-8") as source:
reader = csv.DictReader(source)
with output_path.open("wb") as target:
for row in reader:
target.write(orjson.dumps(clean_row(row)))
target.write(b"\n")
typer.echo(f"wrote {output_path}")
if __name__ == "__main__":
app()
準備資料:
cat > users.csv <<'CSV'
name,email,role
拍拍君,pypy@example.com,admin
拍拍醬,pypychan@example.com,editor
CSV
執行:
uv run csv_to_jsonl.py users.csv users.jsonl
這就是 uv scripts 很適合的場景。 一個檔案可以說完。 依賴清楚。 介面也不差。 #
九、常見踩雷 #
第一個踩雷:把腳本寫到 800 行。 如果單檔工具已經開始有很多 helper、很多狀態、很多 domain,那就拆成專案。 第二個踩雷:依賴永遠不設版本。 短期可以,長期最好至少設下限。 第三個踩雷:忘記 requires-python。 如果你用了 Python 3.12 的語法,請寫:
# requires-python = ">=3.12"
不然同事用 3.10 跑爆,大家都很煩。 第四個踩雷:把 secret 寫進腳本。 用環境變數讀 token:
import os
token = os.environ["API_TOKEN"]
第五個踩雷:把 uv run –with 當成長期依賴管理。 如果只是今天 debug 想加 rich,可以用 –with。 如果腳本正常執行需要它,就寫進 PEP 723 metadata。 #
十、什麼時候該升級成專案? #
適合 uv scripts:
- 單一 .py 檔可以說完
- 不需要被其他程式 import
- 執行頻率不高,但希望可重現
- 是 repo 維護、資料轉換、手動操作、demo 適合 project mode:
- 需要多個模組
- 需要 package metadata
- 需要 entry points
- 需要 lockfile 納入版本控制
- 需要 CI、測試、部署
- 會長期被其他程式依賴 兩者不是誰取代誰。 uv scripts 是小工具的好形狀。 project mode 是正式專案的好形狀。 你要做的是選對容器,而不是把所有東西都塞進同一個盒子。
結語 #
PEP 723 和 uv scripts 解決的是一個很日常的痛點:
我只想分享一個 Python 小工具,為什麼要順便管理一個小專案? 現在答案可以很簡單。 把依賴寫進腳本。 用 uv run 執行。 讓腳本自己帶著執行說明走。 今天拍拍君示範了:
- PEP 723 metadata 怎麼寫
- uv run 如何執行單檔腳本
- uv add –script 與 uv remove –script
- inline dependencies 的版本寫法
- repo 裡的 scripts 工作流
- 什麼時候該升級成正式專案 小工具不用粗糙。 它可以小,但仍然乾淨、可重現、好交接。 這就是 uv scripts 最可愛的地方。