Comparação de bibliotecas menos populares e não muito CLI: cliff, plac, plumbum e outras (parte 2)

No ecossistema Python, existem muitos pacotes para aplicativos CLI, ambos populares, como Click, e não muito. Os mais comuns foram considerados em um artigo anterior , mas pouco conhecidos, mas não menos interessantes, serão mostrados aqui.



Como na primeira parte, um script de console para a biblioteca todolib será gravado para cada biblioteca no Python 3.7. Além disso, um teste trivial com esses equipamentos será escrito para cada implementação:

@pytest.fixture(autouse=True) def db(monkeypatch): """ monkeypatch         ,         """ value = {"tasks": []} monkeypatch.setattr(todolib.TodoApp, "save", lambda _: ...) 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" 

Todo o código-fonte está disponível neste repositório .

penhasco


Github
A documentação
Muitos já ouviram falar do OpenStack, uma plataforma de código aberto para IaaS. A maior parte é escrita em Python, incluindo utilitários de console que repetem a funcionalidade da CLI há muito tempo. Isso continuou até que o penhasco, ou a Estrutura de Formulação da Interface da Linha de Comando, apareceu como uma estrutura comum. Com ele, os desenvolvedores do Openstack combinaram pacotes como python-novaclient, python-swiftclient e python-keystoneclient em um programa openstack .

Equipas
A abordagem para declarar comandos se assemelha a cement e cleo: argparse como um analisador de parâmetros, e os próprios comandos são criados por meio da herança da classe Command. Ao mesmo tempo, existem pequenas extensões para a classe Command, como Lister, que formata os dados independentemente.

código fonte
 from cliff import command from cliff.lister import Lister class Command(command.Command): """Command with a parser shortcut.""" def get_parser(self, prog_name): parser = super().get_parser(prog_name) self.extend_parser(parser) return parser def extend_parser(self, parser): ... class Add(Command): """Add new task.""" def extend_parser(self, parser): parser.add_argument("title", help="Task title") def take_action(self, parsed_args): task = self.app.todoapp.add_task(parsed_args.title) print(task, "created with number", task.number, end=".\n") class Show(Lister, Command): """Show current tasks.""" def extend_parser(self, parser): parser.add_argument( "--show-done", action="store_true", help="Include done tasks" ) def take_action(self, parsed_args): tasks = self.app.todoapp.list_tasks(show_done=parsed_args.show_done) #     'there is no todos'   #      return ( ("Number", "Title", "Status"), [[task.number, task.title, "" if task.done else "✘"] for task in tasks], ) class Done(Command): """Mark task as done.""" def extend_parser(self, parser): parser.add_argument("number", type=int, help="Task number") def take_action(self, parsed_args): task = self.app.todoapp.task_done(number=parsed_args.number) print(task, "marked as done.") #   Done    class Remove(Done): """Remove task from the list.""" def take_action(self, parsed_args): task = self.app.todoapp.remove_task(number=parsed_args.number) print(task, "removed from the list.") 


Aplicação e principal

A classe do aplicativo possui os métodos initialize_app e clean_up ; no nosso caso, eles inicializam o aplicativo e salvam os dados.

código fonte
 from cliff import app from cliff.commandmanager import CommandManager from todolib import TodoApp, __version__ class App(app.App): def __init__(self): #   add_command, CommandManager  #    setuptools entrypoint manager = CommandManager("todo_cliff") manager.add_command("add", Add) manager.add_command("show", Show) manager.add_command("done", Done) manager.add_command("remove", Remove) super().__init__( description="Todo notes on cliff", version=__version__, command_manager=manager, deferred_help=True, ) self.todoapp = None def initialize_app(self, argv): self.todoapp = TodoApp.fromenv() def clean_up(self, cmd, result, err): self.todoapp.save() def main(args=sys.argv[1:]) -> int: app = App() return app.run(argv=args) 


Exemplos de trabalho

 igor$ ./todo_cliff.py add "sell the old laptop" Using database file /home/igor/.local/share/todoapp/db.json Task 'sell the old laptop' created with number 0. Saving database to a file /home/igor/.local/share/todoapp/db.json 

Sair da caixa! E se você olhar por baixo do capô , poderá ver que isso foi feito de uma maneira inteligente: informações no stdout e aviso / erro no stderr e, se necessário, são desativadas pelo sinalizador --quiet .

 igor$ ./todo_cliff.py -q show +--------+----------------------+--------+ | Number | Title | Status | +--------+----------------------+--------+ | 1 | sell the old laptop | ✘ | +--------+----------------------+--------+ 

Como já mencionado, o Lister formata os dados, mas a tabela não se limita a:

 igor$ ./todo_cliff.py -q show -f json --noindent [{"Number": 0, "Title": "sell old laptop", "Status": "\u2718"}] 

Além de json e table, yaml e csv estão disponíveis.

Há também um rastreamento oculto padrão:

 igor$ ./todo_cliff.py -q remove 3 No such task. 

Outra REPL disponível e pesquisa difusa, também conhecida como pesquisa difusa :

 igor$ ./todo_cliff.py -q (todo_cliff) help Shell commands (type help %topic%): =================================== alias exit history py quit shell unalias edit help load pyscript set shortcuts Application commands (type help %topic%): ========================================= add complete done help remove show (todo_cliff) whow todo_cliff: 'whow' is not a todo_cliff command. See 'todo_cliff --help'. Did you mean one of these? show 

Teste

É simples: um objeto App é criado e run () também é chamado, que retorna um código de saída.

 def test_cliff(capsys): app = todo_cliff.App() code = app.run(["add", "test"]) assert code == 0 out, _ = capsys.readouterr() assert out == EXPECTED 

Os prós e contras

Prós:

  • Várias comodidades prontas para uso;
  • Desenvolvido por OpenStack;
  • Modo interativo;
  • Extensibilidade através do ponto de entrada setuptools e CommandHook;
  • Plugin Sphinx para documentação automática para CLI;
  • Conclusão de comandos (somente bash);

Contras:

  • Uma pequena documentação, que consiste basicamente em um exemplo detalhado, mas único;

Outro bug foi notado: no caso de um erro ao ocultar o rastreamento da pilha, o código de saída é sempre zero.

Plac


Github
A documentação

À primeira vista, Plac parece algo como Fogo, mas na realidade é como Fogo, que esconde o mesmo argumento e muito mais sob o capô.

Plac segue, citando a documentação, "o antigo princípio do mundo da computação: os programas devem resolver casos comuns e o simples deve permanecer simples, e o complexo ao mesmo tempo possível ". O autor do framework usa o Python há mais de nove anos e o escreveu com a expectativa de resolver "99,9% das tarefas".

Comandos e principais

Atenção às anotações nos métodos show e done: é assim que o Plac analisa os parâmetros e a ajuda do argumento, respectivamente.

código fonte
 import plac import todolib class TodoInterface: commands = "add", "show", "done", "remove" def __init__(self): self.app = todolib.TodoApp.fromenv() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.app.save() def add(self, task): """ Add new task. """ task = self.app.add_task(title=task) print(task, "created with number", task.number, end=".\n") def show(self, show_done: plac.Annotation("Include done tasks", kind="flag")): """ Show current tasks. """ self.app.print_tasks(show_done=show_done) def done(self, number: "Task number"): """ Mark task as done. """ task = self.app.task_done(number=int(number)) print(task, "marked as done.") def remove(self, number: "Task number"): """ Remove task from the list. """ task = self.app.remove_task(number=int(number)) print(task, "removed from the list.") if __name__ == "__main__": plac.Interpreter.call(TodoInterface) 


Teste

Infelizmente, o teste do código de saída Plac não permite. Mas o teste em si é real:

 def test_plac(capsys): plac.Interpreter.call(todo_plac.TodoInterface, arglist=["add", "test"]) out, _ = capsys.readouterr() assert out == EXPECTED 

Os prós e contras

Prós:

  • Uso simples;
  • Modo interativo com suporte a linha de leitura;
  • API estável
  • Excelente documentação;

Mas a coisa mais interessante sobre o Plac está oculta no uso avançado :

  • Execução de vários comandos em threads e subprocessos;
  • Computação paralela;
  • servidor de telnet;

Contras:

  • Você não pode testar o código de saída;
  • Vida de projeto ruim.

Plumbum


Github
A documentação
Documentação CLI
O Plumbum, de fato, não é uma estrutura tão pouco conhecida - quase 2000 estrelas, e há algo a amar por isso, porque, grosso modo, implementa a sintaxe do UNIX Shell. Bem, com aditivos:

 >>> from plumbum import local >>> output = local["ls"]() >>> output.split("\n")[:3] ['console_examples.egg-info', '__pycache__', 'readme.md'] #  plumbum     cmd,      >>> from plumbum.cmd import rm, ls, grep, wc >>> rm["-r", "console_examples.egg-info"]() '' >>> chain = ls["-a"] | grep["-v", "\\.py"] | wc["-l"] >>> chain() '11\n' 

Comandos e principais

O Plumbum também possui ferramentas para a CLI, para não dizer que são apenas uma adição: existem nargs, comandos e cores:

código fonte
 from plumbum import cli, colors class App(cli.Application): """Todo notes on plumbum.""" VERSION = todolib.__version__ verbosity = cli.CountOf("-v", help="Increase verbosity") def main(self, *args): if args: print(colors.red | f"Unknown command: {args[0]!r}.") return 1 if not self.nested_command: # will be ``None`` if no sub-command follows print(colors.red | "No command given.") return 1 class Command(cli.Application): """Command with todoapp object""" def __init__(self, executable): super().__init__(executable) self.todoapp = todolib.TodoApp.fromenv() atexit.register(self.todoapp.save) def log_task(self, task, msg): print("Task", colors.green | task.title, msg, end=".\n") @App.subcommand("add") class Add(Command): """Add new task""" def main(self, task): task = self.todoapp.add_task(title=task) self.log_task(task, "added to the list") @App.subcommand("show") class Show(Command): """Show current tasks""" show_done = cli.Flag("--show-done", help="Include done tasks") def main(self): self.todoapp.print_tasks(self.show_done) @App.subcommand("done") class Done(Command): """Mark task as done""" def main(self, number: int): task = self.todoapp.task_done(number) self.log_task(task, "marked as done") @App.subcommand("remove") class Remove(Command): """Remove task from the list""" def main(self, number: int): task = self.todoapp.remove_task(number) self.log_task(task, "removed from the list.") if __name__ == '__main__': App.run() 


Teste

Os aplicativos de teste no Plumbum diferem dos outros, exceto que a necessidade de passar também o nome do aplicativo, ou seja, primeiro argumento:

 def test_plumbum(capsys): _, code = todo_plumbum.App.run(["todo_plumbum", "add", "test"], exit=False) assert code == 0 out, _ = capsys.readouterr() assert out == "Task test created with number 0.\n" 

Os prós e contras

Prós:

  • Excelente kit de ferramentas para trabalhar com equipes externas;
  • Suporte para estilos e cores;
  • API estável
  • Vida ativa do projeto;

Nenhuma falha foi notada.

cmd2


Github
A documentação

Primeiro, o cmd2 é uma extensão sobre o cmd da biblioteca padrão,
isto é Destina-se a aplicativos interativos. No entanto, ele está incluído na revisão, pois também pode ser configurado para o modo CLI normal.

Comandos e principais

O cmd2 requer conformidade com uma certa regra: os comandos devem começar com o prefixo do_ , mas, caso contrário, tudo ficará claro:

modo interativo
 import cmd2 import todolib class App(cmd2.Cmd): def __init__(self, **kwargs): super().__init__(**kwargs) self.todoapp = todolib.TodoApp.fromenv() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.todoapp.save() def do_add(self, title): """Add new task.""" task = self.todoapp.add_task(str(title)) self.poutput(f"{task} created with number {task.number}.") def do_show(self, show_done): """Show current tasks.""" self.todoapp.print_tasks(bool(show_done)) def do_done(self, number): """Mark task as done.""" task = self.todoapp.task_done(int(number)) self.poutput(f"{task} marked as done.") def do_remove(self, number): """Remove task from the list.""" task = self.todoapp.remove_task(int(number)) self.poutput(f"{task} removed from the list.") def main(**kwargs): with App(**kwargs) as app: app.cmdloop() if __name__ == '__main__': main() 


modo não interativo
O modo normal requer um pouco de movimento extra.

Por exemplo, você deve retornar ao argparse e escrever a lógica do caso quando o script for chamado sem parâmetros. E agora os comandos obtêm argparse.Namespace .

O analisador é retirado do exemplo argparse com pequenas adições - agora os sub-analisadores são atributos do ArgumentParser principal.

 import cmd2 from todo_argparse import get_parser parser = get_parser(progname="todo_cmd2_cli") class App(cmd2.Cmd): def __init__(self, **kwargs): super().__init__(**kwargs) self.todoapp = todolib.TodoApp.fromenv() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.todoapp.save() def do_add(self, args): """Add new task.""" task = self.todoapp.add_task(args.title) self.poutput(f"{task} created with number {task.number}.") def do_show(self, args): """Show current tasks.""" self.todoapp.print_tasks(args.show_done) def do_done(self, args): """Mark task as done.""" task = self.todoapp.task_done(args.number) self.poutput(f"{task} marked as done.") def do_remove(self, args): """Remove task from the list.""" task = self.todoapp.remove_task(args.number) self.poutput(f"{task} removed from the list.") parser.add.set_defaults(func=do_add) parser.show.set_defaults(func=do_show) parser.done.set_defaults(func=do_done) parser.remove.set_defaults(func=do_remove) @cmd2.with_argparser(parser) def do_base(self, args): func = getattr(args, "func", None) if func: func(self, args) else: print("No command provided.") print("Call with --help to get available commands.") def main(argv=None): with App() as app: app.do_base(argv or sys.argv[1:]) if __name__ == '__main__': main() 


Teste

Somente um script interativo será testado.

Como o teste de aplicativos interativos requer um grande número de recursos humanos e de tempo, os desenvolvedores do cmd2 tentaram resolver esse problema com a ajuda de transcrições - arquivos de texto com exemplos de entrada e saída esperada. Por exemplo:

 (Cmd) add test Task 'test' created with number 0. 

Assim, tudo o que é necessário é transferir a lista de arquivos com transcrições para o aplicativo :

 def test_cmd2(): todo_cmd2.main(transcript_files=["tests/transcript.txt"]) 

Os prós e contras

Prós:

  • Boa API para aplicativos interativos;
  • Desenvolvendo ativamente nos últimos anos;
  • Uma abordagem original para testes;
  • Boa documentação.

Contras:

  • Requer código adicional e argparse ao escrever aplicativos não interativos;
  • API instável;
  • Em alguns lugares, a documentação está vazia.

Bônus: Urwid


Github
A documentação
Tutorial
Exemplos de programas

Urwid é uma estrutura de um mundo um pouco diferente - de maldições e npyscreen, ou seja, da interface do usuário do console / terminal. No entanto, está incluído na revisão como um bônus, pois, na minha opinião, merece atenção.

Aplicativo e equipes

O Urwid possui um grande número de widgets, mas não possui conceitos como uma janela ou ferramentas simples para acessar widgets vizinhos. Portanto, se você deseja obter um resultado bonito, precisará de um design cuidadoso e / ou de outros pacotes, caso contrário, precisará transferir dados nos atributos dos botões, como aqui:

código fonte
 import urwid from urwid import Button import todolib class App(urwid.WidgetPlaceholder): max_box_levels = 4 def __init__(self): super().__init__(urwid.SolidFill()) self.todoapp = None self.box_level = 0 def __enter__(self): self.todoapp = todolib.TodoApp.fromenv() self.new_menu( "Todo notes on urwid", #       ,  #      Button("New task", on_press=add), Button("List tasks", on_press=list_tasks), ) return self def __exit__(self, exc_type, exc_val, exc_tb): self.todoapp.save() def new_menu(self, title, *items): self.new_box(menu(title, *items)) def new_box(self, widget): self.box_level += 1 # overlay      , #     LineBox    self.original_widget = urwid.Overlay( # LineBox  unicode-    self.original_widget, align="center", width=30, valign="middle", height=10, ) def popup(self, text): self.new_menu(text, Button("To menu", on_press=lambda _: self.pop(levels=2))) def keypress(self, size, key): if key != "esc": super().keypress(size, key=key) elif self.box_level > 0: self.pop() def pop(self, levels=1): for _ in range(levels): self.original_widget = self.original_widget[0] self.box_level -= levels if self.box_level == 0: raise urwid.ExitMainLoop() 


equipes
 app = App() def menu(title, *items) -> urwid.ListBox: body = [urwid.Text(title), urwid.Divider()] body.extend(items) return urwid.ListBox(urwid.SimpleFocusListWalker(body)) def add(button): edit = urwid.Edit("Title: ") def handle(button): text = edit.edit_text app.todoapp.add_task(text) app.popup("Task added") app.new_menu("New task", edit, Button("Add", on_press=handle)) def list_tasks(button): tasks = app.todoapp.list_tasks(show_done=True) buttons = [] for task in tasks: status = "done" if task.done else "not done" text = f"{task.title} [{status}]" #         button = Button(text, on_press=task_actions, user_data=task.number) buttons.append(button) app.new_menu("Task list", *buttons) def task_actions(button, number): def done(button, number): app.todoapp.task_done(number) app.popup("Task marked as done.") def remove(button, number): app.todoapp.remove_task(number) app.popup("Task removed from the list.") btn_done = Button("Mark as done", on_press=done, user_data=number) btn_remove = Button("Remove from the list", on_press=remove, user_data=number) app.new_menu("Actions", btn_done, btn_remove) 


principal
 if __name__ == "__main__": try: with app: urwid.MainLoop(app).run() except KeyboardInterrupt: pass 

Os prós e contras

Prós:

  • Ótima API para escrever diferentes aplicativos TUI;
  • Longa história de desenvolvimento (desde 2010) e API estável;
  • Arquitetura competente;
  • Boa documentação, existem exemplos.

Contras:

  • Como testar não é claro. Somente as chaves de envio tmux vêm à mente;
  • Erros não informativos quando os widgets não estão organizados corretamente.

* * *


Cliff é muito parecido com Cleo e Cement e geralmente é bom para grandes projetos.

Pessoalmente, não ousaria usar o Plac, mas recomendo a leitura do código fonte.

O Plumbum possui um conveniente kit de ferramentas CLI e uma API brilhante para executar outros comandos; portanto, se você estiver reescrevendo scripts de shell no Python, é isso que você precisa.
O cmd2 é adequado como base para aplicativos interativos e para aqueles que desejam migrar do cmd padrão.

O Urwid apresenta aplicativos de console bonitos e fáceis de usar.

Os seguintes pacotes não foram incluídos na revisão:

  • aioconsole - sem modo não interativo;
  • pyCLI - sem suporte para subcomandos;
  • Clint - sem suporte para subcomandos, repositório no arquivo morto;
  • linha de comando - é muito antiga (última versão em 2009) e desinteressante;
  • CLIArgs - antigo (última versão em 2010)
  • opterator - relativamente antigo (última versão em 2015)

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


All Articles