PythonでIPythonもどきを作ってみる

PythonでIPythonもどきを作ってみる。 ここで「もどき」と呼んでいるのは、不完全な模倣であるためである。IPythonの完全な模倣はIPythonがやってくれているので、私の仕事は不完全な模倣でも良いだろう。

まずファイルを用意する。

.
└── mimic_ipython.py

IPythonのような動作を目指す。具体的な動作イメージを下に示す。

[System@RandomUser]: python mimic_ipython.py
Python 3.9
MIMIC-IPython

>>> 5 + 3
8

>>> x = 5

>>> x
5

>>> def f():
...     print('Hello')

>>> f
<function f at 0x0123456789abcdef>

>>> f()
Hello

>>> exit
[System@RandomUser]: 

上の動作を実現するためのコードを書いていく。 まずは大枠を作る。

#!/usr/bin/env python
"""Mimic IPython.

"""

import colorama
import sys


__MIMIC_APPLICATION_NAME__ = 'MIMIC-IPython'
__version__ = '0.1'


def mimic_ipython():
    ...  # [TODO]


def main():
    mimic_ipython()


if __name__ == '__main__':
    main()

核心となる処理を、関数mimic_ipython()内に記述する。

必要な処理は、ユーザーの入力を読み取って、それをPythonプログラムとして解釈して実行する処理である。 大雑把に書くと、次のようになる。

def mimic_ipython():
    user_globals = dict()
    user_locals = dict()
    while True:
        user_input = input('>> ')
        if user_input.strip() == 'exit':
            break
        user_exec = compile(user_input, filename='<string>', mode='exec')
        exec(user_exec, user_globals, user_locals)

compile()は文字列で記述されたPythonプログラム片をコンパイルする。

ドキュメント(https://docs.python.org/ja/3/library/functions.html#compile)によるとcompile()の実行結果(返り値)はコードオブジェクトまたはASTオブジェクトとのことである。ASTオブジェクトについては気になるが、今回はいったん置いておく。 第二引数filenameと第三引数modeを指定する必要がある。

filenameには'<string>'を指定するのが一般であるらしい。これは、実行時にエラーが生じた際に表示されるファイル名部分に影響する。

modeには'exec', 'eval', 'single'のいずれかを指定する。ここでは'exec'を指定して、exec()に渡している。事前にコンパイルをしなくても実行できるが、複雑な処理ではコンパイルした方が速いのだろう。

exec()では、第二引数と第三引数にスコープの辞書を与えている。このようにすることで、一連の処理の間、変数を保持することができる。

さて、上のプログラムをとりあえず実行してみる。

[System@RandomUser]: python mimic_ipython.py
>> print('Hello')
Hello
>> 5 + 3
>> x = 5
>> print(x)
5
>> x
>> exit
[System@RandomUser]: 

とりあえず動いたが、気になる点は多い。すぐに思いつく範囲で挙げると、

  1. 関数定義のような複数行にわたる命令を記述したい。
  2. 上記1. を実装するうえで、継続行の途中でプロンプトを変化させたい。
  3. ユーザーの入力とプロンプトが同じ色だと見づらいので、色をつけたい。
  4. 終了するためのコマンドを忘れるので、exit以外にquitなどでも終了できるようにしたい。
  5. IPythonのように、print()文を書かなくても値を画面にプリントしてほしい。

修正する。

def mimic_ipython():
    print('Python ' + sys.version.split('\n')[0])
    print(__MIMIC_APPLICATION_NAME__ + ' ' + __version__)
    print()

    PROMPT_DEFAULT = colorama.Fore.GREEN + '>>> ' + colorama.Style.RESET_ALL
    PROMPT_CONTINUE = colorama.Fore.GREEN + '... ' + colorama.Style.RESET_ALL
    EXIT_COMMAND_LIST = ('exit', 'exit()', 'exit;', 'exit();',
                         'quit', 'quit()', 'quit;', 'quit();')

    user_globals = dict(__name__='__main__')
    user_locals = dict()
    user_code = []
    prompt = PROMPT_DEFAULT

    while True:
        user_input = input(prompt)
        user_code.append(user_input)
        if user_input.strip() in EXIT_COMMAND_LIST:
            break
        user_all_input = '\n'.join(user_code)

        # first try to eval user input
        try:
            user_eval = compile(user_all_input,
                                filename='<string>', mode='eval')
            ret = eval(user_eval, user_globals, user_locals)
            if ret is not None:
                # if evaluation is successful, print the return value
                print(ret)
            user_code = []
            prompt = PROMPT_DEFAULT
            print()
            continue
        except SyntaxError:
            ...

        # next try to execute user input
        try:
            user_exec = compile(user_all_input,
                                filename='<string>', mode='exec')
            exec(user_exec, user_globals, user_locals)
            user_code = []
            prompt = PROMPT_DEFAULT
            print()
            continue
        except SyntaxError:
            ...

        # if both interpretation failed, continuation goes
        prompt = PROMPT_CONTINUE
        continue

printをユーザーが陽に実行しなくてもプリントしてほしいため、まずはevalを実行して、それがうまくいけばreturn valueをプリント、うまくいかなければ例外キャッチして今度はexecを試す、という手順にした。 また、継続行をどのようにして判定したら良いか考えた結果、文法的に誤っていれば継続行になる、という方向で書いてみた。

動かして動作を確認する(図1)。

f:id:pzdc:20211003003031p:plain
図1 実験結果…一見して成功に見える

一見して成功しているようだ。実際、画像は最初に載せた動作例を再現している。しかし実際には、継続行の記述に問題がある。 関数の定義は複数の行にわたる場合があって、その途中までを見ても文法的に間違っていない場合があるのである。 例えば、

def f():
    print('Hello')
    print('World')

このようなコードは、上のUIでは実行できない(2行目の入力を終えてエンターを入力した時点で関数fの定義から抜けてしまう)。複数行にわたる関数が書けないというのは致命的な欠陥であり、このままでは使い物にならないだろう。 IPythonでは、関数定義に続いて1行余分に入力すると定義が完了するようになっている(厳密には副作用完了点のような文法的制約で切っているのだと思われる)。これをどのように実現したら良いだろうか。もしGUI上でIPythonのようなものを作るなら(処理のトリガーをCtrl + Enterにできるため)難しく考える必要はないが、今作りたいCUIではその逃げ道がない。

上では関数定義の例だったが、for文やif文でも同様の問題が生じる。if文の場合は、elseに対応する必要もある。どうも、インデントがある場合に慎重さが求められるようだ。 そこで、少し汚ないが、入力中のコード片内にコロンが入っている場合に、空行を入力するまで処理ブロックが完了しない、という実装が思いつく。

上の戦略にしたがって、次の2行を(user_all_input = ...の直後に)加えてみる。

        if (':' in user_all_input) and (user_input != ''):
            continue

結果は下の通り。

[System@RandomUser]: python mimic_ipython.py
Python 3.9.2 (default, Mar 12 2021, 18:54:15)
MIMIC-IPython 0.1

>>> 5 + 3
8

>>> def f():
>>>     print('Hello')
>>>     print('World')
>>>

>>> f
<function f at 0x7fd4ef8f0f70>

>>> f()
Hello
World

>>> exit
[System@RandomUser]: 

複数行の関数が定義、実行できた。コード片中にコロンが含まれているかどうかで判定する、というのは明らかに不完全な判定方法だが、これ以外に簡単な実装を思いついていない。使えないほどではないだろう。

ここまででだいぶ良くなったが、使ってみるとまだまだ気になる点は出てくるだろう。例えば、実行中にSyntaxError以外の例外が発生した場合に、このプログラムは終了してしまう。