一. 前言:一包 dev dependencies,早晚會變成雜物櫃 #
剛建立 Python 專案時,依賴通常很單純:
uv add fastapi pydantic
uv add --dev pytest ruff
兩行就能開始工作,舒服。
可是專案長大後,dev 裡常會同時塞進:
- 測試用的
pytest、coverage - lint 與型別檢查用的
ruff、mypy - 文件用的
mkdocs、mkdocs-material - release、benchmark、notebook 等偶爾才用的工具
於是每個場合都裝同一大包依賴。 CI 的 lint job 裝了文件產生器,production image 甚至不小心帶進測試工具。 更新一個 docs plugin,整份 lockfile 也變得很難 review。
這時候該用的是 Dependency Groups。
它讓我們在 pyproject.toml 裡把本機開發工具分組,並在不同環境明確選擇:
local development = runtime + test + lint + docs
test CI = runtime + test
docs CI = runtime + docs
production = runtime only
這篇不會重講 uv 的安裝、workspace 或一般 lockfile 流程。
如果還不熟 uv init、uv sync 與 uv.lock,先看 uv workspace 與 lockfile 實戰。
今天拍拍君只處理一件事:把依賴邊界切清楚,而且真的在 local、CI、production 用對它。
二. 先分清楚三種依賴 #
pyproject.toml 裡常見三種看起來很像、用途卻不同的欄位。
| 欄位 | 誰需要 | 發佈到套件 metadata | 常見例子 |
|---|---|---|---|
[project].dependencies |
程式執行本身 | 會 | FastAPI、Pydantic |
[project.optional-dependencies] |
套件使用者選裝 | 會 | package[postgres] |
[dependency-groups] |
維護專案的人 | 不會 | pytest、Ruff、MkDocs |
最重要的判斷不是「這個套件是不是可選」,而是:
安裝你發佈的 wheel 時,使用者需不需要知道這組依賴?
如果需要,考慮一般 dependencies 或 optional dependencies,也就是 extras。 如果只為了開發、測試、文件與維護,才放 Dependency Groups。
例如資料庫 driver 是產品功能的一部分:
[project.optional-dependencies]
postgres = ["psycopg[binary]>=3.2"]
使用者能安裝:
pip install "chatptt-api[postgres]"
但測試工具不該變成公開 extra:
[dependency-groups]
test = ["pytest>=9", "pytest-cov>=7"]
Dependency Groups 是 PEP 735 標準化的專案開發資訊。 不過工具支援度仍可能不同,團隊若不只使用 uv,遷移前要確認其他工具能不能讀懂它。
三. 建立最小專案 #
先準備一個簡單 API:
uv init --package chatptt-api
cd chatptt-api
uv add fastapi pydantic
核心 metadata 會像這樣:
[project]
name = "chatptt-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.141.1",
"pydantic>=2.13.5",
]
這兩個套件是 runtime dependencies。 production 執行 API 時真的會 import 它們,所以不能丟進某個開發 group。
接著分別加入測試、檢查與文件工具:
uv add --group test pytest pytest-cov
uv add --group lint ruff mypy
uv add --group docs mkdocs mkdocs-material
uv 會更新 pyproject.toml 與 uv.lock:
[dependency-groups]
test = [
"pytest>=9.1.1",
"pytest-cov>=7.1.0",
]
lint = [
"mypy>=2.3.1",
"ruff>=0.16.7",
]
docs = [
"mkdocs>=1.6.1",
"mkdocs-material>=9.7.7",
]
版本只是示意;真正下限應該由專案驗證後決定。
重點是每一組都有明確職責,不是把 dev 改名成三個雜物櫃。
四. 用 include-group 組成完整開發環境
#
拆成小 group 後,本機開發者通常還是希望一個指令把工具裝齊。
PEP 735 支援 group composition:
[dependency-groups]
test = [
"pytest>=9.1.1",
"pytest-cov>=7.1.0",
]
lint = [
"mypy>=2.3.1",
"ruff>=0.16.7",
]
docs = [
"mkdocs>=1.6.1",
"mkdocs-material>=9.7.7",
]
dev = [
{ include-group = "test" },
{ include-group = "lint" },
{ include-group = "docs" },
"pre-commit>=4.3.0",
]
dev 現在是聚合入口。
套件仍由原本的 test、lint、docs 分組維護,不必在四處複製版本條件。
拍拍君很不建議這樣寫:
[dependency-groups]
test = ["pytest>=9.1"]
dev = ["pytest>=9.1", "ruff>=0.16", "mkdocs>=1.6"]
當 pytest 升級時,你得同步改兩處;漏掉一次,就會出現「test job 跟本機版本不同」的幽靈問題。
include-group 表達的是組合關係,正好把單一來源保留下來。
五. Default Groups:uv sync 到底會裝什麼?
#
uv 預設會把名為 dev 的 group 一起裝進環境。
所以有 dev 時,直接執行:
uv sync
通常會得到 runtime dependencies、專案本身,以及 dev 包含的所有工具。
如果想明確寫出團隊預設值:
[tool.uv]
default-groups = ["dev"]
也可以預設啟用多組:
[tool.uv]
default-groups = ["test", "lint"]
甚至設定成全部:
[tool.uv]
default-groups = "all"
但 "all" 很容易讓日常環境無止境膨脹。
對大多數團隊,保留一個可預期的 dev 聚合入口會比較好懂。
臨時忽略所有預設 group:
uv sync --no-default-groups
這條指令非常重要,因為 production 與精簡 CI 都會用到它。
六. 六個選擇參數,一次看懂 #
假設專案有 dev、test、lint、docs 四組:
| 指令 | 結果 |
|---|---|
uv sync |
runtime + project + default groups |
uv sync --group docs |
上述內容再加 docs |
uv sync --no-group docs |
排除 docs,即使別處要求加入 |
uv sync --all-groups |
runtime + project + 全部 groups |
uv sync --no-default-groups |
runtime + project,不含預設 groups |
uv sync --only-group lint |
只有 lint group,不裝 project/runtime |
這裡最容易誤會的是 --only-group。
uv sync --only-group test
它不是「runtime 加 test」,而是 只有 test。
專案本身與 [project].dependencies 都會被省略。
如果測試要 import 專案、也需要 runtime dependencies,CI 應該用:
uv sync --locked --no-default-groups --group test
也就是先保留 base project,再額外加入 test。
--only-group 適合真正獨立的工具工作,例如只想建立一個純 lint 環境;使用前要確認該工具不需要 import 專案。
另外,排除永遠優先於加入:
uv sync --group docs --no-group docs
最後仍不會安裝 docs group。 別在 CI 疊出互相打架的參數;明確比炫技重要。
七. Production 不是一個塞滿套件的 group #
看到標題裡的 production,你可能會想建立:
[dependency-groups]
production = ["fastapi", "pydantic"]
先不要。
產品執行必需品應該放在 [project].dependencies。
Dependency Groups 不會寫進發佈套件的 metadata,把 runtime requirements 藏在 production group 會讓 wheel 使用者拿不到完整依賴。
對一般 application,production profile 就是:
uv sync --locked --no-default-groups
它會同步:
- 專案本身
[project].dependencies- lockfile 選定的版本
但不會加入預設的 dev tools。
Docker 裡還可以使用 non-editable install:
uv sync --locked --no-default-groups --no-editable
容器 layer、cache 與 multi-stage build 的細節,可以接著看 uv + Docker 可重現 Image。
如果 production 有真的可選功能,例如 PostgreSQL 與 Redis adapter,那通常是 extras:
[project.optional-dependencies]
postgres = ["psycopg[binary]>=3.2"]
redis = ["redis>=6"]
部署時再明確選擇:
uv sync --locked --no-default-groups --extra postgres
這樣 public package metadata 與實際部署需求才一致。
八. CI:每個 Job 只拿需要的工具 #
把 group 拆開的最大收益,通常在 CI。
測試 job:
- name: Install test environment
run: uv sync --locked --no-default-groups --group test
- name: Run tests
run: uv run --locked --no-sync pytest -q
lint job:
- name: Install lint environment
run: uv sync --locked --no-default-groups --group lint
- name: Run checks
run: |
uv run --locked --no-sync ruff check .
uv run --locked --no-sync mypy src
文件 job:
- name: Install docs environment
run: uv sync --locked --no-default-groups --group docs
- name: Build docs
run: uv run --locked --no-sync mkdocs build --strict
先 sync 再以 --no-sync 執行,是要讓 job 的安裝邊界保持固定。
否則後面的 uv run 可能依照預設 groups 再次調整環境,讓你以為「只裝 test」,實際又補回整包 dev。
如果同一個 job 需要 test 與 lint,可以重複 --group:
uv sync --locked \
--no-default-groups \
--group test \
--group lint
完整的 GitHub Actions cache、matrix 與 lockfile drift 做法,請搭配 uv + GitHub Actions 實戰。
九. Lockfile 會解析全部 Groups #
「CI 只安裝 test」不代表 lockfile 只解析 test。
uv 建立 lockfile 時,會一起解析 project dependencies、extras 與 Dependency Groups。 因此下面兩組即使永遠不一起安裝,預設仍可能造成 resolution failure:
[dependency-groups]
old-stack = ["numpy==1.26.4"]
new-stack = ["numpy==2.3.0"]
如果兩組真的代表互斥環境,可以顯式宣告 conflict:
[tool.uv]
conflicts = [
[
{ group = "old-stack" },
{ group = "new-stack" },
],
]
不過 conflict 不是日常亂鎖版本的解藥。 先問自己:兩個工具是否真的不能共存,還是下限寫得太死、環境本來就應該拆成兩個專案?
每次改 group 都應該檢查:
uv lock --check
uv tree
在 PR 裡 review pyproject.toml 的意圖,也 review uv.lock 的實際解法。
十. 某個 Group 需要更新的 Python? #
你的 library 可能支援 Python 3.10,但最新版文件工具只支援 Python 3.12。 這不代表整個 library 必須立刻放棄 3.10。
uv 允許替 group 指定 Python 範圍:
[project]
requires-python = ">=3.10"
[dependency-groups]
docs = ["mkdocs-material>=9.7"]
[tool.uv.dependency-groups]
docs = { requires-python = ">=3.12" }
注意兩個 table 名稱很接近:
[dependency-groups]定義套件清單[tool.uv.dependency-groups]放 uv 專用的 group 設定
CI 也要配合這個契約,讓 docs job 使用 Python 3.12 以上。 不要只改 TOML,卻留下 Python 3.10 的 runner 等它爆炸。
十一. 從舊 dev-dependencies 遷移
#
早期 uv 專案可能使用:
[tool.uv]
dev-dependencies = [
"pytest>=8",
"ruff>=0.8",
]
現在建議改用標準化的 [dependency-groups]:
[dependency-groups]
dev = [
{ include-group = "test" },
{ include-group = "lint" },
]
test = ["pytest>=9.1"]
lint = ["ruff>=0.16"]
遷移時不要同時保留兩份相同套件。
uv 目前會合併舊欄位與 dependency-groups.dev,重複宣告只會讓維護者更難判斷真正來源。
建議流程:
- 建立
test、lint、docs小 group - 以
include-group重建dev - 移除
tool.uv.dev-dependencies - 執行
uv lock - 逐一測試 local、CI、production sync
- 在同一個 PR 更新貢獻文件與 workflow
如果專案還以 requirements.in / requirements.txt 為部署核心,不必硬切。
可以先看 uv pip-compile migration,再決定何時搬到 uv project mode。
十二. 團隊應該把選擇寫進固定入口 #
別要求每個人背六個 flags。
可以用 Makefile 或 task runner 固定入口:
.PHONY: dev test lint docs prod-check
dev:
uv sync --locked
test:
uv sync --locked --no-default-groups --group test
uv run --locked --no-sync pytest -q
lint:
uv sync --locked --no-default-groups --group lint
uv run --locked --no-sync ruff check .
docs:
uv sync --locked --no-default-groups --group docs
uv run --locked --no-sync mkdocs build --strict
prod-check:
uv sync --locked --no-default-groups
README 只要說:
make dev
make test
底層策略仍在版本控制裡,不會散落到聊天紀錄與每個人的 shell history。
十三. 常見錯誤 #
錯誤一:把 runtime 套件放進 production group #
Dependency Groups 不會成為發佈 metadata。
執行必需品放 [project].dependencies,production 用 base-only sync。
錯誤二:把 Dependency Groups 當成 Extras #
使用者無法靠 pip install package[test] 取得 [dependency-groups].test。
要公開給使用者選裝的功能,請放 [project.optional-dependencies]。
錯誤三:以為 --only-group test 會順便安裝專案
#
它會省略 project 與 runtime dependencies。
需要測完整應用時,用 --no-default-groups --group test。
錯誤四:先精簡 sync,後面的 uv run 又補回 default groups
#
先明確 sync,執行時搭配 --no-sync,讓 job 不再偷偷改環境。
錯誤五:同一套工具在多個 groups 複製版本條件 #
用 include-group 組合,保留單一來源。
錯誤六:只測 local 的完整 dev 環境 #
完整環境可能掩蓋漏掉的依賴。 CI 應分別建立 test、lint、docs 與 production profile,才能驗證邊界。
錯誤七:把所有 group 都設成 default #
方便幾天之後,大家會忘記某個 job 真正需要什麼。 預設保持可預期,特殊環境明確選擇。
十四. 實用檢查清單 #
提交前:
- runtime dependencies 都在
[project].dependencies - 公開可選功能使用 optional dependencies
- test、lint、docs 各自只有必要工具
-
dev用include-group組合,不複製套件 -
uv.lock已更新並通過uv lock --check - README 與 CI 使用相同 group 策略
CI:
- 使用
--locked - job 先用
--no-default-groups清掉隱含預設 - 再用
--group加入必要工具 - 後續
uv run用--no-sync保持環境不變 - docs 的 Python 版本符合 group constraint
Production:
- 使用 base project,而不是自創 runtime group
- 執行
uv sync --locked --no-default-groups - 容器視需求加上
--no-editable - 沒有 pytest、Ruff、MkDocs 等開發工具
- 若使用 extras,部署指令有明確列出
結語 #
Dependency Groups 真正解決的,不是 pyproject.toml 看起來更整齊。
它把「這個環境為什麼需要這個套件」變成可以 review、測試與重現的契約。
拍拍君建議從四層開始:
[project].dependencies放 runtimetest、lint、docs放各自工具dev用include-group組成本機完整環境- production 用
--no-default-groups回到最小 base
先切清楚邊界,再談 cache 與安裝速度。 不然裝得再快,也只是很快地把不需要的東西全部裝進去而已。🔧