快轉到主要內容
  1. 教學文章/

uv Dependency Groups 實戰:dev、test、docs 與 production 依賴分層

·9 分鐘· loading · loading · ·
Python Uv Dependency Groups PEP 735 Pyproject.toml Ci DevOps
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
DevOps - 本文屬於一個選集。
§ 7: 本文

featured

一. 前言:一包 dev dependencies,早晚會變成雜物櫃
#

剛建立 Python 專案時,依賴通常很單純:

uv add fastapi pydantic
uv add --dev pytest ruff

兩行就能開始工作,舒服。

可是專案長大後,dev 裡常會同時塞進:

  • 測試用的 pytestcoverage
  • lint 與型別檢查用的 ruffmypy
  • 文件用的 mkdocsmkdocs-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 inituv syncuv.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.tomluv.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 現在是聚合入口。 套件仍由原本的 testlintdocs 分組維護,不必在四處複製版本條件。

拍拍君很不建議這樣寫:

[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 都會用到它。

六. 六個選擇參數,一次看懂
#

假設專案有 devtestlintdocs 四組:

指令 結果
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,重複宣告只會讓維護者更難判斷真正來源。

建議流程:

  1. 建立 testlintdocs 小 group
  2. include-group 重建 dev
  3. 移除 tool.uv.dev-dependencies
  4. 執行 uv lock
  5. 逐一測試 local、CI、production sync
  6. 在同一個 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 各自只有必要工具
  • devinclude-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、測試與重現的契約。

拍拍君建議從四層開始:

  1. [project].dependencies 放 runtime
  2. testlintdocs 放各自工具
  3. devinclude-group 組成本機完整環境
  4. production 用 --no-default-groups 回到最小 base

先切清楚邊界,再談 cache 與安裝速度。 不然裝得再快,也只是很快地把不需要的東西全部裝進去而已。🔧

延伸閱讀
#

DevOps - 本文屬於一個選集。
§ 7: 本文

相關文章

uv + GitHub Actions 實戰:Lockfile、Cache 與可重現 CI
·5 分鐘· loading · loading
Python Uv GitHub Actions Ci Lockfile Cache Developer-Tools
uv + Docker 實戰:Locked Sync、Layer Cache 與可重現 Image
·9 分鐘· loading · loading
Python Uv Docker Lockfile BuildKit Deployment Reproducible-Builds
uv pip-compile migration:從 requirements.txt 到可重現部署流程
·7 分鐘· loading · loading
Python Uv Requirements.txt Pip-Compile Dependency-Management Deployment
uv 管理 Python 版本:Install、Find、Pin、Upgrade 與直譯器選擇
·9 分鐘· loading · loading
Python Uv Python Versions Interpreter Virtualenv Developer-Tools
Python uv build/publish 實戰:從 wheel 到 private package workflow
·11 分鐘· loading · loading
Python Uv Packaging Wheel PyPI Private-Package Developer-Tools
Python uv scripts 實戰:PEP 723、inline dependencies 與單檔工具
·6 分鐘· loading · loading
Python Uv PEP 723 Script Developer-Tools Automation