Python – pexpectで対話式プログラムを自動化する

対話式プログラムへの自動化処理の方式としてはexpectが有名だが、そのpython版としてpexpectというパッケージがある。expectは使ったことないが、TCL言語がベースなので学習コストガー、とかshellはもうイヤだ、等々あるので、pexpectでサクッとできればそっちの方がスマートでよさそうだ。しかし現状日本語情報は少ないし、あっても説明がイマイチ分かりにくい…。単純化するとこういうことみたいである。

  1. spawn()で、実行するコマンドを定義。
  2. expect()で、想定するプログラムの質問を定義。まさに”expect”。
  3. sendline()で、上記2.に対するアンサーを定義。

つまり、sendline()が人間が端末で入力する処理を代行してくれる、ということらしい。ではやってみる。

$ sudo pip install pexpect

分かりやすい例として、FTPサイトにログインしてリソースを取得する、というのを自動化してみる。以下は北陸先端科学技術大学院大学のFTPサイトにアクセスし、所定のディレクトリlsの結果を出力する。

ls_ftp.py

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
#!/bin/env python
# -*- coding: utf-8 -*-
 
import pexpect
 
child = pexpect.spawn ('ftp ftp.jaist.ac.jp')
child.expect ('Name .*: ')
child.sendline ('anonymous')
child.expect ('Password:')
child.sendline ('')
child.expect ('ftp> ')
child.sendline ('ls /pub/mysql/Downloads/MySQL-5.6/')
child.expect('ftp> ')
print child.before
child.sendline ('bye')
child.close()

実行結果。

$ python ls_ftp.py

01
02
03
04
05
06
07
08
09
10
11
ls /pub/mysql/Downloads/MySQL-5.6/
229 Entering Extended Passive Mode (|||65200|).
150 Here comes the directory listing.
-r--r--r--    1 ftp      ftp      215224320 May 06 20:48 MySQL-5.6.25-1.el6.i686.rpm-bundle.tar
-r--r--r--    1 ftp      ftp           185 May 06 21:15 MySQL-5.6.25-1.el6.i686.rpm-bundle.tar.asc
-r--r--r--    1 ftp      ftp            73 May 06 21:08 MySQL-5.6.25-1.el6.i686.rpm-bundle.tar.md5
-r--r--r--    1 ftp      ftp      31557791 May 06 17:27 MySQL-5.6.25-1.el6.src.rpm
-r--r--r--    1 ftp      ftp      222781440 May 06 20:48 MySQL-5.6.25-1.el6.x86_64.rpm-bundle.tar
-r--r--r--    1 ftp      ftp           185 May 06 21:15 MySQL-5.6.25-1.el6.x86_64.rpm-bundle.tar.asc
-r--r--r--    1 ftp      ftp            75 May 06 21:08 MySQL-5.6.25-1.el6.x86_64.rpm-bundle.tar.md5
(略)

interact()は処理を人間の手に渡す。続けて操作したい場合は、最後のchild.sendline ('bye')child.interact()に変更すると、対話式モードに入る(Ctrl + Cで、ftp>プロンプトになる)。

以下は所定のリソースをダウンロード。って、最初からwgetすればいいじゃないかという話だが…、ま、例なので。

get_ftp.py

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
#!/bin/env python
# -*- coding: utf-8 -*-
 
import pexpect
import time
 
child = pexpect.spawn ('ftp ftp.jaist.ac.jp')
child.expect ('Name .*: ')
child.sendline ('anonymous')
child.expect ('Password:')
child.sendline ('')
child.expect ('ftp> ')
child.sendline ('cd /pub/mysql/Downloads/MySQL-5.6/')
child.expect('ftp> ')
child.sendline ('get MySQL-5.6.25-1.el6.x86_64.rpm-bundle.tar.md5')
time.sleep(0.10)
child.sendline ('bye')
child.close()

素振りはこの辺までにして、もっと実用的にLinux上 OSユーザのパスワードセット/変更例など。以下は実行時の第1引数にユーザ名、第2引数にパスワードを指定する前提。

create_password.py

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
#!/bin/env python
# -*- coding: utf-8 -*-
 
import pexpect
import sys
import time
 
def main():
    (user, newPassword) = (sys.argv[1], sys.argv[2])
 
    child = pexpect.spawn("/usr/bin/passwd %s" % user)
    child.expect("New password:")
    child.sendline(newPassword)
    child.expect("Retype new password:")
    child.sendline(newPassword)
    time.sleep(0.1)
    child.close()
 
if __name__ == "__main__":
        main()

# python create_password.py hogeuser1 Hoge1#$

…などと実行すると、確かにパスワードがセットされた。

しかし怠け者さんは、スクリプト内でランダムにパスワード生成させてセットさせたいだろう。そうするべきだ。で、こちらをマネしてやってみた。マネというかほぼそのまんまなのだがちょっと動かない部分があったので、こうなった。今度は第1引数でユーザのみ指定。

set_random_password.py

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/bin/env python
# -*- coding: utf-8 -*-
  
import pexpect
import sys
import string
import time
from random import Random
  
def main():
        user = sys.argv[1]
        global newPassword
        newPassword = GeneratePassword()
  
        print "Changing password for %s" % user
        print user, ":", newPassword
        ChangePassword(user, newPassword)
  
def GeneratePassword():
        passwdChars = string.letters + string.digits + '!@#$%^&*-_+?'
        passwdLength = Random().sample([10, 11, 12], 1).pop()
        return ''.join(Random().sample(passwdChars, passwdLength))
  
def ChangePassword(user, child):
        child = pexpect.spawn("/usr/bin/passwd %s" % user)
        child.expect("New password:")
        child.sendline(newPassword)
        child.expect("Retype new password:")
        child.sendline(newPassword)
        time.sleep(0.1)
        child.close()
  
if __name__ == "__main__":
        main()

補足。expect()でパターンにマッチする文字列が表れないと待機状態に入って止まってしまう。expectで想定する値は正規表現が使えるので、対応させた方がいいかもしれない。

さらに複数のユーザに一度にセットしたければ、こんな感じでshellから呼び出すとか。

do_crerate_password.sh

01
02
03
04
05
06
07
08
09
10
11
#!/bin/bash
 
usersfile=/tmp/users.txt <--ユーザ名を列挙
work_dir=/tmp/passwd
cmd=$work_dir/set_random_password.py
pass_log=$work_dir/passfile.txt <--生成されたパスワードはここに吐き出す
 
for u in `cat $usersfile`
do
    /usr/bin/python $cmd $u | tee -a $pass_log
done

実行してみると、確かに複数人数分一気にパスワードがセットされる。これでユーザが何人いても楽チンだ。

Ainsibleでも対話モードに対応するモジュールがあったはずなので、内部でpexpectを使っているんだろうか。構築や運用時にどうしても避けられない対話式プログラムがある場合、上手く使えるとよさげ。(ちなみにAnsibleでもパスワードセットはできるが、パスワードをハッシュ化してplaybookに記述する必要があったりして、結構面倒くさい)。

追記(2015/08/01)
最後のスクリプトset_random_password.pyは、よくよく考えると対象サーバ1台だけのときはよいが、複数のサーバで各ユーザに同一パスワードをセットしたい場合には使えない。この場合はやはり予めパスワードを決めておいて、ファイルから読み出すといった処理をさせればいいんではないか。

yml形式でこんなファイルを用意する。

userlist.yml

1
2
3
userhoge: hogepass123!
userfuga: fugapass456#
userpon: ponpass789$

以下の例は、userlist.ymlを読み込んでパスワードをセットする。iteritems()でuserlistのkey-valueをループさせる。

set_fixed_password.py

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/env python
# -*- coding: utf-8 -*-
 
import pexpect
import yaml
import sys
import time
 
def main():
    userdata = yaml.load(file("userlist.yml"))
 
    for user, newPassword in userdata.iteritems():
        print "Set password for %s" % user
        child = pexpect.spawn("/usr/bin/passwd %s" % user)
        child.expect("New password:")
        child.sendline(newPassword)
        child.expect("Retype new password:")
        child.sendline(newPassword)
        time.sleep(0.1)
        child.close()
  
if __name__ == "__main__":
        main()

パスワードセット後はすみやかにuserlist.ymlを削除するとか、パスワードがちゃんとセットされているかのテストまで含めれば、手を動かすことはほぼない。手というのは、こういう仕事のために使うもんじゃないから(キッパリ)。