https://habr.com/ru/post/469093/В экосистеме Python существует множество пакетов для CLI-приложений, как популярных, вроде Click, так и не очень. Наиболее распространённые были рассмотрены в
предыдущей статье, здесь же будут показаны малоизвестные, но не менее интересные.
Как и в первой части, для каждой библиотеки будет написана на Python 3.7 консольный скрипт к библиотеке todolib. Вдобавок к этому, для каждой реализации будет написан тривиальный тест с данными фикстурами:
@pytest.fixture(autouse=True)
def db(monkeypatch):
"""
monkeypatch загрузки и сохранения для пустой БД перед каждым тестом,
и чтобы оригинальная БД пользователя не была затронута
"""
value = {"tasks": []}
monkeypatch.setattr(todolib.TodoApp, "save", lambda _: ...)
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"
Весь исходный код доступен в
этом репозитории.
cliff
GitHub
Документация
Многие слышали про OpenStack, опенсорсную платформу для IaaS. Основная её часть написана на Python, включая консольные утилиты, которые долгое время повторяли друг за другом CLI-функционал. Так продолжалось до тех пор, пока в качестве общего фреймворка не появился cliff, или Command Line Interface Formulation Framework. С ним разработчики Openstack объединили пакеты вроде python-novaclient, python-swiftclient и python-keystoneclient в одну программу
openstack.
Команды
Подход к объявлению команд напоминает cement и cleo: argparse в качестве парсера параметров, а сами команды создаются через наследование класса Command. При этом есть небольшие расширения класса Command, такие как Lister, который самостоятельно форматирует данные.
исходный кодfrom cliff import command
from cliff.lister import Lister
class Command(command.Command):
"""Command with a parser shortcut."""
def get_parser(self, prog_name):
parser = super().get_parser(prog_name)
self.extend_parser(parser)
return parser
def extend_parser(self, parser):
...
class Add(Command):
"""Add new task."""
def extend_parser(self, parser):
parser.add_argument("title", help="Task title")
def take_action(self, parsed_args):
task = self.app.todoapp.add_task(parsed_args.title)
print(task, "created with number", task.number, end=".\n")
class Show(Lister, Command):
"""Show current tasks."""
def extend_parser(self, parser):
parser.add_argument(
"--show-done", action="store_true", help="Include done tasks"
)
def take_action(self, parsed_args):
tasks = self.app.todoapp.list_tasks(show_done=parsed_args.show_done)
# команда не выводит сообщение 'there is no todos' в целях
# поддержки пустого вывода при форматировании
return (
("Number", "Title", "Status"),
[[task.number, task.title, "" if task.done else "✘"] for task in tasks],
)
class Done(Command):
"""Mark task as done."""
def extend_parser(self, parser):
parser.add_argument("number", type=int, help="Task number")
def take_action(self, parsed_args):
task = self.app.todoapp.task_done(number=parsed_args.number)
print(task, "marked as done.")
# унаследована от Done для переиспользования парсера
class Remove(Done):
"""Remove task from the list."""
def take_action(self, parsed_args):
task = self.app.todoapp.remove_task(number=parsed_args.number)
print(task, "removed from the list.")
Application и main
У класса приложения есть методы
initialize_app и
clean_up, в нашем случае в них вписана инициализация приложения и сохранение данных.
исходный кодfrom cliff import app
from cliff.commandmanager import CommandManager
from todolib import TodoApp, __version__
class App(app.App):
def __init__(self):
# кроме ручного add_command, CommandManager может
# доставать команды из setuptools entrypoint
manager = CommandManager("todo_cliff")
manager.add_command("add", Add)
manager.add_command("show", Show)
manager.add_command("done", Done)
manager.add_command("remove", Remove)
super().__init__(
description="Todo notes on cliff",
version=__version__,
command_manager=manager,
deferred_help=True,
)
self.todoapp = None
def initialize_app(self, argv):
self.todoapp = TodoApp.fromenv()
def clean_up(self, cmd, result, err):
self.todoapp.save()
def main(args=sys.argv[1:]) -> int:
app = App()
return app.run(argv=args)
Примеры работы
igor$ ./todo_cliff.py add "sell the old laptop"
Using database file /home/igor/.local/share/todoapp/db.json
Task 'sell the old laptop' created with number 0.
Saving database to a file /home/igor/.local/share/todoapp/db.json
Логирование из коробки! А если заглянуть
под капот, то видно, что оно сделано по-умному: info в stdout, а warning/error — в stderr, и при необходимости отключается флагом
--quiet.
igor$ ./todo_cliff.py -q show
+--------+----------------------+--------+
| Number | Title | Status |
+--------+----------------------+--------+
| 1 | sell the old laptop | ✘ |
+--------+----------------------+--------+
Как уже упоминалось, Lister форматирует данные, но таблицей дело не ограничивается:
igor$ ./todo_cliff.py -q show -f json --noindent
[{"Number": 0, "Title": "sell old laptop", "Status": "\u2718"}]
Кроме json и table доступны yaml и csv.
Скрытие стектрейса по умолчанию также есть:
igor$ ./todo_cliff.py -q remove 3
No such task.
Ещё доступен REPL и fuzzy search aka
нечёткий поиск:
igor$ ./todo_cliff.py -q
(todo_cliff) help
Shell commands (type help %topic%):
===================================
alias exit history py quit shell unalias
edit help load pyscript set shortcuts
Application commands (type help %topic%):
=========================================
add complete done help remove show
(todo_cliff) whow
todo_cliff: 'whow' is not a todo_cliff command. See 'todo_cliff --help'.
Did you mean one of these?
show
Тестирование
Всё просто: создаётся объект App и так же вызывается run(), который возвращает exit code.
def test_cliff(capsys):
app = todo_cliff.App()
code = app.run(["add", "test"])
assert code == 0
out, _ = capsys.readouterr()
assert out == EXPECTED
За и против
Плюсы:
- Разные удобства из коробки;
- Разрабатывается в OpenStack;
- Интерактивный режим;
- Расширяемость через setuptools entrypoint и CommandHook;
- Плагин Sphinx для автодокументации к CLI;
- Автодополнение команд (только bash);
Минусы:
- Небольшая документация, которая в основом состоит из подробного, но единственного примера;
Ещё замечен баг: в случае ошибки при скрытии стектрейса exit code всегда нулевой.
Plac
GitHub
Документация
На первый взгляд Plac кажется чем-то вроде Fire, но
на самом деле он как Fire, который под капотом скрывает тот же argparse и много чего ещё.
Plac следует, цитируя документацию, «древнему принципу компьютерного мира:
Программы должны просто решать обычные случаи и простое должно оставаться простым, а сложное в то же время — достижимым». Автор фреймворка использует Python уже более девяти лет и писал его с расчётом на то, чтобы решать «99.9% задач».
Команды и main
Внимание на аннотации в методах show и done: так Plac парсит параметры и справку аргумента соответственно.
исходный кодimport plac
import todolib
class TodoInterface:
commands = "add", "show", "done", "remove"
def __init__(self):
self.app = todolib.TodoApp.fromenv()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.app.save()
def add(self, task):
""" Add new task. """
task = self.app.add_task(title=task)
print(task, "created with number", task.number, end=".\n")
def show(self, show_done: plac.Annotation("Include done tasks", kind="flag")):
""" Show current tasks. """
self.app.print_tasks(show_done=show_done)
def done(self, number: "Task number"):
""" Mark task as done. """
task = self.app.task_done(number=int(number))
print(task, "marked as done.")
def remove(self, number: "Task number"):
""" Remove task from the list. """
task = self.app.remove_task(number=int(number))
print(task, "removed from the list.")
if __name__ == "__main__":
plac.Interpreter.call(TodoInterface)
Тестирование
К сожалению, тестировать exit code Plac не позволяет. Но само тестирование реально:
def test_plac(capsys):
plac.Interpreter.call(todo_plac.TodoInterface, arglist=["add", "test"])
out, _ = capsys.readouterr()
assert out == EXPECTED
За и против
Плюсы:
- Простое использование;
- Интерактивный режим с поддержкой readline;
- Стабильный API;
- Отличная документация;
Но самое интересное у Plac спрятано в
advanced usage:
- Выполнение нескольких команд в потоках и подпроцессах;
- Параллельные вычисления;
- telnet-сервер;
Минусы:
- Нельзя тестировать exit code;
- Бедная жизнь проекта.
Plumbum
GitHub
Документация
Документация по CLI
Plumbum, на самом деле, не такой уж и малоизвестный фреймворк — почти 2000 звёзд, и любить его есть за что, ведь, грубо говоря, в нём воплощён синтаксис UNIX Shell. Ну, с добавками:
>>> from plumbum import local
>>> output = local["ls"]()
>>> output.split("\n")[:3]
['console_examples.egg-info', '__pycache__', 'readme.md']
# у plumbum также есть магический модуль cmd, который возвращает команды по атрибутам
>>> from plumbum.cmd import rm, ls, grep, wc
>>> rm["-r", "console_examples.egg-info"]()
''
>>> chain = ls["-a"] | grep["-v", "\\.py"] | wc["-l"]
>>> chain()
'11\n'
Команды и main
Также Plumbum располагает и инструментами для CLI, и не сказать, что они просто дополнение: есть и nargs, и команды, и цвета:
исходный кодfrom plumbum import cli, colors
class App(cli.Application):
"""Todo notes on plumbum."""
VERSION = todolib.__version__
verbosity = cli.CountOf("-v", help="Increase verbosity")
def main(self, *args):
if args:
print(colors.red | f"Unknown command: {args[0]!r}.")
return 1
if not self.nested_command: # will be ``None`` if no sub-command follows
print(colors.red | "No command given.")
return 1
class Command(cli.Application):
"""Command with todoapp object"""
def __init__(self, executable):
super().__init__(executable)
self.todoapp = todolib.TodoApp.fromenv()
atexit.register(self.todoapp.save)
def log_task(self, task, msg):
print("Task", colors.green | task.title, msg, end=".\n")
@App.subcommand("add")
class Add(Command):
"""Add new task"""
def main(self, task):
task = self.todoapp.add_task(title=task)
self.log_task(task, "added to the list")
@App.subcommand("show")
class Show(Command):
"""Show current tasks"""
show_done = cli.Flag("--show-done", help="Include done tasks")
def main(self):
self.todoapp.print_tasks(self.show_done)
@App.subcommand("done")
class Done(Command):
"""Mark task as done"""
def main(self, number: int):
task = self.todoapp.task_done(number)
self.log_task(task, "marked as done")
@App.subcommand("remove")
class Remove(Command):
"""Remove task from the list"""
def main(self, number: int):
task = self.todoapp.remove_task(number)
self.log_task(task, "removed from the list.")
if __name__ == '__main__':
App.run()
Тестирование
Тестирование приложений на Plumbum отличается от прочих, разве что, необходимостью передавать ещё и имя приложения, т.е. первый аргумент:
def test_plumbum(capsys):
_, code = todo_plumbum.App.run(["todo_plumbum", "add", "test"], exit=False)
assert code == 0
out, _ = capsys.readouterr()
assert out == "Task test created with number 0.\n"
За и против
Плюсы:
- Отличный инструментарий для работы с внешними командами;
- Поддержка стилей и цветов;
- Стабильный API;
- Активная жизнь проекта;
Недостатков не замечено.
cmd2
GitHub
Документация
Прежде всего, cmd2 это расширение над
cmd из стандартной библиотеки,
т.е. он предназначен для интерактивных приложений. Тем не менее, он включён в обзор, поскольку его можно настроить и на обычный CLI-режим.
Команды и main
cmd2 требует соблюдения определённого правила: команды должны начинаться с префикса
do_, а в остальном всё понятно:
интерактивный режимimport cmd2
import todolib
class App(cmd2.Cmd):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.todoapp = todolib.TodoApp.fromenv()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.todoapp.save()
def do_add(self, title):
"""Add new task."""
task = self.todoapp.add_task(str(title))
self.poutput(f"{task} created with number {task.number}.")
def do_show(self, show_done):
"""Show current tasks."""
self.todoapp.print_tasks(bool(show_done))
def do_done(self, number):
"""Mark task as done."""
task = self.todoapp.task_done(int(number))
self.poutput(f"{task} marked as done.")
def do_remove(self, number):
"""Remove task from the list."""
task = self.todoapp.remove_task(int(number))
self.poutput(f"{task} removed from the list.")
def main(**kwargs):
with App(**kwargs) as app:
app.cmdloop()
if __name__ == '__main__':
main()
неинтерактивный режимОбычный режим требует немного дополнительных движений.
Например, придётся вернуться к argparse и написать логику для случая, когда скрипт вызван без параметров. А ещё теперь команды получают
argparse.Namespace.
Парсер взят из примера с argparse с небольшими дополнениями — теперь подпарсеры являются атрибутами основного
ArgumentParser.
import cmd2
from todo_argparse import get_parser
parser = get_parser(progname="todo_cmd2_cli")
class App(cmd2.Cmd):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.todoapp = todolib.TodoApp.fromenv()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.todoapp.save()
def do_add(self, args):
"""Add new task."""
task = self.todoapp.add_task(args.title)
self.poutput(f"{task} created with number {task.number}.")
def do_show(self, args):
"""Show current tasks."""
self.todoapp.print_tasks(args.show_done)
def do_done(self, args):
"""Mark task as done."""
task = self.todoapp.task_done(args.number)
self.poutput(f"{task} marked as done.")
def do_remove(self, args):
"""Remove task from the list."""
task = self.todoapp.remove_task(args.number)
self.poutput(f"{task} removed from the list.")
parser.add.set_defaults(func=do_add)
parser.show.set_defaults(func=do_show)
parser.done.set_defaults(func=do_done)
parser.remove.set_defaults(func=do_remove)
@cmd2.with_argparser(parser)
def do_base(self, args):
func = getattr(args, "func", None)
if func:
func(self, args)
else:
print("No command provided.")
print("Call with --help to get available commands.")
def main(argv=None):
with App() as app:
app.do_base(argv or sys.argv[1:])
if __name__ == '__main__':
main()
Тестирование
Тестироваться будет только интерактивный скрипт.
Поскольку тестирование интерактивных приложений требует большого количества человеко- и временных ресурсов, разработчики cmd2 постарались решить эту проблему с помощью
транскрипций — текстовых файлов с примерами ввода и ожидаемым выводом. Например:
(Cmd) add test
Task 'test' created with number 0.
Таким образом, всё, что требуется, это передать в
App список файлов с транскрипциями:
def test_cmd2():
todo_cmd2.main(transcript_files=["tests/transcript.txt"])
За и против
Плюсы:
- Хороший API для интерактивных приложений;
- Активно развивается в последние годы;
- Оригинальный подход к тестированию;
- Хорошая документация.
Минусы:
- Требует argparse и дополнительного кода при написании неинтерактивных приложений;
- Нестабильный API;
- Местами документация пустовата.
Бонус: Urwid
GitHub
Документация
Tutorial
Примеры программ
Urwid это фреймворк из немного иного мира — из curses и npyscreen, то есть из Console/Terminal UI. Тем не менее, он включен в обзор в качестве бонуса, поскольку, на мой взгляд, заслуживает внимания.
App и команды
У Urwid есть большое количество виджетов, однако понятиями вроде окна или простым инструментарием для доступа к соседним виджетам он не располагает. Таким образом, если хотите получить красивый результат, то потребуется вдумчивое проектирование и/или использование других пакетов, иначе придётся передавать данные в атрибутах кнопок, как здесь:
исходный кодimport urwid
from urwid import Button
import todolib
class App(urwid.WidgetPlaceholder):
max_box_levels = 4
def __init__(self):
super().__init__(urwid.SolidFill())
self.todoapp = None
self.box_level = 0
def __enter__(self):
self.todoapp = todolib.TodoApp.fromenv()
self.new_menu(
"Todo notes on urwid",
# у кнопки есть текст и хэндлер нажатия, который
# должен принимать объект нажатой кнопки
Button("New task", on_press=add),
Button("List tasks", on_press=list_tasks),
)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.todoapp.save()
def new_menu(self, title, *items):
self.new_box(menu(title, *items))
def new_box(self, widget):
self.box_level += 1
# overlay позволяет держать один виджет поверх другого,
# в данном случае новый LineBox поверх существующего виджета
self.original_widget = urwid.Overlay(
# LineBox рисует unicode-линию вокруг переданного виджета
self.original_widget,
align="center",
width=30,
valign="middle",
height=10,
)
def popup(self, text):
self.new_menu(text, Button("To menu", on_press=lambda _: self.pop(levels=2)))
def keypress(self, size, key):
if key != "esc":
super().keypress(size, key=key)
elif self.box_level > 0:
self.pop()
def pop(self, levels=1):
for _ in range(levels):
self.original_widget = self.original_widget[0]
self.box_level -= levels
if self.box_level == 0:
raise urwid.ExitMainLoop()
командыapp = App()
def menu(title, *items) -> urwid.ListBox:
body = [urwid.Text(title), urwid.Divider()]
body.extend(items)
return urwid.ListBox(urwid.SimpleFocusListWalker(body))
def add(button):
edit = urwid.Edit("Title: ")
def handle(button):
text = edit.edit_text
app.todoapp.add_task(text)
app.popup("Task added")
app.new_menu("New task", edit, Button("Add", on_press=handle))
def list_tasks(button):
tasks = app.todoapp.list_tasks(show_done=True)
buttons = []
for task in tasks:
status = "done" if task.done else "not done"
text = f"{task.title} [{status}]"
# кнопка также может передавать дополнительные данные в коллбэки
button = Button(text, on_press=task_actions, user_data=task.number)
buttons.append(button)
app.new_menu("Task list", *buttons)
def task_actions(button, number):
def done(button, number):
app.todoapp.task_done(number)
app.popup("Task marked as done.")
def remove(button, number):
app.todoapp.remove_task(number)
app.popup("Task removed from the list.")
btn_done = Button("Mark as done", on_press=done, user_data=number)
btn_remove = Button("Remove from the list", on_press=remove, user_data=number)
app.new_menu("Actions", btn_done, btn_remove)
main
if __name__ == "__main__":
try:
with app:
urwid.MainLoop(app).run()
except KeyboardInterrupt:
pass
За и против
Плюсы:
- Отличный API для написания разных TUI-приложений;
- Долгая история разработки (с 2010) и стабильный API;
- Грамотная архитектура;
- Хорошая документация, есть примеры.
Минусы:
- Как тестировать — непонятно. На ум приходит только tmux send-keys;
- Неинформативные ошибки при неправильной компоновке виджетов.
* * *
Cliff во многом похож на Cleo и Cement и в целом хорош для больших проектов.
Использовать Plac я лично не осмелился бы, а вот почитать исходный код рекомендую.
У Plumbum удобный инструментарий для CLI и блестящий API для выполнения других команд, так что если вы занимаетесь переписыванием shell-скриптов на Python, то это то, что нужно.
cmd2 неплохо подходит в качестве основы для интерактивных приложений и для тех, кто хочет мигрировать со стандартного cmd.
А Urwid отличается красивыми и user-friendly консольными приложениями.
В обзор не вошли следующие пакеты:
- aioconsole — нет неинтерактивного режима;
- pyCLI — нет поддержки подкоманд;
- Clint — нет поддержки подкоманд, репозиторий в архиве;
- commandline — уж больно старый (последний релиз в 2009) и неинтересный;
- CLIArgs — старый (последний релиз в 2010-м)
- opterator — относительно старый (последний релиз в 2015-м)