PythonからCLIツールや別のプログラムを呼び出すときは、標準ライブラリの subprocess を使うと、外部コマンドの終了を待って結果を受け取れます。
実務で困りやすいのは、コマンドを起動する部分よりも、その後の扱いです。
正常に終わったのか、標準出力には何が出たのか、失敗理由は標準エラーに出ているのか、そもそも処理が返ってこない場合にどう止めるのか。
ここまで決めておかないと、外部コマンドの失敗がPython側で曖昧になります。
この記事では、subprocess.run() に引数をリストで渡し、stdout、stderr、終了コード、非0終了、タイムアウトを扱う最小構成を見ていきます。
なお、今回使ったサンプルコードは、GitHubに置いています。記事では主要部分を抜粋していますが、実際に動かすコード一式はこのフォルダで確認できます。
外部コマンドは文字列ではなく引数のリストで渡す
subprocess.run() には、実行するプログラム名と引数をリストなどのシーケンスで渡せます。
たとえば、次のような形です。
subprocess.run(["python", "worker.py", "ok"])コマンド全体を1つの文字列として組み立てるよりも、プログラム名と引数を分けて渡すほうが、Python側で引数を扱いやすくなります。通常の用途では、不要に shell=True を使わず、この形を基本にすると考えやすいです。
今回はOSごとの差が出やすい ls や dir ではなく、外部コマンド役の小さなPythonスクリプトを呼び出します。呼び出し側では sys.executable を使い、同じPython実行ファイルで worker を起動します。
中心になる処理は次の部分です。
def run_worker(*args: str, timeout: float = 3.0) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(WORKER), *args],
capture_output=True,
text=True,
check=True,
timeout=timeout,
)ここでは、外部コマンドの引数をリストで渡したうえで、結果をPython側で扱いやすくするためのオプションをまとめて指定しています。
stdoutとstderrを取得する
capture_output=True を指定すると、標準出力と標準エラーを取得できます。さらに text=True を指定すると、通常の出力を文字列として扱えます。
外部コマンドが正常終了した場合、subprocess.run() は CompletedProcess を返します。returncode を見れば終了コード、stdout を見れば標準出力を確認できます。
今回の実行では、正常終了のケースで次の結果になりました。
success.returncode = 0
success.stdout = worker completed終了コード 0 は正常終了として扱える状態です。標準出力に出た worker completed も、Python側では stdout から参照できます。
この形にしておくと、外部コマンドの結果をログへ残したり、次の処理の入力にしたりできます。単に画面へ表示して終わるのではなく、Pythonの値として扱えるところがポイントです。
非0終了はCalledProcessErrorとして扱う
外部コマンドは、起動できても正常に終わるとは限りません。CLIツールなら、入力値の不備や処理中のエラーで終了コードだけが非0になることがあります。
check=True を指定しておくと、終了コードが非0だった場合に CalledProcessError が発生します。
try:
run_worker("fail")
except subprocess.CalledProcessError as exc:
print(f"failure.returncode = {exc.returncode}")
print(f"failure.stderr = {exc.stderr.strip()}")実行結果は次のとおりです。
failure.returncode = 7
failure.stderr = worker failedこのケースでは、外部コマンド自体は起動できています。そのうえで、コマンド側が終了コード 7 を返し、エラー内容を標準エラーへ出しています。
起動できなかった場合とは別物なので、非0終了は CalledProcessError として分けて扱うと、原因を追いやすくなります。
終わらない処理にはtimeoutを指定する
外部コマンドを呼ぶ処理では、コマンドが返ってこないケースも考えておく必要があります。待ち続けてしまうと、Python側の処理もそこで止まります。
timeout を指定すると、指定秒数を超えた場合に TimeoutExpired が発生します。
try:
run_worker("sleep", "--seconds", "2", timeout=0.2)
except subprocess.TimeoutExpired as exc:
print(f"timeout.exception = {type(exc).__name__}")
print(f"timeout.seconds = {exc.timeout}")実行結果は次のようになりました。
timeout.exception = TimeoutExpired
timeout.seconds = 0.2この例では、worker 側は2秒待機する指定ですが、呼び出し側の timeout は0.2秒です。そのため、終了コードを見る前にタイムアウトとして扱われます。
外部コマンドの処理時間が読めない場合は、タイムアウトを決めておくと、失敗を別の経路として処理できます。
プログラムを起動できない失敗もある
非0終了と混同しやすいのが、実行するプログラム自体を起動できないケースです。
たとえば、存在しない実行ファイルを指定した場合、プロセスが開始される前に失敗します。この場合は、コマンドが非0で終了したのではなく、Python側で起動に失敗しています。
代表例として、今回の実行では次の例外になりました。
oserror.exception = FileNotFoundErrorそのため、外部コマンドの失敗は大きく分けて考えると整理しやすくなります。
| 状態 | Python側で見るもの |
|---|---|
| 正常終了 | CompletedProcess.returncode が 0 |
| 非0終了 | CalledProcessError |
| タイムアウト | TimeoutExpired |
| 起動前の失敗 | FileNotFoundError などの OSError 系例外 |
同じ「失敗」に見えても、ログへ残す内容やユーザーへ返すメッセージは変わります。終了コード、標準エラー、例外の種類を分けて見ると、後から原因を追いやすくなります。
shell=Trueは必要なときだけ使う
subprocess は、通常はシステムシェルを暗黙には起動しません。引数をリストで渡す基本形なら、シェルを介さずにプログラムを起動できます。
tomoshell=True を指定すると、渡したコマンド文字列はシステムシェルによって解釈されます。ユーザー入力などを含む文字列をそのまま渡すと、意図しないコマンドとして解釈される危険があります。
シェルの機能が必要な場面はあります。ただ、単に外部プログラムを実行して引数を渡したいだけなら、まずはリストで渡す形を使うほうが安全に書きやすいです。
まとめ
Pythonから外部コマンドを実行するなら、subprocess.run() に引数をリストで渡し、結果までPython側で扱える形にしておくと実務で使いやすくなります。
基本形としては、capture_output=True、text=True、check=True、timeout を組み合わせると、標準出力、標準エラー、非0終了、タイムアウトを分けて扱えます。



外部コマンドの失敗は、すべて同じではありません。終了コードが非0だったのか、時間内に終わらなかったのか、そもそも起動できなかったのかを分けて見ることで、ログやエラー処理を書きやすくなります。


