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
И здесь то же самое — парсер кроме парсинга аргументов больше ничего не умеет, так что логику придётся писать самостоятельно и в одном месте. С одной стороны — жить можно, с другой — можно же лучше, только пока неясно, как именно.
UPD: Как подметил foldr, на самом деле сабпарсерам можно задать функции через set_defaults(func=foo), то есть argparse позволяет укоротить 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, который, как и все взрослые
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.