一. 前言:套件不是寫完就算了 #
很多 Python 小工具一開始都很天真。
先放在某個 repo 裡。
需要的時候就 git clone。
同事要用,就丟一段 README。
別的專案想 import,就開始複製貼上。
然後半年後你會發現:
到底哪個版本才是正式版?
為什麼 staging 裝到舊版?
為什麼同一個 package 在三台機器行為不一樣?
拍拍君今天要處理的是這個很務實的問題:
用 uv 把 Python 套件建構成 wheel,再發佈到公開或私有 package index。
這篇不是 Python packaging 的總複習。
如果你還沒看過一般打包概念,可以先補這篇:
- Python Packaging:從 pyproject.toml 到發佈 PyPI 今天的重點比較窄:
- 用
uv init --package建立可建構的 package - 寫一份剛好夠用的
pyproject.toml - 用
uv build產生 sdist 與 wheel - 在本機檢查 wheel 真的能安裝
- 用
uv publish發到 TestPyPI / PyPI - 把 private package index 的 URL、token、CI 流程整理乾淨 如果你寫的是公司內部 SDK、資料處理共用 library、CLI 小工具、或多個服務共用的型別定義,這篇會比「把檔案複製到每個專案」健康很多。
二. 先分清楚:app、library、package #
uv 可以建立不同型態的專案。 你平常寫一個應用程式,可能會這樣:
uv init my-app
cd my-app
uv add httpx rich
uv run python main.py
這很好。 但如果你的目標是「讓別人 install 後 import」,就要把它當 package 設計。 最簡單的起手式是:
uv init --package pypy-greeter
cd pypy-greeter
--package 的意思不是立刻發佈。
它只是幫你建立一個比較適合 build 的專案骨架。
常見情境可以這樣判斷:
只在這個 repo 執行 main.py -> app
要被其他專案 import -> library/package
要提供命令列工具 -> package + console script
公司內部多個服務共用 -> private package
要給全世界 pip install -> public PyPI package
拍拍君的偏好是: 當程式會被兩個以上專案共用,就早點 package 化。 不需要一開始就上 PyPI。 但至少要能 build、install、test。
三. 建立範例專案 #
今天用一個很小的 package:
pypy-greeter。
它做的事情很單純:
傳入名字,回傳一句問候。
單純不是壞事。
package workflow 的難點不在業務邏輯,而在邊界、版本、建構與發佈流程。
建立專案:
uv init --package pypy-greeter
cd pypy-greeter
你會看到類似這樣的結構:
pypy-greeter/
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── pypy_greeter/
└── __init__.py
如果你建立出來的結構略有不同,不用緊張。 uv 版本會演進。 重點是你要有:
pyproject.toml- package source directory
- 一個清楚的 package name
- 能建構出
dist/*.whl接著加入測試工具:
uv add --dev pytest
開發依賴應該放在 dev group。 使用者 install 你的 package 時,不需要把 pytest 一起裝進去。
四. 寫 package 程式碼 #
修改 src/pypy_greeter/__init__.py:
from .core import Greeting, greet
__all__ = ["Greeting", "greet"]
新增 src/pypy_greeter/core.py:
from dataclasses import dataclass
@dataclass(frozen=True)
class Greeting:
name: str
message: str
def greet(name: str) -> Greeting:
clean_name = name.strip()
if not clean_name:
raise ValueError("name must not be empty")
return Greeting(
name=clean_name,
message=f"拍拍君說:{clean_name},今天也穩穩寫程式。",
)
再新增 tests/test_core.py:
import pytest
from pypy_greeter import greet
def test_greet_returns_message() -> None:
result = greet("拍拍醬")
assert result.name == "拍拍醬"
assert "拍拍醬" in result.message
def test_greet_rejects_blank_name() -> None:
with pytest.raises(ValueError):
greet(" ")
跑測試:
uv run pytest
這一步很重要。 你不是先 build 再祈禱。 你要先確認 package 在開發環境裡能被 import,基本行為也沒破。
五. 看懂 pyproject.toml 的最小核心 #
打開 pyproject.toml,你會看到類似內容:
[project]
name = "pypy-greeter"
version = "0.1.0"
description = "A tiny greeting package"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[build-system]
requires = ["uv_build>=0.10.0,<0.11.0"]
build-backend = "uv_build"
不同 uv 版本或 build backend 可能長得不完全一樣。
這裡先抓幾個重點:
[project] 是公開 metadata。
使用者、index、installer 都會看到這些資訊。
name 是發佈出去的套件名稱。
version 是每次發佈最敏感的欄位。
dependencies 是安裝 package 時會一起安裝的 runtime 依賴。
[build-system] 則是告訴工具:
要用哪個 backend 建構這個 package。
你可以用 uv backend,也可以用 hatchling、flit、setuptools 等。
如果你只是純 Python 小 package,uv 產生的預設通常就夠用。
package name 與 import name 不一定相同 #
這件事很容易踩雷。 發佈名稱可以是:
pypy-greeter
Python import 名稱通常會是:
import pypy_greeter
hyphen 不能出現在 Python module name 裡,所以常常會變 underscore。
README 裡要寫清楚。
不然使用者會 import pypy-greeter,然後對著 syntax error 沉默三秒。
六. 加一個 CLI 入口點 #
很多內部 package 不只被 import,也會提供命令列工具。
新增 src/pypy_greeter/cli.py:
import argparse
from .core import greet
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("name")
args = parser.parse_args()
result = greet(args.name)
print(result.message)
在 pyproject.toml 補上:
[project.scripts]
pypy-greet = "pypy_greeter.cli:main"
測試 CLI:
uv run pypy-greet 拍拍醬
如果出現:
拍拍君說:拍拍醬,今天也穩穩寫程式。
就代表 entry point 接起來了。
這個指令未來會被安裝到環境的 executable path。
使用者不用知道你的 cli.py 在哪。
這就是 package 比「叫大家 python path/to/file.py」可靠的地方。
七. 用 uv build 產生 artifact #
現在進入主題。 建構 package:
uv build
完成後會產生:
dist/
├── pypy_greeter-0.1.0-py3-none-any.whl
└── pypy_greeter-0.1.0.tar.gz
這兩個東西分別是:
wheel:安裝時最快、最常被實際使用的二進位發行格式sdist:source distribution,保留原始碼與建構資訊 純 Python package 的 wheel 通常會看到py3-none-any。 意思是它不綁特定 Python ABI,也不綁特定作業系統。 如果你有 C extension、Rust extension、平台特定依賴,檔名就會複雜很多。 要重新 build 並清掉舊檔,可以用:
uv build --clear
拍拍君建議 release 前都用 --clear。
不然 dist/ 裡殘留舊版本,publish 時很容易把不該上傳的檔案也帶上去。
八. 不要跳過 artifact 檢查 #
很多人 publish 失敗,不是因為 build 壞掉。 是因為 build 出來的東西不是自己以為的東西。 先看 wheel 裡面到底有什麼:
python -m zipfile --list dist/*.whl
你應該看到 package 檔案:
pypy_greeter/__init__.py
pypy_greeter/core.py
pypy_greeter/cli.py
pypy_greeter-0.1.0.dist-info/METADATA
pypy_greeter-0.1.0.dist-info/entry_points.txt
如果你沒看到 core.py,通常是 package discovery 或 layout 有問題。
如果你沒看到 entry_points.txt,通常是 [project.scripts] 沒寫對。
也可以在乾淨環境安裝 wheel:
python -m venv /tmp/pypy-greeter-test
source /tmp/pypy-greeter-test/bin/activate
python -m pip install dist/*.whl
python -c "from pypy_greeter import greet; print(greet('拍拍醬').message)"
pypy-greet 拍拍醬
deactivate
這一步很土。 但非常有效。 因為它會抓出「在 repo 裡可以 import,但裝成 package 後不能 import」的問題。 尤其 src layout 的價值就在這裡。
九. README 和 metadata 會影響使用體驗 #
Package index 不是只有檔案倉庫。 它也是文件入口。 至少把 README 寫到能讓人三分鐘內開始:
# pypy-greeter
Tiny greeting package used in Daily Pypy examples.
## Install
```bash
pip install pypy-greeter
Usage #
from pypy_greeter import greet
print(greet("拍拍醬").message)
然後在 `pyproject.toml` 補一些 metadata:
```toml
[project]
name = "pypy-greeter"
version = "0.1.0"
description = "A tiny greeting package"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [
{ name = "Daily Pypy" }
]
keywords = ["example", "cli", "greeting"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = []
內部 package 也值得寫 README。 因為「只有公司內部使用」不代表未來的你記得怎麼用。 未來的你也是使用者。 而且通常比陌生使用者更沒耐心。
十. 先發 TestPyPI,不要直接衝 PyPI #
公開 package 建議先發 TestPyPI。 先建立 TestPyPI token,然後:
uv publish \
--publish-url https://test.pypi.org/legacy/ \
--check-url https://test.pypi.org/simple/ \
--token "$TEST_PYPI_TOKEN"
--publish-url 是 upload endpoint。
--check-url 是檢查套件是否已存在的 simple index。
這兩個 URL 不是同一個東西。
搞混時,你會得到很不像人話的錯誤訊息。
發完之後,開一個乾淨環境測試安裝:
python -m venv /tmp/test-pypi-install
source /tmp/test-pypi-install/bin/activate
python -m pip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
pypy-greeter
python -c "from pypy_greeter import greet; print(greet('拍拍醬').message)"
deactivate
為什麼要加 --extra-index-url https://pypi.org/simple/?
因為 TestPyPI 不一定有你的所有依賴。
你的 package 在 TestPyPI,但 dependencies 可能還是要從正式 PyPI 裝。
十一. 發正式 PyPI #
正式發佈前,拍拍君會檢查這份清單:
uv run pytest通過uv build --clear通過- wheel 裡有正確 package 檔案
- README 可以被 index 顯示
- version 沒有發過
- token 權限正確 正式發佈:
uv publish --token "$PYPI_TOKEN"
如果你不指定檔案,uv publish 預設會上傳 dist/*。
所以前面才說 release 前要清掉舊 artifact。
也可以明確指定:
uv publish dist/pypy_greeter-0.1.0*
發佈後測試:
python -m venv /tmp/pypi-install
source /tmp/pypi-install/bin/activate
python -m pip install pypy-greeter
pypy-greet 拍拍醬
deactivate
真正完成不是「publish 指令結束」。 真正完成是「一個乾淨環境可以安裝並使用」。
十二. private package workflow:不要把 token 寫進 pyproject #
公司內部 package 通常不會放公開 PyPI。 你可能會用:
- GitHub Packages
- GitLab Package Registry
- Artifactory
- Nexus
- devpi
- 其他私有 Python index 原則很簡單: package metadata 可以 commit,credential 不要 commit。 假設你的私有 upload endpoint 是:
https://packages.example.com/pypi/upload/
simple index 是:
https://packages.example.com/pypi/simple/
可以這樣發佈:
uv publish \
--publish-url https://packages.example.com/pypi/upload/ \
--check-url https://packages.example.com/pypi/simple/ \
--username "$PACKAGE_USERNAME" \
--password "$PACKAGE_PASSWORD"
或用 token:
uv publish \
--publish-url https://packages.example.com/pypi/upload/ \
--check-url https://packages.example.com/pypi/simple/ \
--token "$PACKAGE_TOKEN"
安裝端則使用 index 設定:
uv pip install \
--default-index https://packages.example.com/pypi/simple/ \
pypy-greeter
如果內部 package 會依賴公開 PyPI 套件,可以加公開 index。 但要注意 index 策略。
uv pip install \
--default-index https://packages.example.com/pypi/simple/ \
--index https://pypi.org/simple \
pypy-greeter
安全要求較高的公司會避免 dependency confusion。 也就是外部 PyPI 上有人註冊同名 package,讓解析器裝到錯的來源。 uv 的多 index 行為有策略選項。 內部 package 最保守的做法是:
- 私有 package 名稱加公司 prefix
- private index 優先
- CI 裡固定 index 設定
- 不把私有套件名稱暴露到公開 issue/log 拍拍君不會說每個團隊都要做到金融業等級。 但 token 不進 repo,這個底線不要破。
十三. 用 uv 設定 publish index 名稱 #
如果你常常要發到同一個 private index,不想每次都打一長串 URL。
可以在 uv 設定裡定義 index。
例如 pyproject.toml:
[[tool.uv.index]]
name = "internal"
url = "https://packages.example.com/pypi/simple/"
publish-url = "https://packages.example.com/pypi/upload/"
然後發佈:
uv publish --index internal --token "$PACKAGE_TOKEN"
這種寫法的好處是:
- URL 由專案管理
- credential 由環境變數管理
- CI 指令比較短
- README 比較不容易寫錯
要注意的是,
url是 install/resolve 用的 simple index。publish-url是 upload endpoint。 如果你的 registry 文件把它們寫成兩個不同欄位,照文件填,不要靠猜。
十四. 版本號:發佈後不能假裝沒發生 #
PyPI 和多數 package registry 都不允許覆蓋同版本檔案。 這是好事。 同一個版本應該永遠代表同一份 artifact。 所以你修了 bug,就要升版:
[project]
version = "0.1.1"
常見規則是 SemVer:
0.1.0 -> 0.1.1 修 bug
0.1.0 -> 0.2.0 加功能
0.1.0 -> 1.0.0 API 穩定
但 Python package 世界沒有魔法保證。 真正重要的是團隊約定清楚。 拍拍君建議在 README 或 release note 寫清楚:
- 哪些 API 是穩定的
- 哪些只是內部 helper
- 破壞性變更怎麼升版
- 舊版本支援多久 Private package 尤其需要這件事。 因為內部套件常常被很多服務偷用。 一個「只是改一下函式參數」可以讓星期五下午變得很刺激。
十五. CI 發佈流程範例 #
本機 publish 可以練習。 正式流程最好交給 CI。 下面是一個簡化版 GitHub Actions 範例:
name: release
on:
push:
tags:
- "v*"
jobs:
build-publish:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python
run: uv python install
- name: Install dependencies
run: uv sync --dev
- name: Test
run: uv run pytest
- name: Build
run: uv build --clear
- name: Publish
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: uv publish
如果是 private index:
- name: Publish internal package
env:
UV_PUBLISH_TOKEN: ${{ secrets.INTERNAL_PACKAGE_TOKEN }}
run: uv publish --index internal
這裡把 token 放在 secrets。
不要寫在 workflow 檔案。
不要寫在 pyproject.toml。
不要寫在 README 的範例裡。
拍拍君知道你只是「暫時」。
但暫時的 credential 通常活得很久。
十六. tag release 前的本機檢查 #
CI 很重要。
但發 tag 前,本機也可以跑一個簡單 checklist。
例如寫成 scripts/release-check.sh:
#!/usr/bin/env bash
set -euo pipefail
uv run pytest
uv build --clear
python -m zipfile --list dist/*.whl
tmpdir="$(mktemp -d)"
python -m venv "$tmpdir/venv"
"$tmpdir/venv/bin/python" -m pip install dist/*.whl
"$tmpdir/venv/bin/python" -c "from pypy_greeter import greet; print(greet('拍拍醬').message)"
rm -rf "$tmpdir"
執行:
bash scripts/release-check.sh
你也可以用 uv run 裡的 Python 做檢查。
重點不是工具多漂亮。
重點是把 release 前容易忘的步驟固定下來。
固定流程比記憶可靠。
拍拍君的記憶也不是拿來背 release checklist 的。
十七. 常見錯誤 #
1. version 沒改 #
錯誤訊息通常會說檔案已存在。 解法: 升版,重新 build,再 publish。 不要試圖覆蓋。
2. wheel 裡沒有 package #
通常是 layout 或 build backend 設定問題。 先用:
python -m zipfile --list dist/*.whl
確認檔案真的進去了。
3. README 在 index 上壞掉 #
Markdown 語法、圖片路徑、相對連結都可能出問題。 Public package 可以用 TestPyPI 先看。 Private package 也應該至少打開 registry 頁面確認。
4. private index install 失敗 #
先分清楚是 upload endpoint 還是 simple index。 再檢查 credential 是 publish 權限還是 read 權限。 很多 registry 會分開。
5. CI 裡找不到 uv 建的環境 #
CI 裡不要假設 .venv 已存在。
明確跑:
uv sync --dev
uv run pytest
如果是 workspace,要用對 --package 或工作目錄。
十八. 一個實用 release 節奏 #
拍拍君自己會把 release 拆成這樣:
1. 寫功能
2. 加測試
3. 更新 README
4. 升 version
5. uv run pytest
6. uv build --clear
7. 本機安裝 wheel 測試
8. 發 tag
9. CI publish
10. 乾淨環境安裝確認
這看起來比「直接 publish」多很多步。 但多的是預防踩雷,不是儀式。 尤其 private package 一旦被多個服務吃下去,壞版會傳很快。 你不希望 release 流程靠手感。 你希望它無聊。 越無聊越好。
十九. uv build/publish 小抄 #
建立 package:
uv init --package pypy-greeter
加入開發依賴:
uv add --dev pytest
跑測試:
uv run pytest
建構:
uv build --clear
只建 wheel:
uv build --wheel --clear
只建 sdist:
uv build --sdist --clear
檢查 wheel:
python -m zipfile --list dist/*.whl
發 TestPyPI:
uv publish \
--publish-url https://test.pypi.org/legacy/ \
--check-url https://test.pypi.org/simple/ \
--token "$TEST_PYPI_TOKEN"
發正式 PyPI:
uv publish --token "$PYPI_TOKEN"
發 private index:
uv publish --index internal --token "$PACKAGE_TOKEN"
結語 #
uv 最迷人的地方不是只有快。
而是它把 Python 專案從建立、同步、測試、建構、發佈串成比較一致的體驗。
對 package 來說,這代表你可以少記幾個工具,多專心在 release 流程本身:
artifact 是否正確?
版本是否清楚?
token 是否安全?
乾淨環境能不能安裝?
這些問題比「今天到底用哪個命令建 wheel」更重要。
但如果命令也變簡單,那當然更好。
把共用程式整理成 package,不是為了炫技。
是為了讓下一個專案、下一個同事、下一個半夜醒來的你,都能少一點混亂。
拍拍君喜歡這種無聊的可靠。
很工程師,也很可愛。