HaskellのJupyter用IHaskellは高機能だけどインストールが面倒なので超簡単なGHCi用のkernelを作ろう（改良版）

# 概要

下記の投稿記事に超簡単なGHCi用のJupyterのkernelを作りましたが、`Restart Kernel`と`Interrupt Kernel`に対応する機能が実装していませんでした。今回はそれらを実装した改良版を作ろうと思います。下記の投稿記事を知ってることを前提とします。

https://qiita.com/h6591/items/c565475ee2325c246c45

# 実行環境
```terminal
> sw_vers
ProductName:		macOS
ProductVersion:		15.4.1
BuildVersion:		24E263

> ghci --version
The Glorious Glasgow Haskell Compilation System, version 9.12.2

> python --version
Python 3.12.7

> jupyter --version
Selected Jupyter core packages...
IPython          : 8.27.0
ipykernel        : 6.28.0
ipywidgets       : 7.8.1
jupyter_client   : 8.6.0
jupyter_core     : 5.7.2
jupyter_server   : 2.14.1
jupyterlab       : 4.2.5
nbclient         : 0.8.0
nbconvert        : 7.16.4
nbformat         : 5.10.4
notebook         : 7.2.2
qtconsole        : 5.5.1
traitlets        : 5.14.3
```

# ghci-kernel.pyを改良しよう

## 前回作ったghci_kernel.py
```py:前回作ったghci_kernel.py
from ipykernel.kernelbase import Kernel
from pexpect.replwrap import REPLWrapper

class GHCiKernel(Kernel):
    implementation = 'GHCi'
    implementation_version = '0.0.1'
    language_info = {
        'name': 'haskell',
        'mimetype': 'text/x-haskell',
        'file_extension': '.hs'
    }

    language_version = '9.12.2'
    banner =  'Simple GHCi Kernel'

    replwrapper = REPLWrapper("stack ghci", "ghci> ", None,
                              continuation_prompt="ghci| ")

    def do_execute(self, code, silent, store_history=True, 
                   user_expressions=None, allow_stdin=False):        
        if not silent:
            if self.execution_count == 1:
                code = ":set +m\n" + code 
            if not code.endswith("\n"):
                code += "\n"
            stdout = self.replwrapper.run_command(code, timeout=None)
            stream_content = {'name': 'stdout', 'text': stdout}
            self.send_response(self.iopub_socket, 'stream', stream_content)

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

if __name__ == '__main__':
    from ipykernel.kernelapp import IPKernelApp
    IPKernelApp.launch_instance(kernel_class=GHCiKernel)
```


次に上記の`REPLWrapper`関係の処理を別クラス`GHCiKernel`にしてshutdownの簡単な処理を追加。

```ghci_kernel.py
from ipykernel.kernelbase import Kernel
from pexpect.replwrap import REPLWrapper
import pexpect
import signal

INIT_CMD = """
:set +m
"""
class GHCi:
    def __init__(self):
        self.start_ghci()

    def start_ghci(self):
        self.replwrapper = REPLWrapper("ghci", "ghci> ", None,
                                       continuation_prompt="ghci| ")
        self.run_command(INIT_CMD)
        
    def run(self, code):
        if not code.endswith("\n"):
            code += "\n"
        return self.run_command(code)
    
    def run_command(self, code):
        stdout = self.replwrapper.run_command(code, timeout=None)
        stderr = None
        return stdout, stderr
    
    def shutdown(self, restart):
        self.run_command(":quit")

class GHCiKernel(Kernel):
    implementation = 'GHCi'
    implementation_version = '0.0.1'
    language_info = {
        'name': 'haskell',
        'mimetype': 'text/x-haskell',
        'file_extension': '.hs'
    }

    language_version = '9.12.2'
    banner =  'Simple GHCi Kernel'

    ghci = GHCi()

    def do_execute(self, code, silent, store_history=True, 
                   user_expressions=None, allow_stdin=False):        
        if not silent:
            stdout, stderr = self.ghci.run(code)
            if stdout != None:
                stream_content = {'name': 'stdout', 'text': stdout}
                self.send_response(self.iopub_socket, 'stream', stream_content)
            if stderr != None:
                stream_content = {'name': 'stderr', 'text': stderr}
                self.send_response(self.iopub_socket, 'stream', stream_content)

        return {'status': 'ok', 'execution_count': self.execution_count,
                'payload': [], 'user_expressions': {}}
    
    def do_shutdown(self, restart):
        self.ghci.shutdown(restart)
        return super().do_shutdown(restart)

if __name__ == '__main__':
    from ipykernel.kernelapp import IPKernelApp
    IPKernelApp.launch_instance(kernel_class=GHCiKernel)
```

- `import`の３行目、４行目は後で使うのであらかじめ書いておきます
- `stream_content`の`'name':'stderr'`で返すとnotebookの表示の背景がピンクになります。これも後で使います
- `do_shutdown`はnotebookの`Shutdown`または`Restart Kernel`を実行したときに呼ばれるメソッドなので**GHCi**を**quit**します
- 参照資料
    - [REPLWrapperのソース](https://pexpect.readthedocs.io/en/stable/_modules/pexpect/replwrap.html#REPLWrapper)


# Interrupt KernelとShutdown Kernelへの対応

`class GCHi`にだけ改良を加えるので、`class GHCiKernel`等については記載しません。

```py
class GHCi:
    def __init__(self):
        self.start_ghci()

    def start_ghci(self):
        self.replwrapper = REPLWrapper("ghci", "ghci> ", None,
                                       continuation_prompt="ghci| ")
        self.child = self.replwrapper.child
        self.run_command(INIT_CMD)
        
    def run(self, code):
        if not code.endswith("\n"):
            code += "\n"
        return self.run_command(code)
    
    def run_command(self, code):
        try:
            stderr = None
            stdout = self.replwrapper.run_command(code, timeout=None)
        except BaseException as e:
            stdout, stderr = self.interrupt(e)
        return stdout, stderr
    
    def interrupt(self, e):
        stderr = "exception: {}".format(type(e).__name__)
        # GHCiの起動中かの確認
        if self.child.isalive():
            # GHCiのSIGINT(CTRL+C)を送る
            self.child.sendintr()
            # GHCiがSIGINTを受けてプロンプトを表示するまでのデータを受け取る
            self.replwrapper._expect_prompt()
            # 受け取った内容をstdoutに代入
            stdout = self.child.before
        else:
            # GHCiが起動していないので再起動
            self.start_ghci()
            stdout = "Restarted GHCi"  
        return stdout, stderr
    
    def shutdown(self, restart):
        try:
            self.replwrapper.run_command(":quit", timeout=0.5)
        except pexpect.EOF:
            return
        except BaseException:
            pass
        if self.child.isalive():
            # GHCiが終了していないときは強制終了させる
            self.child.kill(signal.SIGKILL)
```

- `self.replwrapper.child`
    - REPLWrapperは内部に`pexpect.spawn`のオブジェクト(child)を持っていて、大部分の処理はこのオブジェクトを通して行われているようです
    - [pexpect.spawnのドキュメント](https://pexpect.readthedocs.io/en/stable/api/pexpect.html#pexpect.spawn)
- `run_command`の`try ~ excpet`
    - `run_command`が処理中のときに`Interrupt Kernel`をしたときなどに例外が発生します
        - Interrupt Kernel ==> `KeyboardInterrupt` (CTRL+Cです)
        - GHCiから受取データがEOF ==> `pexpect.EOF`
        - timeout ==> `pexpect.TIMEOUT`
    - KeyboardInterruptは親がExceptionでなくBaseExceptionなので例外のキャッチをBaseExceptionにしています(Exceptionの親もBaseException)
    - `run_command`が処理中に`Shutdown Kernel`をすると`KeyboardInterrupt`の例外が発生してから`do_shutdown`を呼ばれるようです
- `shutdown`
    - `:quit`で**GHCi**が終了するとプロンプトは表示されないのでずっと待ち続けてtimeoutになると思うが`pexpect.EOF`のほうが先に発生して、`self.child.isalive()`は**False**になり`kill`しなくてよい
    - `timeout=0.5`にしている理由は、万が一`sendintr()`などが効かず**GHCi**が`:quit`を受け取れずに処理を続けていると`kill`が実行される前にKernel本体が終了して`kill`が出来ずに**GHCi**プロセスが残ってしまう
        - "Kernel本体が強制終了" > "timeout" > "quitが成功してEOF例外発生"　が成り立つようなtimeoutにしたい
        - 通常ならこのケースは起こらない

# テスト

![haskell06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258007/77f85aea-588a-4147-afb5-6476dc2a4aa4.png)


入力[2]で処理中にStopボタン(Interrupt Kernel)を押すと上記画像のように例外発生して,**GHCi**にSIGINT(CTRL+C)が送られて`Interrupted.`が表示されています。下記のGHCi上での処理と同じになりました。
入力[3]で通常の処理が可能になっています。当然,[2]のCellを`fib 10`と書き換えても正常に実行されます。

:::note warn
追記)
フィボナッチ数列は`fib 0 = 0`のようですが、上記のようにIHaskellのサンプルコードは`fib 0 = 1`となっています。画像の変更はしません。
:::

```
> ghci
GHCi, version 9.12.2: https://www.haskell.org/ghc/  :? for help
ghci> :set +m
ghci> -- If your code isn't running fast enough, you can just put it into a module.
ghci> module A.B where
ghci|     fib 0 = 1
ghci|     fib 1 = 1
ghci|     fib n = fib (n-1) + fib (n-2)
ghci| 
ghci> fib 100
^CInterrupted.
ghci> fib 10
89
ghci> 
```

# 終わりに

思い違いをして手間取ったのが`KeyboardInterrupt`を`Exception`でキャッチできると思い込んで何でキャッチ出来ないんだろうと試行錯誤していたところでしょうか。
ここのところC/C++でJupyter関連の投稿や今回のHaskellの投稿をしていて一番勉強になっているのはPythonかもしれませんが、Haskellの勉強も時間をみながら進めていきたいと思っています。

# 機能アップ版

https://qiita.com/h6591/items/5611ce9684189eb608a3