Skip to content

Latest commit

 

History

History
933 lines (651 loc) · 61.4 KB

File metadata and controls

933 lines (651 loc) · 61.4 KB

十八、处理命令行

命令行启动选项、环境变量和配置文件对许多应用程序都很重要,特别是在服务器实现方面。有许多处理程序启动和对象创建的方法。本章将重点讨论参数解析和应用程序的总体架构。

本章将从第 14 章配置文件和持久化扩展配置文件处理,并为命令行程序和服务器的顶层提供更多技术。第 15 章中的核心设计原则、设计原则和模式在设计任何尺寸的应用程序时都是必不可少的。本章还将扩展第 16 章日志和警告模块中的一些日志设计功能。

第 19 章模块和包设计中,我们将扩展这些原则,以查看一种架构设计,我们将在大型中称之为编程。我们将使用命令设计模式来定义无需借助 shell 脚本即可聚合的软件组件。这在编写应用服务器使用的后台处理组件时特别有用。

技术要求

本章的代码文件可在上找到 https://git.io/fj2UD

操作系统界面和命令行

通常,操作系统的 shell 使用构成 OS API 的若干信息启动应用程序:

  • shell 为每个应用程序提供其环境变量集合。在 Python 中,可通过os.environ访问。
  • shell 准备了三个标准文件。在 Python 中,它们被映射到sys.stdinsys.stdoutsys.stderr。还有一些其他模块,例如fileinput,可以提供对sys.stdin的访问。
  • 命令行由 shell 解析为单词。命令行的部分内容可在sys.argv中找到。对于 POSIX 操作系统,shell 可以替换 shell 环境变量和全局通配符文件名。在 Windows 中,简单的cmd.exeshell 不会为我们提供全局文件名。
  • 操作系统还维护上下文设置,例如当前工作目录、用户标识和用户组信息等。这些可通过os模块获得。它们在命令行中不作为参数提供。

操作系统希望应用程序在终止时提供数字状态代码。如果我们想返回一个特定的数字代码,我们可以在应用程序中使用sys.exit()os模块定义了许多值,例如os.EX_OK,以帮助返回具有共同含义的代码。如果程序正常终止,Python 将返回零;如果程序以未处理的异常结束,Python 将返回值 1;如果命令行参数无效,Python 将返回值 2。

shell 的操作是这个操作系统 API 的一个重要部分。给定一行输入,shell 根据(相当复杂的)引用规则和替换选项执行大量替换。然后,它将结果行解析为空格分隔的单词。第一个单词必须是内置的 shell 命令(如cdset),或者必须是文件名,如python3。shell 在其定义的PATH中搜索此文件。

为了有效地使用可执行文件,您必须确保包含这些文件的目录由PATH环境变量命名。在大多数操作系统中,应该为脚本添加冒号(:和目录。在 Windows 中,您应该为脚本添加分号(;和目录。

命令第一个字上命名的文件必须具有 execute、x权限。chmod +x somefile.pyshell 命令将文件标记为可执行文件。不可执行的文件名会出现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 的一系列步骤如下所示:

  1. shell 解析 ch18_demo.py -s someinput.csv行。第一个词是ch18_demo.py。此文件位于 shell 的PATH上,具有x可执行权限。shell 打开文件并找到#!字节。shell 读取此行的其余部分并找到/usr/bin/env python3命令。

  2. shell 解析新的/usr/bin/env命令,这是一个二进制可执行文件。外壳启动env程序。该程序依次启动python3。由 shell['ch18_demo.py', '-s', 'someinput.csv']解析的原始命令行中的单词序列被提供给 Python。

  3. Python 将提取第一个参数之前的任何选项。选项与参数的区别在于有一个前导连字符-。Python 在启动期间使用这些第一个选项。在本例中,没有选项。第一个参数必须是要运行的 Python 文件名。此文件名参数和该行上的所有剩余单词将分配给sys.argv

  4. Python 启动基于找到的选项。根据-s选项,site模块可用于设置导入路径sys.path。如果我们使用了-m选项,那么 Python 将使用runpy模块来启动我们的应用程序。给定的脚本文件可以(重新)编译为字节码。-v选项将公开正在执行的导入。

  5. 我们的应用程序可以使用sys.argv来解析带有argparse模块的选项和参数。我们的应用程序可以在os.environ中使用环境变量。它还可以解析配置文件;您可以阅读第 14 章配置文件和持久化,了解有关此主题的更多信息。

如果没有文件名,Python 解释器将从标准输入读取。如果标准输入是一个控制台(Linux 术语中称为 TTY),那么 Python 将进入一个读取执行打印循环REPL)并显示>>>提示。当我们作为开发人员使用此模式时,我们通常不会将此模式用于已完成的应用程序。

由于 Python 的灵活性,有其他一些方法可以向 Python 运行时提供输入。标准输入可以是重定向文件,例如,python <some_filesome_app | python。虽然这两个示例都是 Python 的有效用法,但它们可能会令人困惑,因为应用程序源代码并不十分明显。

论据和选择

为了运行程序,shell 将命令行解析为单词。这些词可以理解为选项参数的混合体。以下是一些基本准则:

  • 选择是第一位的。它们前面有---。有两种格式:-l--word。有两种选项:不带参数的选项和带参数的选项。几个没有参数的选项示例涉及使用-V显示版本或使用--version显示版本。带有参数的选项的一个示例是-m module,其中-m选项后面必须跟一个模块名。
  • 没有参数的短格式(单字母)选项可以分组在单个-后面。为了方便起见,我们可以使用-bqv组合-b -q -v选项。
  • 通常,参数在选项之后,并且它们没有前导的---(尽管有些 Linux 应用程序违反了这一规则)。有两种常见的论点:
    • 位置参数,其中顺序在语义上是重要的。我们可能有两个位置参数:输入文件名和输出文件名。顺序很重要,因为输出文件将被修改。当文件将被覆盖时,需要小心地根据位置进行简单的区分,以防止混淆。cpmvln命令是位置参数的罕见示例,其中顺序很重要。使用一个选项来指定输出文件(例如,-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 模块

我们与操作系统交互的主要形式之一是使用文件。pathlib模块使其特别灵活。虽然操作系统可以将文件的路径表示为字符串,但所使用的字符串在语法上相当微妙。创建Path对象要比直接解析字符串愉快得多。它们可以从其组成部分组成和分解路径。

路径合成使用/操作符从Pathstr对象开始组装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对象的查询并不依赖于文件系统中表示实际对象的路径。在本例中,对于实际不存在的文件,parentnamesuffix的各种属性都被正确报告。这对于从输入文件创建输出文件名非常有用。

例如,我们可以执行以下操作:

>>> results = p.with_suffix('.json')
>>> results
PosixPath('/Users/slott/mastering-oo-python-2e/data/simulation.json')

我们获取了一个输入Path对象p,并创建了一个输出Path对象results。结果对象具有相同的名称,但后缀不同。新名称是通过Pathwith_suffix()方法建立的。这使我们可以创建相关文件,而不必解析(相对)复杂的路径名。

正如我们在第 6 章中所述,使用可调用对象和上下文时,应将文件用作上下文管理器,以确保其正确关闭。一个Path对象可以直接打开一个文件,产生如下示例所示的程序:

output = Path("directory") / "file.dat"
    with output.open('w') as output_file:
        output_file.write("sample data\n")

本例创建一个Path对象。操作系统将使用相对于当前工作目录没有前导/的路径。Pathopen()方法将创建一个文件对象,然后可用于读取或写入。在本例中,我们将向文件写入一个常量字符串。

我们可以使用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 解析命令行

使用argparse的一般方法包括以下四个步骤:

  1. 首先,我们创建一个ArgumentParser实例。我们可以向这个对象提供有关命令行界面的总体信息。这可能包括描述、显示选项和参数的格式更改,以及-h是否为help选项。一般来说,我们只需要提供描述;其余的选项都有合理的默认值。
  2. 然后,我们定义命令行选项和参数。这是通过使用ArgumentParser.add_argument()方法函数添加参数来实现的。
  3. 接下来,我们解析sys.argv命令行以创建一个namespace对象,该对象详细说明选项、选项参数和总体命令行参数。
  4. 最后,我们使用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,而不是TrueFalse
  • 有时,我们会使用一个动作'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简单地配置记录器,而无需任何进一步的映射或条件处理。

带参数的选项

我们将定义一个选项,该选项的参数具有长名称和可选短名称。我们将提供一个操作来存储随参数提供的值。我们还可以提供类型转换,以防需要floatint值而不是字符串。让我们使用以下代码进行设置:

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_filenameconfig.output_filename来处理这些参数值。

如前所述,使用简单的位置参数来标识输出文件可能会给用户带来问题。GNU/Linuxcpmvln程序应被视为例外情况,文件可能被覆盖。首选方法是使用带有值的选项来指定可以销毁的文件。强制用户使用类似于-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...") 

当文件名列表是可选的时,通常意味着如果没有提供具体的文件名,将使用STDINSTDOUT

如果我们指定一个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值的类型转换。保留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()方法:这允许我们编写非常复杂的输出。这不应该随便使用。如果我们的选项如此复杂,以至于普通的帮助功能无法工作,那么也许我们做得太过分了。

我们的目标是提高可用性。即使我们的程序工作正常,我们也可以通过提供命令行支持来建立信任,使我们的程序更易于使用。

创建顶级 main()函数

第 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重新分配给任何替代或扩展此功能的新功能。

此函数用于构建所需的各种对象-TablePlayerSimulate。我们根据提供的配置参数配置了这些对象。

我们已经设置了真正工作的对象。在对象构建之后,实际工作是一条突出显示的单行: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.pyanalyze.py这样的命令开始。
  • 我们可以向应用程序添加一个参数,该参数允许一个脚本执行模拟或分析,或同时执行两者。我们会有类似于app.py simulateapp.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 的情况下执行此操作。

设计更高级别的复合命令

我们还可以设计一个由其他命令构建的复合命令。为此,我们有两种设计策略:对象组合和类组合。

如果我们使用对象合成,那么我们的合成命令基于内置的listtuple。我们可以扩展或包装一个现有序列。我们将创建复合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 复合操作符:;&amp;|以及用于分组的()。除此之外,我们在外壳中还有ifforwhile环。

我们研究了Command_Sequence类定义中 shell 序列操作符;的语义等价物。序列的概念非常普遍,以至于许多编程语言(如 shell 和 Python)都不需要显式运算符;shell 的语法只是将行尾用作隐含的序列运算符。

shell 的&amp;操作符创建两个并发运行而不是顺序运行的命令。我们可以使用run()方法创建Command_Concurrent类定义,该方法使用multiprocessing创建两个子流程,并等待两者完成。

shell 中的|操作符创建了一个管道:一个命令的输出缓冲区是另一个命令的输入缓冲区,命令同时运行。在 Python 中,我们需要创建一个队列以及两个进程来读取和写入该队列。这是一种更复杂的情况:它涉及到将队列对象填充到各个子对象的配置中。第 13 章发送和共享对象中有一些使用带有队列的multiprocessing在并发进程之间传递对象的示例。

shell 中的if命令有多个用例;然而,除了通过Command子类中的方法提供本机 Python 实现之外,没有任何令人信服的理由。创建一个复杂的Command类来模拟 Python 的if-elif-else处理是没有帮助的;我们可以而且应该直接使用 Python。

类似地,shell 中的whilefor命令也不是我们需要在更高级别的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.stdinsys.stdout创建一个外壳级管道,将我们连接到另一个应用程序。在构建与 shell 兼容的应用程序时,我们可能还想看看fileinput模块。
  • 我们可以使用subprocess模块访问应用程序的命令行界面。这还可能涉及连接到应用程序的stdinstdout以与之正确交互。
  • 我们还可以在 C 或 C++中编写自己的 Python 兼容模块。在这种情况下,我们可以用 C 实现外部应用程序的 API,提供 Python 应用程序可以利用的类或函数。这可能比使用ctypesAPI 提供更好的性能。由于这需要编译 C 或 C++,它也有一点工具密集型。

这种级别的灵活性意味着我们经常使用 Python 作为集成框架或胶水,从较小的应用程序创建更大的复合应用程序。在使用 Python 进行集成时,我们通常会使用 Python 类和对象来镜像另一个应用程序中的定义。

我们将为第 19 章模块和封装设计保留一些额外的设计考虑。这些是更高层次的架构设计考虑事项,超出了处理命令行的范围。

总结

在本章中,我们研究了如何使用argparseos.environ来收集命令行参数和配置参数。这建立在第 14 章配置文件和持久化中所示的技术之上。

我们学习了如何使用argparse实现一些常见的命令行功能。这包括常见功能,例如显示版本号并退出或显示帮助文本并退出。

我们研究了使用命令设计模式来创建可以扩展或重构以提供新功能的应用程序。我们的目标是显式地保持顶级主函数的主体尽可能小。

设计考虑和权衡

命令行 API 是已完成应用程序的重要组成部分。虽然我们的大部分设计工作都集中在程序运行时所做的事情上,但我们确实需要解决两个边界状态:启动和关闭。当我们启动应用程序时,它必须易于配置。它还必须正常关闭,正确地刷新所有输出缓冲区并释放所有操作系统资源。

在使用面向公众的 API 时,我们必须解决模式演化问题的一个变体。随着应用程序的发展和用户知识的发展,我们将修改命令行 API。这可能意味着我们将拥有遗留特性或遗留语法。这也可能意味着我们需要打破与传统命令行设计的兼容性。

在许多情况下,我们需要确保主版本号是应用程序名称的一部分。我们不应该编写名为someapp的顶级模块。当我们需要制作与主要版本 2 不兼容的主要版本 3 时,我们可能会发现很难解释应用程序的名称已更改为someapp3。我们应该考虑用 Tyt2 来开始,这样数字总是应用程序名称的一部分。

期待

在下一章中,我们将扩展一些顶级设计思想,并查看模块和包设计。一个小型 Python 应用程序也可以是一个模块,这意味着它可以导入到一个更大的应用程序中。复杂的 Python 应用程序可能是一个包。它可以包括其他应用模块,也可以包括在更大规模的应用中。******