Pythonからシェルコマンドを実行!subprocessでサブプロセスを実行する方法まとめ

2019.12.23

こんにちは、平野です。

PythonからAWS CLIなどのシェルコマンドを使いたい時には標準モジュールである subprocessモジュールを使いますが、 結構ややこしい感じがしてて上手く使えていませんでした。

subprocess --- サブプロセス管理

シェルコマンドを非同期に動かして色々とごにょごにょしたかったので、 自分の用途の範囲ですがまとめてみました。

なお、subprocessはシェルコマンド実行のモジュールとして紹介されることも多いですが、 名前の示す通り、Pythonを実行しているプロセスからサブプロセスを生成するためのものです。
この辺の勘違いも、上手く理解できていなかった原因かなぁと思います。

動作環境

  • macOS Mojave 10.14.6
$ python -V
Python 3.7.5

Python2系については全く調べていませんので悪しからず。。

同期処理

同期処理でコマンドを実行する際、 つまりサブプロセスに仕事させて終わるまで待つ場合はsubprocess.runを使います。

コマンドを実行して出力結果を得る

出発点となるスクリプトは以下のようになります。

# dateコマンドを実行して文字列として結果を得る
import subprocess
from subprocess import PIPE

proc = subprocess.run("date", shell=True, stdout=PIPE, stderr=PIPE, text=True)
date = proc.stdout
print('STDOUT: {}'.format(date))
# STDOUT: 2019年 12月19日 木曜日 11時46分14秒 JST

procにサブプロセスの結果が格納されているので、そこからstdoutを取得できます。 標準エラー出力についても同様です。

runメソッドに与える引数について少し紹介します。

shell

コマンド文字列(第一引数)をシェルで展開します。

これを指定しない場合は、第一引数はリストである必要があります。1 つまり、文字列の解析をモジュールがやってくれないので、 ユーザが自分で要素ごとに分けたリストを用意してあげる必要があります。 shell=Trueとすることで、文字列からの解析をモジュールに任せることができます。 普通にコマンドラインから実行する感じで使うためにはこの設定が必要です。

ただし、これを有効にするとシェルインジェクション攻撃などの危険性もありますので注意が必要です。 今回は単純さのために特に記述していませんが、 実際に使う場合はshlex.quote()などを使用するようにしてください。

subprocess --- サブプロセス管理 - セキュリティで考慮すべき点

stdout, stderr

サブプロセスの標準出力、標準エラー出力をつなげる先のファイルディスクリプタを指定します。

通常はファイルディスクリプタを設定するのですが、特別にsubprocess.PIPEというものが用意されています。 これを設定すると、例にあるように、proc.stdoutなどとしてPythonスクリプト内で出力を取得できます。

基本的にはPIPEを設定することが多いかと思いますが、 Pythonプロセスと同様に標準出力に出力したい場合はsys.stdoutを設定します。 標準エラー出力も同様に設定できます。

text

入出力をテキストとしてデコード、エンコードしてくれます。 特別バイナリを扱うという場合でなければ設定しておいた方がラクそうです。

Python内の変数を渡す

Pythonスクリプト内の変数を、stdinとしてサブプロセスに渡すことができます。

# 変数をstdinとしてサブプロセスに渡す
import subprocess
from subprocess import PIPE

array = [i for i in range(100)]
input_text = '\n'.join(array)
proc = subprocess.run("cat -n", shell=True, input=input_text, stdout=PIPE, stderr=PIPE, text=True)
print(proc.stdout)
#   1   1
#   2   2
#   3   3
#   ・・・

input

stringを渡すと、それがサブプロセスのstdinに渡されます。 内部的にはstdin=subprocess.PIPEなどが使われているようですが、 使うだけなら気にする必要はありません。

ファイルの入出力

stdin, stdoutにはファイルディスクリプタを指定できるので Pythonのプロセスでファイルの中身をメモリに載せることなくサブプロセスでファイルの入出力ができます。

# ファイルの中身に行数をつけて別ファイルに保存する
import subprocess
from subprocess import PIPE

with open('input.txt') as input_file:
    with open('output.txt', 'w') as output_file:
        proc = subprocess.run("cat -n", shell=True, stdin=input_file, stdout=output_file, text=True)

実は、shell=Trueの状態ではコマンドの中身にはほぼ何でも書けるので、 次のような書き方でも同じことができます。

# ファイルの中身に行数をつけて別ファイルに保存する
import subprocess
from subprocess import PIPE

proc = subprocess.run("cat input.txt | cat -n > output.txt", shell=True, text=True)

サブプロセスのシェルでファイルを開いて、 パイプで処理を繋いだり、リダイレクトでファイル書き出しなんかも問題なく行うことができます。

非同期に使う

subprocess.runは同期処理なので、サブプロセスが終了するまでPythonは次の処理に進みません。 せっかく別プロセスが立ち上がっているのですから非同期に処理を行いたいところです。

非同期で処理を行うためにはsubprocess.Popenを使います。 基本的な使い方としては

  1. Popenでサブプロセスの処理をスタートさせ
  2. communicateでサブプロセスの終了を待ち、出力結果を使う

となり、具体的は以下のようになります。

import subprocess
from subprocess import PIPE

# サブプロセスをスタート
proc = subprocess.Popen("sleep 60; ls hoge.py aaa", shell=True, stdout=PIPE, stderr=PIPE, text=True)

# サブプロセスの完了を待つ
# ここではsleepしている60秒待たされる
result = proc.communicate()

(stdout, stderr) = (result[0], result[1])
print('STDOUT: {}'.format(stdout)
print('STDERR: {}'.format(stderr)
# STDOUT: hoge.py
# STDERR: ls: aaaa: No such file or directory

Popenに渡せる引数は、runとほぼ同様ですが、 大きな違いとしてinputを指定することはできません。 実はcommunicateにはinputを渡すことができるのですが、 それでは同期処理になってしまいますのでうまくいきません。 つまり、Pythonの変数に格納されている文字列を非同期処理の入力に渡すにはひと工夫必要となります。

名前付きパイプを使って非同期処理に変数を渡す

具体的な解決策としては、名前付きパイプを使えばうまくいきました。 普通のファイルに一度書き出しをしても構いませんが、 書き出す量が多い時には書き出しが終わるまでの時間が無駄になりますし、 やっぱりスマートじゃないです。

ということでまずは適当に名前付きパイプを用意します。

# named_pipeという名前の名前付きパイプを作成
$ mkfifo named_pipe
import time
import subprocess
from subprocess import PIPE

s = 'aiueo'
proc = subprocess.Popen("cat {} | cat -n; sleep 60".format('named_pipe'), shell=True, stdout=PIPE, stderr=PIPE, text=True)
with open('named_pipe', 'w') as fifo:
    fifo.write(s)

# サブプロセスが処理終わるまでの間に、並列で別の重い処理
time.sleep(30)

# サブプロセスの完了を待つ
result = proc.communicate()
(stdout, stderr) = (result[0], result[1])
print('STDOUT: {}'.format(stdout)
# STDOUT:  1  aiueo

ポイントとしては、fifoに書き込みを行う前にサブプロセスを作成する点です。 これを逆にしてしまうと、誰も読み出ししていない名前付きパイプに対する書き込み処理が終了しないので、 プロセスが止まってしまいます。

runPopenの関係

同期処理の方法(run)と非同期処理の方法(Popen)を紹介しましたが、 実はこれらは非常にシンプルな関係にあります。

runは、内部的にPopenを呼んで、すぐにcommunicateしている。

これに気づくと両者はとてもスマートに理解できます。 例えば、runではinputで標準入力を渡すことができましたが、Popenではそれができませんでした。 しかしcommunicateにはinputが指定できます。 つまり、runは中でPopenしてすぐにcommunicate(input=string)を実行しているとわかります。

まとめ

以上、subprocessモジュールの使い方をまとめてみました。 色々メソッドがありつつも、 runPopenだけ使えば良い2というシンプルなまとめを得ることができました。 この辺を知っていればたいていの処理には対応できるかと思います。

私のお気に入りとしては、inputを使うことで、 Python内の文字列変数をシェルコマンドで変換した文字列に変えられるところです。 シェルコマンドが用意されていて、それを使えばすぐできるのに、 Pythonの中で自分で実装を書くのは面倒という場合なんかに使えるかと思います。
ただし、サブプロセスを起動する処理はそこまで軽いわけではないので、 1行ずつ変換処理を行うようなスクリプトを書くと びっくりするほど実行は遅くなります。

以上、誰かの参考になれば幸いです!


  1. 文字列も渡せるが、長さ1のリストとして扱われる 
  2. 極論Popenだけでもいいけど