Claude Media
Claude CodeでシェルスクリプトをPythonに書き換え、出力の一致を比べる

Claude CodeでシェルスクリプトをPythonに書き換え、出力の一致を比べる

シェルスクリプトをPythonへ書き換えるとき、先に入出力を固定し、同じ入力で出力を比べる手順です。素朴な移植が食い違った3点も実行結果で示します。

シェルスクリプトをPythonに書き換える作業は、Claude Codeに任せやすい部類です。ただし任せるほど、「動いているように見える」ことと「元と同じ出力になる」ことの差が広がります。見た目の整ったPythonが返ってきても、空行の扱いや並び順が違えば、置き換えた先で静かに数字がずれます。

この記事では、先に元スクリプトの出力を記録として固定し、書き換えのたびに同じ入力で比べる流れを作ります。題材は、アクセスログを集計する小さなスクリプトです。素朴に移植したPythonがどこで食い違ったかを、実行結果のまま載せます。

書き換えの前に固定するもの

固定するのは、スクリプトの外から観測できるものすべてです。標準出力だけを保存して満足すると、エラーメッセージや終了コードの食い違いを見逃します。

  • 標準出力(stdout)
  • 標準エラー出力(stderr)
  • 終了コード
  • 入力の種類(正常なログ、崩れたログ、空ファイル、存在しないパス)

Claude Codeの公式ドキュメントは、合否が返ってくる確認手段をClaudeに渡すと、変更して、実行して、壊れていれば出力を読んで直すというループを自力で回せると説明しています。確認手段の例には、テストファイルやリンターと並んで「出力をフィクスチャ(固定した期待値)と比べるスクリプト」が挙がっています。ここで作るのはまさにそれです。

もう一つ、先に決めておくことがあります。元の出力は、実行した環境のロケールで変わることがあります。実測は後の節に載せます。

題材のスクリプトとフィクスチャ

題材のreport.shは、空白区切りのログ(時刻、ステータス、パス、バイト数)を読みます。出すのは、ステータスごとの件数、件数上位3パス、バイト数の合計です。

#!/usr/bin/env bash
set -euo pipefail
f=${1:?usage: report.sh <log>}
[ -f "$f" ] || { echo "report.sh: no such file: $f" >&2; exit 66; }
echo "== status =="
awk '{print $2}' "$f" | sort | uniq -c | sort -k1,1nr -k2,2 \
  | awk '{printf "%s\t%s\n",$2,$1}'
echo "== top paths =="
awk '{print $3}' "$f" | sort | uniq -c | sort -k1,1nr -k2,2 | head -3 \
  | awk '{printf "%s\t%s\n",$2,$1}'
echo "== bytes =="
awk '{s+=$4} END{printf "total\t%d\n", s}' "$f"

フィクスチャはfixtures/に置く4ファイルです。

  • basic.log: 正常な5行
  • messy.log: 途中に空行があり、バイト数が-の行と、大文字のパス/Bを含む
  • empty.log: 空ファイル
  • case.log: /aと/Aのように、大文字小文字だけが違うパスを並べたもの

存在しないパスを渡す呼び出しも、入力の1種類として数えます。messy.logの中身は次のとおりです(2行目は空行)。

1 200 /a 100
 
2 200 /b -
3 404 /B 10
4 200 /a 7

壊れた入力を最初から入れておくのが要点です。シェル側はawkが雑に読み流すので、崩れた行も何かしらの出力になります。その「何かしら」が、書き換え後に守るべき仕様になります。

出力を記録し、同じ入力で比べる

記録用のcapture.shは、任意のコマンドを各フィクスチャに当て、stdout、stderr、終了コードをファイルに保存します。比較用のcompare.shは、同じことを別ディレクトリに行い、diff -rで突き合わせます。

#!/usr/bin/env bash
# capture.sh <command...>
export LC_ALL=C
out=${OUT:-golden}; mkdir -p "$out"
for f in fixtures/*.log fixtures/missing.log; do
  n=$(basename "$f" .log)
  "$@" "$f" >"$out/$n.out" 2>"$out/$n.err"; echo $? >"$out/$n.code"
  sed -i.bak -E 's/report\.(sh|py):/report:/' "$out/$n.err" \
    && rm "$out/$n.err.bak"
done
#!/usr/bin/env bash
# compare.sh <command...>
OUT=actual ./capture.sh "$@"
diff -r golden actual && echo "IDENTICAL"

fixtures/missing.logは存在しないパスで、ファイルが無いときの標準エラーと終了コードも記録に入ります。sedの行は、エラーメッセージの先頭にあるプログラム名(report.shとreport.py)の違いだけを吸収します。何を「同じ」とみなすかは、この1行で決めています。

記録は、書き換えを始める前に元のスクリプトで取ります。

./capture.sh ./report.sh
ls golden | head -4

golden/に、basic.out、basic.err、basic.codeのような3点組がフィクスチャごとにできます。macOSとawk 20200816版の環境で取ったmessy.logの記録は、次のとおりでした。

== status ==
200	3
	1
404	1
== top paths ==
/a	2
	1
/B	1
== bytes ==
total	117

空行は、ステータスもパスも空欄の1件として数えられています。-のバイト数は0として合計されました。このスクリプトにとっては、これが正しい出力です。

Claudeへの頼み方と、終わらせ方

指示は、書き換えの内容より先に合格条件を書きます。例えば次のような形です(公式の例文に沿った一例です)。

report.sh を Python に書き換えて report.py にして。
合格条件は ./compare.sh python3 report.py が IDENTICAL を出すこと。
差分が出たら golden を直さず report.py を直す。
終わったら、実行したコマンドとその出力を見せて。

「goldenを直さず」の一文は外せません。合格条件がClaudeの手の届く位置にあると、期待値側を実装に合わせて書き換えても条件を満たせてしまいます。記録は元のスクリプトから一度だけ取り、以後は読み取り専用として扱います。

出力を見せるよう頼むのにも理由があります。公式は、「テストは通りました」だけでは得られる情報が少なく、実行したコマンドとその出力を求めるほうが確かだと説明しています。

毎回この指示を書くのを避けるなら、CLAUDE.mdにコマンドだけ置きます。公式は、確認手段のあるリポジトリでは、それを走らせるコマンドがCLAUDE.mdで最も役に立つ1行だと説明しています。

# Commands
- 移植の検証: ./compare.sh python3 report.py(IDENTICAL なら合格)
- golden/ は元の report.sh から取った記録。編集しない

最後まで走らせたいなら、Stopフックで締める手もあります。Claudeが応答を終えようとするたびにスクリプトが走り、終了コード2を返すと作業に戻されます。標準エラーに出した内容が、終われない理由としてClaudeに渡ります。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command",
            "command": "./compare.sh python3 report.py 1>&2 || exit 2" }
        ]
      }
    ]
  }
}

diffが差分を見つけたときの終了コードは1です。フックがブロックに使う2へ変えるため、|| exit 2を付けています。ツール呼び出しを挟まずに8回連続でブロックすると、次のブロックは無視されてターンが終わる、という上限もあります。

テスト駆動の進め方はClaude Codeでテスト駆動開発を回す手順に、既存コードの挙動を記録するテストの考え方はClaude Codeで特性テストを書くにあります。ここで作ったgolden/は、その特性テストをファイル比較で済ませた形です。

素朴な移植は3点で食い違った

Claudeが最初に出しそうな形を再現するため、空行を読み飛ばし、int()でバイト数を足すだけのreport_v1.pyを用意し、compare.shに通しました。5つの入力のうち、messyとmissingで差分が出ました。

差分

素朴な移植で食い違った3点

  • 空行の扱い

    シェル側は空行を「ステータスもパスも空の1件」と数えます。Pythonで空行を読み飛ばすと、messy.outから\t1の行が2つ消え、代わりに/bが上位3件に入りました。

  • 数字でない値

    バイト数が-の行で、int('-')がValueErrorを出して終了コード1で落ちました。シェル側のawkは0として足し、合計117を出しています。

  • 終了コード

    存在しないファイルに対して、シェル側は66、Pythonは1でした。呼び出し側のスクリプトが終了コードで分岐していれば、ここから壊れます。

3つとも、Pythonとして間違った書き方ではありません。入力が崩れたときにシェルが黙ってやっていた挙動を、Pythonの常識で上書きしたことによる差です。目視のレビューでは気づきにくく、messy.logが入っていたから見えました。

挙動を揃えたreport.py

3点を直した版です。コメントは、なぜそうしているかだけに絞っています。

#!/usr/bin/env python3
import sys
from collections import Counter
 
def to_int(s):
    # awk の足し算と同じく、先頭の数字だけを読み、無ければ 0
    digits = ""
    for ch in s:
        if not ch.isdigit():
            break
        digits += ch
    return int(digits) if digits else 0
 
def main(argv):
    path = argv[1]
    try:
        with open(path) as fh:
            lines = fh.read().split("\n")
    except FileNotFoundError:
        print(f"report.py: no such file: {path}", file=sys.stderr)
        return 66
    if lines and lines[-1] == "":
        lines.pop()  # 末尾の改行が作る空要素だけ落とす。途中の空行は残す
    rows = [(l.split() + ["", "", "", ""])[:4] for l in lines]
    status = Counter(r[1] for r in rows)
    paths = Counter(r[2] for r in rows)
    print("== status ==")
    for k, v in sorted(status.items(), key=lambda kv: (-kv[1], kv[0])):
        print(f"{k}\t{v}")
    print("== top paths ==")
    for k, v in sorted(paths.items(), key=lambda kv: (-kv[1], kv[0]))[:3]:
        print(f"{k}\t{v}")
    print("== bytes ==")
    print(f"total\t{sum(to_int(r[3]) for r in rows)}")
    return 0
 
sys.exit(main(sys.argv))

Python 3.12.2で実行すると、5つの入力すべてで記録と一致しました。

./compare.sh python3 report.py
IDENTICAL

揃えたのは、このスクリプトが実際に通る入力の範囲だけです。to_intは小数、符号付きの値、16進表記を扱いません。入力にそれらが出てくるなら、先にフィクスチャを足し、記録を取り直してから実装を直す順で進めます。

比べる土台を先に揃える

差分が出る原因は、Pythonの書き方だけではありません。比較の土台がずれていると、一致して見えたり、毎回ずれたりします。

落とし穴

ロケールが違うと元の出力が変わる

sortの並び順はLC_ALLに依存します。case.logの上位3パスを2つのロケールで出すと、結果が変わりました。

LC_ALL=Cでは/A、/B、/_xの順です。LC_ALL=en_US.UTF-8では/_x、/a、/Aの順でした。記録を取った端末とCIでロケールが違うだけで、元のスクリプトの出力が変わります。capture.shがLC_ALL=Cを固定しているのはこのためです。Pythonのsorted()はコードポイント順で、Cロケールの並びと一致します。

次の3点も、記録を取る前に決めておきます。

  • 日時や乱数に依存する出力は、固定するか比較から外す。外すなら、比較スクリプトに理由をコメントで残す
  • エラーメッセージのプログラム名のように、変えると決めている差は、sedで吸収する範囲を明示する
  • 引数エラー(usage)の終了コードは、元の値に合わせるか、仕様として変えるかを決める

この記事のreport.pyは、引数なしの呼び出しを扱っていません。シェルの${1:?}が出すメッセージを再現するか変えるかは、呼び出し元を見て決める判断です。

長いスクリプトは区切って移す

行数の多いスクリプトを一度に書き換えると、差分が出たときに原因を絞れません。公式のリファクタ手順は、動作を保つよう指示すること、テストを走らせて確かめること、小さく試せる単位で進めることを挙げています。シェルから移すときも同じです。

  • 関数やawkのブロックごとに、対応するPython関数へ移す
  • 1つ移すたびに./compare.shを走らせ、IDENTICALを確かめてから次へ進む
  • curlやgitのような外部コマンドを呼ぶ部分は、まずPythonから同じコマンドを呼ぶ形で移し、ライブラリへの置き換えは別の段階にする

コードベース全体を別の言語へ移す規模の話は、Anthropicのブログ記事で扱われていると公式ドキュメントが案内しています。ここで扱ったのは、1本のスクリプトを単位にした移植です。

元のスクリプトに入出力のテストが無いなら、batsで先に書く手もあります。手順はClaude Codeでbatsのシェルスクリプトテストにあります。元のset -euo pipefailが何を止めていたかは、Claude CodeでBashをtrapとset -euo pipefailで堅牢にする手順で見直せます。pipefailの有無で終了コードが変わるため、Python側の終了コードを決めるときにも効きます。

まとめ

書き換えの合否は、Pythonとして読みやすいかではなく、崩れた入力でも元と同じ出力かで決まります。messy.logのように、シェルが黙って流していた入力を最初から入れると、素朴な移植は3点で食い違いました。

記録は元のスクリプトから一度だけ取り、LC_ALL=Cを固定して、読み取り専用にします。Claudeには比較コマンドを合格条件として渡し、Stopフックで締めれば、差分が残る間は作業が続きます。揃えない差は、sedの1行として目に見える形で決めておきます。

参照した一次ソース

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

  1. code.claude.com/docs/en/common-workflows
  2. code.claude.com/docs/en/best-practices
この記事を共有:XはてブLinkedIn