コース進捗 コース目次 24レッスン中 24件を公開中
Pythonの基礎
データとコレクション
信頼できるプログラムを作る
オブジェクトでモデル化する
プロフェッショナルなPython
上級Python
「パッケージ」を3つの名前へ分けよう
再利用できるコマンドには、3つの別々な名前があります。全部を同じ「パッケージ」と呼ぶと不思議に見えますが、分ければ普通の仕組みです。
-
importパッケージ
import reading_report -
ディストリビューション
reading-reportをインストール -
コマンド
study-reportと入力
名前は一致しても、しなくてもかまいません。1つのディストリビューションが複数のimportパッケージやコマンドを含むこともあります。モジュールは、importパッケージの内外にある1つの.pyファイルです。
プロジェクト地図とビルド契約を描こう
srcレイアウトでは、import可能なコードをリポジトリルートから離します。
reading-report/
├── pyproject.toml
├── README.md
├── src/reading_report/
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ ├── domain.py
│ └── presentation.py
└── tests/test_cli.py
これにより、現在のディレクトリにたまたまパッケージがあるため成功する、意図しないimportを見つけられます。テストはインストール済みプロジェクトを使うか、インストール前の確認だけ明示的にPYTHONPATH=srcを使います。
pyproject.tomlには異なる2つの契約があります。
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "reading-report"
version = "0.1.0"
description = "Summarize study sessions"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[project.scripts]
study-report = "reading_report.cli:main"
- フロントエンド pipやbuildが仕事を依頼
- [build-system] 必要なビルド道具を指定
- バックエンド setuptoolsが成果物を作る
- [project] ディストリビューションを説明
ビルド要件は実行時依存関係ではありません。requires-pythonは互換性の宣言で、Pythonをインストールする依頼でもありません。[project.scripts]の右側は「reading_report.cliをimportし、呼び出し可能なmainを見つけて呼ぶ」という意味です。インストール時に各OS用ランチャーが生成されます。
コマンドの入口を薄く保とう
端末の文字列は、3つの小さな部屋を通ります。
- 解析 文字列を検証済み値へ
- ドメイン 端末なしで計算
- 表示 結果を文章へ
main(argv=None)は関数内で解析し、表示用文章をprintし、整数を返します。0は成功です。最外側の__main__.pyやインストール済みランチャーが、その戻り値をプロセスステータスへ変えます。ドメイン関数でsys.exit()を呼びません。
インストール前にも、python -mなら相対importを保ったまま同じ入口を実行できます。
PYTHONPATH=src python -m reading_report Python:45 Reading:20 --goal 60
完全なstudy-reportプロジェクトを作ろう
src/reading_report/domain.py:
from dataclasses import dataclass
@dataclass(frozen=True)
class Session:
topic: str
minutes: int
def __post_init__(self):
topic = self.topic.strip()
if not topic:
raise ValueError("topic must not be empty")
if isinstance(self.minutes, bool) or not isinstance(self.minutes, int):
raise ValueError("minutes must be an integer")
if self.minutes < 0:
raise ValueError("minutes must not be negative")
object.__setattr__(self, "topic", topic)
def total_minutes(sessions):
return sum(session.minutes for session in sessions)
src/reading_report/presentation.py:
from .domain import total_minutes
def render_report(sessions, goal):
total = total_minutes(sessions)
status = "達成" if total >= goal else "未達成"
lines = ["学習レポート"]
lines.extend(f"- {item.topic}:{item.minutes}分" for item in sessions)
lines.append(f"合計:{total}分")
lines.append(f"目標:{status}({goal}分)")
return "\n".join(lines)
src/reading_report/cli.py:
import argparse
from .domain import Session
from .presentation import render_report
def nonnegative_int(text):
try:
number = int(text)
except ValueError as error:
raise argparse.ArgumentTypeError("整数を入力してください") from error
if number < 0:
raise argparse.ArgumentTypeError("負の値にはできません")
return number
def parse_session(text):
topic, separator, minutes = text.partition(":")
if not separator:
raise argparse.ArgumentTypeError("TOPIC:MINUTESの形にしてください")
try:
return Session(topic, nonnegative_int(minutes))
except (ValueError, argparse.ArgumentTypeError) as error:
raise argparse.ArgumentTypeError(str(error)) from error
def main(argv=None):
parser = argparse.ArgumentParser(prog="study-report")
parser.add_argument("sessions", nargs="+", type=parse_session)
parser.add_argument("--goal", type=nonnegative_int, default=60)
args = parser.parse_args(argv)
print(render_report(args.sessions, args.goal))
return 0
2つの小さな境界ファイルも作ります。
# src/reading_report/__init__.py
from .domain import Session, total_minutes
__all__ = ["Session", "total_minutes"]
# src/reading_report/__main__.py
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
tests/test_cli.py:
import contextlib
import io
import unittest
from reading_report.cli import main, parse_session
from reading_report.domain import Session, total_minutes
class CommandTests(unittest.TestCase):
def test_domain(self):
self.assertEqual(total_minutes([Session("Python", 30)]), 30)
def test_parse(self):
self.assertEqual(parse_session(" Python :25"), Session("Python", 25))
def test_main(self):
output = io.StringIO()
with contextlib.redirect_stdout(output):
status = main(["Python:45", "Reading:20", "--goal", "60"])
self.assertEqual(status, 0)
self.assertIn("目標:達成(60分)", output.getvalue())
if __name__ == "__main__":
unittest.main()
PYTHONPATH=srcを指定してコマンドとテストを実行します。合計:65分、目標:達成(60分)、3つのok、最後のOKを確認してください。
開発、ビルド、公開を意図して行おう
開発中はプロジェクト専用環境と編集可能インストールを使います。
python -m venv .venv
source .venv/bin/activate
python -m pip install --editable .
python -m unittest discover -s tests -v
Windows PowerShellでは.venv\Scripts\Activate.ps1です。編集可能importはsrcを参照しますが、新しいスクリプトなどメタデータ変更時は再インストールが必要な場合があります。.venv/、build/、dist/、*.egg-info/、キャッシュ、coverage出力は無視します。
ビルドと公開は明示的な外部操作です。次のコマンドには別途インストールしたフロントエンド、確認済みメタデータ、選択した認証情報、明確な許可が必要です。ここでは説明するだけで、このチュートリアルが実行する手順ではありません。
python -m build
python -m twine upload dist/*
アップロード前にwheelとsource archiveを調べ、新しい環境へwheelを入れ、テストとコマンドを再実行します。名前、バージョン、README、ライセンス、対応Python、リポジトリタグも確認します。
3つの小さなチャレンジと次への準備
- 3つの入口を改名。 ディストリビューション
reading-tools、importパッケージreading_tools、コマンドreading-summaryを設計し、script対応を書きます。 --headingを追加。cli.pyへ整形を移さず、解析から表示層へ渡します。既定値と独自見出しをテストします。- 不正入力を試す。 コロンなし、空のテーマ、数字でない分数、負の目標を試し、簡潔な診断とゼロ以外の解析ステータスを確認します。
注意点:ルートからだけ成功するimportは構成ミスを隠し、メタデータ変更時は再インストールが必要な場合があります。パーサーエラーはステータス2で終了し、アップロードは外部に残る操作です。
- モジュール、importパッケージ、ディストリビューション、コマンドを区別できる
- ビルドのフロントエンド、バックエンド、projectメタデータを説明できる
- script名を呼び出し可能オブジェクトへ対応付けられる
- 解析、ドメイン処理、表示を分けられる
-
python -m、venv、編集可能インストール、unittestを使える - 生成物はソースでも公開許可でもないと分かる
- 3つの小さなチャレンジを終えた
次章では、Pythonの構文が独自オブジェクトへ表現、比較、ハッシュ、包含、反復を頼む仕組みを学びます。