Python
xonsh
XonshDay 2

Xonshでコマンドを作る

コマンド(callable aliase)の作り方

Xonshでは、ユーザが定義したpython関数をシェルコマンドとして使うことができます。
ライブラリも何もない貧弱なシェルスクリプトではちょっとやりたくないことが、pythonでサクッと作れます。
以下の流れでやっていきます。
- コマンド関数を作る
- 関数をコマンドとして登録する
- 補完を作る

コマンド関数を作る

以下のような、args, stdin, stdout, stderr, specの5つを引数としてとる関数を定義します。

def mycmd(args, stdin=None, stdout=None, stderr=None, spec=None):
    do_something()
  • args: コマンドの引数
  • stdin, stdout, stderr: stdioたち, ファイルオブジェクトのように使えます
  • spec: 高度な設定を含むSubprocSpecオブジェクト

ちなみに、これらの引数は必要に応じて省略も可能です。

それで、もったいぶらずに言うと、あとでこれが

$ echo spam | mycmd arg1 arg2 arg3 > out.log

みたいな感じで使えるようになります。
このとき、

args: ["arg1", "arg2", "arg3"]

となります。

いくつか知っているコマンドを実装してみせるのが口で言うよりもいいと思うので、echoとcatあたりを実装します。面倒なオプションの実装は勘弁してください。

def _pyecho(args, stdin=None, stdout=None):
    end = '\n'
    if args[0] == '-n':
       end = ''
       args = args[1:]
    print(' '.join(args), end=end, file=stdout)

def _pycat(args, stdin=None, stdout=None):
    res = ''.join([ open(path, 'r').read() for path in args ])
    print(res, file=stdout)

関数をコマンドとして登録する

定義した関数は、そのままでもpythonの式としてXonsh上で実行することができます。

$ _pyecho('test')

これをさらに手軽に使うために、シェルコマンドとして登録するのは簡単です。

aliases['pyecho'] = _pyecho

これだけです。グローバルにaliasesという変数があり、辞書のようなオブジェクトになっています。
ここに登録した瞬間から、そのコマンドはsubprocess modeにおけるコマンドの一つとして使用可能になります。

補完を作る

実はXonshにおける補完はfishなどと異なり関数やコマンドに対して紐付くものではないのですが、大抵の場合コマンド毎に補完を定義すると思うので、ここで触れておきます。
高度な補完の話をし始めるとそれだけで一記事いけちゃう程度には色々できるので、ここでは基本的なことだけ説明します。
Xonshにおいて、補完は基本的にタブを押したときなどに起動します。補完が起動されると、登録されている補完関数が直列で実行され、以下の2ステップを行います。

  • 今入力中のバッファに対して補完を行うかどうかの判断
  • 補完を行う場合、ユーザに提示する候補を計算

これもサンプルコードから見せた方が早いです。

# 候補のリストを返す関数(completer)を作る

re_mycmd = re.compile('^mycmd')

def complete_mycmd(prefix, line, begidx, endids, ctx):
    # この関数で候補を提示するか判定
    if not re_mycmd.match(line):
       return

    # 候補のリストを計算
    candidates = ['spam_a', 'spam_b', 'spam_c']
    return candidates, 0


# completerを登録

completer add mycmd complete_mycmd

completer関数は以下の引数を取ります。

  • prefix: カーソルのある位置の直前のtoken
  • line: 入力中の行全体の文字列
  • begidx: line中におけるprefixの開始位置
  • endidx: line中におけるprefixの終了位置
  • ctx: pythonの環境(変数、import済みモジュールの入った辞書({変数(モジュール)名前: インスタンス}))

completerでは、大抵最初にlineかprefixに対して正規表現のマッチングによるガードが入ります。
Tabを押された瞬間にマッチするまで登録されたcompleterは全て試されるため、高速に判定すべきです。
そのため、globalにコンパイル済み正規表現オブジェクトを保持することが慣例です。

次に、ガードが真なら候補の計算に入ります。
引数に与えられる情報を駆使して好きなだけ賢い計算をしてください。ctxには見えるpython環境が全て入っています。
最後に、候補リストと今補完中のprefixの長さを返して下さい。prefixの長さは補完する際の表示を正しくするために必要になります。
上のサンプルコードだと0なので、今あるカーソルのtokenが何も入力されていない時などに適切な設定です。

completerができたら、それをcompleterコマンドで登録します。

$ completer add name completer_func

という感じです。ここで、nameに対して登録できるcompleterは一つだけであることに注意してください。

ここまでのまとめ

ユーザ定義コマンドの作り方の大枠を説明しました。難しいことには深入りせずに説明したつもりです。
ここまでの知識だけでも結構なことができるようになると思います。

ここから下では、より詳細な使い方や慣例について説明します。

コマンド関数を作る(中級編)

あえて深入りしなかったコマンド関数の最後の引数であるspecですが、通常のコマンドとして使う分にはあまり必要の無いものとはいえ、Xonshコマンドとしてフルに活用する場合必須のものになります。

誰かが別の記事で説明する可能性が高いですが、予備知識としてXonsh用語にpython-modeとsubprocess-modeというものがあるので、簡単におさらいします。
単純にpythonの文法ベースで書かれたところはpython-mode, shellの文法ベースで書かれたところがsubprocess-modeで評価されると考えてください。

# python-mode
$ print('spam')

# subprocess-mode
$ echo spam

# captured subprocessを含むpython-mode 
$ print($(echo spam))

# python式を含むsubprocess-mode
$ echo @('spam')

さて、ここで伝統あるechoコマンドを少しいじめてみます。

$ echo spam
spam
$ echo @('spam')
spam
$ echo $(echo @('spam'))
spam

$

間違い探しをしてみて下さい。手元で実行すると気づくと思います。
間違いではないのかもしれませんが、3つめの実行では余計な改行が出力されています。
これは-nを明示的に付けない場合echoが改行を入れてくるという仕様だからですが、これがXonshの動きとあまりマッチしません。
captured subprocess-modeで動く場合はshellにおける表示上の都合のための装飾をしてほしくないのです。
echoは-nがあるだけまだましで、伝統的なコマンドは全て最後に改行をいちいち入れてくれます。

globがあるのであえてやることもないですが、lsの結果をcaptureしたとします。

$ print($(ls).split('\n'))
['spam', ... , '']

こんな具合に最後に空文字が入ってしまいます。あとあと処理が面倒になるかもしれません。
これのように色々な場合で、Xonshのコマンドはcaptureされた時とそうでないときで挙動が変わるべきなのです。

長くなりましたが、この問題を解決するためにSubprocSpecが使えます。
Xonshのためのechoを作ってみましょう。

import xonsh.proc

def _xecho(args, stdin=None, stdout=None, stderr=None, spec=None):
    if spec.captured in xonsh.proc.STDOUT_CAPTURE_KINDS:
        end = ''
    else:
        end = '\n'
    print(' '.join(args), end=end, file=stdout)
    return 0

aliases['xecho'] = _xecho

では、使ってみます。

$ xecho test
test
$ xecho $(xecho test)
test

うまくできました。

関数をコマンドとして登録する(補足)

言い忘れましたが、aliasesには関数だけでなく通常のコマンドも登録できます。

aliases['ls'] = ['ls', '-al']

aliasesはcabc.MutableMappingを継承するクラス(xonsh.aliases.Aliases)のインスタンスですが、内部的に登録されたaliaseを展開する機能などが実装されています。
単純に深さ優先で再帰的に展開していくだけですが、ここに動的な展開の定義をプログラマが差し込めるようにしたらlispのマクロみたいになってきて素敵だなーと最初思ったんですが、別のところにマクロがあるので後で是非記事にさせて下さい。

補完を作る(中級編)

補完は正規表現で引っ掛けてリストを返すだけのとても単純な機能なので、解説すべきトリッキーな使いこなしの機能はないのでtipsをまとめておきます。

lineのパージング

shlexを使うと良いと思います。余計な空白を無視してくれます。

$ shlex.split('ls -a    spam')
['ls', '-a', 'spam']

正規表現オブジェクトの遅延

コンパイル済み正規表現をグローバルに持っとくと良いと説明しましたが、これがちょっとまずい問題を引き起こすことに勘のいい人は気づいたかもしれません。
Xonshで楽しくカスタムを進めていくと、当然rcが長くなり起動が重くなっていきます。
Xonshそのものが起動を高速化するための黒魔術(amalgamate)を導入していたりと、これが結構クリティカルな問題です。
mycmd_re = re.compile('~')みたいな行が大量にrcに書いてあったら、当然これらが直列に作られていって大変なことになります。
これを解決するためにlazyasdというライブラリを使います。Xonshrcを書くユーザにとって一番使う頻度の高いライブラリであるかもしれないくらいよく使います。

from lazyasd import LazyObject

mycmd_re = LazyObject(lambda : re.compile(~), globals(), 'mycmd_re')

このように書き換えるだけです。あとは使う時がきたら第一引数の継続を呼んでオブジェクトを作ってくれます。他のコードはいつも通り書いて大丈夫です。

ちなみに、余談ですが、書き方のバリエーションがもう一つあります。例としてモジュールのインポートを遅延してみます。

import importlib
from lazyasd import lazyobject

@lazyobject
def plt():
    plt = importlib.import_module('matplotlib.pyplot')
    plt.style.use('dark_background')
    return plt

pythonのlambdaが式を一つ書くことしかできない残念な仕様なので、こういうデコレータも提供されています。

まとめ

コマンドを作る方法にまつわる話を書きました。この後のカレンダーを気合で埋める人たちの助けになれば幸いです。
なんか書いてないことがある気がしますが、筆が止まったのでこのへんで終わりにしようと思います。
(python用のシンタックスハイライトだと$が怒られますね。。。)