Python パッケージング #6 公開 — wheel のビルドと PyPI へのアップロード

読了 5分

ここまでは他人が作ったパッケージを受け取る側でした。今回は反対側に立ってみます。ユーティリティモジュールをチームの他のプロジェクトでも使いたい、あるいは自動化 #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-toolinvoice_tool)、パッケージ名の規則のためです。

ビルドバックエンド — ソースを配布物に変える機械 #

#3 で読み方だけ覚えて先送りしたセクションが、いよいよ主役です。

pyproject.toml
[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 build

dist/ の下に 2 つのファイルができます。

フォルダ構造
dist/
├── invoice_tool-0.1.0.tar.gz             # sdist
└── invoice_tool-0.1.0-py3-none-any.whl   # wheel

wheel のファイル名の 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
# .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 まで、チーム単位の規約としてシリーズ全体をまとめます。

X