他人の書いたコードに挑もう – Part 2

この記事の前編はこちら:他人の書いたコードに挑もう – Part 1


慣れる

前にも言ったように、よく知らないプロジェクトのコードを探索する時は、段階を追って進めます。第一段階は、通常、様々なファイルやフォルダを大まかに見ていくことです。何がどこにあって、そのプロジェクトがどんな「モノ」を持っているのかを把握します。それを終えてやっと、自分の見たい特定の「何か」を詳細に見ていくことができるのです。

いろいろなコードを見る

Spyderにあると思われる主なトップフォルダは下記のものです。

  • app_example/:明らかに何らかのアプリケーション例であり、おそらくメインのコードではない。
  • conda.recipe/: Anacondaとのある種のインテグレーションで、Spyderを簡単にインストールできるようにするもの。
  • continuous_integration/:自動の単体テストを実行するものに関する何か
  • doc/:ドキュメンテーションのサイト?
  • img_src/:不明。
  • rope_profiling/ropeはPythonのASTの名前で、処理やコードの書き換えを行うライブラリ。なぜプロファイルされる必要があるのかは不明。
  • scripts/:おそらくメインコードの一部ではなく、開発者が実行するアドホックの何か。
  • spyderlib/:ライブラリとしてのIDE自体。コードの大部分はおそらくここに入っている。
  • spyplugins/:IDEへのプラグイン。アーキテクチャがどうなっているかによるが、微調整を含む場合もあれば、InteliJでのプラグインの働きのようなIDEの機能全体を含む場合もある。

他にやるべきこととしては、単純にリポジトリ内にあるコードの行カウントを行うことです。これで詳細な情報が山ほど得られるわけではありませんが、これをすることによって、自分が今どんな怪物を見ているのかがよく分かります。何千行のコードでしょうか、何万行というコードでしょうか、それとも何百万でしょうか。

$ find . -name "*.py" | xargs wc -l
...
   58606 total

5万8,000行のPythonのコードですね。膨大というわけではありませんが、決して小さなコードベースだとは言えません。大部分の”モノ”がspyderlib/のフォルダ内にあることがすぐに確認できます。

find spyderlib -name "*.py" | xargs wc -l
...
   54799 total

Spyderlib

ファイルブラウザでspyderlib/の中をのぞいてみると、含まれている主なフォルダの内容が把握できました。

  • app/:Spyder IDEを起動する「エントリポイント」のコードを含んでいるらしい。理論上は、Spyderを実行すると、最初にこのファイルがロードされる。
  • config/defaults/:ユーザ定義可能な設定をファイルからロードすることに関連するコードと、未設定値のデフォルト設定。
  • fonts/images/:IDEの見た目を整えることに関する、コード以外のリソース。
  • locale/:翻訳とそれに関連するもの。スペイン語(es/)、フランス語(fr/)、ブラジルポルトガル語(pt_BR)をサポートしているらしい。
  • plugins/:おそらく一般的なプラグインと同様に、簡単に組み込んだり外したり、有効にしたり無効にしたりできるコードの一部。
  • qt/:大部分は、GUIライブラリであるPyQt4PyQt5PySideの切り替えを行うシムと思われる。コードの他の部分で使うためのライブラリをインポートするらしい。UIコード自体を実際に定義するものではなさそう。
  • utils/:コードベース内でおそらく他の何にも依存していない、雑多な機能の置き場所。何らかのヘルプサイトを表示するために使われるJavaScriptやHTML、CSSのコード群も含む。
  • widgets/:Spyder GUIを実際に構築するブロック。各ファイルが個々のウィジェットを含むらしい。例えば、ファイルスイッチャのウィジェット、検索・置換のウィジェットなど。
  • windows/:Windowsでの実行中に、ウィンドウの上部左側に出す2つのアイコンを含む。
  • workers/:いろいろな非同期タスクワーカを置くフォルダに見えるが、githubをポーリングする1つのワーカのコードだけを含む。

現時点で押さえておく概要としては、これで十分でしょう。慣れるための次の段階では、実行したい具体的な変更について詳しく見ていきます。コードベースを眺めて得たばかりの知識を実際に用いてみると共に、さらに理解を深めることもできるはずです。

Hello World

コードを動かして大まかな状況も把握できましたので、次のステップでは「Hello World」の例題で、自分で本当にコードを変更しそのコードが実行できることを確認します。Hello Worldの練習としてよく行われるのは、アプリケーション中のテキストの変更です。例えば、ウィンドウのタイトルは以下のようになっています。

Diving/SpyderWorks.png

このSpyder 3.0.0dev (Python 3.5)の部分を、Spyder Hello World...に変えてみましょう。

見た感じ、タイトルっぽいですよね。HTMLやJava Swingなど、私が今まで使ってきた他のGUIフレームワークではいずれも、ウィンドウ上部のラベルはtitleと呼ばれていましたので、今回もきっとtitleだと思います。コードベースをtitleで検索してみましょう。

Diving/SpyderSearch.png
460件見つかりました。多い気もしますが、実はそうでもありません。460件なら、5分以内でざっと調べられるはずです。多くは各種モーダルにタイトルを設定するもので、他にはHTMLなど他言語のタイトルもあります。とはいえ、ヒットしたものは全てチェックした方がいいでしょう。というのも、「タイトルが.ini.jsonなどの設定ファイルから生成されているため.pyのコード中では明示的には全くセットされていない」ということも十分あり得るからです。

このケースでは幸い、目的の箇所が見つかったようです。

Diving/SpyderFound.png
あいにく見つからなかった場合は、別のキーワードを試すか、GUIのインスタンス生成が始まる場所を知るためにコードの実行をトレースしてみることも必要でしょう。ですが今回は運良く、5分以内に関連コードが見つかりました。

# Set Window title and icon
if DEV is not None:
    title = "Spyder %s (Python %s.%s)" % (__version__,
                                          sys.version_info[0],
                                          sys.version_info[1])
else:
    title = "Spyder (Python %s.%s)" % (sys.version_info[0],
                                       sys.version_info[1])
if DEBUG:
    title += " [DEBUG MODE %d]" % DEBUG

このファイルは見たところ、Spyderが起動するエントリポイントと、Spyder IDEの動作に関連する膨大なロジックの両方の役目を持っているようです。3,168行のコードは、決して単なるイニシャライズ用のスタブではありません。self.register_shortcutにショートカットの登録を行ったり、OSのシグナルをいじったり、セッション(IDEでの意味合いは不明)を扱ったり、env変数をいじったりしているようなのですが、この3,000行のはるか終わりの部分で、Pythonプログラムのエントリポイントの印を見つけました。

if __name__ == "__main__":
    main()

発見したコードのスニペットに戻り、文字列を微調整すれば、タイトルをSpyder Hello World...に変えられるはずです。

$ git diff
diff --git a/spyderlib/app/spyder.py b/spyderlib/app/spyder.py
index 6d9763f..830e8f3 100644
--- a/spyderlib/app/spyder.py
+++ b/spyderlib/app/spyder.py
@@ -436,7 +436,7 @@ class MainWindow(QMainWindow):

         # Set Window title and icon
         if DEV is not None:
-            title = "Spyder %s (Python %s.%s)" % (__version__,
+            title = "Spyder Hello World %s (Python %s.%s)" % (__version__,
                                                   sys.version_info[0],
                                                   sys.version_info[1])
         else:

そしてpython bootstrap.pyを再度実行すると、次のようになりました。

Diving/SpyderTitleChanged.png

最初の変更がうまくいきました。

繰り返す

初めに課題として挙げたのは、画面上部にある既存のHelpメニューの隣に、新しいドロップダウンメニューを追加することでした。

Diving/SpyderMenus.png
これらのメニューが定義されている場所を見つける1つの方法は、menuというキーワードでコードベース内を検索することです。開発者が私たちの予想どおりの語彙を使っていると想定して、比較的大きな概念を用いた網羅的な検索で引っ張ってくることになります。もう1つの方法は、メニューの中で使われている文字列で検索することです。一意的に特定可能な長さの文字列であれば、その文字列が定義されている箇所をすぐに見つけられるはずです。

今回は、Consolesのキーワードで、大文字と小文字を区別してコードベースを検索することにしました。Consolesという文字列が出現する箇所はそれほど多くないだろうと期待したのです。というのも、Pythonの変数やモジュール名には全て小文字が使われているでしょうし、Python以外での使用(HTML文書など)やコメントは簡単に無視できるからです。実行してみると10数件の結果が得られ、その多くは.md.rst.poのファイルでした。

Diving/MenuSearch.png
珍しいことに、一見したところ、どれも求めていた箇所ではなさそうです。大体が長大なヘルプページやコメントの一部なのです。しかし、1つ気になるコメントがありました。

# Consoles menu/toolbar

コードを見てみましょう。

# Debug menu/toolbar
self.debug_menu = self.menuBar().addMenu(_("&Debug"))
self.debug_toolbar = self.create_toolbar(_("Debug toolbar"),
                                            "debug_toolbar")

# Consoles menu/toolbar
self.consoles_menu = self.menuBar().addMenu(_("C&onsoles"))

# Tools menu
self.tools_menu = self.menuBar().addMenu(_("&Tools"))

まさに、探していた箇所ではないでしょうか。このコードが、各ドロップダウンメニューをツールバーに追加するハードコード部分です。なぜかどの名前にも、先頭か途中に奇妙な&の記号が含まれていますが、これはどうやら、次の文字をそのメニューのショートカットキーとするための不思議なシンタックスであるようです。OS-Xでは機能しないでしょうが、Windows版と思われるオリジナルのスクリーンショットを見ると、ショートカットキーを表す下線付きの文字の位置とちょうど一致しています。

Diving/Spyder.png
あとは割と簡単に、メニューバーに新しいメニューを追加できそうです。

$ git diff
diff --git a/spyderlib/app/spyder.py b/spyderlib/app/spyder.py
index 6d9763f..c855220 100644
--- a/spyderlib/app/spyder.py
+++ b/spyderlib/app/spyder.py
@@ -663,6 +663,9 @@ class MainWindow(QMainWindow):
         # Help menu
         self.help_menu = self.menuBar().addMenu(_("&Help"))

+        # Help menu
+        self.misc_menu = self.menuBar().addMenu(_("&Misc"))
+
         # Status bar
         status = self.statusBar()
         status.setObjectName("StatusBar")

すると、Miscメニューが現れました。

Diving/MiscMenu.png
現時点では、このメニューからは何も実行できず、クリックしてもメニュー項目の表示さえできません。まだ項目を1つも追加していないので、メニュー内に何も存在しないためでしょう。次のステップは、既存のメニューが項目を追加している方法を突き止め、それを流用して独自のメニュー項目を加えることです。

悪戦苦闘

見ての通りこの時点では、メニュー項目は上記の場所にまだ追加されていません。各ドロップダウンメニューに名前を付けて初期化しただけです。メニュー項目を追加するコードを書かなければなりません。メニューが使われている場所を探せば、どこでそのメニューが使われるのか、誰かそのメニューに項目を追加しようとしているのかどうかは、簡単に分かります。例えば、console_menuが使われている場所を探してみましょう。

Diving/ConsoleMenuSearch.png
すぐにこんなものが見つかりました。self.consoles_menuの初期化を行っているだけでなく、そのメニューをNoneに設定するコード、self.console_menu_actionsNoneに設定するコード、さらには今回の目的に関係がありそうな、add_actions関数をself.consoles_menuself.consoles_menu__actionsから呼び出すコードがあります。これはすばらしい。同様の呼び出しを実行することにしましょう。ただし、consoles_menu_actionsは今のところ空っぽです。ここにどんなコードを書けばいいのでしょう。これも検索すれば簡単に見つけることができます。

Diving/ConsolesMenuActions.png
どう見てもこれは、interpreter_actionを含むリストです。これはどこから来ているのでしょう。どちらかといえば豆知識ですが、このファイルに対してCmd-Fキーを叩けば、その答えが見つかります。

interpreter_action = create_action(self,
                    _("Open a &Python console"), None,
                    ima.icon('python'),
                    triggered=self.open_interpreter)
if os.name == 'nt':
    text = _("Open &command prompt")
    tip = _("Open a Windows command prompt")
else:
    text = _("Open a &terminal")
    tip = _("Open a terminal window")
terminal_action = create_action(self, text, None, None, tip,
                                triggered=self.open_terminal)
run_action = create_action(self,
                    _("&Run..."), None,
                    ima.icon('run_small'), _("Run a Python script"),
                    triggered=self.run_script)

consoles_menu_actions = [interpreter_action] 

比較的分かりやすいコードです。create_actionを呼び出し、self(中身は何であれ)に渡すのは、_(...)でラップした文字列、None、何かのアイコン、そしてtriggered引数です。これがどうやらコールバックのようです。create_actionの定義にジャンプ(またはgrepで検索)するとすぐに、spyderlib/utils/qthelpers.pyにあることが確認できます。その定義を次に示します。

def create_action(parent, text, shortcut=None, icon=None, tip=None,
                  toggled=None, triggered=None, data=None, menurole=None,
                  context=Qt.WindowShortcut):
    action = QAction(text, parent) 
    if triggered is not None:
        action.triggered.connect(triggered)
    if toggled is not None:
        action.toggled.connect(toggled)
        action.setCheckable(True)
    ...
    return action 

Noneが渡されている先はどうやら、このショートカットのようです。ということは、Open a Python Consoleへのショートカットはもしかして存在しない? それに、datamenurole引数の役割が私には分からないのですが、恐らくこの時点では、まだ理解する必要がないのでしょう。次に注目するのは、ここに渡されるself.open_interpreter関数です。

@Slot(str)def open_interpreter(self, wdir=None):
    """Open interpreter"""
    if not wdir:
        wdir = getcwd()
    self.visibility_changed(True)
    self.start(fname=None, wdir=to_text_string(wdir), args='',
               interact=True, debug=False, python=True) 

@Slot(str)が何なのか、手掛かりは何もありませんが、それぞれのコールバック(例えば上記のスニペットに含まれているrun_action)には全て、コールバックが取る引数の数に見合う@Slots修飾子が含まれるようです。その規則に従ってコードを書けば、なんとか動作するものになるでしょう。

修飾子の部分を除けば、このコードは比較的分かりやすいものです。何か動作をするメソッドを呼び出しているだけです。これで完成です。何かを実行するメソッドを呼び出す関数の書き方を理解することができました。

独自の動作

ここまでに学んだのは、foo_menu_actionsリストをどこで作成すればいいか、このリストをどこに追加すればいいのか、このリストをどのようにしてadd_actionsに渡せば、メニューにそのリストの処理が追加されるのか、ということでした。ここまでできれば、元のプログラムを真似たうえで、独自の処理をフローに組み込むことは難しくありません。

$ git --no-pager diff
diff --git a/spyderlib/app/spyder.py b/spyderlib/app/spyder.py
index 6d9763f..44fb373 100644
--- a/spyderlib/app/spyder.py+++ b/spyderlib/app/spyder.py
@@ -408,6 +408,8 @@ class MainWindow(QMainWindow):
         self.toolbars_menu = None
         self.help_menu = None
         self.help_menu_actions = []
+        self.misc_menu_actions = []+

         # Status bar widgets
         self.mem_status = None
@@ -663,6 +665,9 @@ class MainWindow(QMainWindow):
         # Help menu
         self.help_menu = self.menuBar().addMenu(_("&Help"))

+        # Misc menu+        self.misc_menu = self.menuBar().addMenu(_("&Misc"))+
         # Status bar
         status = self.statusBar()
         status.setObjectName("StatusBar")
@@ -1181,6 +1186,8 @@ class MainWindow(QMainWindow):
                     self.external_tools_menu_actions)
         add_actions(self.help_menu, self.help_menu_actions)

+        add_actions(self.misc_menu, self.misc_menu_actions)+
         add_actions(self.main_toolbar, self.main_toolbar_actions)
         add_actions(self.file_toolbar, self.file_toolbar_actions)
         add_actions(self.edit_toolbar, self.edit_toolbar_actions)
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 9b8ba50..391958a 100644
--- a/spyderlib/plugins/externalconsole.py+++ b/spyderlib/plugins/externalconsole.py
@@ -1021,6 +1021,13 @@ class ExternalConsole(SpyderPluginWidget):
                             _("Open a &Python console"), None,
                             ima.icon('python'),
                             triggered=self.open_interpreter)
+        misc_action = create_action(self,+                            _("&Do Something"), None,+                            ima.icon('python'),+                            triggered=self.misc_action)++        self.misc_menu_actions = [misc_action]+
         if os.name == 'nt':
             text = _("Open &command prompt")
             tip = _("Open a Windows command prompt")
@@ -1180,6 +1187,12 @@ class ExternalConsole(SpyderPluginWidget):
         self.start(fname=None, wdir=to_text_string(wdir), args='',
                    interact=True, debug=False, python=True)

+    @Slot()+    def misc_action(self):+        """Open interpreter"""+        print("Hello World Action")++
     def start_ipykernel(self, client, wdir=None, give_focus=True):
         """Start new IPython kernel""" ```
         if not self.get_option('monitor/enabled'): 

あとはpython3 bootstrap.pyを実行すれば…と思いきや、このコードはうまく動作しません。私たちが作成したMiscメニューをクリックしても、コマンドを選択するための、クリック可能なドロップダウンメニューが表示されないのです。既存のコードをそっくりコピーしたつもりでしたが、途中に幾つか抜けがあったことが分かりました。

$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 6e7c548..d169e48 100644
--- a/spyderlib/plugins/externalconsole.py+++ b/spyderlib/plugins/externalconsole.py
@@ -1026,7 +1026,7 @@ class ExternalConsole(SpyderPluginWidget):
                             ima.icon('python'),
                             triggered=self.misc_action)

-        self.misc_menu_actions = [misc_action]+        self.main.misc_menu_actions = [misc_action]

         if os.name == 'nt':
             text = _("Open &command prompt")
@@ -1048,7 +1048,7 @@ class ExternalConsole(SpyderPluginWidget):
         self.main.consoles_menu_actions += consoles_menu_actions
         self.main.tools_menu_actions += tools_menu_actions

-        return self.menu_actions+consoles_menu_actions+tools_menu_actions+        return self.menu_actions+consoles_menu_actions+tools_menu_actions+[misc_action]

     def register_plugin(self):
         """Register plugin in Spyder's main window""" 

これで動くようになりました!

Diving/MenuAction.png
メニューをクリックすることはできます。ただし、printステートメントが行き場を失っているようです。少なくとも、コンソールには表示されません。コンソールの表示は次の通りです。

$ python3 bootstrap.py
Executing Spyder from source checkout
Revision e978c9c, Branch: master
01.Patched sys.path with /Users/haoyi/test/spyder
02.PyQt4 is detected, selecting
03.Imported Spyder 3.0.0dev
    [Python 3.5.1 64bits, Qt 4.8.7, PyQt4 (API v2) 4.11.4 on Darwin]04.Running Spyder
Bootstrap completed in 00:00:01.2533 

追加したアクションをクリックしても、コンソールの表示には変化がありません。IDEであるSpyderがprintステートメントをどこかのログファイルにリダイレクトしている可能性がありますが、とにかくリポジトリツリーの中にはprintステートメントは見当たりません。私のホームディレクトリには次の通り~/.spyder-py3フォルダが確かに存在するのに、くどいようですがログファイルには表示されないのです。

$ tree ~/.spyder-py3/
/Users/haoyi/.spyder-py3/
├── db
│   └── submodules
├── defaults
│   └── defaults-26.1.0.ini
├── history.py
├── history_internal.py
├── langconfig
├── onlinehelp
├── spyder.ini
├── spyder.ini.bak
├── spyder.lock -> 54811
├── spyplugins
│   ├── __init__.py
│   ├── io
│   │   └── __init__.py
│   └── ui
│       └── __init__.py
├── temp.py
├── template.py
└── workingdir  

私たちが書いたコードが動作していることを確認するには、stdoutに出力するのではなくファイルに書き出すというのも一手です。

$ git diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 94fe765..6a7ca15 100644  ```
--- a/spyderlib/plugins/externalconsole.py+++ b/spyderlib/plugins/externalconsole.py
@@ -1191,7 +1191,8 @@ class ExternalConsole(SpyderPluginWidget):
     def misc_action(self):
         """Open interpreter"""

-        print("Hello World Action")+        with open("logfile.txt", "w") as f:+            f.write("Hello World misc_action")


     def start_ipykernel(self, client, wdir=None, give_focus=True): 

しかし、それを実行してDo somethingをクリックしたのですが、find / -name "logfile.txt"で私のファイルシステムを検索しても、ログファイルがどこにも見当たりません。これはつまり、ここまでこんなに苦労して書き上げたコールバックが動作しないということでしょうか? では1語ずつopen_interpreterの呼び出しをコピーするようにしたらどうでしょう?

$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 94fe765..4041ac3 100644
--- a/spyderlib/plugins/externalconsole.py+++ b/spyderlib/plugins/externalconsole.py
@@ -1187,12 +1187,13 @@ class ExternalConsole(SpyderPluginWidget):
         self.start(fname=None, wdir=to_text_string(wdir), args='',
                    interact=True, debug=False, python=True)

-    @Slot()-    def misc_action(self):-        """Open interpreter"""--        print("Hello World Action")-+    @Slot(str)+    def misc_action(self, wdir=None):+        """Open terminal"""+        if not wdir:+            wdir = getcwd()+        self.start(fname=None, wdir=to_text_string(wdir), args='',+                   interact=True, debug=False, python=False)

     def start_ipykernel(self, client, wdir=None, give_focus=True):
         """Start new IPython kernel""" 

間違いなく、Do somethingをクリックすると新規のインタープリタペインが開くようになりました! ではここでprintステートメントを追加したらどうなるのでしょう?

$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 4041ac3..374c04b 100644
--- a/spyderlib/plugins/externalconsole.py+++ b/spyderlib/plugins/externalconsole.py
@@ -1192,6 +1192,7 @@ class ExternalConsole(SpyderPluginWidget):
         """Open terminal"""
         if not wdir:
             wdir = getcwd()
+        print("HELLO WORLD misc_action")
         self.start(fname=None, wdir=to_text_string(wdir), args='',
                    interact=True, debug=False, python=False) 

ようやくDo somethingで新規のインタープリタペインが開くようになりましたが、プリント処理はまだうまく動作しません。私たちが書いたコード自体は正常に動作しているのですが、恐らくSpyderがprintステートメントやファイル出力をどこかにリダイレクトしているのでしょう。この状態からどのようにデバッグを進めればいいのか、まだ確信が持てませんが、少なくともコードが動作するようになったことは確認できました。

最終段階:エディタ

当初の目標を達成するために必要な最後の作業は、コードエディタの修正です。ファイルシステムをざっと調べると、widgets/editor.pyという期待できそうな名前のファイルが見つかります。

# -*- coding: utf-8 -*-## Copyright © 2009- The Spyder Development Team# Licensed under the terms of the MIT License# (see spyderlib/__init__.py for details)

"""Editor Widget"""

# pylint: disable=C0103# pylint: disable=R0903# pylint: disable=R0911# pylint: disable=R0201

from __future__ import print_function

from spyderlib.qt import is_pyqt46
from spyderlib.qt.QtGui import (QVBoxLayout, QMessageBox, QMenu, QFont,
                                QAction, QApplication, QWidget,
                                QKeySequence, QMainWindow, QSplitter,
                                QHBoxLayout)
...

EditorWidgetという期待できそうな名前のクラスがあります。

class EditorWidget(QSplitter):
    def __init__(self, parent, plugin, menu_actions, show_fullpath,
                 fullpath_sorting, show_all_files, show_comments):
      ...
  ...

編集すべきコードは確かにここに入っています。問題は、これがどこで作られるのか、そして、どうすれば使えるか、です。

どうやら、EditorMainWindow内で作られるようです。

class EditorMainWindow(QMainWindow):
    def __init__(self, plugin, menu_actions, toolbar_list, menu_list,
                 show_fullpath, fullpath_sorting, show_all_files,
                 show_comments):
        QMainWindow.__init__(self)
        self.setAttribute(Qt.WA_DeleteOnClose)

        self.window_size = None

        self.editorwidget = EditorWidget(self, plugin, menu_actions,
                                         show_fullpath, fullpath_sorting,
                                         show_all_files, show_comments)

EditorMainWindowそのものは、plugins/Editor.py内のEditor内に作られています。

class Editor(SpyderPluginWidget):
    """
    Multi-file Editor widget
    """
    CONF_SECTION = 'editor'
    CONFIGWIDGET_CLASS = EditorConfigPage
    TEMPFILE_PATH = get_conf_path('temp.py')
    TEMPLATE_PATH = get_conf_path('template.py')
    DISABLE_ACTIONS_WHEN_HIDDEN = False # SpyderPluginWidget class attribute

    # Signals
    ...
    def create_new_window(self):
        oe_options = self.outlineexplorer.get_options()
        fullpath_sorting=self.get_option('fullpath_sorting', True),
        window = EditorMainWindow(self, self.stack_menu_actions,
                                  self.toolbar_list, self.menu_list,
                                  show_fullpath=oe_options['show_fullpath'],
                                  fullpath_sorting=fullpath_sorting,
                                  show_all_files=oe_options['show_all_files'],
                                  show_comments=oe_options['show_comments'])
        window.resize(self.size())
        window.show()
        self.register_editorwindow(window)
        window.destroyed.connect(lambda: self.unregister_editorwindow(window))
        return window

いろいろなファイルを調べて回るのは、PyCharmなどのスマートエディタを使えば簡単ですが、そのようなエディタを使わなくても、grepackagなどを使ってプロジェクト全体でEditorMainWindowという名前を探せば、見つかります。

Editorそのものは、MainWindowsetupメソッドの一部としてspyder.py内に見つかります。

...

# Editor plugin
self.set_splash(_("Loading editor..."))
from spyderlib.plugins.editor import Editor
self.editor = Editor(self)
self.editor.register_plugin()

# Populating file menu entries
quit_action = create_action(self, _("&Quit"),
                            icon=ima.icon('exit'), 
                            tip=_("Quit"),
                            triggered=self.console.quit)
self.register_shortcut(quit_action, "_", "Quit")
restart_action = create_action(self, _("&Restart"),
                                icon=ima.icon('restart'),
                                tip=_("Restart"),
                                triggered=self.restart)
...

よし、これでメインウィンドウからの経路が分かりました。

  • MainWindowからEditor
  • Editorから、おそらく、どうにかしてEditorStackを通ってEditorMainWindow
  • EditorMainWindowからEditorWidget
  • EditorWidgetから…どこへ?

この経路は、ちょっとハズレだったみたいですね。widgets/sourcecode/codeeditor.pyの下にCodeEditorという名前の別のクラスがあって、実際にはこれがテキストエディタの機能を実装しているようです。

CodeEditorクラスにはset_textというメソッドがあって、これが、私たちが求めている動作をしているようです。

def set_text(self, text):
        """Set the text of the editor"""
        self.setPlainText(text)
        self.set_eol_chars(text)
        #if self.supported_language:
            #self.highlighter.rehighlight()

set_textのスーパークラスTextEditBaseWidgetにはtoPlainTextというメソッドがあって、修正すべき現在のテキストを取得するメソッドとして期待できそうです。

def toPlainText(self):
    """
    Reimplement Qt method
    Fix PyQt4 bug on Windows and Python 3
    """
    # Fix what appears to be a PyQt4 bug when getting file
    # contents under Windows and PY3. This bug leads to
    # corruptions when saving files with certain combinations
    # of unicode chars on them (like the one attached on
    # Issue 1546)
    if os.name == 'nt' and PY3:
        text = self.get_text('sof', 'eof')
        return text.replace('\u2028', '\n').replace('\u2029', '\n')\
                   .replace('\u0085', '\n')
    else:
        return super(TextEditBaseWidget, self).toPlainText()

ここのDocstringに何が書いてあるのか私には分かりませんし、奇妙なunicodeのreplace呼び出しが何をするのかも、toPlainTextsuperバージョンが何をするのかも全く分かりません。でも、このメソッドがreplaceを呼び出すとPython strが返されるのなら、探していたものは、これだと思われます。

それが、先ほど見たエディタとどのように関係するのでしょうか? エディタはEditorStack.create_new_editorの中で作られるようです。

def create_new_editor(self, fname, enc, txt, set_current, new=False,
                      cloned_from=None):
    """
    Create a new editor instance
    Returns finfo object (instead of editor as in previous releases)
    """
    editor = codeeditor.CodeEditor(self)
    ...

このエディタはEditorStack.newの中で呼び出され、EditorStack.newそのものはEditor.newの中で呼び出されています。こういったものが、私が使えるアトリビュートとして最終的にセットされるかは分かりませんが、こうした数少ないエディタ関連のファイルを見て回るうちに、EditorStackの中にget_current_editorというメソッドを見つけました。これこそ、私が求めている動作をするメソッドです。

def get_current_editor(self):
    editorstack = self.get_current_editorstack()
    if editorstack is not None:
        return editorstack.get_current_editor()

この実装が何をするのか、他のメソッドがどこに定義されているのかは分かりませんが、その名前から推測すれば、私のやりたい動作をする可能性が高く、現在IDEがフォーカスしているエディタが手に入ると思われます。コードをきちんと調べていないので間違った推測かも知れず、このコードは何か別のことをするのかも知れません。でも、もし、これが現在フォーカスされているウィンドウではないとしたら、この名前でそんなコードを書く人は大馬鹿者です。普通は、ほとんどのプログラマは良識ある人たちなので、たぶん間違いありません。

デバッグはどうしても必要

さて、self.main.editor.get_current_editor()misc_actionの中で呼び出せば、現在のエディタを手に入れることができますが、手に入るのは、どのエディタなのでしょうか? 名前から推測すれば、次の候補があります。

  • spyderlib.plugins.editor.Editor
  • spyderlib.widgets.editor.EditorMainWindow
  • spyderlib.widgets.editor.EditorStack
  • spyderlib.widgets.editor.EditorWidget
  • spyderlib.widgets.sourcecode.codeeditor.CodeEditor

どれにも”Editor”という語が含まれており、get_current_editorメソッドからの戻り値は、どれもそれらしいタイプです! どうすれば、手に入れたエディタが何なのかを知ることができるでしょうか? misc_actionメソッドが実行しているのが分かっていながら、このメソッドから何かプリントすることもできないのに。

これがJavaなどの静的型付け言語なら、メソッドが何を返すかすぐに分かるのですが…

Diving/ThisIsPython.jpg
闇の中を手探りするしかありません。

何かプリントしたりファイルに書き込んだりすることができなくても、何が起こっているのかを調べるためにmisc_actionからのデバッグ出力を得る方法があることが判明しました。1つは、QMessageBox.warningメソッドを使う方法です。実は、このメソッドは、misc_actionのすぐ下のstart_ipykernelメソッドで使われています。

def start_ipykernel(self, client, wdir=None, give_focus=True):
    """Start new IPython kernel"""
    if not self.get_option('monitor/enabled'):
        QMessageBox.warning(self, _('Open an IPython console'),
            _("The console monitor was disabled: the IPython kernel will "
              "be started as expected, but an IPython console will have "
              "to be connected manually to the kernel."), QMessageBox.Ok)

    if not wdir:
        wdir = getcwd()
    self.main.ipyconsole.visibility_changed(True)
    self.start(fname=None, wdir=to_text_string(wdir), args='',
               interact=True, debug=False, python=True, ipykernel=True,
               ipyclient=client, give_ipyclient_focus=give_focus)

これをmisc_actionメソッドで利用して、何が起こっているかを知るために役立てることができます。

$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 4041ac3..1585abf 100644
--- a/spyderlib/plugins/externalconsole.py+++ b/spyderlib/plugins/externalconsole.py
@@ -1192,6 +1192,9 @@ class ExternalConsole(SpyderPluginWidget):
         """Open terminal"""
         if not wdir:
             wdir = getcwd()
++        QMessageBox.warning(self, _('Open an IPython console'),+                            _(str(type(self.main.editor.get_current_editor()))), QMessageBox.Ok)
         self.start(fname=None, wdir=to_text_string(wdir), args='',
                    interact=True, debug=False, python=False)

こうすれば、アクションを選択すると、get_current_editorから返されるもののタイプがプリントアウトされるので、やりたいことをするために、5つの候補のうちどれで、どのメソッドまたはアトリビュートを呼び出せるのかを知る手掛かりとして期待できます。

では、ドロップダウンでDo somethingをクリックしましょう。結果は下の画像です。

Diving/Debugging.png

これを見ると、5つの候補のうちget_current_editorspyderlib.widgets.sourcecode.codeeditor.CodeEditorオブジェクトを返し、それ以外の候補は返さないことが分かります。それでも、これはPythonなので、今回はCodeEditorオブジェクトを返しても、別のときには別のものを返す可能性はあります。最初にこれを書いたプログラマが大馬鹿者で、毎回違うタイプのものを返すように作っているかも知れませんが、今は、その人が良識ある人だと仮定して、戻り値のタイプが密かに変わることはないと考えてもいいでしょう。

そう仮定すれば、このエディタ上でset_textを呼び出すことができます。

$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 4041ac3..4dfb85b 100644
--- a/spyderlib/plugins/externalconsole.py+++ b/spyderlib/plugins/externalconsole.py
@@ -1192,6 +1192,7 @@ class ExternalConsole(SpyderPluginWidget):
         """Open terminal"""
         if not wdir:
             wdir = getcwd()
+        self.main.editor.get_current_editor().set_text("fooooo")
         self.start(fname=None, wdir=to_text_string(wdir), args='',

      interact=True, debug=False, python=False)

結果の画像はこうなります。

Diving/SetText.png
次に、toPlainTextを、spyderlib.plugins.editor.Editorの便利なget_current_filename()メソッドと一緒に試してみます。私はEditorのように見える5つの似たクラスを調べていたときに、このメソッドに気付いていました。

$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 4041ac3..97cac71 100644
--- a/spyderlib/plugins/externalconsole.py+++ b/spyderlib/plugins/externalconsole.py
@@ -1192,8 +1192,10 @@ class ExternalConsole(SpyderPluginWidget):
         """Open terminal"""
         if not wdir:
             wdir = getcwd()
-        self.start(fname=None, wdir=to_text_string(wdir), args='',-                   interact=True, debug=False, python=False)+        self.main.editor.get_current_editor().set_text(+            ("print('Running %s')\n" % self.main.editor.get_current_filename()) ++            self.main.editor.get_current_editor().toPlainText()+        )

     def start_ipykernel(self, client, wdir=None, give_focus=True):
         """Start new IPython kernel"""

作業を始めてから5時間で、ついに完成しました。

Diving/Works.png
githubのmasterから分岐するブランチからの、コミットdf9577cfc279d2b6f2c07c7ba4e8e7aebfdd1835の書き込み時点での最終的なdiffは、次のようになりました。

$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 4041ac3..97cac71 100644
--- a/spyderlib/plugins/externalconsole.py+++ b/spyderlib/plugins/externalconsole.py
@@ -1192,8 +1192,10 @@ class ExternalConsole(SpyderPluginWidget):
         """Open terminal"""
         if not wdir:
             wdir = getcwd()
-        self.start(fname=None, wdir=to_text_string(wdir), args='',-                   interact=True, debug=False, python=False)+        self.main.editor.get_current_editor().set_text(+            ("print('Running %s')\n" % self.main.editor.get_current_filename()) ++            self.main.editor.get_current_editor().toPlainText()+        )

     def start_ipykernel(self, client, wdir=None, give_focus=True):
         """Start new IPython kernel"""
$ git --no-pager diff df9577cfc279d2b6f2c07c7ba4e8e7aebfdd1835
diff --git a/spyderlib/app/spyder.py b/spyderlib/app/spyder.py
index 6d9763f..4ed17a2 100644
--- a/spyderlib/app/spyder.py+++ b/spyderlib/app/spyder.py
@@ -408,6 +408,9 @@ class MainWindow(QMainWindow):
         self.toolbars_menu = None
         self.help_menu = None
         self.help_menu_actions = []
+        self.misc_menu = None+        self.misc_menu_actions = []+

         # Status bar widgets
         self.mem_status = None
@@ -663,6 +666,9 @@ class MainWindow(QMainWindow):
         # Help menu
         self.help_menu = self.menuBar().addMenu(_("&Help"))

+        # Misc menu+        self.misc_menu = self.menuBar().addMenu(_("&Misc"))+
         # Status bar
         status = self.statusBar()
         status.setObjectName("StatusBar")
@@ -1181,6 +1187,8 @@ class MainWindow(QMainWindow):
                     self.external_tools_menu_actions)
         add_actions(self.help_menu, self.help_menu_actions)

+        add_actions(self.misc_menu, self.misc_menu_actions)+
         add_actions(self.main_toolbar, self.main_toolbar_actions)
         add_actions(self.file_toolbar, self.file_toolbar_actions)
         add_actions(self.edit_toolbar, self.edit_toolbar_actions)
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 9b8ba50..97cac71 100644
--- a/spyderlib/plugins/externalconsole.py+++ b/spyderlib/plugins/externalconsole.py
@@ -1021,6 +1021,13 @@ class ExternalConsole(SpyderPluginWidget):
                             _("Open a &Python console"), None,
                             ima.icon('python'),
                             triggered=self.open_interpreter)
+        misc_action = create_action(self,+                            _("&Do Something"), None,+                            ima.icon('python'),+                            triggered=self.misc_action)++        self.main.misc_menu_actions = [misc_action]+
         if os.name == 'nt':
             text = _("Open &command prompt")
             tip = _("Open a Windows command prompt")
@@ -1041,7 +1048,7 @@ class ExternalConsole(SpyderPluginWidget):
         self.main.consoles_menu_actions += consoles_menu_actions
         self.main.tools_menu_actions += tools_menu_actions

-        return self.menu_actions+consoles_menu_actions+tools_menu_actions+        return self.menu_actions+consoles_menu_actions+tools_menu_actions+[misc_action ]

     def register_plugin(self):
         """Register plugin in Spyder's main window"""
@@ -1180,6 +1187,16 @@ class ExternalConsole(SpyderPluginWidget):
         self.start(fname=None, wdir=to_text_string(wdir), args='',
                    interact=True, debug=False, python=True)

+    @Slot(str)+    def misc_action(self, wdir=None):+        """Open terminal"""+        if not wdir:+            wdir = getcwd()+        self.main.editor.get_current_editor().set_text(+            ("print('Running %s')\n" % self.main.editor.get_current_filename()) ++            self.main.editor.get_current_editor().toPlainText()+        )+
     def start_ipykernel(self, client, wdir=None, give_focus=True):
         """Start new IPython kernel"""
         if not self.get_option('monitor/enabled'):

まとめ

プロ並みの貢献をしたかという点でいえば、まだ「やりきった」とは言えません。今回、できる変更を加え、機能するパッチを実際にやって見せましたが、どのような変更をすべきなのか、そしてそれらをどう扱うのかをよく知るために、基本的なコードベースを理解することは、完全に別問題です。例えば、externalconsole.pymisc__actionコマンドをダンプしても機能はします。しかし、これは明らかに正しい方法ではありませんし、コードレビューは決してできないでしょう!

ともあれ、この問題に取り組むことによって私たちは、プロの環境でのコードレビュープロセスのスタート地点に立たされるのだと思います。私たちが加える変更を、どのように幅広いコードベースに適合するのかについて合理的な議論を行うために、また、洗練された完成品を作るための土台として、(なんとなく動く程度の)パッチを完成させるために、コードベースの全体的なアーキテクチャについて十分な理解を得ることができました。5時間を費やしただけあって、悪い結果ではありません!

私たちは、何も分からない状況から、大規模な既存のデータベースに対して、奮闘を続け、問題を理解し、そして進歩してきました。今回学んだいくつかの興味深い教訓を以下に挙げます。

  • インストールは苦痛だがそれで構わない。私は自分のノートパソコンにSpyderをインストールするのに2時間もかかりました。イライラが募るのに十分な時間ですが、大掛かりなことをすることを考えれば、大した時間ではありません。苛立ちや不満を持たずに、喜んで大量の時間を犠牲にできるプロ意識をもってください。他のもの同様、あなたの時間や努力はリソースの一部です。そして、めちゃくちゃな設定プロセスを攻略するために相当な努力を注ぐことができるよう、自分自身をコントロールすることは、これからのキャリアの中で、克服しなくてはならない数あるタスクの中の1つであると言えます。

  • 全てを理解する必要はない。実際、ほとんどのことを理解する必要はないと言っていいでしょう。少なくとも最初は。一度に理解するには量が多すぎます。コードベースを部分的にしか理解しないまま仕事をしているかもしれませんが、それで全く構いません。物事が「機能」するようになった後で、本当に何が起こっているのかを理解するために、後から他の方法を探ればいいのです。

  • 理解するための方法は統一性がなく、場当たり的である。新しいコードベースについて、数学的な証明を行うわけではありません。あなたがすることは、キーワードを検索したり、ファイルシステムを閲覧したり、解決策をあちこち探し回ったりすることです。不慣れな間は、理論的に作業を行い、手掛かりやヒントを頼りに、疑いの目を持って対応すべきです。あなたが得たいと思っていることに対して、系統的な探索をしている時間はありません。

  • 無関係に思える経験が重要である。何が起こっているのか理解できない時、人は即座に決断や判断をするために、過去に経験した同様の問題を参考にします。例えばこんな経験が挙げられます。ウィンドウの上部にあるテキストをタイトルと言っていた。brew installを使って変なものでもインストールできた。このコードベースは今まで見たことないが、このどこかget_textに対するメソッドや、テキストエディタのコンテンツであるset_textがあったはずだ。このように、別の場所で起こった問題を参考にして対処することは、その場しのぎの調査や推測のように思えますが、より正確なのです。

  • 作業するコードベースは統一性がなく、場当たり的である。5~6個の異なるクラスの全てがEditorに似た名前で呼ばれていて、互いに微妙に複雑に関連し合っているのはなぜか? 通常”editor”に関連している機能、例えば、テキストコンテンツや、カーソルや選択などが、これらの複数のクラスに分散されていて、明らかに”editor”とは無関係なものと、ごちゃ混ぜになっているのはなぜか? より良い名前を付けることはできないのか? より良いdocstringsはないのか? もっともな意見です。しかし実際にはどうでしょう? 恐らく私が知る限り、本来のコードベースの世界にはないでしょう。それが現実です。

  • 全てを検証する。「私のコードは正しい」でしょうか? 検証が必要です。「私のコードは実行されている」でしょうか? 「私のコードはクラッシュしているのではなく、コードのエラーがわずかに飲み込まれている」でしょうか? 「これらのファイルへの変更は実行されているアプリケーションに対して行われていて、無視することできない」でしょうか? そして「何かをプリントすると、コンソールに現れる」でしょうか。これは、正しくありません! 先走って、これら基本のことを検証せずに大規模な変更はしないでください。あなたはコードベースには慣れていないのです。数多くのものが、予想外の動作をします。

  • 学校でこのスキルは学べないが、スキルを自分で学ぶことはできる。不慣れなコードに挑むことは才能ではありません。スキルです。そして他の物事と同じように訓練することができます。アルゴリズムやシステムを設計し、目的を行うための動作するコードを書き、他人の書いたコードに埋め込み、奇妙で不愉快な依存性と格闘する。これらは、プロのソフトウェアエンジニアとしての仕事のほんの一部です。

これが、初めて目にする他人が書いたコードに挑み、数時間あまり分かってない状態で作業しながらも、重要な変更を加える方法です。是非、皆さんの経験や技、技術などを下にあるコメントに投稿してください!


article originally written by Li Haoyi on http://www.lihaoyi.com/post/DivingIntoOtherPeoplesCode.html