 
      
        
        
        
      
    
      
        
        
        
      
      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) 
      
      
        
        
        
      
    
        
        
        
      
      
        
        
        
      
    
     
      
        
        
        
      
     
      
        
        
        
      
      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 
      
      
        
        
        
      
    
        
        
        
      
      
        
        
        
      
    
     
      
        
        
        
      
     
      
        
        
        
      
      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: 
      
      
        
        
        
      
    
        
        
        
      
      
        
        
        
      
    
     
      
        
        
        
      
     
      
        
        
        
      
      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 
      
      
        
        
        
      
    
        
        
        
      
      
        
        
        
      
    
     
      
        
        
        
      
      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 
      
      
        
        
        
      
    
        
        
        
      
      
        
        
        
      
    
     
      
        
        
        
      
     
      
        
        
        
      
      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.