Pythonでシェルを書く

突然、Pythonネタである。シェルがらみの話題が続いたので、リダイレクトとパイプを中心にrcのコードリーディングでもしようかと思ったが、似たようなものを作ってみる方が面白そうだ。しかし、いまさらCか?というのもあって、お勉強がてらPythonで書いてみる*1。調べてみると必要なシステムコールのラッパはosモジュールで提供されているようだ。

さっそく、まずはsystem関数を使ったありがちな手抜き実装。

import os
import sys

class PySh:
    def __init__(self):
        self.prompt = "% "

    def loop(self):
        try:
            while True:
                line = raw_input(self.prompt)
                os.system(line)
        except KeyboardInterrupt:
            sys.exit(1)

if __name__ == '__main__':
    sh = PySh()
    sh.loop()

続いて、(セオリー通りであればリダイレクトなのだが、飛んで)パイプを実装してみる。os.system()をfork & execを使って書き直していく。この辺はシステムプログラミングのキソの「キ」なわけだけど、一発で書けずに、図を書いてからデバッグしたのは内緒だ。この実装では、親プロセス(シェル)の子プロセスとしてコマンド群をfork & execし、それら兄弟プロセスの間で標準出力と標準入力をつなぐためにパイプを使っている。具体的には、親プロセスでpipe(2)でパイプを作り、子プロセスがdup2(2)を使ってパイプの端点をつなぎ変えている。ポイントは、オープンファイル記述子の情報がそっくりそのまま子プロセスに引き継がれる点と、親プロセスがpipeで得たファイル記述子をcloseし忘れない点である。

import os
import sys
import traceback

class PySh:
    def __init__(self):
        self.prompt = "% "

    def loop(self):
        try:
            while True:
                rfd = prevfd = -1
                line = raw_input(self.prompt)
                if len(line) == 0:
                    continue

                cpids = []
                cmds = line.split('|')
                ncmds = len(cmds)
                for i in range(ncmds):
                    args = cmds[i].split()
                    wfd = -1
                    if i != ncmds - 1:
                        rfd, wfd = os.pipe()

                    pid = os.fork()
                    if pid == 0:
                        # child process
                        if prevfd > 0:
                            os.dup2(prevfd, sys.stdin.fileno())
                            os.close(prevfd)
                        if wfd != -1:
                            os.dup2(wfd, sys.stdout.fileno())
                            os.close(wfd)
                            os.close(rfd)

                        os.execvp(args[0], args)
                        # never returns

                    # parent process
                    cpids.append(pid)
                    if prevfd > 0:
                        os.close(prevfd)
                    prevfd = rfd
                    if wfd > 0:
                        os.close(wfd)

                for p in cpids:
                    os.waitpid(0, 0)

        except KeyboardInterrupt:
            sys.exit(1)
        except OSError, e:
            print e
            print traceback.print_tb(sys.exc_traceback)
            sys.exit(1)

if __name__ == '__main__':
    sh = PySh()
    sh.loop()

実行結果はこんな感じになる。

% echo hoge | rev
egoh
% echo hoge | rev | rev
hoge

ちょっと動かしてみていただければ気がつくと思うが、このシェルではcdでカレントディレクトリが移動できない。あとはexitで終了しないとか。考えてみればすぐわかると思うが、それが内部コマンド(builtin)が必要な理由である。

    def __init__(self):
        self.prompt = "% "
        self.builtin = {
            "cd" : self.docd,
            "exit" : self.doexit
        }

    def docd(self, args):
        argc = len(args)
        if argc == 2:
            os.chdir(args[1])
        elif argc == 1:
            os.chdir(os.getenv['HOME'])

    def doexit(self, args):
        sys.exit(0)

    def loop(self):
    :
                    args = cmds[i].split()
                    if args[0] in self.builtin:
                        self.builtin[args[0]](args)
                    else:
                        pid = os.fork()

このまま実装を続けていってもよいが、入力文字列のパージングが面倒なので、yaccでも使いたい気分になっている。rcもyaccを使っている。ちょっと調べてみたが、PLYとかどうなのかな?

*1:メンバ関数の引数にselfを書かなきゃいけないのは、そんなものなの? (追記:2008-10-28)「和訳 : なぜPythonのメソッド引数に明示的にselfと書くのか」だって。