前言

在過去,Python 的世界裡安裝套件、打包專案各有各的工具(setup.pyrequirements.txtPipfile 等),生態系相當分散。pyproject.toml 是 Python 社群為了統一這些標準而誕生的格式,從 PEP 518 開始逐漸成為官方推薦的專案設定入口。它就像專案的「身分證」與「說明書」,集中描述專案名稱、依賴套件、建置方式等所有關鍵資訊。

核心結構

pyproject.toml 主要由以下兩個區塊組成:

  • [build-system]:宣告要用哪個工具來打包專案(常見為 setuptoolshatchling)。
  • [project]:最核心的區塊,包含專案名稱、版本、作者,以及執行時所需的依賴套件(dependencies)。

建立 pyproject.toml

Step 1:建立專案資料夾

以一個需要 requests 套件的爬蟲程式為例,建立以下目錄結構:

1
2
3
my_project/
├── pyproject.toml
└── main.py

Step 2:撰寫 pyproject.toml

pyproject.toml 中填入以下內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "my_awesome_tool"
version = "0.1.0"
authors = [
{ name = "你的名字", email = "yourname@example.com" },
]
description = "這是一個學習用的範例專案"
requires-python = ">=3.8"
dependencies = [
"requests",
]

[build-system] 說明

這個區塊告訴 Python 打包工具鏈如何處理你的專案:

  • requires:打包前需要安裝的工具,例如 setuptools
  • build-backend:執行打包時呼叫的核心引擎,此處使用 setuptools 的標準後端。

Step 3:安裝依賴

現代工具可以直接讀取 pyproject.toml 並自動安裝所有依賴,不需要手動逐一 pip install

方式一:使用 pip(現代版本)

my_project/ 目錄下執行:

1
pip install .

方式二:使用 uv(推薦)

uv 是目前 Python 生態系中速度最快的套件管理工具,完整支援 pyproject.toml

1
uv sync

uv sync 會自動建立虛擬環境(.venv)、讀取 pyproject.toml,並安裝所有需要的套件。

進階配置

依賴分組(dependency-groups)

為什麼要分組? 正式執行時只需要 requests;開發過程中還需要 pytestblack 等工具。這些開發工具不應該被打包進最終產品,否則會增加不必要的體積。

做法是透過 [dependency-groups] 區塊區隔「開發/測試用依賴」:

1
2
3
4
5
6
7
[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
"black>=24.0.0",
]

安裝時加上 --group 參數即可安裝指定群組:

1
uv sync --group dev

多來源套件索引(uv index)

為什麼需要多個來源?

大多數公開套件從 PyPI.org 下載,但公司內部的私有套件庫或 GitHub 上的測試版本無法從 PyPI 取得。uv 允許你在設定檔中定義多個套件來源,依序搜尋。

宣告私有套件索引

使用 [[tool.uv.index]](注意雙括號,代表這是一個清單,可設定多個):

1
2
3
4
5
6
7
[[tool.uv.index]]
name = "my-company"
url = "https://pypi.company.com/simple"

[[tool.uv.index]]
name = "pypi"
url = "https://pypi.org/simple"

指定套件來源

使用 [tool.uv.sources] 強制特定套件從指定索引下載:

1
2
[tool.uv.sources]
my-secret-lib = { index = "my-company" }

注意:[tool.uv.sources] 左側的 key 必須與 [project]dependencies 中的套件名稱完全一致。

除了指定 index,[tool.uv.sources] 也支援其他來源類型:

1
2
3
4
5
6
[tool.uv.sources]
# 從 GitHub 特定分支安裝
cool-feature-lib = { git = "https://github.com/user/repo", branch = "main" }

# 從本地資料夾安裝
local-tool = { path = "../my-local-package" }

搜尋優先順序

uv 決定套件來源的優先順序如下:

  1. [tool.uv.sources] 明確指定(最優先):直接去指定的 index 下載,忽略其他設定。
  2. [[tool.uv.index]] 清單順序:若無明確指定,依清單順序逐一詢問各索引,找到即下載。
  3. 預設 PyPI(最後防線):若未設定任何 index,回落至官方 PyPI.org

憑證管理

重要:請勿將帳號密碼直接寫入 pyproject.toml,這會導致憑證被提交至 Git 並外洩。

推薦以下兩種安全方式管理私有索引的憑證:

方式一:使用 .netrc 檔案(推薦)

在使用者根目錄建立 ~/.netrc(Windows 為 %USERPROFILE%/_netrc):

1
2
3
machine pypi.company.com
login your_username
password your_secret_token

uv 會自動偵測並使用此檔案中的憑證。

方式二:使用環境變數

在執行 uv sync 前設定以下環境變數(MY_COMPANY 對應 [[tool.uv.index]] 中設定的 name,轉為全大寫):

1
2
3
export UV_INDEX_MY_COMPANY_USERNAME=your_username
export UV_INDEX_MY_COMPANY_PASSWORD=your_password
uv sync

防範依賴混淆攻擊(Dependency Confusion)

當私有套件與公開套件同名時,攻擊者可能在 PyPI 上發布同名的惡意套件。建議的防護策略:

  1. 統一命名前綴:公司內部套件一律加上前綴,例如 mycorp-utilsmycorp-api
  2. 私有倉庫優先:將公司索引放在 [[tool.uv.index]] 清單的最前面。
  3. 使用 Lock 檔案uv 會在首次安裝後產生 uv.lock,精確記錄每個套件的來源 index、版本號與雜湊值(Hash)。後續執行 uv sync 時直接參照此檔案,確保環境完全可重現、不受搜尋順序影響。

pytest 設定(tool.pytest.ini_options)

[tool.pytest.ini_options] 讓你將 pytest 的執行參數集中寫在 pyproject.toml,不需要在每次執行時手動附加參數。以下介紹幾個專業開發中最常用的設定。

控制測試輸出

當測試數量增多,可透過 addopts 設定預設的執行參數:

1
2
3
[tool.pytest.ini_options]
# -v: 顯示詳細測試清單;-ra: 結束後摘要所有失敗與跳過的項目
addopts = "-v -ra"

自訂測試檔案命名規則

pytest 預設只掃描 test_*.py*_test.py。若團隊有其他命名習慣,可透過 python_files 調整:

1
2
[tool.pytest.ini_options]
python_files = ["test_*.py", "check_*.py"]

過濾警告訊息

第三方套件有時會輸出大量 DeprecationWarning,掩蓋真正的錯誤。可透過 filterwarnings 過濾:

1
2
3
4
5
[tool.pytest.ini_options]
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::UserWarning",
]

測試分類標記(markers)

markers 讓你為測試加上自訂標籤,執行時可依標籤篩選:

1
2
3
pytest              # 執行所有測試
pytest -m smoke # 只執行標記為 smoke 的快速測試
pytest -m "not e2e" # 跳過所有 e2e 測試

pyproject.toml 中宣告所有自訂標記,避免 pytest 發出未知 marker 的警告:

1
2
3
4
5
[tool.pytest.ini_options]
markers = [
"e2e: end-to-end tests that require a live environment",
"smoke: quick sanity checks for core functionality",
]

設定預設不執行 E2E 測試

E2E 測試通常耗時較長,本地開發時不需要每次都跑。將排除規則寫入 addopts,讓 pytest 指令預設跳過 e2e

1
2
3
4
5
6
[tool.pytest.ini_options]
addopts = "-v -ra -m 'not e2e'"
markers = [
"e2e: end-to-end tests (skipped by default)",
"smoke: quick core tests",
]

若某次需要臨時執行所有測試(包含 E2E),可在指令列明確覆蓋:

1
pytest -m ""

指令列傳入的 -m 參數優先級高於 pyproject.tomladdopts

若 E2E 測試只應在 CI/CD 環境執行,搭配 pytest.mark.skipif 與環境變數是更明確的做法:

1
2
3
4
5
6
7
import os
import pytest

@pytest.mark.e2e
@pytest.mark.skipif(os.environ.get("RUN_E2E") != "1", reason="僅在 CI 環境執行")
def test_full_user_flow():
...
1
2
3
4
5
# 本地執行(跳過 e2e)
pytest

# CI 執行(包含 e2e)
RUN_E2E=1 pytest

環境變數注入(pytest-env)

需要在測試時注入固定的環境變數(如測試用資料庫連線),可安裝 pytest-env 並在 pyproject.toml 設定:

1
2
3
4
5
6
[tool.pytest.ini_options]
# 每個變數為 "KEY=VALUE" 字串格式
env = [
"APP_ENV=test",
"DATABASE_URL=sqlite:///:memory:",
]

注意pytest-env 的設定是靜態的,無法動態切換 .env 檔案(例如依環境讀取 .env.test.env.prod)。若有此需求,改用 pytest-dotenv 插件,它支援指定要載入的 .env 檔案路徑。

完整範例

以下是一份結合私有索引的完整 pyproject.toml 範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "my-app"
version = "0.1.0"
requires-python = ">=3.8"
dependencies = [
"requests", # 公開套件,從 PyPI 取得
"my-company-lib", # 私有套件,從公司索引取得
]

[dependency-groups]
dev = [
"pytest>=8.0.0",
"black>=24.0.0",
]

[[tool.uv.index]]
name = "my-company"
url = "https://pypi.company.com/simple"

[tool.uv.sources]
my-company-lib = { index = "my-company" }

[tool.pytest.ini_options]
# 自動設定測試環境
addopts = "-v -ra"
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

# 過濾掉無關緊要的警告
filterwarnings = ["ignore::DeprecationWarning"]

# 設定自訂標記,讓你可以用 pytest -m smoke 跑煙霧測試
markers = [
"smoke: 快速執行的核心功能測試",
"e2e: 耗時較長的端對端測試",
]