1. 程式人生 > >Python 命令列之旅:深入 click 之子命令篇

Python 命令列之旅:深入 click 之子命令篇



作者:HelloGitHub-Prodesire

HelloGitHub 的《講解開源專案》系列,專案地址:https://github.com/HelloGitHub-Team/Article

一、前言

在上兩篇文章中,我們介紹了 click 中的”引數“和“選項”,本文將繼續深入瞭解 click,著重講解它的“命令”和”組“。

本系列文章預設使用 Python 3 作為直譯器進行講解。
若你仍在使用 Python 2,請注意兩者之間語法和庫的使用差異哦~

二、命令和組

Click 中非常重要的特性就是任意巢狀命令列工具的概念,通過 Command 和 Group (實際上是 MultiCommand)來實現。

所謂命令組就是若干個命令(或叫子命令)的集合,也成為多命令。

2.1 回撥呼叫

對於一個普通的命令來說,回調發生在命令被執行的時候。如果這個程式的實現中只有命令,那麼回撥總是會被觸發,就像我們在上一篇文章中舉出的所有示例一樣。不過像 --help 這類選項則會阻止進入回撥。

對於組和多個子命令來說,情況略有不同。回撥通常發生在子命令被執行的時候:

@click.group()
@click.option('--debug/--no-debug', default=False)
def cli(debug):
    click.echo('Debug mode is %s' % ('on' if debug else 'off'))

@cli.command()  # @cli, not @click!
def sync():
    click.echo('Syncing')

執行效果如下:

Usage: tool.py [OPTIONS] COMMAND [ARGS]...

Options:
  --debug / --no-debug
  --help                Show this message and exit.

Commands:
  sync

$ tool.py --debug sync
Debug mode is on
Syncing

在上面的示例中,我們將函式 cli 定義為一個組,把函式 sync 定義為這個組內的子命令。當我們呼叫 tool.py --debug sync 命令時,會依次觸發 clisync 的處理邏輯(也就是命令的回撥)。

2.2 巢狀處理和上下文

從上面的例子可以看到,命令組 cli 接收的引數和子命令 sync 彼此獨立。但是有時我們希望在子命令中能獲取到命令組的引數,這就可以用 Context 來實現。

每當命令被呼叫時,click 會建立新的上下文,並連結到父上下文。通常,我們是看不到上下文資訊的。但我們可以通過 pass_context 裝飾器來顯式讓 click 傳遞上下文,此變數會作為第一個引數進行傳遞。

@click.group()
@click.option('--debug/--no-debug', default=False)
@click.pass_context
def cli(ctx, debug):
    # 確保 ctx.obj 存在並且是個 dict。 (以防 `cli()` 指定 obj 為其他型別
    ctx.ensure_object(dict)

    ctx.obj['DEBUG'] = debug

@cli.command()
@click.pass_context
def sync(ctx):
    click.echo('Debug is %s' % (ctx.obj['DEBUG'] and 'on' or 'off'))

if __name__ == '__main__':
    cli(obj={})

在上面的示例中:

  • 通過為命令組 cli 和子命令 sync 指定裝飾器 click.pass_context,兩個函式的第一個引數都是 ctx 上下文
  • 在命令組 cli 中,給上下文的 obj 變數(字典)賦值
  • 在子命令 sync 中通過 ctx.obj['DEBUG'] 獲得上一步的引數
  • 通過這種方式完成了從命令組到子命令的引數傳遞

2.3 不使用命令來呼叫命令組

預設情況下,呼叫子命令的時候才會呼叫命令組。而有時你可能想直接呼叫命令組,通過指定 click.groupinvoke_without_command=True 來實現:

@click.group(invoke_without_command=True)
@click.pass_context
def cli(ctx):
    if ctx.invoked_subcommand is None:
        click.echo('I was invoked without subcommand')
    else:
        click.echo('I am about to invoke %s' % ctx.invoked_subcommand)

@cli.command()
def sync():
    click.echo('The subcommand')

呼叫命令有:

$ tool
I was invoked without subcommand
$ tool sync
I am about to invoke sync
The subcommand

在上面的示例中,通過 ctx.invoked_subcommand 來判斷是否由子命令觸發,針對兩種情況列印日誌。

2.4 自定義命令組/多命令

除了使用 click.group 來定義命令組外,你還可以自定義命令組(也就是多命令),這樣你就可以延遲載入子命令,這會很有用。

自定義多命令需要實現 list_commandsget_command 方法:

import click
import os

plugin_folder = os.path.join(os.path.dirname(__file__), 'commands')

class MyCLI(click.MultiCommand):

    def list_commands(self, ctx):
        rv = []  # 命令名稱列表
        for filename in os.listdir(plugin_folder):
            if filename.endswith('.py'):
                rv.append(filename[:-3])
        rv.sort()
        return rv

    def get_command(self, ctx, name):
        ns = {}
        fn = os.path.join(plugin_folder, name + '.py')  # 命令對應的 Python 檔案
        with open(fn) as f:
            code = compile(f.read(), fn, 'exec')
            eval(code, ns, ns)
        return ns['cli']

cli = MyCLI(help='This tool\'s subcommands are loaded from a '
            'plugin folder dynamically.')

# 等價方式是通過 click.command 裝飾器,指定 cls=MyCLI
# @click.command(cls=MyCLI)
# def cli():
#     pass

if __name__ == '__main__':
    cli()

2.5 合併命令組/多命令

當有多個命令組,每個命令組中有一些命令,你想把所有的命令合併在一個集合中時,click.CommandCollection 就派上了用場:


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

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

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

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

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

if __name__ == '__main__':
    cli()

呼叫命令有:

$ cli --help
Usage: cli [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  cmd1  Command on cli1
  cmd2  Command on cli2

從上面的示例可以看出,cmd1cmd2 分別屬於 cli1cli2,通過 click.CommandCollection 可以將這些子命令合併在一起,將其能力提供個同一個命令程式。

Tips:如果多個命令組中定義了同樣的子命令,那麼取第一個命令組中的子命令。

2.6 鏈式命令組/多命令

有時單級子命令可能滿足不了你的需求,你甚至希望能有多級子命令。典型地,setuptools 包中就支援多級/鏈式子命令: setup.py sdist bdist_wheel upload。在 click 3.0 之後,實現鏈式命令組變得非常簡單,只需在 click.group 中指定 chain=True

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


@cli.command('sdist')
def sdist():
    click.echo('sdist called')


@cli.command('bdist_wheel')
def bdist_wheel():
    click.echo('bdist_wheel called')

呼叫命令則有:

$ setup.py sdist bdist_wheel
sdist called
bdist_wheel called

2.7 命令組/多命令管道

鏈式命令組中一個常見的場景就是實現管道,這樣在上一個命令處理好後,可將結果傳給下一個命令處理。

實現命令組管道的要點是讓每個命令返回一個處理函式,然後編寫一個總的管道排程函式(並由 MultiCommand.resultcallback() 裝飾):

@click.group(chain=True, invoke_without_command=True)
@click.option('-i', '--input', type=click.File('r'))
def cli(input):
    pass

@cli.resultcallback()
def process_pipeline(processors, input):
    iterator = (x.rstrip('\r\n') for x in input)
    for processor in processors:
        iterator = processor(iterator)
    for item in iterator:
        click.echo(item)

@cli.command('uppercase')
def make_uppercase():
    def processor(iterator):
        for line in iterator:
            yield line.upper()
    return processor

@cli.command('lowercase')
def make_lowercase():
    def processor(iterator):
        for line in iterator:
            yield line.lower()
    return processor

@cli.command('strip')
def make_strip():
    def processor(iterator):
        for line in iterator:
            yield line.strip()
    return processor

在上面的示例中:

  • cli 定義為了鏈式命令組,並且指定 invoke_without_command=True,也就意味著可以不傳子命令來觸發命令組
  • 定義了三個命令處理函式,分別對應 uppercaselowercasestrip 命令
  • 在管道排程函式 process_pipeline 中,將輸入 input 變成生成器,然後呼叫處理函式(實際輸入幾個命令,就有幾個處理函式)進行處理

2.8 覆蓋預設值

預設情況下,引數的預設值是從通過裝飾器引數 default 定義。我們還可以通過 Context.default_map 上下文字典來覆蓋預設值:

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

@cli.command()
@click.option('--port', default=8000)
def runserver(port):
    click.echo('Serving on http://127.0.0.1:%d/' % port)

if __name__ == '__main__':
    cli(default_map={
        'runserver': {
            'port': 5000
        }
    })

在上面的示例中,通過在 cli 中指定 default_map 變可覆蓋命令(一級鍵)的選項(二級鍵)預設值(二級鍵的值)。

我們還可以在 click.group 中指定 context_settings 來達到同樣的目的:


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

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

@cli.command()
@click.option('--port', default=8000)
def runserver(port):
    click.echo('Serving on http://127.0.0.1:%d/' % port)

if __name__ == '__main__':
    cli()

呼叫命令則有:

$ cli runserver
Serving on http://127.0.0.1:5000/

三、總結

本文首先介紹了命令的回撥呼叫、上下文,再進一步介紹命令組的自定義、合併、連結、管道等功能,瞭解到了 click 的強大。而命令組中更加高階的能力(如命令返回值)則可看官方文件進一步瞭解。

我們通過介紹 click 的引數、選項和命令已經能夠完全實現命令列程式的所有功能。而 click 還為我們提供了許多錦上添花的功能,比如實用工具、引數自動補全等,我們將在下節詳細介紹。


『講解開源專案系列』——讓對開源專案感興趣的人不再畏懼、讓開源專案的發起者不再孤單。跟著我們的文章,你會發現程式設計的樂趣、使用和發現參與開源專案如此簡單。歡迎留言聯絡我們、加入我們,讓更多人愛上開源、貢獻開源