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)
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):
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:
- Various amenities out of the box;
- Developed by OpenStack;
- Interactive mode;
- Extensibility through setuptools entrypoint and CommandHook;
- Sphinx plugin for auto documentation to CLI;
- Command completion (bash only);
Minuses:
- A small documentation, which basically consists of a detailed but single example;
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:
- Simple use;
- Interactive mode with readline support;
- Stable API
- Excellent documentation;
But the most interesting thing about Plac is hidden in
advanced usage :
- Execution of several commands in threads and subprocesses;
- Parallel computing;
- telnet server;
Minuses:
- You cannot test exit code;
- Poor project life.
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']
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:
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:
- Great toolkit for working with external teams;
- Support for styles and colors;
- Stable API
- Active life of the project;
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:
- Good API for interactive applications;
- Actively developing in recent years;
- An original approach to testing;
- Good documentation.
Minuses:
- Requires argparse and additional code when writing non-interactive applications;
- Unstable API;
- In some places the documentation is empty.
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",
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}]"
main
if __name__ == "__main__": try: with app: urwid.MainLoop(app).run() except KeyboardInterrupt: pass
Pros and cons
Pros:
- Great API for writing different TUI applications;
- Long development history (since 2010) and stable API;
- Competent architecture;
- Good documentation, there are examples.
Minuses:
- How to test is unclear. Only tmux send-keys comes to mind;
- Uninformative errors when widgets are not properly arranged.
* * *
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:
- aioconsole - no non-interactive mode;
- pyCLI - no support for subcommands;
- Clint - no support for subcommands, repository in the archive;
- commandline - itβs too old (last release in 2009) and uninteresting;
- CLIArgs - old (last release in 2010)
- opterator - relatively old (last release in 2015)