Python パッケージを PyPI にパブリッシュする

1. 概要

あなたが書いた便利な Python モジュールを、他の人が pip install でインストールできるようにしたい。そのには、コードを distribution にパッケージ化して PyPI (Python Package Index) にアップロードする必要があります。

この記事では全体のプロセスを扱います。標準的なプロジェクト構造を構築し、pyproject.toml でメタデータを宣言し、wheel と sdist をビルドし、TestPyPI でテストしてから本番にリリースします。記事の最後では、GitHub Actions と Trusted Publishing を使ったリリース自動化を紹介します。

2. プロジェクト構造

この記事を通して使う例は、注文の合計金額を計算する小さなライブラリ nonla-order-tools です。src レイアウト を使用します:

nonla-order-tools/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── nonla_order_tools/
│       ├── __init__.py
│       ├── pricing.py
│       └── cli.py
└── tests/
    └── test_pricing.py

コードはプロジェクトルートではなく src/ に配置されます。主な利点は、テスト実行時に Python が作業ディレクトリからコードを無意識にインポートできないことです。インストール済みのバージョンのみをインポートする必要があるため、パッケージングし忘れたファイルのエラーを早期に発見できます。

区別すべき 2 つの名前があります:

  • PyPI 上の distribution 名:nonla-order-tools。ユーザーはこの名前を pip install で入力します。
  • インポートパッケージ名:nonla_order_tools。import で使用し、ハイフンを含みません。

2.1. ライブラリのコード

# src/nonla_order_tools/pricing.py
from dataclasses import dataclass, field
from decimal import Decimal


@dataclass(frozen=True)
class OrderLine:
    product: str
    unit_price: Decimal
    quantity: int


@dataclass
class Order:
    customer: str
    lines: list[OrderLine] = field(default_factory=list)

    def add(self, product: str, unit_price: str, quantity: int = 1) -> None:
        if quantity <= 0:
            raise ValueError("quantity must be positive")
        self.lines.append(OrderLine(product, Decimal(unit_price), quantity))


def calculate_total(order: Order, vat_rate: Decimal = Decimal("0.10")) -> Decimal:
    subtotal = sum((l.unit_price * l.quantity for l in order.lines), Decimal("0"))
    return (subtotal * (1 + vat_rate)).quantize(Decimal("1"))

Order は OrderLine のリストを含んでいます。calculate_total は各行の金額を合計し、VAT(デフォルト 10%)を乗算して通貨単位に四捨五入します。コードは金銭計算の丸め誤差を避けるために Decimal を float の代わりに使用しています。

# src/nonla_order_tools/__init__.py
from importlib.metadata import version

from .pricing import Order, OrderLine, calculate_total

__all__ = ["Order", "OrderLine", "calculate_total"]
__version__ = version("nonla-order-tools")

__version__ はインストール済みパッケージのメタデータから読み取られます。このため、バージョンは pyproject.toml 内の 1 か所でのみ宣言されます。

小さなコマンドラインツールを追加:

# src/nonla_order_tools/cli.py
import sys
from decimal import Decimal

from .pricing import Order, calculate_total


def main() -> None:
    # 使用法:order-total <価格> <数量> [<価格> <数量> ...]
    args = sys.argv[1:]
    order = Order(customer="cli")
    for price, qty in zip(args[::2], args[1::2]):
        order.add("item", price, int(qty))
    print(calculate_total(order, Decimal("0.10")))

3. pyproject.toml でメタデータを宣言する

pyproject.toml はビルド方法とパッケージ情報を説明する唯一の場所です。新しいプロジェクトには setup.py は不要です。

[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"

[project]
name = "nonla-order-tools"
version = "0.1.0"
description = "VAT を含む注文合計金額の計算"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Nón Lá Dev", email = "dev@nonla.example" }]
keywords = ["order", "pricing", "vat"]
classifiers = [
    "Programming Language :: Python :: 3",
    "Operating System :: OS Independent",
]
dependencies = []

[project.optional-dependencies]
test = ["pytest>=8"]

[project.scripts]
order-total = "nonla_order_tools.cli:main"

各セクションの説明:

  • [build-system] は、pip と build に、どの build backend を使用するかを伝えます。ここでは hatchling です。setuptools、flit-core、pdm-backend も使用できます。[project] のメタデータは変わりません。
  • name と version は必須です。バージョンは MAJOR.MINOR.PATCH の形式に従うべきです。
  • readme は PyPI の説明ページになります。
  • requires-python はサポートされていない Python バージョンへのインストールを防止します。
  • license = "MIT" は新しい標準に従う SPDX 識別子です。この標準には hatchling>=1.27 が必要なため、最小バージョンが上記で指定されています。
  • dependencies は実行時に必要なライブラリをリストします。例:["httpx>=0.27"]。
  • optional-dependencies は “extra” を作成します。ユーザーは pip install "nonla-order-tools[test]" でインストールできます。
  • [project.scripts] はパッケージをインストールするときに、order-total コマンドを PATH に作成します。このコマンドは cli モジュール内の main 関数を呼び出します。

Hatchling は、ディレクトリ名がプロジェクト名を正規化したもの(ハイフンをアンダースコアに変換)と一致するため、自動的に src/nonla_order_tools を検出します。名前が異なる場合は、手動で宣言する必要があります:

[tool.hatch.build.targets.wheel]
packages = ["src/nonla_order_tools"]

4. テストの作成と editable モードでのインストール

リリース前に、主な動作が正しいことを確認してください:

# tests/test_pricing.py
from decimal import Decimal

import pytest

from nonla_order_tools import Order, calculate_total


def test_total_includes_vat():
    order = Order(customer="Lan")
    order.add("Cà phê", "25000", 2)
    order.add("Bánh mì", "20000")

    assert calculate_total(order) == Decimal("77000")


def test_rejects_non_positive_quantity():
    order = Order(customer="Minh")
    with pytest.raises(ValueError, match="positive"):
        order.add("Trà", "15000", 0)

最初のテストは (25000 × 2 + 20000) × 1.1 = 77000 を計算します。2 番目のテストは、数量 0 が拒否されることを確認します。

プロジェクトを editable モードでインストールしてからテストを実行します:

python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest -q
..                                                    [100%]
2 passed in 0.02s

-e フラグはインストール済みパッケージを src/ ディレクトリにリンクします。コードを編集するとすぐに有効になり、再インストール不要です。

5. wheel と sdist のビルド

build ツールをインストールしてプロジェクトルートで実行します:

pip install --upgrade build twine
python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling>=1.27
* Building sdist...
* Building wheel from sdist
Successfully built nonla_order_tools-0.1.0.tar.gz and nonla_order_tools-0.1.0-py3-none-any.whl

dist/ ディレクトリに 2 つのファイルが含まれるようになります:

  • sdist (.tar.gz):メタデータ付きのソースコード。適切な wheel がない場合に使用されます。
  • wheel (.whl):事前にビルドされたディストリビューション。py3-none-any は、パッケージが pure Python であるため、Python 3 を使用するすべてのオペレーティングシステムで実行可能であることを意味します。

wheel に必要なファイルがすべて含まれているか確認します:

python -m zipfile -l dist/nonla_order_tools-0.1.0-py3-none-any.whl
File Name                                             Modified             Size
nonla_order_tools/__init__.py                  2020-02-02 00:00:00          209
nonla_order_tools/cli.py                       2020-02-02 00:00:00          363
nonla_order_tools/pricing.py                   2020-02-02 00:00:00          786
nonla_order_tools-0.1.0.dist-info/METADATA     2020-02-02 00:00:00         1012
nonla_order_tools-0.1.0.dist-info/WHEEL        2020-02-02 00:00:00           87
nonla_order_tools-0.1.0.dist-info/entry_points.txt 2020-02-02 00:00:00     58
...

固定された日付は意図的です:Hatchling は 再現可能なビルド を作成します。マシンのファイルサイズは若干異なる場合があります。

次に、twine を使用してメタデータをチェックします。特に README が正しくレンダリングされているかを確認します:

twine check dist/*
Checking dist/nonla_order_tools-0.1.0-py3-none-any.whl: PASSED
Checking dist/nonla_order_tools-0.1.0.tar.gz: PASSED

アップロード前の最後のステップは、wheel を クリーン 環境にインストールしてインポートを試みることです:

python -m venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl
/tmp/verify/bin/python -c "import nonla_order_tools as n; print(n.__version__)"
/tmp/verify/bin/order-total 25000 2 20000 1
0.1.0
77000

このステップで ModuleNotFoundError が発生する場合は、ファイルのパッケージングを忘れたか、パッケージパスを誤って宣言した可能性があります。

6. リリース前に TestPyPI でテストする

TestPyPI は PyPI の独立したコピーで、テスト専用です。両者は独立したアカウントとトークンを持っています。

  1. TestPyPI でアカウントを作成し、二要素認証(2FA)を有効にします。
  2. Account settings → API tokens に移動してトークンを作成します。トークンは pypi- で始まります。
  3. アップロード:
twine upload --repository testpypi dist/*

プロンプトが表示されたら、ユーザー名として __token__ を入力し、パスワードとしてトークン文字列を入力します。再入力を避けるために、~/.pypirc に保存できます:

[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZwIk...

[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJ...

chmod 600 ~/.pypirc で権限を設定して、他のユーザーがファイルを読めないようにします。次に、TestPyPI からインストールをテストします:

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  nonla-order-tools

--extra-index-url フラグを使用すると、pip は本物の PyPI から依存関係を取得できます。TestPyPI には通常、十分な依存パッケージがないためです。

7. PyPI にリリース

すべてが順調なら、実際の PyPI での手順はほぼ同じですが、--repository は省略します:

twine upload dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading nonla_order_tools-0.1.0-py3-none-any.whl
Uploading nonla_order_tools-0.1.0.tar.gz
View at:
https://pypi.org/project/nonla-order-tools/0.1.0/

これからは、誰でも pip install nonla-order-tools を実行できます。

その後のリリースについては、以下の手順に従います:

  1. pyproject.toml のバージョンを上げます。例:0.1.1。
  2. 古い dist/ ディレクトリを削除:rm -rf dist。
  3. python -m build、twine check、その後 twine upload を再実行します。

注意: PyPI は、すでに存在するバージョン名を持つファイルの再アップロードを許可しません。削除済みのリリースであっても同じです。誤ったバージョンをリリースした場合は、yank することしかできず、新しいバージョンをリリースする必要があります。これが常に TestPyPI で先にテストする理由です。

8. Trusted Publishing での自動リリース

CI に長期トークンを保存することは、露出のリスクが潜んでいます。Trusted Publishing はトークンを完全に排除します。PyPI は特定の GitHub Actions ワークフローを OIDC を通じて信頼し、各実行のための短期トークンを発行します。

PyPI での設定:プロジェクトの Publishing に移動するか、プロジェクトがまだ存在しない場合は “pending publisher” を使用します。ここでは、owner、リポジトリ名、ワークフローファイル名 publish.yml、および environment pypi を宣言します。

# .github/workflows/publish.yml
name: Publish to PyPI

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: python -m pip install build
      - run: python -m build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

ワークフローの仕組み:

  • ワークフローは GitHub Release を作成するたびに実行されます。
  • build ジョブは dist/ にファイルを作成し、それらをアーティファクトとして保存します。
  • publish ジョブは分離されており、id-token: write アクセス許可のみを持ちます。このアクセス許可により OIDC トークンをリクエストでき、pypa/gh-action-pypi-publish アクションはそのトークンを使用してアップロードします。シークレットは不要です。
  • 2 つのジョブを分離することで、ビルドステップ(コードとビルド依存関係を実行)がリリースアクセス許可に決してアクセスしないようにします。

9. よくあるエラー

  • 名前が既に取られている:PyPI が 403 または The name is too similar to an existing project を報告します。設定する前に名前を確認し、nonla- のような独自のプレフィックスを追加することをお勧めします。
  • File already exists:バージョンを上げるのを忘れたか、dist/ ディレクトリに古いファイルが残っています。ビルド前に常に dist/ を削除してください。
  • データファイルがない(JSON、テンプレートなど):パッケージディレクトリにないファイルはパッケージされません。それらを src/nonla_order_tools/ に配置し、importlib.resources を使用して読み取ります。
  • マシンでテストが成功しますが、インストール後にインポートが失敗する:これは src レイアウトが回避するのに役立つエラーです。セクション 5 のように、wheel をクリーン venv にインストールするのを常に試してください。
  • 依存関係宣言が厳しすぎる:ライブラリは requests==2.32.3 のようなバージョンをハードコードすべきではありません。ユーザーのプロジェクトとの競合が生じるためです。requests>=2.31 のような下限のみを設定してください。ハードコードはアプリケーションにのみ適切であり、lock ファイルで行うべきです。
  • トークンが露出した:~/.pypirc またはトークンをリポジトリにコミットしないでください。プロジェクトごとに制限されたトークンを使用するか、Trusted Publishing を使用する方が良いです。

注意: すべてのコードが PyPI にアップロードされるべきではありません。社内ライブラリは、プライベートインデックス(Artifactory、GitLab Package Registry など)を使用するか、Git から直接インストールする必要があります。PyPI は公開スペースであり、占有された名前は取り戻すのが非常に難しくなります。

10. 結論

現代的な Python パッケージングプロセスはかなりシンプルです。すべては pyproject.toml 1 つのファイル、hatchling などの build backend、wheel と sdist を作成する python -m build コマンド、チェックとアップロード用の twine を中心に回ります。

テスト実行、ビルド、クリーン venv へのインストール、TestPyPI でのテスト、その後 PyPI へのリリースという順序で行う習慣をつけるべきです。プロジェクトが安定したら、GitHub Actions で Trusted Publishing に切り替えてください。以降のリリースは Release を作成するだけとなり、トークン管理は不要になります。

pythonpypipackagingpyprojectgithub-actions