Сложные приложения
Click разработан для помощи в создании сложных и простых утилит командной строки одинаково. Однако сила его конструкции заключается в возможности произвольно вкладывать системы друг в друга. Например, если вы когда-либо использовали Django, вы заметите, что он предоставляет утилиту командной строки, как и Celery. При использовании Celery с Django существуют две утилиты, которым необходимо взаимодействовать друг с другом и выполнять перекрестную настройку.
В теоретическом мире двух отдельных утилит командной строки Click они могли бы решить эту проблему, вложив одну в другую. Например, веб-фреймворк также мог бы загрузить команды для фреймворка очереди сообщений.
Основные концепции
Чтобы понять, как это работает, вам нужно понять две концепции: контексты и соглашение о вызове.
Контексты
Всякий раз, когда выполняется команда Click, создаётся объект Context, который хранит состояние для этого конкретного вызова. Он запоминает обработанные параметры, какую команду он создал, какие ресурсы необходимо очистить в конце функции и так далее. Он также может необязательно хранить объект, определённый приложением.
Объекты контекста образуют связанный список, пока не достигнут верхнего уровня. Каждый контекст связан с родительским контекстом. Это позволяет команде работать под другой командой и хранить свою информацию там, не опасаясь изменения состояния родительской команды.
Поскольку родительские данные доступны, можно перейти к ним при необходимости.
Большую часть времени вы не видите объект контекста, но при написании более сложных приложений он оказывается полезным. Это приводит нас к следующему пункту.
Соглашение о вызове
Когда выполняется обратный вызов команды Click, ему передаются все нескрытые параметры в качестве ключевых аргументов. Отсутствует контекст. Однако обратный вызов может выбрать передачу объекта контекста, отметив себя тегом pass_context().
Как вызвать обратный вызов команды, если вы не знаете, должен ли он получить контекст или нет? Ответ заключается в том, что сам контекст предоставляет вспомогательную функцию (Context.invoke()), которая может сделать это за вас. Она принимает обратный вызов в качестве первого аргумента и затем правильно вызывает функцию.
Создание клона Git
В этом примере мы хотим создать утилиту командной строки, похожую на систему контроля версий. Такие системы, как Git, обычно предоставляют одну общую команду, которая уже принимает некоторые параметры и настройки, а затем дополнительные подкоманды, выполняющие другие действия.
Корневая команда
На верхнем уровне нам нужна группа, которая может содержать все наши команды. В этом случае мы используем базовую click.group(), которая позволяет нам регистрировать другие команды Click ниже неё.
Для этой команды мы также хотим принять некоторые параметры, которые конфигурируют состояние нашей утилиты:
import os
import click
class Repo(object):
def __init__(self, home=None, debug=False):
self.home = os.path.abspath(home or '.')
self.debug = debug
@click.group()
@click.option('--repo-home', envvar='REPO_HOME', default='.repo')
@click.option('--debug/--no-debug', default=False,
envvar='REPO_DEBUG')
@click.pass_context
def cli(ctx, repo_home, debug):
ctx.obj = Repo(repo_home, debug)
Давайте разберёмся, что это делает. Мы создаём команду группы, которая может иметь подкоманды. При её вызове будет создан экземпляр класса Repo. Это хранит состояние нашей утилиты командной строки. В этом случае он просто запоминает некоторые параметры, но на этом этапе он также может начать загружать конфигурационные файлы и так далее.
Этот объект состояния затем запоминается контекстом как obj. Это специальный атрибут, где команды должны запоминать то, что им нужно передать своим дочерним командам.
Чтобы это работало, нам нужно отметить нашу функцию тегом pass_context(), иначе объект контекста будет полностью скрыт от нас.
Первая дочерняя команда
Давайте добавим нашу первую дочернюю команду — команду clone:
@cli.command()
@click.argument('src')
@click.argument('dest', required=False)
def clone(src, dest):
pass
Теперь у нас есть команда clone, но как мы получим доступ к репозиторию? Как вы можете себе представить, один из способов — использовать функцию pass_context(), которая снова передаст нашему обратному вызову контекст, в котором мы запомнили репозиторий. Однако существует вторая версия этого декоратора, называемая pass_obj(), которая просто передаст сохранённый объект (в нашем случае — репозиторий):
@cli.command()
@click.argument('src')
@click.argument('dest', required=False)
@click.pass_obj
def clone(repo, src, dest):
pass
Вложенные команды
Хотя это не относится к конкретной программе, которую мы хотим создать, также есть хорошая поддержка вложенных систем. Представьте, например, что у нашей системы контроля версий есть суперкрутая надстройка, которой требуется много настроек, и она хочет хранить свою конфигурацию как obj. Если мы затем присоединим другую команду под ней, мы внезапно получим конфигурацию надстройки вместо объекта репозитория.
Один очевидный способ исправить это — сохранить ссылку на репозиторий в надстройке, но тогда команде нужно знать, что она прикреплена под такой надстройкой.
Существует гораздо лучшая система, которая может быть построена за счёт связанной природы контекстов. Мы знаем, что контекст надстройки связан с контекстом, который создал наш репозиторий. Поэтому мы можем начать поиск последнего уровня, где объектом, хранимым контекстом, был репозиторий.
Встроенная поддержка этого предоставляется фабрикой make_pass_decorator(), которая создаёт для нас декораторы, находящие объекты (внутренне она вызывает Context.find_object()). В нашем случае мы знаем, что хотим найти ближайший Repo объект, поэтому давайте создадим декоратор для этого:
pass_repo = click.make_pass_decorator(Repo)
Если мы теперь используем pass_repo вместо pass_obj, мы всегда получим репозиторий вместо чего-то другого:
@cli.command()
@click.argument('src')
@click.argument('dest', required=False)
@pass_repo
def clone(repo, src, dest):
pass
Обеспечение создания объекта
Вышеприведённый пример работает только в том случае, если внешняя команда создала объект Repo и сохранила его в контексте. В некоторых более сложных случаях это может стать проблемой. По умолчанию make_pass_decorator() вызывает Context.find_object(), которое найдёт объект. Если объект не найден, make_pass_decorator() выдаст ошибку. Альтернативное поведение — использовать Context.ensure_object(), которое найдёт объект, а если не найдёт, создаст его и сохранит во внутреннем контексте. Это поведение также можно включить для make_pass_decorator() путём передачи ensure=True:
pass_repo = click.make_pass_decorator(Repo, ensure=True)
В этом случае во внутреннем контексте создаётся объект, если он отсутствует. Это может заменить объекты, размещённые там ранее. В этом случае команда остаётся выполнимой, даже если внешняя команда не выполняется. Чтобы это работало, тип объекта должен иметь конструктор, который не принимает аргументов.
Таким образом, он выполняется автономно:
@click.command()
@pass_repo
def cp(repo):
click.echo(isinstance(repo, Repo))
Как вы можете видеть:
$ cp True
Леничная загрузка подкоманд
Крупные CLI и CLI с медленными импортами могут выиграть от отсрочки загрузки подкоманд. Интерфейсы, которые поддерживают этот режим использования, — MultiCommand.list_commands() и MultiCommand.get_command(). Настраиваемый подкласс MultiCommand может реализовать леничный загрузчик, храня дополнительные данные таким образом, что MultiCommand.get_command() отвечает за выполнение импортов.
Поскольку основной случай для этого — Group, который ленично загружает свои подкоманды, следующий пример демонстрирует реализацию леничной группы.
Предупреждение
Леничная загрузка кода Python может привести к трудно отслеживаемым ошибкам, циклическим импортам в зависимых от порядка кодовых базах и другим неожиданным поведениям. Рекомендуется использовать эту технику только в сочетании с тестированием, которое, по крайней мере, выполнит --help для каждой подкоманды. Это гарантирует, что каждая подкоманда может быть загружена успешно.
Определение леничной группы
Следующий подкласс Group добавляет атрибут, lazy_subcommands, который хранит отображение имён подкоманд к информации для их импорта.
# in lazy_group.py
import importlib
import click
class LazyGroup(click.Group):
def __init__(self, *args, lazy_subcommands=None, **kwargs):
super().__init__(*args, **kwargs)
# lazy_subcommands is a map of the form:
#
# {command-name} -> {module-name}.{command-object-name}
#
self.lazy_subcommands = lazy_subcommands or {}
def list_commands(self, ctx):
base = super().list_commands(ctx)
lazy = sorted(self.lazy_subcommands.keys())
return base + lazy
def get_command(self, ctx, cmd_name):
if cmd_name in self.lazy_subcommands:
return self._lazy_load(cmd_name)
return super().get_command(ctx, cmd_name)
def _lazy_load(self, cmd_name):
# lazily loading a command, first get the module name and attribute name
import_path = self.lazy_subcommands[cmd_name]
modname, cmd_object_name = import_path.rsplit(".", 1)
# do the import
mod = importlib.import_module(modname)
# get the Command object from that module
cmd_object = getattr(mod, cmd_object_name)
# check the result to make debugging easier
if not isinstance(cmd_object, click.BaseCommand):
raise ValueError(
f"Lazy loading of {import_path} failed by returning "
"a non-command object"
)
return cmd_object
Использование LazyGroup для определения CLI
С определённой LazyGroup, теперь можно написать группу, которая ленично загружает свои подкоманды, как показано ниже:
# in main.py
import click
from lazy_group import LazyGroup
@click.group(
cls=LazyGroup,
lazy_subcommands={"foo": "foo.cli", "bar": "bar.cli"},
help="main CLI command for lazy example",
)
def cli():
pass
# in foo.py
import click
@click.group(help="foo command for lazy example")
def cli():
pass
# in bar.py
import click
from lazy_group import LazyGroup
@click.group(
cls=LazyGroup,
lazy_subcommands={"baz": "baz.cli"},
help="bar command for lazy example",
)
def cli():
pass
# in baz.py
import click
@click.group(help="baz command for lazy example")
def cli():
pass
Что запускает леничную загрузку?
Существует несколько событий, которые могут инициировать леничную загрузку, выполняя функцию MultiCommand.get_command(). Некоторые из них интуитивны, а некоторые — нет.
Все случаи описаны относительно вышеприведённого примера, предполагая, что имя основной программы — cli.
- Разрешение команд. Если пользователь запускает
cli bar baz, это должно сначала разрешитьbar, а затем разрешитьbaz. Каждый шаг разрешения подкоманды выполняет леничную загрузку. - Отображение справки. Чтобы получить краткое описание подкоманд,
cli --helpзагрузитfooиbar. Обратите внимание, что это всё ещё не загрузитbaz. - Завершение оболочки. Чтобы получить подкоманды леничной команды,
cli <TAB>потребуется разрешить подкомандыcli. Этот процесс запустит леничные загрузки.
Дополнительная отсрочка импортов
Возможно сделать процесс ещё более леничным, но это, как правило, сложнее, чем больше работы хотите отсрочить.
Например, подкоманды могли быть представлены как пользовательский подкласс BaseCommand, который откладывает импорт команды до её вызова, но который предоставляет BaseCommand.get_short_help_str() для поддержки завершений и справки. Проще говоря, команды могут быть сконструированы таким образом, что их функции обратного вызова откладывают любую фактическую работу до момента импорта.
Это определение команды предоставляет foo, но любая работа, связанная с импортом «реальной» функции обратного вызова, откладывается до времени вызова:
@click.command()
@click.option("-n", type=int)
@click.option("-w", type=str)
def foo(n, w):
from mylibrary import foo_concrete
foo_concrete(n, w)
Поскольку click строит справку и информацию об использовании из опций, аргументов и атрибутов команд, она не имеет представления о том, что основная функция каким-либо образом обрабатывает отложенный импорт. Поэтому все утилиты и функциональность, предоставляемые click, будут работать в обычном режиме с такой командой.
© Copyright 2014 Pallets.
Licensed under the BSD 3-Clause License.
We are not supported nor endorsed by Pallets.
https://click.palletsprojects.com/en/8.1.x/complex/