はじめに
この記事は、東京大学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)
してみると、
#!/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を探してみると
<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が呼ばれていることがわかります。
/**
* 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を辿っていくと
case 'markdown':
cell = new textcell.MarkdownCell(cell_options);
break;
という部分を発見します。ここに'section'
のcaseを追加し、新しくSectionCell
を実装すれば所望の機能は得られそうなことが分かります。
notebook/static/notebook/js/textcell.js
にRawCell
, MarkdownCell
の実装があるため、そこに差し当たりRawCell
の一部を変更した以下のクラスを追加しました。
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_section
funcやcase文など)を実装しました。
セクション間実行の実装
さて、SectionCellが実装できたところで、次はそのセクションで囲われた部分を一括で実行できる機能を実装していきます。
Jupyter Notebokにもともと備わっている機能であるRun All(全てのセルを実行)を参考に実装を進めました。
その実装がどこにあるかを例のごとくファイル全検索で調べたところ、もうすでに見てきたaction.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にすれば良いことがわかります。
それを実装したのが以下になります。
/**
* 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.js
、menubar.js
、notebook.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 formatのmetadata
のformat
にsection
という値が入っている時にセルをSectionCell
と見なすようセーブ・ロードの部分を変更し、これに対応することができました。
セーブ部
if (this instanceof SectionCell){
data.metadata.format = 'section';
}
ロード部
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
を追加します。
.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
となる)なっていますが、上述の通りSectionCell
のcell_type
がraw
である都合上、この機能がうまく動作しませんでした。
そこで、イベント発火側で、このセルがSectionCellかというフラグを渡すことにしました。
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
は
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_splittable
とCell.prototype.is_mergeable
というfunctionを発見しました。これをSectionCell
クラスでオーバーライドすれば良さそうです。
/** @method is_splittable **/
SectionCell.prototype.is_splittable = function () {
return false;
};
/** @method is_mergeable **/
SectionCell.prototype.is_mergeable = function () {
return false;
};
以上のコードを追加したところ、無事分割・合体不可能になりました。
javascriptの継承・クラスの仕組みをよく分かっておらず、最初は
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は本来、セルに打ち込まれているテキストを読み込んでいるものですが、そこを"***"に置き換える事で強制的にセルの表示を水平線にすることができます。
markdown.render("***", { // 元の第一引数は text
with_math: true,
clean_tables: true,
sanitize: true,
},
...
2に関しては、セルをunrenderして編集モードに入るという一連の動作を行っている部分を探し、cellのタイプがSectionCellだった場合読まれないようにします。これは二箇所存在しており、まず一箇所目はtextcell.js内のMarkdowncellをコピーして作ったSectioncellの部分にあり、
this.element.dblclick(function () {
var cont = that.unrender();
if (cont) {
that.focus_editor();
}
});
のようになっていたのでこれをコメントアウトします。そしてこれだけだと、ショートカットキーによる編集モードへの移行が可能なので、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万行ってなんやねん!
- 楽しかった!!(多数)
- 関数名変数名から機能が、機能から関数名変数名が概ね分かるプログラムだったので、検索にも引っかかるし読みやすいしでとてもありがたかった
他のチームの人達もかなり楽しんでいたので、後期実験何取ろうか迷ってる方は検討してみて下さい。
参考文献
- Project Jupyter | Documentation
- gitのremote urlを変更する(レポジトリ移行時) - Qiita
- Node.jsとnpmをアップデートする方法 | Rriver
- [index の複数形は indexes なのか indices なのか - 猫でもわかるWebプログラミングと副業]
(https://www.utakata.work/entry/2016/02/02/105959) - 【Git】リモートブランチをチェックアウトしたいときは「git fetch origin <ブランチ名>」と「git checkout <ブランチ名>」を実行すれば良い | DevelopersIO
- jupyterを支える技術:traitlets(の解読を試みようとした話) - Qiita
- 開発への影響を最小限にしてGithubとGitlab間でリポジトリを移動させる方法 | Ibukish Lab+
以上です。