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

Python uv build/publish 實戰:從 wheel 到 private package workflow

·11 分鐘· loading · loading · ·
Python Uv Packaging Wheel PyPI Private-Package Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 79: 本文

featured

一. 前言:套件不是寫完就算了
#

很多 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,不是為了炫技。 是為了讓下一個專案、下一個同事、下一個半夜醒來的你,都能少一點混亂。 拍拍君喜歡這種無聊的可靠。 很工程師,也很可愛。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 79: 本文

相關文章

Python uv scripts 實戰:PEP 723、inline dependencies 與單檔工具
·6 分鐘· loading · loading
Python Uv PEP 723 Script Developer-Tools Automation
Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台
·9 分鐘· loading · loading
Python Streamlit SQLModel SQLite CRUD Developer-Tools
Python socket 實戰:TCP client/server、timeout 與簡易通訊協定
·8 分鐘· loading · loading
Python Socket TCP Networking Standard-Library Developer-Tools
Python shutil 實戰:檔案複製、搬移、壓縮與安全清理
·7 分鐘· loading · loading
Python Shutil Filesystem Automation Standard-Library Developer-Tools
Python inspect 實戰:看懂函式簽名、物件結構與開發工具自動化
·6 分鐘· loading · loading
Python Inspect Introspection Standard-Library Developer-Tools
Python AnyIO 實戰:TaskGroup、取消管理與跨框架非同步工具
·6 分鐘· loading · loading
Python AnyIO Async Asyncio Trio Developer-Tools