一个强大且Python风格的命令行解析库。给你的程序添加申诉功能!
项目描述
版权所有 © 2021-2023 Larry Hastings
快速入门
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的一个命令行参数处理库,类似于argparse
、optparse
、getopt
、docopt
、Typer
和click
。但是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_CREATE
和 window.c
将是参数;第一个参数,WM_CREATE
,将是您想要搜索的字符串,而window.c
将是您想要搜索的文件名。
命令是某些程序用于指定程序要执行的功能的特殊类型的参数。一个使用命令的程序的好例子是git
;当您运行git add
或git commit
时,add
和commit
都是命令。命令总是使用它们的程序的第一个参数。
如果命令行上的字符串以-
(减号)开头,则这是选项。有两种选项样式:短选项和长选项。
短选项以单个连字符-
开头。这后面跟着一个或多个单独的字符,这些是短选项字符串。在上面的例子中,我们指定了两组短选项:第一组是-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
已经发生了很多事情!让我们逐一了解
- 我们创建了一个名为
app
的Appeal
对象。此对象将处理命令行并调用适当的命令函数。 - 我们使用我们的
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!
您的命令函数的返回值是您程序的返回码。如果返回None
或0
,则表示成功;返回非零整数表示失败。(并且如果您的函数在没有返回语句的情况下退出,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()
也可以用于不使用“命令”的程序。虽然现在的“命令”命令行模式很流行,但大多数程序都不关心它们。例如,ls
、grep
以及……嘿!连 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
子类:counter
、accumulator
和mapping
。
首先,让我们看看counter
。counter
简单地计算命令行上指定选项的次数。这是一个“详细”选项的相对常见的惯用法;一个支持-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()
方法之前,它们会被转换成 int
和 float
类型。您需要自己决定如何存储它们,以及如何将它们渲染成您的 render()
方法返回的单个值。
MultiOption
是一个通用 Option
类的子类。与 MultiOption
相同,Option
的行为完全一样,但它只允许在命令行上指定一次选项,这意味着它只会调用一次您的 option()
方法。您通常不需要烦恼于创建 Option
的子类——直接使用一个类通常更好,就像我们的 class IntAndFloat
例子。通过子类化 Option
,您获得的一个唯一特性是,您可以获得传递给构造函数的参数的默认值。
(子类化 Option
和 MultiOption
的缺点是,它使得将您的 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()
函数相同的方式接受 start
和 stop
参数。请注意,validate_range()
与 Python 的 range
在一个细微之处不同:允许值等于 stop
。
如果您愿意,可以通过将 clamp=True
传递给 validate_range()
来将用户传入的值“夹”在范围内。在这种情况下,如果用户指定的值超出了范围,validate_range()
将返回 start
或 stop
中最接近的值。
(这就是为什么 validate_range()
允许值等于 stop
。如果 stop
本身是非法值,则使用 clamp
会很烦人——特别是如果类型是浮点数的话。)
Appeal 验证函数易于编写。因此,如果这些不足以满足您的需求,您可以轻松地编写自己的。查看 Appeal 中 validate()
和 validate_range()
的实现,看看一种实现方式!
同一参数的多个选项
一些程序在它们的命令行上有一组互相排斥的选项。考虑这个简单的命令行
go [--north|--south|--east|--west]
也就是说,您希望用户能够“去”这四个方向之一,但只能是其中一个。在 Appeal 中您会如何做呢?
很简单。您只需定义多个写入同一参数的选项。到目前为止您所看到的所有行为都是使用 默认 方式将关键字参数映射到选项。但实际上,Appeal 允许您创建自己的映射。您可以将参数映射成任意多的方式,甚至可以使用不同的转换器!
要手动定义自己的选项,请在您的 Appeal 实例上使用 Appeal.option()
方法。这是一个装饰器,您将其应用于您的命令函数。第一个参数是要将选项写入的参数的名称。之后是您要映射到该参数的一个或多个选项。Appeal.option()
还接受 default
和 annotation
关键字参数,允许您分别指定此选项的默认值或注释。
以下是一个使用 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()
有带有 int
和 float
注释的参数。当然,那些函数只接受一个字符串参数;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.main
和Appeal.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 的平台上,这可以通过两个环境变量 VISUAL
和 EDITOR
来配置,优先级顺序如下。
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 实例的命令。如果 name
为 None
,则命令的名称将是 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
是如果调用此选项将使用的转换器。如果没有提供显式的 annotation
,Appeal.option()
将默认为 type(default)
。
default
是此选项的默认值。由于此参数仅在用户指定此选项时才会发挥作用,因此此处的 default
值几乎毫无用处。但它有两个用途:
- 如果注释的类型是
Option
的子类,则此默认值将传递到Option.init()
。 - 如果没有指定
annotation
,则注释默认为type(default)
。
调用 Appeal.option()
而不指定 annotation
或 default
的值是非法的。
如果任何 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_name
是 parameter_name.lower().replace('_', '-')
。
Appeal.default_short_option()
创建具有默认注释和默认值的选项 -{parameter_name[0]}
。
Appeal.default_options()
创建这两个选项。
在这三种情况下,如果函数无法映射至少一个选项,它将引发一个 AppealConfigurationError
。
默认选项语义说明
-
当
Appeal.default_option()
将关键字参数转换为长选项和短选项时,Appeal 会复制第一个字符作为短选项,然后对该字符串运行一个转换函数。转换函数将字符串转换为小写并将下划线转换为连字符。所以对于关键字参数Define
,Appeal.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特别针对恰好五种内置类型进行了特殊处理:bool
、int
、str
、complex
和float
。 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
。这将打印出使用信息和命令列表,这似乎更有用。这就是大多数现代程序的做法(例如git
,hg
)。 - 小的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现在可以读取配置文件了!查看新的API
Appeal.read_mapping
、Appeal.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现在也在这里执行这些规则。(旧的行为似乎是我故意的——我在想什么?!)
- Appeal 只允许在有限的情况下使用子选项:它必须在父选项执行之后,必须在父选项消耗了所有必需的或可选 opargs 之后,并且必须在任何顶级位置参数或父选项执行之前映射的选项之前。但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_relative
和load_o_option
- 不需要的 CharmInterpreter 寄存器
option
(仅在一条错误信息中使用,直接使用程序名称替代)
- 未使用的
- 由于 Appeal 无论如何都依赖于 big,因此切换到 big's
PushbackIterator
。。 - 添加了窥孔优化步骤以进行跳转到跳转优化。老实说,这可能是不必要的,因为我不认为 Appeal 的编译器甚至能生成带有跳转到跳转的代码(目前)。
0.5.8
- 修复了程序选项的“名称”。我们过去使用命令的名称,添加所有选项字符串,并用逗号连接起来,例如
'command, -o, --option'
。现在看起来像'command -o | --option'
。 - 修复了展示错误:如果您为您的命令函数没有足够的参数,但您在命令行上调用过选项,用法文本将包含最后调用的选项的名称(即最后运行的 Charm 程序)。为此添加了一个回归测试。
- 稍微整理了一下实现:不再在
CharmInterpreter
栈和context_stack上使用神秘列表,现在使用定制的CharmStackEntry
和CharmContextStackEntry
类实例。
0.5.7
- 重写了
accumulator[...]
和mapping[...]
背后的技术。之前使用的是exec()
,这限制了功能;例如,你不能使用自己的类型或转换器。新的实现应该更健壮;现在它为创建的子类的option()
方法手动定义了显式的签名。 - 这修复了一个回归问题,即你不能将本地定义的类(例如
IntFloat
)用作accumulator
和mapping
方括号中的类型之一。为此添加了一个测试。
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
- 首次发布!
项目详情
下载文件
下载您平台上的文件。如果您不确定要选择哪个,请了解更多关于安装包的信息。