Сообщить об ошибке.

Произвольное вложение команд в сценариях модуля click в Python

Особенности использование @click.сommand() и @click.group()

Самая важная особенность модуля click - это концепция произвольного вложения команд сценария командной строки. Это поведение реализуется через декораторы @click.сommand() и @click.group().

Содержание:


Различие в обработке простых команд от подкоманд.

Если сценарий имеет только единственную команду, декорированную @click.сommand() и когда запускается сценарий, то всегда выполняется обратный вызов этого декоратора, запускающий выполнение функции, если обратный вызов опции или параметра не предотвратит это. Например, такое происходит при передаче сценарию опции --help.

Для группы команд @click.group() ситуация выглядит иначе. В этом случае обратный вызов срабатывает всякий раз, когда срабатывает подкоманда, если это поведение не было изменено опцией или параметром этой подкоманды. На практике это означает, что внешняя команда выполняется, когда выполняется внутренняя команда:

import click

@click.group()
@click.option('-d', '--debug', is_flag=True, help='Debug mode.')
def cli(debug):
    click.echo(f"Debug mode is {'on' if debug else 'off'}")

# обратите внимание на имя декоратора
# имя начинается на `@cli`, а не `@click!
@cli.command('sync') 
@click.option('-p', '--prn', default='Syncing')
@click.pass_context
def sync(ctx, prn):
    click.echo(prn)

if __name__ == '__main__':
    cli()

Запускаем сценарий:

$ python3 tool.py
# Usage: tool.py [OPTIONS] COMMAND [ARGS]...
# Options:
#   --debug / --no-debug
#   --help                Show this message and exit.
# Commands:
#   sync

$ python3 tool.py -d sync
# Debug mode is on
# Syncing

$ python3 tool.py -d sync --help
# Debug mode is on
# Usage: tool.py sync [OPTIONS]
# Options:
#  -p, --prn TEXT
#  --help          Show this message and exit.

Модуль click строго разделяет опции между командами и подкомандами. Это означает, что опции и параметры для конкретной команды должны быть указаны после имени самой команды, но перед любыми другими именами команд.

Такое поведение можно наблюдать в примере выше с предопределенной опцией --help. Разберем сценарий выше, он имеет группу команд cli(), содержащий подкоманду sync.

  • python3 tool.py --help вернет справку для всей программы (список подкоманд).
  • python3 tool.py sub --help вернет справку по подкоманде.
  • python3 tool.py --help sub будет рассматривать --help как опцию основного сценария. Затем вызывается обратный вызов для --help, который печатает справку и прерывает программу до того, как запустится подкоманда sync.

Вложенная обработка и контексты.

Как видно из предыдущего примера, основная группа команд @click.group() принимает опцию --debug, которая передается ее обратному вызову cli(), но не передается самой подкоманде sync. Подкоманда sync принимает только собственные опции.

Такое поведение позволяет инструментам действовать полностью независимо друг от друга, но как одна команда может взаимодействовать с вложенной? Ответ: на это есть контекст ctx.

Каждый раз, при вызове команды, создается новый контекст, который связывается с родительским контекстом. Обычно эти контексты не видны, но они есть. Контексты передаются в функции обратного вызова, автоматически, вместе со значением опций и параметров. Команды также могут запрашивать передачу контекста, при помощи декоратора @click.pass_context(). В этом случае контекст передается как первый аргумент.

Контекст также может нести/передавать объект, который, например создается командой и может использоваться для целей программы. В общем можно создать такой сценарий:

@click.group()
@click.option('--debug/--no-debug', default=False)
# проталкиваем контекст
@click.pass_context
def cli(ctx, debug):
    # необходимо убедится, что `ctx.obj` 
    # существует и является словарем
    ctx.ensure_object(dict)
    # передаем контексту значение опции `--debug`
    ctx.obj['DEBUG'] = debug

@cli.command()
# проталкиваем контекст в подкоманду
@click.pass_context
def sync(ctx):
    # достаем из контекста значение опции `--debug`
    click.echo(f"Debug is {'on' if ctx.obj['DEBUG'] else 'off'}")

if __name__ == '__main__':
    # запускаем сценарий с контекстом `obj`
    cli(obj={})

Если сценарий запущен с переменными контекста, то каждый контекст будет передавать объект своим дочерним элементам, но на любом уровне объект контекста может быть переопределен. Чтобы связаться с родителем, можно использовать context.parent.

В дополнение к этому, ничто не мешает сценарию изменять состояние глобальных переменных контекста.

Вызов групповой команды, без вызова подкоманды.

По умолчанию, команда/функция, определяющая группу команд не вызывается, если не передана подкоманда. Фактически, если подкоманда не указана, то по умолчанию автоматически передается опция --help. Это поведение можно изменить, передав аргумент invoke_without_command=True группе команд @click.group(). В этом случае всегда будет вызываться обратный вызов (декорированная функция), вместо отображения страницы справки. Объект контекста также включает информацию о том, будет ли вызов выполняться подкомандой.

import click

@click.group(invoke_without_command=True)
@click.pass_context
def tool(ctx):
    if ctx.invoked_subcommand is None:
        click.echo('Run `tool()` without subcommand')
    else:
        click.echo(f"Run `tool()` before subcommand `{ctx.invoked_subcommand}()`")

@tool.command()
def sync():
    click.echo('Run subcommand `sync()`')

if __name__ == '__main__':
    tool()

Вывод сценария:

$ python3 tool.py
# Run `tool()` without subcommand

$ python tool.py sync
# Run `tool()` before subcommand `sync()`
# Run subcommand `sync()`

Объединение нескольких подкоманд.

В некоторых обстоятельствах, для более приятного взаимодействия с командной строкой, может быть интересно объединить несколько команд в один сценарий. Хотя это, как правило, не рекомендуется, т.к они размещаются одна под другой.

Реализацией по умолчанию для такой системы слияния является класс click.CommandCollection(). Он принимает список подкоманд и делает команды доступными на том же уровне.

import click

@click.group()
def cli1():
    pass

@cli1.command()
def cmd1():
    """Command on cli1"""
    click.echo('Command cmd1 on cli1')

@click.group()
def cli2():
    pass

@cli2.command()
def cmd2():
    """Command on cli2"""
    click.echo('Command cmd2 on cli2')

test = click.CommandCollection(sources=[cli1, cli2])

if __name__ == '__main__':
    test()

Вывод сценария:

$ python test.py
# Usage: test.py [OPTIONS] COMMAND [ARGS]...
# Options:
#   --help  Show this message and exit.
# Commands:
#   cmd1  Command on cli1
#   cmd2  Command on cli2

$ python ccli.py cmd1
# Command cmd1 on cli1

$ python ccli.py cmd2
# Command cmd2 on cli2

Если команда существует более чем в одном источнике, то выигрывает первый источник.

Цепочка из нескольких подкоманд

Иногда полезно иметь возможность вызывать более одной подкоманды за один раз. Например, пакет setuptools может запускаться с цепочкой команд загрузки setup.py sdist bdist_wheel, которая перед bdist_wheel вызывает sdist .

Начиная с версии click 3.0 это очень просто реализовать. Все, что нужно, это нужно передать аргумент chain=True декоратору @click.group():

import click

@click.group(chain=True)
def test():
    pass


@test.command('first')
def first():
    click.echo('Called `first()`')


@test.command('second')
def second():
    click.echo('Called `second()`')

if __name__ == '__main__':
    test()

Вывод сценария:

$ python3 test.py first second
# Called `first()`
# Called `second()`

$ python3 test.py first
# Called `first()`

$ python3 test.py second
# Called `second()`

При использовании цепочки из нескольких команд, неограниченное число значений параметр (аргумент nargs=-1) может принимать только одна последняя команда. Также невозможно вложить несколько подкоманд ниже связанных мультикоманд. Кроме этого, нет никаких ограничений на то, как они работают. Они могут принимать опции и параметры как обычно.

Порядок между опциями и параметрами для связанных команд ограничен. В настоящее время разрешен только порядок аргументов: --options param (сначала опции, потом параметры).

Еще одно замечание: атрибут Context.invoked_subcommand бесполезен для нескольких команд, так как, если вызывается более одной команды, то он возвращает '*' в качестве значения. Это необходимо, для того чтобы обработка подкоманд происходила одна за другой, следовательно, когда срабатывает обратный вызов, точные подкоманды, которые будут обрабатываться, еще не доступны.

Примечание. В настоящее время цепочки команд не могут быть вложенными. Такое поведение будет дорабатываться в будущих версиях модуля click.

Переопределение значений опций и параметров по умолчанию.

По умолчанию, значение по умолчанию для опции извлекается из аргумента default, при условии, что он передается декоратору @click.option(). Но это не единственное место, откуда можно загрузить значения по умолчанию. Другое место, это словарь в контексте, объект Context.default_map. Такое поведение позволяет загружать значения по умолчанию из файла конфигурации для переопределения значений по умолчанию, переданных, как аргумент декоратору.

Полезно, при подключении некоторых команд из другого пакета, но при этом значения по умолчанию нужны другие.

Словарь ctx.default_map может иметь вложенную структуру для каждой подкоманды:

default_map = {
    # "option": value для группы команд
    "debug": True,  
    # "cmd": {"option": value} для подкоманд 
    "runserver": {"port": 5000}  
}

Словарь ctx.default_map может быть предоставлен ​​при вызове сценария или заменен командами в любой момент. Например, команда верхнего уровня может загрузить значения по умолчанию из файла конфигурации.

Пример использования:

import click

@click.group()
def test():
    pass

@test.command()
@click.option('-p', '--port', default=8000)
def run(port):
    click.echo(f"Serving on http://127.0.0.1:{port}/")

if __name__ == '__main__':
    test(default_map={'run': {'port': 5000}})

Вывод сценария:

$ python3 test.py run
# Serving on http://127.0.0.1:5000/

$ python3 test.py run -p 443
# Serving on http://127.0.0.1:443/

Контекстные значения по умолчанию.

Начиная с версии click 2.0, можно переопределить значения по умолчанию для контекстов не только при вызове сценария, но и в декораторе, который объявляет групповую команду. Например, учитывая предыдущий пример, который определяет словарь default_map, теперь, тоже самое можно сделать прям в декораторе @click.group().

Этот пример делает то же самое, что и предыдущий:

import click

CONTEXT_SETTINGS = dict(
    default_map={'run': {'port': 5000}}
)

@click.group(context_settings=CONTEXT_SETTINGS)
def test():
    pass

@test.command()
@click.option('-p', '--port', default=8000)
def run(port):
    click.echo(f"Serving on http://127.0.0.1:{port}/")

if __name__ == '__main__':
    test()

Вывод сценария:

$ python3 test.py run
# Serving on http://127.0.0.1:5000/

$ python3 test.py run -p 443
# Serving on http://127.0.0.1:443/