Pythonにオートインデント機能をつける

※このブログはeeicの実験「大規模ソフトウェアを手探る」のレポートとして書かれたものです。

リンク

導入

ターミナルでPythonを使っていると、以下の画像のようにインデントを自分で入れる必要があります。 jupyter notebookでは自動でインデントを追加する機能があるのでターミナルでもこの機能を導入してあげたいと考えました。

入力を探す

オートインデント機能を実装するにあたって、まずユーザーからの入力を受け付ける部分を見つけます。具体的には、Emacs上でgdbを走らせて入力を受け付けていそうな関数の中にひたすらstepで入っていきます。入力を求められたらブレークポイントを設定して最初から走らせるという流れを繰り返しているとPython/pythonrun.c内のPyRun_InertactiveLoopFlags関数で、このようなコードを発見しました。

pythonrun.c/PyRun_InteractiveLoopFlags

    do {
        ret = PyRun_InteractiveOneObjectEx(fp, filename, flags);
        if (ret == -1 && PyErr_Occurred()) {
            /* Prevent an endless loop after multiple consecutive MemoryErrors
             * while still allowing an interactive command to fail with a
             * MemoryError. */
            if (PyErr_ExceptionMatches(PyExc_MemoryError)) {
                if (++nomem_count > 16) {
                    PyErr_Clear();
                    err = -1;
                    break;
                }
            } else {
                nomem_count = 0;
            }
            PyErr_Print();
            flush_io();
        } else {
            nomem_count = 0;
        }
#ifdef Py_REF_DEBUG
        if (show_ref_count) {
            _PyDebug_PrintTotalRefs();
        }
#endif
    } while (ret != E_EOF);

このdo-whileループによって入力、処理→エラーチェックを何度も繰り返しています。 この中で入力と処理を行っているのはPyRun_InteractiveOneObjectEx関数なので、この関数をまたstepしていくとModule/readline.creadline_until_enter_or_signal関数 といういかにも入力を受け付けていそうな関数に入ります。

readline.c/readline_until_enter_or_signal

static char *
readline_until_enter_or_signal(const char *prompt, int *signal)
{
    char * not_done_reading = "";
    fd_set selectset;

    *signal = 0;
#ifdef HAVE_RL_CATCH_SIGNAL
    rl_catch_signals = 0;
#endif

    rl_callback_handler_install (prompt, rlhandler);
    FD_ZERO(&selectset);

    completed_input_string = not_done_reading;

    while (completed_input_string == not_done_reading) {
        int has_input = 0, err = 0;

        while (!has_input)
        {               struct timeval timeout = {0, 100000}; /* 0.1 seconds */

            /* [Bug #1552726] Only limit the pause if an input hook has been
               defined.  */
            struct timeval *timeoutp = NULL;
            if (PyOS_InputHook)
                timeoutp = &timeout;
#ifdef HAVE_RL_RESIZE_TERMINAL
            /* Update readline's view of the window size after SIGWINCH */
            if (sigwinch_received) {
                sigwinch_received = 0;
                rl_resize_terminal();
            }
#endif
            FD_SET(fileno(rl_instream), &selectset);
            /* select resets selectset if no input was available */
            has_input = select(fileno(rl_instream) + 1, &selectset,
                               NULL, NULL, timeoutp);
            err = errno;
            if(PyOS_InputHook) PyOS_InputHook();
        }

        if (has_input > 0) {
            rl_callback_read_char();
        }
        else if (err == EINTR) {
            int s;
            PyEval_RestoreThread(_PyOS_ReadlineTState);
            s = PyErr_CheckSignals();
            PyEval_SaveThread();
            if (s < 0) {
                rl_free_line_state();
#if defined(RL_READLINE_VERSION) && RL_READLINE_VERSION >= 0x0700
                rl_callback_sigcleanup();
#endif
                rl_cleanup_after_signal();
                rl_callback_handler_remove();
                *signal = 1;
                completed_input_string = NULL;
            }
        }
    }

    return completed_input_string;
}

上のコードのうちrl_callback_read_char関数 で入力を受け付けていること、rl_callback_handler_install関数 でターミナル上の>>>...が入力されていることがわかりました。(正確には変数prompt>>>...が格納されていて、rl_callback_handler_install関数で出力している。)

GNU Readlineについて

rl_callback_read_char関数を調べたところ、GNU Readlineと呼ばれるライブラリの関数であることがわかりました。rlはreadlineの略だそうです。

CPythonに入力させる

それでは、実際にインデントをプログラム側から入力するように変えていきます。 rl_callback_read_char関数の前に rl_insert_text("␣␣␣␣") (␣は空白)を追加すればインデントを挿入することができますが、これだけではキーボードから入力されるまでインデントが表示されませんでした。 そこで、ドキュメントを眺めていたところ、rl_line_bufferの現在の内容を画面上に反映させるrl_redisplay() 関数を見つけました。(これはバッファにたまった文字列を画面上に出力するC言語のfflush関数を想起させます。) rl_insert_textの後にrl_redisplay()を入れると、即座にインデントが表示されるようになりました。 変数promptにはターミナル上で表示される>>>...が格納されているので、とりあえず「prompt...ならばインデントを行う」とすればブロック内ならインデントを1個だけ置くという処理が実装できます。

readline.c/readline_until_enter_or_signal

    if(strcmp(prompt,"... ") == 0){
        rl_insert_text("    ");
    }
    rl_redisplay();

直前の入力を読み込む

上記の実装だけではインデントが1個補完されるだけで、Python入れ子構造に対応できていません。そのため、ユーザの直前の入力を持ってきてインデントの数を管理する必要があります。 Module/readline.cについて眺めていると直前の行の入力を受け取る部分がありました。直前の行の入力は変数lineに格納されています。 (以下のコード参照、直前と重複しない限り入力を履歴に追加するというコードのようです)

readline.c/call_readline

    /* we have a valid line */
    n = strlen(p);
    if (should_auto_add_history && n > 0) {
        const char *line;
        int length = _py_get_history_length();
        if (length > 0) {
            HIST_ENTRY *hist_ent;
            if (using_libedit_emulation) {
                /* handle older 0-based or newer 1-based indexing */
                hist_ent = history_get(length + libedit_history_start - 1);
            } else
                hist_ent = history_get(length);
            line = hist_ent ? hist_ent->line : "";
        } else
            line = "";
        if (strcmp(p, line))
            add_history(p);
    }

このコードをコピーして不必要な部分を削れば直前の入力を文字列lineに格納するコードの出来上がりです。

readline.c/readline_until_enter_or_signal

        const char *line;
        int length = _py_get_history_length();
            HIST_ENTRY *hist_ent;
            if (using_libedit_emulation) {
                /* handle older 0-based or newer 1-based indexing */
                hist_ent = history_get(length + libedit_history_start - 1);
            } else
                hist_ent = history_get(length);
            line = hist_ent ? hist_ent->line : "";

インデントの数を変更する

直前の入力lineを読み込んだので、先頭の文字からインデントを読みだしていきます。直前の入力のインデントと同じだけインデントを追加して、直前の入力に:があったらさらにインデントを1つ追加します。また、コメントアウトされていた場合は、それ以上インデントの追加を行わないようにします。

readline.c/readline_until_enter_or_signal

    if(strcmp(prompt,"... ") == 0){
        const char *line;
        int length = _py_get_history_length();
            HIST_ENTRY *hist_ent;
            if (using_libedit_emulation) {
                /* handle older 0-based or newer 1-based indexing */
                hist_ent = history_get(length + libedit_history_start - 1);
            } else
                hist_ent = history_get(length);
            line = hist_ent ? hist_ent->line : "";

        int len = strlen(line);
        for(int i = 0; i < len; i++){
            if(line[i] == ' '){
                rl_insert_text(" ");
            }else if(line[i] == '\t'){
                rl_insert_text("\t");
            }else{
                break;
            }
        }
        for (int i=0;i<len;i++){
            if(line[i] == '#'){
                break;
            } else if(line[i]==':') {
                rl_insert_text("    ");
                break;
            }
        }
    }

    rl_redisplay();

これで望んだ動作を実現できました!

実装結果

最終的なコード

readline.c/readline_until_enter_or_signal

static char *
readline_until_enter_or_signal(const char *prompt, int *signal)
{
    char * not_done_reading = "";
    fd_set selectset;

    *signal = 0;
#ifdef HAVE_RL_CATCH_SIGNAL
    rl_catch_signals = 0;
#endif
    rl_callback_handler_install (prompt, rlhandler);
    FD_ZERO(&selectset);

    completed_input_string = not_done_reading;


    if(strcmp(prompt,"... ") == 0){
        const char *line;
        int length = _py_get_history_length();
            HIST_ENTRY *hist_ent;
            if (using_libedit_emulation) {
                /* handle older 0-based or newer 1-based indexing */
                hist_ent = history_get(length + libedit_history_start - 1);
            } else
                hist_ent = history_get(length);
            line = hist_ent ? hist_ent->line : "";

        int len = strlen(line);
        for(int i = 0; i < len; i++){
            if(line[i] == ' '){
                rl_insert_text(" ");
            }else if(line[i] == '\t'){
                rl_insert_text("\t");
            }else{
                break;
            }
        }
        for (int i=0;i<len;i++){
            if(line[i] == '#'){
                break;
            } else if(line[i]==':') {
                rl_insert_text("    ");
                break;
            }
        }
    }

    rl_redisplay();
    while (completed_input_string == not_done_reading) {
        int has_input = 0, err = 0;
        while (!has_input)
        {               struct timeval timeout = {0, 100000}; /* 0.1 seconds */

            /* [Bug #1552726] Only limit the pause if an input hook has been
               defined.  */
            struct timeval *timeoutp = NULL;
            if (PyOS_InputHook)
                timeoutp = &timeout;
#ifdef HAVE_RL_RESIZE_TERMINAL
            /* Update readline's view of the window size after SIGWINCH */
            if (sigwinch_received) {
                sigwinch_received = 0;
                rl_resize_terminal();
            }
#endif
            FD_SET(fileno(rl_instream), &selectset);
            /* select resets selectset if no input was available */
            has_input = select(fileno(rl_instream) + 1, &selectset,
                               NULL, NULL, timeoutp);
            err = errno;
            if(PyOS_InputHook) PyOS_InputHook();
        }
        if (has_input > 0) {
            rl_callback_read_char();
        }
        else if (err == EINTR) {
            int s;
            PyEval_RestoreThread(_PyOS_ReadlineTState);
            s = PyErr_CheckSignals();
            PyEval_SaveThread();
            if (s < 0) {
                rl_free_line_state();
#if defined(RL_READLINE_VERSION) && RL_READLINE_VERSION >= 0x0700
                rl_callback_sigcleanup();
#endif
                rl_cleanup_after_signal();
                rl_callback_handler_remove();
                *signal = 1;
                completed_input_string = NULL;
            }
        }
    }
    return completed_input_string;
}

下のgif画像から、実際にインデントが必要な数だけ自動補完されていることがわかります。

まとめ・感想

  • gdbを使ってプログラムの動作を一行ずつ見ながら特定の場所を探るということが「大規模ソフトウェアを手探」っている感があって面白く感じられました。
  • 入出力に用いられるGNU readlineというモジュールの存在を知ることができたことはいい収穫でした。とくにrl_insert_text関数やrl_redisplay関数、history_get関数などを通してどのように入出力が行われているのか深く切り込むことができたのは非常にためになりました。
  • CPythonという巨大なプログラムを手探る中で、readline_until_enter_or_signal関数というように関数名などが理解しやすく定義されていて、一人だけで扱うものでないプログラムにおいて後継を意識してコーディングすることの意義を感じられました。