click v8.0.0で追加されたWindows向けのglob展開が微妙
最近プライベートなpcのclickのバージョンを8系に上げたところ, windowsで使っていたスクリプトのいくつかが壊れた
原因としては表題の新機能だが, あまり情報がない(公式ドキュメントには Upgrading to 8.0
の記事がないんだよな...)のでマイグレーション方法をここに残しておく
ちなみに以下のissueで議論されていたようだ
何が起きるか?
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_scripts
や poetry
で pyproject.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_scripts
や poetry
で pyproject.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()
サブコマンド利用時に無効化する
サブコマンドでも main
に windows_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