LoginSignup
18
16

More than 5 years have passed since last update.

Jupyter の Egison 簡易カーネルを自作してみた。

Posted at

前置き

Egison の可能性を色々探っています。
取り敢えず動作確認するのに、標準のよりもう少し使い勝手の良い REPL があると良いな、と前から思ってました。

一方で、Jupyter の可能性を色々探っています。
その中で、REPL が提供されている環境なら、それをラップしたカーネルが(Python で)比較的簡単に作れる、ということを発見。

ということで、Egison の REPL をラップした Jupyter カーネルを自作してみました。
他の言語/環境のコンソールベース REPL でも同様に構築できると思うので、何かの参考になればと、晒してみます。

あと、ちょっとだけ躓いたのでその情報共有も兼ねて、動作確認→カーネル作成の2段階に分けました。手っ取り早くカーネルの作り方だけ知りたい方は STEP.1 の途中は飛ばして STEP.2 からどうぞ。

環境

  • Mac OSX 10.9.5
  • Python 2.7.9
  • IPython 3.1.0
  • Egison 3.5.6

※ Jupyter (IPython)・Egison のインストールは省略。私の過去記事 等を参照してください。

STEP.1 pexpect.replwrap で動作確認

Pexpect のインストール

REPL をラップしたカーネルを作るには、外部ライブラリ Pexpectreplwrap モジュールを利用する(と便利)。とのことで。

pexpect_install.sh
pip install pexpect

※ Pexpect を 利用した IPython カーネル(例:bash_kernel)が既にインストールされていれば、Pexpect もインストールされているので、この作業は不要です。

Egison の REPL をラップして動作確認

まずは結果から。

replwrap_egison_sample.py
# replwrap インポート
from pexpect import replwrap

# プロンプト作成 (※1)
import uuid
prompt = uuid.uuid4().hex + ">"

# Egison ラッパー作成 (※2)
egison = replwrap.REPLWrapper("egison --prompt " + prompt, unicode(prompt), None)

# 簡単な式で動作確認 (※3)
egison.run_command("(test (+ 1 2 3))")
# => u'6\r\n'

# 関数定義・実行
cmd = "(define $sample (lambda [$xs] (nth (pure-rand 1 (length xs)) xs)))"
egison.run_command(cmd)
egison.run_command("(sample {1 2 3 4 5 6 7 8 9 10})")
# => u'4\r\n'(実行するたびに結果は異なります)

# 複数行にわたる関数定義の場合 (※4)
define_fib_fast = """
(define $fib-fast
  (match-lambda integer
    {[,0 0]
     [(& ?(lt? $ 0) $n)
       (if (even? n)
         (neg(fib-fast (neg n)))
         (fib-fast (neg n)))]
     [$n
       (letrec {[$fib-fast-iter (lambda [$a $b $p $q $c]
          (if (lt? c 2)
            (+ (* a p) (* b q))
            (let {[$a' (if (odd? c) (+ (* a p) (* b q)) a)]
                  [$b' (if (odd? c) (+ (* a q) (* b q) (* b p)) b)]
                  [$p' (+ (* p p) (* q q))]
                  [$q' (* (+ (* 2 p) q) q)]
                  [$c' (quotient c 2)]}
              (fib-fast-iter a' b' p' q' c'))))]}
        (fib-fast-iter 0 1 0 1 n))]}))
"""
import re
define_fib_fast = re.sub(r'[\r\n]+', ' ', define_fib_fast.strip())
egison.run_command(define_fib_fast)

egison.run_command("(test (map fib-fast (between -10 10)))")
# => u'{-55 34 -21 13 -8 5 -3 2 -1 1 0 1 1 2 3 5 8 13 21 34 55}\r\n'

egison.run_command("(test (fib-fast 100))")
# => u'354224848179261915075\r\n'

# 再定義(REPL なら可能)
cmd = "(define $sample #f)"
egison.run_command(cmd)
egison.run_command("(sample {1 2 3 4 5 6 7 8 9 10})")
# => u'Expected function, but found: #f\r\n'

簡単に解説:

  • (※1) replwrap.REPLWrapper は、REPL のプロンプトを指定して、それを区切りにして出力結果を取得する仕組み。
    ところが Egison repl のようにプロンプトが単純な '>' だったりする場合、出力結果にそれが含まれているだけで誤動作してしまいます(例:(test '>'))。
    そこで、ランダムで十分な長さの文字列を前に付けたプロンプトを作成。
  • (※2) ↑で作ったプロンプト文字列を指定して egison を実行。
    なお第3引数は「プロンプトを変更するコマンド」の指定。これを指定した上で第4引数で「変更後の新しいプロンプト」を指定することもできるそうですが、Egison REPL はそういうのに対応していない代わりにコマンドライン引数でプロンプトを指定できるので、そのようにしています。
    あと、egison コマンドにはパスが通っている前提です。
  • (※3) REPLWrapper.run_command() メソッドで実行。第1引数が REPL に引き渡すコマンド、第2引数はタイムアウト秒数(省略可、省略時のデフォルトは30秒)。
    戻り値は、そのコマンドを実行してから次のプロンプトが現れるまでの出力全体(Unicode 文字列)。
  • (※4) コマンド文字列に改行文字が含まれる場合。
    REPL でも 普通のコードでも問題ないはず1なんですけれど、run_command() に渡したらなぜかうまく動作しませんでした。
    文法的には問題は無いはず(ホワイトスペース扱い)なので、半角空白に置換してしまえばOK。
    ということで、re.sub(r'[\r\n]+', ' ', cmd.strip()) は常套句、ですね。

ということで、ちゃんと動くことと、動かし方のコツが分かったです。

STEP.2 カーネル作成

カーネル本体

公式の開発ドキュメント や、実際の実装例(bash_kernel) を参考に。

egison-kernel.py
# coding: utf-8

# ===== DEFINITIONS =====

from IPython.kernel.zmq.kernelbase import Kernel
from pexpect import replwrap, EOF
from subprocess import check_output

import re
import signal
import uuid

__version__ = '0.0.1'

version_pat = re.compile(r'(\d+(\.\d+)+)')
crlf_pat = re.compile(r'[\r\n]+')

class EgisonKernel(Kernel):
    implementation = 'egison_kernel'
    implementation_version = __version__

    _language_version = None

    @property
    def language_version(self):
        if self._language_version is None:
            m = version_pat.search(check_output(['egison', '--version']).decode('utf-8'))
            self._language_version = m.group(1)
        return self._language_version


    @property
    def banner(self):
        return u'Simple Egison Kernel (Egison v%s)' % self.language_version


    language_info = {'name': 'egison',
                     'codemirror_mode': 'scheme',
                     'mimetype': 'text/plain',
                     'file_extension': '.egi'}


    def __init__(self, **kwargs):
        Kernel.__init__(self, **kwargs)
        self._start_egison()


    def _start_egison(self):
        # Signal handlers are inherited by forked processes, and we can't easily
        # reset it from the subprocess. Since kernelapp ignores SIGINT except in
        # message handlers, we need to temporarily reset the SIGINT handler here
        # so that Egison is interruptible.
        sig = signal.signal(signal.SIGINT, signal.SIG_DFL)
        prompt = uuid.uuid4().hex + ">"
        try:
            self.egisonwrapper = replwrap.REPLWrapper("egison --prompt " + prompt, 
                unicode(prompt), None)
        finally:
            signal.signal(signal.SIGINT, sig)


    def do_execute(self, code, silent, store_history=True,
                   user_expressions=None, allow_stdin=False):
        code = crlf_pat.sub(' ', code.strip())
        if not code:
            return {'status': 'ok', 'execution_count': self.execution_count,
                    'payload': [], 'user_expressions': {}}

        interrupted = False
        try:
            output = self.egisonwrapper.run_command(code, timeout=None)
        except KeyboardInterrupt:
            self.egisonwrapper.child.sendintr()
            interrupted = True
            self.egisonwrapper._expect_prompt()
            output = self.egisonwrapper.child.before
        except EOF:
            output = self.egisonwrapper.child.before + 'Restarting Egison'
            self._start_egison()

        if not silent:
            # Send standard output
            stream_content = {'name': 'stdout', 'text': output}
            self.send_response(self.iopub_socket, 'stream', stream_content)

        if interrupted:
            return {'status': 'abort', 'execution_count': self.execution_count}

        return {'status': 'ok', 'execution_count': self.execution_count,
                'payload': [], 'user_expressions': {}}


# ===== MAIN =====
if __name__ == '__main__':
    from IPython.kernel.zmq.kernelapp import IPKernelApp
    IPKernelApp.launch_instance(kernel_class=EgisonKernel)

簡単に解説:

  • 18行目と96行目。IPython.kernel.zmq.kernelbase.Kernel クラスを継承したクラスを作って、IPKernelApp.launch_instance() メソッドの引数にそのクラスを渡して起動する。これが(Python 実装による)IPython カーネルの基本。
  • 62行目。Kernel クラスで最低限継承するべきメソッドは do_execute() のみ。
    セルに入力された文字列(=code)を受け取って、解釈・実行して、その結果を send_response() メソッドで送信してステータスを所定の Dictionary で返す。
    try:〜except:〜KeyboardInterrupt とか pexpect.EOF とかの例外ハンドリングしている記述は、bash_kernel からそのまま拝借しましたが、Egison ではひょっとしたら KeyboardInterrupt は不要かも(未確認)。
  • 前後しますが、48〜59行目。replwrap.REPLWrapper の初期化を実施。
    こちらも bash_kernel からほぼそのまま拝借しましたが、signal の処理はひょっとしたら不要かも(未検証)。
    prompt の処理は 前節 の解説 (※1)、(※2) 参照。
  • さらに前に戻って、38行目。codemirror_mode は、主にセルに入力したコードのシンタックスハイライトに使用されます。でも CodeMirror に Egison モードが用意されていないので、文法や予約語が一番近い 'scheme' に設定。

その他、bash_kernel では画像の表示に対応していたり、do_complete()(コード補完)メソッドもオーバーライドしてたりしますが、Egison には不要(もしくは実現困難)なので省いています。

KernelSpec ファイル

~/.ipython/kernels/ ディレクトリ内に、↑で作った egison-kernel.py ファイルを配置した上で、同ディレクトリ内に↓を作成:

kernel.json
{
  "display_name": "Egison",
  "language": "egison", 
  "argv": [
    "python", 
    "/path/to/user_home/.ipython/kernels/egison/egison-kernel.py", 
    "-f", "{connection_file}"
  ], 
  "codemirror_mode": "scheme"
}

codemirror_mode に先ほどと同じ指定をしている以外は、まぁ見ていただければ分かると思いますので詳細略。

動作確認

これで Jupyter を(再)起動すれば、Egison カーネルが反映されます2

JupyterKernels20150618.png

実際に動かしてみたのがこちら↓

Egison_1st.png

結果は↓こちらから閲覧・ダウンロードできます(nbviewer)。
Egison_1st.ipynb

雑感

  • ○:最低限のシンタックスハイライトと、カッコ対応のハイライトはしてくれるので、便利。
  • ○:式ごとに結果を確認できるし、再定義も許可されているから試行錯誤できる。
  • △:コード補完は利用できない(そこまで自作するのはつらい…)。
  • △:カッコの対応が取れていない状態(閉じ括弧が足りない状態)で実行すると、BUSY のまま返ってこなくなる。
    → 慌てず焦らず、メニューの [Kernel] → [Interrupt] を選択して中断すれば OK。
  • ×:エラーがエラーと分かりにくい(replwrapが通常の出力かエラーかの区別をしてくれないので仕方が無い)

ま、REPL をラップしただけのカーネルでここまでの使い勝手なら良い方ですね。
ちょっと使い方に気をつければ充分に使えるレベルです(^-^)

参考


  1. 少し前の Egison REPL は、改行するとそこまででコードを解釈して処理しようとしてた記憶があるのですが、最近のはカッコの対応が取れていなければまだコードが続いていると判定してくれるようです。 

  2. 前回の記事から、さらに色々 Kernel が増えてます。この記事でも触れている bash_kernel とか、まだ触れていない IHaskell とか。 

18
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
16