Comparação de bibliotecas CLI populares para Python: click, cement, fire e outras



O Python é uma ótima linguagem para aplicativos de console e destaca um grande número de bibliotecas para essas tarefas. Mas quais bibliotecas existem? E qual é o melhor para levar? Este material compara ferramentas populares e não muito para o mundo dos consoles e tenta responder à segunda pergunta.

Para facilitar a leitura, a revisão é dividida em duas postagens: a primeira compara as seis bibliotecas mais populares, a segunda - a menos popular e mais específica, mas ainda digna de atenção.

Em cada exemplo, um utilitário de console para a biblioteca todolib será gravado no Python 3.7, com o qual você pode criar, exibir, marcar e excluir tarefas. O restante será adicionado, sujeito à simplicidade da implementação em uma estrutura específica. As próprias tarefas são armazenadas em um arquivo json, que será salvo em uma chamada separada - uma condição adicional para os exemplos.
Além disso, um teste trivial será gravado para cada implementação. O Pytest com os seguintes equipamentos foi usado como uma estrutura de teste:

@pytest.fixture(autouse=True) def db(monkeypatch): """ monkeypatch     ,        """ value = {"tasks": []} monkeypatch.setattr(todolib.TodoApp, "get_db", lambda _: value) return value @pytest.yield_fixture(autouse=True) def check(db): """      """ yield assert db["tasks"] and db["tasks"][0]["title"] == "test" # ,      EXPECTED = "Task 'test' created with number 1.\n" 

Em princípio, todas as opções acima serão suficientes para demonstrar as bibliotecas. O código fonte completo está disponível neste repositório.

argparse


Argparse tem uma vantagem inegável - está na biblioteca padrão e sua API não é difícil de aprender: existe um analisador, há argumentos, os argumentos têm tipo , ação , dest , padrão e ajuda . E há subparser - a capacidade de separar parte dos argumentos e da lógica em comandos separados.

Analisador


À primeira vista - nada de anormal, o analisador é como um analisador. Mas - na minha opinião - a legibilidade não é a melhor quando comparada com outras bibliotecas, porque argumentos para diferentes comandos são descritos em um só lugar.

código fonte
 def get_parser(): parser = argparse.ArgumentParser("Todo notes - argparse version") parser.add_argument( "--verbose", "-v", action="store_true", help="Enable verbose mode" ) parser.add_argument("--version", "-V", action="store_true", help="Show version") subparsers = parser.add_subparsers(title="Commands", dest="cmd") add = subparsers.add_parser("add", help="Add new task") add.add_argument("title", help="Todo title") show = subparsers.add_parser("show", help="Show tasks") show.add_argument( "--show-done", action="store_true", help="Include done tasks in the output" ) done = subparsers.add_parser("done", help="Mark task as done") done.add_argument("number", type=int, help="Task number") remove = subparsers.add_parser("remove", help="Remove task") remove.add_argument("number", type=int, help="Task number") return parser 


principal


E aqui a mesma coisa - o analisador, exceto os argumentos de análise, não pode fazer mais nada; portanto, a lógica terá que ser escrita independentemente e em um só lugar. Por um lado - é possível viver, por outro - é possível melhor, mas ainda não está claro como.

UPD: Como foldr observou, de fato, os subparsers podem definir funções via set_defaults (func = foo), ou seja, argparse permite reduzir o tamanho principal para o tamanho pequeno. Viva e aprenda.

código fonte
 def main(raw_args=None): """ Argparse example entrypoint """ parser = get_parser() args = parser.parse_args(raw_args) logging.basicConfig() if args.verbose: logging.getLogger("todolib").setLevel(logging.INFO) if args.version: print(lib_version) exit(0) cmd = args.cmd if not cmd: parser.print_help() exit(1) with TodoApp.fromenv() as app: if cmd == "add": task = app.add_task(args.title) print(task, "created with number", task.number, end=".\n") elif cmd == "show": app.print_tasks(args.show_done) elif cmd == "done": task = app.task_done(args.number) print(task, "marked as done.") elif cmd == "remove": task = app.remove_task(args.number) print(task, "removed from list.") 


Teste


Para verificar a saída do utilitário, é usado o acessório capsys , que fornece acesso ao texto de stdout e stderr.

 def test_argparse(capsys): todo_argparse.main(["add", "test"]) out, _ = capsys.readouterr() assert out == EXPECTED 

Sumário


Das vantagens - um bom conjunto de recursos para análise, a presença de um módulo na biblioteca padrão.

Em contrapartida, a argparse está envolvida apenas na análise de argumentos, a maior parte da lógica principal teve que ser escrita por mim. E não está claro como testar o código de saída nos testes.

docopt


O docopt é um analisador pequeno (<600 linhas, comparado a 2500 com argparse), que fará você sorrir, citando uma descrição no GitHub. A idéia principal do docopt é descrever a interface literalmente com texto, por exemplo, na sequência de documentos.

No mesmo github, docopt> 6700 estrelas, é usado em pelo menos 22 mil outros projetos. E isso é apenas com a implementação do python! A página do projeto docopt possui muitas opções para diferentes linguagens, de C e PHP a CoffeeScript e até R. Essa plataforma cruzada só pode ser explicada pela compacidade e simplicidade do código.

Analisador


Comparado ao argparse, esse analisador é um grande passo à frente.

 """Todo notes on docopt. Usage: todo_docopt [-v | -vv ] add <task> todo_docopt [-v | -vv ] show --show-done todo_docopt [-v | -vv ] done <number> todo_docopt [-v | -vv ] remove <number> todo_docopt -h | --help todo_docopt --version Options: -h --help Show help. -v --verbose Enable verbose mode. """ 

principal


Em geral, tudo é o mesmo que com argparse, mas agora detalhado pode ter vários valores (0-2), e o acesso aos argumentos é diferente: docopt não retorna um espaço para nome com atributos, mas apenas um dicionário, onde a escolha de um comando é indicada através de seu booleano, como visto se :

código fonte
 def main(argv=None): args = docopt(__doc__, argv=argv, version=lib_version) log.setLevel(levels[args["--verbose"]]) logging.basicConfig() log.debug("Arguments: %s", args) with TodoApp.fromenv() as app: if args["add"]: task = app.add_task(args["<task>"]) print(task, "created with number", task.number, end=".\n") elif args["show"]: app.print_tasks(args["--show-done"]) elif args["done"]: task = app.task_done(args["<number>"]) print(task, "marked as done.") elif args["remove"]: task = app.remove_task(args["<number>"]) print(task, "removed from list.") 


Teste


Semelhante ao teste argparse:
 def test_docopt(capsys): todo_docopt.main(["add", "test"]) out, _ = capsys.readouterr() assert out == EXPECTED 

Sumário


Dos benefícios - muito menos código para o analisador, facilidade de descrição e leitura de comandos e argumentos, versão embutida.

Contras, em primeiro lugar, o mesmo que argparse - muita lógica em main , você não pode testar o código de saída. Além disso, a versão atual (0.6.2) do docopt ainda não é estável e provavelmente nunca será - o projeto foi desenvolvido ativamente de 2012 até o final de 2013, o último commit foi em 17 de dezembro. E a coisa mais desagradável no momento é que alguns docopt regulares provocam DeprecationWarning ao executar testes.

Clique em


O Click é fundamentalmente diferente de argparse e docopt pelo número de recursos e pela abordagem para descrever comandos e parâmetros por meio de decoradores, e propõe-se que a própria lógica seja separada em funções separadas, em vez de uma grande. Os autores afirmam que o Click possui muitas configurações, mas os parâmetros padrão devem ser suficientes. Entre os recursos, os comandos aninhados e seu carregamento lento são enfatizados.

O projeto é extremamente popular: além de ter mais de 8100 estrelas e usá-lo em pelo menos 174 mil projetos (!), Ele ainda está em desenvolvimento: a versão 7.0 foi lançada no outono de 2018 e novas solicitações de confirmação e mesclagem aparecem até hoje. dia

Analisador


Na página de documentação, encontrei o decorador de confirmação_opção , que solicita confirmação do usuário antes de executar o comando. Para demonstrá-lo, o comando wipe foi adicionado, o que limpa toda a lista de tarefas.

código fonte
 levels = [logging.WARN, logging.INFO, logging.DEBUG] pass_app = click.make_pass_decorator(TodoApp) @click.group() @click.version_option(lib_version, prog_name="todo_click") @click.option("-v", "--verbose", count=True) # click   ,     @click.option("--db", help="Path to the database file") @click.pass_context def cli(ctx, verbose, db): """Todo notes - click version.""" level = levels[min(verbose, 2)] logging.basicConfig(level=level) logging.getLogger("todolib").setLevel(level) ctx.obj = TodoApp.fromenv(db) atexit.register(ctx.obj.save) @cli.command() @click.argument("task") @pass_app def add(app, task): """ Add new task. """ task = app.add_task(task) click.echo(f"{task} created with number {task.number}.") @cli.command() @click.option("--show-done", is_flag=True, help="Include done tasks") @pass_app def show(app, show_done): """ Show current tasks. """ app.print_tasks(show_done) @cli.command() @click.argument("number", type=int) @pass_app def done(app, number): """ Mark task as done. """ task = app.task_done(number) click.echo(f"{task} marked as done.") @cli.command() @click.argument("number", type=int) @pass_app def remove(app, number): """ Remove task from the list. """ task = app.remove_task(number) click.echo(f"{task} removed from the list.") @cli.command() @click.confirmation_option(prompt="Are you sure you want to remove database") @pass_app def wipe(app): for task in app.list_tasks(): task.remove() 


principal


E aqui encontramos a principal vantagem do Click - devido ao fato de a lógica dos comandos ser separada por suas funções, quase nada permanece principal. Também é demonstrada aqui a capacidade da biblioteca de receber argumentos e parâmetros de variáveis ​​de ambiente.

 if __name__ == "__main__": cli(auto_envvar_prefix="TODO") 

Teste


No caso do Click, não há necessidade de interceptar o sys.stdout, pois existe um módulo de teste de click.t com um corredor para essas coisas. E o CliRunner não apenas intercepta a saída, mas também permite verificar o código de saída, o que também é legal. Tudo isso permite testar os utilitários de clique sem usar o pytest e ignorar o módulo unittest padrão.

 import click.testing def test_click(): runner = click.testing.CliRunner() result = runner.invoke(todo_click.cli, ["add", "test"]) assert result.exit_code == 0 assert result.output == EXPECTED 

Sumário


Esta é apenas uma pequena parte do que o Click pode fazer. Do restante da API - validação de valores, integração com o terminal (cores, pager a la less, barra de progresso etc.), retorno de chamada de resultado, preenchimento automático e muito mais. Você pode ver os exemplos aqui .

Prós: muitas ferramentas para qualquer ocasião, uma abordagem original, mas ao mesmo tempo conveniente para descrever equipes, facilidade de teste e vida ativa do projeto.

Contras: Quais são as desvantagens de um "clique" - essa é uma pergunta difícil. Talvez ele não saiba algo do que as seguintes bibliotecas são capazes?

Fogo


O Fire não é apenas uma biblioteca jovem (publicada em 2017) para interfaces de console do Google, é uma biblioteca para gerar interfaces de console de, entre aspas, absolutamente qualquer objeto Python.
Entre outras coisas, afirma-se que o fire ajuda no desenvolvimento e na depuração de código, ajuda a adaptar o código existente na CLI, facilita a transição do bash para o Python e tem seu próprio REPL para trabalho interativo. Vamos ver?

Analisador e principal


O fire.Fire é realmente capaz de aceitar qualquer objeto: um módulo, uma instância de classe, um dicionário com nomes de comandos e funções correspondentes, e assim por diante.

O que é importante para nós é que o Fire permite a transferência de um objeto de classe. Assim, o construtor da classe aceita argumentos comuns a todos os comandos, e seus métodos e atributos são comandos separados. Vamos usar isso:

código fonte
 class Commands: def __init__(self, db=None, verbose=False): level = logging.INFO if verbose else logging.WARNING logging.basicConfig(level=level) logging.getLogger("todolib").setLevel(level) self._app = todolib.TodoApp.fromenv(db) atexit.register(self._app.save) def version(self): return todolib.__version__ def add(self, task): """Add new task.""" task = self._app.add_task(task) print(task, "created with number", task.number, end=".\n") def show(self, show_done=False): """ Show current tasks. """ self._app.print_tasks(show_done) def done(self, number): """ Mark task as done. """ task = self._app.task_done(number) print(task, "marked as done.") def remove(self, number): """ Removes task from the list. """ task = self._app.remove_task(number) print(task, "removed from the list.") def main(args=None): fire.Fire(Commands, command=args) 


Sinalizadores embutidos


O Fire possui suas próprias bandeiras com uma sintaxe especial (elas devem ser passadas após o "-"), que permitem que você olhe sob o capô do analisador e do aplicativo como um todo:

exemplos de chamadas
 $ ./todo_fire.py show -- --trace Fire trace: 1. Initial component 2. Instantiated class "Commands" (todo_fire.py:9) 3. Accessed property "show" (todo_fire.py:25) $ ./todo_fire.py -- --verbose | head -n 12 #    , ,  Commands._app NAME todo_fire.py - SYNOPSIS todo_fire.py - GROUP | COMMAND GROUPS GROUP is one of the following: _app Todo Application definition. $ ./todo_fire.py show -- --interactive Fire is starting a Python REPL with the following objects: Modules: atexit, fire, logging, todolib Objects: Commands, args, component, main, result, self, todo_fire.py, trace Python 3.7.4 (default, Aug 15 2019, 13:09:37) [GCC 7.4.0] on linux Type "help", "copyright", "credits" or "license" for more information. (InteractiveConsole) >>> self <__main__.Commands object at 0x7fd0a6125bd0> >>> self._app.db {'tasks': [{'title': 'test', 'done': False}]} 


Teste


Testar a função principal é semelhante a testar argparse e docopt, então não vejo o ponto aqui.

Ao mesmo tempo, vale a pena notar que, devido à natureza introspectiva do fogo, é igualmente possível testar a classe Commands imediatamente.

Sumário


O fogo é uma ferramenta não menos interessante que o clique. Não é necessário listar muitas opções no analisador, a configuração é mínima, há opções para depuração e a própria biblioteca vive e se desenvolve ainda mais ativamente do que o clique (60 confirma neste verão).

Contras: pode ser significativamente menor que o clique e outros analisadores; API instável (a versão atual é 0.2.1).

Cimento


De fato, Cement não é exatamente uma biblioteca CLI, mas uma estrutura para aplicativos de console, mas argumenta-se que é adequado para scripts e aplicativos complexos com várias integrações.

Analisador


O analisador em Cement parece incomum, mas se você observar atentamente os parâmetros, é fácil adivinhar que o argumento familiar está sob o capô. Mas talvez isso seja o melhor - não há necessidade de aprender novos parâmetros.

código fonte
 from cement import Controller, ex class Base(Controller): class Meta: label = "base" arguments = [ ( ["-v", "--version"], {"action": "version", "version": f"todo_cement v{todolib.__version__}"}, ) ] def _default(self): """Default action if no sub-command is passed.""" self.app.args.print_help() @ex(help="Add new task", arguments=[(["task"], {"help": "Task title"})]) def add(self): title = self.app.pargs.task self.app.log.debug(f"Task title: {title!r}") task = self.app.todoobj.add_task(title) print(task, "created with number", task.number, end=".\n") @ex( help="Show current tasks", arguments=[ (["--show-done"], dict(action="store_true", help="Include done tasks")) ], ) def show(self): self.app.todoobj.print_tasks(self.app.pargs.show_done) @ex(help="Mark task as done", arguments=[(["number"], {"type": int})]) def done(self): task = self.app.todoobj.task_done(self.app.pargs.number) print(task, "marked as done.") @ex(help="Remove task from the list", arguments=[(["number"], {"type": int})]) def remove(self): task = self.app.todoobj.remove_task(self.app.pargs.number) print(task, "removed from the list.") 


App e principal


O cimento, entre outras coisas, ainda envolve sinais em exceções. Isso é demonstrado aqui na saída de código zero com SIGINT / SIGTERM.

código fonte
 class TodoApp(App): def __init__(self, argv=None): super().__init__(argv=argv) self.todoobj = None def load_db(self): self.todoobj = todolib.TodoApp.fromenv() def save(self): self.todoobj.save() class Meta: # application label label = "todo_cement" # register handlers handlers = [Base] hooks = [("post_setup", lambda app: app.load_db()), ("pre_close", lambda app: app.save())] # call sys.exit() on close close_on_exit = True def main(): with TodoApp() as app: try: app.run() except CaughtSignal as e: if e.signum not in (signal.SIGINT, signal.SIGTERM): raise app.log.debug(f"\n{e}") app.exit_code = 0 


Se você entender o main, poderá ver que o carregamento e o salvamento do todolib.TodoApp também podem ser feitos no __enter __ / __ exit__ substituído, mas essas fases acabaram sendo separadas em métodos separados para demonstrar ganchos de cimento.

Teste


Para teste, você pode usar a mesma classe de aplicativo:

 def test_cement(capsys): with todo_cement.TodoApp(argv=["add", "test"]) as app: app.run() out, _ = capsys.readouterr() assert out == EXPECTED #     jinja,       assert app.last_rendered is None 

Sumário


Prós: O conjunto de APIs é como um conjunto de facas suíças, extensibilidade através de ganchos e plugins, uma interface estável e desenvolvimento ativo.

Contras: Em locais com documentação vazia; pequenos scripts baseados em cimento podem parecer um pouco complicados.

Cleo


O Cleo está longe de ser uma estrutura tão popular quanto as outras listadas aqui (cerca de 400 estrelas no GitHub no total), e ainda assim consegui conhecê-la quando estudei como o formato Poetry foi formatado.

Portanto, o Cleo é um dos projetos do autor da já mencionada Poesia, uma ferramenta para gerenciar dependências, virtualenvs e compilações de aplicativos. Sobre a poesia em um habr já mais de uma vez escreveu, e sobre sua parte do console - não.

Analisador


Cleo, como Cimento, é construído sobre princípios de objetos, ou seja, Os comandos são definidos por meio da classe Command e sua documentação, os parâmetros são acessados ​​pelo método option () e assim por diante. Além disso, o método line (), usado para gerar texto, suporta estilos (ou seja, cores) e filtragem de saída com base no número de sinalizadores detalhados da caixa. Cleo também tem saída de tabela. E também barras de progresso. E ainda ... Em geral, veja:

código fonte
 from cleo import Command as BaseCommand # cleo    clikit,          from clikit.api.io import flags as verbosity class Command(BaseCommand): def __init__(self): super().__init__() self.todoapp = None def handle(self): with todolib.TodoApp.fromenv() as app: self.todoapp = app self.do_handle() def do_handle(self): raise NotImplementedError class AddCommand(Command): """ Add new task. add {task : Task to add} """ def do_handle(self): title = self.argument("task") task = self.todoapp.add_task(title) # will be printed only on "-vvv" self.line(f"Title: {title}", style="comment", verbosity=verbosity.DEBUG) self.line(f"Task <info>{task.title}</> created with number {task.number}.") class ShowCommand(Command): """ Show current tasks. show {--show-done : Include tasks that are done.} """ def do_handle(self): tasks = self.todoapp.list_tasks(self.option("show-done")) if not tasks: self.line("There is no TODOs.", style="info") self.render_table( ["Number", "Title", "Status"], [ [str(task.number), task.title, "" if task.done else "✘"] for task in tasks ], ) class DoneCommand(Command): """ Mark task as done. done {number : Task number} """ def do_handle(self): task = self.todoapp.task_done(int(self.argument("number"))) self.line(f"Task <info>{task.title}</> marked as done.") class RemoveCommand(Command): """ Removes task from the list. remove {number : Task number} """ def do_handle(self): task = self.todoapp.remove_task(int(self.argument("number"))) self.line(f"Task <info>{task.title}</> removed from the list.") 


principal


Tudo o que é necessário é criar um objeto cleo.Application e passar comandos para add_commands. Para não repetir durante o teste, tudo isso foi transferido do principal para o construtor:

 from cleo import Application as BaseApplication class TodoApp(BaseApplication): def __init__(self): super().__init__(name="ToDo app - cleo version", version=todolib.__version__) self.add_commands(AddCommand(), ShowCommand(), DoneCommand(), RemoveCommand()) def main(args=None): TodoApp().run(args=args) 

Teste


Para testar comandos no Cleo, existe o CommandTester , que, como todos os tios adultos da estrutura, intercepta E / S e código de saída:

 def test_cleo(): app = todo_cleo.TodoApp() command = app.find("add") tester = cleo.CommandTester(command) tester.execute("test") assert tester.status_code == 0 assert tester.io.fetch_output() == "Task test created with number 0.\n" 

Sumário


Prós: estrutura de objetos com dicas de tipo, o que simplifica o desenvolvimento (como muitos IDEs e editores têm bom suporte para código OOP e módulo de digitação); Uma boa quantidade de funcionalidade para trabalhar não apenas com argumentos, mas também com E / S.

Mais ou menos: seu parâmetro de verbosidade, compatível apenas com a E / S Cleo / CliKit. Embora você possa escrever um manipulador personalizado para o módulo de registro em log, pode ser difícil manter junto com o desenvolvimento do cleo.

Contras: obviamente - uma opinião pessoal - uma API jovem: o framework não possui outro usuário "grande", exceto Poetry, e o Cleo está desenvolvendo paralelamente ao desenvolvimento e às necessidades de um; Às vezes, a documentação está desatualizada (por exemplo, os níveis de log agora não estão no módulo clikit, mas em clikit.api.io.flags) e, em geral, é ruim e não reflete a API inteira.

Cleo, comparado com Cement, está mais focado na CLI e é o único que pensou em formatar (ocultar o rastreamento de pilha padrão) de exceções na saída padrão. Mas ele - novamente uma opinião pessoal - perde para Cement na juventude e na estabilidade da API.

Em conclusão


Neste ponto, todos já têm sua própria opinião, o que é melhor, mas a conclusão deve ser: Gostei mais do Click, porque há muitas coisas nele e é muito fácil desenvolver e testar aplicativos com ele. Se você tentar escrever código no mínimo - comece com o Fire. Seu script precisa acessar o Memcached, formatar com jinja e extensibilidade - use Cement e você não se arrependerá. Você tem um projeto para animais de estimação ou deseja tentar outra coisa - veja o cleo.

Source: https://habr.com/ru/post/pt466999/


All Articles