Comparison of less popular and not very CLI libraries: cliff, plac, plumbum and others (part 2)

In the Python ecosystem, there are many packages for CLI applications, both popular ones, like Click, and not so much. The most common ones were considered in a previous article , but little-known, but no less interesting, will be shown here.







As in the first part, a console script for the todolib library will be written for each library in Python 3.7. In addition to this, a trivial test with these fixtures will be written for each implementation:



@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"
      
      





All source code is available in this repository .



cliff



Github

Documentation

Many have heard about OpenStack, an open source platform for IaaS. Most of it is written in Python, including console utilities that have been repeating CLI functionality for a long time. This continued until cliff, or Command Line Interface Formulation Framework, appeared as a common framework. With it, Openstack developers combined packages like python-novaclient, python-swiftclient and python-keystoneclient into one openstack program.



Teams

The approach to declaring commands resembles cement and cleo: argparse as a parameter parser, and the commands themselves are created through the inheritance of the Command class. However, there are small extensions to the Command class, such as Lister, which independently formats the data.



source
 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.")
      
      







Application and main



The application class has initialize_app and clean_up methods , in our case, they initialize the application and save the data.



source
 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)
      
      







Work examples



 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
      
      





Logging out of the box! And if you look under the hood , you can see that it was done in a clever way: info in stdout, and warning / error in stderr, and, if necessary, is disabled by the --quiet flag.



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





As already mentioned, Lister formats the data, but the table is not limited to:



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





In addition to json and table, yaml and csv are available.



There is also a default hidden trace:



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





More available REPL and fuzzy search aka fuzzy search :



 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
      
      





Testing



It's simple: an App object is created and run () is also called, which returns an exit code.



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





Pros and cons



Pros:





Minuses:





Another bug was noticed: in case of an error when hiding the stack trace, exit code is always zero.



Plac



Github

Documentation



At first glance, Plac seems to be something like Fire, but in reality it is like Fire, which hides the same argparse and much more under the hood.



Plac follows, citing the documentation, "the ancient principle of the computer world: Programs should simply solve ordinary cases and the simple should remain simple, and the complex at the same time achievable ." The author of the framework has been using Python for more than nine years and wrote it with the expectation of solving β€œ99.9% of tasks”.



Commands and main



Attention to annotations in the show and done methods: this is how Plac parses the parameters and argument help, respectively.



source
 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)
      
      







Testing



Unfortunately, testing exit code Plac does not allow. But testing itself is real:



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





Pros and cons



Pros:





But the most interesting thing about Plac is hidden in advanced usage :





Minuses:





Plumbum



Github

Documentation

CLI Documentation

Plumbum, in fact, is not such a little-known framework - almost 2000 stars, and there is something to love for it, because, roughly speaking, it implements the UNIX Shell syntax. Well, with additives:



 >>> 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'
      
      





Commands and main



Plumbum also has tools for the CLI, and not to say that they are just an addition: there are nargs, commands, and colors:



source
 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()
      
      







Testing



Testing applications on Plumbum differs from others, except that the need to pass also the name of the application, i.e. first argument:



 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"
      
      





Pros and cons



Pros:





No flaws were noticed.



cmd2



Github

Documentation



First of all, cmd2 is an extension over cmd from the standard library,

those. It is intended for interactive applications. Nevertheless, it is included in the review, since it can also be configured for normal CLI mode.



Commands and main



cmd2 requires a certain rule: the commands must start with the do_ prefix, but otherwise everything is clear:



interactive mode
 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()
      
      







non-interactive mode
Normal mode requires a little extra movement.



For example, you have to go back to argparse and write the logic for the case when the script is called without parameters. And now the commands get argparse.Namespace .



The parser is taken from the argparse example with minor additions - now sub-parsers are attributes of the main ArgumentParser .



 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()
      
      







Testing



Only an interactive script will be tested.



Since testing interactive applications requires a large number of human and time resources, cmd2 developers tried to solve this problem with the help of transcriptions - text files with examples of input and expected output. For example:



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





Thus, all that is required is to transfer the list of files with transcriptions to the App :



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





Pros and cons



Pros:





Minuses:





Bonus: Urwid



Github

Documentation

Tutorial

Program Examples



Urwid is a framework from a slightly different world - from curses and npyscreen, that is, from the Console / Terminal UI. Nevertheless, it is included in the review as a bonus, since, in my opinion, it deserves attention.



App and teams



Urwid has a large number of widgets, but it does not have concepts like a window or simple tools for accessing neighboring widgets. Thus, if you want to get a beautiful result, you will need thoughtful design and / or use of other packages, otherwise you will have to transfer data in the attributes of the buttons, as here:



source
 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()
      
      







teams
 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)
      
      







main

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





Pros and cons



Pros:





Minuses:





* * *



Cliff is a lot like Cleo and Cement and is generally good for large projects.



I personally would not dare to use Plac, but I recommend reading the source code.



Plumbum has a convenient CLI toolkit and a brilliant API for executing other commands, so if you are rewriting shell scripts in Python, then this is what you need.

cmd2 works well as a basis for interactive applications and for those who want to migrate from the standard cmd.



And Urwid features beautiful and user-friendly console applications.



The following packages were not included in the review:






All Articles