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 の独立したコピーで、テスト専用です。両者は独立したアカウントとトークンを持っています。
- TestPyPI でアカウントを作成し、二要素認証(2FA)を有効にします。
- Account settings → API tokens に移動してトークンを作成します。トークンは
pypi-で始まります。 - アップロード:
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 を実行できます。
その後のリリースについては、以下の手順に従います:
pyproject.tomlのバージョンを上げます。例:0.1.1。- 古い
dist/ディレクトリを削除:rm -rf dist。 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 を作成するだけとなり、トークン管理は不要になります。