Comparaison des bibliothèques CLI populaires pour Python: clic, ciment, feu et autres



Python est un excellent langage pour les applications console, et il met en évidence un grand nombre de bibliothèques pour ces tâches. Mais quelles bibliothèques existent? Et quoi de mieux à prendre? Ce matériel compare les outils populaires et peu utilisés pour le monde des consoles et tente de répondre à la deuxième question.

Pour faciliter la lecture, la revue est divisée en deux articles: le premier compare les six bibliothèques les plus populaires, le second - moins populaire et plus spécifique, mais mérite tout de même l'attention.

Dans chacun des exemples, un utilitaire de console pour la bibliothèque todolib sera écrit en Python 3.7, avec lequel vous pouvez créer, afficher, baliser et supprimer des tâches. Le reste sera ajouté sous réserve de la simplicité de mise en œuvre sur un cadre particulier. Les tâches elles-mêmes sont stockées dans un fichier json, qui sera enregistré dans un appel distinct - une condition supplémentaire pour les exemples.
En plus de cela, un test trivial sera écrit pour chaque implémentation. Pytest avec les appareils suivants a été utilisé comme cadre de test:

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

En principe, tout ce qui précède sera suffisant pour démontrer les bibliothèques. Le code source complet est disponible dans ce référentiel.

argparse


Argparse a un avantage indéniable - il est dans la bibliothèque standard et son API n'est pas difficile à apprendre: il y a un analyseur, il y a des arguments, les arguments ont type , action , dest , default et help . Et il y a un sous- analyseur - la possibilité de séparer une partie des arguments et de la logique en commandes distinctes.

Analyseur


À première vue - rien d'inhabituel, l'analyseur est comme un analyseur. Mais - à mon avis - la lisibilité n'est pas la meilleure par rapport à d'autres bibliothèques, car les arguments des différentes commandes sont décrits au même endroit.

code 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 


principal


Et ici, la même chose - l'analyseur, à l'exception des arguments d'analyse, ne peut rien faire de plus, de sorte que la logique devra être écrite indépendamment et en un seul endroit. D'une part - il est possible de vivre, d'autre part - c'est possible mieux, mais on ne sait pas encore comment.

UPD: Comme l' a noté foldr , en fait, les sous-analyseurs peuvent définir des fonctions via set_defaults (func = foo), c'est-à-dire, argparse vous permet de raccourcir le principal en petites tailles. Vivez et apprenez.

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


Test


Pour vérifier la sortie de l'utilitaire, le luminaire capsys est utilisé , ce qui donne accès au texte de stdout et stderr.

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

Résumé


Des avantages - un bon ensemble de fonctionnalités pour l'analyse, la présence d'un module dans la bibliothèque standard.

Contre - argparse ne traite que des arguments d'analyse, la plupart de la logique principale a dû être écrite par moi-même. Et on ne sait pas comment tester le code de sortie dans les tests.

docopt


docopt est un petit analyseur (<600 lignes, comparé à 2500 avec argparse), qui vous fera sourire, citant une description sur GitHub. L'idée principale de docopt est de décrire l'interface littéralement avec du texte, par exemple, dans docstring.

Sur le même github, docopt> 6700 étoiles, il est utilisé dans au moins 22 mille autres projets. Et ce n'est qu'avec l'implémentation de python! La page du projet docopt propose de nombreuses options pour différents langages, du C et PHP au CoffeeScript et même au R. Une telle multiplateforme ne peut être expliquée que par la compacité et la simplicité du code.

Analyseur


Comparé à argparse, cet analyseur est un grand pas en avant.

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

principal


En général, tout est identique à argparse, mais maintenant verbeux peut avoir plusieurs valeurs (0-2), et l'accès aux arguments est différent: docopt ne renvoie pas un espace de noms avec des attributs, mais juste un dictionnaire, où le choix d'une commande est indiqué à travers son booléen, comme on le voit si :

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


Test


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

Résumé


Des avantages - beaucoup moins de code pour l'analyseur, facilité de description et de lecture des commandes et des arguments, version intégrée.

Inconvénients, tout d'abord, la même chose que argparse - beaucoup de logique en principal , vous ne pouvez pas tester le code de sortie. De plus, la version actuelle (0.6.2) de docopt n'est pas encore stable et ne le sera probablement jamais - le projet se développait activement de 2012 à fin 2013, le dernier commit était le 17 décembre. Et la chose la plus désagréable pour le moment est que certains habitués de Docopt provoquent DeprecationWarning lors des tests.

Cliquez sur


Click est fondamentalement différent de argparse et docopt par le nombre de fonctionnalités et l'approche de description des commandes et des paramètres par le biais de décorateurs, et la logique elle-même est proposée pour être séparée en fonctions distinctes au lieu d'un grand principal . Les auteurs affirment que Click a beaucoup de paramètres, mais les paramètres standard devraient être suffisants. Parmi les fonctionnalités, les commandes imbriquées et leur chargement paresseux sont mis en évidence.

Le projet est extrêmement populaire: en plus d'avoir> 8100 étoiles et de l'utiliser dans au moins 174 mille (!) Projets, il est toujours en développement: la version 7.0 est sortie à l'automne 2018, et de nouvelles requêtes de validation et de fusion apparaissent à ce jour. jour.

Analyseur


Sur la page de documentation, j'ai trouvé le décorateur confirmation_option , qui demande la confirmation de l'utilisateur avant d'exécuter la commande. Pour le démontrer, la commande wipe a été ajoutée, ce qui efface toute la liste des tâches.

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


principal


Et ici, nous rencontrons le principal avantage de Click - du fait que la logique des commandes est espacée en fonction de leurs fonctions, presque rien ne reste en principal. La capacité de la bibliothèque à recevoir des arguments et des paramètres de variables d'environnement est également illustrée ici.

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

Test


Dans le cas de Click, il n'est pas nécessaire d'intercepter sys.stdout, car il existe un module click.testing avec un runner pour de telles choses. Et non seulement CliRunner lui-même intercepte la sortie, il vous permet également de vérifier le code de sortie, ce qui est également cool. Tout cela permet de tester les utilitaires de clic sans utiliser pytest et sans passer par le module standard unittest .

 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 

Résumé


Ce n'est qu'une petite partie de ce que Click peut faire. Du reste de l'API - validation des valeurs, intégration avec le terminal (couleurs, pager a la less, barre de progression, etc.), rappel des résultats, auto-complétion et bien plus encore. Vous pouvez voir leurs exemples ici .

Avantages: beaucoup d'outils pour toute occasion, une approche originale, mais en même temps pratique pour décrire les équipes, la facilité des tests et la vie active du projet.

Inconvénients: Quels sont les inconvénients d'un «clic» - c'est une question difficile. Peut-être qu'il ne sait rien de ce dont les bibliothèques suivantes sont capables?

Le feu


Fire n'est pas seulement une jeune bibliothèque (apparue en 2017) pour les interfaces de console de Google, c'est une bibliothèque pour générer des interfaces de console à partir, citant textuellement, absolument n'importe quel objet Python.
Entre autres choses, il est indiqué que le feu aide au développement et au débogage du code, aide à adapter le code existant dans la CLI, facilite la transition de bash à Python et possède son propre REPL pour le travail interactif. Verrons-nous?

Analyseur et principal


fire.Fire est vraiment capable d'accepter n'importe quel objet: un module, une instance de classe, un dictionnaire avec des noms de commande et des fonctions correspondantes, etc.

Ce qui est important pour nous, c'est que le Feu permet le transfert d'un objet de classe. Ainsi, le constructeur de classe accepte des arguments communs à toutes les commandes, et ses méthodes et attributs sont des commandes distinctes. Nous utiliserons ceci:

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


Drapeaux en ligne


Fire a ses propres drapeaux avec une syntaxe spéciale (ils doivent être passés après le "-"), qui vous permettent de regarder sous le capot de l'analyseur et de l'application dans son ensemble:

exemples d'appel
 $ ./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}]} 


Test


Tester la fonction principale est similaire à tester argparse et docopt, donc je ne vois pas le point ici.

Dans le même temps, il convient de noter qu'en raison de la nature introspective de Fire, il est également possible de tester immédiatement la classe Commands.

Résumé


Le feu est un outil non moins intéressant que le clic. Il ne nécessite pas de répertorier de nombreuses options dans l'analyseur, la configuration est minimale, il existe des options de débogage et la bibliothèque elle-même vit et se développe encore plus activement que le clic (60 commits cet été).

Inconvénients: peut considérablement moins que le clic et les autres analyseurs; API instable (la version actuelle est 0.2.1).

Le ciment


En fait, Cement n'est pas exactement une bibliothèque CLI, mais un cadre pour les applications de console, mais on prétend qu'il convient aux scripts et aux applications complexes avec diverses intégrations.

Analyseur


L'analyseur dans Cement semble inhabituel, mais si vous regardez attentivement les paramètres, il est facile de deviner que l'argparse familière est sous le capot. Mais c'est peut-être pour le mieux - pas besoin d'apprendre de nouveaux paramètres.

code 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 et principal


Le ciment, entre autres, encapsule toujours les signaux dans les exceptions. Ceci est démontré ici à la sortie de code zéro avec SIGINT / SIGTERM.

code 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 


Si vous maîtrisez main, vous pouvez voir que le chargement et l'enregistrement de todolib.TodoApp peuvent également être effectués dans le __enter __ / __ exit__ substitué, mais ces phases ont finalement été séparées en méthodes distinctes afin de démontrer les crochets de ciment.

Test


Pour les tests, vous pouvez utiliser la même classe d'application:

 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 

Résumé


Avantages: L'ensemble des API est comme un ensemble de couteaux suisses, l'extensibilité via des crochets et des plugins, une interface stable et un développement actif.

Inconvénients: à certains endroits, documentation vide; les petits scripts à base de ciment peuvent sembler un peu compliqués.

Cleo


Cleo est loin d'être un framework aussi populaire que les autres listés ici (environ 400 étoiles sur GitHub au total), et pourtant j'ai réussi à le connaître quand j'ai étudié comment la sortie formatée par Poetry.

Ainsi, Cleo est l'un des projets de l'auteur de la poésie déjà mentionnée, un outil de gestion des dépendances, des virtualenvs et des builds d'applications. À propos de la poésie sur un habr déjà écrit plus d'une fois, et à propos de sa partie console - non.

Analyseur


Cleo, comme Cement, est construit sur des principes d'objet, c'est-à-dire les commandes sont définies via la classe Command et sa docstring, les paramètres sont accessibles via la méthode option (), etc. De plus, la méthode line (), qui est utilisée pour produire du texte, prend en charge les styles (c'est-à-dire les couleurs) et le filtrage de sortie en fonction du nombre de drapeaux verbeux hors de la boîte. Cleo a également une sortie de table. Et aussi des barres de progression. Et pourtant ... En général, voir:

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


principal


Il suffit de créer un objet cleo.Application , puis de lui passer des commandes pour lui ajouter des commandes. Afin de ne pas répéter pendant les tests, tout cela a été transféré du principal au constructeur:

 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) 

Test


Pour tester les commandes dans Cleo, il y a CommandTester , qui, comme tous les oncles adultes du framework, intercepte les E / S et le code de sortie:

 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" 

Résumé


Avantages: structure d'objet avec des indications de type, ce qui simplifie le développement (puisque de nombreux IDE et éditeurs ont un bon support pour le code OOP et le module de typage); Une bonne quantité de fonctionnalités pour travailler non seulement avec des arguments, mais aussi avec des E / S.

Plus ou moins: son paramètre de verbosité, qui n'est compatible qu'avec les E / S Cleo / CliKit. Bien que vous puissiez écrire un gestionnaire personnalisé pour le module de journalisation, il peut être difficile à maintenir avec le développement de cleo.

Inconvénients: évidemment - une opinion personnelle - une jeune API: le framework manque d'un autre "grand" utilisateur, sauf pour la poésie, et Cleo se développe en parallèle avec le développement et pour les besoins de l'un; parfois, la documentation est obsolète (par exemple, les niveaux de journalisation ne se trouvent désormais plus dans le module clikit, mais dans clikit.api.io.flags), et en général, ils sont médiocres et ne reflètent pas l'intégralité de l'API.

Cleo, par rapport à Cement, est plus concentré sur la CLI, et il est le seul à avoir pensé à formater (masquer la trace de pile par défaut) des exceptions dans la sortie par défaut. Mais il - encore une fois une opinion personnelle - perd face à Cement dans sa jeunesse et la stabilité de l'API.

En conclusion


À ce stade, tout le monde a déjà sa propre opinion, ce qui est mieux, mais la conclusion devrait être: j'ai le plus aimé Click, car il contient beaucoup de choses et il est assez facile de développer et de tester des applications avec. Si vous essayez d'écrire du code au minimum - commencez par Fire. Votre script doit avoir accès à Memcached, au formatage avec jinja et à l'extensibilité - prenez Cement et vous ne le regretterez pas. Vous avez un projet pour animaux de compagnie ou vous voulez essayer autre chose - regardez cleo.

Source: https://habr.com/ru/post/fr466999/


All Articles