pexpect - 対話アプリの自動運転


最近、とっても便利なPexpectというPythonパッケージを使ってみた。
調べたことをまとめがてら、紹介してみる。

1. Pexpectとは?

Pexpectは、対話型アプリなどに対して、自動応答を記述できるPythonパッケージ。
「画面の表示を読んで、キー入力する」という人間の役割を、Pythonで代替できる。
この分野では古くから、Tcl言語を拡張した “Expect” というツールが有名。しかし、Expectを利用するには、少々エキゾチックな Tcl 言語を学習する必要があった。
一方、Pexpect はピュアPythonパッケージなので、既にPythonを知っているなら容易に利用できる。また、Pythonのモダンな環境でプログラミングできることも魅力か。


Expect vs Pexpect
図の説明: Expectは、Tcl言語に主に2つのコマンド sendexpectを追加した言語となっている(実際は他にも様々な便利コマンドが追加されている)。Pexpect は、パッケージ内でspawnクラスを定義し、同様にsendexpectというメソッドを提供している。

ポータル/Version

GitHubをみると、最近も開発は継続している様子。この記事を書いている時点の最新バージョンは3.3で、バージョン4へ向けリファクタリングを進めているらしい。
今後の発展に期待がもてる。

Installation

CentOS6.5には、少し古いけれどPexpect 2.3が含まれている。インストールは簡単。
[root@localhost ~]# yum install pexpect
最新バージョンを利用したいなら、”pip install” でインストールできる。
[root@localhost ~]# yum install python-pip
[root@localhost ~]# pip install pexpect

2. Pexpectの仕掛け

Pexpectは、擬似端末のマスター側(/dev/ptmx)で、画面表示された文字列を人間の代わりに 受け取る 。そして、Pythonプログラムで生成した文字列を、キー入力の代わりに擬似端末に 送信 する。 ※ 太字の部分がPexpectの役割
下図に、人間が「SSHクライアントでログインし、Bashプロンプトに応答している状況」と、Pythonプログラムが「Pexpectを使ってBashプロンプトに応答している状況」を対比する。

この図のように、Bashは 擬似端末(pts, ptmx) を介してキー入力を受け取っているため、Bashの相手をしているのが「人間なのかPythonプログラムなのか」見分けがつかない。
もう一つのポイントは、SSHでは Bash(ログインシェル)を fork するのはsshd(サーバデーモン)だが、pexpectでは spawn クラス を使って、自分で Bashを fork してやる必要がある。

擬似端末について/なぜパイプじゃだめなの?

擬似端末は、SSHの他にも、xterm、expect、scriptコマンド、screenコマンドなどで活用されている。ptsやptmxはググると色々と出てくるので、参考記事を数点ばかり。

3. 基本的な使い方

3.1. 簡単なプログラム

まず、Pexpectで制御対象プロセス(前図ではBash)を fork する。
そのためには pexpect.spawn クラスのインスタンスを生成する。コードはこんな感じ。
import pexpect
p = pexpect.spawn("/bin/bash")
この時、擬似端末も一緒に設定されている。
成功したら、p.expect()p.send()で、制御対象プロセスとお喋りできる。
  • p.expect()は、端末への出力を入力バッファで受けつつ、引数の正規表現を入力バッファとマッチさせ、成功するまで待つ
  • p.send()は、文字列を端末へ送る(エコーバックONなら端末にエコー出力あり)
p.expect()は、正規表現にマッチ成功すると、3つのメンバーをセットして返る。
メンバー 内容
before 入力バッファ先頭から、正規表現にマッチした文字列の直前まで
after 正規表現にマッチした文字列
match 正規表現にマッチした時のPython Matchオブジェクト

実験観察

試しに使ってみよう。
/bin/cat を使い、実際にsend/expectの動作を観察してみる。
まずは、人間がキー入力したケース。
$ cat
123456789  <--- キー入力した文字列(改行込み)が端末でエコーバックされた
123456789  <--- cat が出力した文字列
次に、人間の代わりに、pexpect が文字列を送るケース。
#!/usr/bin/env python
import pexpect

p = pexpect.spawn("/bin/cat")

p.send("123456789\n")

p.expect(r"(345)..(.)")  # match against terminal echo
print "Match1: " + p.before + '"' + p.after + '"'
print p.match.groups()

p.expect(r"(345)..(.)")  # match against cat's output
print "Match2: " + p.before + '"' + p.after + '"'
print p.match.groups()

p.expect(r".*")   # match with the remaining string
print "Match3: " + p.before + '"' + p.after + '"'

p.terminate()
p.expect(pexpect.EOF)
p.send("123456789\n")で “123456789” という文字入力を送信。
p.expect(r"(345)..(.)") で端末に表示される文字列を正規表現で待っている。
  • 1回目の待ちは、端末へのエコー表示を受け取り、戻る。
  • 2回目の待ちは、catによる出力が端末に表示されたのを受け取り、戻る。
p.expect(r".*") で、それ以降に端末に表示された文字列を受け取り(=単に消費)、その後 p.terminate() で cat を終了。
最後に、p.expect(pexpect.EOF)で、端末から得られる EOF を確認して、Pythonプログラムを終了している。
CentOS6.5上で実際に走らせた結果:
[testuser@localhost ~]$ ./test.py
Match1: 12"345678"
('345', '8')
Match2: 9
12"345678"
('345', '8')
Match3: "9
"
この結果から分かること:
  • 端末のエコーバック設定はデフォルトONなので、send()した文字列も画面に表示され、pexpectの入力バッファに入る
  • expect()の正規表現にマッチすると、入力バッファが消費される
    上記の例では、Match2は 9 ではなく 9\n12"345678" であり、端末エコーの名残りが p.before にあるまま、catの画面出力が続き、そしてマッチ成功した
  • 正規表現の”.”は、デフォルトで改行にもマッチ

端末からのエコーバック

端末が対応しているなら、p.setecho(False) によりエコーバックをOFFにできる。
何らかの事情で、入力した文字を表示したくない or 受けとりたくない場合に有用。

EOFとTIMEOUTの取り扱い

expectメソッドの待ち合わせには、Pythonの正規表現の他に、pexpect.EOF と pexpect.TIMEOUT も使える。
逆に、expectメソッドの引数にEOFやTIMEOUTを指定していないのに、期せずしてそれらを受け取ると、例外が発生する。そんな時は、try: で捕まえて、spawnインスタンスの状態を表示させるとよい。
try:
    p.expect(pattern)
except:
    print "Exception was thrown"
    print "debug information:"
    print str(p)
公式docより引用。

その他のメソッド

spawnクラスは、expect()とsend()以外にも色々なメソッドを提供している。例えば…
  • 改行付きで文字列をsend()する sendline()
  • EOFをsend()する sendeof()
  • パターンマッチせず、端末から入力を読み取る read() 系メソッド
  • 一旦、端末を人間に渡す interact()
詳細は「公式doc - spawnクラス」に。

2.2. How to Debug

もっとも簡単なデバッグ方法は、実行トレースを見ること。
そのためには logfile メンバーを利用する。
#!/usr/bin/env python

import pexpect
import sys

p = pexpect.spawn("/bin/bash")
p.logfile_read = sys.stdout

print "-------------"
p.expect("][$#] ")      # wait for prompt
p.send("pwd\n")         # send "pwd" command
p.expect("][$#] ")      # wait for prompt
p.send("exit\n")        # send "exit" command
p.expect(pexpect.EOF)   # wait for EOF = exit of bash
print "-------------"

print "Done!"
実行結果は以下。
[testuser@localhost ~]$ ./pexpect_sample.py
-------------
[testuser@localhost ~]$ pwd
/home/testuser
[testuser@localhost ~]$ exit
exit
-------------
Done!
logfileへの出力は、ほぼリアルタイムに吐き出されるので、途中に予想外のインプットを受けとり永久待ちになったり、間違った文字列を送ってしまうケースを、即座に捕捉できる。
端末のエコーバックをOFFにしている場合は、logfile_readの代わりにlogfile_sendで、送信した文字列をキャプチャできる。

2.3. より高度な例:複数パターンとの同時マッチ

expectメソッドは、入力パターンにマッチするまで、待ち続ける。
例えば、制御しようとしているプログラムが、2つの出力 “Succeeded”と”Failed” のどちらかを返す場合、その結果により次のキー入力を決めるなら、どうするのか?
一つのやり方は、正規表現を頑張ること。
p.expect(r"(Succeeded)|(Failed)")
if p.after == "Succeeded":
    print "Yap"
elif p.after == "Failed":
    print "Nop"
しかし、もっとスマートな方法がある。正規表現のリストを渡すことができる。
index = p.expect([r"Succeeded",r"Failed"])
if index == 0:
    print "Yap"
elif index == 1:
    print "Nop"
リストには、pexpect.EOFやpexpect.TIMEOUTを渡すこともできるので、それらが想定される状況では、try:による例外処理を書かなくてもよくなる。

2.4. パターンマッチでの注意点

若干、直感と違う動きをする部分があるので、正規表現にマッチさせる動作について明確にしておく(本家のドキュメントも少々分かりにくい)。

最小マッチと maxread

正規表現の “*” や “+” では、文字列の終端がわからないと、正確なマッチが判明しない。
具体的な例として、次のケースを考えてみる。
  1. p.expect(r"a.*b") で待つ
  2. 最初に擬似端末から ....a123123b.... という文字列が来て、入力バッファに入る
  3. 次に擬似端末から ....123b がきて、入力バッファは ....a123123b....123b になる
もし最初の入力バッファ ....a123123b.... で正規表現r"a.*b"のマッチを判断するなら、p.expect()は p.after="a123123b"をセットして戻る。
しかし、”.*” は何文字でもマッチ成功するから、その先の入力まで待って総合的に判断するなら、p.after="a123123b....123b"が正解になるはず。結果は変わってくる。
実際のところ、pexpectは次のように動作する(コードはこの辺)。
  • 現在の入力バッファで、正規表現のマッチを試みる
  • 何もマッチしなかった場合だけ、擬似端末から次の文字列を読む
  • 次の文字列を読むときは、最大で maxread 分(既定値 2000バイト)一度に読もうと試みる
  • 実際には、読もうとした時に受け取っていた文字列から、最大で maxread 分だけ読める
maxreadは、spawnの引数で渡すことができる。既定値の2000は、80x25の画面1枚分を一括で受けられるということかな?
p = pexpect.spawn("/bin/bash", maxread=1)
maxread=1 にすると、入力バッファを1文字ずつ増やしながら正規表現マッチを試みるので、”.*” や “.+” は即マッチ成功となる。

searchwindowsize

maxread=1の場合に、特に顕著になる問題がある。
expect()は、マッチ成功するまで毎回、入力バッファの先頭からサーチし直すので、入力バッファが巨大(マッチするまで捨てない)になると、重たい処理になる。
たとえば、入力バッファに8MB分のデータを蓄積しており(=おそらく探している文字列は入っていない過去の大量出力など)、そこから関心ある文字列を1000バイト受け取り、マッチングすることを考える。
デフォルトのmaxread=2000なら、1度の読み込みで入力バッファは 8MB+1000B になり、8.1MB のデータに対して、正規表現マッチングを1ターンだけ行う。処理データの合計は 8.1MB だけ。
しかし、maxread=1だと、次の読み込みで入力バッファは 8MB+1B にしかならない。ここで正規表現マッチングに失敗したら、次もまた1Bしか読まないから、入力バッファは 8MB+2B にしか増えない。最悪ケースでは、1000B処理するのに、1000回の正規表現マッチングが行われ、この時に処理するデータ量は 8TB + 500.5KB にも達する。
この問題を防ぐには、お目当ての文字列と排反なダミーの正規表現を渡し、時々、マッチングを成功させれば良い。マッチング成功すれば、都度、入力バッファは捨てられる。
しかし、もっと単純な方法がある。searchwindowsize を指定し、入力バッファの末端から、決まった長さのみをマッチ対象とすればよい。
p = pexpect.spawn("/bin/bash", maxread=1, searchwindowsize=30)
探している文字列に最大長があり、十分に短いなら(たとえば1000B以下など)、大きな効果が見られるかもしれない。

パターンマッチで選択されるList item

expect()にListを渡すと、2つ以上のパターンで同時にマッチ成功する場合がある。
入力バッファは、マッチしたパターンで消費されていくので、どのパターンにヒットしたのかは重要。ここでルールを明確にしておく。
本家のドキュメント “expect()” の項に説明はあるけれど、少々わかりにくい。
If you pass a list of patterns and more than one matches, the first match in the stream is chosen. If more than one pattern matches at that point, the leftmost in the pattern list is chosen.
意訳すると、次の順番でパターンが選択される。
ルールA
マッチ成功したパターンの内、入力バッファ上の開始位置がもっとも先頭に近いもの
ルールB
入力バッファ上の同じ位置から、複数のパターンがマッチ成功したら、リスト上の順番で最も左にあるもの
具体的に動きをみてみる。まずはサンプルスクリプト。
#!/usr/bin/env python

import pexpect

def match_test(patterns, sendstr):
    print "--------------------------"
    print "Expecting:",patterns
    print "Sending  :",sendstr

    p.sendline(sendstr)
    index = p.expect(patterns)

    print "Match    :",p.before + '"' + p.after + '"'
    print '  --> Used pattern %d "%s"'%(index, patterns[index])
    print ""
    p.expect(".*")

p = pexpect.spawn("/bin/cat")
p.setecho(False)

# Case 1
match_test(patterns=[r"foo.*foo", r"foo.*bar", r".*foo"],
           sendstr=".....foo 123123 bar foo....")

# Case 2
match_test(patterns=[r"foo.*foo", r"foo.*bar", r"foo.*\."],
           sendstr=".....foo 123123 bar foo....")

# Case 3
match_test(patterns=[r"foo.*bar", r"foo.*foo", r"foo.*\."],
           sendstr=".....foo 123123 bar foo....")

# Case 4
match_test(patterns=[r"foo.*bar", r"foo.*foo", r"foo.* "],
           sendstr=".....foo 123123 ....")

p.terminate()
p.expect(pexpect.EOF)
実行結果はこうなる。
[testuser@localhost ~]$ ./test1.py
--------------------------
Expecting: ['foo.*foo', 'foo.*bar', '.*foo']
Sending  : .....foo 123123 bar foo....
Match    : ".....foo 123123 bar foo"
  --> Used pattern 2 ".*foo"

--------------------------
Expecting: ['foo.*foo', 'foo.*bar', 'foo.*\\.']
Sending  : .....foo 123123 bar foo....
Match    : ....."foo 123123 bar foo"
  --> Used pattern 0 "foo.*foo"

--------------------------
Expecting: ['foo.*bar', 'foo.*foo', 'foo.*\\.']
Sending  : .....foo 123123 bar foo....
Match    : ....."foo 123123 bar"
  --> Used pattern 0 "foo.*bar"

--------------------------
Expecting: ['foo.*bar', 'foo.*foo', 'foo.* ']
Sending  : .....foo 123123 ....
Match    : ....."foo 123123 "
  --> Used pattern 2 "foo.* "
1番目(Case 1)は、入力バッファの先頭から “.*foo” がマッチ成功し、ルールAにより、この pattern 2 が選択される。
2番目(Case 2)は、入力バッファの同じ位置から全パターンがマッチ成功。最長マッチは pattern 2 の “foo.*\.” だが、ルールBにより pattern 0 の “foo.*foo” が選択される。
3番目(Case 3)は、2番目(Case 2)のpattern 0とpattern 1が逆になっているケース。同じくルールBにより、pattern 0の “foo.*bar” が選択される。Case 2とはマッチが違うので、入力バッファの消費も異なる。
4番目(Case 4)は、pattern 2が “foo.* “、すなわち「foo から空白まで」なので、入力バッファにfootと空白が出現した時点でマッチ成功。この後に bar や foo が来る可能性は無視される。
予想通りだっただろうか?
最後に、maxread=1にした時の実行結果を示す。入力バッファに1文字ずつ受け取って、毎回、正規表現とマッチさせるので、結果も変わってくる。
[testuser@localhost ~]$ ./test1.py
--------------------------
Expecting: ['foo.*foo', 'foo.*bar', '.*foo']
Sending  : .....foo 123123 bar foo....
Match    : ".....foo"
  --> Used pattern 2 ".*foo"

--------------------------
Expecting: ['foo.*foo', 'foo.*bar', 'foo.*\\.']
Sending  : .....foo 123123 bar foo....
Match    : 123123 bar "foo."
  --> Used pattern 2 "foo.*\."

--------------------------
Expecting: ['foo.*bar', 'foo.*foo', 'foo.*\\.']
Sending  : .....foo 123123 bar foo....
Match    : ..
....."foo 123123 bar"
  --> Used pattern 0 "foo.*bar"

--------------------------
Expecting: ['foo.*bar', 'foo.*foo', 'foo.* ']
Sending  : .....foo 123123 ....
Match    : "foo....
.....foo"
  --> Used pattern 1 "foo.*foo"
1番目(Case 1)では、2つめのfooを待てずに戻っている。
また、maxread=1ではスクリプト中の match_test() にある p.expect(".*") が即座に成功してしまうので、余った入力バッファが消費されず、次の expect() を侵食している。
このように、”.*” や “.+” の使い方には注意する必要がある。

3. 便利なrun関数

ややこしい話が続いたので、お手軽な話題に。
run()は、spawnクラスを内部で利用し、典型処理を書いたヘルパー関数(コードはこの辺)。とっても便利。
pythonのos.system()と同様の気軽さで使え、更に引数に辞書 { “pattern1”:”response1”, “pattern2”:”response2”, …} を渡し、自動応答できる。
どのぐらい便利かを示すのに、以前に書いた記事「Lxrでソースコードを快適ブラウジング - 4.3. Configファイルの生成」の作業を自動化する。やっぱり、リアルな課題をやってみないとね。

3.1. Lxrの設定ファイルを生成してみる

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import pexpect
#import sys  # for logfile

configure_lxr_responses = {
    r'single/multiple trees.*>.* ':'m\n',
    r'Tree designation.*>.* ':'embedded\n',
    r'--- Host name or IP.*>.* ':'http://192.168.170.64\n',
    r'Database engine.*>.* ':'sqlite\n',
    r'--- Directory for glimpse.*>.* ':'/home/lxruser/local/share/glimpse\n',
    r'Is your Apache version 2.4.*>.* ':'no\n',
    r'--- Tree designation for URL.*>.* ':'nupic.core\n',
    r'--- Caption in page header.*>.* ':'Nupic.core by Lxr\n',
    r'--- Source directory.*>.* ':'/home/lxruser/local/share/lxr/nupic.core\n',
    r'Label for version selection menu.*>.* ':'TAG\n',
    r'--- Version name\?  .*>.* ':'2014-09-19\n',
    r'--- Version name\? \(hit return to stop.*>.* ':'\n',
    r'--- Database file.*>.* ':'/home/lxruser/local/share/lxr/lxr.db\n',
    r'Configure another tree.*>.* ':'no\n',
    r'>.* ':'\n'
}

def main():
    output,ret = pexpect.run("/home/lxruser/lxr/scripts/configure-lxr.pl",
                     events=configure_lxr_responses,
                     withexitstatus=True,
                     cwd='/home/lxruser/lxr',
#                    logfile=sys.stdout
                     )

    print "-------- output --------"
    print output
    print "-------- output --------"
    print "exit code:",ret

if __name__ == "__main__":
    main()
たったこれだけ!
実行結果はこちら。
[lxruser@localhost ~]$ ./genlxrconf.py
-------- output --------
Configure for single/multiple trees? [S/m] > m
Server type? [dedicated/SHARED] >
Tree designation? [ARGUMENT
/section name
/prefix in host
/hostname
/embedded in section] > embedded
--- Host name or IP? [//localhost] > http://192.168.170.64
--- Alias name or IP? >
URL section name for LXR in your server? [/lxr] >
Use built-in multiple trees management with tree designation at end of virtual root? [YES/no] >
Database engine? [MYSQL/oracle/postgres/sqlite] > sqlite
How do you setup the databases? [PER TREE/global] >
Will you share database characteristics? [YES/no] >
Will you use the same username and password for all DBs? [YES/no] >
--- DB user name? [lxr] >
--- DB password ? [lxrpw] >
Will you give the same prefix to all tables? [YES/no] >
--- Common table prefix? [lxr_] >
--- Directory for glimpse databases? > /home/lxruser/local/share/glimpse
Is your Apache version 2.4 or higher? [YES/no] > no
--- Use 'buttons-and-menus' instead of 'link' interface? [YES/no] >
--- Tree designation for URL? (e.g. the_tree) > nupic.core
--- Caption in page header? (e.g. Project XYZZY displayed by LXR) > Nupic.core by Lxr
Do you want a speed switch button for this tree ? [YES/no] >
--- Short title for button? (e.g. XYZZY) [nupic.core] >
Do you need a specific encoding for this tree ? [yes/NO] >
How is your tree stored? [FILES/cvs/git/svn/hg/bk] >
--- Source directory? (e.g. /home/myself/project-tree) > /home/lxruser/local/share/lxr/nupic.core
Name to display for the path root? (e.g. Project or $v for version) [$v] >
Label for version selection menu?  [Version] > TAG
Version enumeration method? [LIST/file/function] >
--- Version name?  > 2014-09-19
--- Version name? (hit return to stop) >
--- Default displayed version is first in 'range'? [YES/no] >
--- Directory to ignore, e.g. CVSROOT or CVS? (hit return to stop) >
--- Include directory, e.g. /include? (hit return to stop) >
--- Database file? (e.g. /home/myself/storage.db) > /home/lxruser/local/share/lxr/lxr.db
Do you want to override the global 'lxr_' table prefix? [yes/NO] >

*** Configure another tree? [YES/no] > no

-------- output --------
exit code: 0

3.2. 知っているとお得なノウハウ

肝となるポイントは2つ。
  1. パターンの最後r'>.* ':'\n'で、それ以前のパターンにマッチしていないケースを処理しており、デフォルトパターンの役割を果たす。上述のルールAより、なるべく入力バッファの末端でマッチするようにしている。
  2. 各パターンは、プロンプト ">" の前後に ".*" を含む ".*>.* " のような形になっている。その理由は、configure-lxr.plがESCシーケンスを使って文字に色をつけているからで、".*" のお陰でESCシーケンスを気にしないで済む。

3.3. run関数でのDebug

実行トレースを見るのが良い。
上記の例では、”import sys” と “logfile=sys.stdout” のコメントアウトを外せば良い。
更に詳しく情報が見たい場合や、トレースが取れない場合は、straceコマンドを貼り付ける(strace -f -s523 your_pexpect_test.py みたいに)という方法もある。

3.4. run関数ではできないこと

現在のrun関数では、expect([]) で利用できたパターンマッチのルールBを活用できない。
その理由は、Pythonの辞書 {} を引数にしているためで、本来ならタプルリスト [ (“pattern1”,”response1”), (“pattern2”,”response2”), … ] を渡せるようになっていたほうがよい。
2014/10/23追記:
expect([]) を利用できるよう GitHub にPRを送ったら、2014/10/13にマージされた。
また状態遷移がないので、同じプロンプトには、同じキー入力しか投入できない。例えば、Bashを操作するなら、現状は expect() を直接使うしかない。
状態遷移については、FSMによる表現を受け取れるよう機能拡張できると、だいぶ良いかもしれない。

今回はここまで。