Python パッケージング #3 pyproject.toml — プロジェクト設定の標準
前回の結論は「意図の一覧と再現用のスナップショットを分離すべき」でした。今回はそのうち意図が住む家です。最近の Python リポジトリを開くと、ほぼ必ずルートに pyproject.toml があります。依存関係の宣言、プロジェクトのメタデータ、フォーマッター・リンター・テストの設定まで、すべてこの 1 ファイルに集まります。なぜこのファイルが標準になり、各セクションが何を収めているのかを知れば、他人のプロジェクトを読む速度が変わります。
散らばっていた時代から 1 ファイルへ #
pyproject.toml 以前の Python プロジェクトは、設定が散らばっていました。パッケージ情報は実行しないと中身がわからない setup.py スクリプトに、依存関係は requirements.txt に、ツールの設定は setup.cfg、.flake8、pytest.ini などそれぞれのファイルにありました。PEP 518 がこのファイルを導入し、PEP 621 がプロジェクトメタデータの標準形式を定義したことで、「プロジェクトに関する宣言は pyproject.toml に書く」へとエコシステムが収束しました。いまでは pip、uv、poetry のようなインストーラーも、ruff、pytest、mypy のような開発ツールも、みんなこのファイルを読みます。
[project] — プロジェクトが何であるかを宣言します #
中心のセクションから見ていきます。
[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 種類の付加的な依存を分けておきます。
[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] — このプロジェクトをパッケージにする方法 #
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"このセクションは「このプロジェクトをインストール可能な配布物にビルドするとき、どのツールを使うか」の宣言です。ライブラリを作って公開するときに意味を持ち、公開しないアプリケーションならなくてもかまいません。ビルドバックエンドが実際に何をするのかは #6 の公開回で手を動かして確認します。いまは「他人のプロジェクトでこのセクションを見たら、公開用のパッケージなんだな」と読めれば十分です。
[tool.*] — ツール設定の集合場所 #
pyproject.toml の残り半分は、開発ツールの設定です。ツールごとに自分の名前の名前空間を使います。
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
strict = true.flake8、pytest.ini、mypy.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 で手作業だったすべてのことが、いくつかのコマンドに畳み込まれるのを見ることになります。