LoginSignup
1
0

More than 1 year has passed since last update.

Jupyter Notebookにセクション実行機能を追加してみた

Last updated at Posted at 2021-11-08

はじめに

この記事は、東京大学eeicの実験「大規模ソフトウェアを手探る」のレポート用に実験内容をまとめたものになります。この実験の趣旨としては、全容を把握できないほどのファイル数/行数のコードからなるOSSを少しずつ紐ほどき、欲しい機能を追加してみよう、というものです。この記事は自分含むチームメンバー3人で共同で執筆しました。そのため表記揺れや文調の変化が多々あるかもしれませんが、ご了承ください。OSS開発をしてみたいと思っている方、Jupyter Notebookをいじってみたい方の参考になれば幸いです。

Jupyter Notebookとは?

自分のチームでは、改造対象のソフトウェアとしてJupyter Notebookを扱いました。Jupyter Notebookとは、pythonをWebブラウザ上で実行できるIDE(統合開発環境)です。プログラミング初心者が最初の一歩として触ることが多いのではないでしょうか。

セルをまとまりとして実行できるようにしたい!

Notebookは「セル」という単位でコードを実行でき、出力が簡単に確認できます。そのため、気づいたときにはセルが大量にあり、変数もぐちゃぐちゃ、、そんなことがよくあると思います。そこで、セルを束ねる"セクション"のような機能を実装できれば、見る人も見やすく、自分でも管理しやすいコードが書けるようになるのではないかと考えました。matlabのライブスクリプトには以下の画像のようなセクションの実装があり、これと同じようなものをjupyter notebookで実現することを目標としました

↑matlabのライブスクリプトの画像です。

事前準備

インストールとビルド

Jupiter Notebook含むProject Jupyterのソースコードはこちらにまとまっています。
多数のリポジトリがありますが(何と76!!)、今回はセルをまとめて実行するというかなり表層側の機能の実装なので、末端のnotebookに手を加えます。構造は下の画像より。ちなみに、コードは全部で約200万行近くありました。とても大規模ですね…。

引用元

ビルドの方法に関してはCONTRIBUTING.rstに記載があります。npmとNode.jsをインストールしたのち、手順に従ってビルドすればOKです。
注. pip、npm、Node.jsのバージョンが低いとエラーを吐く場合があるので注意が必要です。自分はnpmのバージョンが足りなくてエラーが出ましたがnpm update -g npmで解決しました。

動くようなったバージョンを書いておきます。エラーが出る方はこのバージョンにしてみてください。

python:3.8.10
npm:8.0.0
node:v16.11.0

デバッグのすすめ

通常$ jupyter notebookコマンドでnotebookを起動します。このjupyterの実態を調べるために$ cat $(which jupyter)してみると、

jupyter
#!/Users/xxxxx/.local/share/virtualenvs/large-SOx33mtI/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from jupyter_core.command import main
if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

中身は普通のpythonファイルでした。shebangがあるため、インタプリタをコマンドで指定しなくても自動でpythonで実行されます。
デバッグ開始時は、ipdb(gdbのpython版であるpdbのリッチなバージョン)を使って、セル実行時などのjupyterの内部の挙動を追おうとしましたが、Jupyter Notebookのサーバーが立ち上がった辺りで無限ループの中に入ってしまい進めなくなるなどの問題にぶち当たり、このままipdbを使っても成果が得られないと考え、方針を変えることにします。
 その方針は、ファイル内の文字検索です。関数名やクラス名、コメントがしっかりとついていれば、検索すれば関係のありそうなファイルがヒットするでしょう。というわけで、今回の場合ならデフォルトの機能にRun allというすべてのセルを実行するという目標に近いものがあるので、「Run all」で検索します。そうした結果24件のファイルがヒットしますが、一個ずつ流し読みしていくと、notebook.jsというJava Scriptのファイルが怪しいことがわかります。

実装

目標であるセクション実行機能を実装するには、まずはセクションとセクションの間を区切る「セクションセパレータ」的な物が必要です。全貌を把握できないほど膨大なソースコードに変更を加えるにあたって、全く新しいクラスや概念を追加するとエラーやバグが多発すると思ったので、積極的に既存のクラス等を活用しました。具体的にはcode, markdown, rawの既存の3つのセルタイプに新しくsectionを追加し、sectionタイプのセル(以下section cell)をセクションセパレータとして利用する設計方針で実装することにしました。

メニューバーのCell TypeへのSectionの追加

section機能実装の第一歩として、既存のcellをsection cellに変更する機能の実装からチャレンジしました。セルタイプは下記画像のようにメニューバーのCell -> Cell Typeから変更できます。

ここにSectionを追加し、クリックしたら選択中のcellがsection cellになるようにしたいですね。
Markdownへの変更を行う部分を参考にすれば簡単に作れそうなので、まずはMarkdownボタンを押したときの挙動を辿ってみます。

Run Allを探した時に発見したhtmlを探してみると

notebook/temaplates/notebook.html
<li id="to_markdown"
  title="{% trans %}Contents will be rendered as HTML and serve as explanatory text{% endtrans %}">
  <a href="#">{% trans %}Markdown{% endtrans %}</a></li>

という部分がありました。
notebook/static/notebook/js/menubar.jsからこの#to_markdownに対応するchange-cell-to-markdownを得て、さらにnotebook/static/notebook/js/action.jsを参照すると、最終的にはnotebook.cells_to_markdown()というfunctionが呼ばれていることがわかります。

notebook.js
/**
 * Turn one or more cells into Markdown.
 *
 * @param {Array} indices - cell indices to convert
 */
    Notebook.prototype.cells_to_markdown = function (indices) {
    if (indices === undefined) {
        indices = this.get_selected_cells_indices();
    }

    for(var i=0; i < indices.length; i++) {
        this.to_markdown(indices[i]);
    }
    };

/**
 * Turn a cell into a Markdown cell.
 * 
 * @param {integer} [index] - cell index
 */
Notebook.prototype.to_markdown = function (index) {
    var i = this.index_or_selected(index);
    if (this.is_valid_cell_index(i)) {
        var source_cell = this.get_cell(i);

        if (!(source_cell instanceof textcell.MarkdownCell) && source_cell.is_editable()) {
            var target_cell = this.insert_cell_below('markdown',i);

            this.transfer_to_new_cell(source_cell, target_cell);
            this.select(i);

            if ((source_cell instanceof textcell.TextCell) && source_cell.rendered) {
                target_cell.render();
            }
            var cursor = source_cell.code_mirror.getCursor();
            target_cell.code_mirror.setCursor(cursor);
            this.set_dirty(true);
        }
    }
};

以上がそのfunctionの中身ですが、本質的なのは
var target_cell = this.insert_cell_below('markdown',i);
の行です。
詳細は割愛しますが、このfunctionを辿っていくと

notebook.js
case 'markdown':
    cell = new textcell.MarkdownCell(cell_options);
    break;

という部分を発見します。ここに'section'のcaseを追加し、新しくSectionCellを実装すれば所望の機能は得られそうなことが分かります。
notebook/static/notebook/js/textcell.js
RawCell, MarkdownCellの実装があるため、そこに差し当たりRawCellの一部を変更した以下のクラスを追加しました。

notebook/static/notebook/js/textcell.js
var SectionCell = function (options) {
    /**
     * Constructor
     *
     * Parameters:
     *  options: dictionary
     *      Dictionary of keyword arguments.
     *          events: $(Events) instance 
     *          config: ConfigSection instance
     *          keyboard_manager: KeyboardManager instance 
     *          notebook: Notebook instance
     */
    options = options || {};
    var config_default = utils.mergeopt(TextCell, SectionCell.options_default);
    this.class_config = new configmod.ConfigWithDefaults(options.config,
                                        config_default, 'RawCell');
    TextCell.apply(this, [$.extend({}, options, {config: options.config})]);
    this.cell_type = 'section';
};

SectionCell.options_default = {
    highlight_modes : {
        'diff'         :{'reg':[/^diff/]}
    },
    placeholder : i18n.msg._("Write raw LaTeX or other formats here, for use with nbconvert. " +
        "It will not be rendered in the notebook. " +
        "When passing through nbconvert, a Raw Cell's content is added to the output unmodified."),
};

SectionCell.prototype = Object.create(TextCell.prototype);

/** @method bind_events **/
SectionCell.prototype.bind_events = function () {
    TextCell.prototype.bind_events.apply(this);
    var that = this;
    this.element.focusout(function() {
        that.auto_highlight();
        that.render();
    });

    this.code_mirror.on('focus', function() { that.unrender(); });
};

/** @method render **/
SectionCell.prototype.render = function () {
    var cont = TextCell.prototype.render.apply(this);
    if (cont){
        var text = this.get_text();
        if (text === "") { text = this.placeholder; }
        this.set_text(text);
        this.element.removeClass('rendered');
        this.auto_highlight();
    }
    return cont;
};

また、to_markdownを参考に、上記で発見したnotebook.html, menubar.js, action.js, notebook.jsへ、対応する要素(to_sectionfuncやcase文など)を実装しました。

セクション間実行の実装

さて、SectionCellが実装できたところで、次はそのセクションで囲われた部分を一括で実行できる機能を実装していきます。
Jupyter Notebokにもともと備わっている機能であるRun All(全てのセルを実行)を参考に実装を進めました。
その実装がどこにあるかを例のごとくファイル全検索で調べたところ、もうすでに見てきたaction.jsnotebook.jsに記述がありました。

notebook/static/notebook/js/notebook.js
    /**
     * Execute all cells.
     */
    Notebook.prototype.execute_all_cells = function () {
        this.execute_cell_range(0, this.ncells());
        this.scroll_to_bottom();
    };

この記述から、execute_cell_rangeという関数を用いて実行していることがわかります。この関数もこのファイル内に定義されており、引数に(start, end)をとるため、ここのstartを選択中のセルの上にあるsection cell、endを下にあるsection cellにすれば良いことがわかります。
それを実装したのが以下になります。

notebook/static/notebook/js/notebook.js
    /**
     * Execute section cells.
     */
     Notebook.prototype.execute_section_cells = function () {
        var index = this.get_selected_index();
        var start = 0;
        var end = this.ncells();
        //したに下っていき、セクションがあればそのインデックスをendに、最後まで行ってしまったら最後のインデックスをendに
        for(var i=index; i<end; ++i){
            var source_cell = this.get_cell(i);
            if(source_cell instanceof textcell.SectionCell){
                end = i;
                break;
            }
        }
        //上に登っていき、セクションがあればそのインデックスをstartに、最初まで行ってしまったら最初のインデックスをstartに
        for(var i=index; i>start; --i){
            var source_cell = this.get_cell(i);
            if(source_cell instanceof textcell.SectionCell){
                start = i;
                break;
            }
        }
        this.execute_cell_range(start, end);
        this.focus_cell();
    };

メインの実装はこれで終わりです。画面上にrun sectionを表示するため、action.jsmenubar.jsnotebook.htmlにrun sectionの表記を追加し、終了です。

画面表示はこんな感じ↓

validation回避のためのSectionCellの仕様変更

以上でSectionCellの実装とセクション間実行は実現できたのですが、編集中の.ipynbを保存しようとすると、

とメッセージが表示され保存できません。これは、メニューバーのCell TypeへのSectionの追加の章で追加したSectionCell内で、this.cell_type = 'section';としていることに原因があります。
上の方にあるjupyter projectの図のnotebookの右下にnbformatというレポジトリがあります。エラーが発生した箇所を辿っていくと、保存時にこのnbformatパッケージにより.ipynbファイルがvalidateされ、未知のcell_typeであるsectionがファイルに含まれるためこのエラーが発生するようです。
これを回避するにはnbformatに手を入れるかsectionでないtypeにするかですが、前者は新しくもう一つのレポジトリを改造しなければならないのに加え、今後同じような問題が発生することが予期されたので後者で対応しました。

加えて、このままでは.ipynbファイルにSectionCellの情報を保存することができないことに気づきました。SectionCellとして書いたのに次起動するとRawCellに戻ってしまうのです。
そこで、notebook formatmetadataformatsectionという値が入っている時にセルをSectionCellと見なすようセーブ・ロードの部分を変更し、これに対応することができました。

セーブ部

notebook/static/notebook/js/textcell.js
if (this instanceof SectionCell){
    data.metadata.format = 'section';
}

ロード部

notebook/static/notebook/js/notebook.js
if(cell_data.metadata.format === 'section'){
    new_cell = this.insert_cell_at_index('section', i);
}else{
    new_cell = this.insert_cell_at_index(cell_data.cell_type, i);
}

メインツールバーでもSectionCellに変更可能に

以下がメインツールバーです。メニューバーのすぐ下にあります。

メニューバーに追加したのと同じような変更をここに行います。
notebook/static/notebook/js/maintoolbar.jsにプルダウンメニューの選択肢と選択時の動作が実装されているので、そこにSectionを追加します。

notebook/static/notebook/js/maintoolbar.js
.append($('<option/>').attr('value','raw').text(i18n.msg._('Raw NBConvert')))
.append($('<option/>').attr('value','section').text(i18n.msg._('Section')))
.append($('<option/>').attr('value','heading').text(i18n.msg._('Heading')))

.
.
.

case 'raw':
    that.notebook.cells_to_raw();
case 'section':
    that.notebook.cells_to_section();
    break;
case 'heading':
    that.notebook._warn_heading();

また、オプションの内容は選択したセルに追従するよう(例えば、markdownセルを選択したときにはオプションはMarkdownとなる)なっていますが、上述の通りSectionCellcell_typerawである都合上、この機能がうまく動作しませんでした。
そこで、イベント発火側で、このセルがSectionCellかというフラグを渡すことにしました。

notebook/static/notebook/js/notebook.js
this.events.trigger('selected_cell_type_changed.Notebook',
    {
        'cell_type': cell.cell_type,
        'editable': cell.is_editable()
        'editable': cell.is_editable(),
        'is_section': cell instanceof textcell.SectionCell
    }
);

また、受け取る側であるmaintoolbar.js

notebook/static/notebook/js/maintoolbar.js
this.events.on('selected_cell_type_changed.Notebook', function (event, data) {
.
.
.
    if (data.cell_type === 'heading') {
            sel.val('Markdown');
        } else {
            if(data.is_section){
                sel.val('section');
            }else{
                sel.val(data.cell_type);
            }
        }
    }
});

とし、SectionCellが選択された時にオプションの内容もそれに一致するようになりました。

SectionCellをsplit/merge不可に

jupyter notebookには、セルを分割したりマージしたりする機能があります。
しかし、この機能はセルとセルの間を分けるセパレータとしての役割を持つSectionCellには必要のないものです。
今の状態だとSectionCellもmerge/splitされてしまうので、これを出来ないようにしましょう。
幸いnotebook/static/notebook/js/cell.jsの中にCell.prototype.is_splittableCell.prototype.is_mergeableというfunctionを発見しました。これをSectionCellクラスでオーバーライドすれば良さそうです。

notebook/static/notebook/js/textcell.js
/** @method is_splittable **/
SectionCell.prototype.is_splittable = function () {
    return false;
};

/** @method is_mergeable **/
SectionCell.prototype.is_mergeable = function () {
    return false;
};

以上のコードを追加したところ、無事分割・合体不可能になりました。
javascriptの継承・クラスの仕組みをよく分かっておらず、最初は

notebook/static/notebook/js/textcell.js
SectionCell.prototype = Object.create(TextCell.prototype);

の上にis_splittable等を加えてしまっていましたが、この場合実装したfunctionが逆にオーバーライドされてしまうので注意です。

ショートカットキーの実装

 例によって「shortcut」でファイル内を検索して、ショートカットキーを設定してそうなファイルを探します。すると怪しげなファイルが2つヒットしたので両方のコードをじっくりと読みます。
 第一候補はkeyboard.js。ショートカットキーを格納する配列・それに新たなキーを追加する関数・入力されたキーと配列を照合する関数、など重要なものがありましたが、肝心のデフォルト時のショートカットキーが見つからず仕舞いでした。
 第二候補はkeyboardmanager.js。この中にショートカットの一覧があったので、Shift+sでセクションセルへの変更、Command+Shift+Enterでセクション実行ができるように追加します。

get_default_common_shortcutsは、セルの編集中からでも機能するショートカット。どこにカーソルがあってもshift+enterで実行できる、みたいな感じ


get_default_command_shortcutsはセルの編集中には利用できないショートカット。aでセルを上に挿入できるのですが、コード書いているときにaを打った途端毎回セルが追加されたらブチ切れますよね。コマンドモードでのみ発動されるショートカットです。

見た目の改善

また、後の見た目の調整のため、RawCellではなくMarkdownCellを元にSectionCellを作り変えました。

上位に位置するCell等も含めた図としては以上の様な感じです。
 このままだとsection cellの見た目はMarkdown cellと同じであるため、大層見づらくなります。section cellは単純にセル同士を区切るだけの存在なので、それが一目でわかるような見た目が好ましいことになります。
 これを実現するために、二つの仕様を実装します。
 1. section cellが生成される際に自動的に水平線を入力する仕様。
 2. section cellを編集不可能とする仕様。
1に関しては、新たに作ったSectionCellクラス(markdownをコピーしたもの)中のrenderという名前の関数に着目します。第一引数のtextは本来、セルに打ち込まれているテキストを読み込んでいるものですが、そこを"***"に置き換える事で強制的にセルの表示を水平線にすることができます。

notebook/static/notebook/js/textcell.js
markdown.render("***", { // 元の第一引数は text
    with_math: true,
    clean_tables: true,
    sanitize: true,
}, 
...


上の画像の水平線がsection cellになります。

 2に関しては、セルをunrenderして編集モードに入るという一連の動作を行っている部分を探し、cellのタイプがSectionCellだった場合読まれないようにします。これは二箇所存在しており、まず一箇所目はtextcell.js内のMarkdowncellをコピーして作ったSectioncellの部分にあり、

notebook/static/notebook/js/textcell.js
            this.element.dblclick(function () {
                var cont = that.unrender();
                if (cont) {
                    that.focus_editor();
                }
            });

のようになっていたのでこれをコメントアウトします。そしてこれだけだと、ショートカットキーによる編集モードへの移行が可能なので、notebook.js内の関数に条件分岐を設定します。

notebook/static/notebook/js/notebook.js
            Notebook.prototype.edit_mode = function () {
            this._contract_selection();
            var cell = this.get_selected_cell();
            if (cell && this.mode !== 'edit' && !cell instanceof textcell.SectionCell) {
                cell.unrender();
                cell.focus_editor();
            }
        };

少々汚いコードになってしまいましたが、これで無事にsection cellは編集が不可能になりました。
 最後に、二つの仕様はセルを実行してrenderされていることが前提になっているので、セルをsection cellに変更する関数の最後でセルを実行する関数(target_cell.execute)を実行させることで、renderされていないsection cellが存在しなくなるようにしました。

デモ

デモ動画
上が、実装したセクション実行機能のデモ動画です。section cellの追加とセクション実行、メニューバーやメインツールバーが実装出来ていることが分かります。動画では行っていませんが、ショートカットを使用したセクションセルの追加も可能です。
以上で実装は完了です!

得られた知見

  • repository内の.mdや公式ドキュメントにビルド方法、ソフトウェアのいじり方等が書いてあることが多いので参考にしよう
  • いじりたい機能の目星がついているなら、デバッガでちまちまステップ実行するよりもディレクトリ全検索のほうが役に立つかも
  • 新しくコードを追加するよりも、もとからある機能を再利用したりパクったりしたほうが低コスト&安定する

チームメンバーの感想

  • コード多すぎ!200万行ってなんやねん!
  • 楽しかった!!(多数)
  • 関数名変数名から機能が、機能から関数名変数名が概ね分かるプログラムだったので、検索にも引っかかるし読みやすいしでとてもありがたかった

他のチームの人達もかなり楽しんでいたので、後期実験何取ろうか迷ってる方は検討してみて下さい。

参考文献

以上です。

1
0
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
1
0