Pythonを書いていて、ふとこう思ったことはありませんか。いつも手で打っているあのコマンド、Pythonから呼べたら楽なのに、と。
gitの履歴を取り出す、画像をまとめて変換する、既存のシェルスクリプトを1本走らせる。ターミナルでなら一瞬で終わる作業です。
これをPythonのコードの中から実行するための標準ライブラリが、subprocessです。追加のインストールは要りません。
この記事では、subprocessの基本形から、初心者が事故を起こしやすい落とし穴までを順番に見ていきます。
subprocessは、Pythonから別のプログラムを呼び出す道具¶
subprocessという名前は、sub(子)とprocess(プロセス)を組み合わせたものです。プロセスとは、実行中のプログラム1つ分のことだと考えてください。
いまPythonを動かしている自分自身が親のプロセス。そこから新しく別のプログラムを起動すると、それが子プロセスになります。
subprocessは、この子プロセスを起動し、出力を受け取り、終了したかどうかを確認するための機能をまとめた標準ライブラリです。公式ドキュメントにも、os.systemやos.spawn系といった古い関数を置き換えることを意図したモジュールだと書かれています。
外部のコマンドを実行したいときの、いまの標準的な答えがこれ、というわけですね。
まずはsubprocess.runを動かしてみる¶
覚える関数は、ほとんど1つだけで足ります。subprocess.runを使えば、日常の用事はたいてい片づきます。
試しに、日付を表示するdateコマンドを呼んでみましょう。
import subprocess
result = subprocess.run(["date"])
print(result.returncode)
実行すると、ターミナルに日付が表示され、そのあとに0と出ます。この0が、コマンドが問題なく終わったという合図です。
ここで注目してほしいのは、コマンドをリストで渡している点です。コマンド名と引数を要素に分けて書くのが、subprocessの基本の形になります。
引数が増えても考え方は同じで、次のように並べていくだけです。
subprocess.run(["ls", "-l", "/tmp"])
ターミナルでスペースを打つ位置が、そのままリストの区切りになる。そう考えると、迷いにくくなるはずです。
返ってくるCompletedProcessの中身¶
runの戻り値は、CompletedProcessという名前のオブジェクトです。実行し終えた結果が、ここに詰まっています。
どんな情報が入っているのかを表にまとめました。
| 属性 | 入っているもの |
|---|---|
| args | 実行したコマンド。渡したリストがそのまま入る |
| returncode | 終了コード。0なら成功、それ以外は失敗 |
| stdout | 標準出力。受け取る指定をしたときだけ入る |
| stderr | 標準エラー出力。こちらも指定したときだけ入る |
0が成功という決まりは、Python独自のルールではありません。Unix系のコマンド全体に共通する、昔からの約束事です。
そのため、他人が書いたコマンドを呼ぶときでも、returncodeを見れば成功か失敗かを判断できます。
コマンドの出力をPythonで受け取る¶
さきほどの例では、dateの結果は画面に流れただけでした。これではPython側で加工できません。
出力を変数として受け取りたいときは、capture_outputという引数をTrueにします。
import subprocess
result = subprocess.run(
["ls", "-1", "/tmp"],
capture_output=True,
text=True,
)
files = result.stdout.splitlines()
print(len(files), files[:3])
capture_output=Trueを付けると、標準出力と標準エラー出力がresult.stdoutとresult.stderrに入ります。あとはPythonの文字列としてそのまま扱えます。
この2つの引数は、どちらもPython 3.7で追加されたものです。subprocess.run自体はPython 3.5からある関数なので、いま学び始める方が気にする必要はまずありません。
textを付けないとbytesで返ってくる¶
text=Trueを外すと、返ってくる値はbytesという別の型になります。表示すると先頭にbが付いた見た目になり、文字化けしたように感じる方も多いところです。
result = subprocess.run(["echo", "こんにちは"], capture_output=True)
print(result.stdout) # b'\xe3\x81\x93...'
bytesは、文字ではなくバイトの並びをそのまま持っている型です。日本語を含む出力だと、余計に読みにくく見えます。
文字列として扱いたいならtext=Trueを付ける。これを最初から習慣にしておくと、無用な混乱を避けられます。
コマンドの出力からファイルのパスを組み立てる場面も多いので、pathlibとあわせて覚えておくと便利です。【関連記事】Pythonのpathlibとは?ファイルパス操作を初心者向けに解説
shell=Trueが危ない理由¶
ネットでsubprocessの記事を探すと、shell=Trueという指定をよく見かけます。これを使うと、コマンドをリストではなく1本の文字列で書けるようになります。
一見すると便利です。ターミナルに打つのと同じ形で書けるのですから、そう思うのも自然でしょう。
ところがこの指定は、Pythonがコマンドを直接起動するのではなく、いったんシェルに文字列を解釈させる動きに変わります。シェルとは、ターミナルであなたの入力を受け取っている、あのプログラムのことです。
シェルはセミコロンやパイプ、ドル記号などを特別な意味を持つ記号として解釈します。ここに外部から来た値が混ざると、話が一気にきな臭くなります。
実際に何が起きるのかを確かめる¶
言葉だけではぴんと来ないので、手元で試せる形にしてみましょう。ユーザーが入力した名前を、そのままコマンドに埋め込んでいる想定のコードです。
import subprocess
name = "a; echo INJECTED"
# 危険な書き方
r1 = subprocess.run(f"echo {name}", shell=True, capture_output=True, text=True)
print(repr(r1.stdout)) # 'a\nINJECTED\n'
# 安全な書き方
r2 = subprocess.run(["echo", name], capture_output=True, text=True)
print(repr(r2.stdout)) # 'a; echo INJECTED\n'
上の実行結果に、はっきり差が出ています。shell=Trueのほうは、セミコロンから先が別のコマンドとして実行されてしまいました。
下のリスト形式では、文字列がまるごと1つの引数として扱われ、余計なコマンドは動いていません。リストで渡すかぎり、値の中身がコマンドとして解釈されることはないわけです。
今回はechoなので笑い話で済みますが、ここが削除コマンドだったらと考えると背筋が寒くなります。公式ドキュメントも、コマンド文字列を外部からの入力で組み立てる場合はshell=Trueの使用を強く控えるよう述べています。
私は10年ほどエンジニアとして開発に関わってきましたが、shell=Trueが原因の指摘は、いまでもコードレビューで定期的に出てきます。動くコードほど見過ごされやすいのが、この問題の厄介なところです。
失敗をきちんと拾う方法¶
subprocess.runは、コマンドが失敗しても例外を出しません。returncodeに0以外が入るだけで、処理はそのまま先へ進みます。
つまり、returncodeを見ないコードは、失敗に気づかないまま動き続けてしまいます。これが地味に怖いところです。
失敗したらその場で止めたいときは、check=Trueを付けます。
import subprocess
try:
subprocess.run(["ls", "/no/such/dir"], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
print("失敗しました:", e.returncode)
print(e.stderr.strip())
check=Trueを指定すると、終了コードが0以外のときにCalledProcessErrorという例外が上がります。この例外には、終了コードと、捕まえていればstdoutやstderrも入っています。
なお、コマンド自体が見つからない場合は少し別の動きになります。この場合はCalledProcessErrorではなく、FileNotFoundErrorが送出されます。
例外の読み方に自信がない方は、エラー文の追い方から確認しておくと安心です。【関連記事】Pythonのエラー文はどこを読めばいい?初心者向けtracebackの見方
応答が返ってこないときはtimeout¶
外部コマンドは、こちらの都合と関係なく固まることがあります。ネットワークの向こうが応答しない、入力待ちで止まっている、といった状況ですね。
そのまま待ち続けると、プログラム全体が止まってしまいます。これを防ぐのがtimeoutです。
import subprocess
try:
subprocess.run(["sleep", "5"], timeout=1)
except subprocess.TimeoutExpired:
print("時間内に終わりませんでした")
指定した秒数を超えると、TimeoutExpiredという例外が上がります。定期実行するスクリプトでは、これを付けておくかどうかで運用の安心感がまるで違います。
定期実行そのものの仕組みが気になる方は、こちらもあわせてどうぞ。【関連記事】Pythonで定期実行するには?cronとタスクスケジューラの基本
os.systemとの違いと使い分け¶
古い記事では、os.systemという関数で外部コマンドを実行している例を見かけます。1行で書けるので、手軽に見えるかもしれません。
ただし、できることの幅がまるで違います。両者を並べて比べてみましょう。
| 観点 | os.system | subprocess.run |
|---|---|---|
| 出力の取得 | できない(画面に流れるだけ) | capture_outputで受け取れる |
| シェルの経由 | 必ずシェルを通す | 既定では通さない |
| 失敗の検知 | 戻り値を自分で解釈する | check=Trueで例外にできる |
| タイムアウト | 指定できない | timeoutで指定できる |
| 公式の位置づけ | subprocessに置き換える対象 | 現在の推奨 |
必ずシェルを通すという性質上、os.systemはさきほどの注入の問題と常に隣り合わせです。新しく書くコードで選ぶ理由は、ほぼないと考えていいでしょう。
Linuxのコマンドそのものに不安がある場合は、先に基本を押さえておくと理解が早くなります。【関連記事】【Python初学者向け】Linuxで覚えておきたい基本コマンド完全ガイド
実務で使うときに気をつけていること¶
最後に、現場で私が意識している点を少しだけ共有します。どれも難しい話ではありません。
まず、Pythonで書ける処理をわざわざ外部コマンドに投げないこと。ファイルのコピーや一覧の取得は標準ライブラリだけで完結しますし、そのほうが環境の違いに強くなります。
以前、担当したバッチでファイルの移動をmvコマンドに任せていたことがありました。Windowsの検証環境に持っていった途端に全滅し、結局Pythonの標準ライブラリで書き直したという苦い記憶があります。
次に、コマンドのパスや引数を設定ファイルから読むときは、必ずリスト形式で渡すこと。文字列で組み立てた瞬間に、shell=Trueと同じ危うさが顔を出します。
そして、失敗したときに何が起きるかを先に決めておくこと。止めるのか、記録だけ残して進むのか。ここを決めずに書くと、あとから原因を追えないログだけが残ります。
つまずきポイントを表で確認する¶
ここまでに出てきた落とし穴を、症状から引ける形でまとめておきます。
| 症状 | 原因 | 対処 |
|---|---|---|
| 出力が変数に入らない | 受け取る指定がない | capture_output=Trueを付ける |
| 先頭にbが付いた値になる | bytesのまま扱っている | text=Trueを付ける |
| 失敗しても処理が進む | returncodeを見ていない | check=Trueを付ける |
| コマンドが見つからないと落ちる | FileNotFoundError | try文で受け止める |
| 処理が固まったまま戻らない | 応答待ちで止まっている | timeoutを指定する |
| 意図しないコマンドが動く | shell=Trueで文字列を渡した | リスト形式で渡す |
どれも一度ぶつかれば忘れないものばかりです。裏を返せば、知らないうちは必ず一度は時間を溶かします。
subprocessが使えるようになると、Pythonが手元の道具をまとめて動かす司令塔になります。作ったスクリプトをコマンドとして整えたくなったら、次はargparseに進んでみてください。【関連記事】Pythonでコマンドラインツールを作るには?argparse入門
まずはdateやlsのような、失敗しても困らないコマンドをrunで呼ぶところから始めてみましょう。動いた瞬間に、できることの幅が一段広がります。
ここまでお読みいただきありがとうございました。