Comparison of popular CLI libraries for Python: click, cement, fire and others





Python is a great language for console applications, and it highlights a large number of libraries for these tasks. But what libraries do exist? And which is better to take? This material compares popular and not-so-very tools for the console world and attempts to answer the second question.



For ease of reading, the review is divided into two posts: the first compares the six most popular libraries, the second - the less popular and more specific, but still worthy of attention.



In each of the examples, a console utility for the todolib library will be written in Python 3.7, with which you can create, view, tag, and delete tasks. The rest will be added subject to the simplicity of implementation on a particular framework. The tasks themselves are stored in a json file, which will be saved in a separate call - an additional condition for the examples.

In addition to this, a trivial test will be written for each implementation. Pytest with the following fixtures was taken as a testing framework:



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





In principle, all of the above will be enough to demonstrate the libraries. Full source code is available in this repository.



argparse



Argparse has an undeniable advantage - it is in the standard library and its API is not difficult to learn: there is a parser, there are arguments, the arguments have type , action , dest , default and help . And there is subparser - the ability to separate part of the arguments and logic into separate commands.



Parser



At first glance - nothing unusual, a parser as a parser. But - in my opinion - readability is not the best when compared with other libraries, because arguments to different commands are described in one place.



source
 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
      
      







main



And here the same thing - the parser except for parsing arguments can do nothing more, so the logic will have to be written independently and in one place. On the one hand - it is possible to live, on the other - it is possible better, but it is not yet clear how.



UPD: As foldr noted, in fact, subparsers can set functions via set_defaults (func = foo), that is, argparse allows you to shorten main to small sizes. Live and learn.



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







Testing



To check the output of the utility, the capsys fixture is used , which gives access to text from stdout and stderr.



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





Total



Of the advantages - a good set of opportunities for parsing, the presence of a module in the standard library.



Cons - argparse only parses arguments, most of the logic in main had to be written by myself. And it is not clear how to test exit code in tests.



docopt



docopt is a small (<600 lines, compared to 2500 with argparse) parser, that will make you smile, quoting a description on GitHub. The main idea of ​​docopt is to describe the interface literally with text, for example, in docstring.



On the same github, docopt> 6700 stars, it is used in at least 22 thousand other projects. And this is only with the python implementation! The docopt project page has many options for different languages, from C and PHP to CoffeeScript and even R. Such a cross-platform can only be explained by the compactness and simplicity of the code.



Parser



Compared to argparse, this parser is a big step forward.



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





main



In general, everything is the same as with argparse, but now verbose can have several values ​​(0-2), and access to the arguments is different: docopt returns not a namespace with attributes, but just a dictionary, where the choice of a command is indicated through her boolean, as seen in if :



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







Testing



Similar to argparse testing:

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





Total



Of the advantages - much less code for the parser, ease of description and reading of commands and arguments, built-in version.



Cons, firstly, the same as argparse - a lot of logic in main , you can not test exit code. In addition, the current version (0.6.2) of docopt is not yet stable and is unlikely to ever be - the project was actively developing from 2012 to the end of 2013, the last commit was in December 17th. And the most unpleasant thing at the moment is that some docopt regulars provoke DeprecationWarning during the tests.



Click



Click is fundamentally different from argparse and docopt by the number of features and the approach to describing commands and parameters through decorators, and the logic itself is proposed to be separated into separate functions instead of a large main . The authors claim that Click has a lot of settings, but the standard parameters should be enough. Among the features, the nested commands and their lazy loading are emphasized.



The project is extremely popular: in addition to having> 8100 stars and using it in at least 174 thousand (!) Projects, it is still developing: version 7.0 was released in autumn 2018, and new commits and merge requests appear to this day day.



Parser



On the documentation page, I found the confirmation_option decorator, which asks for confirmation from the user before executing the command. To demonstrate it, the wipe command was added, which clears the entire list of tasks.



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







main



And here we encounter the main advantage of Click - due to the fact that the logic of the commands is separated by their functions, almost nothing remains in main. Also demonstrated here is the ability of the library to receive arguments and parameters from environment variables.



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





Testing



In the case of Click, there is no need to intercept sys.stdout, since there is a click.testing module with a runner for such things. And not only does CliRunner itself intercept the output, it also allows you to check the exit code, which is also cool. All this allows you to test click utilities without using pytest and get by with the standard unittest module.



 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
      
      





Total



This is just a small part of what Click can do. From the rest of the API - validation of values, integration with the terminal (colors, pager a la less, progress bar, etc.), result callback, auto-completion and much more. You can see their examples here .



Pros: a lot of tools for every occasion, an original, but at the same time convenient approach to describing teams, ease of testing and active project life.



Cons: What are the disadvantages of a “click” - this is a difficult question. Maybe he doesn’t know something from what the following libraries are capable of?



Fire



Fire is not just a young (appeared in 2017) library for console interfaces from Google, it is a library for generating console interfaces from, verbatim quoting, absolutely any Python object .

Among other things, it is stated that fire helps in the development and debugging of code, helps to adapt existing code in the CLI, facilitates the transition from bash to Python, and has its own REPL for interactive work. We'll see?



Parser and main



fire.Fire is really capable of accepting any object: a module, a class instance, a dictionary with command names and corresponding functions, and so on.



What is important to us is that Fire allows the transfer of a class object. Thus, the class constructor accepts arguments common to all commands, and its methods and attributes are separate commands. We will use this:



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







Inline flags



Fire has its own flags with a special syntax (they must be passed after the "-"), which allow you to look under the hood of the parser and the application as a whole:



call examples
 $ ./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}]}
      
      







Testing



Testing the main function is similar to testing argparse and docopt, so I don’t see the point here.



At the same time, it is worth noting that, due to the introspective nature of Fire, it is equally possible to test the Commands class immediately.



Total



Fire is a tool no less interesting than click. It does not require listing many options in the parser, the configuration is minimal, there are options for debugging, and the library itself lives and develops even more actively than click (60 commits this summer).



Cons: can do significantly less than click and other parsers; unstable API (current version is 0.2.1).



Cement



In fact, Cement is not exactly a CLI library, but a framework for console applications, but it is argued that it is suitable for scripts and complex applications with various integrations.



Parser



The parser in Cement looks unusual, but if you look closely at the parameters, then it is easy to guess that the familiar argparse is under the hood. But maybe this is for the best - no need to learn new parameters.



source
 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 and main



Cement, among other things, still wraps signals in exceptions. This is demonstrated here at the zero-code output with SIGINT / SIGTERM.



source
 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
      
      







If you read the main one, you can see that loading and saving todolib.TodoApp can also be done in the overridden __enter __ / __ exit__, but these phases were eventually separated into separate methods in order to demonstrate Cement hooks.



Testing



For testing, you can use the same application class:



 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
      
      





Summary



Pros: The set of APIs is like a set of Swiss knives, extensibility through hooks and plugins, a stable interface and active development.



Cons: In places empty documentation; small Cement-based scripts may seem a little complicated.



Cleo



Cleo is far from being as popular as the others listed here (about 400 stars on GitHub in total), and yet I managed to get to know it when I studied how Poetry formats output.



So, Cleo is one of the projects of the author of the already mentioned Poetry, a tool for managing dependencies, virtualenvs and application builds. About Poetry on a habr already more than once wrote, and about its console part - no.



Parser



Cleo, like Cement, is built on object principles, i.e. commands are defined through the Command class and its docstring, parameters are accessed through the option () method, and so on. In addition, the line () method, which is used to output text, supports styles (i.e. colors) and output filtering based on the number of verbose flags out of the box. Cleo also has table output. And also progress bars. And yet ... In general, see:



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







main



All that is needed is to create a cleo.Application object and then pass commands to add_commands to it. In order not to repeat during testing, all this was transferred from main to the constructor:



 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)
      
      





Testing



To test commands in Cleo, there is CommandTester , which, like all adult uncles of the framework, intercepts I / O and exit code:



 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"
      
      





Total



Pros: object structure with type hints, which simplifies development (as many IDEs and editors have good support for OOP code and typing module); a good amount of functionality for working not only with arguments, but also I / O.



Plus or minus: its own verbosity parameter, which is compatible only with I / O Cleo / CliKit. Although you can write a custom handler for the logging module, it can be difficult to maintain along with the development of cleo.



Cons: obviously - a personal opinion - a young API: the framework lacks another "large" user, except for Poetry, and Cleo develops in parallel with the development and for the needs of one; sometimes the documentation is outdated (for example, the logging levels now lie not in the clikit module, but in clikit.api.io.flags), and in general it is poor and does not reflect the entire API.



Cleo, compared to Cement, is more focused on the CLI, and he is the only one who has thought about formatting (hiding the default stack trace) of exceptions in the default output. But he - again a personal opinion - loses to Cement in his youth and stability of the API.



Finally



At this point, everyone already has their own opinion, which is better, but the conclusion should be: I liked Click most of all, because it has a lot of things and at the same time it’s easy to develop and test applications with it. If you try to write code to a minimum - start with Fire. Your script needs access to Memcached, formatting with jinja and extensibility - take Cement and you will not regret it. Do you have a pet project or want to try something else - look at cleo.



All Articles