Python Debugpy
目次
- skill の情報
- 参考: SKILL.md 全文
- 概要
- 使いどころ
- pdb 早見表
- レシピ 1: 手元でブレークポイントを置く
- レシピ 2: スクリプトを pdb の下で起動する(ソースは触らない)
- レシピ 3: pytest のテストをデバッグする
- レシピ 4: どんな例外でも事後解析する
- レシピ 5: debugpy でリモートからデバッグする(動いているプロセスに接続)
- 下ごしらえ
- パターン A: ソースを書き換えて、起動時にデバッガの接続を待たせる
- パターン B: ソースを触らず、-m debugpy で起動する
- パターン C: すでに動いているプロセスに接続する
- ターミナルからクライアントをつなぐ
- Hermes 固有のプロセスをデバッグする
- テスト
- run_agent.py と CLI — 一度きりの実行
- tui_gateway のサブプロセス(hermes --tui から起動される)
- _SlashWorker のサブプロセス
- gateway(gateway/run.py)
- つまずきやすいところ
- 確認リスト
- 場面別のレシピ
Python のデバッグです。pdb の REPL と、debugpy のリモート接続(DAP)を使います。
skill の情報
| 提供元 | 最初から入っています |
| パス | skills/software-development/python-debugpy |
| バージョン | 1.0.0 |
| 作者 | Hermes Agent |
| ライセンス | MIT |
| 対応プラットフォーム | linux, macos |
| タグ | debugging, python, pdb, debugpy, breakpoints, dap, post-mortem |
| 関連 skill | systematic-debugging, node-inspect-debugger |
参考: SKILL.md 全文
Python のデバッガ(pdb と debugpy)
概要
道具は 3 つあり、状況で使い分けます。
| ツール | 使いどころ |
|---|---|
breakpoint() と pdb |
手元で対話的に調べる、いちばん手軽なやり方です。ソースに breakpoint() を書いて普通に実行すると、その行で REPL が開きます。 |
python -m pdb |
手元のスクリプトを、ソースを書き換えずに pdb の下で起動します。ちょっと覗きたいときに便利です。 |
debugpy |
リモート、画面なし、「すでに動いているプロセスに後から接続する」場合に使います。DAP でやり取りし、ターミナルからスクリプトで操作でき、長く動き続けるプロセス(gateway、デーモン、PTY の子プロセス)にも使えます。 |
まずは breakpoint() から。 いちばん手間がかからず、それで済むことが多いからです。
使いどころ
- テストが失敗したが、traceback を見ても値がおかしい理由がわからない
- 関数を 1 行ずつ進めながら、コレクションが書き換わる様子を見たい
- 長く動き続けるプロセス(hermes gateway、tui_gateway)の挙動がおかしく、再起動もできない
- 事後解析。本番同然のコードで例外が出たので、落ちた場所のローカル変数を調べたい
- サブプロセスや子プロセス(Python の
_SlashWorker、PTY のブリッジワーカー)そのものがバグの現場である
使わなくてよい場面: print() や logging.debug で 1 分もかからず片づくこと、pytest -vv --tb=long --showlocals ですでにわかることです。
pdb 早見表
pdb のプロンプト((Pdb))で使えます。
| コマンド | はたらき |
|---|---|
h / h cmd |
ヘルプ |
n |
次の行へ(関数の中には入らない) |
s |
関数の中に入る |
r |
いまの関数から抜けるまで進める |
c |
実行を続ける |
unt N |
N 行目まで進める |
j N |
N 行目に飛ぶ(同じ関数の中だけ) |
l / ll |
いまの行の周辺 / 関数全体のソースを表示 |
w |
いまどこにいるか(スタックトレース) |
u / d |
スタックを 1 つ上 / 下へ移動 |
a |
いまの関数の引数を表示 |
p expr / pp expr |
式を表示 / 整形して表示 |
display expr |
停止するたびに式を自動で表示 |
b file:line |
ブレークポイントを置く |
b func |
関数に入ったところで止める |
b file:line, cond |
条件つきブレークポイント |
cl N |
N 番のブレークポイントを消す |
tbreak file:line |
一度だけ効くブレークポイント |
!stmt |
任意の Python を実行する(代入も可) |
interact |
いまのスコープのまま本物の Python REPL に入る(Ctrl+D で戻る) |
q |
終了 |
いちばん強力なのは interact です。何でも import でき、複雑なオブジェクトを調べられ、状態を書き換えるメソッドまで呼べます。ローカル変数は既定では読み取り専用なので、書き換えたいときは (Pdb) プロンプトから !x = 42 のようにします。
レシピ 1: 手元でブレークポイントを置く
いちばん簡単なやり方です。ファイルをこう書き換えます。
def compute(x, y):
result = some_helper(x)
breakpoint() # <-- drops into pdb here
return result + yあとは普通に実行します。breakpoint() の行で止まり、ローカル変数に自由に触れます。
コミットする前に breakpoint() を消すのを忘れないでください。 git diff で確かめるか、コミット前に grep をかけます。
rg -n 'breakpoint\(\)' --type pyレシピ 2: スクリプトを pdb の下で起動する(ソースは触らない)
python -m pdb path/to/script.py arg1 arg2
# Lands at first line of script
(Pdb) b path/to/script.py:42
(Pdb) cレシピ 3: pytest のテストをデバッグする
hermes のテストランナーも pytest も、これに対応しています。
# Drop to pdb on failure (or on any raised exception):
scripts/run_tests.sh tests/path/to/test_file.py::test_name --pdb
# Drop to pdb at the START of the test:
scripts/run_tests.sh tests/path/to/test_file.py::test_name --trace
# Show locals in tracebacks without pdb:
scripts/run_tests.sh tests/path/to/test_file.py --showlocals --tb=longただし scripts/run_tests.sh は、run_tests_parallel.py を通してテストファイルごとに出力を取り込んだサブプロセスで走らせます(xdist は使っていません)。そのため、このラッパー越しでは対話的な pdb は動きません。--pdb を使いたいときは pytest を直接実行してください。
source .venv/bin/activate
python -m pytest tests/foo_test.py::test_bar --pdbこの方法だと環境を隔離する仕組みは効かなくなります。デバッグ中はそれでかまいませんが、push する前にラッパー越しで走らせ直して確かめてください。
レシピ 4: どんな例外でも事後解析する
try:
run_the_thing()
except Exception:
pdb.post_mortem(sys.exc_info()[2])スクリプト全体を包むこともできます。
python -m pdb -c continue script.py
# When it crashes, pdb catches it and you're in the frame of the exceptionREPL や jupyter では、全体にフックを仕掛けるやり方もあります。
def excepthook(etype, value, tb):
import pdb; pdb.post_mortem(tb)
sys.excepthook = excepthookレシピ 5: debugpy でリモートからデバッグする(動いているプロセスに接続)
長く動き続けるプロセス向けです。Hermes の gateway、tui_gateway、デーモン、すでに挙動がおかしくてきれいに再起動できないプロセスなどが対象になります。
下ごしらえ
source <hermes-agent-repo>/.venv/bin/activate
pip install debugpyパターン A: ソースを書き換えて、起動時にデバッガの接続を待たせる
エントリポイントの先頭近く(またはデバッグしたい関数の中)に、次を足します。
debugpy.listen(("127.0.0.1", 5678))
print("debugpy listening on 5678, waiting for client...", flush=True)
debugpy.wait_for_client()
debugpy.breakpoint() # optional: pause immediately once attachedプロセスを起動すると、wait_for_client() のところで止まったまま待ちます。
パターン B: ソースを触らず、-m debugpy で起動する
python -m debugpy --listen 127.0.0.1:5678 --wait-for-client your_script.py arg1モジュールとして起動する場合も同じ要領です。
python -m debugpy --listen 127.0.0.1:5678 --wait-for-client -m your.moduleパターン C: すでに動いているプロセスに接続する
PID がわかっていて、相手の環境に debugpy が入っていることが前提です。
python -m debugpy --listen 127.0.0.1:5678 --pid <pid>
# debugpy injects itself into the process. Then attach a client as below.カーネルやセキュリティ設定によっては、ptrace を使った割り込みが止められています(/proc/sys/kernel/yama/ptrace_scope)。その場合はこうします。
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scopeターミナルからクライアントをつなぐ
ターミナル側の DAP クライアントとしていちばん手軽なのは、VS Code の CLI か、小さなスクリプトです。Hermes の中からなら、現実的な選択肢は 2 つあります。
選択肢 1: debugpy 用の自作 CLI REPL — 公式の機能ではありませんが、ごく小さな DAP クライアントを書けます。
# /tmp/dap_client.py
HOST, PORT = "127.0.0.1", 5678
s = socket.create_connection((HOST, PORT))
seq = itertools.count(1)
def send(msg):
msg["seq"] = next(seq)
body = json.dumps(msg).encode()
s.sendall(f"Content-Length: {len(body)}\r\n\r\n".encode() + body)
def recv():
header = b""
while b"\r\n\r\n" not in header:
header += s.recv(1)
length = int(header.decode().split("Content-Length:")[1].split("\r\n")[0].strip())
body = b""
while len(body) < length:
body += s.recv(length - len(body))
return json.loads(body)
send({"type": "request", "command": "initialize", "arguments": {"adapterID": "python"}})
print(recv())
send({"type": "request", "command": "attach", "arguments": {}})
print(recv())
send({"type": "request", "command": "setBreakpoints",
"arguments": {"source": {"path": sys.argv[1]},
"breakpoints": [{"line": int(sys.argv[2])}]}})
print(recv())
send({"type": "request", "command": "configurationDone"})
# ... loop reading events and sending continue/stepIn/etc.一度きりの自動化なら十分ですが、対話的に使うにはつらい方法です。
選択肢 2: VS Code / Cursor / Zed からつなぐ — 利用者がそれらを開いているなら、launch.json にこう足してもらえます。
{
"name": "Attach to Hermes",
"type": "debugpy",
"request": "attach",
"connect": { "host": "127.0.0.1", "port": 5678 },
"justMyCode": false,
"pathMappings": [
{ "localRoot": "${workspaceFolder}", "remoteRoot": "<hermes-agent-repo>" }
]
}選択肢 3: DAP をやめて remote-pdb を使う — ターミナルで動くエージェントが本当に欲しいのは、たいていこちらです。
pip install remote-pdbコードにはこう書きます。
from remote_pdb import set_trace
set_trace(host="127.0.0.1", port=4444) # blocks until connectionそのうえで、ターミナルからつなぎます。
nc 127.0.0.1 4444
# You get a (Pdb) prompt exactly as if debugging locally.debugpy の DAP が大げさすぎるとき、remote-pdb はエージェントにとっていちばん扱いやすい選択肢です。debugpy は、IDE との連携が本当に必要なときだけ使ってください。
Hermes 固有のプロセスをデバッグする
テスト
レシピ 3 を見てください。ラッパーはサブプロセスの出力を取り込んでしまうので、対話的な pdb を使うなら pytest を直接実行します。
run_agent.py と CLI — 一度きりの実行
いちばん簡単なのは、怪しい行の近くに breakpoint() を置いて hermes を普通に実行することです。止まった時点で、操作はターミナルに戻ってきます。
tui_gateway のサブプロセス(hermes --tui から起動される)
gateway は Node 製 TUI の子プロセスとして動きます。やり方は 2 つあります。
A. gateway のソースを書き換える:
# tui_gateway/server.py near the top of serve()
debugpy.listen(("127.0.0.1", 5678))
debugpy.wait_for_client()hermes --tui を起動します。TUI は固まったように見えます(裏側が待っているためです)。クライアントをつないで continue すると、実行が再開します。
B. 目的のハンドラで remote-pdb を使う:
from remote_pdb import set_trace
set_trace(host="127.0.0.1", port=4444) # in the RPC handler you want to trapTUI から対応するスラッシュコマンドを実行し、別のターミナルで nc 127.0.0.1 4444 とつなぎます。
_SlashWorker のサブプロセス
やり方は同じです。ワーカーの exec の経路に remote-pdb の set_trace() を置きます。このワーカーはスラッシュコマンドをまたいで生き続けるので、最初の 1 回は接続するまで止まりますが、以降のスラッシュコマンドは仕掛け直さないかぎりそのまま通ります。
gateway(gateway/run.py)
長く動き続けます。ハンドラに remote-pdb を置くか、どのみち gateway を再起動するなら debugpy に --wait-for-client を付けて使います。
つまずきやすいところ
- 並列実行や出力の取り込みを行うランナーの下では、pdb は何も言わずに効かなくなります。 プロンプトは表示されず、テストがただ固まります(pytest-xdist でも、
scripts/run_tests.shがファイルごとに出力を取り込むサブプロセスでも同じです)。対話的にデバッグするときは、1 ファイルに絞って pytest を直接実行してください。
- CI や端末のない場所では、
breakpoint()はプロセスを固まらせます。 手元では安全ですが、絶対にコミットしないでください。念のため、コミット前の grep を仕掛けておきます。
PYTHONBREAKPOINT=0はすべてのbreakpoint()を無効にします。ブレークポイントで止まらないときは、環境変数を確かめてください。
echo $PYTHONBREAKPOINTdebugpy.listenだけでは止まりません。wait_for_client()も呼ぶ必要があります。 呼ばないと実行はそのまま進み、クライアントがつながる前に最初のブレークポイントを通り過ぎることがあります。
- 設定を固めたカーネルでは、PID への接続が失敗します。
ptrace_scope=1(Ubuntu の既定)では、同じユーザーの子プロセスに対する ptrace しか許されません。逃げ道はecho 0 > /proc/sys/kernel/yama/ptrace_scope(root が必要)か、最初からdebugpyの下で起動することです。
- スレッド。
pdbがデバッグできるのは、いまのスレッドだけです。マルチスレッドのコードではdebugpy(スレッドを扱える DAP)を使うか、スレッドごとにthreading.settrace()を設定します。
- asyncio。
pdbはコルーチンの中でも動きますが、pdb の中でawaitするには Python 3.13 以降が必要で、それより古い版ではinteractモードからawaitします。3.11 や 3.12 では、asyncio.run_coroutine_threadsafeを使う小技か、asyncio.ensure_futureを!stmtで呼ぶ形で待ちます。
scripts/run_tests.shは認証情報を取り除き、HOME=<tmpdir>を設定します。 バグが利用者の設定や本物の API キーに依存している場合、このラッパーの下では再現しません。まず素のpytestで再現させ、そのあとラッパー越しで確かめ直してください。
- fork と multiprocessing。 pdb は fork の先を追いません。子プロセスにはそれぞれ
breakpoint()かset_trace()が要ります。Hermes のサブエージェントは、1 プロセスずつデバッグしてください。
確認リスト
- [ ]
pip install debugpyのあと、python -c "import debugpy; print(debugpy.__version__)"で入ったことを確かめる - [ ] リモートデバッグでは、ポートが実際に待ち受けているか確かめる:
ss -tlnp | grep 5678 - [ ] 最初のブレークポイントで本当に止まる(止まらないなら、
PYTHONBREAKPOINT=0になっている、並列実行や出力の取り込みを行うランナーの下にいる、接続前に実行が終わっている、のどれかです) - [ ]
whereまたはwで、想定どおりの呼び出し履歴が出る - [ ] 後片づけ。コミットするコードに
breakpoint()やset_trace()が残っていない
rg -n 'breakpoint\(\)|set_trace\(|debugpy\.listen' --type py場面別のレシピ
「この dict にキーがないのはなぜ?」
# add above the KeyError site
breakpoint()
# then in pdb:
(Pdb) pp d
(Pdb) pp list(d.keys())
(Pdb) w # how did we get here「単体では通るのに、まとめて実行すると落ちるテスト」
scripts/run_tests.sh tests/the_test.py # confirm it fails under the isolated runner first
# For interactive debugging, or if it only fails WITH other tests:
source .venv/bin/activate
python -m pytest tests/ -x --pdb
# Now it pdb-traps at the exact failing test after state accumulated.「非同期のハンドラがデッドロックする」
# Add at handler entry
ハンドラを動かします。nc 127.0.0.1 4444 でつなぎ、w で止まっているフレームを見て、!import asyncio; asyncio.all_tasks() でほかに何が残っているかを見ます。
「Ink の子プロセスやサブプロセスが落ちたときの事後解析」
PYTHONFAULTHANDLER=1 python -m pdb -c continue path/to/entrypoint.py
# On crash, pdb lands at the frame of the exception with full locals