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.