pyproject.toml、ロックファイル、src/ レイアウト — 現代的なプロジェクト構造
setup.py から pyproject.toml まで、uv.lock による再現可能なビルド、__init__.py と src/ レイアウト。そして平らなスクリプトのフォルダを、コマンド付きの本物のパッケージに変換します。
どうやって学びますか?
解決する問題
チケットのトリアージ作業は、最初は 1 つのフォルダに置かれた数個のスクリプトから始まりました。clean.py、score.py、run_triage.py です。作者はフォルダの中に立って python run_triage.py を実行し、すべてが動きました。ところが 3 か月のあいだに、次のようなことが起きました:
- 同僚が
scripts/サブフォルダにレポート用のスクリプトを置いたところ、ModuleNotFoundError: No module named 'score'で失敗しました。隣のフォルダにはscore.pyがちゃんとあるのに、です。 - 誰かが API キーを入れるために、フォルダに
secrets.pyというファイルを作りました。すると、別のスクリプトで Python 標準のsecretsモジュールが突然「壊れ」ました。 - 新しいメンバーが参加しました。古い
setup.pyとrequirements.txtは、パッケージを 2 通りの書き方で列挙しており、どちらが正しいのか誰にも分からなくなりました。
コードにバグがあるわけではありません。問題は構造にあります。この章では、このフォルダを本物の Python パッケージに変えます。pyproject.toml、ロックファイル、src/ レイアウトを用意します。会社で使われている現代的な Python リポジトリは、ほぼ例外なくこの形をしています。
この章を終える頃には
setup.pyからpyproject.tomlに移行した理由を説明でき、pyproject.tomlの主なセクションを読めるuv.lockが何を固定するのかを説明でき、uv lock、uv sync、uv sync --lockedを適切な場面で使えるuv init --packageと--libでsrc/レイアウトのプロジェクトを作成でき、__init__.pyの役割を説明できる- 平らなフォルダに潜む 2 つの import の罠を見分けられ、
src/レイアウトがそれらを早期に捕まえる理由を示せる - 平らなスクリプトのフォルダを、コマンドラインのエントリーポイントを持つパッケージに変換できる
前提条件: Git、ブランチ、Pull Request、それに前の章の uv init、uv add、uv run。
setup.py から pyproject.toml へ
古い Python プロジェクトでは、次のようなファイルがよく見られます:
from setuptools import find_packages, setup
setup(
name="ticket-triage",
version="0.1.0",
packages=find_packages(),
install_requires=["httpx"],
entry_points={"console_scripts": ["triage=ticket_triage.cli:main"]},
)その隣には requirements.txt があり、中身は別のリストになっていることがよくあります。setup.py の本質的な問題は、それがプログラムだということです。プロジェクトの名前や依存関係を知るだけでも、実行しなければなりません。しかも Python のコードなので、ファイルの読み込み、条件分岐、さらにはネットワーク通信まで、何でも書けてしまいます。そのため、ツールはそれについて確実なことを何も言えませんでした。
pyproject.toml は同じ情報を、宣言として持ちます。実行されることはなく、読まれるだけのただの TOML ファイルです。今では Python の標準であり、uv、エディター、テストツール、リンターがすべてこれを読みます。uv init --package ticket-triage が生成するのは次のファイルです:
[project]
name = "ticket-triage"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Nadia Karim", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = []
[project.scripts]
ticket-triage = "ticket_triage:main"
[build-system]
requires = ["uv_build>=0.9.1,<0.10.0"]
build-backend = "uv_build"(uv は作者の名前とメールアドレスを Git の設定から取得します。)セクションは 3 つあります:
[project]— プロジェクトの識別情報と依存関係です。dependenciesのリストは、uv addが管理してくれます。[project.scripts]— コマンドラインのコマンドです。ticket-triage = "ticket_triage:main"は、「ticket-triageというコマンドを作り、ticket_triageパッケージのmain関数を呼び出す」という意味です。[build-system]— プロジェクトをインストール可能なパッケージに変換するツールです(「ビルドバックエンド」)。uvは独自のuv_buildを使います。ほかのプロジェクトではhatchlingやsetuptoolsを見かけることもありますが、役割は同じです。
プロジェクト名は ticket-triage(ハイフン)ですが、Python パッケージ名は ticket_triage(アンダースコア)であることに注目してください。Python の名前にハイフンは使えません。import ticket-triage と書くと、引き算になってしまいます。そのため uv が変換を代わりに行います。
ロックファイル:「動いた」から「いつも同じように動く」へ
pyproject.toml では、依存関係は範囲で書かれます。"httpx>=0.28.1" は、0.28.1 かそれより新しいどのバージョンでもよい、という意味です。これは意図的な設計です。動作する範囲を宣言しているのです。ただ、今日インストールするのと 3 か月後にインストールするのとでは、異なるバージョンが入ることがあります。さらにその下にある依存パッケージの場合は、なおさらです。
uv.lock がこの隔たりを埋めます。すべてのパッケージの正確なバージョンとファイルのハッシュを記録します。もちろん、さらに下の依存パッケージも含みます。リポジトリにこれがあれば、どのマシン、CI、本番サーバーでも、まったく同じものがインストールされます。これが「再現可能なビルド」です。
コマンドは 4 つあり、それぞれ役割が違います:
uv lock—pyproject.tomlを読んで、ロックファイルを作成または更新します。何もインストールしません。uv sync—.venvをロックファイルに正確に一致させます。必要ならば、先にロックファイルを更新します。uv sync --locked— 同じですが、ロックファイルが古い場合は、更新せずに停止します。uv lock --check— 何も変更せず、ロックファイルがpyproject.tomlと一致しているかどうかだけを報告します。
uv add と uv remove は、両方のファイルを一緒に更新します。ですから普段の作業では、これらを個別に実行する必要はありません。問題が起きるのは、誰かが pyproject.toml を手で編集したときです。たとえば、dependencies = [] を手で dependencies = ["httpx>=0.28"] に変えて、uv lock を実行しなかったとします:
uv sync --locked
Resolved 8 packages in 57ms
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.CI が常に uv sync --locked を実行するのは、そのためです。レビューされていないバージョンが、こっそり入り込むことがありません。
通常のuv syncでは、この場合でもエラーは出ません。--lockedを付けずに同じ状況で実行すると、uvは黙って依存関係を再解決し、uv.lockを自分で書き換えて、そのまま処理を続けます。自分のマシンでは便利ですが、CI では、リポジトリにあるものとは別のものがテストされていることになります。
パッケージ、__init__.py、src/ レイアウト
モジュールは 1 つの .py ファイルです。パッケージは、中に __init__.py を持つフォルダで、モジュールをまとめたものです。__init__.py には 2 つの役割があります。1 つは、そのフォルダを import 可能なパッケージとして示すことです。もう 1 つは、import ticket_triage と書いたときに最初に実行されるファイルであることです。ですから、パッケージの「玄関」として見せたいものを、ここに置くことができます。
uv init には 3 つの形式があります:
uv init— スクリプトプロジェクトです。main.pyとpyproject.tomlがあり、ビルドシステムはありません。1 つか 2 つのファイルで済む作業や、実験に向いています。uv init --package— アプリケーションのパッケージです。src/レイアウト、ビルドシステム、そして[project.scripts]に書かれたコマンドがあります。uv init --lib— ほかの人が import するライブラリです。src/レイアウトとビルドシステムがありますが、コマンドはありません。代わりに、空のpy.typedファイルがあります。これは、型チェッカーに対して、このパッケージが型情報を含むことを伝えるファイルです。
uv init --package ticket-triage で作られる構造は次のとおりです:
ticket-triage/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── ticket_triage/
└── __init__.pyパッケージはプロジェクトのルートではなく、src/ フォルダの中に置かれます。この余分な 1 段がなぜ存在するのかを理解するには、まず平らなフォルダで何がうまくいかないのかを見てみましょう。
平らなフォルダに潜む 2 つの罠
罠 1:Python がどこを探すか。 スクリプトを実行すると、Python はまず スクリプトが置かれているフォルダ から import を探します。ターミナルでどのフォルダに立っているかではありません。平らなフォルダでは、from score import priority が動くのは、score.py とスクリプトが同じフォルダにあるからにすぎません。同僚の scripts/report.py は次のとおりです:
from score import priority
print(priority("Database breach reported"))triage-scripts フォルダの中に立って、python scripts/report.py を実行すると:
Traceback (most recent call last):
File "scripts\report.py", line 1, in <module>
from score import priority
ModuleNotFoundError: No module named 'score'(実際のマシンでは、トレースバックにはフルパスが表示されます。ここでは短くしてあります。)Python は scripts/ の中を探しましたが、そこに score.py はありません。import が成功するかどうかは、ファイルがたまたまどこにあるかに左右されます。プロジェクトにとって、これほど壊れやすいルールはほかにほとんどありません。
罠 2:名前の衝突。 同じ理由で、スクリプトのフォルダにあるファイルは、Python 自身のモジュールよりも先に検索されます。誰かがキーを入れるために secrets.py をフォルダに作り、その隣の new_id.py が標準ライブラリの secrets を使おうとすると:
import secrets
print(len(secrets.token_hex(8)))Traceback (most recent call last):
File "new_id.py", line 3, in <module>
print(len(secrets.token_hex(8)))
^^^^^^^^^^^^^^^^^
AttributeError: module 'secrets' has no attribute 'token_hex'import secrets ではエラーは出ませんでした。プロジェクトの secrets.py が見つかり、それを import したのです。エラーは後になって出ます。コードが、そのファイルにはない何かを要求したときです。AI プロジェクトでは、実験用のファイル名 openai.py、anthropic.py、httpx.py などでこれがよく起きます。そうしたファイルが、まさに使おうとしているライブラリを隠してしまうのです。
src/ レイアウトが、これらのミスを早期に捕まえる仕組み
src/ レイアウトでは、2 つのことが変わります。
第一に、すべてが 1 つの固有の名前の下に置かれます。 モジュールはもはや clean、score、secrets ではなく、ticket_triage.clean、ticket_triage.score です。プロジェクトのどのファイルも、標準ライブラリや別のパッケージを隠すことはできません。import 文そのものが、どのコードが自分のものかを示します。
第二に、パッケージはインストールせずには import できません。 プロジェクトのルートから、普通の python で試してみます:
python -c "import ticket_triage"
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'ticket_triage'この失敗こそが、この仕組みの利点です。パッケージがプロジェクトのルートに置かれていたら、このコマンドは動いてしまいます。Python がフォルダを直接見つけるからです。そうなると、pyproject.toml の設定が正しいかどうかを、いつまでも知ることができません。src/ の中にあるので、パッケージに到達する方法は 1 つしかありません。インストールすることです。そして uv run が、毎回それを行います。uv run はプロジェクトを .venv に「editable」としてインストールします。つまり、インストールされたパッケージは src/ フォルダを指すので、コードを変更するたびに再インストールする必要がありません:
uv run python -c "import ticket_triage; print(ticket_triage.priority('OUTAGE'))"
highそのため、パッケージングのミスはすべて、自分のマシンで最初の uv run の時点で表面化します。デプロイの日に初めて分かるのではありません。たとえば、パッケージのフォルダ名が間違っていたり、__init__.py がなかったりすると:
× Failed to build `ticket-triage @
│ file:///.../ticket-triage`
╰─▶ Expected a Python module at: src\ticket_triage\__init__.py(パスは短くしてあります。)ビルドバックエンドは、プロジェクト名からパッケージ名を導き出します。ticket-triage から src/ticket_triage/ を探し、そこに何もなければ停止します。意図的に別の名前を使いたい場合は、pyproject.toml の [tool.uv.build-backend] 以下の module-name でそう指定します。
完全な例
では、章の最初にあった平らなフォルダを変換してみましょう。隣のフォルダで uv init --package ticket-triage --python 3.12 を実行し、古いファイルをパッケージの中へ移すと、構造は次のようになります:
ticket-triage/
├── pyproject.toml
├── uv.lock
├── scripts/
│ └── report.py
└── src/
└── ticket_triage/
├── __init__.py
├── clean.py
├── cli.py
└── score.pypyproject.toml の変更は 1 つです。コマンド名を短くし、新しい cli モジュールを指すようにします:
[project.scripts]
triage = "ticket_triage.cli:main"src/ticket_triage/clean.py — 元の clean.py です。型ヒントと docstring を加えてあります:
import re
def clean_text(text: str) -> str:
"""Lowercase the text and collapse every run of whitespace to one space."""
return re.sub(r"\s+", " ", text).strip().lower()src/ticket_triage/score.py — 唯一の実質的な変更は import 行です。from clean import ... が from ticket_triage.clean import ... になりました:
from ticket_triage.clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"src/ticket_triage/__init__.py — パッケージの玄関です:
from ticket_triage.score import priority
__all__ = ["priority"]scripts/report.py — 前に壊れていたスクリプトです。今はパッケージ名で import しています:
from ticket_triage import priority
print(priority("Database breach reported"))そして src/ticket_triage/cli.py — 古い run_triage.py に代わる、コマンドラインのエントリーポイントです:
import sys
from ticket_triage.score import priority
DEMO_TICKETS = ["Login page is slow", "Payments DOWN for all users"]
def main() -> None:
tickets = sys.argv[1:] or DEMO_TICKETS
for ticket in tickets:
print(f"{priority(ticket):<6} {ticket}")
if __name__ == "__main__":
main()プロジェクトのルートから実行します:
uv run triage
uv run triage "Service down since morning" "Typo on the pricing page"
uv run scripts/report.py
uv lock --checknormal Login page is slow
high Payments DOWN for all users
high Service down since morning
normal Typo on the pricing page
high
Resolved 1 package in 2msuv run triage の最初の実行では、その前に数行が表示されます。.venv を作成し、Building ticket-triage と Built ticket-triage が出力されます。uv がプロジェクトを初めてインストールしているためです。2 回目以降は、これらは表示されなくなります。最後の行は uv lock --check によるもので、終了コードは 0 です。ロックファイルが pyproject.toml と一致していることを意味します。
この変換における判断は次のとおりです。
絶対 import(from ticket_triage.clean import ...)。 パッケージの中では、相対 import も使えます。たとえば from .clean import clean_text です。しかし絶対 import は、どのファイルから見ても同じに読めますし、検索もしやすくなります。どちらを選ぶにしても、プロジェクトごとにスタイルは 1 つに統一してください。
__init__.py では priority だけを公開する。 外部のコード(scripts/report.py、今後のテスト、ほかのサービス)は、from ticket_triage import priority だけを知っていればよいことになります。あとから score.py と clean.py の分け方が変わっても、外部のコードは壊れません。__all__ は、この公開リストを明示的に書き出すものです。
cli.py を分けておく。 ロジック(score.py)と入出力(cli.py)を分けておくと、priority は、テストや API など、どこからでも sys.argv や print なしで呼び出せます。sys.argv[1:] が空のときは、デモ用のチケットが実行されるので、引数なしで実行しても何かが表示されます。
f"{priority(ticket):<6}"。 :<6 は、テキストを 6 文字分で左揃えにします。そのため、high と normal のどちらの後でも、チケットが同じ列から始まります。
知っておくと良い制限が 1 つあります。clean_text は空白文字でのみ単語を分割します。そのため "The site is down." では、単語は down. になり、ピリオドが含まれたままです。そして URGENT_WORDS にはマッチしません。テキストから単語を抽出するより良い方法は、このコースでもう少し後に扱います。この章は構造についてのものです。構造が正しいので、その改善は clean.py の中だけで行われ、ほかのどこも変える必要がありません。
うまくいかないとき
ModuleNotFoundError: No module named 'clean' ファイルはパッケージの中へ移動されましたが、どこかのファイルがまだ古い import from clean import clean_text を使っています。パッケージの中に clean というトップレベルのモジュールはありません。from ticket_triage.clean import clean_text と書いてください。変換したあとは、プロジェクト全体で from clean と from score を検索してください。
ModuleNotFoundError: No module named 'ticket_triage' uv run ではなく普通の python で実行されたため、パッケージがインストールされた環境が使われませんでした。uv run ... で実行してください。これは src/ レイアウトが意図どおりに動いているのであって、バグではありません。
Expected a Python module at: src\ticket_triage\__init__.py パッケージが、プロジェクト名から決まるビルドバックエンドの期待するフォルダにありません。フォルダ名が間違っている(src/triage/、src/ticket-triage/)か、__init__.py がないかのどちらかです。フォルダ名を直すか、__init__.py を追加するか、module-name で正しい名前を指定してください。
The lockfile at uv.lock needs to be updated, but --locked was provided. pyproject.toml が手で編集されたのに uv lock が実行されていないか、ロックファイルがコミットされていません。uv lock を実行して、両方のファイルを一緒にコミットしてください。次からは、依存関係の変更には uv add と uv remove を使いましょう。どちらも、両方のファイルを揃えて更新してくれます。
AttributeError: module 'secrets' has no attribute 'token_hex' プロジェクトに、標準ライブラリのモジュールと同じ名前のファイル(secrets.py、random.py、logging.py など)があり、本物のモジュールを隠しています。ファイルの名前を変えてください。そして、秘密情報は .py ファイルではなく .env に保存してください。
error: Failed to spawn: triag に続いて Caused by: program not found uv run に渡した名前が、[project.scripts] にある名前と違っています。ここではタイプミスです。pyproject.toml の名前を確認してください。名前を変えた後は、uv run がプロジェクトを自動的に再インストールして、新しいコマンドを作ります。
ステップ 4 / 6 — 予想する
理解度をチェック
章の clean_text を、乱れたチケットの文字列に対して実行します。何が表示されるでしょうか?
import re
def clean_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
print(repr(clean_text(" Payments DOWN\tsince 9 ")))- A`' payments down since 9 '`
- B`'payments down since 9'`
- C`'payments down\tsince 9'`
- D`'Payments DOWN since 9'`
平らなフォルダから src/ レイアウトに移行したあと、uv run triage で ModuleNotFoundError: No module named 'clean' が出ます。clean.py は src/ticket_triage/clean.py にあり、そのすぐ隣にあります。バグはどこにあるでしょうか?
# src/ticket_triage/score.py
from clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"- A`clean.py` はプロジェクトのルートに戻さなければならない。Python はそこを探すからだ
- Bimport が平らなフォルダ式のままになっている。パッケージの中では `from ticket_triage.clean import clean_text` でなければならない
- C`src/ticket_triage/` に `__init__.py` があると、隣のファイルを import できないので、それを削除しなければならない
- D`uv run triage` が見るのは `cli.py` だけで、`score.py` の import は一度も実行されない
PR で、誰かがパッケージを追加するために pyproject.toml を手で編集しました。CI の uv sync --locked が失敗します。エラーは The lockfile at uv.lock needs to be updated です。正しい修正は何でしょうか?
- ACI から `--locked` を外して、CI 自身にロックファイルを更新させる
- B`uv.lock` を `.gitignore` に追加して、各自がロックファイルを作れるようにする
- Cローカルで `uv lock` を実行する(最初から `uv add` を使う)、そして更新された `uv.lock` を同じ PR でコミットする
- D`uv.lock` を削除する。ロックファイルがなければ、このエラーは出ないから
プレイは Pro の機能です
+30 XP が待っています
Pro でこの章をプレイ
読むのは無料で、章はすべて公開されています。無料コースでは、最初の 5 章を無料でプレイできます。第 6 章からは、問題への回答、XP の獲得、連続記録の更新が Pro の機能になります。
Pro でプレイを解放やってみよう
この章の最初の平らなフォルダを、自分で作って変換してみましょう。まず、triage-scripts/ フォルダに 3 つのファイルを置きます。clean.py(上の clean_text を、型ヒントなしで書いたもの)、score.py(from clean import clean_text から始まるもの)、そして 2 つのデモ用チケットの優先度を表示する run_triage.py です。それから次のことをしてください:
- 隣に、
uv init --package ticket-triage --python 3.12で新しいプロジェクトを作り、ファイルをsrc/ticket_triage/に移します。そして、すべての import をticket_triage.から始まる形に書き換えます run_triage.pyを、この章のとおり(同じ 2 つのデモ用チケットを使って)cli.pyのmain()関数に変え、triage = "ticket_triage.cli:main"を[project.scripts]に追加します__init__.pyからはpriorityだけを公開し、scripts/report.pyではfrom ticket_triage import priorityを使って呼び出しますuv lock --checkを実行して、ロックファイルが一致していることを確認します。そしてpyproject.toml、uv.lock、src/、scripts/を 1 つのコミットにまとめてコミットします。.venvは含めないでください
プロジェクトのルートで uv run triage を実行し、続けて uv run scripts/report.py を実行すると、ちょうど次の出力になるはずです:
normal Login page is slow
high Payments DOWN for all users
highそれから、わざと 2 回壊してみてください。1 回目は、score.py の import を from clean import clean_text に戻して、どのエラーがどの行で出るかを確認します。2 回目は、src/ticket_triage/ の中に secrets.py を作り、cli.py に import secrets と print(len(secrets.token_hex(8))) を追加して、uv run triage を実行します。平らなフォルダの罠は、ここでも再現するでしょうか。その理由は何でしょうか?