Stimulator

機械学習とか好きな技術話とかエンジニア的な話とかを書く

ptk1.xから2.xへの変更を選択コマンド実装から学ぶ

- はじめに -

この記事は、Xonsh Advent Calendar 2018 - Qiitaの5日目の記事です。


前年度、Xonsh Advent Calendar 2017 - Qiitaにおいて、私は以下のような2つの記事を投稿しています。
Python Prompt Toolkitで対話的な選択コマンドを作る - Stimulator
PythonでHatenaブックマークのホットエントリを取得して表示する - Stimulator
これは、xonshのコアライブラリとなるpython prompt-toolkit (以下、ptk)を利用したものです。

今回のアドベントカレンダー2018の1日目の記事にも書いた通り、xonshはptkのwrapperと言っても過言ではないレベルでptkは重要な役割を担っています。つまりptkを理解する事は、xonshを理解する事に繋がっています。
そして2018年、ptkは1.xから2.xへのメジャーアップデートにおいて多くの破壊的変更を含んでいます。
参考:Upgrading to prompt_toolkit 2.0 — prompt_toolkit 2.0.7 documentation


本記事は、前年度の以下のようなコマンドをptkの破壊的変更を逐一確認しながら進めるものです。
f:id:vaaaaaanquish:20171225155520g:plain:w400:h200
このコマンドにはptkの多くの要素が含まれており、これらを確認していくとおのずと詳細にふれる事ができます。
xonshがptk 2.xに対応した今年にぴったりの記事です。

 

- スクリプトの変更箇所を理解していく -

前年度実装したコマンドから変更点を抜き出しながら逐一確認していきます。
また、コマンドの全容が見たい場合は、こちらのgit repositoryにも公開しています。
GitHub - vaaaaanquish/select-command-using-ptk: select command using python prompt toolkit

 

MouseEventTypeのリネーム

マウス操作が入った時に落とすためのデコレータ部分です。
MouseEventTypeはMouseEventTypesにリネームされていますので変更が必要です。

- from prompt_toolkit.mouse_events import MouseEventTypes # ptk 1.x
+ from prompt_toolkit.mouse_events import MouseEventType # ptk 2.x

def if_mousedown(handler):
    def handle_if_mouse_down(app, mouse_event):
        if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
            return handler(app, mouse_event)
        else:
            return NotImplemented
    return handle_if_mouse_down

また、詳細な理由を後述しますが前記事でhandle_if_mouse_down(cli, mouse_event)としていた第一引数の変数名をappに変更しています。cliという表記は後方互換性のためにptkの中でもいくつか残っているものの、今後無くなっていくかと思われますので、ここでもそれに習います。

 

KeyBindingManagerとKeybindings

キーバインディング周りは、KeyBindingManagerクラスが廃止され、KeyBindingsクラスによって全て賄う事ができるようになっています。これは、xonshrc等を書く場合でも同様です。
加えて、Keysクラスによって管理されていたキー入力周りですが、KeyBindingsでは「c-a」のようなstrを利用する事ができます。

その他、KeyBindingsのデコレータを付けた関数の引数に入るevent.cliはset_return_valueメソッドを持たなくなっています。
ptk 1.xではここにCommandLineInterfaceというクラスが入っていましたが、CommandLineInterfaceはapplication.Applicationクラスに統合されました。
上記MouseEventTypeの項でもcliという表記をappに変更したのは、そのためです(詳細は後述)。

Applicationクラスにset_return_valueメソッドはないため、exitを利用して同様の動作を実現します。
https://python-prompt-toolkit.readthedocs.io/en/master/pages/reference.html?highlight=Application#prompt_toolkit.application.Application

###### ptk 1.x ######
from prompt_toolkit.key_binding.manager import KeyBindingManager
from prompt_toolkit.keys import Keys
manager = KeyBindingManager.for_prompt()
@manager.registry.add_binding(Keys.ControlQ, eager=True)
@manager.registry.add_binding(Keys.ControlC, eager=True)
def _(event):
    event.cli.set_return_value(None)
@manager.registry.add_binding(Keys.Enter, eager=True)
def set_answer(event):
    ic.answered = True
    event.cli.set_return_value(None)

###### ptk 2.x ######
from prompt_toolkit.key_binding import KeyBindings
kb = KeyBindings()
@kb.add('c-q', eager=True)
@kb.add('c-c', eager=True)
def _(event):
    event.app.exit(None)
@kb.add('enter', eager=True)
def set_answer(event):
    ic.answered = True
    event.app.exit(None)

ガラッと変わっているのが目に見えてわかると思います。
前回の記事に記載のmove_cursor_down、move_cursor_upについてもKeyBindingsに対応させる事で動作させる事ができます。

 

Styleの設定

Style周りの変更は、かなり大きな影響を与えているように思います。
ptkは長らくPygrids、PygmentsにSyntaxColor、lexer等を依存させていましたが、ptk 2.xでは、Pygmentsのトークンのサポートを残しつつ、CSSのような独自コンポーネントが作成され、自由なクラス名を割り当てて自由なスタイリングを行う事が可能になりました。

以下がptk 1.xを利用してstyleを定義する部分です。

###### ptk 1.x ######
from prompt_toolkit.token import Token
from prompt_toolkit.styles import style_from_dict

inquirer_style = style_from_dict({
    Token.QuestionMark: '#5F819D',
    Token.Selected: '#FF9D00',
    Token.Instruction: '',
    Token.Answer: '#FF9D00 bold',
    Token.Question: 'bold',
})

 
同様の記載方法を利用したい場合はpygmentsのTokenクラスを利用する必要があります。

###### ptk 2.x ######
from pygments.token import Token
from prompt_toolkit.styles.pygments import style_from_pygments_dict

inquirer_style = style_from_pygments_dict({
    Token.QuestionMark: '#5F819D',
    Token.Selected: '#FF9D00',
    Token.Instruction: '',
    Token.Answer: '#FF9D00 bold',
    Token.Question: 'bold'
})

この記法を利用した場合、inquirer_styleの中では「Token.QuestionMark: '#5F819D'」が「'class:pygments.questionmark': '#5F819D'」のように取り扱われます。ptkの中でこのpygments.tokenを利用してStyleを適応させたい場合は、styles.pygments_token_to_classnameなるメソッドが用意されているのでそちらを使ってToken -> ptk classnameに変換して使う必要があります。

 
もちろん前述の通りptk2.xではpygemntsを使わない記法も可能です。

###### ptk 2.x ######
from prompt_toolkit.styles import Style
inquirer_style = Style.from_dict({
    'qu': '#5F819D',
    's': '#FF9D00',
    'instruction': '',
    'aaa': '#FF9D00 bold',
    'question': 'bold'
})

上記の例は大げさですが、他クラスに被らなければ自由なクラス名をつける事が可能です。これらを表示時に適応したい場合は「('class:qu', 'hogehoge')」「('instruction': 'piyo')」のようなtupleを作ってprint_formatted_textに投げてあげるとコンソール上にカラースタイルが適応された状態で表示する事ができます。

実際にxonshで表示してみます

###### ptk 2.x ######
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import FormattedText
print_formatted_text(
        FormattedText([('class:qu', 'hogehoge piyopiyo')]),
        style=inquirer_style)

f:id:vaaaaaanquish:20181121151916p:plain

より詳細には以下を参考にすると良いでしょう。pygmentsとprompt_toolkit classnameの比較表もあります。
https://python-prompt-toolkit.readthedocs.io/en/master/pages/printing_text.html

 

Styleの出力

前回の記事においては、get_prompt_tokens関数を定義して、promptに動的にテキストを渡すようにしています。

こちらも上記Styleの変更に対応するため、変更が必要です。
ptkのtokenの利用を止め、pygments.tokenを利用する事でほぼ同じ記述で同じ動作が実現できます。
最後returnする時にpygments_token_to_classnameで「class:pygments.hoge」な記法に変換してやれば良いです。

- from prompt_toolkit.token import Token
+ from pygments.token import Token
+ from prompt_toolkit.styles import pygments_token_to_classname

###### ptk 1.x #####
def get_prompt_tokens(cli):
###### ptk 2.x #####
def get_prompt_tokens():

    tokens = []
    T = Token
    tokens.append((Token.QuestionMark, '?'))
    tokens.append((Token.Question, string_query))
    if ic.answered:
        tokens.append((Token.Answer, ' ' + ic.get_selection()))
        selected_item(ic.get_selection())
    else:
        tokens.append((Token.Instruction, inst))

    ###### ptk 1.x #####
    return tokens    

    ###### ptk 2.x ######
    return [('class:'+pygments_token_to_classname(x[0]), str(x[1])) for x in tokens]

tokenとテキストのtupleをリストにして返す関数ですが、returnする時にtuple[0]のtokenを変換する処理を挟んでいます。
前述のStyleでも紹介した通り、pygmentsを使わない記法も使えますので、そちらも検討すると良いでしょう。

 

TokenListControlがFormattedTextControlにリネーム

前回の記事では、layout.controls.TokenListControlなるクラスを継承し、InquirerControlなる独自のセレクトアイテムを制御するClassを定義していました。
まず前提として、ptk 2.xではTokenListControlクラスはFormattedTextControlにリネームされています。

加えて、前述したStyle出力と同様に、pygments_token_to_classnameを使ってStyleの記法を変更する必要もあります。

- from prompt_toolkit.layout.controls import TokenListControl
- from prompt_toolkit.token import Token
+ from pygments.token import Token
+ from prompt_toolkit.layout.controls import FormattedTextControl
+ from prompt_toolkit.styles import pygments_token_to_classname

- class InquirerControl(TokenListControl):
+ class InquirerControl(FormattedTextControl):

    selected_option_index = 0
    answered = False

    def __init__(self, choices, **kwargs):
        self.choices = choices
        super(InquirerControl, self).__init__(self._get_choice_tokens, **kwargs)

    @property
    def choice_count(self):
        return len(self.choices)

    def get_selection(self):
        return self.choices[self.selected_option_index]

    def _get_choice_tokens(self):
        tokens = []
        T = Token

        def append(index, label):
            selected = (index == self.selected_option_index)
            def select_item(app, mouse_event):
                self.selected_option_index = index
                self.answered = True
                app.set_return_value(None)
            token = T.Selected if selected else T
            tokens.append((T.Selected if selected else T, ' > ' if selected else '   '))
            if selected:
                tokens.append((Token.SetCursorPosition, ''))
            tokens.append((T.Selected if selected else T, '%-24s' % label, select_item))
            tokens.append((T, '\n'))

        for i, choice in enumerate(self.choices):
            append(i, choice)
        tokens.pop()  # Remove last newline.
        
        ###### ptk 1.x #####
        return tokens    

        ###### ptk 2.x ######
        return [('class:'+pygments_token_to_classname(x[0]), str(x[1])) for x in tokens]
 
ic = InquirerControl(choices)

Tokenについては前述同様pygments.tokenを利用していますが、tokenを利用しない記法に変えても大丈夫です。

 

LayoutとContainer

前述の通り、TokenListControlはリネームされました。
その結果、引数のとり方が変わっているため注意が必要です。

加えて、ptk2.xでは表示アーキテクチャにContainerという概念が出てきました。
RendererオブジェクトはLayoutで定義されたオブジェクトを画面に表示します。
ptk 2.xにおいて、Layoutを構成する要素はContainerとUIControlの2つです。
Containerは水平、垂直分割を利用したContainerを再帰的に定義する事ができます。
UIControlはWindowやFormattedTextControl等を含んでおり、表示のサイズ、形式を定義する事ができます。

ptk1.xまでHSplitメソッドはContainerではなくLayoutを直接返していましたが、ptk2.xではContainerを再帰的に定義できるようにすることでより複雑なLayoutを実現できるようになったため、HSplitメソッドはContainerを返すようになりました。
よって以下のように、Layoutの定義の最後にLayoutクラスにする変更が必要です。

from prompt_toolkit.layout.containers import ConditionalContainer, ScrollOffsets, HSplit
from prompt_toolkit.layout.dimension import LayoutDimension as D
- from prompt_toolkit.layout.controls import TokenListControl
+ from prompt_toolkit.layout.controls import FormattedTextControl
+ from prompt_toolkit.layout.layout import Layout

###### ptk 1.x ######
layout = HSplit([
###### ptk 2.x ######
HSContainer = HSplit([

    Window(height=D.exact(1),

           ###### ptk 1.x ######
           content=TokenListControl(get_prompt_tokens, align_center=False)),
           ###### ptk 2.x ######
           content=FormattedTextControl(get_prompt_tokens)),

    ConditionalContainer(
        Window(
            ic,
            width=D.exact(43),
            height=D(min=3),
            scroll_offsets=ScrollOffsets(top=1, bottom=1)
        ),
        filter=~IsDone())])

###### ptk 2.x only ######
layout = Layout(HSContainer)

LayoutとContainerの関係については、以下を見ておくと良いでしょう。
https://python-prompt-toolkit.readthedocs.io/en/master/pages/advanced_topics/rendering_flow.html
https://python-prompt-toolkit.readthedocs.io/en/master/pages/full_screen_apps.html?highlight=Container#the-layout

また、アーキテクチャの全体像については(少し古いですが)以下が参考になります。
https://python-prompt-toolkit.readthedocs.io/en/master/pages/advanced_topics/architecture.html

 

CommandLineInterfaceがApplicationにマージ

MouseEventType、Keybindingsの項でも触れた通り、ptk 2.xではinterface.CommandLineInterfaceはapplication.Applicationにmergeされています。
後方互換のため、少なからずcliなる記載やメソッドが残っていますが)

最後に今までの項で作った設定や関数を利用してptkアプリを実行する部分のコードを以下に示します。

###### ptk 1.x ######
app = Application(
    layout=layout,
    key_bindings_registry=manager.registry,
    mouse_support=False,
    style=inquirer_style
)
eventloop = create_eventloop()
try:
    cli = CommandLineInterface(application=app, eventloop=eventloop)
    cli.run(reset_current_buffer=False)
finally:
    eventloop.close()

###### ptk 2.x ######
app = Application(
    layout=layout,
    key_bindings=kb,
    mouse_support=False,
    style=inquirer_style
)
app.run()

ApplicationはCommandLineInterfaceと違って適切にeventloopを設定してくれるようになっているため、eventloopを明示的に書く必要はありません。


 

- 全体のコード -

コード全体はptk 1.xと2.xのそれぞれを以下のrepositoryに入れてあります。
それぞれのdiffを見る事で違いを確認できます。
github.com


 

- おわりに -

ptkの理解はxonshの理解です。
他にもptkのリファレンスには様々なApplicationのサンプルがありますので、一読し是非ともxonshに活かして頂ければと思います。

アドベントカレンダーですが後半が空いてますので参加者を募集しています!!
宜しくお願いします!!
qiita.com