python

Сравнение малопопулярных и не очень CLI-библиотек: cliff, plac, plumbum и другие (часть 2)

  • суббота, 28 сентября 2019 г. в 00:32:44
https://habr.com/ru/post/469093/
  • Python


В экосистеме 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-м)