Claude Media
Claude CodeでPython2をPython3へ移行する手順 — 2to3とpyupgrade

Claude CodeでPython2をPython3へ移行する手順 — 2to3とpyupgrade

Python 2のコードをPython 3へ移すとき、機械変換の順序と、変換後に残るbytesとstrの差をテストで固定する流れを、claude -pのファイル単位ループで回す形にまとめます。

Python 2のコードをPython 3へ移すとき、Claude Codeに「全部直して」と頼んでも、構文の置き換えまでは進みます。問題は構文の外にあります。移行後に動いてはいても、文字列とバイト列の境界、整数除算、辞書の並びが静かに変わる箇所が残るためです。ここでは、機械変換(2to3とpyupgrade)で構文を先に片付け、残る差をテストで固定してから、claude -pでファイルごとに回す手順をまとめます。

先に機械変換、残りをClaudeに渡す

Claude Codeの仕事は、機械変換が決められない部分に絞ります。print文やraw_inputのように規則で直せるものまでモデルに書かせると、ファイルごとに出来が揺れ、差分のレビューも重くなります。規則で直せる分は決定的なツールに任せ、モデルには「変換後に残った警告と、テストが落ちた箇所」だけを渡します。

流れは4段です。

手順

Python 2から3への移行の流れ

  1. 1

    Python 2のまま挙動を固定する

    変換前のコードを実行し、入力と出力を記録します。bytesとstrの境界はここで決まります。

  2. 2

    2to3で構文を変換する

    print文、exceptの書式、dictのイテレータ系メソッドなどを一括で書き換えます。

  3. 3

    pyupgradeで旧式の書き方を整理する

    u''リテラル、super(C, self)、sixの互換コードなどを取り除きます。

  4. 4

    claude -pでファイル単位に残りを直す

    テストが落ちるファイルだけをループに載せ、ターン数と使えるツールに上限を付けて回します。

順序を入れ替えない理由は、pyupgradeの一覧に出てくる書き換えがPython 3の書き方を前提にしているためです。pyupgradeのREADMEにある項目は、u'foo'を'foo'に直す、super(C, self).f()をsuper().f()にする、sixの互換コードを外す、といったもので、Python 2のprint文を3系の構文にする項目は載っていません。構文の橋渡しは2to3、その後の掃除はpyupgrade、と役割が分かれます。

bytesとstrの境界をテストで固定する

Stepsの1番目、現在の挙動の記録から始めます。構文変換の前に済ませておく作業です。記録の取り方を誤ると、後の工程がすべて「テストを通すだけ」になります。

Python 2では、str型は実質バイト列で、unicode型がテキストです。変換後は、前者がbytes、後者がstrに対応します。この対応を、実行結果から機械的に取ります。次のスクリプトは、Python 2側で動かして入力と出力を1つのJSONに固定する例です。例ではlegacy.reportのparse_recordを対象にしていますが、関数名は読者のコードに合わせて置き換えてください。

# capture.py — Python 2 で実行し、入力と出力の型を golden.json に残す
import json
from legacy.report import parse_record
 
def kind(x):
    return "bytes" if isinstance(x, bytes) else "text" if isinstance(x, unicode) else type(x).__name__
 
inputs = ["616263", "e38182", "00ff10"]  # バイト列の 16 進表現
cases = []
for h in inputs:
    out = parse_record(h.decode("hex"))
    cases.append({"input_hex": h, "kind": kind(out), "value": out if kind(out) != "bytes" else out.encode("hex")})
json.dump(cases, open("golden.json", "w"))

Python 3側のテストは、同じ入力を渡して、型の種類と値の両方を比べます。kind()の判定をcapture.pyと同じにそろえてあるので、parse_recordがintやNoneを返す入力でも、移行が正しければ落ちません。

# tests/test_boundary.py — Python 3 で実行する特性化テスト
import json
import pytest
from legacy.report import parse_record
 
CASES = json.load(open("golden.json"))
 
def kind(x):  # capture.py と同じ判定。Python 3 では str が text
    return "bytes" if isinstance(x, bytes) else "text" if isinstance(x, str) else type(x).__name__
 
@pytest.mark.parametrize("case", CASES)
def test_parse_record_keeps_boundary(case):
    out = parse_record(bytes.fromhex(case["input_hex"]))
    got_kind = kind(out)
    assert got_kind == case["kind"]
    if got_kind == "bytes":
        assert out.hex() == case["value"]
    else:  # text とそれ以外(int や None など)は値をそのまま比べる
        assert out == case["value"]

型の種類(kind)を値と別に比べるのが要点です。Python 2のコードがstrを返していた箇所は、移行後にbytesを返さなければ、ファイルへの書き出しやソケット送信で型エラーになります。値だけを比べるテストは、'abc'とb'abc'の違いを通してしまいます。

テストを先に置く考え方は、テストが無いコード全般を扱うClaude Codeリファクタリングを安全に進める手順と同じです。移行に固有なのは、基準が「ある時点の自分のコード」ではなく、「Python 2で動かした出力」になる点です。基準値を別言語の実行結果から作る点は、MATLABからPythonへの移植で数値結果を検算する記事と同型です。

整数除算と辞書の並びは別に固定する

bytesとstrの次に事故を起こしやすい2つも、特性化テストの対象にします。

  • 整数除算: Python 2では7 / 2が3でしたが、Python 3では3.5です。「平均」「割合」「ページ数」を計算する関数は、整数の入力で呼ぶケースを必ず基準値に入れます。意図が切り捨てなら//に直し、実数が正しいなら期待値を直します。どちらが意図かはコードからは決まらないので、基準値の側で人が決めます。
  • 辞書の並び: 辞書を巡回して文字列を組み立てたり、先頭の1件を取ったりするコードは、Python 2では並びが保証されていませんでした。Python 3.7以降は挿入順です。出力に並びが影響する関数は、並びを固定するか、並びに依存しない比較(sortedしたうえで比べる)に直します。

これらは言語仕様の変更で、2to3のfixerでは吸収されません。変換前に基準値を取っておけば、移行後に値が変わった時点でテストが落ちて気づけます。逆にテストが無ければ、気づくのは本番のログを見た後です。

2to3は新しいPythonでは動かない

2to3には、事前に確かめておく条件が1つあります。Pythonの3.12版ドキュメントは、2to3とlib2to3が3.11で非推奨になり、3.13で標準ライブラリから削除されると書いています。3.13以降のPythonしか入っていない環境では、2to3コマンドそのものが無い状態になります。

ドキュメントは代替として、LibCSTやparsoといったサードパーティのライブラリを挙げています。移行作業用に3.12以前のインタープリターを1つ用意し、そこで2to3だけを実行する分け方が手堅い構成です。変換後のコードを走らせるのは、移行先のPython 3.xです。

# 変換前に差分だけ確認する(ファイルは書き換わらない)
2to3 legacy/report.py
 
# 書き戻す。-n を付けると .bak を作らない(git で戻せる前提)
2to3 -w -n legacy/
 
# 変換だけで1コミットにする(Python 3.12 以前の環境で)
git commit -am "update: 2to3 で構文を Python 3 へ変換"

ドキュメントによると、2to3は何も付けなければ差分を表示するだけで、-wを付けると書き戻します。-nを付けない場合は元ファイルのバックアップが作られます。自動で直せない箇所には、差分の下に警告が出ます。この警告は、後でClaudeに渡す指示の材料になります。変換だけを1コミットに切っておくと、後でClaudeが書いた差分と混ざりません。

2to3が直すもの、直さないもの

2to3の個々の変換は「fixer」と呼ばれ、2to3 -lで一覧できます。ドキュメントの説明から、移行で効くものを拾います。

変換の対象2to3の動き
raw_input()2to3の動きinput()に変える
unicode2to3の動きstrに変える
xrange()2to3の動きrange()に変え、既存のrange()をlistで包む
dict.iteritems()など2to3の動きitems()などに変え、既存のitems()もlistで包む
has_key()2to3の動きin演算子に変える
map() zip()2to3の動きlistで包む

xrange・dict・map・zipの行に共通するのは、Python 3で遅延評価になる関数をlist(...)で包み、Python 2の挙動をそのまま保つ点です。動作は保たれる代わりに、移行後のコードにはlist(d.items())のような包みが残ります。不要かどうかはコードごとに違うため、整理は人かClaudeの仕事です。

一方で、ドキュメントのfixer一覧に、整数の除算を扱うものは載っていません。/の意味が変わる件は、構文の書き換えでは直らない領域です。bytesとstrの扱いも同じで、unicodeの改名はあっても、どの文字列がバイト列であるべきかを判断する変換はありません。

pyupgradeで旧式の書き方を整理する

2to3のコミットの後に、pyupgradeを当てて別のコミットにします。変換の差分とClaudeの差分が混ざらないようにするためです。

pip install pyupgrade
pyupgrade --py311-plus $(git ls-files 'legacy/*.py')
git commit -am "update: pyupgrade で旧式の書き方を整理"

pyupgradeの--py311-plusのように、対象とする最低バージョンを指定する形式は、READMEの各項目の条件欄に出てきます(例: --py311-plusでasyncio.TimeoutErrorに関する書き換えが有効になる)。移行先のPythonの最低バージョンに合わせて選びます。

コミットを3段(基準値、2to3、pyupgrade)に分けておけば、次の工程でFAILが返ったファイルだけを人が見る、という切り分けもできます。

claude -pでファイル単位に回す

変換直後は、テストの大半が落ちる状態でも構いません。落ちたファイルがfiles.txtに入り、Claudeの担当範囲がそのまま可視化されます。この残りの作業を、ファイルごとのclaude -pで回します(-pの基本はclaude -pオプションの解説にあります)。Claude Codeの公式ドキュメントは、スクリプト向けには--bareが推奨で、将来は-pの既定になる予定だと書いています。--bareは、hooks、スキル、プラグイン、MCPサーバー、自動メモリー、CLAUDE.mdの自動読み込みを省き、どのマシンでも同じ結果になるようにします。

--bareではClaudeがBash、ファイルの読み取り、ファイルの編集のツールだけを使えます。サブスクリプションのログインも使われないので、ANTHROPIC_API_KEYを環境変数に設定してから実行します。

# 2to3 と pyupgrade の後にテストが落ちたファイルを列挙しておく
# --tb=short でトレースバックに legacy/ 側のパスを出し、そこから拾う
pytest -q --tb=short 2>&1 | grep -o "legacy/[a-z_/]*\.py" | sort -u > files.txt
 
for f in $(cat files.txt); do
  claude --bare -p "$f を Python 3 で動くように直してください。\
tests/ と golden.json は変更せず、pytest が通るまで直す。\
bytes と str の境界は golden.json の kind に合わせる。\
終わったら OK、直せなければ FAIL とだけ返す。" \
    --append-system-prompt-file migration-rules.md \
    --allowedTools "Read,Edit,Bash(pytest *)" \
    --permission-mode dontAsk \
    --max-turns 15
done

ループの形は、公式のベストプラクティスにある、ファイルごとに移行してOKかFAILを返させる例に沿っています。止める仕組みは3つあります。

  • --allowedToolsで使えるツールを列挙する。ここでは読み取り、編集、pytestの実行だけにしています
  • --permission-mode dontAskで、リストに無い操作は確認なしに拒否する
  • --max-turnsで1ファイルあたりの往復を打ち切る。上限に達した実行はエラーで終了します

--bareはCLAUDE.mdを読み込みません。移行の規約(「文字列はテキスト、ファイルはrbで開く」など)は、--append-system-prompt-fileで渡します。規約の中身は、移行方針を数行で書いたmigration-rules.mdで足ります。

落とし穴

テストを直して通してしまう

golden.jsonやテストの編集を許すと、Claudeはテストの期待値を書き換えて緑にすることがあります。指示で「変更しない」と書くだけでなく、基準値のファイルを別ディレクトリに置いて読み取り専用で参照させるか、実行後にgit diff --stat tests/ golden.jsonで差分が空であることをスクリプトで確かめてください。テストだけを通す書き方を防ぐ指示の書き方は、テストだけ通すハードコードを防ぐプロンプトの書き方にあります。

最初から全ファイルに流さず、3ファイルほどで試してください。公式のベストプラクティスも、まず少数に当てて何が起きるか見てからプロンプトを直し、残りを回す進め方を勧めています。

規模が大きいときの選択肢

数百ファイル規模の移行では、ファイル単位のループよりも、並列で作業を分ける運用が向く場面があります。大規模なコード移行をオーケストレーションする事例は、BlockのClaude Fable 5によるコード移行の記事が扱っています。/batchコマンドの使い方はClaude Codeの/batchコマンドにまとまっています。

どの規模でも、特性化テストを先に固定し、止める条件(ターン数・ツール・権限)を明示する点は変わりません。無人で回すときの確認事項は、公式のベストプラクティスが「完了の定義」「自己検証の手段」「触れてよい範囲」「止める条件」「結果の行き先」の5問に整理しています。この移行では、順に「pytestが通ること」「golden.jsonとの一致」「legacy/配下だけ」「--max-turns」「PRでの人間の承認」にあたります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/headless
  2. code.claude.com/docs/en/best-practices
  3. docs.python.org/3.12/library/2to3.html
  4. github.com/asottile/pyupgrade
この記事を共有:XはてブLinkedIn