Click命令行接口工具


本文主要参考和对官方文档进行二次浓缩而成

Click 是一个利用很少的代码以可组合的方式创造优雅命令行工具接口的 Python 库。 它是高度可配置的,但却有合理默认值的命令行接口创建工具。它致力于将创建命令行工具的过程变的快速而有趣,免除你因无法实现一个 CLIAPI 的挫败感。

  • 支持任意嵌套命令
  • 自动生成帮助页面
  • 支持在运行时延迟加载子命令

第三方扩展库之click

下面,我们一起看一个官方文档中提及的 click 项目的使用示例代码。Click 是通过装饰器声明命令的,在内部高级用例有一个非装饰器接口,但不鼓励直接使用高级用法。一个函数通过装饰器 click.command() 成为一个 Click 命令行工具 。

import click

@click.command()
@click.option('--count', default=1, help='Number of greetings.')
@click.option('--name', prompt='Your name', help='The person to greet.')
def hello(count, name):
    """Simple program that greets NAME for a total of COUNT times."""
    for x in range(count):
        click.echo('Hello %s!' % name)

if __name__ == '__main__':
    hello()
# 当它运行的时候是这样的
$ python hello.py --count=3
Your name: John
Hello John!
Hello John!
Hello John!

# 自动生成美观的格式化帮助页面
$ python hello.py --help
Usage: hello.py [OPTIONS]
  Simple program that greets NAME for a total of COUNT times.
Options:
  --count INTEGER  Number of greetings.
  --name TEXT      The person to greet.
  --help           Show this message and exit.

1. 添加方式

参数(包括选项和参数)都接受一些参数声明的位置参数。

Click 支持两种类型的脚本参数形式:选项(option)参数(argument) 。正如其名称所示,选项是可选的,虽然参数在合理的范围内是可选的,但是它们在选择的方式上会受到更多的限制。参数功能略少于选项,以下功能仅适用于选项。另一方面与选项不同,参数可以接受任意数量的参数,选项可以严格地只接受固定数量的参数,默认是一个。

  • 选项可自动提示缺少输入
  • 选项可作为标志(布尔值或其他)
  • 选项值可以从环境变量中拉出来,但参数不能
  • 选项能完整记录在帮助页面中,但参数不能
# 分别使用了两种装饰器
@click.command()
@click.option('--count', default=1, help='number of greetings')
@click.argument('name')
def hello(count, name):
    for x in range(count):
        click.echo('Hello %s!' % name)
# 运行效果如下所示
$ python hello.py --help
Usage: hello.py [OPTIONS] NAME

Options:
  --count INTEGER  number of greetings
  --help           Show this message and exit.

2. 选项使用

选项不是位置参数

选项通过 option() 装饰器给命令增加选项,使用配置参数来控制不同的选项类型。这里有一个坑,那就是 Click 中的选项不同于函数中的位置参数,所以在使用的时候需要特别注意下。

  • 基本使用
# dots --n=2
@click.command()
@click.option('--n', default=1)
def dots(n):
    click.echo('.' * n)
  • 提示信息
# hello
@click.command()
@click.option('--name', prompt='Your name please')
def hello(name):
    click.echo('Hello %s!' % name)
  • 密码提示
# encrypt
@click.command()
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True)
def encrypt(password):
    click.echo('Encrypting password to %s' % password.encode('rot13'))
# encrypt
@click.command()
@click.password_option()
def encrypt(password):
    click.echo('Encrypting password to %s' % password.encode('rot13'))
  • 变量获取
@click.command()
@click.option('--username', prompt=True, default=lambda: os.environ.get('USER', ''))
def hello(username):
    print("Hello,", username)
  • 多值选项
# findme --pos 2.0 3.0
@click.command()
@click.option('--pos', nargs=2, type=float)
def findme(pos):
    click.echo('%s / %s' % pos)
# commit -m foo -m bar
@click.command()
@click.option('--message', '-m', multiple=True)
def commit(message):
    click.echo('\n'.join(message))
  • 元组选项
# putitem --item peter 1338
@click.command()
@click.option('--item', type=(unicode, int))
def putitem(item):
    click.echo('name=%s id=%d' % item)
# putitem --item peter 1338
@click.command()
@click.option('--item', nargs=2, type=click.Tuple([unicode, int]))
def putitem(item):
    click.echo('name=%s id=%d' % item)
  • 选项计数
# log -vvv
@click.command()
@click.option('-v', '--verbose', count=True)
def log(verbose):
    click.echo('Verbosity: %s' % verbose)
  • 布尔标记
# info --shout
# info --no-shout
import sys
@click.command()
@click.option('--shout/--no-shout', default=False)
def info(shout):
    rv = sys.platform
    if shout:
        rv = rv.upper() + '!!!!111'
    click.echo(rv)
# info --upper
# info --lower
import sys
@click.command()
@click.option('--upper', 'transformation', flag_value='upper', default=True)
@click.option('--lower', 'transformation', flag_value='lower')
def info(transformation):
    click.echo(getattr(sys.platform, transformation)())
  • 选择选项
# digest --hash-type=md5
# digest --hash-type=sha1
@click.command()
@click.option('--hash-type', type=click.Choice(['md5', 'sha1']))
def digest(hash_type):
    click.echo(hash_type)
  • Yes 参数
# dropdb
# dropdb --yes
def abort_if_false(ctx, param, value):
    if not value:
        ctx.abort()

@click.command()
@click.option('--yes', is_flag=True, callback=abort_if_false,
              expose_value=False,
              prompt='Are you sure you want to drop the db?')
def dropdb():
    click.echo('Dropped all tables!')
# dropdb
# dropdb --yes
@click.command()
@click.confirmation_option(prompt='Are you sure you want to drop the db?')
def dropdb():
    click.echo('Dropped all tables!')
  • 前缀参数
# chmod +w
# chmod -w
@click.command()
@click.option('+w/-w')
def chmod(w):
    click.echo('writable=%s' % w)

if __name__ == '__main__':
    chmod()
  • 范围选项
# repeat --count=1000 --digit=5
@click.command()
@click.option('--count', type=click.IntRange(0, 20, clamp=True))
@click.option('--digit', type=click.IntRange(0, 10))
def repeat(count, digit):
    click.echo(str(digit) * count)

if __name__ == '__main__':
    repeat()
  • 优先选项和回调选项
# hello
# hello --version
def print_version(ctx, param, value):
    if not value or ctx.resilient_parsing:
        return
    click.echo('Version 1.0')
    ctx.exit()

@click.command()
@click.option('--version', is_flag=True, callback=print_version,
              expose_value=False, is_eager=True)
def hello():
    click.echo('Hello World!')
  • 回调函数进行验证
# roll --rolls=42
def validate_rolls(ctx, param, value):
    try:
        rolls, dice = map(int, value.split('d', 2))
        return (dice, rolls)
    except ValueError:
        raise click.BadParameter('rolls need to be in format NdM')

@click.command()
@click.option('--rolls', callback=validate_rolls, default='1d6')
def roll(rolls):
    click.echo('Rolling a %d-sided dice %d time(s)' % rolls)

if __name__ == '__main__':
    roll()

3. 参数使用

参数是位置参数

由于其语法性质,参数也仅支持 options全部功能的一个子集,Click 不会记录参数,希望你手动记录它们以避免跳出帮助页面。

  • 基本参数
# touch foo.txt
@click.command()
@click.argument('filename')
def touch(filename):
    click.echo(filename)
  • 可变参数
# copy foo.txt bar.txt my_folder
@click.command()
@click.argument('src', nargs=-1)
@click.argument('dst', nargs=1)
def copy(src, dst):
    for fn in src:
        click.echo('move %s to folder %s' % (fn, dst))
  • 文件参数
# inout - hello.txt
# inout hello.txt -
@click.command()
@click.argument('input', type=click.File('rb'))
@click.argument('output', type=click.File('wb'))
def inout(input, output):
    while True:
        chunk = input.read(1024)
        if not chunk:
            break
        output.write(chunk)
  • 文件路径参数
# touch hello.txt
@click.command()
@click.argument('f', type=click.Path(exists=True))
def touch(f):
    click.echo(click.format_filename(f))
  • 环境变量
# export SRC=hello.txt
# echo
@click.command()
@click.argument('src', envvar='SRC', type=click.File('r'))
def echo(src):
    click.echo(src.read())
  • Option-Like 参数
# touch -- -foo.txt bar.txt
@click.command()
@click.argument('files', nargs=-1, type=click.Path())
def touch(files):
    for filename in files:
        click.echo(filename)

4. 命令嵌套

将多个命令进行排列组合,使工具更加复杂。

顾名思义,命令可以附加到其他 Group 类型的命令之中,下面例子中的脚本实现了两个管理数据库相关的命令。正如你所看到的那样, group() 装饰器就像 command() 装饰器一样工作,但创建一个 Group 对象,可以通过 Group.add_command() 赋予多个可以附加的子命令。

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

@click.command()
def initdb():
    click.echo('Initialized the database')

@click.command()
def dropdb():
    click.echo('Dropped the database')

cli.add_command(initdb)
cli.add_command(dropdb)

对于简单的脚本,也可以使用 Group.command() 装饰器自动附加和创建命令,我们将上面的脚本可以,如下方式的改写,即可简单代码。

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

@cli.command()
@click.argument("date")
def initdb(date):
    click.echo(f'[{date}] Initialized the database')

@cli.command()
@click.option('--count', default=1, type=click.IntRange(1, 100000))
def dropdb(count):
    click.echo(f'[{count}] Dropped the database')
  • 回调调用
# tool.py --debug sync
@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()
def sync():
    click.echo('Synching')
  • 合并多个指令
# cli --help
import click

@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()
  • 嵌套处理和上下文
@click.group()
@click.option('--debug/--no-debug', default=False)
@click.pass_context
def cli(ctx, debug):
    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={})

5. 实用功能

这里介绍一下其他有趣的小功能,可以使我们写的工具更加出彩。

  • [1] 用户输入提示

Click 支持两个不同地方的提示,第一种是参数处理发生时自动提示,第二种是后来单独要求提示。这可以通过 prompt() 功能实现,该功能要求根据类型进行有效输入,或者使用 confirm() 功能,并在使用时要求确认的功能是/否。

# 输入提示
value = click.prompt('Please enter a number', default=42.0)
value = click.prompt('Please enter a valid integer', type=int)

# 确认提示
if click.confirm('Do you want to continue?'):
    click.echo('Well done!')
click.confirm('Do you want to continue?', abort=True)
  • [2] 彩色字体打印

Click 2.0 开始,该 echo() 函数获得了处理ANSI颜色和样式的额外功能。 在Windows上,此功能仅在安装了 colorama 时可用。如果安装,则ANSI代码将被智能处理。

import click

click.echo(click.style('Hello World!', fg='green'))
click.echo(click.style('Some more text', bg='blue', fg='white'))
click.echo(click.style('ATTENTION', blink=True, bold=True))
  • [3] 分页支持

在某些情况下,您可能希望在终端上显示长文本,并让用户滚动浏览。这可以通过使用与 echo_via_pager() 函数类似的 echo() 函数来实现 ,但是总是写入标准输出,如果可能的话,可以使用分页器。

@click.command()
def less():
    click.echo_via_pager('\n'.join('Line %d' % idx for idx in range(200)))
  • [4] 屏幕清除

Click 2.0开始提供的功能,要清除终端屏幕,您可以使用 clear()。它的名字的确如此:它以平台不可知的方式清除整个可见的屏幕。

import click
click.clear()
  • [5] 从终端获取字符

通常,当从终端读取输入时,您将从标准输入读取。但是,这是缓冲输入,直到线路终止才显示。在某些情况下,您可能不想这样做,而是在写入时读取单个字符。为此,Click提供了 getchar() 函数,从终端缓冲区读取单个字符并将其作为Unicode字符返回的功能。

import click

click.echo('Continue? [yn] ', nl=False)
c = click.getchar()
click.echo()
if c == 'y':
    click.echo('We will go on')
elif c == 'n':
    click.echo('Abort!')
else:
    click.echo('Invalid input :(')
  • [6] 打印文件名

由于文件名可能不是Unicode,格式化它们可能有点棘手。通常,在Python2中比在Python3上更容易,因为你可以用这个 print 函数把字节写到stdout中,但是在Python3中,你总是需要使用Unicode。这与点击工作的方式是通过 format_filename() 它尽最大努力将文件名转换为Unicode,永远不会失败。这使得可以在完整的Unicode字符串的上下文中使用这些文件名。

click.echo('Path: %s' % click.format_filename(b'foo.txt'))
  • [7] 智能文件打开

Click 3.0开始, File 通过 open_file() 函数公开从文件中打开文件的逻辑,它可以智能地打开标准输入/标准输出以及任何其他文件。

with click.open_file(filename, 'w') as f:
    f.write('Hello World!\n')
  • [8] 显示进度条

有时候,你有命令行脚本需要处理大量的数据,但是你想要快速地向用户展示一下这个过程需要多长时间。点击支持通过该 progressbar() 函数进行简单的进度条渲染。基本用法非常简单:这个想法是你有一个你想要操作的迭代器。对于迭代中的每个项目,可能需要一些时间来处理。

import click

with click.progressbar(all_the_users_to_process) as bar:
    for user in bar:
        modify_the_user(user)

Click 会自动将进度条打印到终端,并计算剩余的时间。剩余时间的计算要求可迭代的长度。如果它没有长度,但你知道长度,你可以明确地提供它。

with click.progressbar(all_the_users_to_process, length=number_of_users) as bar:
    for user in bar:
        modify_the_user(user)

另一个有用的功能是将标签与将在进度条之前显示的进度条相关联

with click.progressbar(all_the_users_to_process,
                       label='Modifying user accounts',
                       length=number_of_users) as bar:
    for user in bar:
        modify_the_user(user)

有时,可能需要迭代外部迭代器,并不规则地前进进度条。为此,您需要指定长度(并且不可迭代),并在上下文返回值上使用 update 方法,而不是直接在其上进行迭代。

with click.progressbar(length=total_size,
                       label='Unzipping archive') as bar:
    for archive in zip_file:
        archive.extract()
        bar.update(archive.size)

6. 高级功能

介绍官网文档中重要的高级功能和特性!

  • [1] 类装饰使用

如果我们需要一个可以容纳所有命令的组,在这种情况下,我们使用 click.group() 允许我们在其下面注册其他 Click 命令。

import click

class Repo(object):
    def __init__(self, home=None, debug=False):
        self.home = os.path.abspath(home or '.')
        self.debug = debug

    def hello(self):
        print('hello world!')

@click.command()
@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)
    ctx.obj.hello()

if __name__ == "__main__":
    cli()
import click

class Repo(object):
    def __init__(self, home=None, debug=False):
        self.home = os.path.abspath(home or '.')
        self.debug = debug

    def hello(self):
        print('hello world!')

@click.command()
@click.argument('repo-home')
@click.argument('debug', default=False)
@click.pass_context
def cli(ctx, repo_home, debug):
    ctx.obj = Repo(repo_home, debug)
    ctx.obj.hello()

if __name__ == "__main__":
    cli()
import os
import click

class Environment:
    def __init__(self):
        self.home = os.getcwd()

    def get_home(self, home):
        """Logs a message to stderr."""
        if not home is None:
            self.home = home
        print(f'{self.home}')

pass_environment = click.make_pass_decorator(Environment, ensure=True)

@click.command()
@click.option('--home', help='test home')
@pass_environment
def cli(ctx, home):
    ctx.verbose = home
    ctx.get_home('/home/escape')

if __name__ == "__main__":
    cli()
import os
import click
import posixpath

class Repo:
    def __init__(self, home):
        self.home = home
        self.config = {}
        self.verbose = False

    def set_config(self, key, value):
        self.config[key] = value
        if self.verbose:
            click.echo(f"  config[{key}] = {value}", file=sys.stderr)

    def __repr__(self):
        return f"<Repo {self.home}>"

pass_repo = click.make_pass_decorator(Repo)

@click.group()
@click.option("--repo-home", envvar="REPO_HOME", default=".repo", metavar="PATH", help="Changes the repository folder location.")
@click.option("--config", nargs=2, multiple=True, metavar="KEY VALUE", help="Overrides a config key/value pair.")
@click.option("--verbose", "-v", is_flag=True, help="Enables verbose mode.")
@click.pass_context
def cli(ctx, repo_home, config, verbose):
    ctx.obj = Repo(os.path.abspath(repo_home))
    ctx.obj.verbose = verbose
    for key, value in config:
        ctx.obj.set_config(key, value)

@cli.command()
@click.argument("src")
@click.argument("dest", required=False)
def clone(repo, src, dest):
    if dest is None:
        dest = posixpath.split(src)[-1] or "."
    click.echo(f"Cloning repo {src} to {os.path.basename(dest)}")
    repo.home = dest

@cli.command()
@click.option("--username", prompt=True, help="The developer's shown username.")
@click.option("--email", prompt="E-Mail", help="The developer's email address")
@click.password_option(help="The login password.")
@pass_repo
def setuser(repo, username, email, password):
    repo.set_config("username", username)
    repo.set_config("email", email)
    repo.set_config("password", "*" * len(password))
    click.echo("Changed credentials.")

if __name__ == "__main__":
    cli()
  • [2] 多命令管道

多命令管道,顾名思义就是让一个命令处理前一个命令的结果,有多种方法可以实现该功能。最显而易见的方法是在上下文对象上存储一个值,并将其从一个函数处理到另一个函数。这可以通过装饰一个 pass_context() 函数来完成,在这个函数之后提供上下文对象,子命令可以在那里存储它的数据。

另一种方法是通过返回处理函数来设置管线。可以这样想:当一个子命令被调用时,它处理所有的参数,并提出一个如何处理的计划。在那时,它返回一个处理函数并返回。链式多命令可以通过 resultcallback() 遍历所有这些函数来注册回调,然后调用它们。

import click

@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

if __name__ == "__main__":
    cli()
  • [3] 上下文默认值

Click 2.0 开始,不仅可以在调用脚本时覆盖上下文的默认值,还可以在声明命令的装饰器中覆盖默认值。举个例子,前面定义了一个自定义的例子,default_map 现在也可以在装饰器中完成。

import click

@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
        }
    })
import click

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()

7. Bash 参数补全

即打包之后,通过命令行可以直接补全使用。

Click 2.0 中的任何 Click 脚本都支持 Bash 参数补全。在完成这项工作时,会有一些限制,但大多数情况下,它是可以正常工作的。只有当脚本正确安装时,才可以使用 Bash 参数补全,而不是通过 python 命令执行。 如何做到这一点,参见 Setuptools 集成。此外,Click 目前只支持 Bash 参数补全。

$ repo <TAB><TAB>
clone    commit   copy     delete   setuser

$ repo clone -<TAB><TAB>
--deep     --help     --rev      --shallow  -r

8. Setuptools 集成

说白了就是,将工具打包成为库进行使用,方便且简单。

就目前你写的代码,文件末尾有一个类似这种的代码块: if __name__ == '__main__':,这是一个传统独立的 Python 文件格式。使用 Click 你可以继续这样做,但使用 setuptools 是一个更好的办法。

而使用setuptools的两个主要的原因是:第一个原因是 setuptools 自动为 Windows 生成可执行的包装器,所以你的命令行工具也可以在 Windows的系统上面工作。第二个原因是 setuptools 脚本和 Unix 上的 virtualenv 一起工作,而不需要激活 virtualenv。这是一个非常有用的概念,它允许你将你的脚本和所有依赖绑定到一个虚拟环境中。

# 目录结构
.
|__ setup.py
|__ yourscript.py
# yourscript.py内容
import click
@click.command()
def cli():
    """Example script."""
    click.echo('Hello World!')
# setup.py内容
from setuptools import setup
setup(
    name='yourscript',
    version='0.1',
    py_modules=['yourscript'],
    install_requires=[
        'Click',
    ],
    entry_points='''
        [console_scripts]
        yourscript=yourscript:cli
    ''',
)
# 测试效果(创建新环境并安装)
$ virtualenv venv
$ . venv/bin/activate
$ pip install --editable .

$ yourscript
Hello World!

文章作者: Escape
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Escape !
  目录