サブプロセス Python編

# サブプロセス Python

PythonのSubprocessについてのまとめノートです。

尚、この記事のコードを試す場合、最低でもPython version 3.5以上の環境をお勧めします。一部3.7以上必要なものもあります。

## 動作環境
```sh
name -srv
# Linux 5.4.0-121-generic #137-Ubuntu SMP Wed Jun 15 13:33:07 UTC 2022

python --version
# Python 3.8.5

```

## Subprocessモジュールの概略
- **run()** : サブプロセスを起動させるための関数。
  - 内部でPopenクラスのオブジェクトを生成する
  - 最も基本的な使用方法は、コマンドを文字列のリストとしてrun()関数の引数に渡す [→コード例](#シンプルな呼び出し)
  - 引数のうち、timeout, input, check, capture_output以外はそのままPopenクラスのコンストラクタに渡される
- class **Popen** : サブプロセス生成、管理のためのクラス
  - 直接このクラスオブジェクトを生成も可能
    この記事では対象外
- class **CompletedProcess** : 終了した子プロセスの情報を保持するクラス
  - run()の戻り値として返ってくるオブジェクトのクラス
- exception 
  - **TimeoutExpired** : サブプロセスのタイムアウト例外 [→コード例](#子プロセスのタイムアウト)
  - **CalledProcessError** : サブプロセスのエラー発生(終了ステータスが0以外)例外[→コード例](#子プロセスのエラー時に親プロセスで例外を発生させる)

## サンプルコード
### 実行方法
子プロセス、親プロセスの各スクリプトをそれぞれchild.py, parent.pyとする。
カレントディレクトリに存在し、以下のような実行を行うこととする。

```sh
ls
# child.py parent.py

python parent.py
```

parent.pyから、`python child.py [オプション]`という形のコマンドをサブプロセスとして起動する。

### 子プロセス（共通）: child.py
擬似的な子プロセス用のコード。

```
usage: child.py [-h] [-r RETCODE] [-d DURATION] [-e]

options:
  -h, --help            show this help message and exit
  -r RETCODE, --retcode RETCODE
  -d DURATION, --duration DURATION
  -e, --exception

```

- `-r 整数` で終了ステータスを指定 (デフォルトは0) 
   0以外の場合、stderrに擬似的エラーメッセージ出力
- `-d 整数` で処理時間(今回はsleepするだけ)を指定 (デフォルトは5[sec])
- `-e`で例外を発生させるように指定 (デフォルトは例外無し)


```python:child.py
#!/usr/bin/env python
import os, sys
from time import sleep
from argparse import ArgumentParser

def func(retcode=0, duration=5, exception=False):
    print('child : PID={}, PPID={}'.format( os.getpid(), os.getppid()))
    sleep(duration)
    if retcode != 0:
        print('ERR child : PID={}, PPID={}'.format(os.getpid(), os.getppid()),
                file=sys.stderr)
    if exception :
        raise RuntimeError
    print('child : I\'m done.')
    return retcode

if __name__ == '__main__':
    parser = ArgumentParser()
    parser.add_argument('-r', '--retcode', type=int, default=0)
    parser.add_argument('-d', '--duration', type=int, default=5)
    parser.add_argument('-e', '--exception',action='store_true', default=False)
    args = parser.parse_args()
    print(__file__)
    print("child :", vars(args))

    sys.exit(func(**vars(args)))
```

`argparse`については別途記事を書く予定。

### 親プロセス (実装例各種) : parent.py

#### 共通部分
以降のサンプルコードは以下のモジュールを使用している。

```python:parent.py
import subprocess
import os
import sys 
```

#### シンプルな呼び出し
`run()`を使用してコマンドを呼び出す。引数に` ['python', 'child.py' ]`を渡す。


```python:parent.py

def call_subproc(cmd):
    print('parent : PID={}'.format(os.getpid()))
    completeproc = subprocess.run(cmd)
    print('parent : child\'s exit status', completeproc.returncode)

if __name__ == '__main__':
    print(__file__)

    # python child.py    
    # 終了ステータス=0、処理時間=5秒、例外無し
    call_subproc( ['python', 'child.py' ])

``` 

戻り値としてCompletedProcessクラスのオブジェクト(completeproc)が返ってくる。

終了ステータス(completeproc.returncode)を確認することでエラーの有無を確認できる。

実行時の出力例

```
parent.py
parent : PID=21076
child.py
child : {'retcode': 0, 'duration': 5, 'exception': False}
child : PID=21109, PPID=21076
child : I'm done.
parent : child's exit status 0

```

#### 子プロセスにbash
子プロセスはPythonコードである必要はない。

```python:parent.py
# 略
## 上記のcall_subproc()の定義を参照

if __name__ == '__main__':
    print(__file__)
    call_subproc( [ 'bash', '-c', 'echo $$ $PPID; echo ERR $$ $PPID 1>&2; exit 1; ' ])

```
出力例

```
parent.py
parent : PID=22196
22229 22196
ERR 22229 22196
parent : child's exit status 1
```

#### 標準出力を`/dev/null`へリダイレクト
`stdout`に`subprocess.DEVNULL`を指定する。

```python
def call_sub_devnull(cmd):
    print('parent : PID={}'.format(os.getpid()))
    completeproc = subprocess.run(cmd, stdout=subprocess.DEVNULL)
    print('parent : child\'s exit status', completeproc.returncode)

if __name__ == '__main__':
    print(__file__)

    call_sub_devnull( ['python', 'child.py' ])
```

実行時の出力例

```
parent.py
parent : PID=21528
parent : child's exit status 0
```

#### 子プロセスのstdout,stderr
##### 子プロセスでエラー
子プロセスがstderrにエラーメッセージを出力し、ステータス=１で返ってくる場合を擬似的に再現。

`capture_output`を使用することで子プロセスのstdoutやstderrの出力を受け取れる。デフォルトではバイトデータで渡されるため、`text`を指定して文字列で受け取る。

注意:`text`はPython version 3.7以降必要。

```python:parent.py
def call_sub_stderr():
    print('parent : PID={}'.format(os.getpid()))

    # python child.py -r 1
    # 終了ステータス=1、処理時間=5秒、例外無し
    completeproc = subprocess.run(
            ['python', 'child.py', '-r', '1' ],
            capture_output=True,
            text=True,
            )   
    print('parent : child\'s exit status', completeproc.returncode)
    print('parent : child\'s stdout ---')
    print(completeproc.stdout)
    print('parent : -------------------')
    print('parent : child\'s stderr ---')
    print(completeproc.stderr)
    print('parent : -------------------')

if __name__ == '__main__':
    print(__file__)

    call_sub_stderr()
```

出力例

```
parent.py
parent : PID=21625
parent : child's exit status 1
parent : child's stdout ---
child.py
child : {'retcode': 1, 'duration': 5, 'exception': False}
child : PID=21658, PPID=21625
child : I'm done.

parent : -------------------
parent : child's stderr ---
ERR child : PID=21658, PPID=21625

parent : -------------------

```


##### 子プロセスがPython例外
Pythonコードで、例外が発生してエラー終了する場合は例外のメッセージがstderrに出力される。

```python:parent.py
def call_sub_except():
    print('parent : PID={}'.format(os.getpid()))

    # python child.py -e   
    # 終了ステータス=0、処理時間=5秒、例外有り
    #→例外発生により最終的には終了ステータスは1
    completeproc = subprocess.run(
            ['python', 'child.py', '-e' ],
            capture_output=True,
            text=True,
            )   
    print('parent : child\'s exit status', completeproc.returncode)
    print('parent : child\'s stdout ---')
    print(completeproc.stdout)
    print('parent : -------------------')
    print('parent : child\'s stderr ---')
    print(completeproc.stderr)
    print('parent : -------------------')

    if __name__ == '__main__':
    print(__file__)

    call_sub_except()
```

出力例

```
parent.py
parent : PID=21866
parent : child's exit status 1
parent : child's stdout ---
child.py
child : {'retcode': 0, 'duration': 5, 'exception': True}
child : PID=21899, PPID=21866

parent : -------------------
parent : child's stderr ---
Traceback (most recent call last):
  File "child.py", line 28, in <module>
    sys.exit(func(**vars(args)))
  File "child.py", line 13, in func
    raise RuntimeError
RuntimeError

parent : -------------------
```


#### 子プロセスのエラー時に親プロセスで例外を発生させる

`check`を指定する。もしくは戻り値のオブジェクトでcheck_returncode()を呼び出すことでも可能。

子プロセスの終了ステータスが0以外の場合エラーとして検出される。

```python:parent.py
def call_sub_check():
    print('parent : PID={}'.format(os.getpid()))
    try:
        # python child.py -r 1   
        # 終了ステータス=1、処理時間=5秒、例外無し
        completeproc = subprocess.run(
                ['python', 'child.py', '-r', '1' ],
                stderr=subprocess.PIPE,
                text=True,
                check=True
                )
    except subprocess.CalledProcessError as e:
        # 子プロセスのコマンド
        print('parent : ', e.cmd)
        # 子プロセスの終了ステータス
        print('parent : ', e.returncode)
        print('parent : child\'s stderr ---')
        # 子プロセスのstderr
        print(e.stderr)
        print('parent : -------------------')

if __name__ == '__main__':
    print(__file__)
    call_sub_check()

```
ここでは、stderrの出力をキャプチャして親プロセスで受け取る設定もいれている。

run()が正常終了せず、例外が発生した場合は、例外クラスのstderrやstdoutにキャプチャされた出力が入ってくる。

出力例

```
parent.py
parent : PID=21935
child.py
child : {'retcode': 1, 'duration': 5, 'exception': False}
child : PID=21968, PPID=21935
child : I'm done.
parent :  ['python', 'child.py', '-r', '1']
parent :  1
parent : child's stderr ---
ERR child : PID=21968, PPID=21935

parent : -------------------
```

#### 子プロセスのタイムアウト
子プロセスが指定時間待っても戻ってこない場合、タイムアウトの例外を発生させるように設定して呼び出せる。

```python:parent.py
def call_sub_timeout():
    print('parent : PID={}'.format(os.getpid()))
    try:
        # python child.py -d 10   
        # 終了ステータス=1、処理時間=10秒、例外無し
        # Timeoutを3secに指定
        completeproc = subprocess.run(
            ['python', 'child.py', '-d', '10' ],
            timeout=3)
    except subprocess.TimeoutExpired as e:
        print('parent :', e)
        return 

    print('parent : child\'s exit status', completeproc.returncode)

if __name__ == '__main__':
    print(__file__)
    call_sub_timeout()        
```

出力例

```
parent.py
parent : PID=22083
child.py
child : {'retcode': 0, 'duration': 10, 'exception': False}
child : PID=22116, PPID=22083
parent : Command '['python', 'child.py', '-d', '10']' timed out after 2.9998990160020185 seconds

```

#### 注意事項
- ここではrun()の引数に渡す子プロセスのコマンドは`python child.py 引数...`の形で呼び出すコードになっているが、公式マニュアルではコマンドのフルパスを指定することを推奨している。具体的には`python`の部分は`shutil.which("python")`や`sys.executable`などに置き換えると良い。


## 参照資料
- [Python Document 3.10.5 subprocess — Subprocess management](https://docs.python.org/3/library/subprocess.html?highlight=subprocess)
- [Python Documentation 3.10.4 subprocess --- サブプロセス管理](https://docs.python.org/ja/3/library/subprocess.html?highlight=subprocess)