Сравнение популярных CLI-библиотек для Python: click, cement, fire и другие

Python — отличный язык для консольных приложений, и это подчёркивает большое количество библиотек для этих задач. Но какие вообще библиотеки существуют? А какую лучше взять? В этом материале сравниваются популярные и не очень инструменты для консольного мира и дана попытка ответить на второй вопрос.

Для удобства чтения обзор разделён на два поста: в первом сравнивается шесть самых популярных библиотек, во втором — менее популярные и более специфичные, но всё же заслуживающие внимания.

В каждом из примеров будет написана на Python 3.7 консольная утилита к библиотеке todolib, с которой можно создавать, просматривать, помечать и удалять задачи. Остальное будет дописано при условии простоты реализации на том или ином фреймворке. Сами задачи хранятся в json-файле, который будет сохраняться отдельным вызовом — дополнительное условие к примерам.
Вдобавок к этому, для каждой реализации будет написан тривиальный тест. За фреймворк для тестирования взят pytest со следующими фикстурами:

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

В принципе, для демонстрации библиотек всего перечисленного хватит. Полный исходный код доступен в этом репозитории.

argparse

У argparse есть неоспоримое преимущество — он есть в стандартной библиотеке и его API нетрудно выучить: есть парсер, есть аргументы, у аргументов есть type, action, dest, default и help. И есть subparser — возможность выделять часть аргументов и логики в отдельные команды.

Парсер

На первый взгляд — ничего необычного, парсер как парсер. Но — на мой взгляд — читаемость не самая лучшая, если сравнивать с другими библиотеками, т.к. аргументы к разным командам описываются в одном месте.

исходный код

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

И здесь то же самое — парсер кроме парсинга аргументов больше ничего не умеет, так что логику придётся писать самостоятельно и в одном месте. С одной стороны — жить можно, с другой — можно же лучше, только пока неясно, как именно.

исходный код

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

Тестирование

Для проверки вывода утилиты используется фикстура capsys, которая даёт доступ к тексту из stdout и stderr.

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

Итог

Из плюсов — хороший набор возможностей для парсинга, наличие модуля в стандартной библиотеке.

Минусы — argparse занимается лишь парсингом аргументов, большую часть логики в main пришлось писать самому. И неясно, как в тестах проверять exit code.

docopt

docopt — это небольшой (<600 строк, в сравнении с 2500 у argparse) парсер, that will make you smile, цитируя описание на GitHub. Основная идея docopt заключается в том, чтобы описать интерфейс буквально текстом, например, в docstring.

На том же гитхабе у docopt >6700 звёзд, он используется в минимум 22 тысячах других проектах. И это лишь у python-реализации! На странице проекта docopt есть множество вариантов под разные языки, от C и PHP до CoffeeScript и даже R. Такую кросплатформенность могу объяснить лишь компактностью и простотой кода.

Парсер

В сравнении с argparse, этот парсер — большой шаг вперёд.

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

В целом всё так же, как и с argparse, однако теперь у verbose может быть несколько значений (0-2), и ещё доступ к аргументам отличается: docopt возвращает не namespace с атрибутами, а просто словарь, где выбор той или иной команды обозначается через её boolean, что видно в if:

исходный код

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

Тестирование

Аналогично тестированию argparse:

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

Итог

Из плюсов — гораздо меньше кода для парсера, простота описания и чтения команд и аргументов, встроенный version.

Минусы, во-первых, те же, что и у argparse — много логики в main, нельзя протестировать exit code. К тому же текущая версия (0.6.2) docopt ещё не стабильна и вряд ли когда-нибудь будет — проект активно развивался с 2012 по конец 2013 года, последний коммит был в декабре 17-го. А самое неприятное на данный момент — некоторые регулярки docopt’а провоцируют DeprecationWarning’и при выполнении тестов.

Click

Click принципиально отличается от argparse и docopt количеством функционала и подходом к описанию команд и параметров через декораторы, а саму логику предлагается выделять в отдельные функции вместо большого main. Авторы утверждают, что у Click много настроек, но стандартных параметров должно хватить. Среди фич подчёркиваются вложенные команды и их ленивая подгрузка.

Проект крайне популярен: кроме того, что у него >8100 звёзд и он используется в минимум 174 тысячах (!) проектах, он до сих пор развивается: версия 7.0 вышла осенью 2018 года, а новые коммиты и merge request’ы появляются и по сей день.

Парсер

На странице документации я нашёл декоратор confirmation_option, который запрашивает подтверждения у пользователя перед выполнением команды. Для его демонстрации была добавлена команда wipe, которая очищает весь список задач.

исходный код

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

И тут мы встречаемся с главным преимеществом Click — благодаря тому, что логика команд разнесена по их функциям, в main почти ничего не остаётся. Также здесь продемонстрирована возможность библиотеки получать аргументы и параметры из переменных окружения.

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

Тестирование

В случае с Click в перехвате sys.stdout нет нужды, так как есть модуль click.testing с раннером для таких вещей. И мало того, что CliRunner сам перехватывает вывод, он ещё и позволяет проверить exit code, что тоже круто. Всё это позволяет тестировать click-утилиты без использования pytest и обходиться стандартным модулем 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 

Итог

Это лишь малая часть того, что умеет Click. Из остального API — валидация значений, интеграция с терминалом (цвета, пейджер а-ля less, прогресс-бар и т.д.), result callback, автодополнение и многое другое. Можете посмотреть их примеры здесь.

Плюсы: много инструментов на любой случай, оригинальный, но при этом удобный подход к описанию команд, простота тестирования и активная жизнь проекта.

Минусы: Какие у «клика» минусы — это сложный вопрос. Может, он чего-то не умеет из того, на что способны следующие библиотеки?

Fire

Fire — это не просто молодая (появилась в 2017-м) библиотека для консольных интерфейсов от Google, это библиотека для генерации консольных интерфейсов из, дословно цитируя, абсолютно любого объекта Python.
Среди прочего заявляется, что fire помогает в разработке и отладке кода, помогает адаптировать существующий код в CLI, облегчает переход из баша в Python и обладает своим REPL для интерактивной работы. Посмотрим?

Парсер и main

fire.Fire действительно способен принимать любой объект: модуль, инстанс класса, словарь с именами команд и соответствующими функциями, и так далее.

Что для нас важно, так это то, что Fire допускает передачу объекта класса. Таким образом, конструктор класса принимает аргументы, общие для всех команд, а его методы и атрибуты являются отдельными командами. Этим мы и воспользуемся:

исходный код

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) 

Встроенные флаги

У Fire есть собственные флаги с особым синтаксисом (их надо передавать после "—"), которые позволяют заглянуть под капот парсера и приложения в целом:

примеры вызовов

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

Тестирование

Тестирование main-функции аналогично тестированию argparse и docopt, поэтому приводить его здесь не вижу смысла.

В то же время стоит отметить, что из-за интроспективной натуры Fire можно с тем же успехом тестировать сразу класс Commands.

Итог

Fire — инструмент, интересный не меньше, чем click. Он не требует перечисления множества опций в парсере, конфигурация минимальна, есть свои опции для отладки, а сама библиотека живёт и развивается даже активнее, чем click (60 коммитов за это лето).

Минусы: умеет ощутимо меньше, чем click и другие парсеры; нестабильный API (текущая версия — 0.2.1).

Cement

Вообще-то, Cement не совсем CLI-библиотека, а фреймворк для консольных приложений, но утверждается, что он подходит и для скриптов, и для сложных приложений с различными интеграциями.

Парсер

Парсер в Cement выглядит необычно, но если приглядеться к параметрам, то нетрудно догадаться, что под капотом стоит знакомый argparse. Но, может, это и к лучшему — не надо учить новые параметры.

исходный код

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 и main

Cement, кроме всего остального, ещё оборачивает сигналы в исключения. Здесь это продемонстрировано на выходе с нулевым кодом при SIGINT/SIGTERM.

исходный код

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 

Если вчитаться в main, то можно заметить, что загрузку и сохранение todolib.TodoApp можно провести и в переопределённых __enter__/__exit__, но эти фазы в итоге были выделены в отдельные методы для того, чтобы продемонстрировать хуки Cement.

Тестирование

Для тестирования можно использовать тот же класс приложения:

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 

Итоги

Плюсы: Набор API походит на набор швейцарского ножа, расширяемость через хуки и плагины, стабильный интерфейс и активная разработка.

Минусы: Местами пустая документация; небольшие скрипты на основе Cement могут показаться несколько сложноватыми.

Cleo

Cleo далеко не такой популярный фреймворк, как другие перечисленные здесь (всего около 400 звёзд на GitHub), и всё же мне удалось познакомиться с ним, когда я изучал, каким образом Poetry осуществляет форматирование вывода.

Так вот, Cleo — это один из проектов автора уже упомянутого Poetry, инструмента для управления зависимостями, virtualenv’ами и сборками приложений. Про Poetry на хабре уже не раз писали, а про его консольную часть — нет.

Парсер

Cleo, как и Cement, построен на объектных принципах, т.е. определение команд происходит через класс Command и его docstring, доступ к параметрам осуществляется через метод option(), и так далее. Кроме того, метод line(), который используется для вывода текста, поддерживает стили (т.е. цвета) и фильтрацию вывода на основании количества verbose-флагов из коробки. А ещё у cleo есть вывод таблиц. А ещё прогресс-бары. А ещё… В общем, смотрите:

исходный код

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

Всё, что надо, это создать объект cleo.Application и потом передать ему команды в add_commands. Чтобы не повторяться при тестировании, всё это было перенесено из main в конструктор:

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) 

Тестирование

Для тестирования команд в Cleo есть CommandTester, который, как и все взрослые дяди фреймворки, перехватывает I/O и 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" 

Итог

Плюсы: объектная структура с наличием type hints, что упрощает разработку (т.к. многие IDE и редакторы имеют хорошую поддержку ООП-кода и модуля typing); хороший объём функционала по работе не только с аргументами, но и I/O.

Плюс-минус: свой параметр verbosity, который совместим только с I/O Cleo/CliKit. И хотя можно написать кастомный handler для модуля logging, его может быть сложно поддерживать вместе с развитием cleo.

Минусы: явно — личное мнение — молодое API: фреймворку не хватает другого «крупного» пользователя, кроме Poetry, а так Cleo развивается параллельно с развитием и под нужды его одного; местами документация устаревшая (например, уровни логирования теперь лежат не в модуле clikit, а в clikit.api.io.flags), да и в целом она скудна и не отражает всего API.

Cleo, в сравнении с Cement, больше сфокусирован на CLI, и он единственный, кто задумался о форматировании (скрытии стектрейса по умолчанию) исключений в выводе по умолчанию. Но он — снова личное мнение — проигрывает Cement’у в своей юности и стабильности API.

В заключение

К этому моменту у всех уже есть своё мнение, что лучше, но заключение должно быть: мне больше всего понравился Click, за то, что в нём много чего есть и при этом с ним достаточно просто разрабатывать и тестировать приложения. Если вы стараетесь писать код по минимуму — начните с Fire. Вашему скрипту нужен доступ в Memcached, форматирование с jinja и расширяемость — берите Cement и не пожалеете. У вас пет-проджект или хотите попробовать что-то иное — посмотрите на cleo.


ссылка на оригинал статьи https://habr.com/ru/post/466999/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *