Pythonバッチにお飾りGUI

Pythonバッチに Tkinter でお飾りGUI

こんな貴方に

日常的に、しょうもない作業を Python にやらせている貴方。「汚れ作業は機械に」を日々実践している。普段は自分さえ使えればいい、と、テキトーな「半自動系ゴミプログラム」で済ませる日々。

そんなある日、「皆にも使わせれば俺の作業も減るじゃねーか、バカヤロー」、と思いましたとさ。

さて、ここで問題がある。自分ひとりで使うなら、コマンドラインだけで済ませてしまうので、イチイチ GUI なんか作らない。が、人に使わせるのにそれはさすがに酷だ。説明するのも鬱陶しいし。

いかに手間をかけずに「お飾り GUI を作れるか?」。これが本日のお題。ただし入力に限る。バッチ作ってるなら、出力はなにがしかファイルとかだろうしね。

もうひとつの前提。

「セキュリティが厳しくて、好き勝手に OSS を持ってくることは厳禁」というマゾヒスティックな環境(かなり一般的)にいるとする。ただし Python だけはなぜか許されている、とする。(多分貴方かほかの誰かが普及活動をした結果だ。)

然るに、出来るだけ標準添付ライブラリの世界を出ないでやりたい。

簡単に複雑な GUI を作れる開発環境なんかこれまで一度として登場していない

GUI プログラミングは昔からダルい。今でもダルい。今後もダルくあり続ける。なぜダルいか。答えは簡単。

  • 逐次実行でないのでプログラミングしながら振る舞いを想像しにくいから
  • デザイン…

どんなに優秀な環境も、この2点だけは突破出来ない。

であるから、サボれる限界があって、その限界を超えた時点で、本気モードに入らないといけない。つまり「行儀の良い GUI プログラミング」に突入しなければならない。

「お気楽~」的なサイトや書籍は多いけれど、別にコーディング量がお気楽なわけじゃねっす。

選択肢はもはや Tkinter しかない

Tkinter が好きだろうとそうでなかろうと、「Tkinter は、Python をインストールすれば、大抵の場合は一緒に付いてくる」。

何かの理由でほかに選択肢がある貴方は、この記事は忘れましょう。

ファイル選択、フォルダ選択

単一ファイル選択

読み込みのための単一ファイルなら、tkFileDialog.askopenfilename。

1 # -*- coding: utf-8 -*-
2 import tkFileDialog
3 filez = tkFileDialog.askopenfilename(title="Choose file")
4 #場合によっては unicode にデコード必要かも
5 #import sys
6 #targets = filez.decode(sys.stdout.encoding)
7 print(filez)

ちょっと気持ち悪いのもいて、読み込みのための単一ファイルを開いて返す、tkFileDialog.askopenfile がいる。

1 # -*- coding: utf-8 -*-
2 import tkFileDialog
3 fi = tkFileDialog.askopenfile(title="Choose file")  # open済みで返る
4 #読み込みモードを指定出来る:
5 #fi = tkFileDialog.askopenfile(mode="rb", title="Choose file")
6 print(fi.read())

書き込み用、いわゆる「Save As」用には、tkFileDialog.asksaveasfilename と tkFileDialog.asksaveasfile。使い方は同じ。

複数ファイル選択

読み込みのための複数ファイルなら、tkFileDialog.askopenfilenames。

1 # -*- coding: utf-8 -*-
2 import tkFileDialog
3 filez = tkFileDialog.askopenfilenames(title="Choose files")
4 #場合によっては unicode にデコード必要かも
5 #import sys
6 #targets = [t.decode(sys.stdout.encoding) for t in filez]
7 for path in filez:
8     print(path)

単一ファイルの場合と同様に、tkFileDialog.askopenfiles がいる。

1 # -*- coding: utf-8 -*-
2 import tkFileDialog
3 filez = tkFileDialog.askopenfiles(title="Choose files")
4 #読み込みモードを指定出来る:
5 #filez = tkFileDialog.askopenfiles(mode="rb", title="Choose files")
6 for fi in filez:
7     print(fi.read())

Save As に相当するものはないが、tkFileDialog.askopenfiles の mode=”w” とかで(嘘つき UI だけど)出来たりするんだろうか? とは言えほとんどこのニーズはないとは思う。(なぜって、順不同で返ってきた「書き込み対象ファイル」を受け取って、一体どうやって正しい振る舞いを書けるというのだ。)

フォルダ選択

tkFileDialog.askdirectory。

1 # -*- coding: utf-8 -*-
2 import tkFileDialog
3 folder = tkFileDialog.askdirectory(title="Choose folder")
4 print(folder)

そういえば、学生時代は UNIX しか使ったことがなく、はじめて Windows に触れた際に「フォルダ」という用語がどうしても馴染めなかったのを思い出した。

整数入力だけ/浮動小数入力だけ/文字列入力だけ

tkSimpleDialogで。

1 # -*- coding: utf-8 -*-
2 import Tkinter
3 import tkSimpleDialog
4 root = Tkinter.Tk()
5 root.withdraw()
6 print(
7     tkSimpleDialog.askinteger(
8         u"askinteger", u"整数をくれい", minvalue=1, maxvalue=10)
9     )

1 # -*- coding: utf-8 -*-
2 import Tkinter
3 import tkSimpleDialog
4 root = Tkinter.Tk()
5 root.withdraw()
6 print(
7     tkSimpleDialog.askfloat(
8         u"askfloat", u"小数でくれい", minvalue=0.1, maxvalue=10.0)
9     )

 1 # -*- coding: utf-8 -*-
 2 import Tkinter
 3 import tkSimpleDialog
 4 root = Tkinter.Tk()
 5 root.withdraw()
 6 print(
 7     tkSimpleDialog.askstring(
 8         u"askstring", u"文字列が好きだぁ")
 9     )
10 print(
11     tkSimpleDialog.askstring(
12         u"askstring", u"みえないなんてやだぁ", show="*")
13     )


テキスト入力(マルチライン)たった一つ

ここから急に鬱陶しくなってくる。すなわちもはや「CommonDialog的」ではなく、一撃では出来ない。つまりは、ここいらあたりからが「本気で取り組む必要がある」かどうかの境目となる。

仕様はこんなだとする:

  1. 「~てください」的なプロンプトはタイトルバーに出ればいい
  2. OK, Cancelボタンだけは付いていて、Cancel かどうかがわかる必要はある
  3. 2項目以上入力させたい場合には何個もダイアログが立ち上がっても気にしない
  4. (Tcl/Tkベースでは付きまとう)rootウィンドウは出ちゃイヤ
  5. 入力チェックなんかいらない
    • バッチに戻ってからやればいい
    • 入力チェックエラーならダイアログ立ち上げなおせばいい
  6. 型なんか気にしない
    • 数値が必要ならバッチに戻ってから変換すればいい

仕様というより「サボりたい、という願望」が含まれている。そういう記事なんです。

以下がこれを実現するもの。

 1 # -*- coding: utf-8 -*-
 2 # 厳重注意:「バッチに付けるお飾りGUI」以外の目的での参考には絶対にしないこと。
 3 # ことごとく(GUIプログラミング的に)マナー違反してます。
 4 class tkTrickeryTextQuery(object):
 5     def __init__(self, title, **kw):
 6         import Tkinter
 7         #import ScrolledText
 8         from Tkinter import E, W, N, S  # for sticky
 9         self.root = Tkinter.Tk()
10         self.root.title(title)
11         self.value = None
12         
13         self.ent = Tkinter.Text(  # Entry -> Text
14             self.root, **kw)
15         # ScrolledText.ScrolledText ならスクロールバーがついたもの。
16 
17         self.ent.grid(row=0, column=0, columnspan=10)
18         btno = Tkinter.Button(
19             self.root, text="OK", command=self._ok)
20         btno.grid(row=1, column=0, sticky=E + W)
21         btnc = Tkinter.Button(
22             self.root, text="Cancel", command=self.root.destroy)
23         btnc.grid(row=1, column=1, sticky=E + W)
24         self.root.mainloop()
25 
26     def __call__(self):
27         return self.value
28         
29     def _ok(self):
30         self.value = self.ent.get('1.0', 'end-1c')  #
31         self.root.destroy()
32 
33 if __name__ == "__main__":
34     print(tkTrickeryTextQuery(
35             u"ほげを入力しなされ", width=100, height=10)())


やや長いが、この程度であれば、たとえ「インターネットから切り離されている」としても、例えばスマホ片手に写経出来なくはないであろう。

真面目に Tkinter で GUI を作りたい場合は真似しないほうがいい。「仕様」に書いた通りであるが、Tkinter.Tk() が主役ではない「バッチプログラムのお飾り GUI」に特化したコードである。具体的には、

1 root = Tkinter.Tk()
2 root.withdraw()

がプログラムのトップレベルにいないのが行儀悪い。ただ、「一個だけ入力が必要なのだ」というお飾りGUI時は、イチイチ汎用考えたくないのだ。

プルダウンたった一つとかも出来るが…

一般的なコントロールがただ一つ、という GUI はおそらくプルダウンまでが限界で、「チェックボックスただ一つ」なんて馬鹿げているし、「ラジオボタンただ一つ」などありえない。リストボックスくらいはありえるが、それが必要な UI なら、きっと本気モードに移行した方がいい。きっと他のコントロール付けたくなるから。となれば、ここがサボれる限界、と思う。

以下の通り:

 1 # -*- coding: utf-8 -*-
 2 # 厳重注意:「バッチに付けるお飾りGUI」以外の目的での参考には絶対にしないこと。
 3 # ことごとく(GUIプログラミング的に)マナー違反してます。
 4 class tkTrickeryPulldownQuery(object):
 5     def __init__(self, title, values, **kw):
 6         import Tkinter
 7         import ttk
 8         from Tkinter import E, W, N, S  # for sticky
 9         self.root = Tkinter.Tk()
10         self.root.title(title)
11         self.value = None
12         
13         self.ent = ttk.Combobox(
14             self.root, **kw)
15         self.ent['values'] = values
16         self.ent.current(0)  # 先頭アイテム選択
17 
18         self.ent.grid(row=0, column=0, columnspan=10)
19         btno = Tkinter.Button(
20             self.root, text="OK", command=self._ok)
21         btno.grid(row=1, column=0, sticky=E + W)
22         btnc = Tkinter.Button(
23             self.root, text="Cancel", command=self.root.destroy)
24         btnc.grid(row=1, column=1, sticky=E + W)
25         self.root.mainloop()
26 
27     def __call__(self):
28         return self.value
29         
30     def _ok(self):
31         self.value = self.ent.get()  #
32         self.root.destroy()
33 
34 if __name__ == "__main__":
35     print(tkTrickeryPulldownQuery(
36             u"選んでね", values=[u'リンゴ', u'みかん', u'ニシキヘビ'], state='readonly')())
37     print(tkTrickeryPulldownQuery(
38             u"選ぶか入力しろよ", values=[u'そうか', 'そうなのか', u'そうでもない'])())


2021-05-03追記: 「プルダウンたった一つ」が実用になる稀有なケースについて一言

最初に書いた際にこれを意識していたかどうかは憶えてない。ただ、bash コマンドラインでたとえば日本語入力が出来ない場合、たとえばこういうことをするわけだ:

1 [me@host: ~]$ chrometabopen.py --mode=search_ecosia "`cat`"
2 エコシア 検索エンジン

(→ chrometabopen.py。)
Windows 付属の「コンソール」も、標準入力に対しては問題なく(設定されているコードページのものは)入力出来る。出来ないのはあくまでも「bash コマンドライン」なのだ。ただ、Windows 付属の「コンソール」に付属の IME は特殊で、ほかのアプリケーションから使える IME と同じような使い勝手ではなくて、結構使いにくい。と思うならば…、と。「tkinter の Entry だけの UI があればうれしいってことはないか?」てことになる。

もう一歩。「どうせなら」。これね:

1 [me@host: ~]$ chrometabopen.py --mode=search_ecosia "ecosia search engine"

この場合、bash のヒストリに残るので、似たワードで検索したいなら、bash のヒストリサーチを活用すれば楽できる。そして「"`cat`"」でやった方は、「エコシア 検索エンジン」を入力したことは復元できない。そうよね、「プルダウンに過去の入力が出てきたら嬉しいんじゃないか?」:

query_string.py
 1 # -*- coding: utf-8 -*-
 2 from __future__ import unicode_literals
 3 from __future__ import print_function
 4 
 5 import io
 6 import os
 7 import sys
 8 import json
 9 try:
10     import Tkinter as tkinter  # python 2.x
11     import ttk
12     from tkSimpleDialog import Dialog
13 except ImportError:
14     import tkinter
15     from tkinter import ttk
16     from tkinter.simpledialog import Dialog
17 
18 
19 __MYNAME__, _ = os.path.splitext(
20     os.path.basename(sys.modules[__name__].__file__))
21 
22 
23 class _QueryStringDialog(Dialog):
24     def __init__(self, parent):
25         self._savepath = os.path.join(
26             os.environ.get("HOME", os.environ.get("USERPROFILE")),
27             ".{}.hist".format(__MYNAME__))
28         self._histories = []
29         if os.path.exists(self._savepath):
30             try:
31                 self._histories = json.load(
32                     io.open(self._savepath, encoding="utf-8"))
33             except Exception:
34                 pass
35         self.result = ""
36         Dialog.__init__(self, parent, "query string")
37 
38     def body(self, master):
39         font = ["courier", 12, "normal"]
40         self._pdqs = ttk.Combobox(master, width=120, font=font)
41         self._pdqs["values"] = self._histories
42         self._pdqs.pack()
43         return self._pdqs
44 
45     def apply(self):
46         self.result = v = self._pdqs.get()
47         if v and v not in self._histories:
48             self._histories.insert(0, v)
49         with io.open(self._savepath, "w", encoding="utf-8") as fo:
50             print("[\n    ", file=fo, end="")
51             print(",\n    ".join(
52                 ['"{}"'.format(s) for s in self._histories]), file=fo)
53             print("]", file=fo)
54 
55 
56 if __name__ == '__main__':
57     tkroot = tkinter.Tk()
58     tkroot.withdraw()
59     dlg = _QueryStringDialog(tkroot)
60     if dlg.result:
61         print(dlg.result)

このページに書いてる本題は「UI をサボりたい」なので、少々そこからは外れてしまうのだが、「プルダウン一個の UI がちゃんと実用になることもある」のは言っといてもいいかなと思ってな。

なお、Python 3.x だけを相手にするとめっぽう簡単なのだが、ワタシの個人的事情により、2025年末までは python 2.7 を見捨てないと決めてるので、あえて苦しんでる。一番ヒドいのが json.dump を使えてないことで、これは無論「unicode 問題」に起因する措置。こういうのをみて、ちゃんと「馬鹿げてる」と思うのが正解。あなたは Python 2.7 を心置きなく捨てるべきだよ。(このページを最初に書いた頃とはかなり状況が違っているのだ。今や Python 3.5 でさえもサポート対象外であることを理解すること。)

Python 3.xではインターフェイスが違うので注意

Python 2.7 を例にした。Tkinter、ScrolledText はモジュールが変更になっていて、Python 3.x では tkinter、tkinter.scrolledtext。

参考: Google Code に近いものがあるようだ

今回のお題にはそぐわないが、

https://code.google.com/p/pybase/source/browse/pybase/#pybase%2Ftk

なんてのがある。内容から察するに、Python 標準に取り込まれた元。プルダウンについてはもっと複雑なことが一撃で出来るようになっているようだ。

参考: 本気で Tkinter に取り組みたいなら

Python のソース配布物に、Tkinter のサンプルコードが含まれている。Tkinter に限らず色んなサンプル(Demo)が入っているので、入手しておくと良い。

tkColorChooser、tkMessageBoxはいいよね?

ともに良く使うだろうけど、

1 >>> import tkColorChooser
2 >>> help(tkColorChooser)
3 >>> import tkMessageBox
4 >>> help(tkMessageBox)

ですぐにわかると思う。

2020-10-19追記: ListBox 一個、の実際例

この記事、長らく放置プレイだったのだけれど、実際に自分のための道具でまさにこれをやった。実際に動作する「ちゃんとしたインチキ GUI」をご堪能しなはれ: ffchopreview.py
(今後も「ListBox一個」であり続けるわけでもないと思う。成長して複雑になってた暁には、その「一個だった」初期状態、つまりここでのお題「お飾りGUI」を保っている版を参照しなはれ。)

何するものかつぅと、「動画を特定範囲で千切るとどうなるのよプレビュー」を目的とした、ffmpe具。例えば「19:20~19:25くらいの範囲」を精度良く切り取りたいとする。まともな道具がないと、この切り取り範囲の微調整はかなり大変。まともな動画編集ツールにとってはあまりにも基礎中の基礎な機能だけれど、「ワシは ffmpeg しか手持ちじゃないんじゃぁ」な人はこういうのがまずツラいのよね。

なお、始まりも雑だし、「ちゃんと保守しよう」とするとは今のところ思ってないのもあって、Rev4 時点で相当汚らしいスクリプトになってる。ちゃんとしたい人はちゃんと批判的な目を凝らしてちゃんと整理して活用してほしい。(特に関数間の依存関係がめちゃくちゃ(「場当たり的」)なので、整理せずに拡張し続けると間違いなく死ぬパターン。)

2020-10-21追記: Entry のバリデータ

あくまでも「GUI をサボる」ノリを維持するものとして。願望として書いた通りで、本当に「妥当な入力が是が非でもないと世界がオワたるのよ」のだとしてもそれは「使う直前でチェックしりゃぁええやん」でいいし、実はこれから説明する残念なお知らせからは、「本格的な GUI るとしても相変わらず真実」だったりもする。

まず Tcl/Tk てのはその誕生からして独特だし、今でもちょこまかと異質なんだけれど、このバリデータについては、きっとまっさきにお目にかかる「独特」ではないかと思う。わかってしまってから説明するのはそんなに難しくなくて、「インジェクション対象となるプログラム片に何を通知して欲しいのかをあらかじめ知らせやがれや」という仕組みになってる。っと…現実のコードを見てもらった方が早いのかもね:

 1 try:
 2     import Tkinter as tkinter  # python 2.x, but sorry, I'm not testing it.
 3     import ttk
 4     from Tkinter import E, W, N, S  # for sticky
 5 except ImportError:
 6     import tkinter
 7     from tkinter import ttk
 8     from tkinter import E, W, N, S  # for sticky
 9 
10 
11 class _MyEntry(tkinter.Entry):
12     def __init__(self, root, validcharsfun, *args, **kwargs):
13         tkinter.Entry.__init__(self, root, *args, **kwargs)
14         #
15         def _validator(*args):
16             P, d = args
17             if d == "1":  # insert
18                 return validcharsfun(P)
19             return True
20         #
21         # %d = Type of action (1=insert, 0=delete, -1 for others)
22         # %i = index of char string to be inserted/deleted, or -1
23         # %P = value of the entry if the edit is allowed
24         # %s = value of entry prior to editing
25         # %S = the text string being inserted or deleted, if any
26         # %v = the type of validation that is currently set
27         # %V = the type of validation that triggered the callback
28         #      (key, focusin, focusout, forced)
29         # %W = the tk name of the widget
30         self.config(validate="key")
31         self.config(validatecommand=(root.register(_validator), '%P', '%d'))
32 
33 
34 class _MyTSEntry(_MyEntry):
35     def __init__(self, root, *args, **kwargs):
36         _MyEntry.__init__(
37             self, root,
38             lambda s: re.match(r"^[\d:.]+$", s) is not None,
39             *args, **kwargs)
40 
41 
42 class _MyFloatEntry(_MyEntry):
43     def __init__(self, root, *args, **kwargs):
44         _MyEntry.__init__(
45             self, root,
46             #lambda s: re.match(r"^[\d.eE+-]+$", s) is not None,
47             lambda s: re.match(r"^[\d.]+$", s) is not None,
48             *args, **kwargs)
49 
50 
51 ## ---        
52 root = tkinter.Tk()
53 
54 font = ('courier', 14, 'normal')
55 entry = _MyTSEntry(root, font=font)
56 entry.pack()
57 entry2 = _MyFloatEntry(root, font=font)
58 entry2.pack()
59 root.mainloop()

ポイントというか異質・独特・奇特なのが無論「validate=」「validatecommand=」の部分で、

  1. 自前バリデーション処理が受け取りたい通知のタイミングと種類をお知らせしなければならんがゆえの validate= と register
  2. validate= にまつわる残念なお知らせ

前者は覚えるしかないわけで、そういうもんだと諦めれば済む。ただ、後者は地味にかなりいやらしい。何かつぅと、(頼みの)「validate=”focusout”が不完全」な点。理想的に「ユーザが満足に入力し終わった」ことをもってチェックするのが一番簡単なので、GUI プログラミングでは一般的に focusout をよく利用する。けれど、(Windows 版だけの可能性は否定はしないが)この focusout、まずは「コントロールが一つしかない場合は決して focusout 出来ない」上に、たとえば Button を押したタイミングで Entry が focusout を受け取ることもない。

ゆえ、残念ながら「validate=”key”」しか頼りにはならないのだが、だとすると「入力チェック」ではないのよ、これは。なぜなら「validate=”key”」は絶賛入力中にバリデーションを通すことなので、不完全入力を許容する必要があるわけである。つまり「文字 insert 真っ最中」に「時刻の妥当な形式でないとイヤん」として「時刻として妥当」の完全なチェックを試みると、無論「21:」という入力が出来ない。つまり「時刻入力し始め」を許容するチェックにしなければならない。というわけで、よほど単純でない限りは、「validate=”key”」は「不正文字(列)拒絶」としてしか使えない、普通は。

「「不正文字(列)拒絶」としてしか使えないのかよ」として無視してしまってもいいとは思うよ。ただ、実際ユーザとして利用する場合に、入っちゃいけない文字が入らないだけでも、結構便利さを実感できる、とは思うよ。

2020-10-21追記(2): accesskey の話と Python 3.x の話

世間的には「Python 2.7 is DEAD. デっス」なので、今このページ内容を刷新するなら「Python 2.7 なんてこの世には存在しない」ノリで書き直すのも手なんだけれど、個人的事情によりまだ 2.7 を捨てられない微妙な時期になってて、ので、まぁやるなら「Python 3.x 対応」を改めて追記する形にするんだろうね。上で書いた際は時期的にまだまだ 2.7 が、「現役どころか何十倍も Python 3.x よりユーザが多い」だったんでアレでも良かったんだけど、今はさすがに「Python 2.7 を推奨するノリは厳禁」だもの、注意深く書き直さないとなぁと思う。ともあれそれについてはもうちっと待っといてくれ。量は大したことないんで、その気になればすぐに出来る。(最低でも tkFileXxx の部分がまったくダメなのは知ってる。措置手段もワタシ自身は知ってる。)

まぁそっちはそっちとして、で、accesskey の話なのだが、これは「GUI をサボる」というお題からは少し外れるのかもしれない。ただ、「使いやすさ」に直結する、ということは、使ってもらう相手にヘンな言い訳を増やさなくていいという意味で、知っとくと良かろう、てこと、なわけで、あえて紹介しとくことにした。

「Entry のバリデータ」の話でも書いたように Tcl/Tk は色々独特なのだが、その独特さの由来は無論「超絶に古い」からである。もはや現存する GUI ツールキットの中ではほとんど最古に属する。私が大学生だった 1992 年くらいには既にこれは存在してた。TkInter がそのころからあったのかはワタシは知らないけれど、tcl/tk は間違いなく存在してた。その頃ってアレよ、Sun がまだ SunView を諦めきれてなかった頃よ。X Windows System に「完敗」してたけど、まだ死んではいなかった。そんな時期ね。

だから、なのかと想像してるんだけど、ないんだよ、「accesskey」のシカケが。たぶん。ちょっと探したけど見つからず。これが事実だとしたら、長い歴史の中で、今でも絶賛進化中の tcl/tk、なぜにこれを取り込まなかったのかがかなりナゾだが、まぁそういうことならそういうことなんだろう…。で、「じゃぁ諦める」必要があるかと言えばそうでもなくて。実際やってるんで眺めてみて。(なお、「ちょっと探してみた」はほんとにちょっとしか探してないので、実はあったりしたらゴメン。)