跳转到主要内容

一个强大且Python风格的命令行解析库。给你的程序添加申诉功能!

项目描述

## Appeal

## Give your program Appeal!

版权所有 © 2021-2023 Larry Hastings

# test badge # python versions badge

快速入门

import appeal
import sys

app = appeal.Appeal()

@app.command()
def hello(name):
    print(f"Hello, {name}!")

app.main()

这是一个简单的fgrep工具

import appeal
import sys

app = appeal.Appeal()

@app.command()
def fgrep(pattern, *files, ignore_case=False):
    if not files:
        files = ['-']
    print_file = len(files) > 1
    if ignore_case:
        pattern = pattern.lower()
    for file in files:
        if file == "-":
            f = sys.stdin
        else:
            f = open(file, "rt")
        for line in f:
            if ignore_case:
                match = pattern in line.lower()
            else:
                match = pattern in line
            if match:
                if print_file:
                    print(file + ": ", end="")
                print(line.rstrip())
        if file != "-":
            f.close()


if __name__ == "__main__":
    app.main()

概述

Appeal是Python的一个命令行参数处理库,类似于argparseoptparsegetoptdocoptTyperclick。但是Appeal采用了全新的方法。

其他库具有复杂、繁琐的接口,迫使你一次又一次地重复自己。Appeal利用Python丰富的函数调用接口,使定义命令行界面变得轻而易举。你编写Python函数,而Appeal则将它们转换为命令行选项和参数。

Appeal提供了惊人的功能和灵活性--但它也非常直观,因为它与Python本身相似。如果你了解如何编写Python函数,那么你理解Appeal已经成功了一半!

Appeal只有一个依赖项,即我的big

Appeal目前仅支持POSIX平台(UNIX、Linux、BSD、OS X等)。它可能在Windows上工作,但这尚未经过测试。

一种新颖且吸引人的方法

Appeal与其他命令行解析库不同。事实上,你根本不应该将Appeal视为一个“命令行解析库”。虽然你通过传递给Appeal调用的函数来与Appeal一起工作,但你也不应该将这些函数视为“回调”。

申诉功能让您能够设计可以从命令行调用的API。它就像任何其他Python库API一样--只不过调用者是来自命令行而不是Python。申诉是转换这两个域的机制:它将您的API转换为命令行语义,然后将用户的命令行转换回对您的API的调用。

这又提出了另一个很好的观点:使用申诉构建的API也常常成为一个非常好的自动化API,允许您的程序在最小努力的情况下被其他程序作为库使用。

基础知识

分类法

让我们首先确立我们将用于命令行的术语,基于POSIX和流行程序建立的命令行习惯用法。下面是一个示例命令行,说明了您可能会看到的各种类型的事物

% ./script.py --debug add --flag ro -v -xz myfile.txt
  ^           ^       ^   ^      ^  ^  ^   ^
  |           |       |   |      |  |  |   |
  |           |       |   |      |  |  |   argument
  |           |       |   |      |  |  |
  |           |       |   |      |  |  multiple short options
  |           |       |   |      |  |
  |           |       |   |      |  short option
  |           |       |   |      |
  |           |       |   |      oparg
  |           |       |   |
  |           |       |   long option
  |           |       |
  |           |       command
  |           |
  |           global long option
  |
  program name

命令行是一系列由空格分隔的字符串。每个字符串的含义可以取决于字符串的位置以及字符串本身中的字符。

参数是指令行上任何由空格分隔的字符串,它不以-(减号)开头。除非它是oparg(我们将在下面讨论)--它的含义由其位置定义。例如,如果您运行

fgrep WM_CREATE window.c

WM_CREATEwindow.c 将是参数;第一个参数,WM_CREATE,将是您想要搜索的字符串,而window.c将是您想要搜索的文件名。

命令是某些程序用于指定程序要执行的功能的特殊类型的参数。一个使用命令的程序的好例子是git;当您运行git addgit commit时,addcommit都是命令。命令总是使用它们的程序的第一个参数。

如果命令行上的字符串以-(减号)开头,则这是选项。有两种选项样式:短选项长选项

短选项以单个连字符-开头。这后面跟着一个或多个单独的字符,这些是短选项字符串。在上面的例子中,我们指定了两组短选项:第一组是-v,第二组是-xz。您可以组合选项,这与单独指定它们相同。我们可以说-vxz,或者-v -x -z;这两者都做同样的事情。当我们谈论短选项时,我们说“连字符”后跟字母。例如,-v将被发音为“连字符v”。

长选项以两个连字符--开头。两个连字符之后的是选项的名称。在上面的例子中,我们可以看到一个长选项,--flag。同样,当我们谈论长选项时,我们大声说出连字符,然后说出选项中的单词。例如,--flag将被发音为“连字符连字符flag”。

这两种类型的选项都可以可选地接受一个(或多个)自己的参数。选项的参数称为oparg。在上面的例子中,长选项--flag接受oparg ro

最后,有全局选项命令选项。全局选项适用于整个程序,总是可用,并且在命令之前指定。命令选项是特定于命令的,在命令之后出现。全局选项可以是长选项或短选项;命令选项也可以是长选项或短选项。

将Python映射到命令行

现在让我们考虑一个Python函数调用

#      positional parameters |  keyword-only parameters
#            |        |      |      |
#            v        v      |      v
def fgrep(pattern, filename, *, ignore_case=False):
    ...

我们可以在Python函数调用和命令行之间找到一些相似之处。

例如,它们都支持具有显著位置的参数。命令行参数类似于Python函数的位置参数,因为它们都是通过位置来识别的。

Python函数调用和命令行都支持通过名称识别的参数。命令行选项类似于Python的关键字参数

在Python中,所有*args*之后的参数都是关键字参数。您只能通过名称传递这些参数的值。如果您自己调用fgrep,您只能像这样传递给ignore_case的值

result = fgrep(p, f, ignore_case=True)

这引出了Appeal背后的基本概念。使用Appeal,您编写一个Python函数,并告诉Appeal它代表一个命令。Appeal检查该函数,将其参数转换为命令行特性。位置参数变为命令行参数,关键字参数变为选项。

(技术上,Appeal将位置参数位置或关键字参数都转换为参数。为了清晰和一致性,我总是将这些统称为位置参数。)

你好,世界!

让我们看看Appeal的实际应用,以我们的第一个例子为例。在所有我们的例子中,我们假设你的程序叫做script.py。假设script.py看起来像这样

import appeal
app = appeal.Appeal()

@app.command()
def hello(name):
    print(f"Hello, {name}!")

app.main()

如果你现在运行python3 script.py help hello,你会看到你的hello命令的用法信息。它将像这样开始

usage: script.py hello name

已经发生了很多事情!让我们逐一了解

  • 我们创建了一个名为appAppeal对象。此对象将处理命令行并调用适当的命令函数。
  • 我们使用我们的Appeal对象的调用方法对函数hello()进行了装饰,即@app.command()。这告诉Appeal,hello()应该是一个命令,使用函数的名称作为命令字符串,并将函数的参数转换为命令行参数。因此,我们的命令行命令是hello。我们称带有@app.command()装饰器的函数为命令函数
  • 我们的hello()命令函数接受一个位置参数,name。因此,我们的命令行中的hello命令接受一个位置参数,我们在用法字符串中将其标识为name
  • Appeal还自动为我们创建了简单的帮助信息,显示用法信息。用法显示了命令可以接受的命令行选项和参数。

所以!如果您在命令行运行此命令

% python3 script.py hello world

Appeal将这样调用您的hello()函数

hello('world')

然后你会得到

Hello, world!

您的命令函数的返回值是您程序的返回码。如果返回None0,则表示成功;返回非零整数表示失败。(并且如果您的函数在没有返回语句的情况下退出,Python会表现得像您的函数以return None结束一样。)

默认值和*args

让我们改变一下例子,并添加一个可选参数

import appeal
app = appeal.Appeal()

@app.command()
def fgrep(pattern, filename=None):
    print(f"fgrep {pattern} {filename}")

app.main()

现在我们的命令叫做fgrep,它接受两个参数。第二个参数,filename,是可选的,默认值为None

当然,您也可以指定这两个参数。运行这个

% python3 script.py fgrep WM_CREATE window.c

结果将导致Appeal像这样调用您的fgrep()函数

fgrep('WM_CREATE', 'window.c')

但是,您也可以省略filename参数。如果您在命令行运行此命令

% python3 script.py fgrep WM_CREATE

Appeal将像这样调用fgrep

fgrep('WM_CREATE', None)

实际上这并不完全准确。当Appeal构建调用您的fgrep()函数的参数时,它只会传递您在命令行上传递的参数。所以实际上Appeal是这样调用您的fgrep()函数的

fgrep('WM_CREATE')

然后Python会将filename参数设置为None

Appeal命令函数还能做什么呢?嗯,它们可以有*args参数。自然地,接受*args(内部称为var_positional参数)的命令函数可以接受用户想提供的任意多的位置参数。这里有一个演示

import appeal
app = appeal.Appeal()

@app.command()
def fgrep(pattern, *filenames):
    print(f"fgrep {pattern} {filenames}")

app.main()

现在用户可以传入没有文件名、一个文件名、五十个文件名——他们想要的任何数量!它们都会被收集成一个元组,并通过 filenames 参数传递给 fgrep()

选项、Opargs 和关键字参数

现在让我们来看看 Appeal 是如何处理关键字参数的。让我们在我们的示例中添加三个关键字参数。

import appeal
app = appeal.Appeal()

@app.command()
def fgrep(pattern, *filenames, color="", number=0, ignore_case=False):
    print(f"fgrep {pattern} {filenames} {color!r} {number} {ignore_case}")

app.main()

现在 fgrep 命令行用法如下

usage: script.py fgrep [-c|--color] [-n|--number int] [-i|--ignore-case] pattern [filenames]...

又发生了很多事情。

首先,我要提醒你,关键字参数在命令行上呈现为选项。Appeal 自动将每个关键字参数添加 '--' 到参数名的前面,并将其转换为选项。(此外,如果参数名中包含任何下划线,Appeal 会将其转换为破折号。)

其次,Appeal 还自动使用关键字参数的第一个字母作为简短选项。因此,color 关键字参数既是 --color 也是 -c 选项。当运行程序时,用户可以使用 -c--color 互换。对 -i--ignore_case 以及 -n--number 也是如此。

(如果你有两个以相同字母开头的关键字参数怎么办?第一个获得简短选项。如果我们将关键字参数 credit 添加到 fgrep() 参数列表的末尾,Appeal 会将 color 映射到 --color-c,但只将 credit 映射到 --credit。)

第三,选项总是可选的。(一个一丝不苟的人可能会说——“线索就在名字中。”)因此,在 Appeal 中,命令函数的关键字参数必须始终有一个默认值。(Python 程序员通常为他们的关键字参数设置默认值,所以这个要求并不是什么大问题。)

第四,请注意,--color 接受一个参数,或称为 oparg。 Appeal 注意到 color 参数的默认值为 ""——它的默认值是 str。所以 Appeal 推断你希望用户向 --color 提供一个 oparg。如果用户在命令行上指定了 --color,它后面必须跟着一个 oparg,Appeal 会从命令行中提取该字符串并将其直接传递给 color 参数。

第五,--number 也接受一个 oparg,但默认值为 0。从这一点可以推断出 --number 应该是一个 int。Appeal 会自动将命令行中的字符串转换为 Python 对象,使用默认值的类型。 (Appeal 也为 --color 做了这件事——但 --color 接受一个 str,所以不需要转换。)当用户在命令行上为 --number 提供一个 oparg 时,它后面必须跟着一个 oparg;Appeal 会接受该 oparg,将其传递给 int,然后接受 int 的返回值并将其传递给 number 参数。

最后,ignore_case 的默认值为 False。选项的布尔值是一个特殊情况:它们不接受 oparg。它们只是否定默认值。所以如果用户在命令行上指定 -i 一次,Appeal 就会向 ignore_case 参数传递 True

(顺便说一下,默认值为 None 是第二个特殊情况。如果一个位置参数或关键字参数的默认值为 None,Appeal 会像默认值是 str 一样处理。它会从命令行消费一个参数或 oparg 并将其原样传递给该参数。)

让我们把它们放在一起!如果你在命令行上运行这个命令

% python3 script.py fgrep -i --number 3 --color blue WM_CREATE window.c

Appeal将像这样调用fgrep

fgrep('WM_CREATE', 'window.c', color='blue', number=3, ignore_case=True)

如果你在命令行上运行这个命令

% python3 script.py fgrep --color green boogaloo

Appeal将像这样调用fgrep

fgrep('boogaloo', color='green')

全局命令、子命令和默认命令

许多支持“命令”的程序也具有“全局选项”。全局选项是在命令行中指定,且位于命令之前的选项。例如,在本文档顶部的命令行示例中,script.py 使用了在命令之前指定的 --debug 选项——这使得它成为一个“全局选项”。

Appeal 也支持全局选项。很简单:像平常一样编写你的命令函数,但不是用 Appeal.command() 装饰它,而是用 Appeal.global_command() 装饰它。Appeal 将在命令之前处理所有这些选项,并调用你的全局命令函数。

Appeal.global_command() 也可以用于不使用“命令”的程序。虽然现在的“命令”命令行模式很流行,但大多数程序都不关心它们。例如,lsgrep 以及……嘿!连 python 本身都不支持命令,但它们都支持命令行参数和选项。

自然,Appeal 支持这种行为。只需用一个 Appeal.global_command() 装饰一个函数,并不要添加任何命令函数。

另一方面,Appeal 也支持 子命令。这是命令行解析库的常见特性,尽管在实际中很少使用。想法是,你的命令本身可以跟另一个命令。

要向你的 Appeal 实例添加子命令,只需用两个链式命令调用装饰你的命令函数,在第一个调用中指定现有命令的名称,如下所示

@app.command()
def db(...):
    ...

@app.command("db").command()
def deploy(...):
    ...

这将在 db 命令下添加一个 deploy 子命令。所以现在整个命令行看起来像这样

script.py [global arguments and options] db [db arguments and options] deploy [deploy arguments and options]

最后,如果程序接受命令,但用户没有提供,Appeal 应该怎么做?这就是 默认命令 的作用。默认命令是 Appeal 如果你的 Appeal 实例有命令,但用户没有提供命令时,将为您运行的命令。例如,如果 script.py 有十个不同的命令,但用户只是运行

script.py

没有任何参数,Appeal 会运行默认命令。

如果你没有指定默认命令,Appeal 有一个内置的默认 默认命令。默认 默认命令 会引发使用错误,这意味着它会打印基本帮助信息并退出。

要指定你自己的默认命令,只需用 Appeal.default_command() 装饰器装饰一个命令函数。例如,如果你想当用户没有指定命令时,程序运行 status 命令,你可以这样做

@app.default_command()
def default():
    return status()

注意,默认命令不接受任何参数或选项。根据定义,它根本不能接受任何。

(如果用户在未指定命令的情况下指定了选项,它们将被视为“全局选项”,并由全局命令处理。如果用户指定了一个参数,则该参数将自动成为要运行的命令的名称。)

是的,子命令也可以有默认命令

@app.command('db').default_command()
def db_default():
    return db_status()

注解和内省

Python 3 支持函数参数的注解,旨在从概念上表示类型。Appeal 也支持注解;它们明确告诉 Appeal 参数需要哪种类型的对象。例如

import appeal
app = appeal.Appeal()

@app.command()
def fgrep(pattern, *filenames, id:float=None):
    print(f"fgrep {pattern} {filenames} {id}")

app.main()

在这里,id 有一个默认值 None,但它还有一个显式的 float 注解。如果用户在命令行上使用 --id,它必须后跟一个 oparg,Appeal 会通过调用 float 将其转换为 Python 对象。(正如你所看到的,注解和默认值的类型不必 必须 一致……尽管通常是个好主意。)

尽管注解旨在表示类型,但 Appeal 实际上接受任何可调用对象——它可以是类型,用户定义的类,或者只是一个普通函数。Appeal 将这些注解称为 转换器

以下是 Appeal 如何决定参数的转换器,从最高优先级到最低优先级

  • 如果该参数的签名有一个注解,Appeal 使用该注解作为转换器。
  • 如果该参数的签名没有注释,但具有默认值, Appeal在大多数情况下将使用type(default)作为转换器。例外情况
    • 如果type(default)NoneType,Appeal将使用str代替。
    • 如果type(default)bool,并且该参数是关键字参数,Appeal将使用一个特殊的内部类来实现具有布尔默认值的选项的特殊情况“否定默认值”行为。
  • 如果该参数的签名缺少注释和默认值,Appeal使用str作为转换器。

转换器非常灵活。例如,Appeal将检查关键字参数的转换器,并将所有位置参数映射到opargs中。这就是Appeal支持接受多个opargs的选项的原因:你只需在关键字参数上标注一个接受多个参数的转换器。Appeal还会注意转换器参数的注释,并使用这些注释将命令行中的字符串转换为Python对象。

让我们通过另一个示例将所有这些内容结合起来

import appeal
app = appeal.Appeal()

def int_and_float(integer: int, real: float):
    return [integer*3, real*5]

@app.command()
def fgrep(pattern, *filenames, position:int_and_float=(0, 0.0)):
    print(f"fgrep {pattern} {filenames} {position}")

app.main()

在这里,Appeal将检查fgrep(),然后也会检查int_and_float()。生成的使用字符串现在看起来像这样

usage: script.py fgrep [-p|--position integer real] pattern [filenames]...

--position接受两个 opargs。Appeal会对第一个调用int,对第二个调用float。然后,它会用这些值调用int_and_float(),并将int_and_float()的返回值传递给fgrep()上的position参数。

所以现在如果你运行

% python3 script.py fgrep -p 2 13 funkyfresh

Appeal将调用

fgrep('funkyfresh', position=[6, 65.0])

最后,让我们改变示例以演示另一件事:虽然转换器可以是任何可调用的,但用户定义的类也可以。并且Appeal可以正确推断任何类型的默认值。所以考虑这个例子

import appeal
app = appeal.Appeal()

class IntAndFloat:
    def __init__(self, integer: int, real: float):
        self.integer = integer * 3
        self.real = real * 5

    def __repr__(self):
        return f"<IntAndFloat {self.integer} {self.real}>"

@app.command()
def fgrep(pattern, *filenames, position=IntAndFloat(0, 0.0)):
    print(f"fgrep {pattern} {filenames} {position}")

app.main()

这个例子基本上与这个部分之前的例子相同,只是position的格式略有不同。但命令行使用完全相同!Appeal根据position的默认值类型推断出转换器,然后检查该类型以确定它应从命令行消耗多少个opargs以及如何转换它们。

关于注释的重要说明

如果你在项目中使用静态类型分析,你的静态类型分析器可能不会喜欢分析使用Appeal的Python代码。静态类型分析器旨在理解“类型提示”,这是通过在Python 3.5中引入的typing模块指定静态类型信息的方法。但Appeal不使用类型提示,而且Appeal使用注释的方式有些静态类型分析器可能不喜欢。

幸运的是,有一些方法可以使静态类型分析器与Appeal一起工作。

首先,你可以使用@typing.no_type_check()装饰你的Appeal命令函数和转换器。这仅在您使用函数作为注释时才是必要的;如果您只使用类型和类,则不需要这样做。

其次,如果您使用的是Python 3.9或更高版本,您可以使用typing.Annotated与您的注释一起使用。typing.Annotated允许您指定一个有序值列表,静态类型提示仅使用第一个值。Appeal也处理typing.Annotated,但Appeal仅使用最后一个值。这使得它变得容易--您可以在旁边同时有这两种类型的注释,并且静态类型检查器和Appeal都会很高兴。

转换器灵活性

您可以使用几乎任何函数作为注释...在合理范围内。Appeal将检查您的注释,确定其输入参数,并调用它以将命令行参数转换为传递给您的命令函数的参数。

例如,如果您想要一个接受字符串并基于分隔符子字符串将其拆分的选项呢?这在类UNIX平台的configure脚本中是一个常见的惯用法;例如,Python自己的configure脚本支持此选项

--with-dbmliborder=db1:db2:...

在Appeal中,这很容易实现。只需编写一个接受字符串并按您喜欢的任何方式将其拆分成子字符串的转换函数,然后返回列表。

尽管如此...您不需要费心!Appeal还提供了一个名为appeal.split()的转换器,它会为您完成这项工作。您传入任何数量的分隔符字符串,然后appeal.split()将跨所有这些分隔符拆分命令行。(如果您没有指定任何分隔符,则appeal.split()将在每个空白字符处拆分。)

指定选项多次

您可能已经注意到了:您看到的接口仅允许Appeal处理选项可以指定零次或一次的命令行。如果您想让用户能够指定一个选项三次或十次怎么办?这正是MultiOption类的作用所在。MultiOption对象是允许多次指定选项的转换器。

MultiOption本身并没有什么用;它只是一个抽象基类。要使用它,您需要使用一个子类——或者创建自己的。

这次,让我们从一些示例开始。Appeal提供了三个有用的MultiOption子类:counteraccumulatormapping

首先,让我们看看countercounter简单地计算命令行上指定选项的次数。这是一个“详细”选项的相对常见的惯用法;一个支持-v表示详细的程序可能允许您多次指定-v以使其更详细。这是您如何使用Appeal来实现这一点的示例

import appeal
app = appeal.Appeal()

@app.command()
def fgrep(*, verbose:appeal.counter()=0):
    print(f"fgrep verbose={verbose!r}")

app.main()

如果用户运行

% python3 script.py fgrep

Appeal将调用

fgrep()

允许Python将默认值0传递给verbose。如果用户运行

% python3 script.py fgrep -v --verbose -v

Appeal将调用

fgrep(verbose=3)

accumulator处理接受单个操作参数的选项。它记住所有这些,并返回一个数组。如下所示

import appeal
app = appeal.Appeal()

@app.command()
def fgrep(*, pattern:appeal.accumulator=[]):
    print(f"fgrep pattern={pattern!r}")

app.main()

如果用户运行

% python3 script.py fgrep --pattern three -p four --pattern fiv5

Appeal将调用

fgrep(pattern=['three', 'four', 'fiv5'])

如果您不想使用字符串,而是想使用其他类型呢?使用来自未来的疯狂科学魔法,accumulator实际上是参数化的。您可以说

import appeal
app = appeal.Appeal()

@app.command()
def fgrep(*, pattern:appeal.accumulator[int]=[]):
    print(f"fgrep pattern={pattern!r}")

app.main()

现在--pattern的操作参数都将使用int进行转换。

您甚至可以将多个类型作为参数化的accumulator版本参数的参数,用逗号分隔。选项将需要多个操作参数,并将它们转换为指定的类型。

mapping类似于accumulator,但它返回一个dict而不是一个list。带有mapping()注解的选项从命令行中消耗两个位置参数;第一个是键,第二个是值。(您也可以以与参数化accumulator相同的方式参数化mapping,尽管您只能指定确切的两个类型。)

当然,您也可以从MultiOption派生子类以创建具有自定义行为的自定义转换器类。MultiOption子类可以覆盖这些三个方法

class MultiOption:

    def init(self, default):
        ...

    def option(self, ...):
        ...

    def render(self):
        ...

实际上,子类必须覆盖option()render()方法。但init()方法是可选的。

如果您将MultiOption的子类指定为Appeal命令函数的关键字参数的注解,将发生几件事情

  • 如果该选项在命令行上指定了一次或多次,Appeal将实例化这些对象中的一个,并调用其init()方法。
  • 每次用户在命令行上指定该选项时,Appeal都会调用对象的option()方法。
  • 在完成处理命令行之后,Appeal将调用对象的render()方法,并将它返回的值作为该关键字参数的参数传递。

本界面最强大的功能:您可以重新定义 option() 以满足您的需求——它支持与注解相同的泛型。Appeal 会反射您的 option() 方法,以确定从命令行中消耗多少个 opargs,以及如何转换它们。

让我们用另一个例子来演示所有这些。如果您想使您的选项接受两个 opargs,其中一个是 int 类型,另一个是 float 类型,您可以在您的子类中这样定义 option()

class MyMultiOption(appeal.MultiOption):

    def option(self, a:int, b:float):
        ....

每次用户指定您的选项时,它都会接受两个 opargs,并且在调用您的 option() 方法之前,它们会被转换成 intfloat 类型。您需要自己决定如何存储它们,以及如何将它们渲染成您的 render() 方法返回的单个值。

MultiOption 是一个通用 Option 类的子类。与 MultiOption 相同,Option 的行为完全一样,但它只允许在命令行上指定一次选项,这意味着它只会调用一次您的 option() 方法。您通常不需要烦恼于创建 Option 的子类——直接使用一个类通常更好,就像我们的 class IntAndFloat 例子。通过子类化 Option,您获得的一个唯一特性是,您可以获得传递给构造函数的参数的默认值。

(子类化 OptionMultiOption 的缺点是,它使得将您的 Appeal API 作为自动化 API 导出对用户来说稍微不太方便,因为用户将不得不通过调用 option 方法来构建这些对象并将值喂入它们。)

数据验证

如果您想限制用户在命令行上提供的数据?这很简单,只需使用一个转换器!Appeal 提供了一些用于数据验证的样本转换器,但编写自己的转换器也很容易。

经典的例子是只能使用列表中一个值的参数。为此,您可以使用 Appeal 的 validate() 转换器。例如,这个命令将 direction 参数限制为六个标准方向之一

import appeal
app = appeal.Appeal()

@app.command()
def go(direction:appeal.validate('up', 'down', 'left', 'right', 'forward', 'back')):
    print(f"go direction={direction!r}")

app.main()

您可以使用 type= 命名参数显式传递类型给 validate();如果您省略了它,它将使用第一个参数的类型。

Appeal 还有一个内置的范围验证器,称为 validate_range()。它以与 Python 的 range() 函数相同的方式接受 startstop 参数。请注意,validate_range() 与 Python 的 range 在一个细微之处不同:允许值等于 stop

如果您愿意,可以通过将 clamp=True 传递给 validate_range() 来将用户传入的值“夹”在范围内。在这种情况下,如果用户指定的值超出了范围,validate_range() 将返回 startstop 中最接近的值。

(这就是为什么 validate_range() 允许值等于 stop。如果 stop 本身是非法值,则使用 clamp 会很烦人——特别是如果类型是浮点数的话。)

Appeal 验证函数易于编写。因此,如果这些不足以满足您的需求,您可以轻松地编写自己的。查看 Appeal 中 validate()validate_range() 的实现,看看一种实现方式!

同一参数的多个选项

一些程序在它们的命令行上有一组互相排斥的选项。考虑这个简单的命令行

go [--north|--south|--east|--west]

也就是说,您希望用户能够“去”这四个方向之一,但只能是其中一个。在 Appeal 中您会如何做呢?

很简单。您只需定义多个写入同一参数的选项。到目前为止您所看到的所有行为都是使用 默认 方式将关键字参数映射到选项。但实际上,Appeal 允许您创建自己的映射。您可以将参数映射成任意多的方式,甚至可以使用不同的转换器!

要手动定义自己的选项,请在您的 Appeal 实例上使用 Appeal.option() 方法。这是一个装饰器,您将其应用于您的命令函数。第一个参数是要将选项写入的参数的名称。之后是您要映射到该参数的一个或多个选项。Appeal.option() 还接受 defaultannotation 关键字参数,允许您分别指定此选项的默认值或注释。

以下是一个使用 Appeal 实现上述 go 命令的简单示例

import appeal
app = appeal.Appeal()

@app.command()
@app.option("direction", "--north", annotation=lambda: "north")
@app.option("direction", "--south", annotation=lambda: "south")
@app.option("direction", "--east",  annotation=lambda: "east")
@app.option("direction", "--west",  annotation=lambda: "west")
def go(*, direction='north'):
    print(f"go direction={direction!r}")

app.main()

所有这些注释都返回一个字符串。但实际上,您可以返回任何您想要的数据类型--您甚至可以将返回不同类型的多重注释映射到同一参数。您甚至可以用 MultiOption 注释来允许指定该选项多次!

请注意,每当您使用 option() 装饰器将您自己的选项映射到一个参数时,Appeal 不会为该参数添加其默认选项。它只会有您明确设置的选项。这意味着,例如,在上面的示例代码中,我们没有为创建的选项设置任何简短选项。-n 不会工作,只有 --north

最后还有一件事。您的命令函数也可以接受 **kwargs。只有您使用 Appeal.option() 创建的选项会进入其中,这些选项映射到其他不存在的参数。

递归转换器

您已经知道您可以将接受多个参数的转换器传递进去,并且 Appeal 将从命令行中消耗多个参数来填充它。如果转换器的参数有注释,Appeal 将调用这些函数将命令行参数转换为转换器想要的类型。

但是,如果您做了... 这个 呢?

import appeal
app = appeal.Appeal()

def int_float(i: int, f: float):
    return (i, f)

def my_converter(i_f: int_float, s: str):
    return [i_f, s]

@app.command()
def recurse(a:str, b:my_converter=[(0, 0), '']):
    print(f"recurse a={a!r} b={b!r}")

app.main()

my_converter() 参数 i_f 是一个有注释的位置参数,该注释本身 接受两个位置参数。

您会惊讶吗?是的,它实际上是可以工作的!

转换器已经完全递归了这一整个过程。实际上,这个事实一直就在我们眼前隐藏着--所有使用 int_and_float() 的示例也都是递归的,因为 int_and_float() 有带有 intfloat 注释的参数。当然,那些函数只接受一个字符串参数;my_converter() 接受两个带有注释的位置参数。但原则是一样的。

然而,这比我们之前看到的情况更复杂。recurse 接收一个有默认值的位置参数 b,但它的转换器接收多个位置参数,其中之一 有一个接受多个位置参数的转换器。Appeal 如何将它们映射到命令行呢?

Appeal 将转换器函数的树形结构“扁平化”成一系列线性参数和选项。在这种情况下,使用字符串将如下所示

recurse a [i f s]

这告诉您 recurse 命令接受一个或四个命令行参数。那个可选的三参数组在 Appeal 中有一个特殊名称:它是一个“参数组”。从技术上讲,Appeal 视这个命令行接受两个“参数组”:第一个组是必需的,消耗一个命令行参数;第二个组是可选的,消耗三个命令行参数。

(我们实际上在上面的 注释和内省 部分的第二个示例中看到了我们的第一个“参数组”,但那次参数组是一个 oparg。)

现在让我们添加一个选项看看会发生什么变化

import appeal
app = appeal.Appeal()

def int_float(i: int, f: float):
    return (i, f)

def my_converter(i_f: int_float, s: str, *, verbose=False):
    return [i_f, s, verbose]

@app.command()
def recurse2(a:str, b:my_converter=[(0, 0), '', False]):
    print(f"recurse2 a={a!r} b={b!r}")

app.main()

现在的使用情况如下所示

recurse2 a [i [-v|--verbose] f s]

注意 Appeal 在使用字符串中的渲染方式--选项是在可选参数组的第一个参数之后创建的。这听起来可能有些奇怪,但它就是这样工作的。这就是它必须这样工作的原因。

为什么?从高层次概念上讲,Appeal 不知道您已经“进入”了可选参数组,直到它看到用户为该组提供了第一个参数。因此,它不会在第一个参数之后创建该组中定义的选项。

这个高概念级别与Appeal调用您函数的方式完全一致。考虑一下,如果用户运行这个命令

recurse2 xyz

Appeal是这样调用您的函数的

recurse2('xyz')

由于Appeal从未调用my_converter(),因此无法映射--verbose。只有当它知道将要调用my_converter()时,才能映射--verbose,而这只有在您提供了第二个命令行参数的那一刻才成为事实。

一旦您确实提供了第二个命令行参数,您就必须再提供两个,总共四个。

recurse2 pdq 1 2 xyz

Appeal是这样调用您的函数的

recurse2('pdq', my_converter(int('1'), float('2'), xyz))

recurse2 pdq 1 2 xyz

在这个例子中,您可以在第二个参数之后的任何位置提供-v--verbose。所以如果您的命令行看起来像这样

recurse2 pdq 1 2 xyz -v

Appeal是这样调用recurse()

recurse2('pdq', my_converter(int('1'), float('2'), xyz, verbose=True))

回顾一下本文档中的所有示例,并考虑一下,在您指定函数或类型的地方,您可以传递您喜欢的几乎所有可调用函数。

例如,mapping的参数化版本不仅限于简单类型。如果您使用mapping[str, int_float]作为关键字参数的注释,那么该选项将消耗三个命令行参数:一个str,一个int和一个float,字典将把字符串映射到包含int和float的元组。

现在您开始看到Appeal转换器有多么强大了!

现在见证这个全副武装且完全运作的战斗站的威力!

递归转换器只是开始!

系好安全带,多萝西——因为堪萨斯州要走了。

--《黑客帝国》(1999年)中的赛弗

映射其他选项的选项

如果您做了……这个呢?

import appeal
app = appeal.Appeal()

def my_converter(a: int, *, verbose=False):
    return [a, verbose]

@app.command()
def inception(*, option:my_converter=[0, False]):
    print(f"inception option={option!r}")

app.main()

哇,这也行!我们已经创建了一个可以自己接受选项的选项。如果您运行fgrep --option,您现在也可以指定-v--verbose——但是只有在您指定了--option之后。

如果您在好奇:Appeal.option()必须装饰接受您映射选项的参数的函数。所以如果您想在上面的示例中为verbose参数定义显式的选项,您将用Appeal.option()装饰my_converter,而不是inception。(这也意味着,您在任何地方使用my_converter作为转换器的地方,它都将以相同的方式表现,包括接受相同的选项。)

多个不是MultiOptions的选项

我们才刚刚开始!怎么样,这个

import appeal
app = appeal.Appeal()

def my_converter(a: int, *, verbose=False):
    return [a, verbose]

@app.command()
def repetition(*args:my_converter):
    print(f"repetition args={args!r}")

app.main()

这也行,我敢打赌您已经猜到了它做什么。这个版本的weird接受用户在命令行上想要指定的任何数量的int参数,并且每个都可以选择性地接受自己的-v--verbose标志。

只消耗选项的位置参数

我再给您一个疯狂的例子

import appeal
app = appeal.Appeal()

class Logging:
    def __init__(self, *, verbose=False, log_level='info'):
        self.verbose = verbose
        self.log_level = log_level

    def __repr__(self):
        return f"<Logging verbose={self.verbose!r} log_level={self.log_level}>"

@app.command()
def mixin(log:Logging):
    print(f"mixin log={log!r}")

app.main()

你能猜到mixin的用法看起来像什么吗?(可能吧!)它看起来像这样

mixin [-v|--verbose] [-l|--log-level str]

尽管log是位置参数,但它不会在命令行上消耗任何位置参数。Logging转换器只添加选项!这是面向对象程序员可能称之为“混入”的东西。使用Logging转换器,您可以为您的每个命令添加日志选项,而无需每次都重新实现它。(尽管在大多数情况下,将此类选项添加到全局命令函数可能更好。)

在内部,它的工作方式与您预期的完全一样。由于log参数不消耗命令行参数,Appeal将始终调用其转换器。指定任何选项都会为该调用设置参数。并且生成的Logging对象将被作为参数传递给log

实际上,从Appeal的角度来看,"命令函数"和"转换器"之间没有区别。命令函数本质上就是一个被映射为命令的转换器。所以,你可以用命令函数做的任何事情,也可以用转换器做。转换器可以定义选项,它可以被app.option()(或我们尚未讨论的app.parameter())装饰,它可以接受Python定义的任何类型的参数,任何参数都可以使用(几乎)任何转换器。而且这些转换器可以递归地使用其他转换器。

事实上,任何都可以和任何一起使用

  • 用于位置参数的转换器可以接受位置参数,或仅限关键字的参数,或*args,或**kwargs
  • 用于仅限关键字参数的转换器可以接受位置参数,或仅限关键字的参数,或*args,或**kwargs
  • 用于*args的转换器可以接受位置参数,或仅限关键字的参数,或*args,或**kwargs
  • 命令函数可以使用任何转换器。
  • 全局命令函数可以使用任何转换器。

到目前为止,你已经看到了Appeal赋予你的表达力。当然,你很少会只用其中的一小部分。但知道无论你想表达什么样的命令行API隐喻,在Appeal中不仅可能实现,而且容易实现,这让人感到安心。

类、实例和准备器

也许你已经注意到——到目前为止的所有示例都使用了标准的Python函数作为Appeal命令。那么方法调用呢?你可以用这些作为命令吗?答案是,当然可以!但稍微复杂一点。

Appeal存在的全部目的就是通过从命令行获取数据来调用函数。每当它看到一个函数的位置参数时,它就会想“好吧,我必须为那个参数提供一个参数”。所以如果你将一个未绑定的方法调用映射到一个命令

class MyApp:
    @app.command()
    def sum(self, *operands: int):
        return sum(*operands)

Appeal会看到self参数并想“哈哈!我需要在那里传递一个字符串!”我们需要阻止Appeal首先看到那个参数。

有两种主要的技术来处理这个问题。第一种是简单直接,但稍微有点不灵活:首先创建你的类的实例,然后对绑定的实例调用app.command()()。就像这样

app = appeal.Appeal()
class MyApp:
    def sum(self, *operands: int):
        return sum(*operands)

o = MyApp()
app.command()(o.sum)
app.main()

由于你传递了已经绑定的方法给Appeal,它甚至看不到签名中的self参数。(绑定方法的签名不包括self参数)

这没问题... 但可能看起来有点奇怪。我们不再装饰函数(或方法),而是直接调用装饰器函数并传递绑定的方法。这也限制了我们只能有一个MyApp实例与一个Appeal实例一起使用,这可能会有些限制。

另一种技术使用一点魔法来提供一个方便且看起来熟悉的接口。Appeal.app_class()给你两个装饰器;你使用一个来装饰你的类,另一个来装饰类中的方法。Appeal会为你实例化你的类,并使用你的__init__方法作为你的应用的全局命令来处理全局选项!

import appeal

app = appeal.Appeal()
app_class, command_method = app.app_class()

@app_class()
class MyApp:
    def __init__(self, *, verbose=False):
        print(f"MyApp init verbose={verbose!r}")
        self.verbose = verbose

    def __repr__(self):
        return "<MyApp>"

    @command_method()
    def add(self, a, b, c):
        print(f"MyApp add self={self!r} a={a!r} b={b!r} c={c!r} self.verbose={self.verbose!r}")

app.main()

幕后,这使用了一个CommandMethodPreparer对象来处理方法到对象的后期绑定。由于Appeal.app_class()有点不灵活,你可能想直接使用这些对象。你可以通过调用Appeal.command_method()手动创建一个。这里有一个示例,展示了如何使用一个

import appeal

app = appeal.Appeal()
command_method = app.command_method()

class MyApp:
    def __init__(self, id):
        self.id = id

    def __repr__(self):
        return f"<MyApp id={self.id!r}>"

    @command_method()
    def add(self, a, b, c):
        print(f"MyApp add self={self!r} a={a!r} b={b!r} c={c!r}")

my_app = MyApp("dingus")

p = app.processor()
p.preparer(command_method.bind(my_app))
p.main()

这是你第一次看到Processor对象。处理命令行的所有运行时信息都存储在Processor对象中;实际上,Appeal.mainAppeal.process都是对Processor对象上等效方法的薄包装。将所有运行时信息移到Processor对象中让你可以使用同一个Appeal对象处理多个命令行,甚至是同时处理!

CommandMethodPreparer 对象是 Appeal 处理方法到对象动态绑定核心的关键。首先,你用这个对象装饰你的类中的方法调用。然后,你调用该对象的 bind 方法,传入你想绑定这些方法的那个类的实例。尽管 app_class() 已经为你处理了这一点。bind() 返回一个可调用的对象,你可以将它传递给 Processor.preparer,这样在 Appeal 调用该方法之前,就会将该方法绑定到该实例。

在底层,CommandMethodPreparer 使用 functools.partial 对象包装方法,传入一个用于 self 参数的占位符对象。然后 command_method.bind() 替换占位符为实际实例。为了最大兼容性,它实际上使用 getattr() 来将实例绑定到方法。

编写帮助文档

Appeal 自动生成命令函数的 用法。但是,解释这些命令、参数和选项实际 做什么 的文档则需要你自己编写。

有关如何在 Appeal 中编写文档的详细说明,请参阅 Appeal 源代码分发中的 appeal/notes/writing.documentation.txt。简而言之,你以特定的方式编写文档字符串,Appeal 可以自动解析和组合它们。因此,你为每个转换器单独编写文档,Appeal 将所有这些文档片段合并起来,以生成命令函数的帮助信息。

(一点说明:你程序的主要帮助应该是 Appeal 实例的全局命令的文档字符串。)

Appeal 的最新超级功能:读取配置文件

Appeal 允许无需摩擦的命令行 API。你编写命令函数,将 Appeal 指向它,然后谁osh!现在你就有了命令行界面。但是,用户可能还想使用其他接口来配置你的程序。现在 Appeal 也可以处理这些。

例如,你的程序可能从环境变量中读取配置。一些程序启动一个编辑器;例如 git 在提交修订时会打开一个编辑器。在基于 UNIX 的平台上,这可以通过两个环境变量 VISUALEDITOR 来配置,优先级顺序如下。

Appeal 不需要添加对环境变量的显式支持,因为 Python 已经提供了一个易于使用的接口。例如,以下是如何支持配置编辑器的环境变量

然而,许多程序也支持配置文件,也称为 UNIX 中的 "rc 文件"。按照惯例,此类配置文件中的设置通常优先于环境变量。例如,你可以使用存储在配置文件中的名为 core.editor 的值来配置 git 用于提交的编辑器。

截至 0.6 版本,Appeal 具有从配置文件读取数据的支持。请注意,Appeal 不会自己读取数据文件;你已经有了一个用于此目的的库。相反,Appeal 具有从可迭代或映射(列表或字典)读取数据的通用机制。

第一步是从配置文件中读取值,并生成一个字典或类似字典的对象。你可以使用任何你喜欢的库。例如,tomli 库非常适合 TOML 文件。 JSON 和 YAML 解析器也运行良好。而且这个功能与我设计的 Perky 文件格式配合得非常好。尽管这只是一个巧合,因为它们是分开设计的,相隔几年。真的!

(你也可以使用 configparser 读取你的 INI 配置文件,但这与 Appeal 的模型不太匹配。对读取 INI 文件的支持可能是 Appeal 将来的一个发展方向。)

一旦你得到了包含你的配置信息的字典,你可以通过一个方法调用让 Appeal 从中读取

    Appeal.read_mapping(self, callable, mapping)

只需传入您希望调用的可调用对象以及从配置文件中读取的映射(字典)。Appeal 将读取可调用参数的名称,使用这些名称从映射中提取值,并将这些值传递给可调用对象的调用中。

当然,任何映射都可以使用。但这种方法与装饰了 dataclasses.dataclass 的类配合使用尤其出色。只需几行代码,您就可以定义一个包含配置信息的类,从文件中读取它,并用所有正确的类型填充该类!

在许多方面,这与 Appeal 处理命令行的方式非常相似。例如

  • Appeal 将使用注释和默认值将字典中的值转换为正确的类型。
  • 具有默认值的参数是可选的;没有默认值的参数是必需的。

但也有不同之处

  • 您可以使用仅位置参数、位置或关键字参数或仅关键字参数。但是,不支持可变位置参数(*args)和可变关键字参数(**kwargs)。

让我们用一个例子来综合所有这些。假设我们正在编写一个可能启动编辑器的假设程序。我们的复杂程序有 五种 方法来决定运行编辑器的程序。按照重要性的降序排列

  • 命令行选项 '-e' 和 '--editor' 指定了用于此实例的编辑器。
  • 配置文件 ~/.myprogramrc 是一个 Perky 文件,它可以包含一个 editor 值。
  • 如果用户设置了 VISUAL 环境变量,则使用该变量。
  • 如果用户设置了 EDITOR 环境变量,则使用该变量。
  • 默认值是 /usr/bin/vi

以下是实现这些语义的示例 Python 代码

    default_editor = os.environ.get("VISUAL",
        os.environ.get("EDITOR", "/usr/bin/vi"))
    @dataclasses.dataclass
    class ConfigFile:
        editor:str=default_editor

    d = perky.load(os.path.expanduser("~/.myprogramrc"))
    app = appeal.Appeal()
    config_file = app.read_mapping(ConfigFile, d)

    @app.global_command()
    def global_command(*, editor=config_file.editor):
        print(f"editor = {editor}")

    app.main()

注意:使用 os.path.expanduser 和此类硬编码的文件名已不再是最佳实践。您应该使用 platformdirs 来定义配置文件的路径。

嵌套

Appeal 的配置文件读取器支持从嵌套字典中读取值。这直接映射到注释中的嵌套函数调用。如果一个注释有两个或更多参数,则具有该注释的参数的名称将用作嵌套字典的名称。

由于这可能不是很清楚——抱歉!——一个例子可能有所帮助。考虑以下示例字典

    d = {
        'a': 33,
        'b': {
            'verbose': True,
            'color': 'blue',
        },
    }

在这里,'b' 的值是一个嵌套字典。如果我们希望 Appeal 读取具有此形状的字典,它将必须进入那个嵌套字典。Appeal 默认在参数有注释且注释有两个或更多参数时这样做。以下是使用 Appeal 读取此字典的示例 Python 代码

    def read_b(verbose=False, color='black'):
        return (verbose, color)

    def config_file(a: int, b: read_b):
        return (a, b)

由于 read_b 是一个接受多个参数的注释,Appeal 假设 'b' 的值是一个嵌套字典,并从该字典中获取 'verbose''color' 的值。

如果您不想这种行为,您可以使用 Appeal 对象上的 unnested 方法装饰注释函数来禁用它。如果我们更改代码如下

    @app.unnested()
    def read_b(verbose=False, color='black'):
        return (verbose, color)

    def config_file(a: int, b: read_b):
        return (a, b)

Appeal 不会 进入名为 'b' 的嵌套字典。在这种情况下,字典必须是完全扁平的,如下所示

    d = { 'a': 33, 'verbose': True, 'color': 'blue' }

可迭代对象

Appeal 还可以从字典中的可迭代对象读取。接受可迭代的参数应使用 MultiOption 的子类进行注释。Appeal 将实例化 MultiOption 并使用 MultiOption 协议填充对象。

例如,如果您的配置文件字典看起来像这样

    d = {
        'color': 'blue',
        'lines': [
            'line 1',
            'here is line 2',
            'and finally, line 3',
            ]
    }

Appeal 可以将其映射到以下可调用对象

    @dataclasses.dataclass
    class ConfigFile:
        lines: appeal.accumulator
        color:str = ''

如果 MultiOption 选项接受多个参数,则列表必须包含字典。例如,以下 Python 代码

    class Resolutions(appeal.Multioption):
        def init(self, default=None):
            self.default = default
            self.values = None

        def option(self, width:int, height:int, depth:int):
            if self.values is None:
                self.values = []
            self.values.append((width, height, depth))

        def render(self):
            if self.values is None:
                return self.default
            return self.values

    @dataclasses.dataclass
    class ConfigFile:
        resolutions: Resolutions
        color:str = ''

将能够读取此映射

    d = {
        'color': 'orange',
        'resolutions': [
            {'width': 1280, 'height': 1024, 'depth': 24},
            {'width': 1600, 'height': 1200, 'depth': 16},
        ],
    }

read_iterable

除了 Appeal 的 read_mapping 方法之外,Appeal 还支持 read_iterable 方法。API 几乎相同

    Appeal.read_iterable(callable, iterable)

然而这个函数要简单得多。可迭代的本身应该是一个可迭代的可迭代对象。Appeal会为每个嵌套的可迭代对象调用你指定的可调用对象一次。结果将被添加到列表中,然后read_iterable将返回该列表。

一如既往,Appeal将处理使用你指定的注解转换值。与read_mapping不同,这里你可以传入一个接受*args的函数,在这种情况下,Appeal将处理任意数量的尾随参数。与read_mapping不同的是,这里不支持测试——既不支持嵌套字典,也不支持(进一步)嵌套列表。(当处理命令行时,读取可迭代对象中的值时,嵌套注解会被扁平化。)

CSV文件

最后,Appeal对读取CSV文件有特殊支持。这看起来可能有些奇怪——没有人将CSV文件用作配置文件。但CSV文件是Appeal配置文件支持的证明概念,并在另一个项目中得到了证明,所以目前它仍然保留。有一种特殊的方法用于读取CSV文件

    Appeal.read_csv(self, callable, csv_reader, *, first_row_map=None)

你传入你的可调用对象和一个新的csv.reader对象。Appeal将从CSV对象中读取行,将字符串传递给callable,并将结果添加到列表中。返回值是那个列表。

如果first_row_map为false,read_csv将忽略CSV文件的第一行(“列名”行),并按位置传递CSV文件中的值。如果first_row_map为true,read_csv将使用CSV文件第一行的行作为字典的键,用随后的每一行填充值,并将参数按名称传递。

换句话说,如果first_row_map为false,Appeal调用

    callable(*row)

对于CSV文件第一行之后的每一行。如果first_row_map为true,Appeal调用

    d = {key: value for key, value in zip(column_headers, row)}
    callable(**d)

对于CSV文件第一行之后的每一行。

API参考

Appeal(help=True, version=None, positional_argument_usage_format="{name}", default_options=default_options)

创建一个新的Appeal实例。

如果help为true,Appeal将自动为你的程序添加帮助支持

  • 添加硬编码的-h--help选项,打印基本帮助。
  • 如果你的Appeal实例有任何命令,并且你没有定义help命令,将自动添加一个help命令。

如果version为true,它应该是一个表示你的程序版本的字符串。Appeal将自动为你的程序添加版本支持

  • 添加硬编码的-v--version选项,打印版本字符串。
  • 如果你的Appeal实例有任何命令,并且你没有定义version命令,将自动添加一个打印版本字符串的version命令。

positional_argument_usage_format是用于格式化位置参数的格式字符串。这个字符串内部唯一有效的插值是{name},它评估为参数的名称,以及{name.upper()},它评估为参数名称的大写形式。所以如果你想让你的用法字符串显示为<name>NAME的参数或opargs,你可以通过设置positional_argument_usage_format<{name}>{name.upper()}来实现。

default_options是一个可调用对象,当命令函数或转换器的关键字参数没有显式映射任何选项时被调用。default_options的目的是通过调用Appeal.option()一次或多次来为该关键字参数创建一些默认选项。

default_options可调用对象的API应该是

default_options(appeal, callable, parameter_name, annotation, default)
  • appeal是Appeal实例。
  • callable是参数定义在的命令函数或转换器。
  • parameter_name是未定义任何显式选项的关键字参数的名称。
  • annotation 是此参数的注释。这可以显式设置在函数上,也可以从默认参数推断。
  • default 是此参数的默认值。由于 Appeal 要求关键字参数必须始终有默认值,因此此值不能为 inspect.Parameter.empty

default_options 的返回值被忽略。

default_options 的默认值为 Appeal.default_options(),如下文所述。

Appeal.command(name=None)

用作装饰器。返回一个可调用对象,该对象接受单个参数 callable,该参数必须是一个可调用对象。

将可调用对象添加为当前 Appeal 实例的命令。如果 nameNone,则命令的名称将是 callable.__name__

(以任何方式都不修改 callable)。

Appeal.global_command()

用作装饰器。返回一个可调用对象,该对象接受单个参数 callable,该参数必须是一个可调用对象。

设置此 Appeal 对象的 全局命令。这是在第一个命令函数之前处理全局选项的命令。

只能设置在顶层 Appeal 对象上。(你不能调用 app.command('foo').global_command()。)

(以任何方式都不修改 callable)。

Appeal.default_command()

用作装饰器。返回一个可调用对象,该对象接受单个参数 callable,该参数必须是一个可调用对象。

设置此 Appeal 对象的 默认命令。当你的 Appeal 实例有子命令但用户未在命令行上指定命令名称时,将运行默认命令。

你的默认命令函数不得接受任何参数。

(以任何方式都不修改 callable)。

Appeal.option(parameter_name, *options, annotation=empty, default=empty)

用作装饰器。返回一个可调用对象,该对象接受单个参数 callable,该参数必须是一个可调用对象。

将命令行上的选项映射到装饰函数上的 parameter_name 参数。所有后续的位置参数都是选项,如 --verbose-v。(因此,它们必须是字符串,长度为两个字符,或四个或更多字符。)

annotation 是如果调用此选项将使用的转换器。如果没有提供显式的 annotationAppeal.option() 将默认为 type(default)

default 是此选项的默认值。由于此参数仅在用户指定此选项时才会发挥作用,因此此处的 default 值几乎毫无用处。但它有两个用途:

  • 如果注释的类型是 Option 的子类,则此默认值将传递到 Option.init()
  • 如果没有指定 annotation,则注释默认为 type(default)

调用 Appeal.option() 而不指定 annotationdefault 的值是非法的。

如果任何 option 已在此 Appeal 实例内部以不同的签名映射,则引发 AppealConfigurationError

(以任何方式都不修改 callable)。

Appeal.parameter(self, parameter_name, *, usage=None)

用作装饰器。返回一个可调用对象,该对象接受单个参数 callable,该参数必须是一个可调用对象。

允许配置命令函数或转换器上的位置(或位置-关键字)参数。parameter_name 是参数的名称;它必须是装饰的 callable 的一个参数。

目前唯一支持的配置是 usage,它指定将代表此参数的用法信息的字符串。

(以任何方式都不修改 callable)。

Appeal.main(args=None)

处理命令行并调用你的命令函数。在第一个失败结果处停止,并将其传递给 sys.exit()。捕获用法错误;如果捕获到一个,则显示用法信息。该实现调用 Appeal.process()

Appeal.process(args=None)

处理命令行并调用你的命令函数。在第一个失败结果处停止并返回该结果。不捕获任何错误。主要用于自动化,尤其是用于测试,并且是 Appeal.main() 的主要驱动程序。

Appeal.default_options()

Appeal.default_long_option()

Appeal.default_short_option()

这些函数创建关键字参数的默认选项。它们都是 Appeal() 构造函数中 default_options 参数的有效回调函数。Appeal.default_options() 是该参数的默认值。

Appeal.default_long_option() 创建具有默认注释和默认值的选项 --{modified_parameter_name}modified_parameter_nameparameter_name.lower().replace('_', '-')

Appeal.default_short_option() 创建具有默认注释和默认值的选项 -{parameter_name[0]}

Appeal.default_options() 创建这两个选项。

在这三种情况下,如果函数无法映射至少一个选项,它将引发一个 AppealConfigurationError

默认选项语义说明

  • Appeal.default_option() 将关键字参数转换为长选项和短选项时,Appeal 会复制第一个字符作为短选项,然后对该字符串运行一个转换函数。转换函数将字符串转换为小写并将下划线转换为连字符。所以对于关键字参数 DefineAppeal.default_option() 会(尝试)创建两个选项 -D--define。对于关键字参数 block_type,它将尝试创建 -b--block-type

  • 如果你有多个关键字参数,它们具有相同的首字母,则只有第一个映射成功。所以如果你使用 def myoptions(*, block_type=None, bad_block=None) 作为 Appeal 命令,-b 将映射到 block_type。如果你想将其映射到 bad_block,只需交换这两个关键字参数,使 bad_block 首先出现,或者通过在函数上装饰 Appeal.option() 显式定义你的选项。(从某些最近版本开始,Python 保证在检查函数时将关键字参数的顺序保持不变——而且即使在没有明确保证的情况下,这也是每个版本的 Python 的一个意外之处。)

AppealConfigurationError

一个异常。在 Appeal API 使用不当时会引发。

AppealUsageError

一个异常。在 Appeal 处理无效的命令行时引发。由 Appeal.main() 捕获,用于打印用法信息和返回错误。

AppealCommandError

一个异常。在 Appeal 命令函数返回指示错误的結果时引发。(相当于 SystemExit。)由 Appeal.main() 捕获,用于打印用法信息和返回错误。

参考

库检查你的函数参数,并使用这些参数作为子命令的参数、选项和 opargs

  • 仅位置参数和位置或关键字参数(在 *,*args, 之前的位置参数)映射到位置参数。这将

    @app.command()
    def fgrep(pattern, file, file2=None):
        ...
    

    需要两个必需的命令行参数 "pattern" 和 "file",以及一个可选的第三个命令行参数 "file2"。

  • 关键字参数映射到选项。它们必须具有默认值。参数的名称是选项的名称,例如,此子命令接受一个 --verbose 参数

    @app.command()
    def foo(*, verbose=False):
        ...
    
  • 如果你的函数的参数有注释,则调用该值将命令行字符串转换为字符串,然后再将其传递给你的函数。例如。

    @app.command()
    def foo(level:int):
        ...
    

    会在将字符串传递给 level 之前,在命令行上调用 int

  • 如果你的函数的参数没有注释,但 确实 有默认值,则它表现得好像你添加了一个注释 type(default_value)。例如。

    @app.command()
    def foo(level=0):
        ...
    

    也会在将字符串传递给 level 之前调用 int

    • 具有 bool 注释或布尔默认值的仅关键字参数是特殊的:它们不需要参数。相反,它们切换默认值。

    • 具有默认值为 None 且没有注释的参数也是略微特殊的,因为它们接受一个 str 参数(因为接受一个 NoneType 参数没有意义)。

    • 当可能时,Appeal会自动为仅关键词参数添加单字母选项。由于仅关键词参数在Python*++*中保持其顺序,Appeal将单字母快捷键分配给以该字母开头的第一个参数。例如:

      @app.command()
      def foo(*, verbose=False, varigated=0):
          ...
      

      -v将映射到--verbose,而不是--varigated

将这些放在一起:如果您想要编写一个类似“fgrep”的子命令,并且使用以下这样的“usage”字符串:

fgrep [-v|--verbose] [--level <int>] pattern [ file1 [ file2 ... ] ]

您将按以下方式编写它

@app.command()
def fgrep(pattern, *file, verbose=False, level=0):
    ...

++ 这现在是当前Python中的保证行为,甚至在Python 3系列之前的版本中,这也始终是正确的。

Appeal与POSIX实用工具语义

POSIX标准在1003.1第12章中定义了所有POSIX实用工具命令的命令行行为,目前修订为POSIX.1-2017

https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html

Appeal与POSIX语义不完全匹配;它禁止POSIX允许的一些操作,并允许POSIX禁止的一些操作。

  • 根据所需的POSIX语义(1003.1-2017第12章),选项永远不能是必需的。因此,在Appeal中,命令函数的关键字参数必须始终有一个默认值。
  • POSIX标准没有提及“长选项”,因此不清楚标准是否允许它们。(预计它们将在未来的标准中得到允许。)
  • POSIX要求接受/要求多个操作数(opargs)的选项应接受它们作为一个单独的字符串,其中操作数由空格或逗号分隔。Appeal通过appeal.split支持这种行为。但同时也允许命令行中消费多个单独的操作数。
  • POSIX要求在所有位置参数之前指定所有选项。Appeal不强制执行此要求,并乐意以任何顺序消费选项和位置参数。实际上,“子命令”需要允许在位置参数之后指定选项,以支持任何超出最简单可能的子命令支持。
  • POSIX要求如果选项(短选项)有一个单独的可选操作数(oparg),则其操作数必须连接到选项。例如,如果-f接受一个可选参数,并且您想指定参数guava,您必须将其写为-fguava,不允许其他拼写。虽然Appeal支持这种拼写,但它也支持-f=guava-f guava。更重要的是,如果您在命令行上指定了-f(而不是-f=<something>-f<something>),Appeal将命令行上的下一个参数作为oparg消费,这正是POSIX肯定不希望看到的。我认为Appeal的一致性比支持这种句法欺骗更重要。请注意,oparg仍然是可选的,因此如果-f是您的命令行上的最后一项,这将实现这种“选项具有默认值”的行为。

其他细微功能和行为

  • 您可以在命令行上以任何顺序指定选项和参数,Appeal并不关心。如果您想使Appeal停止识别以连字符开头的参数作为选项,请指定--(两个连字符,后面没有其他内容)。命令行上随后的所有字符串都将用作参数,即使它们以-开头。
  • 许多内置类型是不可内省的。如果您调用inspect.signature(int),它会抛出ValueError。Appeal特别针对恰好五种内置类型进行了特殊处理:boolintstrcomplexfloat
  • Accumulator实际上允许通过逗号分隔来参数化多个类型。Accumulator[int, float]将在每次指定选项时接受两个操作数,第一个是int,第二个是float。返回的列表将包含包含整数和浮点数的元组。
  • 您不能在Appeal对象上多次调用main()。您使用的Appeal()实例具有在执行其main()方法时发生变化的内部状态。
  • 特定转换器的信息被本地化到特定的Appeal()实例。如果你用@app.option()装饰转换器,那么在该Appeal()实例中使用该转换器的每个地方也会接收到你用@app.option()所做的更改。
  • 你应该在你将所有命令、选项和参数信息添加到你的Appeal对象中之后再调用usage()。为什么?因为,例如,usage()计算默认选项,这些选项没有明确定义。但如果你然后定义了其中一个选项,Appeal会向你抛出错误。
  • 几乎任何可调用项都可以作为转换器——但并非所有函数都可以。有两个限制。首先,如前所述,为了使一个函数成为合法的转换器,每个关键字参数都必须有一个默认值。第二个要求更加具体:为了将函数用作*args*参数的转换器,在该函数的注释树中,必须有些函数接受一个必需的位置参数。

最后,UNIX的make命令有一个有趣而微妙的行为。make--jobs-j选项指定并行运行的作业数量。如果你不带任何参数运行make,它将一次运行一个作业。如果你运行make -j 5,它将同时运行五个作业。但是!如果你指定make -j,其中-j是命令行的最后一个参数,它将同时运行尽可能多的作业。从某种意义上说,-j选项有两个默认值。

你可以在Appeal中这样做吗?当然可以!只需用注释和默认值指定你的关键字参数,然后设计注释函数,使其接受一个具有默认值的参数。就像这样

def jobs(jobs:int=math.inf):
    return jobs

@app.command()
def make(*targets, jobs:jobs=1):
    ...

Appeal命令函数的限制

  • 你不能使用inspect.Parameter.empty作为转换器或命令函数的任何关键字参数的默认值。
  • 对于var_positional*args)参数的转换器必须至少需要一个位置参数。

变更日志

下一个版本 正在开发中

0.6.3 2024/09/06

  • 使用情况修复。如果命令行参数的会话失败,Appeal现在将打印一个上下文特定的错误,然后打印出失败命令的使用情况。修复了#18。
  • 修复了read_mapping的错误。之前你无法在映射函数的注释树中的任何地方有两个同名参数,现在可以了。

0.6.2 2023/10/12

  • 展示更改:如果你在没有参数的情况下运行程序,将运行无参数的help而不是usage。这将打印出使用信息和命令列表,这似乎更有用。这就是大多数现代程序的做法(例如githg)。
  • 小的API更改:重命名了Appeal的自定义异常,以删除单词Appeal。例如,AppealUsageError现在是简单的UsageError。我添加了别名,所以旧的名字仍然有效;我最终会移除它们,但我保证至少保留旧的名字一年。
  • 修复了使用情况生成,并添加了测试。
  • 修复了一个错误,即使用“简单类型”(例如bool,float)作为选项的注释会导致Appeal帮助引发异常。修复了#15。

0.6.1 2023/07/22

  • 修复了对3.6和3.7的支持——一些f-strings中的等号使用了。
  • 添加了GitHub Actions集成。在每次提交后,云中都会运行测试和覆盖率。感谢Dan Pope在这一点上对我温柔地引导!
  • 修复了pyproject.toml文件中的元数据。
  • 添加了测试和受支持的Python版本的徽章。(尚未添加覆盖率徽章...它太尴尬了!)

0.6 2023/07/20

巨大的升级!

  • 新功能:Appeal现在可以读取配置文件了!查看新的APIAppeal.read_mappingAppeal.read_iterable,甚至Appeal.read_csv。这是一个巨大的工程,并且涉及到编译器的重大改造。

  • 对现有行为最大的改变:现在在早期映射选项。参见问题 #3。简而言之:当选项仅定义在可选组中时,它们会被临时映射(使可用)该组第一个参数之前。使用该选项就像指定该组第一个参数一样进入组。您会在使用上看到差异;以前映射了选项的可选组看起来像 [a [-v|--verbose] b c],但现在看起来像 [[-v|--verbose] a b c]

  • 现在,Appeal 以相同的方式处理多个连续的短选项(例如 -ace),就像它们单独指定一样(例如 -a -c -e)。这导致了关于子选项何时取消映射的行为上的可观察变化。

    • Appeal 只允许在有限的情况下使用子选项:它必须在父选项执行之后,必须在父选项消耗了所有必需的或可选 opargs 之后,并且必须在任何顶级位置参数或父选项执行之前映射的选项之前。但Appeal在处理多个短选项连续使用(例如 -ace)时对执行这些规则不太严格;它会处理所有选项,然后根据需要取消映射子选项。好消息是:Appeal现在也在这里执行这些规则。(旧的行为似乎是我故意的——我在想什么?!)
  • 对于未知选项现在提高的用法信息现在要好得多。如果选项在正在运行的程序中定义过,它将打印不同的消息告诉你它不能在这里使用,但同时也告诉你可以在哪里使用。例如,如果你使用了选项 -x,但那是 --parent 映射的子选项,消息将说 -x 不能在这里使用,它必须立即在 --parent 后使用

  • Appeal.argument 重命名为 Appeal.parameter。这是一次“我在想什么?”的时刻。该函数影响参数,而不是参数。旧名称仍然有效,但将在1.0之前删除。

  • short_option_concatenated_oparg 现在更加严格地执行:仅允许具有 恰好一个 可选 oparg 的短选项,如 POSIX 所指定。

0.5.9

  • 改进了在 VAR_POSITIONAL 参数之后有必需参数时生成的错误信息。(此命令行永远不会成功,因为 VAR_POSITIONAL 消耗了命令行上的所有剩余参数,这意味着后续的必需参数永远无法满足。)修复了 #6。
  • 将 README 更改为使用绝对链接而不是相对链接,这意味着图像现在应该在 PyPI 的 Appeal 页面上正确渲染。感谢 Hugo 提交的 PR!
  • 将大量内部类切换到使用 Python “slots”。希望这能带来微小的内存和速度优化。
  • 移除未使用/不需要的物品
    • 未使用的 partial_replace* 函数
    • 未使用的 Charm 字节码指令 jump_relativeload_o_option
    • 不需要的 CharmInterpreter 寄存器 option(仅在一条错误信息中使用,直接使用程序名称替代)
  • 由于 Appeal 无论如何都依赖于 big,因此切换到 big's PushbackIterator
  • 添加了窥孔优化步骤以进行跳转到跳转优化。老实说,这可能是不必要的,因为我不认为 Appeal 的编译器甚至能生成带有跳转到跳转的代码(目前)。

0.5.8

  • 修复了程序选项的“名称”。我们过去使用命令的名称,添加所有选项字符串,并用逗号连接起来,例如 'command, -o, --option'。现在看起来像 'command -o | --option'
  • 修复了展示错误:如果您为您的命令函数没有足够的参数,但您在命令行上调用过选项,用法文本将包含最后调用的选项的名称(即最后运行的 Charm 程序)。为此添加了一个回归测试。
  • 稍微整理了一下实现:不再在CharmInterpreter栈和context_stack上使用神秘列表,现在使用定制的CharmStackEntryCharmContextStackEntry类实例。

0.5.7

  • 重写了accumulator[...]mapping[...]背后的技术。之前使用的是exec(),这限制了功能;例如,你不能使用自己的类型或转换器。新的实现应该更健壮;现在它为创建的子类的option()方法手动定义了显式的签名。
  • 这修复了一个回归问题,即你不能将本地定义的类(例如IntFloat)用作accumulatormapping方括号中的类型之一。为此添加了一个测试。

0.5.6

  • 修复了当你有全局命令和子命令时的格式问题。

0.5.5

  • 添加了对Python 3.9中新引入的typing.Annotated的支持。
  • 将依赖项添加到我的big。这使得Appeal具有更好的multisplit实现,我计划切换到big词包装器和列化函数,这些函数在Appeal当前实现中是一个... "大的"改进。
  • SingleOption重命名为Option。(SingleOption这个名字现在已经弃用,但我将暂时保留它作为Option的冗余名称...)

0.5.3

  • 修复了与Python 3.6的兼容性问题。

0.5.2

  • 修复了与Python 3.11的兼容性问题。Python的inspect.Parameter对象不再允许使用关键字作为name,这是一个小问题(Appeal有时会在这里使用lambda)。

0.5.1

  • 修复了回归问题,问题#5。如果你没有提供足够的必需参数,你会得到一个TypeError,而不是适当的用法错误。

0.5

  • 首次发布!

项目详情


下载文件

下载您平台上的文件。如果您不确定要选择哪个,请了解更多关于安装包的信息。

源代码分发

appeal-0.6.3.tar.gz (233.2 kB 查看哈希值)

上传时间 源代码

构建分发

appeal-0.6.3-py3-none-any.whl (104.6 kB 查看哈希值)

上传时间 Python 3

支持者