TechQuant Blog

Python 開発に Makefile を導入したら作業時間が半分になった話

7分で読める

make test と1行打ったら、lint → 型チェック → テスト実行がまとめて走る。2秒。毎回3つのコマンドを手打ちしていた過去の自分に教えてやりたい。

Makefile は1976年生まれの古い技術だ。C言語のビルドツールとして作られたもので、Python 界隈ではあまり見かけない。自分も「今さら make かよ」と思っていた側の人間だった。ところが、Raspberry Pi で動かしている自動化スクリプト群が10本を超えたあたりで、「テストの走らせ方」「lintのかけ方」「デプロイの手順」をいちいち思い出すのが辛くなった。Task runner を色々見たけど、追加のインストールなしでどの Linux 環境でも使えるのは結局 make だった。

なぜ Python プロジェクトに Makefile なのか

Python にはタスクランナーが山ほどある。invokenoxtoxjust。どれも優秀だ。

ただ、面倒ごとが1つある。環境セットアップのたびに「まずタスクランナーを入れてください」と言わなきゃいけない。make はほぼ全ての Linux / macOS に最初から入っている。Windows でも WSL を使えば動く。セットアップ手順が1行減るだけだが、これが地味に効く。

もう1つ。Makefile の「ターゲット」という仕組みが、開発タスクの管理と相性がいい。make lintmake testmake deploy。コマンド名を見ただけで何が起こるか分かる。READMEに手順を書くより、Makefile にターゲットを並べたほうが「動くドキュメント」として信頼できる。

最小構成の Makefile を書く

まずは小さく始める。以前 uv の導入記事で紹介したパッケージマネージャ uv を使うプロジェクトを例にした。

.PHONY: install test lint format clean

install:
	uv sync

test:
	uv run pytest tests/ -v

lint:
	uv run ruff check src/

format:
	uv run ruff format src/ tests/

clean:
	find . -type d -name __pycache__ -exec rm -rf {} +
	rm -rf .pytest_cache .ruff_cache dist/

ファイル名は Makefile(先頭大文字、拡張子なし)でプロジェクトルートに置く。これだけで make test が動く。

気をつけたいのは .PHONY の宣言。make は本来「ファイルが存在するかどうか」でターゲットの実行を判断する。.PHONY をつけると「これはファイルじゃなくてコマンドだ」と明示できる。忘れると、たまたま test という名前のディレクトリがあったときに make: 'test' is up to date. と言われて「は?」ってなる。自分は1回ハマった。

インデントはタブ必須 — 最大のトラップ

Makefile のレシピ行(コマンド行)はタブ文字じゃないと動かない。スペースだとエラーになる。Python 書きとしてはスペース派が多いだろうけど、ここだけはタブが必要だ。

# NG: スペース4つだとエラー
test:
    uv run pytest tests/

# OK: タブ文字
test:
	uv run pytest tests/

VS Code なら、.vscode/settings.json に以下を追加しておくと Makefile を開いたときだけタブに切り替わる。

{
  "[makefile]": {
    "editor.insertSpaces": false,
    "editor.tabSize": 4
  }
}

実務で手放せなくなったターゲット

最初は test と lint だけだったのが、気づいたら10個以上に増えていた。その中から特に便利なものを紹介する。

ワンコマンド品質チェック

check: lint typecheck test
	@echo "All checks passed"

typecheck:
	uv run mypy src/ --ignore-missing-imports

make check の1行で lint → 型チェック → テストが順番に走る。途中でコケたらそこで止まる。GitHub Actions で CI を組むときも、ワークフロー側は make check と書くだけで済む。CIの設定ファイルがシンプルになるのは正義だ。

デプロイの自動化

deploy: check
	rsync -avz --delete dist/ user@server:/var/www/app/
	ssh user@server 'systemctl restart myapp'

deploy-dry: check
	rsync -avz --delete --dry-run dist/ user@server:/var/www/app/

自分は Raspberry Pi で開発して VPS にデプロイする構成を使っている。deploy-dry で差分を確認してから本番に反映する運用にしたら、うっかりミスが激減した。ちなみにデプロイ先にはお名前.comのVPSを使っていて、月額1,000円台で安定している。

あ、もう一つ。deploy: check と書くと、デプロイ前に自動でチェックが走る。テストが通っていないコードがデプロイされる事故を構造的に防げる。

help ターゲット — Makefile を自己文書化する

.DEFAULT_GOAL := help

help: ## 利用可能なコマンド一覧
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2}'

install: ## 依存パッケージをインストール
	uv sync

test: ## テストを実行
	uv run pytest tests/ -v

lint: ## コードの静的解析
	uv run ruff check src/

各ターゲットの横に ## コメント を書いておくと、引数なしで make と打つだけでコマンド一覧がカラー表示される。プロジェクトに初めて触る人への案内板として機能する。

Makefile で地味にハマるポイント

各行が別シェルで実行される

これを知らないとバグる。Makefile のレシピは 1行ごとに別の shell で実行される。つまり cd の効果が次の行に残らない。

# ダメ(cd の効果が消える)
build:
	cd dist
	python setup.py bdist_wheel

# OK(&& で1行にまとめる)
build:
	cd dist && python setup.py bdist_wheel

あるいは .ONESHELL: をファイル先頭に書くと、全行を1つの shell で実行してくれる。ただし挙動が変わるので既存の Makefile に後から足すと別の問題が出ることもある。新規なら検討していい。

変数でパスやコマンドを共通化する

PYTHON := uv run python
SRC := src
TESTS := tests

test:
	$(PYTHON) -m pytest $(TESTS) -v

lint:
	uv run ruff check $(SRC)

パスやコマンドは変数に切り出しておくと、プロジェクトの構成が変わったときに上の数行を直すだけで済む。:= は即時評価、= は遅延評価。ほとんどの場合 := で問題ない。

導入して実際どうだったか

正直に書く。劇的に何かが変わったわけじゃない。

ただ、「あのコマンドなんだっけ」と README を見に行く回数がゼロになった。以前 Claude Code の cron 自動実行を設定したときも、テストの走らせ方を毎回忘れていたのが、Makefile に集約してから一切困らなくなった。

PR レビューで「テスト通ってる?」と聞かれたときに「make check 通ってます」の一言で済むのも楽だ。

一方で、Makefile の構文に慣れていない人がタブ問題やシェルの挙動で詰まることはある。正直、構文のモダンさでは justtask のほうが上だと思う。ここは現在進行形で試行錯誤中で、チームの規模や習熟度によって正解が変わりそう。

とはいえ、個人プロジェクトや少人数チームなら、追加インストール不要で使える make は今でも十分実用的だ。100行の Makefile が1つあるだけで、プロジェクトの「操作マニュアル」として機能する。投資対効果はかなりいい。