JEPA4Japan · チュートリアル

コマンドラインパッケージを作る

2,253文字 7分で読めます #Python

複数のモジュールを、安定した操作方法を持つインストール可能なコマンドにします。

コース進捗 コース目次 24レッスン中 24件を公開中

「パッケージ」を3つの名前へ分けよう

再利用できるコマンドには、3つの別々な名前があります。全部を同じ「パッケージ」と呼ぶと不思議に見えますが、分ければ普通の仕組みです。

  1. importパッケージ import reading_report
  2. ディストリビューション reading-reportをインストール
  3. コマンド study-reportと入力
Pythonはコードをimportし、インストーラーは配布物を記録し、シェルはコマンドを起動します。

名前は一致しても、しなくてもかまいません。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"
  1. フロントエンド pipやbuildが仕事を依頼
  2. [build-system] 必要なビルド道具を指定
  3. バックエンド setuptoolsが成果物を作る
  4. [project] ディストリビューションを説明
フロントエンドが宣言済みバックエンドへ依頼し、projectメタデータが作る物を説明します。

ビルド要件は実行時依存関係ではありません。requires-pythonは互換性の宣言で、Pythonをインストールする依頼でもありません。[project.scripts]の右側は「reading_report.cliをimportし、呼び出し可能なmainを見つけて呼ぶ」という意味です。インストール時に各OS用ランチャーが生成されます。

コマンドの入口を薄く保とう

端末の文字列は、3つの小さな部屋を通ります。

  1. 解析 文字列を検証済み値へ
  2. ドメイン 端末なしで計算
  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つの小さなチャレンジと次への準備

  1. 3つの入口を改名。 ディストリビューションreading-tools、importパッケージreading_tools、コマンドreading-summaryを設計し、script対応を書きます。
  2. --headingを追加。 cli.pyへ整形を移さず、解析から表示層へ渡します。既定値と独自見出しをテストします。
  3. 不正入力を試す。 コロンなし、空のテーマ、数字でない分数、負の目標を試し、簡潔な診断とゼロ以外の解析ステータスを確認します。

注意点:ルートからだけ成功するimportは構成ミスを隠し、メタデータ変更時は再インストールが必要な場合があります。パーサーエラーはステータス2で終了し、アップロードは外部に残る操作です。

  • モジュール、importパッケージ、ディストリビューション、コマンドを区別できる
  • ビルドのフロントエンド、バックエンド、projectメタデータを説明できる
  • script名を呼び出し可能オブジェクトへ対応付けられる
  • 解析、ドメイン処理、表示を分けられる
  • python -m、venv、編集可能インストール、unittestを使える
  • 生成物はソースでも公開許可でもないと分かる
  • 3つの小さなチャレンジを終えた

次章では、Pythonの構文が独自オブジェクトへ表現、比較、ハッシュ、包含、反復を頼む仕組みを学びます。