Python パッケージング #6 公開 — wheel のビルドと PyPI へのアップロード
ここまでは他人が作ったパッケージを受け取る側でした。今回は反対側に立ってみます。ユーティリティモジュールをチームの他のプロジェクトでも使いたい、あるいは自動化 #7 で作った CLI を uv tool install でインストールできるようにしたいなら、コードをインストール可能なパッケージにする必要があります。pip install requests ができるようになるまでに requests 側で起きていたことが、今回の内容です。
パッケージになるプロジェクトの形 — src レイアウト #
公開するプロジェクトは、コードを src/ の下のパッケージディレクトリに置きます。
invoice-tool/
├── pyproject.toml
├── README.md
├── src/
│ └── invoice_tool/
│ ├── __init__.py
│ └── cli.py
└── tests/uv init --package invoice-tool で始めると、この構造が作られます。ルートにコードを置く方式(フラットレイアウト)でも動きはしますが、src レイアウトが現代の標準になった理由は 1 つです。「インストールされたパッケージ」と「現在のディレクトリのソース」が混ざる事故を防ぎます。 ルートにコードがあると、テストがインストールされたものではなくソースをインポートしてしまい、ビルドからファイルが漏れてもローカルのテストは通る、という罠が生まれます。名前のハイフンがディレクトリではアンダースコアになるのは(invoice-tool → invoice_tool)、パッケージ名の規則のためです。
ビルドバックエンド — ソースを配布物に変える機械 #
#3 で読み方だけ覚えて先送りしたセクションが、いよいよ主役です。
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"ビルドバックエンドは、ソースツリーを受け取って配布物を作り出す道具です。標準インターフェース(PEP 517)が定義されているのでバックエンドは差し替え可能で、hatchling が無難なデフォルトです。作り出す配布物は 2 種類です。
- sdist(ソース配布物): ソースコードをそのまま固めた tar.gz です。インストールする側がビルドをやり直します。
- wheel: ビルドが終わった zip です。インストールは展開に近い形で終わります。#2 で pip が受け取っていたのが、まさにこれです。
純粋な Python パッケージなら wheel 1 つがすべてのプラットフォームをカバーし、C 拡張があればプラットフォームごとの wheel が必要になります。慣例は両方をアップロードすることです。
ビルドとローカルでの検証 #
uv builddist/ の下に 2 つのファイルができます。
dist/
├── invoice_tool-0.1.0.tar.gz # sdist
└── invoice_tool-0.1.0-py3-none-any.whl # wheelwheel のファイル名の py3-none-any は「すべての Python 3、ABI 不問、すべてのプラットフォーム」という互換性タグです。上げる前に、ローカルでインストールのリハーサルをします。
uvx --with dist/invoice_tool-0.1.0-py3-none-any.whl invoice-tool --help開発中はこのリハーサルが毎回必要なわけではありません。uv のプロジェクトでは自分自身が editable インストール(ソースの修正が即座に反映されるリンク方式)で環境に入っているので、uv run invoice-tool は常に現在のソースで動きます。
TestPyPI でリハーサル、PyPI で本番 #
PyPI にはやり直しがありません。一度上げたバージョン番号は、削除しても再利用できないので、最初の公開は練習場である TestPyPI でリハーサルするのが定石です。
# TestPyPI にアップロード (アカウント・トークンは test.pypi.org で発行)
uv publish --index testpypi --token $TESTPYPI_TOKEN
# インストールまで確認できたら本番
uv publish --token $PYPI_TOKENトークンはアカウント全体の権限ではなく、プロジェクトスコープのトークンを発行して使います。バージョンを上げるときは pyproject.toml の version を修正し(semver の慣例: 互換な修正はパッチ、機能追加はマイナー、互換性を壊す変更はメジャー)、ビルドからやり直します。
Trusted Publishing — トークンなしのデプロイが現代の標準です #
トークンは流出事故の常連です。そこで現在の標準は、CI からトークンなしで公開する Trusted Publishing です。PyPI のプロジェクト設定に「この GitHub リポジトリのこのワークフローが公開主体」と登録しておくと、GitHub Actions が実行時に OIDC で身元を証明し、短期の資格情報を受け取ってアップロードします。リポジトリにシークレットを保存する必要そのものがなくなります。
# .github/workflows/publish.yml (核心部)
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # OIDC の身元証明
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv build
- run: uv publish # Trusted Publishing ならトークン不要テスト #7 の CI につながる図です。リリースを作れば、テストを通ったコードが人の手なしで PyPI に届きます。
公開したくないなら — 社内配布の選択肢 #
すべてのパッケージが PyPI に行く必要はありません。社内専用なら 2 つが実用的です。
- git 依存:
uv add "invoice-tool @ git+https://github.com/org/invoice-tool"のように、リポジトリを直接依存関係として掛けます。追加のインフラなしで始められ、タグでバージョンを固定します。 - プライベートインデックス: 組織の規模が大きくなったら、私設のパッケージインデックス(クラウドのアーティファクトレジストリなど)を置いて
[[tool.uv.index]]で登録します。ワークフローは PyPI と同じになります。
まとめ #
今回扱った内容です。
- 公開するプロジェクトは src レイアウトで作ります。インストールされたものとソースが混ざるインポート事故を構造的に防いでくれます
- ビルドバックエンド(hatchling など)がソースを sdist と wheel に変えます。wheel はビルドが終わった zip で、慣例は両方のアップロードです
uv buildでビルドし、上げる前に wheel を直接インストールしてリハーサルします。開発中は editable インストールのおかげでuv runが常に現在のソースで動きます- PyPI のバージョン番号は再利用できません。最初の公開は TestPyPI で練習し、トークンはプロジェクトスコープに絞ります
- CI からのデプロイの現代の標準は Trusted Publishing です。OIDC で身元を証明するので、リポジトリにトークンを置く必要がありません
- 社内専用のパッケージは、git 依存かプライベートインデックスで PyPI なしに配布します
次回(#7 チームの規約と道具の選択)はシリーズの締めくくりです。uv、poetry、pip を 1 つの表に載せて選ぶ基準を整理し、CI のキャッシュと Docker まで、チーム単位の規約としてシリーズ全体をまとめます。