🐷

click v8.0.0で追加されたWindows向けのglob展開が微妙

2021/10/26に公開

最近プライベートなpcのclickのバージョンを8系に上げたところ, windowsで使っていたスクリプトのいくつかが壊れた
原因としては表題の新機能だが, あまり情報がない(公式ドキュメントには Upgrading to 8.0 の記事がないんだよな...)のでマイグレーション方法をここに残しておく

ちなみに以下のissueで議論されていたようだ
https://github.com/pallets/click/issues/1096

何が起きるか?

import click


@click.command()
@click.argument('pattern')
def main(pattern):
    print(pattern)


if __name__ == '__main__':
    main()

このようなスクリプト example.py があるとする
これを

C:\Users\xxxxx\Desktop>dir /b
a.txt
b.txt
example.py

の環境で python example.py * で実行するとする
7.1.2までは

C:\Users\xxxxx\Desktop>python example.py *
*

* が表示されて終わりである
しかし8.0.0以降では

C:\Users\xxxxx\Desktop>python example.py *
Usage: example.py [OPTIONS] PATTERN
Try 'example.py --help' for help.

Error: Got unexpected extra arguments (b.txt example.py)

となる
*a.txt b.txt example.py に展開されているが, 受け入れる引数が一つしか定義されていないため余計な引数としてエラーになるわけだ

何が微妙?

エスケープできない

これがこの機能をデフォルトで有効にするとした判断が信じられない理由である
正規表現やらHTTPヘッダやら, アスタリスクが含まれそうな引数などいくらでも想像できように

APIのデザイン

@click.command() が返す Callable の引数でしか展開を無効化できないようになっている
そのせいで setup.py の console_scriptspoetrypyproject.toml の scripts を利用していると無効化のために冗長なコードを書く必要がある
できれば @click.command() の引数で無効化できるようにして欲しかったね

単純な対策

単純な対策は nargs=-1 の引数を末尾に追加し, 引数がいくつであっても全て消費することである

import click


@click.command()
@click.argument('path')
@click.argument('paths', nargs=-1)
def main(path, paths):
    print([path, *paths])


if __name__ == '__main__':
    main()

無効化する

エスケープできないため特殊文字を渡したい場合は無効化するしかない
前述の通り @click.command() が返す Callable の引数に windows_expand_args を渡すと無効化できる

import click


@click.command()
@click.argument('pattern')
def main(pattern):
    print(pattern)


if __name__ == '__main__':
    main(windows_expand_args=False)

python <script>python -m <module> で実行できればよいならこの対応で申し分ない
しかし setup.py の console_scriptspoetrypyproject.toml の scripts を利用する場合, main が直接呼び出されるためこの方法では windows_expand_args を渡すことができない
そのような場合は click.Command クラスの main をオーバーライドすることになる

import click


class MyCommand(click.Command):
    def main(self, *args, **kwargs):
        return super().main(*args, **kwargs, windows_expand_args=False)


@click.command(cls=MyCommand)
@click.argument('pattern')
def main(pattern):
    print(pattern)


if __name__ == '__main__':
    main()

あるいは以下のようにデコレータを使ってもいいだろう

from functools import wraps
import click


def disable_windows_expand_args(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        func(*args, **kwargs, windows_expand_args=False)
    return wrapper


@disable_windows_expand_args
@click.command()
@click.argument('pattern')
def main(pattern):
    print(pattern)


if __name__ == '__main__':
    main()

サブコマンド利用時に無効化する

サブコマンドでも mainwindows_expand_args=False を渡せばいいのだが, 生憎 @click.group()cls 引数を取らないし, @disable_windows_expand_args でデコレートしても意図通りには動作しない
正解は以下のようになる

import click


class MyGroup(click.Group):
    def main(self, *args, **kwargs):
        return super().main(*args, **kwargs, windows_expand_args=False)


@click.command(cls=MyGroup)
def main():
    pass


@main.command()
@click.argument('pattern')
def sub():
    pass


if __name__ == '__main__':
    main()

これで一応動作するのだが, @click.command()Group でなく Command を返す定義となっているために @main.command() でタイプエラーが発生するのが難点である
解消のためには cast() を増やさなければいけない

from typing import cast
import click


class MyGroup(click.Group):
    def main(self, *args, **kwargs):
        return super().main(*args, **kwargs, windows_expand_args=False)


def click_command(func) -> click.Group:
    return cast(click.Group, click.command(cls=MyGroup)(func))


@click_command
def main():
    pass


@main.command()
@click.argument('pattern')
def sub():
    pass


if __name__ == '__main__':
    main()

どうにかならんものか...

Discussion