[Из песочницы] Сравнение популярных CLI-библиотек для Python: click, cement, fire и другие

rpekben_u6jtlwru-flsslxk70e.png

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 — это небольшой (

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

Парсер


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

"""Todo notes on docopt.

Usage:
  todo_docopt [-v | -vv ] add 
  todo_docopt [-v | -vv ] show --show-done
  todo_docopt [-v | -vv ] done 
  todo_docopt [-v | -vv ] remove 
  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[""])
            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[""])
            print(task, "marked as done.")
        elif args["remove"]:
            task = app.remove_task(args[""])
            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 {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 {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 {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.

© Habrahabr.ru