Pythonで外部プロセスと標準入出力で通信する

## はじめに
Pythonでお手軽に外部プロセスと通信したい。標準入出力を使用して通信をしてみる。

通信相手には次のような標準入力を受付け、結果を標準出力を返すプログラムを用意する。


```python:listener.py
while True:
    x = input()
    print(x, end='\n')
```


## subprocess を使用する方法

`subprocess.Popen`で`listener.py`を起動し、`communicate`メソッドで標準入出力通信を行う。

```python:main.py
import subprocess


def main():

    p = subprocess.Popen(
        ["python", "listener.py"],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE
    )

    input_str = "OK!"
    output_str = p.communicate(input_str.encode())[0].decode()
    print(output_str)


if __name__ == '__main__':
    main()
```

これを実行すると次のようになる。

```console
$ python main.py
Traceback (most recent call last):
  File "listener.py", line 2, in <module>
    x = input()
EOFError: EOF when reading a line
OK!

$
```

`listener.py`から応答が帰ってきているが、例外が発生している。これはプログラムを次のように書き換えるとより詳細にわかる。

```python:main.py
import subprocess


def main():

    p = subprocess.Popen(
        ["python", "listener.py"],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE
    )

    input_str = "OK!"
    output_str = p.communicate(input_str.encode())[0].decode()
    print(output_str)

    input_str = "OK!"
    output_str = p.communicate(input_str.encode())[0].decode()
    print(output_str)


if __name__ == '__main__':
    main()
```

これを実行すると次のようになる。


```console
$ python main.py
Traceback (most recent call last):
  File "listener.py", line 2, in <module>
    x = input()
EOFError: EOF when reading a line
OK!

Traceback (most recent call last):
  File "main.py", line 26, in <module>
    main()
  File "main.py", line 17, in main
    output_str = p.communicate(input_str.encode())[0].decode()
  File "/Users/xxxxx/opt/miniconda3/envs/py37/lib/python3.7/subprocess.py", line 939, in communicate
    raise ValueError("Cannot send input after starting communication")
ValueError: Cannot send input after starting communication
$ 
```

さらに例外が発生している。これは二回目の`communicate`ができないことを意味している。subprocessは`communicate`を終えると、プロセス間の通信を切ってしまうようで、一回しか通信できない。`EOFError`が発生しているのは、`listener.py`が無限ループで次の標準入力を待っているのにプロセスを終了しようとするためである。

したがって、subprocessはあくまでもプロセスを起動し、コマンドを一回送信するときだけに使える。


## pexpect を使用する方法

今期待しているのは、`listener.py`のプロセスを立ち上げ、何度も通信することである。そこで、`pexpect`モジュールを使用する。

```bash
pip install pexpect
```

pexpectを用いた通信は次のように行う。

```python:main.py
import pexpect


def main():
    p = pexpect.spawn('python listener.py')

    p.sendline("OK!")
    p.expect("\n", timeout=None)
    print(p.before.decode(encoding='utf-8'))

    p.close()


if __name__ == '__main__':
    main()
```
これを実行すると次のようになる。

```cosnole
$ python main.py
OK!
$
```

`pexpect.spawn`で子プロセスを生成、子プロセスを制御するインスタンスを得る。`expect`メソッドで、期待パターンと一致する標準出力が出力されるまで待機する。パターンには正規表現やEOF、TIMEOUTが使用できる。

`sendline`メソッドで標準入力へ文字列を送信する。`expect`メソッドで、標準出力の文字列のカーソルを移動する。ここでは`listener.py`で出力された文字列最後尾の改行コードへ移動し、`before`メソッドで改行コードより前の文字列を取得している。


ここでもし、リスナーが次のようなプログラムであった場合、

```python:listener.py
print('process start')

while True:
    x = input()
    print(x, end='\n')
```

通信を行うと次のようになる。

```console
$ python main.py
process start
$
```
これは一つ目の改行コードが`print('process start')`の部分にあるためである。この場合は、`spawn`直後に`expect`メソッドを使用する。

```python:main.py
import pexpect


def main():
    p = pexpect.spawn('python listener.py')
    p.expect(".+")

    p.sendline("OK!")
    p.expect("\n", timeout=None)
    print(p.before.decode(encoding='utf-8'))

    p.close()


if __name__ == '__main__':
    main()
```

ただし、今度は`listener.py`実行時に何か標準出力されないとTimeoutまで待機し続けてしまうので注意。


## 参考

https://pexpect.readthedocs.io/en/stable/overview.html

## おわりに

もっと賢い方法があったら教えて下さい。