python-design について
Python の CLI スクリプトやユーティリティを保守しやすく設計するためのスキルです。型付きデータ、明確なモジュール境界、簡潔なエラー処理を通じて複雑さを抑える原則を示します。
主な機能
- 01深いモジュールと情報隠蔽
- 02型を先に定める開発
- 03複雑さをモジュール内に集約
- 04事後条件に基づくエラー設計
- 05KISS と三度目の法則
- 06CLI 出力の解析指針
活用シーン
- 01Python モジュール境界の設計
- 02Python スクリプトのレビューやリファクタリング
- 03CLI サブコマンドやユーティリティの追加
- 04解析処理やヘルパーの重複削減
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mindfold-ai/ trellis --skill python-designインストール後、Claude Code で/python-designを入力して読み込まれたか確認します。
詳細
CLI スクリプトのための Python 設計
保守しやすい Python CLI ツールやユーティリティを記述するための設計パターンと原則です。 A Philosophy of Software Design(Ousterhout)を基に、スクリプトの文脈に合わせて応用しています。
有効にするタイミング
- Python ファイルを作成または変更するとき
- モジュールの分割を計画するとき
- Python の変更をコードレビューするとき
- 「ごちゃごちゃした」と感じるスクリプトをリファクタリングするとき
- 新しいサブコマンドやユーティリティ関数を追加するとき
中心となる考え
中心的な課題は機能の追加ではなく、複雑さの管理です。
複雑さとは、コードの理解や変更を難しくするものです。次の3つの兆候があります。
- 変更の波及 — 小さな変更に、多くの箇所の編集が必要になる
- 認知負荷 — 安全に変更するために、あまりにも多くの文脈を把握する必要がある
- 未知の未知 — 何が分かっていないのか分からない(最も危険)
複雑さは少しずつ増していきます。壊滅的な失敗が1回起きて蓄積するのではなく、何百もの小さな判断を通じて積み重なります。したがって、細部をおろそかにしないでください。
原則 1:深いモジュール
モジュールの価値は、隠されている機能と公開されているインターフェースの比率です。
Deep module (good): Shallow module (bad):
┌──────────┐ ┌──────────────────────────┐
│ simple │ │ complex interface │
│ interface│ │ many params, many methods │
├──────────┤ ├──────────────────────────┤
│ │ │ │
│ rich │ │ thin implementation │
│ impl │ │ │
│ │ └──────────────────────────┘
│ │
└──────────┘
実用的なテスト:モジュールを正しく使うために、呼び出し側が内部の仕組みを理解する必要があるなら、そのモジュールは浅すぎます。
例:タスクデータへのアクセス
# Shallow — caller must know JSON structure, file paths, error handling
def _read_json_file(path: Path) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
# Every caller does this independently:
task_path = tasks_dir / name / "task.json"
data = _read_json_file(task_path)
title = data.get("title") or data.get("name", "")
status = data.get("status", "planning")
assignee = data.get("assignee", "")
# Deep — caller gets what they need, module hides JSON/path/parsing
@dataclass(frozen=True)
class TaskInfo:
name: str
title: str
status: str
assignee: str
priority: str
directory: Path
def load_task(tasks_dir: Path, name: str) -> TaskInfo | None:
"""Load task by directory name. Returns None if not found."""
...
def list_active_tasks(tasks_dir: Path) -> list[TaskInfo]:
"""List all non-archived tasks, sorted by priority."""
...
深いモジュールは、JSON の解析、フィールドのデフォルト値、ディレクトリのスキャン、アーカイブのフィルタリングといった複雑さを引き受けます。呼び出し側は型付けされたデータを扱うだけです。
原則 2:型を優先した開発
型は実装より先に契約を定義します。このワークフローによって、設計上の問題を早期に発見できます。
- データの形を定義する — まず dataclass または TypedDict を定義します
- 関数シグネチャを定義する — パラメーターと戻り値の型を定義します
- 型を満たすように実装する — 型チェッカーを使って、必要な実装がすべて揃っているか確認します
- 境界で検証する — 実行時チェックは、システムにデータが入る箇所でのみ行います
内部データには Frozen Dataclass を使用する
from dataclasses import dataclass
from typing import Literal
@dataclass(frozen=True)
class AgentRecord:
agent_id: str
task_name: str
worktree_path: Path
platform: Literal["Codex", "codex", "cursor"]
status: Literal["running", "done", "failed"]
branch: str
Frozen Dataclass は不変です。意図しない変更を防ぎ、安全に受け渡せます。
外部 JSON の形には TypedDict を使用する
データがファイル(task.json、config.yaml、registry.json)から読み込まれる場合は、期待される形を記述するために TypedDict を使用します。
from typing import TypedDict, Required, NotRequired
class TaskData(TypedDict):
title: Required[str]
status: Required[str]
assignee: NotRequired[str]
priority: NotRequired[str]
parent: NotRequired[str]
children: NotRequired[list[str]]
これにより、あちこちに散らばっていた .get("field", default) の呼び出しが不要になります。データの形は一度だけ記述します。
ドメインプリミティブには NewType を使用する
2つの文字列が異なる意味を持つ場合は、型システムでそれを強制します。
from typing import NewType
TaskName = NewType("TaskName", str) # directory name like "03-10-v040"
BranchName = NewType("BranchName", str) # git branch like "feat/v0.4.0"
def create_branch(task: TaskName) -> BranchName:
return BranchName(f"task/{task}")
状態には判別可能なユニオンを使用する
エンティティが、異なるデータを持つ複数の状態を取り得る場合に使用します。
@dataclass(frozen=True)
class Pending:
status: Literal["pending"] = "pending"
@dataclass(frozen=True)
class Running:
status: Literal["running"] = "running"
pid: int
worktree: Path
@dataclass(frozen=True)
class Completed:
status: Literal["completed"] = "completed"
branch: str
commit: str
AgentState = Pending | Running | Completed
def handle(state: AgentState) -> None:
match state:
case Running(pid=pid, worktree=wt):
check_process(pid)
case Completed(branch=br):
create_pr(br)
case Pending():
pass
型チェッカーによって、すべての状態が処理されていることを確認できます。分岐を忘れたまま if data.get("status") == "running" と書く必要はもうありません。
原則 3:情報隠蔽
各モジュールは設計上の判断をカプセル化する必要があります。同じ知識が複数のモジュールに現れる場合、情報が漏れています。
スクリプトでよくある情報漏洩のパターン
JSON スキーマの知識があちこちに散らばっている:
# BAD — 9 files all know how to iterate tasks and parse task.json
for d in sorted(tasks_dir.iterdir()):
if d.name == "archive" or not d.is_dir():
continue
task_json = d / "task.json"
if task_json.exists():
data = json.loads(task_json.read_text())
title = data.get("title") or data.get("name", "")
...
# GOOD — one module owns task iteration
# common/tasks.py
def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]:
"""Yield all active (non-archived) tasks."""
for d in sorted(tasks_dir.iterdir()):
if d.name == "archive" or not d.is_dir():
continue
info = _load_task_json(d)
if info:
yield info
ファイル形式の詳細がレイヤー間に漏れている:
# BAD — caller knows it's JSON, knows the path convention
registry_path = trellis_dir / "registry.json"
data = json.loads(registry_path.read_text())
data["agents"][agent_id] = {...}
registry_path.write_text(json.dumps(data, indent=2))
# GOOD — module hides storage format
registry = AgentRegistry(trellis_dir)
registry.add(agent_id, task=task_name, platform="Codex")
原則 4:複雑さを下位へ引き寄せる
複雑さを避けられない場合、モジュールは呼び出し側に押し付けるのではなく、内部で引き受けるべきです。モジュールの開発者は少数で、その利用者は多数です。したがって、すべての呼び出し側が個別に複雑さを処理するよりも、モジュールの作成者が一度だけ処理するほうが適切です。
# BAD — pushes complexity to every caller
def run_git(args: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(["git"] + args, capture_output=True, text=True)
# Every caller must: check returncode, decode stderr, handle encoding,
# strip whitespace, handle repo not found, etc.
# GOOD — absorbs complexity
def run_git(args: list[str], *, cwd: Path | None = None) -> str:
"""Run git command, return stdout. Raises GitError on failure."""
result = subprocess.run(
["git"] + args,
capture_output=True, text=True, encoding="utf-8",
errors="replace", cwd=cwd,
)
if result.returncode != 0:
raise GitError(args[0], result.stderr.strip())
return result.stdout.strip()
複雑さを上位に押し付けるアンチパターン
- 生の
subprocess.CompletedProcessを返し、呼び出し側に.returncodeの確認を任せる - 呼び出し側が内容を解析しなければならない汎用的な例外を送出する
- 判断を避けるために設定パラメーターを使う
- 型付きオブジェクトなら呼び出し側の検証を省ける場面で
dictを返す
原則 5:エラーが存在しないように定義する
例外処理は複雑さの大きな原因です。最善の方法は、エラー条件が単なるエラーではなくなるようにセマンティクスを設計することです。
# BAD — raises if key doesn't exist
def remove_agent(registry: dict, agent_id: str) -> None:
if agent_id not in registry["agents"]:
raise KeyError(f"Agent {agent_id} not found")
del registry["agents"][agent_id]
# GOOD — guarantees postcondition: agent is not in registry
def remove_agent(registry: dict, agent_id: str) -> None:
"""Ensure agent_id is not in the registry after this call."""
registry["agents"].pop(agent_id, None)
# BAD — raises if directory already exists
def init_workspace(path: Path) -> None:
if path.exists():
raise FileExistsError(f"{path} already exists")
path.mkdir()
# GOOD — guarantees postcondition: directory exists
def ensure_workspace(path: Path) -> Path:
"""Ensure workspace directory exists. Returns the path."""
path.mkdir(parents=True, exist_ok=True)
return path
重要なのは、操作を前提条件(「呼び出す前に X が真でなければならない」)ではなく、事後条件(「この呼び出しの後、X が真になる」)によって定義することです。
原則 6:KISS と三回ルール
KISS — シンプルに保つ
機能する最もシンプルな解決策を選びます。複雑さは、具体的な(仮定上ではない)要件によって正当化される必要があります。
# Over-engineered — registry pattern for 3 formatters
class FormatterRegistry:
_registry: dict[str, type] = {}
@classmethod
def register(cls, name: str): ...
@classmethod
def create(cls, name: str): ...
# Simple — just a dictionary
FORMATTERS = {"json": format_json, "text": format_text, "table": format_table}
def format_output(fmt: str, data: Any) -> str:
formatter = FORMATTERS.get(fmt)
if not formatter:
raise ValueError(f"Unknown format: {fmt}")
return formatter(data)
3 つのルール
パターンを抽象化するのは、その事例が 3 つ そろうまで待ちます。2 つは偶然、3 つはパターンです。時期尚早な抽象化は、次の理由から重複よりも悪いものです。
- 共通の抽象化を通じて、無関係なコードを結び付けてしまいます
- 各事例を個別に理解するのが難しくなります
- 将来の事例が適合しない場合でも、抽象化に当てはめる圧力が生まれます
ただし、3 つそろったら、すぐに抽象化します。9 つまで増やしてはいけません。
原則 7:単一責任とモジュール境界
各モジュールには、変更する理由が 1 つ だけあるべきです。モジュールが約 300 行を超えて大きくなったら、複数の責任を担っていないか確認します。
分割の兆候
次の場合は分割します。
- ファイル内に、コメント見出しで区切られた複数の「セクション」がある
- 大きなモジュールから、特定の関数だけをインポートする必要がある
- モジュール内の各部分のテストで、共通のセットアップが使われていない
- ある責任に対する変更で、別の責任を理解する必要がない
分割方法
実行順序(何がいつ実行されるか)ではなく、情報隠蔽(どの知識がカプセル化されているか)を基準に分割します。
# BAD — split by execution order (temporal decomposition)
# step1_parse_args.py, step2_validate.py, step3_execute.py
# All three must know the command structure
# GOOD — split by responsibility
# task_store.py — owns task.json read/write, schema, iteration
# task_cli.py — owns argparse, subcommand routing
# task_display.py — owns formatting, colors, table output
原則 8:共有インフラストラクチャの一貫性
複数のスクリプトで同じ機能が必要な場合は、common/ に一度だけ実装します。
| 機能 | 配置先 | 配置しない場所 |
|---|---|---|
| JSON ファイルの読み書き | common/io.py | 各スクリプトの _read_json_file |
| ターミナルの色とログ記録 | common/log.py | 各スクリプトの Colors クラス |
| Git コマンドの実行 | common/git.py | _run_git_command という接頭辞付きの非公開関数 |
| タスクデータへのアクセス | common/tasks.py | その場しのぎの task.json 解析 |
| パス定数 | common/paths.py(既存) | ハードコードされた文字列 |
命名:他のモジュールから使われる関数は公開 API です。名前の先頭に _ を付けてはいけません。
原則 9:構造化された CLI 出力の解析
シェルコマンド(git、grep など)の出力を解析するときは、意味を持つ空白を考慮します。
# BAD — .strip() destroys semantic whitespace
# git submodule status prefix: ' ' = initialized, '-' = uninitialized, '+' = changed
line = output_line.strip() # Loses the prefix character!
# GOOD — strip only trailing newlines
line = output_line.rstrip("\n\r")
prefix = line[0] if line else " "
構造化されたコマンド出力を解析する際は、各フィールドの位置が何を意味するかを必ず文書化します。
注意すべき兆候のクイックリファレンス
コードレビューとセルフレビューで使用します。
| 兆候 | 意味 |
|---|---|
| 浅いモジュール | インターフェースが実装とほぼ同じくらい複雑 |
| 情報漏洩 | 同じ JSON スキーマ/ファイル形式に関する知識が複数のモジュールに存在 |
| ユーティリティの重複 | 同じヘルパー関数が複数のファイルにコピーされている |
| 巨大モジュール | 500 行を超え、無関係な複数の責任を担うファイル |
| 単なる中継関数 | 類似したシグネチャを持つ別の関数に引数を渡すだけの関数 |
マジックな .get() の連鎖 | data.get("x") or data.get("y", "") — 型定義がない |
| sys.path の改変 | sys.path.insert(0, ...) — 代わりにパッケージ構造を修正する |
| 非公開名の公開 API | 3 つ以上の外部モジュールが _function をインポートしている |
| 生の Dict の受け渡し | 4 つ以上の関数呼び出しにわたって dict を渡している — データクラスを使う |
| 反復処理の繰り返し | 同じディレクトリ走査/ファイル解析のパターンが 3 か所以上にある |
| 広範な例外の捕捉 | 再送出しない except Exception: — バグを隠してしまう |
| 時間的な分割 | 「何がいつ実行されるか」ではなく「何が何を知っているか」でモジュールが分割されている |
設計チェックリスト(コードを書く前に)
- まず型を決める:ロジックを書く前にデータの形を定義します
- モジュールの深さを確認する:インターフェースは実装よりシンプルになりますか?
- 重複を調べる:新しいユーティリティを作る前に
grep -r "pattern" .を実行します - 責任を確認する:既存のモジュールに含めるべきではありませんか?
- エラー設計:エラーそのものが発生しないようにできますか?
- 命名の正確さ:実装を読まなくても、名前から意味が伝わりますか?
設計チェックリスト(コードレビュー時)
- 危険信号の確認:上の表と差分を照らし合わせます
- 型安全性:新しいデータ構造は型とともに文書化されていますか?
- 情報隠蔽:変更によって実装の詳細が漏れていませんか?
- 一貫性:モジュール内の既存のパターンに従っていますか?
- シンプルさ:呼び出し側にとって、一般的な処理経路はシンプルですか?
戦略的な投資
変更ごとに、およそ**10~20%**を周辺の設計改善に充てます。
動作するコードは必要ですが、それだけでは不十分です。ソフトウェア開発の各段階で積み重ねるべきなのは、単なる機能ではなく抽象化です。それぞれの変更によって、コードベースを着手前より少し良い状態にします。
これは完璧主義ではなく、複利のようなものです。小さな設計改善が積み重なり、時間とともに、はるかに扱いやすいシステムになります。