Python パッケージング #3 pyproject.toml — プロジェクト設定の標準

読了 5分

前回の結論は「意図の一覧と再現用のスナップショットを分離すべき」でした。今回はそのうち意図が住む家です。最近の Python リポジトリを開くと、ほぼ必ずルートに pyproject.toml があります。依存関係の宣言、プロジェクトのメタデータ、フォーマッター・リンター・テストの設定まで、すべてこの 1 ファイルに集まります。なぜこのファイルが標準になり、各セクションが何を収めているのかを知れば、他人のプロジェクトを読む速度が変わります。

散らばっていた時代から 1 ファイルへ #

pyproject.toml 以前の Python プロジェクトは、設定が散らばっていました。パッケージ情報は実行しないと中身がわからない setup.py スクリプトに、依存関係は requirements.txt に、ツールの設定は setup.cfg.flake8pytest.ini などそれぞれのファイルにありました。PEP 518 がこのファイルを導入し、PEP 621 がプロジェクトメタデータの標準形式を定義したことで、「プロジェクトに関する宣言は pyproject.toml に書く」へとエコシステムが収束しました。いまでは pip、uv、poetry のようなインストーラーも、ruff、pytest、mypy のような開発ツールも、みんなこのファイルを読みます。

[project] — プロジェクトが何であるかを宣言します #

中心のセクションから見ていきます。

pyproject.toml
[project]
name = "invoice-tool"
version = "0.1.0"
description = "請求書 PDF を生成する社内ツール"
requires-python = ">=3.12"
dependencies = [
    "django>=5.2,<6",
    "requests~=2.32.0",
    "celery>=5.5",
]
  • name・version: パッケージの身元です。#6 で PyPI に公開するとき、この名前がそのまま使われます。
  • requires-python: このプロジェクトが動く Python のバージョン範囲です。ツールがこの宣言を読んでインタープリターを合わせてくれるので、必ず書きます。
  • dependencies: #2 で言った「意図の一覧」がまさにここです。直接依存だけを、意図した範囲とともに書きます。freeze の出力のように推移的依存が混ざることはありません。

バージョンを >=5.2,<6 のように範囲で書くのがコツです。正確なバージョンの固定はこのファイルの仕事ではなく、#5 で扱うロックファイルの仕事です。ここでは「私たちのコードが互換な範囲」という意図だけを宣言します。

選択的な依存 — extras と dependency-groups #

依存関係はすべて同じ重さではありません。2 種類の付加的な依存を分けておきます。

pyproject.toml
[project.optional-dependencies]
pdf = ["weasyprint>=63"]        # インストールする側が選ぶ機能 (extras)

[dependency-groups]
dev = [                          # 開発者だけが使うツール (PEP 735)
    "pytest>=8",
    "ruff>=0.8",
]
  • optional-dependencies(extras): パッケージの利用者が機能単位で選ぶ依存です。pip install invoice-tool[pdf] のように角かっこで選んでインストールします。PDF 出力が必要な人だけが重いライブラリを受け取る構造です。
  • dependency-groups: パッケージを使う人とは無関係に、開発する人にだけ必要なツールの一覧です(PEP 735 標準)。テストシリーズで使っていた uv add --dev pytest が pyproject.toml に残す記録が、まさにこのセクションです。

配布物に含まれうる extras と、開発環境専用の dependency-groups を分けるのが、現代の標準の整理の仕方です。

[build-system] — このプロジェクトをパッケージにする方法 #

pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

このセクションは「このプロジェクトをインストール可能な配布物にビルドするとき、どのツールを使うか」の宣言です。ライブラリを作って公開するときに意味を持ち、公開しないアプリケーションならなくてもかまいません。ビルドバックエンドが実際に何をするのかは #6 の公開回で手を動かして確認します。いまは「他人のプロジェクトでこのセクションを見たら、公開用のパッケージなんだな」と読めれば十分です。

[tool.*] — ツール設定の集合場所 #

pyproject.toml の残り半分は、開発ツールの設定です。ツールごとに自分の名前の名前空間を使います。

pyproject.toml
[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.mypy]
strict = true

.flake8pytest.inimypy.ini に散らばっていた設定が 1 ファイルに集まると、リポジトリのルートがきれいになる以上の効果があります。プロジェクトの規約が 1 ファイルで見えるので、新しいメンバーが読むファイルが 1 つに減り、設定ファイル同士が衝突する事故もなくなります。

requirements.txt との関係 #

整理すると役割はこう分かれます。pyproject.toml の dependencies は宣言(直接依存 + 互換範囲)で、requirements.txt は伝統的ワークフローでスナップショットまで兼ねていたファイルです。pyproject.toml が宣言を引き受けたので、残る質問は「スナップショットは誰が担うのか」です。その答えがロックファイルで、宣言からロックを作り環境まで合わせてくれるツールが、次回の uv です。

まとめ #

今回扱った内容です。

  • setup.py・setup.cfg・各種ツール設定ファイルに散らばっていたプロジェクト情報が、PEP 518/621 を経て pyproject.toml という 1 ファイルに収束しました
  • [project] には名前、バージョン、requires-python、そして直接依存を意図した範囲とともに宣言します。正確なバージョン固定はロックファイルの仕事です
  • 利用者が選ぶ機能は extras(optional-dependencies)、開発者専用のツールは dependency-groups(PEP 735)に分けます。uv add --dev が記録する場所は後者です
  • [build-system] は公開用パッケージのビルド方法の宣言で、公開しないプロジェクトにはなくてもかまいません
  • [tool.*] に ruff、pytest、mypy の設定を集めると、プロジェクトの規約が 1 ファイルで読めます

次回(#4 uv)では、この宣言を読んで仮想環境、インストール、ロック、Python のバージョンまで一度に処理するツール、uv を扱います。#1〜#3 で手作業だったすべてのことが、いくつかのコマンドに畳み込まれるのを見ることになります。

X