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/