IPython NotebookライクなTkinterラッパーを書いてみた【Python】

タイトルの通り、IPython Notebookのインタラクティブウィジェットのように簡単にGUIを呼び出せるTkinterのラッパーを書いてみたので上げておきます。若干予期しない動作をしており、悩んでいるところので、皆様の教えを請いたいなと。その点を除けばだいぶ使いやすくなったと思っているので、次はQtなりGtkなりに移植しようと思っています。

追記：Gtkに移植した[(link)](http://qiita.com/ssh0/items/a514b7ff963b7ec0d3fe)

![out-2_65-139.gif](https://qiita-image-store.s3.amazonaws.com/0/54869/b8617937-08b2-1b59-8f1e-95209f8f0802.gif)

#作成の動機

最近はIPythonノートブックを使うことが多くなってきましたが、この中で自分がどんな機能に惹かれているか考えてみると、インタラクティブに変数を変更しながら関数の実行を行える、interactやinteractiveの存在によるところが大きいと思います。後はスライドの作成ですね。今までもTkinterで自分用のラッパーを書いてきましたが、IPython Notebookのインタラクティブウィジェットの使いやすさに感動したので、同じような呼び出し方でTkウィジェットを表示できるものを作ってみようと思ったわけです。したがって、使い方は

- [github/ipython/ipython/blob/2.x/examples/Interactive Widgets/Using Interact.ipynb][interact]

[interact]:http://nbviewer.ipython.org/github/ipython/ipython/blob/2.x/examples/Interactive%20Widgets/Using%20Interact.ipynb

に載っているものとほとんど同じです。Tkinterのモジュールの都合上、ドロップダウンリストはラジオボタンに変更。
ただし、Tkでドロップダウンを作成している方もいて、

- [Source listing for dropdown.py:](http://infohost.nmt.edu/tcc/cgi/pre.cgi?file=/u/www/docs/tcc/help/lang/python/mapping/dropdown.py)
- [How to add a drop down box in Python GUI - Python:](http://bytes.com/topic/python/answers/569150-how-add-drop-down-box-python-gui)

なんかを組み込めば、できないこともなさそうですが、今回は見送りました。（どうせメインはGtkにするつもりだし・・・）


#使用方法

呼び出す型は先程述べたように、[Using Interact][interact]と同じです。
クラスinteractiveとしてまとめてあり、スクリプトから呼び出す際には、表示にw.display()を実行する必要があります。

```pycon

>>> from tk_wrapper import interactive
>>> def f(a,b=20):
...     return a + b
...
>>> w = interactive(f, a=(0, 20, 2), b=20)

(in console, tk widget appear here)
(you can add buttons and label here manually)

(then, you should add the next line)
>>> w.display()

(and you can get the result for f(a,b) by)
>>> w.result
32

(or get the arguments with a dictionary)
>>> w.kwargs
{'a':8, 'b':24}
```

また、本家ではinteractiveで引数を指定するときに、リンクさせる関数fの引数と同じ引数が全て含まれていないとエラーとなります。変更させたくない引数にはc=fixed(10)のようにfixedクラスを指定する必要があります。今回実装が難しかったのと使う機会もあまりなかったので、interactiveで指定しない変数でリンクされた関数fの引数にデフォルト値が設定されているものは、そのままその値を使って実行できるようにしてあります（もちろん指定していなかったら”引数が足りないよ”とタイプエラーを吐きます）。

#スクリプト全体

長いですが（といっても本家よりはだいぶシンプル）、よかったら見ていってください。
Python2.7.6で動作確認済みなので、使ってみてください。
Python3.4.0でも、"Tkinter"を"tkinter"に変え、print文を関数表記にすること、それから3系では辞書の値を取り出すと

```pycon
>>> a = {"d": 1, "c":4, "b":5}
>>> a.values()
dict_values([4, 5, 1])
```

のようになるので

```pycon
>>> list(a.values())
[4, 5, 1]
```

のようにarg.value()の部分(97,116行目)をlist(arg.value())に置換するだけで実行できました。

- [Gist](https://gist.github.com/7067a36bf3930a143700.git)


```py:tk_wrapper.py
#! /usr/bin/env python
# -*- coding:utf-8 -*-
#
# written by ssh0, October 2014.
__doc__ = '''IPython-like Tkinter wrapper class.

You can create Scalebar, Checkbutton, Radiobutton via simple interface.

usage example:

>>> from tk_wrapper import interactive
>>> def f(a,b=20):
...     return a + b
...
>>> w = interactive(f, a=(0, 20, 2), b=20)

(in console, tk widget appear here)
(you can add buttons and label here manually)

(then, you should add the next line)
>>> w.display()

(and you can get the result for f(a,b) by)
>>> w.result
32

(or get the arguments with a dictionary)
>>> w.kwargs
{'a':8, 'b':24}
'''

import Tkinter
import inspect


class interactive(object):

    def __init__(self, func, title='title', **kwargs):
        self.__doc__ = __doc__
        self.func = func
        self.kwargs = dict()
        args, varargs, keywords, defaults = inspect.getargspec(self.func)
        d = []
        for default in defaults:
            d.append(default)
        self.kwdefaults = dict(zip(args[len(args)-len(defaults):], d))

        self.root = Tkinter.Tk()
        self.root.title(title)
        self.f = Tkinter.Frame(self.root)
        for kw, arg in kwargs.items():
            kw = str(kw)
            arg_type = type(arg)
            if arg_type == tuple:
                # type check for elements in tuple
                argtype = self.type_check(arg)
                if argtype == str:
                    self.radiobuttons_str(kw, arg)
                else:
                    self.scale_bar(kw, arg, argtype)
            elif arg_type == int or arg_type == float:
                self.scale_bar(kw, [arg], arg_type)
            elif arg_type == bool:
                self.checkbutton(kw, arg)
            elif arg_type == str:
                self.label(arg)
                self.kwargs[kw] = arg
            elif arg_type == dict:
                self.radiobuttons_dict(kw, arg)
            else:
                raise TypeError

        self.f.pack()
        self.status = Tkinter.Label(self.root, text=str(self.kwargs))
        self.status.pack()

    def display(self):
        self.root.mainloop()

    def status_change(self):
        self.status.config(text=str(self.kwargs))
        self.kwargs_for_function = self.kwdefaults
        for k, v in self.kwargs.items():
            self.kwargs_for_function[k] = v
        self.result = self.func(**self.kwargs_for_function)

    def set_value(self, var, kw, new_value):
        # if argument is already given in func, use it for a default one
        if kw in self.kwdefaults:
            var.set(self.kwdefaults[kw])
            self.kwargs[kw] = self.kwdefaults[kw]
        else:
            var.set(new_value)
            self.kwargs[kw] = new_value

    def type_check(self, arg):
        argtype = type(arg[0])
        if not all([type(a) == argtype for a in arg]):
            raise TypeError("""types in a tuple must be the same.
            int or float: Scalebar
            str         : Radiobuttons""")
        return argtype

    def scale_bar(self, kw, arg, argtype):

        def scale_interact(new):
            self.kwargs[kw] = var.get()
            self.status_change()

        # length check for tuple
        len_arg = len(arg)
        if len_arg > 3 or len_arg == 0:
            raise IndexError("tuple must be 1 or 2 or 3 element(s)")

        if argtype == int:
            var = Tkinter.IntVar()
            scale_resolution = 1
        elif argtype == float:
            var = Tkinter.DoubleVar()
            scale_resolution = 0.1
        else:
            raise TypeError("arg must be int or float")

        # set the values
        if len_arg == 3:
            scale_from = arg[0]
            scale_to = arg[1]
            scale_resolution = arg[2]
        elif len_arg == 2:
            scale_from = arg[0]
            scale_to = arg[1]
        else:
            scale_from = arg[0]*(-1)
            scale_to = arg[0]*2

        self.set_value(var, kw, arg[0])
        # create scale widget in frame
        scale = Tkinter.Scale(self.f, label=kw, from_=scale_from, to=scale_to,
                              resolution=scale_resolution, variable=var,
                              orient='h', width=10, length=200,
                              command=scale_interact)
        scale.pack()

    def checkbutton(self, kw, arg):

        def check_interact():
            self.kwargs[kw] = var.get()
            self.status_change()

        var = Tkinter.BooleanVar()
        self.set_value(var, kw, arg)
        checkbutton = Tkinter.Checkbutton(self.f, text=kw, variable=var,
                                          command=check_interact)
        checkbutton.pack()

    def radiobuttons_str(self, kw, arg):

        def select():
            self.kwargs[kw] = var.get()
            self.status_change()

        var = Tkinter.StringVar()
        label = Tkinter.Label(self.f, text='select one for '+kw)
        label.pack()
        self.set_value(var, kw, arg[0])
        for x in arg:
            R = Tkinter.Radiobutton(self.f, text=x, variable=var,
                                    value=x, command=select)
            R.pack()

    def radiobuttons_dict(self, kw, arg):

        def select():
            self.kwargs[kw] = var.get()
            self.status_change()

        vtype = self.type_check(arg.values())
        if vtype == str:
            var = Tkinter.StringVar()
        elif vtype == int:
            var = Tkinter.IntVar()
        elif vtype == float:
            var = Tkinter.DoubleVar()
        else:
            raise TypeError("arg must be int or float or str.")
        label = Tkinter.Label(self.f, text='select one for '+kw)
        label.pack()
        self.set_value(var, kw, arg.values()[0])
        for k, d in arg.items():
        
            R = Tkinter.Radiobutton(self.f, text=k, variable=var,
                                    value=d, command=select)
            R.pack()

if __name__ == '__main__':
    import sys
    import Tkinter

    def f(x, y, z, o=True, i=0):
        print x, y, z, o, i

    def b1():
        print w.kwargs

    buttons = [('b1', b1), ('exit', sys.exit)]
    w = interactive(f, x=(0, 10), y=(1, 100, 10),
                    z=("ZZZ", "III", "foo", "bar"),
                    i={'0':0, '10':10, '20':20},
                    o=True
                    )

    for button in buttons:
        button = Tkinter.Button(w.root, text=button[0], command=button[1])
        button.pack()

    w.display()
```


#問題点と今後の展望


![実行した様子](https://qiita-image-store.s3.amazonaws.com/0/54869/7d7d27a1-82dc-8bdb-04ce-1d4ba01e316c.png)

問題点としましては、見て頂いて分かるように、変数の表示される順番がバラバラになってしまっています。これは辞書形式で参照していることに起因していると思うのですが、どう解決すべきかがわからないのでとりあえずこのままになっています。これは何とかしたいところ。多分OrderdDict使えばいいです。

それから、gif画像や上の画像を見て頂いても分かるとおり、はじめにscalebarに指定した引数の数だけ関数fの中身が実行されてしまっています。おそらくこれはTkinterのScaleの仕様で、表示する段階で値がセットされたことになっているのだと思います。関数displayのself.root.mainloop()の直前の行でscale_changeを有効化するようにしてもダメだったので。Gtkに移植した時に同様の問題が出たら原因は別でしょうが。したがって、IPython Notebookと同様に、参照する関数はあまり重くない関数（もしくは完全にダミーの関数）にしておくべきで、ボタン押下時に重たい計算を走らせる方が安定する気がします。

今後の展望と致しましては、とりあえずGtkに拡張、その際はドロップダウンやタブ、プログレスバーを組み込みたいです。