本章介紹維護 Python 套件的長期策略。

本章目標

學完本章後,你將能夠:

  1. 建立良好的專案結構
  2. 管理依賴與版本
  3. 制定棄用與升級策略

【設計層】專案結構

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 = false

Pre-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+g1234abc
1# 發布流程
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 版本
1112v2.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[![PyPI version](https://badge.fury.io/py/my-package.svg)](https://pypi.org/project/my-package/)
 4[![Python versions](https://img.shields.io/pypi/pyversions/my-package.svg)](https://pypi.org/project/my-package/)
 5[![License](https://img.shields.io/pypi/l/my-package.svg)](https://github.com/user/my-package/blob/main/LICENSE)
 6[![CI](https://github.com/user/my-package/actions/workflows/ci.yml/badge.svg)](https://github.com/user/my-package/actions)
 7[![codecov](https://codecov.io/gh/user/my-package/branch/main/graph/badge.svg)](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- 貢獻指南

思考題

  1. 函式庫和應用程式的依賴管理策略為什麼不同?各有什麼優缺點?
  2. 如何平衡「保持向後相容」和「改進 API 設計」的矛盾?
  3. 開源專案的維護者應該如何處理安全性漏洞的披露?

實作練習

  1. 為一個現有專案加入 pre-commit 設定,包含 ruff 和 mypy
  2. 實作一個 @deprecated 裝飾器,並寫測試驗證警告訊息
  3. 為一個開源專案撰寫完整的 CONTRIBUTING.md

延伸閱讀


上一章:發布到 PyPI 返回:模組六首頁