6.4 套件維護最佳實踐
6.4 套件維護最佳實踐
本章介紹維護 Python 套件的長期策略。
本章目標
學完本章後,你將能夠:
- 建立良好的專案結構
- 管理依賴與版本
- 制定棄用與升級策略
【設計層】專案結構
Src Layout vs Flat Layout
1Flat Layout:
2my-package/
3├── pyproject.toml
4├── README.md
5├── my_package/
6│ ├── __init__.py
7│ └── module.py
8└── tests/
9 └── test_module.py
10
11Src Layout(推薦):
12my-package/
13├── pyproject.toml
14├── README.md
15├── src/
16│ └── my_package/
17│ ├── __init__.py
18│ └── module.py
19└── tests/
20 └── test_module.py為什麼推薦 Src Layout?
1Flat Layout 的問題:
2├── 可能意外匯入本地未安裝的套件
3├── 測試可能使用開發版而非安裝版
4└── 容易混淆專案根目錄和套件目錄
5
6Src Layout 的優點:
7├── 強制測試已安裝的套件
8├── 清楚區分原始碼和設定檔
9├── 避免命名衝突
10└── 更接近使用者的安裝環境完整專案結構範例
1my-package/
2├── .github/
3│ └── workflows/
4│ ├── ci.yml # 測試與檢查
5│ └── publish.yml # 發布流程
6├── docs/
7│ ├── conf.py
8│ ├── index.rst
9│ └── api/
10├── src/
11│ └── my_package/
12│ ├── __init__.py
13│ ├── py.typed # 標記為有型別提示
14│ ├── core.py
15│ └── utils.py
16├── tests/
17│ ├── __init__.py
18│ ├── conftest.py # pytest fixtures
19│ ├── test_core.py
20│ └── test_utils.py
21├── .gitignore
22├── .pre-commit-config.yaml # pre-commit 設定
23├── CHANGELOG.md
24├── LICENSE
25├── README.md
26└── pyproject.toml【實作層】依賴管理
依賴版本約束策略
1版本約束類型:
2
3精確版本(不推薦用於函式庫):
4└── requests==2.31.0
5
6最小版本(推薦):
7└── requests>=2.28
8
9相容版本(~= 運算子):
10└── requests~=2.28.0 # 等同於 >=2.28.0,<2.29.0
11
12上限版本(謹慎使用):
13└── requests>=2.28,<3.0函式庫 vs 應用程式的依賴策略
1函式庫(被其他專案依賴):
2├── 使用寬鬆的版本約束
3├── 只指定最小版本
4├── 不鎖定間接依賴
5└── 讓使用者決定具體版本
6
7dependencies = [
8 "requests>=2.28",
9 "click>=8.0",
10]
11
12應用程式(直接部署):
13├── 使用精確的版本鎖定
14├── 鎖定所有間接依賴
15├── 使用 lock 檔案
16└── 確保可重現的環境
17
18# 使用 Poetry/PDM 的 lock 檔案
19# 或 pip-tools 的 requirements.txt可選依賴(Optional Dependencies)
1# pyproject.toml
2[project.optional-dependencies]
3# 開發依賴
4dev = [
5 "pytest>=7.0",
6 "pytest-cov",
7 "ruff",
8 "mypy",
9]
10
11# 文件依賴
12docs = [
13 "sphinx>=6.0",
14 "sphinx-rtd-theme",
15 "myst-parser",
16]
17
18# 特定功能
19async = ["aiohttp>=3.8"]
20cli = ["click>=8.0", "rich"]
21
22# 全部安裝
23all = [
24 "my-package[async,cli]",
25]依賴更新策略
1定期更新流程:
2
31. 檢查過期依賴
4 pip list --outdated
5 # 或使用工具
6 pip-audit # 安全性檢查
7
82. 更新依賴
9 # Poetry
10 poetry update
11 poetry update requests # 更新特定套件
12
13 # PDM
14 pdm update
15
163. 執行測試
17 pytest
18
194. 審查變更
20 git diff pyproject.toml poetry.lock
21
225. 提交更新
23 git commit -m "chore: update dependencies"【實作層】品質保證
測試策略
1# pyproject.toml
2[tool.pytest.ini_options]
3testpaths = ["tests"]
4python_files = ["test_*.py"]
5python_functions = ["test_*"]
6addopts = [
7 "-v",
8 "--strict-markers",
9 "--cov=my_package",
10 "--cov-report=term-missing",
11 "--cov-report=html",
12]
13markers = [
14 "slow: marks tests as slow",
15 "integration: marks integration tests",
16]
17
18[tool.coverage.run]
19source = ["src/my_package"]
20branch = true
21parallel = true
22
23[tool.coverage.report]
24exclude_lines = [
25 "pragma: no cover",
26 "if TYPE_CHECKING:",
27 "raise NotImplementedError",
28 "@overload",
29]
30fail_under = 80程式碼品質工具
1# pyproject.toml
2
3# Ruff(linter + formatter)
4[tool.ruff]
5line-length = 88
6target-version = "py38"
7
8[tool.ruff.lint]
9select = [
10 "E", # pycodestyle errors
11 "W", # pycodestyle warnings
12 "F", # pyflakes
13 "I", # isort
14 "UP", # pyupgrade
15 "B", # flake8-bugbear
16 "SIM", # flake8-simplify
17 "RUF", # Ruff-specific rules
18]
19ignore = ["E501"] # line too long(由 formatter 處理)
20
21[tool.ruff.lint.isort]
22known-first-party = ["my_package"]
23
24# Mypy(型別檢查)
25[tool.mypy]
26python_version = "3.8"
27strict = true
28warn_return_any = true
29warn_unused_ignores = true
30show_error_codes = true
31
32[[tool.mypy.overrides]]
33module = ["tests.*"]
34disallow_untyped_defs = falsePre-commit 設定
1# .pre-commit-config.yaml
2repos:
3 - repo: https://github.com/pre-commit/pre-commit-hooks
4 rev: v4.5.0
5 hooks:
6 - id: trailing-whitespace
7 - id: end-of-file-fixer
8 - id: check-yaml
9 - id: check-toml
10 - id: check-added-large-files
11
12 - repo: https://github.com/astral-sh/ruff-pre-commit
13 rev: v0.3.0
14 hooks:
15 - id: ruff
16 args: [--fix]
17 - id: ruff-format
18
19 - repo: https://github.com/pre-commit/mirrors-mypy
20 rev: v1.8.0
21 hooks:
22 - id: mypy
23 additional_dependencies: [types-requests]1# 安裝 pre-commit
2pip install pre-commit
3pre-commit install
4
5# 手動執行
6pre-commit run --all-files【實作層】版本與變更管理
語義化版本實踐
1MAJOR.MINOR.PATCH
2
3何時增加 MAJOR:
4├── 移除已棄用的 API
5├── 更改現有 API 的行為
6├── 更改函式簽名(必要參數)
7└── 不再支援舊版 Python
8
9何時增加 MINOR:
10├── 新增功能
11├── 新增可選參數
12├── 棄用現有 API(但不移除)
13└── 效能改進
14
15何時增加 PATCH:
16├── 修復 bug
17├── 安全性修補
18├── 文件修正
19└── 內部重構(不影響 API)CHANGELOG 格式
1# Changelog
2
3本專案遵循 [Keep a Changelog](https://keepachangelog.com/) 格式。
4
5## [Unreleased]
6
7### Added
8- 新功能描述
9
10### Changed
11- 變更描述
12
13### Deprecated
14- 棄用描述
15
16### Removed
17- 移除描述
18
19### Fixed
20- 修復描述
21
22### Security
23- 安全性修補描述
24
25## [1.2.0] - 2025-01-15
26
27### Added
28- 新增 `process_async` 函式支援非同步處理 (#123)
29- 新增 `Config` 類別用於設定管理
30
31### Changed
32- `process` 函式現在預設啟用快取
33
34### Deprecated
35- `old_function` 將在 2.0.0 移除,請使用 `new_function`
36
37### Fixed
38- 修復在 Windows 上的路徑處理問題 (#456)
39
40## [1.1.0] - 2025-01-01
41...
42
43[Unreleased]: https://github.com/user/project/compare/v1.2.0...HEAD
44[1.2.0]: https://github.com/user/project/compare/v1.1.0...v1.2.0
45[1.1.0]: https://github.com/user/project/releases/tag/v1.1.0自動化版本管理
1# 使用 setuptools-scm
2[build-system]
3requires = ["setuptools>=61.0", "setuptools-scm>=8.0"]
4build-backend = "setuptools.build_meta"
5
6[project]
7name = "my-package"
8dynamic = ["version"]
9
10[tool.setuptools_scm]
11# 從 git tag 自動產生版本
12# v1.0.0 → 1.0.0
13# v1.0.0-5-g1234abc → 1.0.0.dev5+g1234abc1# 發布流程
2git tag v1.2.0
3git push --tags
4
5# CI 自動建構並發布【實作層】棄用策略
棄用流程
1棄用時間線(範例):
2
3v1.0: 引入 old_function
4 │
5v1.5: 引入 new_function
6 標記 old_function 為棄用
7 │
8v1.6: old_function 發出棄用警告
9v1.7: │
10v1.8: │ 至少保留 2-3 個 minor 版本
11 │
12v2.0: 移除 old_function實現棄用警告
1# my_package/deprecated.py
2import warnings
3from functools import wraps
4from typing import Callable, TypeVar
5
6F = TypeVar("F", bound=Callable)
7
8def deprecated(
9 reason: str,
10 version: str,
11 replacement: str | None = None,
12) -> Callable[[F], F]:
13 """標記函式為棄用。
14
15 Args:
16 reason: 棄用原因
17 version: 將移除的版本
18 replacement: 替代方案
19
20 Example:
21 @deprecated(
22 reason="使用新的 API",
23 version="2.0.0",
24 replacement="new_function",
25 )
26 def old_function():
27 pass
28 """
29
30 def decorator(func: F) -> F:
31 message = f"{func.__name__} 已棄用,將在 {version} 移除。{reason}"
32 if replacement:
33 message += f" 請使用 {replacement} 替代。"
34
35 @wraps(func)
36 def wrapper(*args, **kwargs):
37 warnings.warn(
38 message,
39 DeprecationWarning,
40 stacklevel=2,
41 )
42 return func(*args, **kwargs)
43
44 # 更新 docstring
45 wrapper.__doc__ = f".. deprecated:: {version}\n {message}\n\n{func.__doc__ or ''}"
46
47 return wrapper # type: ignore
48
49 return decorator
50
51# 使用範例
52@deprecated(
53 reason="效能問題",
54 version="2.0.0",
55 replacement="process_v2",
56)
57def process_v1(data: list) -> list:
58 """處理資料(舊版)。"""
59 return [x * 2 for x in data]
60
61def process_v2(data: list) -> list:
62 """處理資料(新版,更高效)。"""
63 # 新的實現
64 return [x * 2 for x in data]棄用類別屬性
1import warnings
2
3class Config:
4 def __init__(self):
5 self._new_setting = "default"
6
7 @property
8 def old_setting(self) -> str:
9 """已棄用,請使用 new_setting。"""
10 warnings.warn(
11 "old_setting 已棄用,將在 2.0.0 移除。請使用 new_setting。",
12 DeprecationWarning,
13 stacklevel=2,
14 )
15 return self._new_setting
16
17 @old_setting.setter
18 def old_setting(self, value: str) -> None:
19 warnings.warn(
20 "old_setting 已棄用,將在 2.0.0 移除。請使用 new_setting。",
21 DeprecationWarning,
22 stacklevel=2,
23 )
24 self._new_setting = value
25
26 @property
27 def new_setting(self) -> str:
28 """新的設定屬性。"""
29 return self._new_setting
30
31 @new_setting.setter
32 def new_setting(self, value: str) -> None:
33 self._new_setting = value【實作層】向後相容性
API 穩定性承諾
1API 穩定性等級:
2
3Public API(穩定):
4├── 文件記載的函式和類別
5├── __all__ 中導出的名稱
6└── 遵循語義化版本
7
8Internal API(不穩定):
9├── 以 _ 開頭的名稱
10├── 未在文件中記載
11└── 可能在任何版本變更
12
13Experimental API:
14├── 明確標記為實驗性
15├── 可能在 minor 版本變更
16└── 不保證向後相容維護向後相容的技巧
1# 1. 新增可選參數時使用預設值
2def process(data, *, new_option=None): # 新增 new_option
3 if new_option is not None:
4 # 新行為
5 pass
6 # 舊行為保持不變
7
8# 2. 使用 **kwargs 保持彈性
9def create_client(host, port, **kwargs):
10 # 未來可以新增參數而不破壞現有程式碼
11 timeout = kwargs.get("timeout", 30)
12 # ...
13
14# 3. 提供相容層
15def old_api(*args, **kwargs):
16 """已棄用,請使用 new_api。"""
17 warnings.warn("...", DeprecationWarning, stacklevel=2)
18 # 轉換參數格式
19 return new_api(*args, **kwargs)
20
21# 4. 版本檢查
22import sys
23
24if sys.version_info >= (3, 10):
25 from typing import TypeAlias
26else:
27 from typing_extensions import TypeAlias【實作層】文件與社群
文件結構
1docs/
2├── index.rst # 首頁
3├── installation.rst # 安裝指南
4├── quickstart.rst # 快速開始
5├── tutorial/ # 教學
6│ ├── basics.rst
7│ └── advanced.rst
8├── api/ # API 參考
9│ ├── index.rst
10│ └── modules.rst
11├── changelog.rst # 變更日誌
12└── contributing.rst # 貢獻指南README 模板
1# My Package
2
3[](https://pypi.org/project/my-package/)
4[](https://pypi.org/project/my-package/)
5[](https://github.com/user/my-package/blob/main/LICENSE)
6[](https://github.com/user/my-package/actions)
7[](https://codecov.io/gh/user/my-package)
8
9簡短描述這個套件做什麼。
10
11## 特點
12
13- 功能 1
14- 功能 2
15- 功能 3
16
17## 安裝
18
19\`\`\`bash
20pip install my-package
21\`\`\`
22
23## 快速開始
24
25\`\`\`python
26from my_package import something
27
28result = something.do_thing()
29\`\`\`
30
31## 文件
32
33完整文件請見:https://my-package.readthedocs.io/
34
35## 貢獻
36
37歡迎貢獻!請見 [CONTRIBUTING.md](/python-advanced/07-packaging/best-practices/CONTRIBUTING.md)。
38
39## 授權
40
41MIT License - 詳見 [LICENSE](/python-advanced/07-packaging/best-practices/LICENSE)Issue 與 PR 模板
1<!-- .github/ISSUE_TEMPLATE/bug_report.md -->
2---
3name: Bug 回報
4about: 回報問題以協助改進
5---
6
7**描述問題**
8清楚簡潔地描述問題。
9
10**重現步驟**
111. ...
122. ...
13
14**預期行為**
15描述你期望的行為。
16
17**環境**
18- OS: [e.g., macOS 14.0]
19- Python: [e.g., 3.11.0]
20- Package version: [e.g., 1.2.0]
21
22**額外資訊**
23任何其他相關資訊。【進階】維護者工作流程
發布檢查清單
1發布前檢查:
2
3□ 所有測試通過
4□ 程式碼覆蓋率達標
5□ 型別檢查通過
6□ 文件已更新
7□ CHANGELOG 已更新
8□ 版本號已更新
9□ 無安全性漏洞(pip-audit)
10
11發布步驟:
12
131. 更新 CHANGELOG
14 - 將 [Unreleased] 內容移到新版本
15 - 新增發布日期
16
172. 建立發布 commit
18 git add CHANGELOG.md
19 git commit -m "chore: prepare release v1.2.0"
20
213. 建立 tag
22 git tag v1.2.0
23 git push origin main --tags
24
254. CI 自動發布到 PyPI
26
275. 建立 GitHub Release
28 - 使用 CHANGELOG 內容
29 - 附上二進位檔案(如適用)
30
316. 宣布發布
32 - 更新文件網站
33 - 社群媒體/郵件列表安全性維護
1# 檢查已知漏洞
2pip install pip-audit
3pip-audit
4
5# 檢查依賴更新
6pip list --outdated
7
8# 使用 Dependabot(GitHub)
9# .github/dependabot.yml 1# .github/dependabot.yml
2version: 2
3updates:
4 - package-ecosystem: "pip"
5 directory: "/"
6 schedule:
7 interval: "weekly"
8 commit-message:
9 prefix: "chore(deps)"
10 labels:
11 - "dependencies"
12
13 - package-ecosystem: "github-actions"
14 directory: "/"
15 schedule:
16 interval: "weekly"總結
最佳實踐清單
1專案結構:
2- 使用 src layout
3- 清楚的目錄組織
4- 包含 py.typed 標記
5
6依賴管理:
7- 函式庫使用寬鬆版本
8- 應用程式使用 lock 檔案
9- 定期更新依賴
10
11品質保證:
12- 完整的測試覆蓋
13- 型別提示和 mypy
14- 使用 pre-commit
15
16版本管理:
17- 遵循語義化版本
18- 維護 CHANGELOG
19- 自動化版本號
20
21向後相容:
22- 清楚的棄用流程
23- 至少 2-3 個版本的過渡期
24- 明確的 API 穩定性承諾
25
26文件與社群:
27- 完整的 README
28- API 文件
29- 貢獻指南思考題
- 函式庫和應用程式的依賴管理策略為什麼不同?各有什麼優缺點?
- 如何平衡「保持向後相容」和「改進 API 設計」的矛盾?
- 開源專案的維護者應該如何處理安全性漏洞的披露?
實作練習
- 為一個現有專案加入 pre-commit 設定,包含 ruff 和 mypy
- 實作一個
@deprecated裝飾器,並寫測試驗證警告訊息 - 為一個開源專案撰寫完整的 CONTRIBUTING.md