命令行启动选项、环境变量和配置文件对许多应用程序都很重要,特别是在服务器实现方面。有许多处理程序启动和对象创建的方法。本章将重点讨论参数解析和应用程序的总体架构。
本章将从第 14 章、配置文件和持久化扩展配置文件处理,并为命令行程序和服务器的顶层提供更多技术。第 15 章中的核心设计原则、设计原则和模式在设计任何尺寸的应用程序时都是必不可少的。本章还将扩展第 16 章、日志和警告模块中的一些日志设计功能。
在第 19 章、模块和包设计中,我们将扩展这些原则,以查看一种架构设计,我们将在大型中称之为编程。我们将使用命令设计模式来定义无需借助 shell 脚本即可聚合的软件组件。这在编写应用服务器使用的后台处理组件时特别有用。
本章的代码文件可在上找到 https://git.io/fj2UD 。
通常,操作系统的 shell 使用构成 OS API 的若干信息启动应用程序:
- shell 为每个应用程序提供其环境变量集合。在 Python 中,可通过
os.environ
访问。 - shell 准备了三个标准文件。在 Python 中,它们被映射到
sys.stdin
、sys.stdout
和sys.stderr
。还有一些其他模块,例如fileinput
,可以提供对sys.stdin
的访问。 - 命令行由 shell 解析为单词。命令行的部分内容可在
sys.argv
中找到。对于 POSIX 操作系统,shell 可以替换 shell 环境变量和全局通配符文件名。在 Windows 中,简单的cmd.exe
shell 不会为我们提供全局文件名。 - 操作系统还维护上下文设置,例如当前工作目录、用户标识和用户组信息等。这些可通过
os
模块获得。它们在命令行中不作为参数提供。
操作系统希望应用程序在终止时提供数字状态代码。如果我们想返回一个特定的数字代码,我们可以在应用程序中使用sys.exit()
。os
模块定义了许多值,例如os.EX_OK
,以帮助返回具有共同含义的代码。如果程序正常终止,Python 将返回零;如果程序以未处理的异常结束,Python 将返回值 1;如果命令行参数无效,Python 将返回值 2。
shell 的操作是这个操作系统 API 的一个重要部分。给定一行输入,shell 根据(相当复杂的)引用规则和替换选项执行大量替换。然后,它将结果行解析为空格分隔的单词。第一个单词必须是内置的 shell 命令(如cd
或set
),或者必须是文件名,如python3
。shell 在其定义的PATH
中搜索此文件。
为了有效地使用可执行文件,您必须确保包含这些文件的目录由PATH
环境变量命名。在大多数操作系统中,应该为脚本添加冒号(:
和目录。在 Windows 中,您应该为脚本添加分号(;
和目录。
命令第一个字上命名的文件必须具有 execute、x
权限。chmod +x somefile.py
shell 命令将文件标记为可执行文件。不可执行的文件名会出现OS Permission Denied
错误。使用 OSls -l
(或 Windows 等效命令)查看文件权限。
可执行文件的第一个字节有一个神奇的数字,shell 使用该数字来决定如何执行该文件。一些幻数表示该文件是一个二进制可执行文件;shell 可以派生子 shell 并执行它。其他幻数,特别是由两个字节b'#!'
编码的值,表示该文件是正确的文本脚本,需要解释器。此类文件第一行的其余部分是解释器的名称。
我们通常在 Python 文件中使用如下行:
#!/usr/bin/env python3
如果 Python 文件具有执行权限,并将其作为第一行,那么 shell 将运行env
程序。env
程序的参数(python3
将导致它设置一个环境,并以 Python 文件作为第一个位置参数运行 Python 3 程序。
正确设置PATH
后,当我们在命令行输入ch18_demo.py -s someinput.csv
时会发生什么?程序通过可执行脚本从操作系统外壳到 Python 的一系列步骤如下所示:
-
shell 解析
ch18_demo.py -s someinput.csv
行。第一个词是ch18_demo.py
。此文件位于 shell 的PATH
上,具有x
可执行权限。shell 打开文件并找到#!
字节。shell 读取此行的其余部分并找到/usr/bin/env python3
命令。 -
shell 解析新的
/usr/bin/env
命令,这是一个二进制可执行文件。外壳启动env
程序。该程序依次启动python3
。由 shell['ch18_demo.py', '-s', 'someinput.csv']
解析的原始命令行中的单词序列被提供给 Python。 -
Python 将提取第一个参数之前的任何选项。选项与参数的区别在于有一个前导连字符
-
。Python 在启动期间使用这些第一个选项。在本例中,没有选项。第一个参数必须是要运行的 Python 文件名。此文件名参数和该行上的所有剩余单词将分配给sys.argv
。 -
Python 启动基于找到的选项。根据
-s
选项,site
模块可用于设置导入路径sys.path
。如果我们使用了-m
选项,那么 Python 将使用runpy
模块来启动我们的应用程序。给定的脚本文件可以(重新)编译为字节码。-v
选项将公开正在执行的导入。 -
我们的应用程序可以使用
sys.argv
来解析带有argparse
模块的选项和参数。我们的应用程序可以在os.environ
中使用环境变量。它还可以解析配置文件;您可以阅读第 14 章、配置文件和持久化,了解有关此主题的更多信息。
如果没有文件名,Python 解释器将从标准输入读取。如果标准输入是一个控制台(Linux 术语中称为 TTY),那么 Python 将进入一个读取执行打印循环(REPL)并显示>>>
提示。当我们作为开发人员使用此模式时,我们通常不会将此模式用于已完成的应用程序。
由于 Python 的灵活性,有其他一些方法可以向 Python 运行时提供输入。标准输入可以是重定向文件,例如,python <some_file
或some_app | python
。虽然这两个示例都是 Python 的有效用法,但它们可能会令人困惑,因为应用程序源代码并不十分明显。
为了运行程序,shell 将命令行解析为单词。这些词可以理解为选项和参数的混合体。以下是一些基本准则:
- 选择是第一位的。它们前面有
-
或--
。有两种格式:-l
和--word
。有两种选项:不带参数的选项和带参数的选项。几个没有参数的选项示例涉及使用-V
显示版本或使用--version
显示版本。带有参数的选项的一个示例是-m module
,其中-m
选项后面必须跟一个模块名。 - 没有参数的短格式(单字母)选项可以分组在单个
-
后面。为了方便起见,我们可以使用-bqv
组合-b -q -v
选项。 - 通常,参数在选项之后,并且它们没有前导的
-
或--
(尽管有些 Linux 应用程序违反了这一规则)。有两种常见的论点:- 位置参数,其中顺序在语义上是重要的。我们可能有两个位置参数:输入文件名和输出文件名。顺序很重要,因为输出文件将被修改。当文件将被覆盖时,需要小心地根据位置进行简单的区分,以防止混淆。
cp
、mv
和ln
命令是位置参数的罕见示例,其中顺序很重要。使用一个选项来指定输出文件(例如,-o output.csv
)更为清晰。 - 参数列表,所有参数在语义上都是等价的。我们可能有所有输入文件名的参数。这与 shell 执行文件名全局化的方式非常吻合。当我们说
process.py *.html
时,*.html
命令被 shell 扩展为成为位置参数的文件名。(这在 Windows 中不起作用,因此必须使用glob
模块。)
- 位置参数,其中顺序在语义上是重要的。我们可能有两个位置参数:输入文件名和输出文件名。顺序很重要,因为输出文件将被修改。当文件将被覆盖时,需要小心地根据位置进行简单的区分,以防止混淆。
有关更多信息,请参阅:http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_02 。
Python 命令行有十几个选项,可以控制 Python 行为的一些细节。参见Python 设置和使用文档(https://docs.python.org/3/using/index.html 了解有关这些选项的更多信息。Python 命令的位置参数是要运行的脚本的名称;这将是我们应用程序的最顶层文件。
我们与操作系统交互的主要形式之一是使用文件。pathlib
模块使其特别灵活。虽然操作系统可以将文件的路径表示为字符串,但所使用的字符串在语法上相当微妙。创建Path
对象要比直接解析字符串愉快得多。它们可以从其组成部分组成和分解路径。
路径合成使用/
操作符从Path
和str
对象开始组装Path
。该操作员适用于 Windows 以及与 POSIX 兼容的操作系统,如 Linux 和 macOS。因为单个操作员将构建适当的路径,所以最好对所有文件系统访问使用Path
对象。
以下是构建Path
对象的一些示例:
Path.home() / "some_file.dat"
此选项命名用户主目录中的给定文件。 ***Path.cwd() / "data" / "simulation.csv"
*:该选项命名一个相对于当前工作目录的文件。Path("/etc") / "profile"
:该名称从文件系统的根开始命名一个文件。
**我们可以进行许多有趣的查询,以查找给定Path
对象的详细信息。在某些情况下,我们可能需要知道路径的父目录或文件名的扩展名。以下是一些例子:
p = Path.cwd() / "data" / "simulation.csv"
>>> p.parent
PosixPath('/Users/slott/mastering-oo-python-2e/data')
>>> p.name
'simulation.csv'
>>> p.suffix
'.csv'
>>> p.exists()
False
请注意,这些关于Path
对象的查询并不依赖于文件系统中表示实际对象的路径。在本例中,对于实际不存在的文件,parent
、name
和suffix
的各种属性都被正确报告。这对于从输入文件创建输出文件名非常有用。
例如,我们可以执行以下操作:
>>> results = p.with_suffix('.json')
>>> results
PosixPath('/Users/slott/mastering-oo-python-2e/data/simulation.json')
我们获取了一个输入Path
对象p
,并创建了一个输出Path
对象results
。结果对象具有相同的名称,但后缀不同。新名称是通过Path
的with_suffix()
方法建立的。这使我们可以创建相关文件,而不必解析(相对)复杂的路径名。
正如我们在第 6 章中所述,使用可调用对象和上下文时,应将文件用作上下文管理器,以确保其正确关闭。一个Path
对象可以直接打开一个文件,产生如下示例所示的程序:
output = Path("directory") / "file.dat"
with output.open('w') as output_file:
output_file.write("sample data\n")
本例创建一个Path
对象。操作系统将使用相对于当前工作目录没有前导/
的路径。Path
的open()
方法将创建一个文件对象,然后可用于读取或写入。在本例中,我们将向文件写入一个常量字符串。
我们可以使用Path
对象来管理目录和文件。我们通常希望使用以下代码创建工作目录:
>>> target = Path("data")/"ch18_directory"
>>> target.mkdir(exist_ok=True, parents=True)
我们已经将Path
对象从对data
目录和特定子目录ch18_directory
的相对引用中组合起来。此Path
对象的mkdir()
方法将确保文件系统中存在所需的目录结构。我们提供了两种常见的选择。如果文件已经存在,exists_ok
选项将抑制将引发的FileExistsError
异常。parents
选项将创建所有必需的父目录。这在创建复杂的嵌套目录树时非常方便。
处理 web 日志时的一个常见用例是按日期分隔日志。我们可以使用类似以下示例的代码创建特定于日期的目录:
>>> import datetime
>>> today = datetime.datetime.today()
>>> target = Path("data")/today.strftime("%Y%m%d") >>> target.mkdir(exists_ok=True)
在本例中,我们计算了当前日期。由此,我们可以使用data
子目录和当前日期的年、月、日创建目录路径。我们希望能够容忍已经存在的目录,因此我们抑制了异常。这不会创建父目录,如果数据目录不存在,将引发FileNotFoundError
异常。
使用argparse
的一般方法包括以下四个步骤:
- 首先,我们创建一个
ArgumentParser
实例。我们可以向这个对象提供有关命令行界面的总体信息。这可能包括描述、显示选项和参数的格式更改,以及-h
是否为help
选项。一般来说,我们只需要提供描述;其余的选项都有合理的默认值。 - 然后,我们定义命令行选项和参数。这是通过使用
ArgumentParser.add_argument()
方法函数添加参数来实现的。 - 接下来,我们解析
sys.argv
命令行以创建一个namespace
对象,该对象详细说明选项、选项参数和总体命令行参数。 - 最后,我们使用
namespace
对象配置应用程序并处理参数。有许多替代方法可以优雅地处理此问题。这可能涉及解析配置文件以及命令行选项。在本节中,我们将介绍几种设计。
argparse
的一个重要特征是,它为我们提供了选项和参数的统一视图。两者之间的主要区别在于选项或参数值出现的次数。选项是很好的,可选的,可以出现一次,也可以不出现。争论通常出现一次或多次。
我们可以使用如下代码创建解析器:
parser = argparse.ArgumentParser(
description="Simulate Blackjack")
我们提供了描述,因为没有好的默认值。以下是一些用于定义应用程序命令行 API 的常见模式:
- **一个简单的开-关选项:**我们通常将其视为
-v
或--verbose
选项。 - **带参数的选项:**这可能是
-s ','
或--separator '|'
选项。 - 任何位置参数:当我们有一个输入文件和一个输出文件作为命令行参数时,可以使用该参数。这是罕见的,应该避免,因为它从来没有完全清楚的顺序应该是什么。
- **所有其他参数:**当我们有输入文件列表时,我们会使用这些参数。
--version
这是一个显示版本号并退出的特殊选项。 ***--help
此选项将显示帮助并退出。这是一个默认值,因此我们不需要做任何事情来实现这一点。**
****一旦定义了参数,我们就可以解析它们并使用它们。下面是我们分析它们的方法:
config = parser.parse_args()
config
对象是argparse.Namespace
对象;该类类似于types.SimpleNamespace
。它将有许多属性,我们可以轻松地向该对象添加更多属性。
我们将分别研究这六种常见的论点。ArgumentParser
类中有很多聪明而复杂的解析选项。它们中的大多数都超出了通常为命令行参数处理建议的过于简单的指导原则。一般来说,我们应该避免类似于find
这样的程序的超复杂选项。当命令行选项变得非常复杂时,我们可能已经开始在 Python 之上创建特定于域的语言。这通常意味着我们正在创建一种框架,而不仅仅是创建一个应用程序。
我们将定义一个简单的开-关选项,使用一个字母短的名称或更长的名称。我们还应该提供明确的行动。如果省略较长的名称,或者较长的名称作为 Python 变量令人不快,那么我们可能希望提供一个目标变量。让我们使用以下代码进行设置:
parser.add_argument(
'-v', '--verbose', action='store_true', default=False)
这将定义命令行选项的长版本和短版本。如果该选项存在,该操作将把verbose
选项设置为True
。如果不存在该选项,verbose
选项将默认为False
。以下是该主题的一些常见变体:
- 我们可能会将操作更改为
'store_false'
,默认值为True
。 - 有时,我们会有一个默认值
None
,而不是True
或False
。 - 有时,我们会使用一个动作
'store_const'
和一个额外的const=
参数。这使我们能够超越简单的布尔值,存储诸如日志记录级别或其他对象之类的内容。 - 我们可能还有一个动作
'count'
,它允许重复该选项,从而增加计数。在这种情况下,默认值通常为零。
如果使用记录器,我们可能会定义一个调试选项,如以下代码:
parser.add_argument(
'--debug', action='store_const', const=logging.DEBUG,
default=logging.INFO, dest="logging_level" )
我们将操作更改为store_const
,它存储一个常量值,并提供一个特定的常量值logging.DEBUG
。这意味着生成的选项对象将直接提供配置根记录器所需的值。然后,我们可以使用config.logging_level
简单地配置记录器,而无需任何进一步的映射或条件处理。
我们将定义一个选项,该选项的参数具有长名称和可选短名称。我们将提供一个操作来存储随参数提供的值。我们还可以提供类型转换,以防需要float
或int
值而不是字符串。让我们使用以下代码进行设置:
parser.add_argument(
"-b", "--bet", action="store", default="Flat",
choices=["Flat", "Martingale", "OneThreeTwoSix"],
dest='betting_rule')
parser.add_argument(
"-s", "--stake", action="store", default=50, type=int)
第一个示例将定义命令行语法的两个版本,长版本和短版本。解析命令行参数值时,该选项后面必须有一个字符串值,并且该字符串值必须来自可用选项。目标名称betting_rule
将接收选项的参数字符串。
第二个示例还定义了命令行语法的两个版本。它包括类型转换。在分析参数值时,这将存储选项后面的整数值。长名称stake
将是解析器创建的选项对象中的值。
在某些情况下,可能存在与参数关联的值列表。在这种情况下,我们可以提供一个nargs="+"
选项来收集列表中由空格分隔的多个值。
我们使用不带“-
”装饰的名称定义位置参数。在有固定数量的位置参数的情况下,我们将适当地将它们添加到解析器中,如以下代码所示:
parser.add_argument("input_filename", action="store")
parser.add_argument("output_filename", action="store")
解析参数值时,两个位置参数字符串将存储在最终的命名空间对象中。我们可以使用config.input_filename
和config.output_filename
来处理这些参数值。
如前所述,使用简单的位置参数来标识输出文件可能会给用户带来问题。GNU/Linuxcp
、mv
和ln
程序应被视为例外情况,文件可能被覆盖。首选方法是使用带有值的选项来指定可以销毁的文件。强制用户使用类似于-o name.csv
的选项,以使完全清楚输出文件名是什么,这几乎总是比较安全的。
Avoid defining commands where the exact order of arguments is significant.
我们使用一个没有-
修饰的名称和nargs=
参数中的一条建议来定义参数列表。如果规则是一个或多个参数值,则指定nargs="+"
。如果规则是零个或多个参数值,则指定nargs="*"
,如下代码所示。如果规则为可选,则我们指定nargs="?"
。这会将所有其他参数值收集到结果命名空间中的单个序列中:
parser.add_argument(
"filenames", action="store", nargs="*", metavar="file...")
当文件名列表是可选的时,通常意味着如果没有提供具体的文件名,将使用STDIN
或STDOUT
。
如果我们指定一个nargs=
值,那么结果将成为一个列表。如果我们指定了nargs=1
,那么结果对象是一个单元素列表;如果我们需要更改为nargs='+'
,这可以很好地概括。作为一种特殊情况,省略nargs
会导致结果为单个值;这不能很好地概括。
创建一个列表(即使它只有一个元素)很方便,因为我们可能希望以以下方式处理参数:
for filename in config.filenames:
process(filename)
在某些情况下,我们可能希望提供一系列输入文件,其中包括STDIN
。常用的约定是将文件名-
作为参数。我们必须在应用程序中使用以下代码来处理此问题:
for filename in config.filenames:
if filename == '-':
process(sys.stdin)
else:
with open(filename) as input:
process(input)
这向我们展示了一个循环,该循环将尝试处理大量文件名,可能包括-
以显示何时处理文件列表中的标准输入。在with
语句周围可能应该使用try:
块。
显示版本号的选项非常常见,因此有一个特殊的快捷方式向我们显示版本信息:
parser.add_argument(
"-V", "--version", action="version", version=__version__ )
本例假设文件中某处有一个全局__version__= "3.3.2"
模块。此特殊的action="version"
会产生副作用,显示版本信息后退出程序。
显示帮助的选项是argparse
的默认功能。另一种特殊情况允许我们将help
选项从默认值更改为-h
或--help
。这需要两件事。首先,我们必须使用add_help=False
创建解析器。这将关闭内置的-h
、--help
功能。完成此操作后,我们将添加要使用的参数(例如,'-?'
)和action="help"
。这将显示帮助文本并退出。
环境变量的一般策略是提供配置输入,类似于命令行选项和参数。在大多数情况下,我们对很少更改的设置使用环境变量。我们通常会通过.bashrc
或.bash_profile
文件进行设置,以便在每次登录时设置这些值。我们可以在/etc/bashrc
文件中更全局地设置环境变量,以便它们适用于所有用户。我们也可以在命令行上设置环境变量,但这些设置仅适用于正在运行的程序。
在某些情况下,可以在命令行上提供所有配置设置。在这种情况下,环境变量可以用作缓慢更改变量的一种备份语法。
在其他情况下,提供环境变量的配置值可能与通过命令行选项执行的配置断开连接。我们可能需要从环境中获取一些值,并合并来自命令行的值。
我们可以利用环境变量来设置配置对象中的默认值。我们希望在解析命令行参数之前收集这些值。这样,命令行参数可以覆盖环境变量。有两种常见的方法:
- 定义命令行选项时显式设置值:这样做的好处是在帮助消息中显示默认值。它仅适用于与命令行选项重叠的环境变量。我们可以执行如下操作,使用
SIM_SAMPLES
环境变量来提供可重写的默认值:
parser.add_argument(
"--samples",
action="store",
default=int(os.environ.get("SIM_SAMPLES",100)),
type=int,
help="Samples to generate")
- 隐式设置值作为解析过程的一部分:这使得将带有命令行选项的环境变量合并到单个配置中变得简单。我们可以用默认值填充名称空间,然后用命令行中解析的值覆盖它。这为我们提供了三个级别的选项值:解析器中定义的默认值、植入命名空间的覆盖值,最后是命令行上提供的任何覆盖值,如以下代码所示:
config4 = argparse.Namespace()
config4.samples = int(os.environ.get("SIM_SAMPLES",100))
config4a = parser.parse_args(sys.argv[1:], namespace=config4)
The argument parser can perform type conversions for values that are not simple strings. However, g athering environment variables doesn't automatically involve a type conversion. For options that have non-string values, we must perform the type conversion in our application.
我们可以合并配置文件、环境变量和命令行选项。这为我们提供了三种为应用程序提供配置的方法:
- 配置文件的层次结构可以提供默认值。参见第 14 章、配置文件和持久化,了解各种方法的示例。
- 环境变量可以为配置文件提供覆盖。这可能意味着将环境变量名称空间转换为配置名称空间。
- 命令行选项定义最终替代。
使用这三种方法可能太好了。如果要搜索的地方太多,则很难跟踪设置。关于配置的最终决定通常取决于与应用程序和框架的总体集合保持一致。我们应该努力使我们的编程与其他组件无缝配合。
我们将使用一个三阶段的过程来合并环境变量。对于此应用程序,环境变量将用于覆盖配置文件设置。第一阶段是从各种文件中收集默认值。这是基于第 14 章、配置文件和持久化中所示的示例。我们可以使用如下代码:
config_locations = (
Path.cwd(),
Path.home(),
Path.cwd() / "opt" , # A testing stand-in for Path("/opt")
Path(__file__) / "config" ,
# Other common places...
# Path("~someapp").expanduser(),
)
candidate_paths = (dir / "ch18app.yaml" for dir in config_locations)
config_paths = (path for path in candidate_paths if path.exists())
files_values = [yaml.load( str (path)) for path in config_paths]
本例使用按重要性排序的位置序列。当前工作目录中的值提供最直接的配置。对于此处未设置的值,用户的主目录是保存常规设置的地方。我们应该使用当前工作目录的opt
子目录Path.cwd()/"opt"
;它代替了Path("/etc")
或Path("/opt")
。在各种目录路径之后放置一个标准名称"ch18app.yaml"
,为配置文件创建一些具体路径,以设置candidate_paths
变量。分配给config_paths
的生成器表达式将生成实际存在的可匹配路径序列。
files_values
中的最终结果是从发现存在的文件中获取的一系列配置值。每个文件都应该创建一个字典,将参数名称映射到参数值。此列表可以用作最终ChainMap
对象的一部分。
第二阶段是构建用户基于环境的设置。我们可以使用如下代码进行设置:
env_settings = [
( "samples" , nint(os.environ.get( "SIM_SAMPLES" , None ))),
( "stake" , nint(os.environ.get( "SIM_STAKE" , None ))),
( "rounds" , nint(os.environ.get( "SIM_ROUNDS" , None ))),
]
env_values = {k: v for k, v in env_settings if v is not None }
创建这样的映射可以将外部环境变量名(如SIM_SAMPLES
)重写为内部配置名(如samples
)。内部名称将与应用程序的配置属性匹配。外部名称的定义方式通常使其在复杂环境中具有唯一性。
对于未定义的环境变量,如果未定义环境变量,nint()
函数(如下代码所示)将提供None
作为默认值。当我们创建env_values
时,None
对象将从环境值的初始集合中移除。
给定多个字典,我们可以使用ChainMap
组合它们,如下代码所示:
defaults = argparse.Namespace(
**ChainMap(
env_values, # Checks here first
*files_values # All of the files, in order
)
)
我们将各种映射组合成一个ChainMap
。首先搜索环境变量。如果存在值,则首先从用户配置文件中查找值,然后从其他配置中查找值(如果用户配置文件未提供值)。
*files_values
确保值列表将作为位置参数值序列提供。这允许单个序列(或 iterable)为多个位置参数提供值。**ChainMap
确保将字典转换为许多命名参数值。每个键都成为与字典中的值关联的参数名。下面是一个如何工作的示例:
>>> argparse.Namespace(a=1, b=2)
Namespace(a=1, b=2)
>>> argparse.Namespace(**{'a': 1, 'b': 2})
Namespace(a=1, b=2)
解析命令行参数时,生成的Namespace
对象可用于提供默认值。我们可以使用以下代码来解析命令行参数并更新这些默认值:
config = parser.parse_args(sys.argv[1:], namespace=defaults)
我们将配置文件设置的ChainMap
转换为argparse.Namespace
对象。然后,我们解析命令行选项以更新该名称空间对象。由于环境变量位于ChainMap
中的第一位,因此它们会覆盖任何配置文件。
设置环境变量的三阶段过程包括许多参数和配置设置的常见来源。我们并不总是需要环境变量、配置文件和命令行选项;某些应用程序可能只需要这些技术的一个子集。
我们经常需要保留None
值的类型转换。保留None
值将确保我们能够判断何时未设置环境变量。下面是一个更复杂的类型转换,可以称为无感知:
from typing import Optional
def nint(x: Optional[ str ]) -> Optional[ int ]:
if x is None :
return x
return int (x)
我们在将环境变量值转换为整数时使用它。如果未设置环境变量,将使用默认值None
。如果设置了环境变量,则该值将转换为整数。在后面的处理步骤中,我们可以依赖None
值,仅从不是None
的正确值构建字典。
我们可以使用类似的无感知转换来处理float
值。我们不需要对字符串进行任何转换,os.environ.get("SIM_NAME")
将提供环境变量值或None
。
下面是一些直接来自默认argparse.print_help()
代码的典型输出:
usage: ch18_ex1.py [-v] [--debug] [--dealerhit {Hit17,Stand17}]
[--resplit {ReSplit,NoReSplit,NoReSplitAces}]
[--decks DECKS] [--limit LIMIT] [--payout PAYOUT]
[-p {SomeStrategy,AnotherStrategy}]
[-b {Flat,Martingale,OneThreeTwoSix}] [-r ROUNDS]
[-s STAKE] [--samples SAMPLES] [-V] [-?]
output
Simulate Blackjack
positional arguments:
output
optional arguments:
-v, --verbose
--debug
--dealerhit {Hit17,Stand17}
--resplit {ReSplit,NoReSplit,NoReSplitAces}
--decks DECKS Decks to deal (default: 6)
--limit LIMIT
--payout PAYOUT
-p {SomeStrategy,AnotherStrategy}, --playerstrategy {SomeStrategy,AnotherStrategy}
-b {Flat,Martingale,OneThreeTwoSix}, --bet {Flat,Martingale,OneThreeTwoSix}
-r ROUNDS, --rounds ROUNDS
-s STAKE, --stake STAKE
--samples SAMPLES Samples to generate (default: 100)
-V, --version show program's version number and exit
-?, --help
默认的帮助文本是根据解析器定义中的四个内容构建的:
usage:
行是选项的摘要。我们可以用自己的用法文本替换默认的计算,该文本省略了不太常用的细节。- 下面是描述。默认情况下,我们提供的文本会稍微清理一下。在这个例子中,我们提供了一个简陋的两个词的描述,
Simulate Blackjack
,因此没有明显的清理。 - 然后,显示参数。它们分为两个亚组:
- 位置参数
- 选项,按照我们定义它们的顺序。
- 在此之后,可以显示可选的尾声文本;我们没有对此定义提供任何支持。
在某些情况下,这种简洁的提醒就足够了。然而,在其他情况下,我们可能需要提供更多细节。对于更详细的帮助,我们有三层支持:
- 将
help=
添加到参数定义:这是定制帮助详细信息时的起点。这将用更有意义的细节补充选项说明。 - 使用其他帮助格式化程序类:这是在构建
ArgumentParser
时通过formatter_class=
参数完成的。如果我们想使用ArgumentDefaultsHelpFormatter
,那么这将对每个参数定义的help=
值起作用。 - 扩展
ArgumentParser
类并覆盖print_usage()
和print_help()
方法:这允许我们编写非常复杂的输出。这不应该随便使用。如果我们的选项如此复杂,以至于普通的帮助功能无法工作,那么也许我们做得太过分了。
我们的目标是提高可用性。即使我们的程序工作正常,我们也可以通过提供命令行支持来建立信任,使我们的程序更易于使用。
在第 14 章、配置文件和持久化中,我们提出了两种应用配置设计模式:
- 全局属性映射:在前面的示例中,我们使用
ArgumentParser
创建的Namespace
对象实现了全局属性映射。 - 对象构造:对象构造背后的思想是根据配置参数构建所需的对象实例,有效地将
main()
函数中的全局属性映射降级为本地属性映射,而不保存属性。
我们在上一节中向您展示的是使用一个本地Namespace
对象来收集所有参数。由此,我们可以构建必要的应用程序对象来完成应用程序的实际工作。这两种设计模式不是二分法;它们是互补的。我们使用Namespace
累积一组一致的值,然后基于该名称空间中的值构建各种对象。
这就引出了顶层功能的设计。在查看实现之前,我们需要考虑这个函数的正确名称。有两种方法可以命名函数:
- 将其命名为
main()
,因为这是整个应用程序起点的通用术语;每个人都期待着这一点。 - 不要把它命名为
main()
,因为main()
太模糊,从长远来看没有意义,并且限制了重用。如果我们遵循这条路径,我们可以创建一个有意义的顶级函数,并使用一个verb_noun()
短语来公平地描述操作。我们还可以添加一行main = verb_noun
,提供别名main()
。
使用第二个两部分实现,让我们通过扩展来更改main()
的定义。我们可以添加一个新函数,并将名称main
重新分配给新函数。旧函数名作为稳定的、不断增长的 API 的一部分保留下来。
下面是一个顶级应用程序脚本,它从配置Namespace
对象构建对象:
import ast
import csv
import argparse
def simulate_blackjack(config: argparse.Namespace) -> None :
dealer_classes = { "Hit17" : Hit17, "Stand17" : Stand17}
dealer_rule = dealer_classes[config.dealer_rule]()
split_classes = {
"ReSplit" : ReSplit, "NoReSplit" : NoReSplit, "NoReSplitAces" : NoReSplitAces
}
split_rule = split_classes[config.split_rule]()
try :
payout = ast.literal_eval(config.payout)
assert len (payout) == 2
except Exception as ex:
raise ValueError(f"Invalid payout {config.payout}") from ex
table = Table(
decks =config.decks,
limit =config.limit,
dealer =dealer_rule,
split =split_rule,
payout =payout,
)
player_classes = { "SomeStrategy" : SomeStrategy, "AnotherStrategy" : AnotherStrategy}
player_rule = player_classes[config.player_rule]()
betting_classes = {
"Flat" : Flat, "Martingale" : Martingale, "OneThreeTwoSix" : OneThreeTwoSix
}
betting_rule = betting_classes[config.betting_rule]()
player = Player(
play =player_rule,
betting =betting_rule,
max_rounds =config.rounds,
init_stake =config.stake,
)
simulate = Simulate(table, player, config.samples)
with Path(config.outputfile).open( "w" , newline = "" ) as target:
wtr = csv.writer(target)
wtr.writerows(simulate)
main = simulate_blackjack
simulate_blackjack
函数依赖于外部提供的具有配置属性的Namespace
对象。它没有命名为main()
,以便我们将来可以进行添加和更改。我们可以将main
重新分配给任何替代或扩展此功能的新功能。
此函数用于构建所需的各种对象-Table
、Player
和Simulate
。我们根据提供的配置参数配置了这些对象。
我们已经设置了真正工作的对象。在对象构建之后,实际工作是一条突出显示的单行:wtr.writerows(simulate)
。大约 90%的程序时间将花费在这里,生成样本并将其写入所需文件。
GUI 应用程序也有类似的模式。它们进入一个主循环来处理 GUI 事件。该模式也适用于进入主循环以处理请求的服务器。
我们依赖于将配置对象作为参数传入。这源于我们最小化依赖关系的测试策略。此顶级simulate_blackjack()
功能不依赖于配置创建方式的细节。然后,我们可以在应用程序脚本中使用此函数,如下所示:
if __name__ == "__main__":
logging.config.dictConfig(yaml.load("logging.config"))
config5 = get_options_2(sys.argv[1:])
simulate_blackjack(config5)
logging.shutdown()
这代表了关注点的分离。应用程序的工作分为三个独立的部分:
- 最外层是通过日志记录定义的。我们在所有其他应用程序组件之外配置了日志记录,以确保配置日志记录的其他顶级包之间没有冲突。当我们考虑将应用程序组合到更大的复合处理中时,我们需要确保组合的几个应用程序不会导致日志记录配置冲突。
- 内部级别由应用程序的配置定义。我们不希望在单独的应用程序组件之间发生冲突。我们希望允许单个命令行 API 与我们的应用程序实现分开发展。我们希望能够将我们的应用程序处理嵌入到单独的环境中,可能由
multiprocessing
或 RESTful web 服务器定义。 - 最后一部分是
simulate_blackjack()
函数。这与日志记录和配置问题是分开的。这允许使用各种技术来提供参数配置。此外,当我们考虑将其与其他处理相结合时,日志和配置的分离将非常有用。
在构造参数解析器和使用参数配置应用程序之间,我们有一个潜在的不要重复自己(DRY)问题。我们使用一些重复的键构建参数。
我们可以通过创建一些映射到外部可见值的内部配置来消除这种重复。例如,我们可以将此全局变量定义为:
dealer_rule_map = {"Hit17": Hit17, "Stand17", Stand17}
我们可以使用它创建参数解析器,如下所示:
parser.add_argument(
"--dealerhit", action="store", default="Hit17",
choices=dealer_rule_map.keys(),
dest='dealer_rule')
我们可以使用它创建工作对象,如下所示:
dealer_rule = dealer_rule_map[config.dealer_rule]()
这消除了重复。它允许我们随着应用程序的发展在一个地方添加新的类定义和参数键映射。它还允许我们缩写或重写外部 API,如下所示:
dealer_rule_map ={"H17": Hit17, "S17": Stand17}
从命令行(或配置文件)字符串到应用程序类的映射有四种。使用这些内部映射简化了simulate_blackjack()
函数。
在某种程度上,嵌套上下文的存在意味着顶级脚本应该类似于以下代码:
if __name__ == "__main__":
with Setup_Logging():
with Build_Config(arguments) as config_3:
simulate_blackjack_betting(config_3)
我们添加了两个上下文管理器来规范工作上下文的创建。有关更多信息,请参见第 6 章、使用可调用对象和上下文。以下是用于日志记录的上下文管理器:
class Setup_Logging:
def __enter__(self, filename="logging.config") -> "Setup_Logging":
logging.config.dictConfig(yaml.load(filename))
def __exit__(self, *exc) -> None:
logging.shutdown()
这样做的目的是确保正确配置日志进程,并在应用程序运行时关闭。这消除了关于是否保存了最终缓冲区以及日志文件是否正确关闭的任何疑问。
类似地,我们可以定义一个上下文管理器来构建运行应用程序所需的配置。在本例中,上下文管理器是围绕get_options_2()
函数的一个非常薄的包装器,如前面的代码所示。上下文管理器如下所示:
from typing import List
class Build_Config:
def __init__ ( self , argv: List[ str ]) -> None :
self .options = get_options_2(argv)
def __enter__ ( self ) -> argparse.Namespace:
return self .options
def __exit__ ( self , *exc) -> None :
return
Build_Config
上下文管理器可以从许多文件以及命令行参数中收集配置。在这种情况下,使用上下文管理器不是必要的;但是,如果配置变得更复杂,它会留下扩展空间。
此设计模式可以澄清围绕应用程序启动和关闭的各种问题。虽然这对于大多数应用程序来说可能有点过分,但与 Python 上下文管理器的基本配合似乎有助于应用程序的增长和扩展。
当我们面对一个不断增长和扩展的应用程序时,我们通常会进行更大规模的编程。为此,将可变的应用程序处理与不太可变的处理上下文分开是很重要的。
让我们为 21 点模拟添加一个功能:结果分析。我们有几种方法来实现这个新增功能。我们必须考虑两个方面,导致大量的组合。一个方面是如何设计新功能:
- 我们可以添加一个函数,并找出将其集成到整体中的方法。
- 我们可以使用命令设计模式创建命令的层次结构,其中一些是单个函数,另一些是函数序列。
我们必须考虑的另一个维度是如何包装新的特征:
- 我们可以编写一个新的顶级脚本文件。这往往会基于文件名创建新命令。我们可以从
simulate.py
和analyze.py
这样的命令开始。 - 我们可以向应用程序添加一个参数,该参数允许一个脚本执行模拟或分析,或同时执行两者。我们会有类似于
app.py simulate
和app.py analyze
的命令。
这将导致实现选择的四种组合。我们将重点介绍如何使用命令设计模式。首先,我们将修改现有的应用程序以使用命令设计模式。然后,我们将通过以新命令子类的形式添加特性来扩展我们的应用程序。
许多应用程序涉及隐式命令设计模式;毕竟,我们在处理数据。为此,必须至少有一个活动语音动词或命令,用于定义应用程序如何转换、创建或使用数据。一个简单的应用程序可能只有一个动词,作为函数实现。对于简单的应用程序,使用命令类设计模式可能没有帮助。
更复杂的应用程序将有多个相关动词。GUI 和 web 服务器的关键特性之一是,它们可以做多种事情,从而产生多种命令。在许多情况下,GUI 菜单选项定义应用程序动词的域。
在某些情况下,应用程序的设计源于对更大、更复杂动词的分解。我们可以将整个处理分解为几个较小的命令步骤,这些步骤在最终应用程序中组合在一起。
当我们观察应用程序的发展时,我们经常会看到一种新功能不断增加的模式。在这些情况下,每个新功能都可以成为一种单独的命令子类,添加到应用程序类层次结构中。
命令的抽象超类具有以下设计:
class Command:
def __init__ ( self ) -> None :
self .config: Dict[ str , Any] = {}
def configure( self , namespace: argparse.Namespace) -> None :
self .config.update( vars (namespace))
def run( self ) -> None :
"""Overridden by a subclass"""
pass
我们通过将config
属性设置为argparse.Namespace
来配置这个Command
类。这将从给定的namespace
对象填充实例变量。
一旦配置了对象,我们可以通过调用run()
方法将其设置为执行命令的工作。此类实现了一个相对简单的用例,如以下代码所示:
main = SomeCommand()
main.configure = some_config
main.run()
这捕获了创建对象、配置对象,然后让它完成配置工作的一般风格。我们可以通过向命令子类定义中添加特性来扩展这个想法。
下面是一个实现 21 点模拟的具体子类:
class Simulate_Command(Command):
dealer_rule_map = {
"Hit17" : Hit17, "Stand17" : Stand17}
split_rule_map = {
"ReSplit" : ReSplit, "NoReSplit" : NoReSplit, "NoReSplitAces" : NoReSplitAces
}
player_rule_map = {
"SomeStrategy" : SomeStrategy, "AnotherStrategy" : AnotherStrategy}
betting_rule_map = {
"Flat" : Flat, "Martingale" : Martingale, "OneThreeTwoSix" : OneThreeTwoSix
}
def run( self ) -> None :
dealer_rule = self .dealer_rule_map[ self .config[ "dealer_rule" ]]()
split_rule = self .split_rule_map[ self .config[ "split_rule" ]]()
payout : Tuple[ int , int ]
try :
payout = ast.literal_eval( self .config[ "payout" ])
assert len (payout) == 2
except Exception as e:
raise Exception ( f"Invalid payout { self .config[ 'payout' ] !r} " ) from e
table = Table(
decks = self .config[ "decks" ],
limit = self .config[ "limit" ],
dealer =dealer_rule,
split =split_rule,
payout =payout,
)
player_rule = self .player_rule_map[ self .config[ "player_rule" ]]()
betting_rule = self .betting_rule_map[ self .config[ "betting_rule" ]]()
player = Player(
play =player_rule,
betting =betting_rule,
max_rounds = self .config[ "rounds" ],
init_stake = self .config[ "stake" ],
)
simulate = Simulate(table, player, self .config[ "samples" ])
with Path( self .config[ "outputfile" ]).open( "w" , newline = "" ) as target:
wtr = csv.writer(target)
wtr.writerows(simulate)
此类实现基本的顶级函数,该函数配置各种对象,然后执行模拟。该类重构前面显示的simulate_blackjack()
函数,以创建Command
类的具体扩展。这可以在主脚本中使用,如以下代码所示:
if __name__ == "__main__":
with Setup_Logging():
with Build_Config(sys.argv[1:]) as config:
main = Simulate_Command()
main.configure(config)
main.run()
虽然我们可以将此命令转换为Callable
并使用main()
而不是main.run()
,但使用 callable 可能会令人困惑。我们明确区分了以下三个设计问题:
- 构造:我们特意将初始化保持为空。在后面的部分中,我们将向您展示一些 PITL 示例,其中我们将从较小的组件命令构建较大的复合命令。
- 配置:我们通过
property
设置器进行配置,与施工和控制隔离。 - 控制:这是命令在构建和配置之后的实际工作。
当我们看一个可调用函数或函数时,构造是定义的一部分。配置和控制被合并到函数调用本身中。如果我们试图定义一个可调用函数,我们会牺牲一点灵活性。
我们将通过添加分析功能来扩展我们的应用程序。当我们使用命令设计模式时,我们可以添加另一个子类进行分析。
这是我们的分析功能,也是作为Command
类的子类设计的:
class Analyze_Command(Command):
def run( self ) -> None :
with Path( self .config[ "outputfile" ]).open() as target:
rdr = csv.reader(target)
outcomes = ( float (row[ 10 ]) for row in rdr)
first = next (outcomes)
sum_0, sum_1 = 1 , first
value_min = value_max = first
for value in outcomes:
sum_0 += 1 # value**0
sum_1 += value # value**1
value_min = min (value_min, value)
value_max = max (value_max, value)
mean = sum_1 / sum_0
print (
f" { self .config[ 'outputfile' ] }\n "
f"Mean = { mean : .1f }\n "
f"House Edge = { 1 - mean / 50 : .1% }\n "
f"Range = { value_min : .1f } { value_max : .1f } "
)
这个类继承了Command
类的一般特性。在这种情况下,只有一个小功能,即保存配置信息。run()
方法执行的工作在统计上没有太大意义,但重点是向您显示第二个命令,该命令使用配置名称空间来执行与我们的模拟相关的工作。我们使用outputfile
配置参数来命名读取的文件,以执行一些统计分析。
之前,我们提到了一种支持多个特性的通用方法。一些应用程序在单独的.py
脚本文件中使用多个顶级主程序。如果我们这样做,那么组合来自不同文件的命令将迫使我们编写一个 shell 脚本。在大型(PITL中引入另一种工具和另一种语言进行编程似乎不是最佳选择。
创建单独脚本文件的一个稍微灵活的替代方法是使用位置参数来选择特定的顶级Command
对象。对于我们的示例,我们希望选择 simulation 或 analysis 命令。为此,我们将向解析以下代码的命令行参数添加一个参数:
parser.add_argument(
"command", action="store", default='simulate',
choices=['simulate', 'analyze'])
parser.add_argument("outputfile", action="store", metavar="output")
这将更改命令行 API,将顶级谓词添加到命令行。然后,我们可以将参数值映射到实现所需命令的类名,如以下代码所示:
command_map = {
'simulate': Simulate_Command,
'analyze': Analyze_Command
}
command = command_map[options.command]
command.configure(options)
command.run()
这使我们能够创建更高级别的复合功能。例如,我们可能希望将模拟和分析结合到一个单一的整体程序中。我们也可能希望在不使用 shell 的情况下执行此操作。
我们还可以设计一个由其他命令构建的复合命令。为此,我们有两种设计策略:对象组合和类组合。
如果我们使用对象合成,那么我们的合成命令基于内置的list
或tuple
。我们可以扩展或包装一个现有序列。我们将创建复合Command
对象,作为其他Command
对象实例的集合。我们可能会考虑编写如下代码:
simulate_and_analyze = [Simulate(), Analyze()]
这样做的缺点是我们没有为我们独特的复合命令创建一个新类。我们创建了一个通用组合,并用实例填充它。如果我们想要创建更高层次的合成,我们必须解决低层次Command
类和基于内置序列类的高层次复合Command
对象之间的这种不对称性。
我们希望有一个复合命令,它也是命令的一个子类。如果我们使用类组合,那么我们的低级命令和高级复合命令的结构将更加一致。
下面是一个实现一系列其他命令的类:
class Command_Sequence(Command):
steps: List[Type[Command]] = []
def __init__ ( self ) -> None :
self ._sequence = [class_() for class_ in self .steps]
def configure( self , config: argparse.Namespace) -> None :
for step in self ._sequence:
step.configure(config)
def run( self ) -> None :
for step in self ._sequence:
step.run()
我们定义了一个类级变量steps
,以包含一系列命令类。在对象初始化过程中,__init__()
将构造一个内部实例变量_sequence
,命名类的对象在self.steps
中。
设置配置后,它将被推送到每个组成对象中。当通过run()
执行复合命令时,它被委托给复合命令中的每个组件。
下面是由另外两个Command
子类构建的Command
子类:
class Simulate_and_Analyze(Command_Sequence):
steps = [Simulate_Command, Analyze_Command]
这个类只是定义步骤序列的一行代码。因为这是Command
类本身的一个子类,所以它有必要的多态 API。我们现在可以用这个类创建合成,因为它与Command
的所有其他子类兼容。
现在,我们可以对参数解析进行以下非常小的修改,以将此功能添加到应用程序中:
parser.add_argument(
"command", action="store", default='simulate',
choices=['simulate', 'analyze', 'simulate_analyze']
)
我们只是在参数选项值中添加了另一个选项。我们还需要调整从参数选项字符串到类的映射,如下所示:
command_map = {
'simulate': Simulate_Command,
'analyze': Analyze_Command,
'simulate_analyze': Simulate_and_Analyze}
注意,我们不应该使用模糊的名称,例如both
来组合两个命令。如果我们避免含糊不清,我们就会创造机会来扩展或修改我们的应用程序。使用命令设计模式可以轻松地添加功能。我们可以定义复合命令,也可以将较大的命令分解为较小的子命令。
打包和实现可能涉及添加选项并将该选项映射到类名。如果我们使用更复杂的配置文件(参见第 14 章、配置文件和持久化),我们可以直接在配置文件中提供类名,并保存从选项字符串到类的映射。
我们可以确定许多复合命令设计模式。在前面的示例中,我们设计了一个复合对象,它实现了一系列操作。为了获得灵感,我们可以看看 bashshell 复合操作符:;
、&
、|
以及用于分组的()
。除此之外,我们在外壳中还有if
、for
和while
环。
我们研究了Command_Sequence
类定义中 shell 序列操作符;
的语义等价物。序列的概念非常普遍,以至于许多编程语言(如 shell 和 Python)都不需要显式运算符;shell 的语法只是将行尾用作隐含的序列运算符。
shell 的&
操作符创建两个并发运行而不是顺序运行的命令。我们可以使用run()
方法创建Command_Concurrent
类定义,该方法使用multiprocessing
创建两个子流程,并等待两者完成。
shell 中的|
操作符创建了一个管道:一个命令的输出缓冲区是另一个命令的输入缓冲区,命令同时运行。在 Python 中,我们需要创建一个队列以及两个进程来读取和写入该队列。这是一种更复杂的情况:它涉及到将队列对象填充到各个子对象的配置中。第 13 章发送和共享对象中有一些使用带有队列的multiprocessing
在并发进程之间传递对象的示例。
shell 中的if
命令有多个用例;然而,除了通过Command
子类中的方法提供本机 Python 实现之外,没有任何令人信服的理由。创建一个复杂的Command
类来模拟 Python 的if-elif-else
处理是没有帮助的;我们可以而且应该直接使用 Python。
类似地,shell 中的while
和for
命令也不是我们需要在更高级别的Command
子类中定义的类型。我们可以简单地用 Python 的方法编写它。
下面是一个for all类定义的示例,该类定义将现有命令应用于集合中的所有值:
class ForAllBets_Simulate(Command):
def run( self ) -> None :
for bet_class in "Flat" , "Martingale" , "OneThreeTwoSix" :
self .config[ "betting_rule" ] = bet_class
self .config[ "outputfile" ] = Path("data")/f"ch18_simulation7_{bet_class}.dat"
sim = Simulate_Command()
sim.configure(argparse.Namespace(** self .config))
sim.run()
我们在模拟中列举了三类博彩。对于这些类中的每一个,我们都调整了配置,创建了一个模拟,并执行了该模拟。
请注意,此for all类将不适用于前面定义的Analyze_Command
类。我们不能简单地创建反映不同工作范围的组合。Analyze_Command
类运行单个模拟,而ForAllBets_Simulate
类运行一组模拟。我们有两种选择来创建兼容的工作范围:我们可以创建一个Analyze_All
命令或ForAllBets_Sim_and_Analyze
命令。设计决策取决于用户的需求。
在与其他应用程序集成时,有几种方法可以使用 Python。很难提供全面的概述,因为有这么多的应用程序,每个应用程序都具有独特的功能。我们可以在下面的列表中向您展示一些广泛的设计模式:
- Python 可以是应用程序的脚本语言。您可以在中找到一个应用程序列表,其中简单地将 Python 作为添加特性的主要方法 https://wiki.python.org/moin/AppsWithPythonScripting 。
- Python 模块可以实现应用程序的 API。有许多应用程序包括 Python 模块,这些模块提供了与应用程序 API 的绑定。使用一种语言工作的应用程序开发人员通常会为其他语言(包括 Python)提供 API 库。
- 我们可以使用
ctypes
模块直接在 Python 中实现另一个应用程序的 API。在 C 或 C++的应用程序库中,这很好。 - 我们可以使用
sys.stdin
和sys.stdout
创建一个外壳级管道,将我们连接到另一个应用程序。在构建与 shell 兼容的应用程序时,我们可能还想看看fileinput
模块。 - 我们可以使用
subprocess
模块访问应用程序的命令行界面。这还可能涉及连接到应用程序的stdin
和stdout
以与之正确交互。 - 我们还可以在 C 或 C++中编写自己的 Python 兼容模块。在这种情况下,我们可以用 C 实现外部应用程序的 API,提供 Python 应用程序可以利用的类或函数。这可能比使用
ctypes
API 提供更好的性能。由于这需要编译 C 或 C++,它也有一点工具密集型。
这种级别的灵活性意味着我们经常使用 Python 作为集成框架或胶水,从较小的应用程序创建更大的复合应用程序。在使用 Python 进行集成时,我们通常会使用 Python 类和对象来镜像另一个应用程序中的定义。
我们将为第 19 章、模块和封装设计保留一些额外的设计考虑。这些是更高层次的架构设计考虑事项,超出了处理命令行的范围。
在本章中,我们研究了如何使用argparse
和os.environ
来收集命令行参数和配置参数。这建立在第 14 章、配置文件和持久化中所示的技术之上。
我们学习了如何使用argparse
实现一些常见的命令行功能。这包括常见功能,例如显示版本号并退出或显示帮助文本并退出。
我们研究了使用命令设计模式来创建可以扩展或重构以提供新功能的应用程序。我们的目标是显式地保持顶级主函数的主体尽可能小。
命令行 API 是已完成应用程序的重要组成部分。虽然我们的大部分设计工作都集中在程序运行时所做的事情上,但我们确实需要解决两个边界状态:启动和关闭。当我们启动应用程序时,它必须易于配置。它还必须正常关闭,正确地刷新所有输出缓冲区并释放所有操作系统资源。
在使用面向公众的 API 时,我们必须解决模式演化问题的一个变体。随着应用程序的发展和用户知识的发展,我们将修改命令行 API。这可能意味着我们将拥有遗留特性或遗留语法。这也可能意味着我们需要打破与传统命令行设计的兼容性。
在许多情况下,我们需要确保主版本号是应用程序名称的一部分。我们不应该编写名为someapp
的顶级模块。当我们需要制作与主要版本 2 不兼容的主要版本 3 时,我们可能会发现很难解释应用程序的名称已更改为someapp3
。我们应该考虑用 Tyt2 来开始,这样数字总是应用程序名称的一部分。
在下一章中,我们将扩展一些顶级设计思想,并查看模块和包设计。一个小型 Python 应用程序也可以是一个模块,这意味着它可以导入到一个更大的应用程序中。复杂的 Python 应用程序可能是一个包。它可以包括其他应用模块,也可以包括在更大规模的应用中。******