使用Poetry和Typer的Python CLI实用程序

839 阅读15分钟

所以,你已经有了一个要建立的东西的想法--真棒!但是,也许那段代码并没有真正意义上被建立成可服务的东西,比如说webapp。- 但也许那段代码并没有真正意义上的建立在可服务的东西上,比如webapp。相反,你想做一个可共享的工具,这样其他用户就可以即时运行你的工具。把你的代码构建成一个合适的Python包是很好的,但是你可能并不真的需要其他用户把你的代码,在一个细微的层次上,整合到他们自己的代码中--相反,他们可能只需要能够以一种临时的方式,用相对较少的选项来触发工具。在这一点上,你真正谈论的是建立一个可执行的工具,而不是一个包。

不幸的是,Python在构建独立的可执行文件方面没有一个精彩的故事(尽管像 PyInstaller等工具已经取得了长足的进步),这是由于 Python 环境处理依赖关系的方式。没有一种标准化的方法可以将可执行代码、它的依赖关系和 (潜在的) 运行时信息捆绑到一个可分发的块中,就像一个胖胖的 Java.jar 文件或 Go 可执行文件那样。

然而,有一个中间地带!Python 确实有一种很好的方法,可以把脚本和相关的代码打包到它的包里,以创建 (相对) 封闭的命令行工具。虽然这仍然有那些环境依赖 (也就是说,它被安装到一个特定的Python 环境中,并且需要在那里安装外部依赖),但这样的工具是pip-安装的,并且从用户的角度来看,可以被视为独立的。这种模式在常见的Python工具中极为常见,从核心管理工具如pipvirtualenv 到代码质量工具如blackflake8mypy

尽管看起来令人生畏,但在Python中构建这种类型的命令行实用程序实际上是非常容易的在这篇文章中,我们将讨论如何用一些令人兴奋的Python新工具来构建CLI工具,包括。

  • 构建一个软件包 poetry:这给我们带来了很多好处,比如跨环境和构建控制的集成工具,以及完全指定的、确定的依赖性解析
  • 写一个命令行脚本:我们将使用新的 typer库,因为它简洁的设计和对 Python 类型注释的巧妙使用。
  • 集成CLI:将脚本烘烤到软件包的入口中,使之成为一个可安装的工具

这篇文章的完整docker化代码,包括额外的配置、构建工具和功能,可以在这里找到。我们不会涉及的东西。

  • 单元测试设计(尽管链接的示例代码包括了包测试)
  • CI或部署,因为这是高度组织化的 - 然而,poetry 与任何标准的Python包索引(即公共的PyPI,或私人的工件存储)都能很好地配合。

让我们开始吧!

建立一个包

首先,让我们考虑一下我们的工具将做什么的想法。我最近一直在玩《龙与地下城》(我们在写博客文章时得到了花哨的骰子),所以一个掷骰子的应用程序听起来不错--让我们为此做一些事情吧

首先,我们希望能够指定要掷出的骰子数量和它们的大小(即面数)--让它返回一个单独掷出的列表(按降序排序,以备我们想从较大的掷出中挑选)和它们的总数。接下来,我们希望能够用我们常用的速记方法来指定掷骰子--例如,写 "2D6 "来指定掷两个六面骰子--因此我们希望有一个函数能够将这样的字符串解析为我们第一个函数的数字输入。首先,我们可以把这些函数写在一个文件中......让我们把它叫做dice.py

import re
from typing import Tuple, List
import random


def roll(num_dice: int = 1, sides: int = 20) -> Tuple[List[int], int]:
    rolls = sorted(
        [random.choice(range(1, sides + 1)) for _ in range(num_dice)], reverse=True
    )
    return (rolls, sum(rolls))


def parse_dice_string(dice_string: str) -> Tuple[int, int]:
    # extract digits from dice-roll strings like "2D6" with regex witchcraft
    hit = re.search(r"(\d*)[dD](\d+)", dice_string)
    if not hit:
        raise ValueError("bad string")

    count, sides = hit.groups()
    count_int = int(count or 1)  # regex hits on "" for 1st digit, munge to 1
    sides_int = int(sides)
    return (count_int, sides_int)


def roll_from_string(dice_string: str) -> Tuple[List[int], int, str]:
    count, sides = parse_dice_string(dice_string)
    rolls, total = roll(num_dice=count, sides=sides)
    return (rolls, total, f"{count}D{sides}")

(我们暂时跳过为这些写文档,而依靠有用的变量名和Python 的类型提示来引导人们阅读我们的代码--示例代码有更详尽的文档)。

接下来,让我们尝试将其构建为一个包--我们将使用 poetry工具,因为它为我们提供了不少好处,比如环境和构建控制的集成工具,以及确定性的依赖性解决。简单地运行poetry new roll-the-dice ,就可以为我们创建一个准备好的项目目录。

roll-the-dice/
|-- roll_the_dice/
|   |-- __init__.py
|   |-- dice.py
|-- tests/
|   |-- __init__.py
|   |-- test_dice.py
|-- pyproject.toml
|-- README.rst

(当然是在添加我们的dice.py 和编写一些适当的单元测试之后)。通过在目录中根据需要嵌套代码 (每个目录都有一个__init__.py 文件),我们表明目录结构应该被解释为一个模块路径 - 也就是说,我们可以从roll_the_dice.dice 模块导入我们的函数。我们需要管理项目的环境和构建的一切都在pyproject.toml

[tool.poetry]
name = "roll-the-dice"
version = "0.0.1"
description = "a roll the dice CLI"
authors = [
    "John Walk <[email protected]>"
]

[tool.poetry.dependencies]
python = "^3.7"
typer = "^0.0.8"

[tool.poetry.dev-dependencies]
pytest = "^5.2"

[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

(更多关于poetry 的配置选项可以在这里找到。)有了这些设置,只需运行poetry install ,就可以创建一个安装了我们指定的依赖项的虚拟环境,并准备好使用软件包代码。运行poetry shell ,将在该虚拟环境中启动一个shell,用于交互式测试,我们可以用poetry run ,在环境中运行shell命令(例如,poetry run pytest 将在项目根部运行我们的单元测试,自动发现项目结构中的测试文件)。最终,我们可以使用poetry build -f wheelpoetry publish ,将我们的软件包组装成一个wheel 文件,并将其推送到PyPI(或任何其他我们希望的软件包索引)。

写一个命令行脚本

从本质上讲,在Python中编写一个简单的命令行脚本是很简单的--为了得到相当于在Python shell中运行单个命令的行为,我们只需要一个文件(我们称之为cli.py ),其结构是这样的。

def do_something():
    # functions that do some things...
    ...


if __name__ == "__main__":
    do_something()

其中从命令行运行python cli.py 将执行最后的if 块中指定的任何命令(即本例中的do_something() )。这看起来有点玄乎,但一般来说我们不需要担心这个问题--简单的解释是,Python 会自动为任何代码设置一个__name__ 属性到模块级 (例如,import foo 将为其中的代码设置__name__"foo" ),其中"__main__" 是用于从命令行执行顶层的保留名称。可以说,该块在直接从命令行调用时被执行,而在所有其他情况下被忽略(比如用户导入cli.py ),所以它为我们的脚本提供了一个方便的钩子。

接下来,我们要考虑管理命令行接口,因为仅仅调用静态代码是有点愚蠢的。在标准库中,Python 提供了一个叫做argparse 的包,它可以为一个函数建立命令行参数,但是它需要一个有点不透明的(但高度灵活的)结构来连接命令行参数和内部传递给 Python 函数的参数。新的 typer库 (建立在同样出色的 click)使之变得更加简单,通过使用 Python 的新类型注解系统将命令行参数直接植入 Python 函数调用。Typer 是由 FastAPI (我的另一个最爱) 的同一个设计者建立的,并且利用了许多相同的设计决定来实现这种聪明性。

首先,我们来看看创建一个非常简单的脚本。

import typer


def hello_world():
    """our first CLI with typer!
    """
    typer.echo("Opening blog post...")
    typer.launch(
        "https://pluralsight.com/tech-blog/python-cli-utilities-with-poetry-and-typer"
    )


if __name__ == "__main__":
    typer.run(hello_world)

对于简单的命令,只需在一个函数上调用typer.run 就足够了--像python cli.py 那样从命令行中调用它就会触发脚本,打印到你的命令行,并在浏览器中启动这篇博文。调用typer ,还增加了一些好处,比如通过typer.echo ,在终端进行语法高亮和着色。它甚至开始建立文档和调用选项--运行python cli.py --help ,会显示一个帮助信息,包括函数的文档串内容和任何参数或选项(包括自动生成的--help 标志)。

我们可以通过实例化一个typer 应用程序来增加更多的控制,而不是直接调用run ,我们可以将上述内容写成

import typer

app = typer.Typer()


@app.command("hello")
def hello_world()
    ...  # contents from the function above


if __name__ == "__main__":
    app()

创建一个应用程序,然后将命令绑定到它。如果你熟悉Flask或FastAPI(与typer 为同一作者所著)来构建webapp,这种模式应该很熟悉--但你不是在创建一个webapp,而是在为一个命令行app创建端点。这让我们可以通过绑定多个函数来为脚本创建多个子命令(就像webapp上的端点一样),并让我们增加一些额外的功能,比如指定命令的名称(在本例中是python cli.py hello )来覆盖函数的名称,以便进行清洁调用。

接下来,让我们编写第一个有用的命令(也就是使用我们的roll_the_dice 工具的命令)--首先,我们将编写一个从输入字符串中掷出骰子的命令。这意味着我们需要能够智能地处理对我们脚本的命令行输入。在像argparse 这样的工具中,这需要创建一个单独的对象来处理参数,以某种迂回的方式调用它们。但在typer ,我们只需要用类型注解将参数添加到函数本身,CLI应用程序就会处理其余的部分(同样,这种模式对任何在FastAPI中做过请求参数处理的人来说都很熟悉)。

@app.command("roll-str")
def roll_string(dice_str: str):
    """Rolls the dice from a formatted string.

    We supply a formatted string DICE_STR describing the roll, e.g. '2D6'
    for two six-sided dice.
    """
    try:
        rolls_list, total, formatted_roll = roll_from_string(dice_str)
    except ValueError:
        typer.echo(f"invalid roll string: {dice_str}")
        raise typer.Exit(code=1)

    typer.echo(f"rolling {formatted_roll}!\n")
    typer.echo(f"your roll: {total}\n")

typer 应用程序会自动接收这个函数参数和类型注释,并将其构建为脚本调用的位置参数--我们可以这样调用

$ python cli.py roll-str 2D6

并且typer ,正确地处理输入参数(注意,我们用app.command 中指定的名称访问命令)。这个应用程序自动处理调用中缺失的或多余的参数,并给我们提供了一种简单的方法,将Python错误(比如由格式不好的字符串引发的ValueError )挂到CLI错误中。它甚至可以使用注释和函数的文档串为命令生成一个有用的帮助字符串。

$ python cli.py roll-str --help
Usage: cli.py roll-str [OPTIONS] DICE_STR

  Rolls the dice from a formatted string.

  We supply a formatted string DICE_STR describing the roll, e.g. '2D6' for
  two six-sided dice.

Options:
  --help                Show this message and exit.

我们也可以对我们的命令行选项进行更花哨的处理。虽然typer 将把注释的关键字参数解释为选项或标志(就像Python函数的位置参数被当作CLI的必要参数一样),但它也提供了辅助函数,以实现更大的控制。我们可以使用typer.Argumenttyper.Option 命令来处理位置参数和关键字输入,让我们设置诸如帮助字符串、标志名称的覆盖和基本的输入验证。

我们将使用Option 标志,用于直接从数字输入中掷骰子的命令的输入(即我们明确传递要掷的骰子的数量和大小),这样我们可以选择省略选项,而使用默认值。也就是说,我们要像这样调用函数来掷出一对D20。

$ python cli.py roll-num -n 2 -d 20 --rolls

(或者我们可以跳过任何一个输入,使用函数中定义的默认值)。typer 应用程序正确解释了这些选项的关键字参数。

@app.command("roll-num")
def roll_num(
    num_dice: int = typer.Option(
        1, "-n", "--num-dice", help="number of dice to roll", show_default=True, min=1
    ),
    sides: int = typer.Option(
        20, "-d", "--sides", help="number-sided dice to roll", show_default=True, min=1
    ),
    rolls: bool = typer.Option(
        False, help="set to display individual rolls", show_default=True
    ),
):
    """Rolls the dice from numeric inputs.

    We supply the number and side-count of dice to roll with option arguments.
    """
    rolls_list, total = roll(num_dice=num_dice, sides=sides)

    typer.echo(f"rolling {num_dice}D{sides}!\n")
    typer.echo(f"your roll: {total}\n")
    if rolls:
        typer.echo(f"made up of {rolls_list}\n")

Option 旗帜让我们指定旗帜名称(包括短版和长版,如-d for--sides ),为旗帜设置帮助字符串,甚至为输入强制执行最小或最大值。所有这些额外的设置都自动包含在命令的帮助字符串中。

$ python cli.py roll-num --help
Usage: cli.py roll-num [OPTIONS]

  Rolls the dice from numeric inputs.

  We supply the number and side-count of dice to roll with option arguments.

Options:
  -n, --num-dice INTEGER RANGE  number of dice to roll  [default: 1]
  -d, --sides INTEGER RANGE     number-sided dice to roll  [default: 20]
  --rolls / --no-rolls          set to display individual rolls  [default:
                                False]
  --help                        Show this message and exit.

Typer甚至为布尔标记添加了一些聪明的功能,自动生成flagno-flag 选项,而不是要求用户传递真/假值。(在这里可以找到一些其他聪明的参数处理技术,如日期、文件路径和命令的枚举选项。)对于像这样的多命令脚本,typer 也会自动生成顶层帮助。

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

Options:
  --help  Show this message and exit.

Commands:
  hello     our first CLI with typer!
  roll-num  Rolls the dice from numeric inputs.
  roll-str  Rolls the dice from a formatted string.

(注意,命令描述有一点小聪明--它自动使用文档串的第一句话,所以这应该是对命令的 "单行本 "描述)。

总而言之,这给了我们一个处理命令行脚本的好方法。我们可以根据需要轻松地创建多个子命令,每个子命令都有自动文档和我们需要的任何数据验证,比起我们对裸露的Python函数所需要的额外代码来说,这些代码是最小的。接下来,让我们看看如何将其集成到我们的软件包中,成为一个独立的工具,而不只是一个脚本。

集成CLI

上面的脚本对于命令行来说是完全有效的--也就是说,我们可以用python cli.py 来调用它,它就会做这个事情(只要我们安装了我们的roll-the-dice 包)。然而,这对于建立一个真正可重复使用的独立工具来说并不理想。首先,我们没有一个很好的方法来分发这个脚本--用户可以安装roll-the-dice (一旦我们把它推送到软件包索引),但他们需要单独管理(和版本控制!)这个脚本的安装。相反,我们希望有一种方法可以将脚本包含在软件包本身中,这样脚本就可以作为一个独立的命令与软件包一起安装并进行版本控制 (就像 Python 工具如flake8mypy 可以被导入或从命令行调用一样)。也就是说,我们想把我们的脚本变成一个命令 (我们把它命名为rtd),这样我们就可以像这样调用它来代替我们笨拙的python cli.py 调用。

$ rtd COMMAND [OPTIONS] ARGS

历史上,Python 的打包工具支持脚本设置,我们可以在bin/ 顶层目录中包含像我们上面写的那样的脚本文件 (与打包源和测试目录平行)。setuptools 构建过程将把这些文件与包源文件或轮子文件一起打包,并在安装时将脚本复制到 Python 环境的bin/ 目录中,以便在环境激活时创建一个可访问的命令。然而,这在将脚本代码与包的其他部分集成时遇到了一些麻烦(例如,用于测试,或者处理复杂工具的多文件源代码),并且很难在 Windows 和 POSIX 系统上工作。

相反,我们将使用更现代的console_scripts 入口来创建我们的命令。这让我们直接把命令行工具包含在函数中,并且避免了任何与命名空间有关的麻烦 (例如,__name__ == "__main__" 检查),而是直接引用软件包本身中的一个函数(而不是一个脚本!)。在软件包安装时,Python 会在其环境的bin/ 目录中自动创建脚本,这些脚本只是简单地导入被引用的函数,并从那里运行 (而不是复制由旧的scripts 关键词引用的整个源代码)。

让我们开始吧--首先,我们简单地将我们的cli.py 文件复制到包中,在那里我们会像对待其他子模块一样对待它。

roll-the-dice/
|-- roll_the_dice/
|   |-- __init__.py
|   |-- cli.py
|   |-- dice.py
|-- tests/
|-- pyproject.toml
|-- README.rst

在我们的CLI文件中,我们需要用一个普通的函数来代替__main__ 的调用:在

if __name__ == "__main__":
    app()

我们只需要

def main():
    app()

由于这只是我们包中的另一个子模块,我们甚至可以像其他函数一样为它写单元测试--pytest 提供了一个内置的capsys fixture来捕获标准输出和错误日志,所以我们可以很容易地在CLI命令输出上进行测试,就像下面这样。

def test_roll_num(capsys):
    roller = roll_num(num_dice=1, sides=20)
    stdout = capsys.readouterr().out

    regex = re.compile(r"rolling (\d+D\d+)!\n\nyour roll: (\d+)")
    roll_str, total = re.search(regex, stdout).groups()
    assert roll_str == "1D20"
    assert int(total) in range(1, 21)

(但是请注意,在使用typer.Argumenttyper.Option 的情况下,我们可能需要明确地提供数值,因为Python解释器不会正确地将typer 的字段解析为它们的基本值。)最后,我们需要将这个函数定义为我们的pyproject.toml 文件的入口。

[tool.poetry.scripts]
rtd = "roll_the_dice.cli:main"

(我们实际上可以在这里直接引用app 函数,使main() 成为不必要的 - 但这种模式更明确,允许在应用程序实例化时需要的任何额外的设置调用,并尽量减少导入自动生成的脚本的对象。)我们可以在开发过程中使用poetry run rtd ,因为poetry 将在其包含的虚拟环境中运行TOML文件中定义的任何脚本。

在软件包构建时,poetry 会自动将其转换为一个标准的软件包入口,在安装时,这个入口会在我们的 Python 环境中创建一个 CLI 命令rtd ,我们可以完全正常地调用。完成这些后,我们就可以掷骰子了,例如,rtd roll-str 2D6 ,以获得我们的第一个滚动函数--我们有一个功能齐全的 CLI 工具可以使用了

收尾工作

尽管Python在构建完全独立的可执行文件方面没有一个精彩的故事,但它仍然是一种伟大的脚本语言--而且现代工具使我们很容易将脚本打包成pip-可安装的命令行工具,让我们验证、测试和控制脚本的版本。在这个演练中,我们。

  • 将一个快速掷骰子的工具打包成一个Python包,其中包括poetry
  • 使用该包和新的typer 库编写了一个命令行脚本
  • 将该脚本集成到一个使用包的入口的命令行工具中。

使用这些下一代工具给我们带来了很多好处,比如用poetry 集成工具和依赖性解决,或者用typer 干净的、有类型注释的CLI布局,但如果我们愿意,我们也可以很容易地使用其他工具来进行这种布局。例如,console_scripts 入口与 CLI 的实际行为无关--它只需要访问一个不需要参数的可导入函数(在 Python 中--参数由我们选择的 CLI 解析器处理)。例如,如果我们使用argparse ,我们只需要在我们的main() 函数中包含对ArgumentParser 对象的处理。

同样,我们可以用setuptools 而不是poetry 来构建我们的包(包括脚本)--这使我们失去了集成的环境、包和构建控制,但在某些情况下是必要的(例如,构建一个与编译的非Python代码绑定的包)。我们仍然可以通过依靠setup.cfg 文件而不是在可执行的setup.py 文件中堆积配置来获得很多更现代的构建配置的好处。要把我们的脚本作为一个带有setuptools 的软件包的入口,我们只需要在我们的setup.cfg 文件中添加。

[options.entry_points]
console_scripts =
    rtd = roll_the_dice.cli:main

这将构建软件包的console_scripts 入口,与poetrytool.poetry.scripts 选项相同。无论我们选择什么样的工具,用Python构建pip-installable CLI工具是一种很好的方式,可以以一种经过测试的、版本控制的方式发布临时脚本,而且几乎没有额外的开销。