现代-Python-秘籍第三版-全-

现代 Python 秘籍第三版(全)

原文:zh.annas-archive.org/md5/6de5aad446152651bf30741f0cb614b7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Python 是开发者、工程师、数据科学家和爱好者首选的语言。它是一种强大的脚本语言,可以为您的应用程序提供速度、安全性和可扩展性。通过将 Python 作为一系列简单的菜谱展示,这本书可以帮助您在具体上下文中深入了解特定的语言特性。理念是避免对语言特性的抽象讨论,而专注于将语言应用于具体的数据和处理问题。

你需要这本书什么

要跟随本书中的示例,你只需要一台运行任何 Python(版本 3.12 或更高)的计算机。许多示例可以修改以适应 Python 3.12 之前的版本。第十章的材料描述了 Python 3.10 中引入的匹配语句。

我们强烈建议安装一个新的 Python 版本,避免使用预安装的操作系统 Python。语言运行时可以从 www.python.org/downloads/ 下载。另一种选择是使用 Miniconda 工具(docs.conda.io/en/latest/miniconda.html)并使用 conda 创建一个 Python 3.12(或更高版本)的环境。

Python 2 已无法使用。自 2020 年起,Python 2 已不再是替代品。

这本书面向谁

本书面向网络开发者、程序员、企业程序员、工程师和大数据科学家。如果您是初学者,这本书可以帮助您入门。如果您有经验,它将扩展您的知识库。基本编程知识将有所帮助;虽然涵盖了一些基础主题,但这不是编程或 Python 的教程。

这本书涵盖的内容

本书包含超过 130 个菜谱。我们可以将它们分解为四个一般领域:

  • Python 基础知识

    第一章,数字、字符串和元组,将探讨不同类型的数字,如何处理字符串,如何使用元组,以及如何使用 Python 中的基本内置类型。我们还将展示如何利用 Unicode 字符集的全部功能。

    第二章,语句和语法,将介绍创建脚本文件的一些基础知识。然后我们将转向查看一些复杂语句,包括 if、while、for、break、try、raise 和 with。

    第三章,函数定义,将探讨多种函数定义技术。我们将用几个菜谱来介绍各种类型的类型提示。我们还将通过使用函数和主导入切换来讨论设计可测试脚本的一个要素。

    第四章, 内置数据结构第一部分:列表和集合, 开始概述可用的内置数据结构及其解决的问题。这包括一系列展示列表和集合操作的食谱,包括列表和集合推导式。

    第五章, 内置数据结构第二部分:字典, 继续探讨内置数据结构,详细考察字典。本章还将探讨一些与 Python 处理对象引用相关的更高级主题。它还展示了如何将可变对象作为函数参数的默认值处理。

    第六章, 用户输入和输出,解释了如何使用 print()函数的不同功能。我们还将探讨用于提供用户输入的不同函数。f-string 用于调试以及 argparse 模块用于命令行输入的特点被介绍。

  • 面向对象和函数式设计方法

    第七章, 类和对象的基础,开始介绍面向对象编程。它展示了如何创建类以及与类定义相关的类型提示。本节从之前的版本扩展而来,以涵盖 dataclasses。它展示了如何扩展内置类,以及如何创建上下文管理器来管理资源。

    第八章, 更高级的类设计,继续探索面向对象设计和编程。这包括对组合与继承问题的探讨,并展示了如何管理 Python 的“鸭子类型”原则。

    第九章, 函数式编程特性,探讨了 Python 的函数式编程特性。这种编程风格强调函数定义和无状态、不可变对象。食谱探讨了生成器表达式,使用 map()、filter()和 reduce()函数。我们还探讨了创建部分函数的方法以及用不可变对象集合构建的数据结构替换有状态对象的一些示例。

  • 更复杂的设计

    第十章, 使用类型匹配和注解,更深入地探讨了类型提示和 match 语句。这包括使用 Pydantic 创建具有更严格运行时类型检查的类。它还探讨了注解类型的内省。

    第十一章,输入/输出、物理格式和逻辑布局,将处理一般路径和文件。它将探讨在多种文件格式中读取和写入数据,包括 CSV、JSON(和 YAML)、XML 和 HTML。HTML 部分将强调使用 Beautiful Soup 提取数据。

    第十二章,使用 Jupyter Lab 进行图形和可视化,将使用 Jupyter Lab 创建使用 Python 进行数据分析和可视化的笔记本。这将展示如何将数据导入笔记本以创建图表,以及如何使用 Markdown 从笔记本创建有用的文档和报告。

    第十三章,应用程序集成:配置,将开始探讨我们可以设计更大应用程序的方法。本章的食谱将解决处理配置文件的不同方式以及如何管理日志。

    第十四章,应用程序集成:组合,将继续探讨从更小的组件创建组合应用程序的方法。这将探讨面向对象的设计模式和命令行界面(CLI)应用程序。它还将探讨使用子进程模块在 Python 的控制下运行现有应用程序。

  • 完成项目:完善和完成

    第十五章,测试,提供了使用 Python 内置的 doctest 和 unittest 测试框架的食谱。此外,食谱还将涵盖 pytest 工具。

    第十六章,依赖和虚拟环境,涵盖了用于管理虚拟环境的工具。内置的 venv、conda 和 poetry 都将被介绍。管理虚拟环境有众多解决方案,我们无法涵盖所有。

    第十七章,文档和风格,涵盖了可以帮助创建高质量软件的额外工具。这特别关注 sphinx 创建全面、易读的文档。我们还将探讨 tox 来自动运行测试。

为了充分利用这本书

为了充分利用这本书,你可以按照以下说明下载示例代码文件和彩色图像。

下载示例代码文件

本书的相关代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Modern-Python-Cookbook-Third-Edition。这个仓库也是开始讨论书中特定主题的最佳地方。如果您想与作者或其他读者交流,请随意提出问题。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781835466384

使用的约定

在这本书中,您会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“我们可以通过使用 include 指令来包含其他上下文。”

代码块设置如下:

if distance is None: 

    distance = rate * time 

elif rate is None: 

    rate = distance / time 

elif time is None: 

    time = distance / rate

任何命令行输入或输出都如下所示:

 >>> import math 

>>> math.factorial(52) 

80658175170943878571660636856403766975289505440883277824000000000000

新术语和重要词汇以粗体显示。

警告或重要注意事项如下所示。

技巧和窍门如下所示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:请将反馈发送至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 与我们联系。

错误清单:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,请访问packtpub.com/support/errata,选择您的书籍,点击错误提交表单链接,并输入详细信息。

侵权:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《现代 Python 食谱》第三版,我们很乐意听听您的想法!扫描下面的二维码直接进入此书的亚马逊评论页面,分享您的反馈或在该购买网站上留下评论。

PIC

packt.link/r/1835466389

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?您的电子书购买是否与您选择的设备不兼容?

别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到您的邮箱。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

    PIC

    packt.link/free-ebook/9781835466384

  2. 提交您的购买证明

  3. 就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的邮箱

第一章:1

数字、字符串和元组

本章将探讨 Python 对象的一些核心类型。我们将探讨处理不同类型的数字、处理字符串和使用元组。这些是 Python 处理的最简单的数据类型。在后面的章节中,我们将探讨建立在这些基础上的数据结构。

虽然这些食谱从对 Python 3.12 的入门级理解开始,但它们也为熟悉该语言的人提供了一些更深入的知识背景。特别是,我们将探讨一些关于数字如何内部表示的细节,因为这在面对更高级的数值编程问题时可能会有所帮助。这将帮助我们区分丰富多样的数值类型的用例。

我们还将探讨两种不同的除法运算符。它们有特定的用例,我们将探讨一种需要截断除法的算法。

当处理字符串时,有几个常见的操作很重要。我们将探讨字节(在我们的操作系统文件中使用)与用于表示 Unicode 文本的字符串之间的差异。我们将看看如何利用 Unicode 字符集的全部力量。

在本章中,我们将像在交互式 Python 的 >>> 提示符下工作一样展示这些食谱。这是在命令行运行 python 或在许多集成开发环境(IDE)工具中的 Python 控制台中提供的提示符。这有时被称为读取-评估-打印循环(REPL)。在后面的章节中,我们将改变风格,使其看起来更像脚本文件。本章的一个目标是通过交互式探索来鼓励学习,因为这是一种学习语言的好方法。

我们将介绍这些食谱来介绍基本的 Python 数据类型:

  • 在 float、decimal 和 fraction 之间进行选择

  • 在真除法和地板除法之间进行选择

  • 使用正则表达式解析字符串

  • 使用 f-strings 构建复杂的字符串

  • 从字符串列表构建复杂的字符串

  • 使用键盘上没有的 Unicode 字符

  • 编码字符串 – 创建 ASCII 和 UTF-8 字节

  • 解码字节 – 如何从一些字节中获取正确的字符

  • 使用项目元组

  • 使用 NamedTuples 简化元组中的项目访问

我们将从数字开始,逐步过渡到字符串,最后以元组和 NamedTuple 对象的简单组合形式处理对象。

1.1 在 float、decimal 和 fraction 之间进行选择

Python 提供了多种处理有理数和无理数近似值的方法。我们有三种基本选择:

  • 浮点数

  • 小数

  • 分数

当我们有选择时,有一些标准来做出选择是有帮助的。

1.1.1 准备工作

涉及到整数以外的数字的表达式有三个一般情况,它们是:

  1. 货币:美元、分、欧元等等。货币通常有固定的位数和小数点后舍入规则,以正确量化结果。

  2. 有理数或分数:当我们把为八人准备的食谱缩小到五人份时,例如,我们正在使用5 8的缩放因子进行分数数学。

  3. 浮点数:这包括所有其他类型的计算。这也包括无理数,如π、根提取和对数。

当我们遇到前两种情况之一时,我们应该避免使用浮点数。

1.1.2 如何操作...

我们将分别查看三个案例。

进行货币计算

当处理货币时,我们应该始终使用 decimal 模块。如果我们尝试使用 Python 内置的 float 类型的值,我们可能会遇到数字舍入和截断的问题:

  1. 要处理货币,从 decimal 模块导入 Decimal 类:

    >>> from decimal import Decimal
    
  2. 我们需要从字符串或整数创建 Decimal 对象。在这种情况下,我们想要 7.25%,即7.25 100。我们可以使用 Decimal 对象来计算这个值:

    >>> tax_rate = Decimal(’7.25’)/Decimal(100) 
    
    >>> purchase_amount = Decimal(’2.95’) 
    
    >>> tax_rate * purchase_amount 
    
    Decimal(’0.213875’)
    

    我们也可以使用 Decimal('0.0725')而不是显式地进行除法。

  3. 要四舍五入到最近的便士,创建一个便士对象:

    >>> penny = Decimal(’0.01’)
    
  4. 使用便士对象量化结果:

    >>> total_amount = purchase_amount + tax_rate * purchase_amount 
    
    >>> total_amount.quantize(penny) 
    
    Decimal(’3.16’)
    

这使用的是默认的 ROUND_HALF_EVEN 舍入规则。Decimal 模块提供了其他舍入变体。例如,我们可能做如下操作:

>>> import decimal 

>>> total_amount.quantize(penny, decimal.ROUND_UP) 

Decimal(’3.17’)

这显示了使用不同舍入规则的结果。

分数计算

当我们进行具有精确分数值的计算时,我们可以使用 fractions 模块来创建有理数。在这个例子中,我们想要将八人份的食谱缩小到五人份,使用58的每种成分。当食谱要求 21 2杯大米时,这会缩小到多少?

要处理分数,我们将这样做:

  1. 从 fractions 模块导入 Fraction 类:

    >>> from fractions import Fraction
    
  2. 从字符串、整数或整数对创建 Fraction 对象。我们从一个字符串创建了一个分数,'2.5'。我们从一个浮点表达式创建第二个分数,5 / 8。这仅在分母是 2 的幂时才有效:

    >>> sugar_cups = Fraction(’2.5’) 
    
    >>> scale_factor = Fraction(5/8) 
    
    >>> sugar_cups * scale_factor 
    
    Fraction(25, 16)
    

我们可以看到,我们将使用几乎一升半的大米来调整八人份的食谱,而不是五人份。虽然浮点数对于有理分数通常很有用,但除非分母是 2 的幂,否则它们可能不精确。

浮点近似

Python 内置的 float 类型可以表示各种值。这里的权衡是浮点值通常是一个近似值。可能会有一个小的差异,揭示了 float 的实现与无理数的数学理想之间的差异:

  1. 为了与浮点数一起工作,我们通常需要四舍五入值以使其看起来合理。重要的是要认识到所有浮点数计算都是近似值:

    >>> (19/155)*(155/19) 
    
    0.9999999999999999
    
  2. 从数学上讲,值应该是 1。由于使用了近似值,计算结果并不完全等于 1。我们可以使用 round(answer, 3)将其四舍五入到三位数字,创建一个更有用的值:

    >>> answer = (19/155)*(155/19) 
    
    >>> round(answer, 3) 
    
    1.0
    

近似值有非常重要的后果。

不要比较浮点数的精确相等性。

使用精确的等于(==)测试浮点数的代码,当两个近似值只相差一个比特时,可能会引起问题的潜在风险。

浮点数近似规则来自 IEEE,并不是 Python 的独特特性。许多编程语言都使用浮点数近似,并且具有相同的行为。

1.1.3 它是如何工作的...

对于这些数值类型,Python 提供了各种运算符:+、-、*、/、//、%和**。这些分别用于加法、减法、乘法、真除法、截断除法、取模和求幂。我们将探讨两个除法运算符/和//,在选择真除法和截断除法的食谱中。

Python 会在各种数值类型之间进行一些转换。我们可以混合使用整型(int)和浮点型(float)值;整数将被提升为浮点数以提供尽可能精确的答案。同样,我们也可以混合使用整型与分数(Fraction)以及整型与十进制(Decimal)。请注意,我们无法随意混合十进制与浮点数或分数;需要显式转换函数。

重要的是要注意,浮点数是近似值。Python 语法允许我们使用十进制数字来编写浮点值;然而,这并不是值在内部表示的方式。

我们可以在 Python 中这样写出值 8.066 × 10⁶⁷:

>>> 8.066e+67 

8.066e+67

实际内部使用的值将涉及我们写入的十进制值的二进制近似。此示例的内部值如下:

>>> (6737037547376141/(2**53))*(2**226) 

8.066e+67

分子是一个大数,6737037547376141。分母始终是 2 的 53 次方。这就是为什么值可能会被截断。

我们可以使用 math.frexp()函数来查看一个数字的内部细节:

>>> import math 

>>> math.frexp(8.066E+67) 

(0.7479614202861186, 226)

这两部分被称为尾数(或有效数字)和指数。如果我们把尾数乘以 2 的 53 次方,我们总是得到一个整数,这是二进制分数的分子。

与内置的浮点数不同,分数(Fraction)是两个整数值的精确比率。我们可以创建涉及具有非常大量数字的整数的比率。我们不受固定分母的限制。

类似地,十进制值基于一个非常大的整数值,以及一个缩放因子来确定小数点的位置。这些数字可以非常大,并且不会遭受奇特的表示问题。

1.1.4 更多...

Python 的 math 模块包含用于处理浮点值的一些专用函数。此模块包括常见的初等函数,如平方根、对数和各种三角函数。它还有一些其他函数,如伽玛、阶乘和高斯误差函数。

math 模块包含几个函数,可以帮助我们进行更精确的浮点计算。例如,math.fsum()函数将比内置的 sum()函数更仔细地计算浮点数之和。它对近似问题不太敏感。

我们还可以使用 math.isclose()函数来比较两个浮点值、一个表达式和一个字面量 1.0,以查看它们是否近似相等:

>>> (19/155)*(155/19) == 1.0 

False 
 >>> math.isclose((19/155)*(155/19), 1.0) 

True

此函数为我们提供了一种有意义地比较两个浮点数近似的手段。

Python 还提供了复数。复数有一个实部和虚部。在 Python 中,我们用 3.14+2.78j 来表示复数 3.14 + 2.78√ --- − 1。Python 可以轻松地在浮点数和复数之间进行转换。我们有一组常用的运算符可用于复数。

为了支持复数,有 cmath 包。例如,cmath.sqrt()函数在提取负数的平方根时将返回一个复数值,而不是抛出异常。以下是一个示例:

>>> math.sqrt(-2) 

Traceback (most recent call last): 

... 

ValueError: math domain error 
 >>> import cmath 

>>> cmath.sqrt(-2) 

1.4142135623730951j

此模块在处理复数时很有帮助。

1.1.5 参见

1.2 选择真除法和截断除法

Python 为我们提供了两种除法运算符。它们是什么,我们如何知道该使用哪一个?我们还将探讨 Python 的除法规则以及它们如何应用于整数值。

1.2.1 准备工作

除法有几个通用情况:

  • 一个除法-取模对:我们想要两个部分——商和余数。这个名字指的是将除法和取模操作组合在一起。我们可以将商和余数总结为 q,r = (⌊a b⌋,a mod b)。

    我们经常在将一个基数的值转换为另一个基数时使用它。当我们把秒转换为小时、分钟和秒时,我们将进行一种除法-取模的运算。我们不需要确切的小时数;我们想要一个截断的小时数,余数将被转换为分钟和秒。

  • 真实值:这是一个典型的浮点值;它将是商的一个很好的近似。例如,如果我们正在计算几个测量的平均值,我们通常期望结果是浮点数,即使输入值都是整数。

  • 有理分数值:当在美国单位英尺、英寸和杯中工作时,这通常是必要的。为此,我们应该使用分数类。当我们除以分数对象时,我们总是得到精确的答案。

我们需要决定这些情况中的哪一个适用,以便我们知道要使用哪个除法运算符。

1.2.2 如何做...

我们将分别查看这三个案例。

执行截断除法

当我们进行除法-模运算时,我们可能会使用整除运算符//和取模运算符%。表达式 a % b 给出了 a // b 的整数除法的余数。或者,我们可能会使用内置的 divmod()函数同时计算这两个值:

  1. 我们将秒数除以 3,600 以得到小时值。使用%运算符计算的模数,或除法中的余数,可以单独转换为分钟和秒:

    >>> total_seconds = 7385 
    
    >>> hours = total_seconds // 3600 
    
    >>> remaining_seconds = total_seconds % 3600
    
  2. 接下来,我们将秒数除以 60 以得到分钟数;余数是小于 60 的秒数:

    >>> minutes = remaining_seconds // 60 
    
    >>> seconds = remaining_seconds % 60 
    
    >>> hours, minutes, seconds 
    
    (2, 3, 5)
    

这里是另一种方法,使用 divmod()函数同时计算商和模数:

  1. 同时计算商和余数:

    >>> total_seconds = 7385 
    
    >>> hours, remaining_seconds = divmod(total_seconds, 3600)
    
  2. 再次计算商和余数:

    >>> minutes, seconds = divmod(remaining_seconds, 60) 
    
    >>> hours, minutes, seconds 
    
    (2, 3, 5)
    

执行真正的除法

执行真正的除法计算给出一个浮点近似值作为结果。例如,7,385 秒大约是多少小时?这里使用真正的除法运算符:736805

>>> total_seconds = 7385 

>>> hours = total_seconds / 3600 

>>> round(hours, 4) 

2.0514

我们提供了两个整数值,但得到了一个浮点精确结果。与我们的先前的配方一致,当使用浮点值时,我们将结果四舍五入以避免查看微小的误差数字。

有理分数计算

我们可以使用分数对象和整数进行除法。这迫使结果成为一个数学上精确的有理数:

  1. 至少创建一个分数值:

    >>> from fractions import Fraction 
    
    >>> total_seconds = Fraction(7385)
    
  2. 在计算中使用分数值。任何整数都将提升为分数:

    >>> hours = total_seconds / 3600 
    
    >>> hours 
    
    Fraction(1477, 720)
    

    720 的分母似乎不太有意义。使用这种分数需要一点技巧来找到对人们有意义的分母。否则,转换为浮点值可能是有用的。

  3. 如果需要,将精确的分数转换为浮点近似值:

    >>> round(float(hours), 4) 
    
    2.0514
    

首先,我们为总秒数创建了一个分数对象。当我们对分数进行算术运算时,Python 会将任何整数提升为分数对象;这种提升意味着数学运算尽可能精确。

1.2.3 它是如何工作的...

Python 有两个除法运算符:

  • / 真除法运算符产生一个真正的浮点结果。即使两个操作数都是整数,它也会这样做。在这方面,这是一个不寻常的运算符。所有其他运算符都保留数据类型。当应用于整数时,真正的除法操作产生一个浮点结果。

  • // 截断除法运算符总是产生截断结果。对于两个整数操作数,这是截断商。当使用浮点操作数时,这是截断浮点结果:

    >>> 7358.0 // 3600.0 
    
    2.0
    

1.2.4 参见

  • 更多关于浮点数和分数之间选择的信息,请参阅选择浮点数、十进制和分数配方。

  • 查看PEP-238

1.3 使用正则表达式进行字符串解析

我们如何分解一个复杂的字符串?如果我们有复杂、棘手的标点符号怎么办?或者——更糟糕的是——如果我们没有标点符号,但必须依赖数字的模式来定位有意义的信息呢?

1.3.1 准备工作

分解复杂字符串的最简单方法是将字符串泛化为一个模式,然后编写一个描述该模式的正则表达式。

正则表达式可以描述的模式的数量是有限的。当我们面对像 HTML、XML 或 JSON 这样的深度嵌套文档时,我们经常会遇到问题,并被禁止使用正则表达式。

re 模块包含我们创建和使用正则表达式所需的所有各种类和函数。

假设我们想要分解来自食谱网站的文本。每一行看起来像这样:

>>> ingredient = "Kumquat: 2 cups"

我们希望将成分与测量值分开。

1.3.2 如何操作...

要编写和使用正则表达式,我们通常这样做:

  1. 将示例进行泛化。在我们的情况下,我们有一些可以泛化的东西:

    (ingredient words): (amount digits) (unit words)
    
  2. 我们将文本替换成了两部分总结:它的含义和它的表示方式。例如,成分用单词表示,而数量用数字表示。导入 re 模块:

    >>> import re
    
  3. 将模式重写为正则表达式(RE)表示法:

    >>> pattern_text = r’([\w\s]+):\s+(\d+)\s+(\w+)’
    

    我们将成分词、字母和空格的混合表示法替换为[\w\s]+。我们将数量数字替换为\d+。我们将单个空格替换为\s+,以便可以使用一个或多个空格作为标点符号。我们保留了冒号,因为在正则表达式表示法中,冒号匹配自身。

    对于数据字段的每个字段,我们使用()捕获匹配模式的匹配数据。我们没有捕获冒号或空格,因为我们不需要标点符号字符。

    正则表达式通常使用很多\字符。为了在 Python 中使其工作得很好,我们几乎总是使用原始字符串。r’告诉 Python 不要查看\字符,也不要将它们替换成不在我们键盘上的特殊字符。

  4. 编译模式:

    >>> pattern = re.compile(pattern_text)
    
  5. 将模式与输入文本进行匹配。如果输入与模式匹配,我们将得到一个匹配对象,它显示了匹配的子字符串的详细信息:

    >>> match = pattern.match(ingredient) 
    
    >>> match is None 
    
    False 
    
    >>> match.groups() 
    
    (’Kumquat’, ’2’, ’cups’)
    
  6. 从匹配对象中提取命名的字符组:

    >>> match.group(1) 
    
    ’Kumquat’ 
    
    >>> match.group(2) 
    
    ’2’ 
    
    >>> match.group(3) 
    
    ’cups’
    

每个组通过正则表达式的捕获 () 部分的顺序来标识。这给我们一个从字符串中捕获的不同字段的元组。我们将在使用项目元组菜谱中返回到元组数据结构的使用。在更复杂的正则表达式中,这可能会令人困惑;有一种方法可以提供名称,而不是数字位置来标识捕获组。

1.3.3 它是如何工作的...

我们可以用正则表达式描述很多不同种类的字符串模式。

我们已经展示了多个字符类:

  • \w 匹配任何字母数字字符(a 到 z,A 到 Z,0 到 9)。

  • \d 匹配任何十进制数字。

  • \s 匹配任何空格或制表符字符。

这些类也有它们的逆:

  • \W 匹配任何非字母或数字字符。

  • \D 匹配任何非数字字符。

  • \S 匹配任何非空格或制表符字符。

许多字符匹配自身。然而,一些字符具有特殊含义,我们必须使用 \ 来转义这种特殊含义:

  • 我们看到,作为后缀的 + 表示匹配前面的一个或多个模式。\d+ 匹配一个或多个数字。要匹配普通的 +,我们需要使用 +。

  • 我们还有 * 作为后缀,它匹配前面的零个或多个模式。\w* 匹配零个或多个字符。要匹配一个 *,我们需要使用 *。

  • 我们有 ? 作为后缀,它匹配前面的零个或一个表达式。这个字符在其他地方也被使用,但在其他上下文中具有不同的含义。我们将在 ?P...)|, 其中它位于 \verb|)— 内部,用来定义分组特殊属性。

  • . 字符匹配任何单个字符。要特定地匹配一个 .,我们需要使用 ..

我们可以使用 [] 来创建我们自己的唯一字符集。我们可能有一些像这样的东西:

(?P<name>\w+)\s*[=:]\s*(?P<value>.*)

这有一个 \w+ 来匹配任意数量的字母数字字符。这将收集到一个名为 name 的组中。它使用 \s* 来匹配一个可选的空格序列。它匹配集合 [=:] 中的任何字符。这个集合中的两个字符中恰好有一个必须存在。它再次使用 \s* 来匹配一个可选的空格序列。最后,它使用 .* 来匹配字符串中的其他所有内容。这被收集到一个名为 value 的组中。

我们可以使用它来解析字符串,如下所示:

size = 12 

weight: 14

通过对标点符号的灵活性,我们可以使程序更容易使用。我们将容忍任意数量的空格,以及 = 或 : 作为分隔符。

1.3.4 更多...

一个长的正则表达式可能难以阅读。我们有一个巧妙的 Pythonic 方法来以一种更容易阅读的方式呈现表达式:

>>> ingredient_pattern = re.compile( 

... r’(?P<ingredient>[\w\s]+):\s+’ # name of the ingredient up to the ":" 

... r’(?P<amount>\d+)\s+’ # amount, all digits up to a space 

... r’(?P<unit>\w+)’ # units, alphanumeric characters 

... )

这利用了三个语法规则:

  • 一个语句只有在 () 字符匹配后才会完成。

  • 相邻的字符串字面量会被静默地连接成一个单个的长字符串。

  • 任何在 # 和行尾之间的内容都是一个注释,并且会被忽略。

我们在我们的正则表达式的重要子句后面添加了 Python 注释。这可以帮助我们理解我们做了什么,也许有助于我们以后诊断问题。

我们还可以使用正则表达式的“详细”模式在正则表达式字符串内添加多余的空白和注释。为此,我们必须在编译正则表达式时使用 re.X 作为选项,以便使空白和注释成为可能。这种修改后的语法看起来像这样:

>>> ingredient_pattern_x = re.compile(r’’’ 

... (?P<ingredient>[\w\s]+):\s+ # name of the ingredient up to the ":" 

... (?P<amount>\d+)\s+ # amount, all digits up to a space 

... (?P<unit>\w+) # units, alphanumeric characters 

... ’’’, re.X)

我们可以将模式分解为单独的字符串组件,或者使用扩展语法使正则表达式更易于阅读。提供名称的好处在于,当我们使用 match 对象的 groupdict() 方法通过捕获模式的关联名称提取解析值时。

1.3.5 相关阅读

1.4 使用 f 字符串构建复杂的字符串

在许多方面,创建复杂的字符串与解析复杂的字符串正好相反。我们通常使用模板和替换规则来将数据放入更复杂的格式中。

1.4.1 准备工作

假设我们有一些需要转换为格式化消息的数据。我们可能有的数据包括以下内容:

>>> id = "IAD" 

>>> location = "Dulles Intl Airport" 

>>> max_temp = 32 

>>> min_temp = 13 

>>> precipitation = 0.4

我们希望得到一条看起来像这样的线:

IAD : Dulles Intl Airport : 32 / 13 / 0.40

1.4.2 如何做...

  1. 为结果创建一个 f 字符串,将所有数据项替换为占位符。在每个占位符内部,放置一个变量名(或一个表达式)。请注意,字符串使用 f' 前缀。这个前缀创建了一个复杂的字符串对象,其中值在字符串被使用时被插入到模板中:

    f’{id} : {location} : {max_temp} / {min_temp} / {precipitation}’
    
  2. 对于每个名称或表达式,可以在模板字符串中的名称后附加一个可选的数据类型。基本数据类型代码如下:

    • s 用于字符串

    • d 用于十进制数字

    • f 用于浮点数

    它看起来会是这样:

    f’{id:s} : {location:s} : {max_temp:d} / {min_temp:d} / {precipitation:f}’
    

    由于这本书的边距很窄,字符串已经被断开。

    适应页面。这是一行(非常宽)的代码。

  3. 在需要的地方添加长度信息。长度信息并不总是必需的,在某些情况下,甚至可能不希望有。然而,在这个例子中,长度信息确保了每条消息都有统一的格式。对于字符串和十进制数字,使用以下格式添加长度:19s 或 3d。对于浮点数,使用两部分的格式前缀,如 5.2f,以指定总长度为五个字符,其中小数点右边有两个字符。以下是整个格式:

    >>> f’{id:3s} : {location:19s} : {max_temp:3d} / {min_temp:3d} / {precipitation:5.2f}’ 
    
    ’IAD : Dulles Intl Airport :  32 /  13 /  0.40’
    

1.4.3 它是如何工作的...

F 字符串可以通过将数据插入模板来实现许多相对复杂的字符串组装。有几种转换可用。

我们已经看到了三种格式化转换——s、d、f,但还有很多其他的。详细信息可以在 Python 标准库的格式化字符串字面量部分找到:docs.python.org/3/reference/lexical_analysis.html#formatted-string-literals

这里是一些我们可能使用的格式转换:

  • b 是用于二进制,基数 2。

  • c 是用于 Unicode 字符。值必须是一个数字,它将被转换成一个字符。通常,我们使用十六进制数字来表示这些字符,所以你可能想尝试 0x2661 到 0x2666 之间的值来查看有趣的 Unicode 符号。

  • d 是用于十进制数。

  • E 和 e 是用于科学记数法。6.626E-34 或 6.626e-34,具体取决于使用的 E 或 e 字符。

  • F 和 f 是用于浮点数。对于非数字,f 格式显示小写的 nan;F 格式显示大写的 NAN。

  • G 和 g 是用于通用。这会自动在 E 和 F(或 e 和 f)之间切换,以保持输出在给定的字段大小内。对于 20.5G 的格式,最多显示 20 位数字,使用 F 格式。更大的数字将使用 E 格式。

  • n 是用于特定地区的十进制数字。这将插入逗号或点字符,具体取决于当前的地区设置。默认地区可能没有定义 1,000 分隔符。更多信息,请参阅地区模块。

  • o 是用于八进制,基数 8。

  • s 是用于字符串。

  • X 和 x 是用于十六进制,基数 16。数字包括大写 A-F 和小写 a-f,具体取决于使用的 X 或 x 格式字符。

  • %是用于百分比。数字乘以 100,输出包括一个%字符。

我们可以使用多个前缀来表示这些不同类型。最常见的一个是长度。我们可以使用{name:5d}来插入一个 5 位数。对于前述类型,有几个前缀可以使用:

  • 填充和对齐:我们可以指定一个特定的填充字符(默认为空格)和对齐方式。数字通常右对齐,字符串左对齐。我们可以使用<、>或^来改变这一点。这分别强制左对齐、右对齐或居中对齐。还有一个特殊的=对齐,用于在符号前填充。

  • 符号:默认规则是在需要的地方有一个前导负号。我们可以使用+来给所有数字加上符号,-来只给负数加上符号,或者使用空格来用空格代替正数的加号。在科学输出中,我们经常使用{value:5.3f}。空格确保留出空间给符号,确保所有的小数点对齐得很好。

  • 交替形式:我们可以使用#来获取交替形式。我们可能有一些像{0:#x}、{0:#o}或{0:#b}这样的格式,以在十六进制、八进制或二进制值上添加前缀。带有前缀的数字将看起来像 0xnnn、0onnn 或 0bnnn。默认情况下,省略两个字符的前缀。

  • 领先零:我们可以包含 0 以在数字前面填充前导零。例如,{code:08x} 将生成一个填充到八位的十六进制值。

  • 宽度和精度:对于整数值和字符串,我们只提供宽度。对于浮点值,我们通常提供宽度.精度。

有时候我们不会使用 {name:format} 说明符。有时,我们需要使用 {name!conversion} 说明符。只有三种转换可用:

  • {name!r} 显示由 repr(name) 生成的表示。

  • {name!s} 显示由 str(name) 生成的字符串值;这是如果不指定任何转换时的默认行为。使用 !s 明确地允许你添加字符串类型格式说明符。

  • {name!a} 显示由 ascii(name) 生成的 ASCII 值。

  • 此外,还有一个方便的调试格式说明符可用。我们可以在变量或表达式后包含一个尾随等号,=,以获取一个方便的变量或表达式转储。以下示例使用了这两种形式:

    >>> value = 2**12-1 
    
    >>> f’{value=} {2**7+1=}’ 
    
    ’value=4095 2**7+1=129’ 
    

f-string 显示了名为 value 的变量的值以及表达式 2**7+1 的结果。

在第七章,我们将利用 {name!r} 格式说明符的想法来简化显示有关相关对象的信息。

1.4.4 更多...

f-string 处理依赖于字符串的 format() 方法。我们可以利用这个方法和相关的 format_map() 方法来处理更复杂的数据结构。

期待第五章,我们可能有一个字典,其键是符合 format_map() 规则的简单字符串:

>>> data = dict( 

... id=id, location=location, max_temp=max_temp, 

... min_temp=min_temp, precipitation=precipitation 

... ) 

>>> ’{id:3s} : {location:19s} : {max_temp:3d} / {min_temp:3d} / {precipitation:5.2f}’.format_map(data) 

’IAD : Dulles Intl Airport :  32 /  13 /  0.40’

我们创建了一个包含多个值的字典对象,data,其键是有效的 Python 标识符:id、location、max_temp、min_temp 和 precipitation。然后我们可以使用这个字典和 format_map() 方法来使用键从字典中提取值。

注意,这里的格式模板不是 f-string。它没有 f" 前缀。我们不是使用 f-string 的自动格式化功能,而是通过 f-string 的 format_map() 方法“硬编码”了插值。

1.4.5 参见

1.5 从字符串列表构建复杂的字符串

我们如何对不可变的字符串进行复杂更改?我们能从单个字符组装一个字符串吗?

在大多数情况下,我们之前看到的食谱为我们提供了创建和修改字符串的许多工具。我们还有更多处理字符串操作问题的方法。在这个食谱中,我们将探讨使用列表对象作为分解和重建字符串的方法。这将与第四章中的一些食谱相呼应。

1.5.1 准备工作

这是一个我们想要重新排列的字符串:

>>> title = "Recipe 5: Rewriting an Immutable String"

我们想要进行两种转换:

  • 移除冒号前的部分。

  • 用下划线替换标点符号,并将所有字符转换为小写。

我们将利用字符串模块:

>>> from string import whitespace, punctuation

这有两个重要的常数:

  • string.whitespace 列出了所有也是 ASCII 一部分的空白字符,包括空格和制表符。

  • string.punctuation 列出了也是 ASCII 一部分的标点符号。Unicode 有大量的标点符号。这是一个广泛使用的子集。

1.5.2 如何做...

我们可以处理分解成列表的字符串。我们将在第四章中更深入地探讨列表:

  1. 将字符串分解成列表对象:

    >>> title_list = list(title)
    
  2. 找到分隔字符。列表的 index()方法与字符串的 index()方法具有相同的语义。它定位给定值的索引位置:

    >>> colon_position = title_list.index(’:’)
    
  3. 删除不再需要的字符。del 语句可以从列表中删除项。与字符串不同,列表是可变的数据结构:

    >>> del title_list[:colon_position+1]
    
  4. 通过遍历每个位置来替换标点符号。在这种情况下,我们将使用一个 for 语句来访问字符串中的每个索引:

    >>> for position in range(len(title_list)): 
    
    ...     if title_list[position] in whitespace+punctuation: 
    
    ...         title_list[position]= ’_’
    
  5. 表达式 range(len(title_list))生成介于 0 和 len(title_list)-1 之间的所有值。这确保了 position 的值将是列表中的每个索引值。将字符列表连接起来创建一个新的字符串。当将字符串连接在一起时,使用零长度字符串''作为分隔符看起来有点奇怪。然而,它工作得很好:

    >>> title = ’’.join(title_list) 
    
    >>> title 
    
    ’_Rewriting_an_Immutable_String’
    

我们将生成的字符串重新赋值给原始变量。原始字符串对象,该变量曾引用它,不再需要:它将自动从内存中删除(这被称为垃圾回收)。新的字符串对象替换了变量的值。

1.5.3 它是如何工作的...

这是一个表示技巧的改变。由于字符串是不可变的,我们无法更新它。然而,我们可以将其转换为可变形式;在这种情况下,是一个列表。我们可以对可变列表对象进行所需的任何更改。当我们完成时,我们可以将表示从列表转换回字符串,并替换原始变量的值。

列表提供了一些字符串没有的特性。反过来,字符串也提供了一些列表没有的特性。例如,我们不能像转换字符串那样将列表转换为小写。

这里有一个重要的权衡:

  • 字符串是不可变的,这使得它们非常快。字符串专注于 Unicode 字符。当我们查看映射和集合时,我们可以使用字符串作为映射的键和集合中的项,因为值是不可变的。

  • 列表是可变的。操作较慢。列表可以持有任何类型的项。我们不能使用列表作为映射的键或集合中的项,因为列表值可能会改变。

字符串和列表都是特殊类型的序列。因此,它们有许多共同特性。基本的项索引和切片特性是共享的。同样,列表使用与字符串相同的负索引值:表达式 list[-1] 是列表对象中的最后一个项。

我们将在第四章返回可变数据结构。

1.5.4 参考内容

  • 有时,我们需要构建一个字符串,然后将其转换为字节。请参阅编码字符串——如何创建 ASCII 和 UTF-8 字节配方,了解我们如何做到这一点。

  • 有时,我们还需要将字节转换为字符串。请参阅解码字节——如何从一些字节中获取正确的字符配方以获取更多信息。

1.6 使用键盘上没有的 Unicode 字符

一个大键盘可能有近 100 个独立的按键。通常,其中不到 50 个键是字母、数字和标点符号。至少有十几个是功能键,它们执行的操作不仅仅是简单地插入字母到文档中。一些按键是不同类型的修饰符,它们旨在与另一个键一起使用——例如,我们可能有 Shift、Ctrl、Option 和 Command。

大多数操作系统将接受创建大约 100 个字符左右的简单键组合。更复杂的键组合可能创建另外大约 100 个不太流行的字符。这甚至不足以涵盖世界上所有字母表中的字符领域。而且,在我们的计算机字体中还有大量的图标、表情符号和装饰符号。我们如何访问所有这些符号呢?

1.6.1 准备工作

Python 使用 Unicode。有数千个独立的 Unicode 字符可用。

我们可以在 en.wikipedia.org/wiki/List_of_Unicode_characters 以及 www.unicode.org/charts/ 看到所有可用的字符。

我们需要 Unicode 字符编号。我们可能还需要 Unicode 字符名称。

我们计算机上的某个字体可能没有设计为为所有这些字符提供符号。特别是,Windows 计算机字体可能难以显示这些字符中的某些字符。有时需要使用以下 Windows 命令来切换到代码页 65001:

chcp 65001

Linux 和 macOS 很少出现 Unicode 字符问题。

1.6.2 如何操作...

Python 使用转义序列来扩展我们可以输入的普通字符,以覆盖 Unicode 字符的广阔空间。每个转义序列都以一个 \ 字符开始。下一个字符告诉我们需要创建哪个 Unicode 字符。找到所需的字符。获取名称或数字。数字总是以十六进制形式给出,基数为 16。描述 Unicode 的网站通常将字符写作 U+2680。名称可能是 DIE FACE-1。使用 \unnnn,其中 nnnn 是最多四位数的数字。或者,使用 \N{name},其中包含完整的名称。如果数字超过四位,使用 \Unnnnnnnn,将数字填充到正好八位:

>>> ’You Rolled \u2680’ 

’You Rolled ’ 
 >>> ’You drew \U0001F000’ 

’You drew ’ 
 >>> ’Discard \N{MAHJONG TILE RED DRAGON}’ 

’Discard ’

是的,我们可以在 Python 输出中包含各种字符。要在字符串中放置一个反斜杠(\)而不让后面的字符成为转义序列的一部分,我们需要使用\. 例如,我们可能需要这样做来表示 Windows 文件路径。

1.6.3 它是如何工作的...

Python 在内部使用 Unicode。我们可以直接使用键盘输入的 128 个左右字符都有方便的内部 Unicode 数字。

当我们写:

’HELLO’

Python 将其视为以下简写:

’\u0048\u0045\u004c\u004c\u004f’

一旦我们超出键盘上的字符,剩下的数千个字符仅通过它们的数字来识别。

当字符串被 Python 编译时,\uxxxx、\Uxxxxxxxx 和 \N{name} 都会被替换为正确的 Unicode 字符。如果我们有语法错误——例如,\N{name with no closing }——Python 的内部语法检查会立即报错。

正则表达式使用了很多 \ 字符,并且我们明确不希望 Python 的正常编译器触及它们;我们在正则表达式字符串上使用了 r’ 前缀,以防止 \ 被视为转义并可能被转换为其他内容。要使用 Unicode 字符的全域,我们无法避免使用 \ 作为转义。

如果我们需要在正则表达式中使用 Unicode,我们需要在正则表达式中到处使用 \。我们可能会看到类似这样的:’\w+[\u2680\u2681\u2682\u2683\u2684\u2685]\d+’。

我们不能在字符串上使用 r’ 前缀,因为我们需要处理 Unicode 转义。这迫使我们使用 \ 作为正则表达式的元素。我们使用了 \uxxxx 来表示模式中的 Unicode 字符。Python 的内部编译器会将 \uxxxx 替换为 Unicode 字符,而 \w 将在内部变为所需的 \w。

当我们在 >>> 提示符下查看字符串时,Python 会以规范形式显示字符串。Python 倾向于使用单引号(')作为分隔符,当字符串包含单引号时使用双引号(")。在编写代码时,我们可以使用单引号或双引号作为字符串分隔符。Python 通常不会显示原始字符串;相反,它会将所有必要的转义序列放回字符串中:

>>> r"\w+" 

’\\w+’

我们提供了一个原始格式的字符串。Python 以规范形式显示了它。

1.6.4 参见

  • 在编码字符串 – 创建 ASCII 和 UTF-8 字节和解码字节 – 如何从某些字节中获取正确的字符的菜谱中,我们将探讨 Unicode 字符是如何转换成字节序列的,这样我们就可以将它们写入文件。我们还将探讨文件中的字节(或从网站下载的)是如何转换成 Unicode 字符,以便进行处理。

  • 如果你对历史感兴趣,你可以在这里了解 ASCII 和 EBCDIC 以及其他旧式字符编码:www.unicode.org/charts/

1.7 编码字符串 – 创建 ASCII 和 UTF-8 字节

我们的计算机文件是字节。当我们从互联网上传或下载时,通信是以字节为单位的。一个字节只有 256 个不同的值。我们的 Python 字符是 Unicode。Unicode 字符的数量远远超过 256 个。

我们如何将 Unicode 字符映射到字节以写入文件或进行传输?

1.7.1 准备工作

从历史上看,一个字符占用 1 个字节。Python 利用旧的 ASCII 编码方案来处理字节;这有时会导致字节和 Unicode 字符的文本字符串之间的混淆。

Unicode 字符被编码成一系列的字节。有许多标准化的编码和许多非标准化的编码。

此外,还有一些编码只适用于 Unicode 字符的一个小子集。我们尽量避免使用这些编码,但在某些情况下,我们可能需要使用子集编码方案。

除非我们有充分的理由不这样做,否则我们几乎总是使用 UTF-8 编码来表示 Unicode 字符。它的主要优点是它是对拉丁字母的紧凑表示,拉丁字母被用于英语和许多欧洲语言。

有时,一个互联网协议需要 ASCII 字符。这是一个需要特别注意的特殊情况,因为 ASCII 编码只能处理 Unicode 字符的一个小子集。

1.7.2 如何操作...

Python 通常使用我们操作系统的默认编码来处理文件和互联网流量。这些细节对每个操作系统都是独特的:

  1. 我们可以使用 PYTHONIOENCODING 环境变量来设置一个通用设置。我们将其设置在 Python 之外,以确保在所有地方都使用特定的编码。当使用 Linux 或 macOS 时,使用 shell 的 export 语句来设置环境变量。对于 Windows,使用 set 命令或 PowerShell 的 Set-Item 命令。对于 Linux,它看起来像这样:

    (cookbook3) % export PYTHONIOENCODING=UTF-8
    
  2. 运行 Python:

    (cookbook3) % python
    
  3. 当我们在脚本内部打开文件时,我们有时需要做出特定的设置。我们将在第十一章中回到这个话题。以指定的编码打开文件。向文件中读取或写入 Unicode 字符:

    >>> with open(’some_file.txt’, ’w’, encoding=’utf-8’) as output: 
    
    ...     print(’You drew \U0001F000’, file=output) 
    
    >>> with open(’some_file.txt’, ’r’, encoding=’utf-8’) as input: 
    
    ...     text = input.read() 
    
    >>> text 
    
    ’You drew ’
    

在极少数需要以字节模式打开文件的情况下,我们也可以手动编码字符;如果我们使用 wb 模式,我们还需要对每个字符串进行手动编码:

>>> string_bytes = ’You drew \U0001F000’.encode(’utf-8’) 

>>> string_bytes 

b’You drew \xf0\x9f\x80\x80’

我们可以看到,一个字节序列 (\xf0\x9f\x80\x80) 被用来编码单个 Unicode 字符,U+1F000,PIC

1.7.3 它是如何工作的...

Unicode 定义了多种编码方案。虽然 UTF-8 是最流行的,但也有 UTF-16 和 UTF-32。数字是每个字符的典型位数。一个包含 1,000 个字符的 UTF-32 编码文件将是 4,000 个 8 位字节。一个包含 1,000 个字符的 UTF-8 编码文件可能只有 1,000 个字节,具体取决于字符的确切组合。在 UTF-8 编码中,Unicode 编号高于 U+007F 的字符需要多个字节。

各种操作系统都有自己的编码方案。macOS 文件可以编码为 Mac Roman 或 Latin-1。Windows 文件可能使用 CP1252 编码。

所有这些方案的目的都是为了有一个可以映射到 Unicode 字符的序列的字节,以及反过来将每个 Unicode 字符映射到一个或多个字节的方法。理想情况下,所有 Unicode 字符都应该被考虑到。实际情况下,这些编码方案中的一些是不完整的。

ASCII 编码的历史形式只能表示大约 100 个 Unicode 字符作为字节。很容易创建一个无法使用 ASCII 方案编码的字符串。

这就是错误的样子:

>>> ’You drew \U0001F000’.encode(’ascii’) 

Traceback (most recent call last): 

... 

UnicodeEncodeError: ’ascii’ codec can’t encode character ’\U0001f000’ in position 9: ordinal not in range(128

当我们意外地以不是广泛使用的 UTF-8 标准的编码打开文件时,我们可能会看到这种错误。当我们看到这种错误时,我们需要更改我们的处理过程,以选择创建文件实际使用的编码。猜测使用了哪种编码几乎是不可能的,因此可能需要进行一些研究以定位关于文件编码的元数据。

字节通常使用可打印字符来显示。我们将看到 b’hello’ 作为五个字节的值的缩写。字母是使用旧的 ASCII 编码方案选择的,其中从 0x20 到 0x7F 的字节值将显示为字符,而在此范围之外,将使用更复杂的转义序列。

这种用字符表示字节值的方法可能会令人困惑。b’ 前缀是我们的提示,表明我们正在查看字节,而不是正确的 Unicode 字符。

1.7.4 参见

  • 有多种构建数据字符串的方法。请参阅 使用 f-strings 构建复杂的字符串 和 从字符串列表构建复杂的字符串 的配方,以了解创建复杂字符串的示例。其思想是,我们可能有一个构建复杂字符串的应用程序,然后将其编码为字节。

  • 有关 UTF-8 编码的更多信息,请参阅 en.wikipedia.org/wiki/UTF-8

  • 有关 Unicode 编码的一般信息,请参阅 unicode.org/faq/utf_bom.html

1.8 解码字节 - 如何从一些字节中获取正确的字符

我们如何处理未正确编码的文件?对于使用 ASCII 编码编写的文件,我们该怎么办?

从互联网下载的内容几乎总是以字节形式存在——不是字符。我们如何从这串字节流中解码字符?

此外,当我们使用 subprocess 模块时,操作系统命令的结果是以字节形式出现的。我们如何恢复正确的字符?

这其中大部分内容也与第十一章的材料相关。我们在这里包含这个配方,因为它与之前的配方相反,编码字符串——创建 ASCII 和 UTF-8 字节。

1.8.1 准备工作

假设我们对离岸海洋天气预报感兴趣。这可能是因为我们即将离开切萨皮克湾前往加勒比海。

弗吉尼亚州韦克菲尔德的国家气象服务机构是否有任何特殊的警告?

这是链接:forecast.weather.gov/product.php?site=AKQ&product=SMW&issuedby=AKQ

我们可以使用 Python 的 urllib 模块下载它:

>>> import urllib.request 

>>> warnings_uri = ( 

...     ’https://forecast.weather.gov/’ 

...     ’product.php?site=AKQ&product=SMW&issuedby=AKQ’ 

... ) 
 >>> with urllib.request.urlopen(warnings_uri) as source: 

...     forecast_text = source.read()

注意,我们已经将 URI 字符串放在括号中,并将其分成两个单独的字符串字面量。Python 会将这两个相邻的字面量连接成一个单一的字符串。我们将在第二章中深入探讨这一点。

作为替代,我们可以使用 curl 或 wget 等程序来获取这些信息。在操作系统终端提示符下,我们可能会运行以下(长)命令:

(cookbook3) % curl ’https://forecast.weather.gov/product.php?site=AKQ&product=SMW&issuedby=AKQ’ -o AKQ.html

排版这本书时,往往会将命令拆分成多行。实际上,它是一行非常长的命令。

代码库包括一个示例文件,ch01/Text Products for SMW Issued by AKQ.html。

forecast_text 值是一串字节。它不是一个正确的字符串。我们可以通过它像这样开始来判断:

>>> forecast_text[:80] 

b’<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/x’

数据持续了一段时间,从网页上提供了详细信息。因为显示的值以 b'开头,所以它是字节,而不是正确的 Unicode 字符。它可能被 UTF-8 编码,这意味着一些字符可能用奇特的\xnn 转义序列代替了正确的字符。我们想要的是正确的字符。

虽然这些数据有许多易于阅读的字符,但 b'前缀表明它是一组字节值,而不是正确的文本。一般来说,字节对象的行为类似于字符串对象。有时,我们可以直接处理字节。大多数时候,我们想要解码字节并从中创建正确的 Unicode 字符。

1.8.2 如何操作...

  1. 如果可能,确定编码方案。为了将字节解码成正确的 Unicode 字符,我们需要知道使用了哪种编码方案。当我们读取 XML 文档时,文档内部提供了很大的提示:

    <?xml version="1.0" encoding="UTF-8"?>
    

    在浏览网页时,通常会有一个包含此信息的头部:

    Content-Type: text/html; charset=ISO-8859-4
    

    有时,一个 HTML 页面可能将此作为头部的一部分包含:

    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    

    在其他情况下,我们只能猜测。在处理美国天气数据的情况下,一个好的初步猜测是 UTF-8。另一个好的猜测是 ISO-8859-1。在某些情况下,猜测将取决于语言。

  2. codecs — Python 标准库中的编解码器注册表和基类部分列出了可用的标准编码。解码数据:

    >>> document = forecast_text.decode("UTF-8") 
    
    >>> document[:80] 
    
    ’<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/x’
    

    b'前缀不再用于表示这些是字节。我们已经从字节流中创建了一个正确的 Unicode 字符字符串。

  3. 如果这一步因异常而失败,我们可能猜错了编码。我们需要尝试另一种编码来解析生成的文档。

由于这是一个 HTML 文档,我们应该使用 Beautiful Soup 来提取数据。请参阅www.crummy.com/software/BeautifulSoup/

然而,我们可以在不完整解析 HTML 的情况下从这个文档中提取一条信息:

>>> import re 

>>> content_pattern = re.compile(r"// CONTENT STARTS(.*?)// CONTENT ENDS", re.MULTILINE | re.DOTALL) 

>>> content_pattern.search(document) 

<re.Match object; span=(8530, 9113), match=’// CONTENT STARTS HERE -->\n\n<span style="font-s>

这告诉我们我们需要知道的信息:目前没有警告。这并不意味着一切顺利,但这确实意味着没有可能引发灾难的主要天气系统。

1.8.3 它是如何工作的...

请参阅编码字符串 – 创建 ASCII 和 UTF-8 字节配方,了解更多关于 Unicode 及其不同方式的信息,这些方式可以将 Unicode 字符编码成字节流。

在操作系统的底层,文件和网络连接是由字节构建的。是我们的软件解码字节以发现内容。可能是字符、图像或声音。在某些情况下,默认的假设是错误的,我们需要进行自己的解码。

1.8.4 参考信息

  • 一旦我们恢复了字符串数据,我们就有多种方式来解析或重写它。请参阅使用正则表达式进行字符串解析配方中的示例,了解如何解析复杂字符串。

  • 更多关于编码的信息,请参阅en.wikipedia.org/wiki/UTF-8unicode.org/faq/utf_bom.html

1.9 使用项目元组

最好的方式来表示简单的(x,y)和(r,g,b)值组是什么?我们如何将成对的值,例如纬度和经度,放在一起?

1.9.1 准备工作

在使用正则表达式进行字符串解析配方中,我们跳过了一个有趣的数据结构。

我们有如下看起来这样的数据:

>>> ingredient = "Kumquat: 2 cups"

我们使用正则表达式解析了这些有意义的数据,如下所示:

>>> import re 

>>> ingredient_pattern = re.compile(r’(?P<ingredient>\w+):\s+(?P<amount>\d+)\s+(?P<unit>\w+)’) 

>>> match = ingredient_pattern.match(ingredient) 

>>> match.groups() 

(’Kumquat’, ’2’, ’cups’)

结果是一个包含三份数据的元组对象。这种分组数据在很多地方都能派上用场。

1.9.2 如何操作...

我们将从这个方面探讨两个问题:将事物放入元组和从元组中取出事物。

创建元组

Python 在很多地方都会为我们创建数据元组。在使用正则表达式进行字符串解析配方的准备工作部分,我们向您展示了正则表达式匹配对象将如何从字符串中解析出文本元组。

我们也可以创建自己的元组。以下是步骤:

  1. 将数据括在括号()内。

  2. 使用逗号分隔项目。

    >>> from fractions import Fraction 
    
    >>> my_data = (’Rice’, Fraction(1/4), ’cups’)
    

对于单元素元组,有一个重要的特殊情况。即使元组中只有一个项目,我们也必须包含逗号:

>>> one_tuple = (’item’, ) 

>>> len(one_tuple) 

1

括号字符并不总是必需的。有些时候我们可以省略它们。省略它们并不是一个好主意。

是逗号创建了值的元组。这意味着当我们有一个额外的逗号时,我们可能会看到一些奇怪的事情:

>>> 355, 

(355,)

355 后面的逗号将值转换成单元素元组。

我们还可以通过从另一个序列转换来创建元组。例如,tuple([355])从一个单元素列表创建一个单元素元组。

从元组中提取项目

元组的概念是成为一个容器,其项目数量由问题域固定:例如,对于(red, green, blue)颜色编号,项目数量始终是三个。

在我们的例子中,我们有一个成分,数量和单位。这必须是一个三项集合。我们可以以两种方式查看单个项目:

  • 通过索引位置;也就是说,位置从左到右开始编号,从零开始:

    >>> my_data[1] 
    
    Fraction(1, 4)
    
  • 使用多重赋值:

    >>> ingredient, amount, unit =  my_data 
    
    >>> ingredient 
    
    ’Rice’ 
    
    >>> unit 
    
    ’cups’
    

元组——就像字符串一样——是不可变的。我们无法更改元组内部的单个项目。当我们想要将数据放在一起时,我们会使用元组。

1.9.3 它是如何工作的...

元组是更一般序列类的一个例子。我们可以对序列做几件事情。

这里有一个我们可以操作的示例元组:

>>> t = (’Kumquat’, ’2’, ’cups’)

这里有一些我们可以对这个元组执行的操作:

  • t 中有多少个项目?

    >>> len(t) 
    
    3
    
  • 特定值在 t 中出现了多少次?

    >>> t.count(’2’) 
    
    1
    
  • 哪个位置有特定值?

    >>> t.index(’cups’) 
    
    2 
    
    >>> t[2] 
    
    ’cups’
    
  • 当一个项目不存在时,我们会得到一个异常:

    >>> t.index(’Rice’) 
    
    Traceback (most recent call last): 
    
    ... 
    
    ValueError: tuple.index(x): x not in tuple
    
  • 特定值是否存在?

    >>> ’Rice’ in t 
    
    False
    

1.9.4 更多...

元组,就像字符串一样,是一系列项目的序列。在字符串的情况下,它是一系列字符的序列。在元组的情况下,它是一系列许多事物的序列。因为它们都是序列,所以它们有一些共同的特征。我们已经注意到,我们可以通过索引位置提取单个项目。我们可以使用 index()方法来定位项目的位置。

相似之处到此为止。字符串有许多方法可以用来创建一个新的字符串,这个新字符串是原始字符串的转换,还有解析字符串的方法,以及确定字符串内容的方法。元组没有这些附加功能。它——可能是——最简单的可能的数据结构。

1.9.5 参见

  • 我们在从字符串列表构建复杂的字符串配方中查看了一个其他序列,列表。

  • 我们还会在第四章中查看序列。

1.10 使用 NamedTuples 简化元组中的项目访问

当我们处理元组时,我们必须记住位置作为数字。当我们使用(r,g,b)元组来表示颜色时,我们可以用"red"代替零,用"green"代替 1,用"blue"代替 2 吗?

1.10.1 准备工作

让我们继续查看食谱中的项目。解析字符串的正则表达式有三个属性:成分、数量和单位。我们使用了以下模式,为各种子字符串命名:

r’(?P<ingredient>\w+):\s+(?P<amount>\d+)\s+(?P<unit>\w+)’)

生成的数据元组看起来是这样的:

>>> item = match.groups() 

>>> item 

(’Kumquat’, ’2’, ’cups’)

虽然成分、数量和单位的匹配相当清晰,但使用以下方式并不理想。1 代表什么?它真的是数量吗?

>>> from fractions import Fraction 

>>> Fraction(item[1]) 

Fraction(2, 1)

我们希望定义带有名称和位置的元组。

1.10.2 如何做到...

  1. 我们将使用来自 typing 包的 NamedTuple 类定义:

    >>> from typing import NamedTuple
    
  2. 使用这个基类定义,我们可以定义我们自己的独特元组,为项目命名:

    >>> class Ingredient(NamedTuple): 
    
    ...     ingredient: str 
    
    ...     amount: str 
    
    ...     unit: str
    
  3. 现在,我们可以通过使用类名来创建这种独特类型的元组实例:

    >>> item_2 = Ingredient(’Kumquat’, ’2’, ’cups’)
    
  4. 当我们想要从元组中获取一个值时,我们可以使用名称而不是位置:

    >>> Fraction(item_2.amount) 
    
    Fraction(2, 1) 
    
    >>> f"Use {item_2.amount} {item_2.unit} fresh {item_2.ingredient}" 
    
    ’Use 2 cups fresh Kumquat’
    

1.10.3 它是如何工作的...

NamedTuple 类定义引入了第七章的核心概念。我们扩展了基类定义,为我们的应用程序添加了独特功能。在这种情况下,我们为每个成分元组必须包含的三个属性命名。

由于 NamedTuple 类的子类是元组,属性名称的顺序是固定的。我们可以使用类似 item_2[0]的表达式以及 item_2.ingredient 这样的表达式。这两个名称都指的是元组中索引为 0 的项目,item_2。

核心元组类型可以称为“匿名元组”或“仅索引元组”。这有助于将它们与通过 typing 模块引入的更复杂的“命名元组”区分开来。

元组作为紧密相关数据的微型容器非常有用。使用 NamedTuple 类定义使它们更容易处理。

1.10.4 更多内容...

我们可以在元组或命名元组中混合使用各种值。在我们构建元组之前,我们需要进行转换。重要的是要记住,元组永远不能被更改。它是一个不可变对象,在许多方面与字符串和数字的不可变性相似。

例如,我们可能想要处理精确分数的金额。这里有一个更复杂的定义:

>>> from typing import NamedTuple 

>>> from fractions import Fraction 

>>> class IngredientF(NamedTuple): 

...     ingredient: str 

...     amount: Fraction 

...     unit: str

这些对象在创建时需要一些小心。如果我们使用一大堆字符串,我们不能简单地从三个字符串值构建这个对象;我们需要将数量转换为 Fraction 实例。以下是一个使用 Fraction 转换创建项目的示例:

>>> item_3 = IngredientF(’Kumquat’, Fraction(’2’), ’cups’)

这个元组对于每种成分的数量来说更有用。我们现在可以对数量进行数学运算:

>>> f’{item_3.ingredient} doubled: {item_3.amount * 2}’ 

’Kumquat doubled: 4’

在 NamedTuple 类定义中明确声明数据类型非常方便。实际上,Python 并不直接使用类型信息。例如,mypy 这样的工具可以检查 NamedTuple 中的类型提示与代码中其他操作的一致性。

1.10.5 参见

  • 我们将在第七章中查看类定义。

加入我们的社区 Discord 空间

加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

PIC

第二章:2

语句和语法

Python 语法被设计得非常简单。在本章中,我们将通过查看语言中最常用的语句来了解规则。具体的例子可以帮助阐明语言的语法。

我们将首先介绍创建脚本文件的一些基础知识。然后我们将转向查看一些更常用的语句。Python 在语言中只有大约 20 种不同的命令式语句。我们已经在第一章(ch005_split_000.xhtml#x1-170001)中看到了两种语句,即赋值语句和表达式语句。

当我们编写如下内容时:

>>> print("hello world") 

hello world

我们实际上执行的是一个只包含函数评估的语句,即 print()。这种类型的语句——评估一个函数或对象的某个方法——是很常见的。

我们已经看到的其他类型的语句是赋值语句。Python 在这方面有很多变体。大多数时候,我们都是将单个值赋给单个变量。当一个函数返回一个元组作为结果时,我们可以解包这个集合,并在单个赋值语句中同时赋值给多个变量。这样做如下:

>>> quotient, remainder = divmod(355, 113)

本章中的食谱将探讨 if、while、for、with 和 try 语句。我们还将简要介绍一些更简单的语句,如 pass、break 和 raise。

在后面的章节中,我们将探讨其他语句。以下是一个总结:

|


|


|

语句 章节

|


|


|

def 第三章(ch007_split_000.xhtml#x1-1610003)
return 第三章(ch007_split_000.xhtml#x1-1610003)
import 第三章(ch007_split_000.xhtml#x1-1610003)
del 第四章(ch008_split_000.xhtml#x1-2240004)
class 第七章(ch011_split_000.xhtml#x1-3760007)
match 第八章(ch012.xhtml#x1-4520008)
type 第十章(ch014.xhtml#x1-57300010)
assert 第十章(ch014.xhtml#x1-57300010)

|


|


|

表 2.1:Python 语句和章节

在本章中,我们将探讨以下内容:

  • 编写 Python 脚本和模块文件——语法基础

  • 编写长行代码

  • 包括描述和文档

  • 使用 RST 标记编写更好的 docstrings

  • 设计复杂的 if...elif 链

  • 使用“walrus”运算符保存中间结果

  • 避免 break 语句的潜在问题

  • 利用异常匹配规则

  • 避免 except 子句的潜在问题

  • 隐藏异常的根本原因

  • 使用 with 语句管理上下文

我们将从宏观的角度开始,即脚本和模块,然后我们将深入到单个语句的细节。

2.1 编写 Python 脚本和模块文件 – 语法基础

Python(以及一般编程)的目的是创建涉及数据和处理的自动化解决方案。此外,我们编写的软件是一种知识表示;这意味着清晰性可能是软件最重要的质量方面。

在 Python 中,我们通过创建脚本文件来实现自动化解决方案。这些是 Python 编程的顶级、主要程序。除了主脚本外,我们还可以创建模块(以及模块的包)来帮助将软件组织成智力上可管理的块。脚本是一个模块;然而,它有一个明确的意图,当由操作系统启动时执行有用的处理。

创建清晰、可读的 Python 文件的关键部分是确保我们的代码遵循广泛采用的约定。

例如,我们需要确保将文件以 UTF-8 编码保存。虽然 Python 仍然支持 ASCII 编码,但它对于现代编程来说是一个较差的选择。我们还需要确保我们的编辑器使用空格而不是制表符。这通常是编程编辑器中的一个配置设置。使用 Unix 换行符也有助于可移植性。

2.1.1 准备工作

为了编辑 Python 脚本,我们需要一个好的编程编辑器。几乎不可能只推荐一个。所以我们将推荐几个。

JetBrains PyCharm 编辑器具有许多功能。社区版是免费的。请参阅www.jetbrains.com/pycharm/download/

ActiveState 的 Komodo IDE 非常复杂。Komodo Edit 版本是免费的,并且与完整的 Komodo IDE 做了一些相同的事情。请参阅komodoide.com/komodo-edit/

Notepad++非常适合 Windows 开发者。请参阅notepad-plus-plus.org

BBEdit 非常适合 macOS X 开发者。请参阅www.barebones.com/products/bbedit/。Sublime 在 macOS X 上也很受欢迎。请参阅www.sublimetext.com

对于 Linux 开发者,有几个内置的编辑器,包括 Vim 和 gedit。由于 Linux 倾向于偏向开发者,因此可用的编辑器都适合编写 Python。

在工作时有两个窗口打开是有帮助的:

  • 一个编辑器来创建最终的脚本或模块文件。

  • 一个带有 Python 的>>>提示符的终端会话,我们可以尝试一些事情来看看哪些可行,哪些不可行。

大多数编辑器都识别.py 扩展名,并根据PEP-8提供适当的格式化。这通常包括以下内容:

  • 文件编码应该是 UTF-8。

  • 缩进应该是四个空格。

  • 我们希望键盘上的 Tab 键插入空格而不是制表符,\t。

一旦配置了编辑器,我们就可以编写一个其他人可以轻松使用或扩展的脚本文件。

2.1.2 如何做到...

这是创建脚本文件的方法:

  1. 大多数 Python 脚本文件的第一行看起来像这样:

    #!/usr/bin/env python3
    

    这在您正在编写的文件和 Python 之间设置了一个关联。如果文件的权限设置为可执行,并且目录在 OS PATH 列表中,那么脚本将是一个一等应用,就像任何内置命令一样可用。

    对于 Windows,文件名到程序的关联是通过默认程序控制面板中的一个设置完成的。找到设置关联的面板,并确保 .py 文件绑定到 Python 程序。这通常由安装程序设置,我们很少需要更改它或手动设置它。

  2. 在前言之后,惯例建议我们包括一个三引号文本块。这是我们即将创建的文件的文档字符串(称为 docstring):

    """ 
    
    A summary of this script. 
    
    """
    

    由于 Python 的三引号字符串可以无限长,请随意写尽可能多的内容。这应该是描述脚本或库模块的主要工具。这甚至可以包括如何工作的示例。

  3. 现在是脚本的有趣部分:真正做事情的这部分。我们可以编写完成工作所需的所有语句。目前,我们将使用它作为占位符:

    print(’hello world’)
    

    这并不多,但至少脚本做了些事情。创建函数和类定义,以及编写使用函数和类做事情的语句是很常见的。

对于我们的第一个简单脚本,所有的语句都必须从左边界开始,并且必须在单行内完成。Python 中有许多语句包含嵌套的语句块。这些内部语句块将被缩进来明确它们的范围。通常——因为我们把缩进设置为四个空格——我们可以按 Tab 键来正确地缩进代码。

我们文件应该看起来像这样:

#!/usr/bin/env python3 

""" 

My First Script: Calculate an important value. 

""" 

print(355 / 113)

2.1.3 它是如何工作的...

与其他语言不同,Python 中几乎没有样板代码。只有一行开销,甚至 #!/usr/bin/env python3 行通常是可选的。

为什么我们要将编码设置为 UTF-8?虽然该语言最初是为了仅使用原始的 128 个 ASCII 字符而设计的,但我们经常发现 ASCII 是有限的。如果我们以 UTF-8 格式保存文件,这是合法的 Python:

 = 355/113 

print()

在 Python 中选择空格和制表符时保持一致性很重要。它们都是或多或少不可见的,混合使用它们很容易在尝试运行脚本时出错。建议使用空格。

初始的#!行是一个注释。因为这两个字符有时被称为 sharp 和 bang,所以组合被称为“shebang”。#和行尾之间的所有内容都被忽略。Linux 加载器(一个名为 execve 的程序)查看文件的第一个几个字节以确定文件内容。这些第一个几个字节有时被称为 magic bytes,因为加载器的行为看起来很神奇。当存在时,这个由#!组成的两字符序列后面跟着处理文件中其余数据的程序的路径。我们更喜欢使用/usr/bin/env 为我们启动 Python 程序。我们可以利用 env 程序来设置 Python 特定的环境变量。

2.1.4 更多内容...

Python 标准库文档部分来源于模块文件中存在的文档字符串。在模块、包和脚本中编写复杂的 docstrings 是常见做法。有像 pydoc 和 Sphinx 这样的工具可以将模块 docstrings 重新格式化为优雅的文档。我们将在使用 RST 标记编写更好的 docstrings 的菜谱中查看这一点,以及第十七章中的使用 Sphinx autodoc 创建 API 参考的菜谱。

此外,单元测试用例可以包含在 docstrings 中。像 doctest 这样的工具可以从文档字符串中提取示例并执行代码,以查看文档中的答案是否与运行代码找到的答案匹配。这是第十五章中许多菜谱的主题。本书中的许多示例都经过 doctest 验证。

三引号文档字符串比#注释更受欢迎。虽然#和行尾之间的所有文本都被忽略,但这仅限于单行;传统做法是尽量少用。docstring 可以是不定长度的;它们被广泛使用。

有时还会包含一些额外的开销。Vim 和 gedit 编辑器允许我们在文件中保留编辑偏好。这被称为 modeline。以下是一个典型的 modeline,对 Python 很有用:

# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

这确保任何制表符字符都将转换为八个空格;当我们按下 Tab 键时,我们将移动四个空格。这被广泛使用,因为传统上制表符字符缩进八个空格,这种替换很可能创建正确的缩进。这个设置嵌入在代码中;我们不需要对 Vim 进行任何设置来将这些设置应用到我们的 Python 脚本文件中。

2.1.5 参考阅读

  • 我们将在包括描述和文档和使用 RST 标记编写更好的 docstrings 的菜谱中查看如何编写有用的文档字符串。

  • 关于建议的样式更多信息,请参阅PEP-8

2.2 编写长行代码

有很多次我们需要编写非常长的代码行,以至于很难阅读。很多人喜欢将代码行的长度限制在 80 个字符或更少。图形设计的一个众所周知的原则是,较窄的文本区域更容易阅读。有关行宽和可读性的更深入讨论,请参阅 webtypography.net/2.1.2

尽管每行字符较少对眼睛更友好,但我们的代码可能会拒绝遵守这个原则。我们如何将长 Python 语句拆分成更易管理的部分?

2.2.1 准备工作

假设我们有一些像这样的事情:

>>> import math
>>> example_value = (63/25) * (17+15*math.sqrt(5)) / (7+15*math.sqrt(5))
>>> mantissa_fraction, exponent = math.frexp(example_value)
>>> mantissa_whole = int(mantissa_fraction*2**53)
>>> message_text = f’the internal representation is {mantissa_whole:d}/2**53*2**{exponent:d}’
>>> print(message_text) 

the internal representation is 7074237752514592/2**53*2**2

这段代码包含一个长公式和一个长格式字符串,我们将值注入其中。当在书中排版时,这看起来很糟糕;f-string 行可能被错误地断行。当尝试编辑此脚本时,它可能看起来很糟糕。(有关 f-string 的更多信息,请参阅第一章的 构建复杂的字符串与 f-string)。

我们不能随意将 Python 语句拆分成块。语法规则明确指出,一个语句必须在单个逻辑行上完整。

“逻辑行”这个术语为我们提供了如何继续进行的线索。Python 在逻辑行和物理行之间做出了区分;我们将利用这些语法规则来拆分长语句。

2.2.2 如何操作...

Python 给我们提供了几种方法来包装长语句,使它们更易读:

  • 我们可以在一行的末尾使用 \ 来将逻辑行延续到下一物理行。虽然这总是可行的,但有时候很难找到 \。

  • Python 有一个规则,即一个语句可以跨越多个逻辑行,因为括号、方括号和花括号必须平衡。此外,我们还可以利用 Python 自动连接相邻字符串字面量以形成一个较长的字符串字面量的方式:("a" "b") 与 "ab" 相同。

  • 在某些情况下,我们可以通过将中间结果分配给不同的变量来将一个语句分解成多个语句。

我们将在这份食谱的单独部分逐一查看这些内容。

使用反斜杠将长语句拆分为逻辑行

  1. 如果有有意义的断行,插入 \ 来分隔语句:

    >>> message_text = f’the internal representation is \ 
    
    ... {mantissa_whole:d}/2**53*2**{exponent:d}’
    

为了使这可行,\ 必须是行的最后一个字符。反斜杠后面的额外空格很难看到;需要一些小心。PEP-8 www.python.org/dev/peps/pep-0008/ 建议提供了格式化指南,并倾向于不鼓励这种技术。

尽管这有点难以看清,但 \ 总是可以使用的。把它看作是使代码行更易读的最后一招。

使用括号将长语句拆分成合理的部分

  1. 即使令人困惑,也要将整个语句写在一行上:

    >>> import math  >>> example_value1 = (63/25) * (17+15*math.sqrt(5)) / (7+15*math.sqrt(5))
    

    添加额外的括号,这些括号不会改变值,但允许将表达式拆分成多行:

    >>> example_value2 = (63/25) * ( (17+15*math.sqrt(5)) / (7+15*math.sqrt(5)) )  >>> example_value2 == example_value1 
    
    True
    
  2. 在括号内断行:

    >>> example_value3 = (63/25) * ( 
    
    ...     (17+15*math.sqrt(5)) 
    
    ...     / (7+15*math.sqrt(5)) 
    
    ... ) 
    
    >>> example_value3 == example_value1 
    
    True
    

匹配()字符的技术非常强大,并且可以在许多情况下工作。这被广泛使用,并且强烈推荐。

我们几乎总是可以找到一种方法在语句中添加额外的()字符。在极少数情况下,当我们不能添加()字符时,我们可以退回到使用\来将语句分成几个部分。

使用字符串字面量连接

我们可以将()字符与另一个规则结合起来,该规则连接相邻的字符串字面量。这对于长而复杂的格式字符串特别有效:

  1. 将长字符串值包裹在()字符中。

  2. 将字符串分解成有意义的子字符串:

    >>> message_text = ( 
    
    ... f’the internal representation ’ 
    
    ... f’is {mantissa_whole:d}/2**53*2**{exponent:d}’ 
    
    ... ) 
    
    >>> message_text 
    
    ’the internal representation is 7074237752514592/2**53*2**2’
    

我们总是可以将长字符串字面量分解成相邻的部分。然后我们可以使用我们需要的物理行断行。对于字符串字面量值,不需要显式运算符。

将中间结果分配给单独的变量

这里是这个技术的背景:

>>> import math
>>> example_value = (63/25) * (17+15*math.sqrt(5)) / (7+15*math.sqrt(5)) 

我们可以将这个表达式分解成三个中间值:

  1. 在整体表达式中识别子表达式。将这些分配给变量:

    >>> a = (63/25) 
    
    >>> b = (17+15*math.sqrt(5)) 
    
    >>> c = (7+15*math.sqrt(5))
    
  2. 用创建的变量替换子表达式:

    >>> example_value = a * b / c
    

我们总是可以将子表达式分配给变量,并在子表达式被使用的地方使用该变量。15*sqrt(5)的乘积被重复;这也是重构表达式的良好候选。

我们没有给出这些变量有描述性的名称。在某些情况下,子表达式有一些语义,我们可以用有意义的名称来捕捉。

2.2.3 它是如何工作的...

Python 语言手册在逻辑行和物理行之间做出了区分。一个逻辑行包含一个完整的语句。它可以通过称为行连接的技术跨越多个物理行。手册确定了两种技术:显式行连接和隐式行连接。

使用\进行显式行连接有时是有帮助的。因为它很容易被忽视,所以通常不鼓励这样做。PEP-8建议这应该是最后的手段。

在许多情况下可以使用()进行隐式行连接。它通常与表达式的结构在语义上相匹配,因此是鼓励的。

2.2.4 更多...

表达式在许多 Python 语句中被广泛使用。任何表达式都可以添加()字符。这给了我们很多灵活性。

然而,有几个地方我们可能有一个长的语句,它并不特别涉及长的表达式。最显著的例子是导入语句——它可以变得很长,但并不使用任何表达式。尽管没有适当的表达式,但它确实仍然允许使用()。以下示例显示我们可以围绕一个非常长的导入名称列表:

>>> from math import ( 

...     sin, cos, tan, 

...     sqrt, log, frexp)

虽然()字符绝对不是表达式的一部分,但它们是可用的语法的一部分,有助于使语句更易于阅读。

2.2.5 参见

  • 隐式行连接也适用于匹配的[]和{}字符。这些适用于我们在第四章中将要查看的集合数据结构。

2.3 包含描述和文档

当我们有一个有用的脚本时,我们经常需要为自己和其他人留下关于它做什么、如何解决某些特定问题以及何时应该使用的笔记。这个配方包含一个建议的大纲,以帮助使文档合理完整。

2.3.1 准备工作

如果我们使用了编写 Python 脚本和模块文件 – 语法基础配方来开始脚本文件,我们将有一个小的文档字符串。我们将在这个配方中扩展这个文档字符串。

在其他地方也应该使用文档字符串。我们将在第三章和第七章中查看这些附加位置。

我们将编写总结文档字符串的两种通用类型的模块:

  • 库模块:这些文件将主要包含函数定义以及类定义。文档字符串摘要应侧重于模块中的定义,描述模块是什么。文档字符串可以提供使用模块中定义的函数和类的示例。在第三章和第七章中,我们将更详细地查看这些模块。

  • 脚本:这些是我们通常期望会执行一些实际工作的文件。文档字符串应该描述模块的功能和使用方法。选项、环境变量和配置文件是文档字符串的重要组成部分。

我们有时会创建包含两者一部分的文件。这需要在行动和存在之间保持适当的平衡。

2.3.2 如何操作...

编写文档的第一步对库模块和脚本都是相同的:

  1. 简要总结脚本或模块是什么或做什么。总结不需要深入挖掘其工作原理。就像报纸文章的开头一样,它介绍了模块的谁、什么、何时、何地、如何和为什么。细节将在文档字符串的主体中跟随。

这有助于避免像“此脚本”这样的不必要的短语。我们可能以这样的方式开始模块的文档字符串:

""" 

Downloads and decodes the current Special Marine Warning (SMW) 

for the area ’AKQ’. 

"""

我们将根据模块的一般重点来区分其他步骤。

为脚本编写文档字符串

当我们记录脚本时,我们需要关注将使用脚本的人的需求。

  1. 按照前面所示开始,创建一个总结句。

  2. 为文档字符串的其余部分草拟一个大纲。我们将使用 ReStructuredText(RST)标记。在一行上写上主题,然后在主题下方写一行=,使其成为合适的标题。记住,在每个主题之间留一个空行。

    主题可能包括:

    • 概要:如何运行此脚本的摘要。如果脚本使用 argparse 模块处理命令行参数,argparse 生成的帮助文本是理想的概要文本。其他可安装的工具,如 click 或 invoke,也可以生成优雅的概要文本。(参见第六章中的使用 argparse 获取命令行输入)

    • 描述:解释此脚本的功能。

    • 选项:这提供了所有参数和选项的详细信息。(参见第六章中的使用 argparse 获取命令行输入)

    • 环境:这提供了描述环境变量及其含义的地方。(参见第六章中的使用 OS 环境设置)

    • 文件:脚本创建或读取的文件名是非常重要的信息。

    • 示例:一些使用脚本的示例总是很有帮助。在某些情况下,这可能是用户唯一会阅读的部分。

    • 参见:任何相关的脚本或背景信息。

    其他可能有趣的主题包括退出状态、作者、错误、报告错误、历史或版权。在某些情况下,例如报告错误的建议可能并不真正属于模块的文档字符串,而更可能位于项目的 GitHub 或 SourceForge 页面的其他地方。

  3. 在每个主题下填写详细信息。准确性很重要。由于文档与代码位于同一文件中,因此更容易做到正确、完整和一致。

这里是一个脚本的文档字符串示例:

""" 

Downloads and decodes the current Special Marine Warning (SMW) 

for the area \textquotesingle AKQ\textquotesingle{} 

SYNOPSIS 

======== 

:: 

    python3 akq_weather.py 

DESCRIPTION 

=========== 

Downloads the Special Marine Warnings 

Files 

===== 

Writes a file, ’’AKW.html’’. 

EXAMPLES 

======== 

Here’s an example:: 

    slott\$ python3 akq_weather.py 

    None issued by this office recently.

在概要部分,我们使用了 :: 作为独立的段落。在示例部分,我们在段落的末尾使用了 ::。这两种版本都是对 RST 处理工具的提示,表明接下来的缩进部分应该被格式化为代码。参见第十七章,文档和风格。

为库模块编写文档字符串

当我们记录库模块时,我们需要关注将导入模块以在他们的代码中使用程序员的需需求:

  1. 为文档字符串的其余部分绘制一个大纲。我们将使用 RST 标记。在一行上写下主题。在每个主题下方包括一行等号字符,以将主题转换为适当的标题。请记住,在每段之间留一个空行。

  2. 按照之前所示开始,创建一个总结句子:

    • 描述:模块内容的摘要以及为什么模块是有用的

    • 模块内容:在此模块中定义的类和函数

    • 示例:使用此模块的示例

  3. 为每个主题填写详细信息。模块内容可能是一长串类或函数定义的列表。文档字符串应该是一个摘要。在每个类或函数中,我们将有一个单独的文档字符串,其中包含该项目的详细信息。

2.3.3 它是如何工作的...

几十年来,man 页面大纲已经发展成为一个包含 Linux 命令完整描述的工具。这种编写文档的通用方法已被证明是有用且具有弹性的。我们可以利用大量的经验,并使我们的文档结构遵循 man 页面模型。

我们想要准备模块文档字符串,这些文档字符串可以被 Sphinx Python 文档生成器使用(参见 www.sphinx-doc.org/en/stable/)。这是用于生成 Python 文档文件的工具。Sphinx 中的 autodoc 扩展会读取我们模块、类和函数上的文档字符串标题,以生成最终看起来像 Python 生态系统中的其他模块的文档。

2.3.4 更多内容...

RST 标记具有简单、中心的语法规则:段落由空白行分隔。

这条规则使得编写可以被各种 RST 处理工具检查并重新格式化以看起来很棒的文档变得容易。

编写好的软件文档可能具有挑战性。信息不足和重复代码中明显细节的文档之间存在很大的鸿沟。

重要的是要关注那些对软件或其工作方式不太了解的人的需求,但他们可以阅读 Python 代码。为这样的半知识用户提供他们需要的信息,以便理解软件的功能和使用方法。

2.3.5 参考信息

  • 我们在 使用 RST 标记编写更好的文档字符串 中探讨了额外的技术。

  • 如果我们使用了 编写 Python 脚本和模块文件 – 语法基础 的配方,我们将在脚本文件中放置一个文档字符串。当我们构建第三章 3 中的函数和第七章 7 中的类时,我们将查看其他可以放置文档字符串的地方。

  • 更多关于 Sphinx 的信息,请参见 www.sphinx-doc.org/en/stable/

  • 更多关于 man 页面大纲的背景信息,请参见 en.wikipedia.org/wiki/Man_page

2.4 使用 RST 标记编写更好的文档字符串

当我们有一个有用的脚本时,我们经常需要留下关于它做什么、如何工作以及何时应该使用的笔记。包括 Docutils 在内的许多生成文档的工具都与 RST 标记一起工作。这允许我们编写纯文本文档。它可以包括一些特殊标点符号,以选择粗体或斜体字体变体来强调细节。此外,RST 允许通过列表和章节标题来组织内容。

2.4.1 准备工作

在 包含描述和文档 的配方中,我们探讨了将一些基本文档放入模块中。我们将查看一些 RST 格式化规则,用于创建可读的文档。

2.4.2 如何做...

  1. 从关键点的概述开始,创建 RST 部分标题以组织材料。一个部分标题有一行标题,后面跟着一行与标题长度相等的下划线字符,使用 =、-、^、~。

    一个标题看起来像这样:

    Topic 
    
    =====
    

    标题文本在一行,下划线字符在下一行。这必须被空白行包围。下划线字符可以多于标题字符,但决不能少于。

    RST 工具将推断我们选择的下划线字符的使用模式。只要下划线字符使用一致,docutil 工具就能检测文档的结构。

    在开始时,有一个明确的标题下划线标准可能会有所帮助:

    |


    |


    |

    字符 级别

    |


    |


    |

    = 1
    - 2
    ^ 3
    ~ 4

    |


    |


    |

  2. 填写各种段落。段落(包括部分标题)至少由一个空行分隔。

  3. 如果编程编辑器有拼写检查器,请使用它。这样做可能会很令人沮丧,因为代码示例通常包含拼写检查失败的缩写。

2.4.3 它是如何工作的...

Docutils 转换程序将检查文档,寻找部分和主体元素。部分通过标题识别。下划线用于将部分组织成适当的嵌套层次结构。

一个正确嵌套的文档可能具有以下下划线字符序列:

TITLE 

===== 

SOMETHING 

---------- 

MORE 

^^^^^ 

EXTRA 

^^^^^ 

LEVEL 2 

------- 

LEVEL 3 

^^^^^^^^

当从文档创建 HTML 文件时,它将具有

标签以表示不同级别。创建 LaTeX 文件需要一些额外的配置选择,但常见的文章模板意味着生成的文档将使用 \section、\subsection 和 \subsubsection 标题。当我们写作时,这些最终的表现选择并不是我们的主要关注点;最重要的是使用适当的下划线来反映所需的组织结构。

RST 解析器可以识别几种不同的主体元素。我们已经展示了几个。更完整的列表包括:

  • 文本段落:这些可能使用内联标记来表示不同类型的强调或突出显示。

  • 文本块:这些通过 :: 引入,并缩进四个空格。它们也可以通过 .. parsed-literal:: 指令引入。doctest 块缩进四个空格,并包含 Python >>> 提示符。

  • 列表、表格和块引用:我们稍后会讨论这些。这些可以包含其他主体元素。

  • 脚注:这些是特殊的段落。在渲染时,它们可能显示在页面底部或部分末尾。它们也可以包含其他主体元素。

  • 超链接目标、替换定义和 RST 注释:这些是更专业的文本项,我们在这里不会详细讨论。

2.4.4 更多内容...

在包含描述和文档的食谱中,我们查看了几种不同的主体元素,我们可能会使用:

  • 文本段落:这是一个由空白行包围的文本块。在这些段落中,我们可以使用内联标记来强调单词或短语。我们将在使用 RST 标记编写更好的 docstrings 菜谱中查看内联标记。

  • 列表:这些段落以看起来像数字或项目符号的东西开始。我们可能会有这样的段落。

    It helps to have bullets because: 
    
    -  They can help clarify 
    
    -  They can help organize
    

    行首可以使用其他字符,但 - 和 * 似乎是最常见的选项。

  • 编号列表:存在多种可识别的模式。这包括以数字或字母开头,后跟 . 或 )。使用 # 而不是数字或字母将延续前一段落的值。

  • 文字块:代码示例以纯文本形式呈现,不寻找 RST 元素。此文本必须缩进。一个方便的前缀是 ::。也可以使用 .. code-block:: 指令。

  • 指令:指令通常看起来像 .. directive::。它可能有一些内容,这些内容缩进以包含在指令内。它可能看起来像这样:

    ..  important:: 
    
        Do not flip the bozo bit.
    

    .. important:: 文本是指令。这后面跟着指令内的缩进文本。

使用指令

Docutils 有几个内置指令。Sphinx 工具添加了大量具有各种功能的额外指令。

最常用的指令之一是警告:注意、警告、危险、错误、提示、重要、注意、技巧、警告和通用警告。这些是复合体元素,因为它们内部有嵌套文本。上面,我们提供了一个重要警告的例子。

使用内联标记

在一个段落中,我们有几种内联标记可以使用:

  • 我们可以用 * 将单词或短语包围起来以实现 强调。这通常以斜体形式排版。

  • 我们可以用 ** 将单词或短语包围起来以实现 强烈强调。这通常以粗体形式排版。

  • 我们用单反引号 将引用包围起来,链接后跟下划线 _. 我们可能使用 ‘section title‘ 来引用文档中的特定部分。我们通常不需要在 URL 周围放置任何标记。Docutils 工具可以识别这些。有时我们希望显示一个单词或短语,而隐藏 URL。我们可以使用这个:‘the \textbf{Sphinx} documentation http://www.sphinx-doc.org/en/stable/`。

  • 我们可以用双反引号 ‘‘ 将与代码相关的单词包围起来,使它们看起来像 ‘‘代码‘‘。这将作为代码排版。

此外,还有一种更通用的技术称为角色。角色以 :word: 作为角色名称开始,后跟单引号 中的适用单词或短语。一个文本角色看起来像这样::strong:‘this‘。

有许多标准角色名称,包括 :emphasis:、:literal:、:code:、:math:、:pep-reference:、:rfc-reference:、:strong:、:subscript:、:superscript: 和 :title-reference:。其中一些也可以使用更简单的标记,如 emphasisstrong

此外,我们可以使用指令定义新的角色。如果我们想进行非常复杂的处理,我们可以为 Docutils 工具提供处理新角色的类定义。这允许我们调整文档的处理方式。

2.4.5 参考信息

2.5 设计复杂的 if...elif 链

在大多数情况下,我们的脚本将涉及许多选择。有时选择很简单,我们可以通过查看代码来判断设计的质量。在其他情况下,选择更复杂,很难确定我们的 if 语句是否设计得当,能够处理所有条件。

在最简单的情况下,我们有一个条件 C 及其逆否¬C。这是 if...else 语句的两个条件。一个条件 C 在 if 子句中声明;逆否条件¬C 在 else 子句中隐含。

这遵循排中律:我们声称在两个条件 C 和¬C 之间没有缺失的替代方案。然而,对于复杂条件,这可能很难可视化。

如果我们有类似以下的情况:

 if weather == Weather.RAIN and plan == Plan.GO_OUT: 

    bring("umbrella") 

else: 

    bring("sunglasses")

这可能一开始并不明显,但我们已经省略了许多可能的替代方案。天气和计划变量有四种不同的值组合。其中一种条件是明确声明的,其他三种则是假设的:

  • weather == RAIN and plan == GO_OUT。带伞似乎是正确的。

  • weather != RAIN and plan == GO_OUT。带太阳镜似乎是合适的。

  • weather == RAIN and plan != GO_OUT。如果我们待在家里,那么似乎没有哪个附加物品是合适的。

  • weather != RAIN and plan != GO_OUT。如果我们不出门,附加问题似乎就无关紧要了。

我们如何确保我们没有遗漏任何东西?我们如何确保我们没有将太多东西合并到一个假设的条件中,而不是明确声明?

2.5.1 准备工作

让我们来看一个 if...elif 链的具体例子。在骰子游戏 Craps 中,有一些规则适用于两个骰子的投掷。这些规则适用于游戏的第一次投掷,称为开场投掷:

  • 2, 3, 或 12 是 Craps,对于大多数赌注来说,这是输。

  • 7 或 11 是大多数赌注的赢家。

  • 剩余的数字确定一个点。掷骰子将继续根据另一组规则进行。

我们将使用这组三个条件作为例子来查看这个食谱,因为它包含一个可能含糊不清的条款。

2.5.2 如何做到...

当我们编写 if 语句时,即使它看起来很 trivial,我们也需要确保所有条件都已涵盖。

  1. 列举我们已知的条件。在我们的例子中,我们有三个规则:(2, 3, 12)规则,(7, 11)规则,以及“剩余的数字”的模糊陈述。这可以形成一个 if 语句的第一稿。

  2. 确定所有可能选择的全集。对于这个例子,有 11 种可能的结果:从 2 到 12 的数字,包括 2 和 12。

  3. 将各种 if 和 elif 条件 C 与选择的全集 U 进行比较。存在三种可能的设计模式:

    • 在代码中,我们有的 if 条件比选择的全集 C ⊂ U 中的可能条件要多。最常见的原因是未能完全列举宇宙中所有可能的选择。例如,我们可能用 0 到 5 而不是 1 到 6 来模拟骰子。选择的全集看起来是从 0 到 10 的值,但实际上有针对 11 和 12 的条件。

    • 我们代码中的条件有间隙,U ∖ C≠∅。宇宙中未明确声明的 if 条件的选择的最常见原因是未能完全理解代码中的条件。例如,我们可能将值列举为两个元组而不是总和。2、3 和 12 由一对数字定义,包括(1, 1)、(1, 2)和(6, 6)。可能会忽略条件(2, 1),使得 if 语句的任何子句都没有对其进行测试。

    • 我们可以证明代码中表达的条件与选择的全集 U 之间有匹配,即 U ≡ C。这是理想的。所有可能选择的全集与语句的 if 和 elif 子句中的所有条件相匹配。

在这个例子中,列举所有可能的选择很容易。在其他情况下,可能需要一些仔细的推理来理解任何间隙或遗漏。

在这个例子中,我们有一个模糊的术语,剩余的数字,我们可以用值列表(4, 5, 6, 8, 9, 10)来替换。提供列表可以消除任何可能的间隙和疑问。

当恰好有两个选择时,我们可以为其中一个选择编写条件表达式。另一个条件可以隐含;if 和 else 将工作。

当我们有超过两个选择时,我们可以使用这个方法来编写一系列的 if 和 elif 语句,每个选择一个语句:

  1. 编写一个涵盖所有已知选择的 if ... elif ... elif 链。对于我们的例子,它可能开始如下:

     dice = die_1 + die_2 
    
    if dice in (2, 3, 12): 
    
        game.craps() 
    
    elif dice in (7, 11): 
    
        game.winner() 
    
    elif dice in (4, 5, 6, 8, 9, 10): 
    
        game.point(dice) 
    
  2. 添加一个引发异常的 else 子句,如下所示:

     else: 
    
        raise Exception(’Design Problem’)
    

这个额外的 else 为我们提供了一种方法来正确定位逻辑问题时。我们可以确信,我们做出的任何设计错误在程序运行时都会导致一个明显的问题。理想情况下,我们将在单元测试期间发现任何问题。

在这种情况下,很明显,所有 11 个选择都被 if 语句的条件所覆盖。额外的 else 永远不会被使用。并非所有现实世界的问题都有这种简单的方法来证明所有选择都被条件所覆盖。这有助于提供一个嘈杂的故障模式。

2.5.3 它是如何工作的...

我们的目的是确保我们的程序可靠地工作。虽然测试有帮助,但在设计和创建测试用例时,我们仍然可能做出错误的假设。

虽然严格的逻辑是必不可少的,但我们仍然可能犯错。此外,进行普通软件维护的人可能会引入错误。向复杂 if 语句中添加新功能可能是问题的潜在来源。

这种 Else-Raise 设计模式迫使我们对每个条件都进行明确说明。没有任何假设。正如我们之前提到的,如果异常被抛出,我们逻辑中的任何错误都将被揭露。

在设计问题时,通过异常崩溃是一种合理的行为。虽然另一种选择是将消息写入错误日志,但具有这种深刻设计缺陷的程序应被视为致命损坏。

2.5.4 更多内容...

在许多情况下,我们可以通过检查程序处理过程中的某个点的期望后置条件来推导出 if...elif...elif 链。例如,我们可能需要一个语句来建立类似于 m 等于 a 或 b 中较大的数。

(为了处理逻辑,我们将避免使用 Python 的便捷的 m = max(a, b),并关注我们如何从排他性选择中计算结果。)

我们可以像这样形式化最终条件:

(m = a ∨ m = b)∧ m ≥ a ∧ m ≥ b

我们可以通过将目标写成断言语句来从这个最终条件反向工作:

# do something 

assert (m == a or m == b) and m >= a and m >= b

一旦我们明确了目标,我们就可以识别出导致该目标实现的语句。显然,像 m = a 或 m = b 这样的赋值语句是合适的,但每个都只在有限条件下有效。

我们可以推导出这些语句应该使用的前提条件。赋值语句的前提条件将用 if 和 elif 表达式编写。

当 a >= b 时,我们需要使用语句 m = a。同样,当 b >= a 时,我们需要使用语句 m = b。将逻辑重新排列成代码,我们得到如下:

if a >= b: 

    m = a 

elif b >= a: 

    m = b 

else: 

    raise Exception(’Design Problem’) 

assert (m == a or m == b) and m >= a and m >= b

注意,我们的条件集合 U = {a ≥ b, b ≥ a}是完整的;没有其他可能的关系。此外,请注意,在 a = b 的边缘情况下,我们实际上并不关心使用哪个赋值语句。Python 将按顺序处理决策,并执行 m = a。这种选择的一致性不应该对我们的 if...elif...elif 链的设计有任何影响。我们可以不考虑子句的评估顺序来设计条件。

2.5.5 参见

  • 这与“悬挂 else”的语法问题有些相似。参见docs.oracle.com/javase/specs/jls/se9/html/jls-14.html

    这不是同一个问题;Python 的缩进消除了“悬挂 else”语法问题。这是一个相邻的语义问题,试图确保在复杂的 if...elif...elif 链中所有条件都得到适当考虑。

2.6 使用 := “walrus” 运算符保存中间结果

有时候我们会遇到一个复杂的情况,需要保留一个昂贵的中间结果以供后续使用。想象一下一个涉及复杂计算的条件;计算的成本很高,以时间、输入输出操作、内存资源或三者兼而有之来衡量。

一个例子是使用正则表达式(re)包进行重复搜索。match()方法可以在返回 Match 对象或 None 对象(表示没有找到模式)之前进行相当多的计算。一旦这个计算完成,我们可能需要使用这个结果,并且我们绝对不希望再次进行计算。通常,最初的使用是简单地检查结果是否是 Match 对象或 None。

这是一个可以给表达式的值命名并也在 if 语句中使用表达式的例子。我们将探讨如何使用“赋值表达式”或“walrus”操作符。它被称为 walrus,因为赋值表达式操作符 := 对某些人来说看起来像海象的脸。

2.6.1 准备工作

这里有一个求和示例,最终每个项都变得如此之小,以至于继续将其加到总和中已经没有意义了:

 ∑ (--1---)2 0≤n<∞ 2n+ 1

实际上,这类似于以下求和函数:

>>> s = sum((1 / (2 * n + 1)) ** 2 for n in range(0, 20_000))

不清楚的是需要多少项的问题。在示例中,我们求和了 20,000 个值。但如果 16,000 个就足以提供一个准确的答案呢?

我们不想编写这样的求和:

>>> b = 0 

>>> for n in range(0, 20_000): 

...     if (1 / ( 2 * n + 1)) ** 2 >= 0.000_000_001: 

...         b = b + (1 / (2 * n + 1)) ** 2

这个例子重复了一个昂贵的计算,(1/(2*n+1))**2。我们可以通过使用 walrus 操作符来避免包含这种浪费时间开销的处理。

2.6.2 如何做...

  1. 首先,我们隔离一个昂贵的操作,它是条件测试的一部分。在这个例子中,变量 term 用于保存昂贵的计算结果:

    >>> p = 0 
    
    >>> for n in range(0, 20_000): 
    
    ...     term = (1 / (2 * n + 1)) ** 2 
    
    ...     if term >= 0.000_000_001: 
    
    ...         p = p + term
    
  2. 将赋值语句重写为使用 := 赋值操作符。这取代了 if 语句的简单条件。

  3. 添加一个 else 条件以跳出 for 语句,如果不再需要更多项的话。以下是这两个步骤的结果:

    >>> q = 0 
    
    >>> for n in range(0, 20_000): 
    
    ...     if (term := (1 / (2 * n + 1)) ** 2) >= 0.000_000_001: 
    
    ...         q = q + term 
    
    ...     else: 
    
    ...         break
    

    注意,我们改变了求和变量。在食谱的先前的步骤中,它是 p。在这个步骤中,它是 q。这允许我们轻松地进行并排比较,以确保结果仍然是正确的。

赋值表达式 := 允许我们在 if 语句中做两件事。

2.6.3 它是如何工作的...

赋值表达式操作符 := 保存了一个中间结果。操作符的结果值与右侧操作数相同。这意味着表达式 a + (b := c+d) 与表达式 a+(c+d) 相同。表达式 a + (b := c+d) 与表达式 a+(c+d) 之间的区别在于在评估过程中设置 b 变量值的副作用。

赋值表达式可以在 Python 中几乎任何允许表达式的地方使用。最常见的情况是 if 语句。另一个好主意是放在 while 条件中。

它在几个地方也是被禁止的。它们不能用作表达式语句中的运算符。我们特别禁止将:=2 写为一个语句:这里有一个完美的赋值语句用于此目的,而赋值表达式,虽然意图相似,但可能存在混淆。

2.6.4 更多内容...

我们可以对前面这个菜谱中展示的无限求和示例进行一些优化。使用 for 语句和 range()对象看起来很简单。问题是,我们希望提前结束 for 语句——当被加的项非常小,对最终总和没有显著变化时。

我们可以将提前退出与项计算结合起来:

 >>> r = 0 

>>> n = 0 

>>> while (term := (1 / (2 * n + 1)) ** 2) >= 0.000_000_001: 

...     r += term 

...     n += 1

我们使用了一个带有赋值表达式运算符的 while 语句。这将计算一个值,使用(1/(2*n+1))**2,并将其分配给 term 变量。如果这个值是显著的,我们将它加到总和 r 上,并增加 n 变量的值。如果分配给 term 的值太小,不足以显著,while 语句将结束。

这里有一个例子,展示了如何计算一系列值的运行总和。这预示了第四章中的概念。具体来说,这展示了使用赋值表达式运算符构建的列表推导式:

>>> data = [11, 13, 17, 19, 23, 29] 

>>> total = 0 

>>> running_sum = [(total := total + d) for d in data] 

>>> total 

112 

>>> running_sum 

[11, 24, 41, 60, 83, 112]

我们从一些数据开始,在 data 变量中。这可能是大多数一周内每天的锻炼分钟数。final_running_sum 变量的值是一个列表对象,通过评估表达式(total := total + d)对 data 变量中的每个值 d 进行计算而构建。因为赋值表达式改变了 total 变量的值,所以得到的列表是每个新值累加的结果。

2.6.5 参考内容

  • 关于赋值表达式的详细信息,请参阅PEP-572,其中首次描述了该功能。

2.7 避免使用 break 语句可能引起的问题

理解 for 语句的常见方式是它创建了一个对所有条件的 for 条件。在语句的末尾,我们可以断言,对于集合中的所有项目,语句体内的处理已经完成。

for 语句并不只有一种含义。当 break 语句在 for 语句的体内使用时,它改变了语义为存在。当 break 语句离开 for(或 while)语句时,我们可以断言至少存在一个项目导致了包含语句的结束。

这里有一个附带问题。如果 for 语句在没有执行 break 语句的情况下结束,会怎样?无论如何,我们都在 for 语句之后的语句。使用 break 语句退出 for 或 while 语句时,所得到的条件可能是模糊的。我们无法轻易判断;这个方案提供了一些设计指导。

当我们有多条 break 语句,每条都有其自己的条件时,问题会放大。我们如何最小化由这些复杂的条件造成的退出 for 或 while 语句时产生的问题?

2.7.1 准备工作

在解析配置文件时,我们经常需要在字符串中找到第一个:或=字符。属性文件格式使用属性名和:或=后跟值。

找到标点符号是 for 语句存在修改的一个例子。我们不想处理所有字符;我们想知道最左边的:或=字符在哪里被找到。

这里是我们要用作例子的样本数据:

>>> sample_1 = "some_name = the_value"

这里有一个小的 for 语句,用于在样本字符串值中定位最左边的:或=字符:

>>> for position in  range(len(sample_1)): 

...     if sample_1[position] in ’=:’: 

...         break 

>>> print(f"name={sample_1[:position]!r}", 

...     f"value={sample_1[position+1:]!r}") 

name=’some_name ’ value=’ the_value’

当发现等号(=)字符时,break 语句结束 for 语句。位置变量的值显示了所需字符被找到的位置。

关于以下边缘情况怎么办?

>>> sample_2 = "name_only" 

>>> for position in range(len(sample_2)): 

...     if sample_2[position] in ’=:’: 

...         break 

>>> print(f"name={sample_2[:position]!r}", 

...     f"value={sample_2[position+1:]!r}") 

name=’name_onl’ value=’’

结果是尴尬的错误:name 的值中丢失了 y 字符。这是为什么?更重要的是,我们如何使 for 语句末尾的条件更清晰?

2.7.2 如何实现...

每个语句都建立了一个后置条件。当设计 for 或 while 语句时,我们需要明确语句末尾应该成立的条件。理想情况下,后置条件是像 text[position] in ’=:’这样的简单条件。然而,在给定文本中没有=或:的情况下,过于简单的后置条件可能不成立。

在 for 语句的末尾,以下两种情况之一是真实的:

  • 要么是位置索引的字符是:或=

  • 或者所有字符都已检查,并且没有字符是:或=

我们的应用程序代码需要处理这两种情况。

  1. 写出明显的后置条件。我们有时称这为“快乐路径”条件,因为它是在没有发生任何异常时为真的条件:

    assert text[position] in ’=:’  # We found a = or :
    
  2. 通过添加边缘情况的条件来创建整体的后置条件。在这个例子中,我们有两个额外的条件:

    • 没有等号(=)或冒号(:)。

    • 没有字符。这意味着 len()是零,for 语句实际上从未执行过。这也意味着位置变量永远不会被创建。

    因此,在这个例子中,我们已经发现了总共三个条件:

    • len(text) == 0

    • not(’=’ in text or ’:’ in text),这可以用多种方式表达。not(text[position] == ’:’ or text[position] == ’=’)可能最清晰。

    • text[position] in ’=:’

  3. 当一个 while 语句可以被重新设计以在 while 子句中包含完整的后置条件集时,这可以消除使用 break 语句的需要。仍然需要正确初始化变量。

  4. 当使用 for 语句时,需要正确初始化变量。在 for 语句的主体之后添加 if 语句,以处理各种终止条件。以下是结果 for 语句和复杂的 if 语句,用于检查所有可能的后置条件:

    >>> position = -1 
    
    >>> for position in range(len(sample_2)): 
    
    ...     if sample_2[position] in ’=:’: 
    
    ...         break 
    
    ... 
    
    >>> if position == -1: 
    
    ...     print(f"name=None value=None") 
    
    ... elif not(sample_2[position] == ’:’ or sample_2[position] == ’=’): 
    
    ...     print(f"name={sample_2!r} value=None") 
    
    ... else: 
    
    ...     print(f"name={sample_2[:position]!r}", 
    
    ...         f"value={sample_2[position+1:]!r}") 
    
    name=’name_only’ value=None
    

在 for 语句之后的语句中,我们已经明确地列举了所有终止条件。

2.7.3 它是如何工作的...

这种方法迫使我们仔细地确定后置条件,以确保我们绝对知道 for 或 while 语句结束的所有原因。

这里的想法是放弃任何假设或直觉。通过一点点的纪律,我们可以确保知道所有后置条件的原因。当语句正常工作时,明确该条件是真实的至关重要。这是我们软件的目标,我们可以通过选择最简单的语句来实现目标条件,从而从目标逆向工作。

2.7.4 更多...

我们还可以在 for 语句上使用 else 子句来确定语句是否正常完成或执行了 break 语句。我们可以使用类似以下的方法:

>>> for position in  range(len(sample_2)): 

...     if sample_2[position] in ’=:’: 

...         name, value = sample_2[:position], sample_2[position+1:] 

...         break 

... else: 

...     if len(sample_2) > 0: 

...         name, value = sample_2, None 

...     else: 

...         name, value = None, None 

>>> print(f"{name=!r} {value=!r}") 

name=’name_only’ value=None

在 for 语句中使用 else 子句有时会令人困惑,我们不推荐这样做。不清楚这个版本是否实质上优于任何其他替代方案。因为它很少使用,所以很容易忘记 else 执行的原因。

2.7.5 参见

2.8 利用异常匹配规则

try 语句让我们能够捕获异常。当引发异常时,我们有许多处理它的选择:

  • 忽略它:如果我们什么都不做,程序将停止。我们可以通过两种方式做到这一点——一开始不要使用 try 语句,或者在 try 语句中没有匹配的 except 子句。

  • 记录它:我们可以写一条消息并使用 raise 语句在写入日志后让异常传播。预期这将停止程序。

  • 从中恢复:我们可以编写一个 except 子句来执行一些恢复操作,以撤销部分完成的 try 子句的任何影响。

  • 使其静默:如果我们什么都不做(即使用 pass 语句),那么在 try 语句之后会继续处理。这使异常静默,但不会纠正根本问题,或作为恢复尝试提供替代结果。

  • 重新编写它:我们可以引发不同的异常。原始异常成为新引发的异常的上下文。

关于嵌套上下文呢?在这种情况下,一个异常可以被内部 try 语句忽略,但由外部上下文处理。每个 try 上下文的基本选项集是相同的。软件的整体行为取决于嵌套定义。

try 语句的设计取决于 Python 异常如何形成类层次结构。有关详细信息,请参阅 Python 标准库中的异常层次结构部分。例如,ZeroDivisionError 异常也是 ArithmeticError 和 Exception。另一个例子,FileNotFoundError 异常也是 OSError 以及 Exception。

如果我们试图同时处理详细异常和通用异常,这种层次结构可能会导致混淆。

2.8.1 准备工作

假设我们将使用 shutil 模块从一个地方复制文件到另一个地方。大多数可能引发的异常表明问题严重到无法解决。然而,在 FileNotFoundError 异常的具体事件中,我们希望尝试恢复操作。

这是我们想要做的粗略概述:

>>> from pathlib import Path 

>>> import shutil 

>>> source_dir = Path.cwd()/"data" 

>>> target_dir = Path.cwd()/"backup" 

>>> for source_path in source_dir.glob(’**/*.csv’): 

...     source_name = source_path.relative_to(source_dir) 

...     target_path = target_dir / source_name 

...     shutil.copy(source_path, target_path)

我们有两个目录路径,source_dir 和 target_dir。我们使用了 glob() 方法来定位 source_dir 下所有具有 *.csv 文件的文件。

表达式 source_path.relative_to(source_dir) 给我们文件名的尾部,即目录之后的部分。我们使用这个来在 target_dir 目录下构建一个新的、类似的路径。这确保了在 source_dir 目录中的文件名为 wc1.csv,在 target_dir 目录中也将有类似的名称。

处理 shutil.copy() 函数引发的异常时会出现问题。我们需要一个 try 语句,以便从某些类型的错误中恢复。如果我们尝试运行以下内容,我们将看到这种错误:

Traceback (most recent call last): 

... 

FileNotFoundError: [Errno 2] No such file or directory: ...

(我们用 ... 替换了一些细节,因为它们在您的计算机上会有所不同。)

当备份目录尚未创建时,会引发此异常。当源目录树中存在而目标目录树中不存在的子目录时,也会发生这种情况。我们如何创建一个 try 语句来处理这些异常并创建缺失的目录?

2.8.2 如何实现...

  1. 将我们想要使用的代码缩进写入 try 块中:

    >>> try: 
    
    ...     shutil.copy(source_path, target_path)
    
  2. 在 except 子句中首先包含最具体的异常类。在这种情况下,我们对特定的 FileNotFoundError 异常有一个有意义的响应。

  3. 在后面包含任何更通用的异常。在这种情况下,我们将报告遇到的任何通用 OSError 异常。这导致以下结果:

    >>> try: 
    
    ...     target = shutil.copy(source_path, target_path) 
    
    ... except FileNotFoundError: 
    
    ...     target_path.parent.mkdir(exist_ok=True, parents=True) 
    
    ...     target = shutil.copy(source_path, target_path) 
    
    ... except OSError as ex: 
    
    ...     print(f"Copy {source_path} to {target_path} error {ex}")
    

我们首先匹配最具体的异常,然后是更通用的异常。

我们通过创建缺失的目录来处理 FileNotFoundError 异常。然后我们再次尝试 copy(),因为我们知道现在它将正常工作。

我们记录了任何其他属于 OSError 类的异常。例如,如果存在权限问题,该错误将被写入日志,然后尝试下一个文件。我们的目标是尝试复制所有文件。任何引起问题的文件将被记录,但整体复制过程将继续。

并且,是的,复制文件的代码在两个不同的上下文中重复。第一次重复是在没有错误的情况下。第二次是在尝试从初始错误中恢复后。在一定程度上,这感觉像是违反了“不要重复自己”的原则。让我们看看替代方案,它似乎并不那么好。

为了满足 DRY 标准,我们可以尝试在 for 循环中嵌套这个操作。如果一切顺利,则使用 break 语句,否则可以尝试多次。for 循环的额外复杂性似乎比重复更糟糕。

一种常见的折衷方案是编写一个单行函数,将重复的内容简化为函数名。这样做的好处是可以在一个地方将其更改为 shutil.copy 函数中的另一个函数。

2.8.3 它是如何工作的...

Python 的异常匹配规则旨在简单:

  • 按顺序处理 except 子句。

  • 将实际异常与异常类(或异常类的元组)进行匹配。匹配意味着实际异常对象(或异常对象的任何基类)在 except 子句中属于给定的类。

这些规则说明了为什么我们将最具体的异常类放在最前面,而将更一般的异常类放在最后。像 Exception 这样的通用异常类几乎可以匹配任何类型的异常。我们不希望它排在第一位,因为其他子句将不会被检查。

还有一个更通用的类,即 BaseException 类。没有很好的理由去处理这个类的异常。如果我们这样做,我们将捕获 SystemExit 和 KeyboardInterrupt 异常;这会干扰终止不当行为的应用程序的能力。我们只在定义存在于正常异常层次结构之外的新异常类时使用 BaseException 类作为超类。

2.8.4 更多内容...

我们的例子包括一个嵌套的上下文,其中可以引发第二个异常。考虑这个 except 子句片段(从上下文中摘出):

... except FileNotFoundError: 

...     target_path.parent.mkdir(exist_ok=True, parents=True) 

...     target = shutil.copy(source_path, target_path)

如果 mkdir()方法或 shutil.copy()函数在处理原始 FileNotFoundError 异常时实际引发异常,则不会被处理。在 except 子句中引发的任何异常都可能使整个程序崩溃。处理这些嵌套异常可能需要嵌套的 try 语句。

我们可以重写 except 子句以包括恢复过程中的嵌套 try:

>>> try: 

...     target = shutil.copy(source_path, target_path ) 

... except FileNotFoundError: 

...     try: 

...         target_path.parent.mkdir(exist_ok=True, parents=True) 

...         target = shutil.copy(source_path, target_path) 

...     except OSError as ex2: 

...         print(f"{target_path.parent} problem: {ex2}") 

... except OSError as ex: 

...     print(f"Copy {source_path} to {target_path} error {ex}")

在这个例子中,嵌套的上下文为 OSError 异常写入一条消息。在外部上下文中,使用略微不同的错误消息来记录类似错误。在这两种情况下,处理都可以继续。不同的错误消息可以使调试问题稍微容易一些。

2.8.5 参考内容

  • 在 Avoiding a potential problem with an except: clause 菜谱中,我们探讨了设计异常处理语句时的一些额外考虑。

2.9 避免使用 except:子句可能带来的潜在问题

在异常处理中存在一些常见的错误。这些错误可能导致程序变得无响应。

我们可能犯的一个错误是使用没有命名异常类的 except:子句进行匹配。如果我们不谨慎地处理异常,我们可能会犯一些其他的错误。

本菜谱将展示一些我们可以避免的常见异常处理错误。

2.9.1 准备工作

当代码可以引发各种异常时,有时会诱使我们尝试匹配尽可能多的异常。匹配过多的异常类可能会干扰停止行为不端的 Python 程序。我们将在本菜谱中扩展“不要做什么”的概念。

2.9.2 如何操作...

我们需要避免使用裸 except:子句。相反,使用 except Exception:来匹配一个应用可以合理处理的最为通用的异常类型。

处理过多的异常类可能会干扰我们停止行为不端的 Python 程序的能力。当我们按下 Ctrl + C,或者通过操作系统的 kill -2 命令发送 SIGINT 信号时,我们通常希望程序停止。我们很少希望程序写一条消息然后继续运行。如果我们使用裸 except:子句,我们可能会意外地静音重要的异常。

我们应该小心避免尝试处理以下几类异常:

  • SystemError

  • RuntimeError

  • MemoryError

通常,这些异常意味着 Python 内部某个地方出了问题。而不是静音这些异常或尝试恢复,我们应该允许程序失败,找到根本原因,并修复它。

此外,如果我们捕获了这些异常,可能会干扰这些内部异常的处理方式:

  • SystemExit

  • KeyboardInterrupt

  • GeneratorExit

尝试处理这些异常可能会在我们需要停止程序的时候导致程序变得无响应。

2.9.3 工作原理...

我们应该避免以下三种技术:

  • 不要在 except BaseException:子句中匹配 BaseException 类。

  • 不要使用没有异常类的 except:。这会匹配所有异常,包括我们应避免尝试处理的异常。

  • 不要匹配那些无法合理恢复的异常。

如果我们处理过多的异常类型,可能会加剧问题,通过错误的异常处理方式将其转化为更大、更神秘的问题。

编写一个永远不会崩溃的程序是一种崇高的愿望。然而,干扰 Python 的一些内部异常并不会创建一个更可靠的程序。相反,它创建了一个清晰的失败被掩盖并变成一个难以理解的问题的程序。

2.9.4 相关内容

  • 在 利用异常匹配规则 菜谱中,我们探讨了设计异常处理语句时的考虑因素。

2.10 隐藏异常的根本原因

异常包含一个根本原因。内部引发的异常的默认行为是使用隐式的 context 属性来包含异常的根本原因。在某些情况下,我们可能想淡化根本原因,因为它可能会误导或对调试无帮助。

这种技术几乎总是与定义了唯一异常的应用程序或库配对。想法是显示唯一的异常,而不显示来自应用程序或库外部的无关异常的杂乱。

2.10.1 准备工作

假设我们正在编写一些复杂的字符串处理。我们希望将多种不同类型的详细异常视为一个通用的错误,以便我们的软件用户免受实现细节的影响。我们可以在通用错误中附加详细信息。

2.10.2 如何做...

  1. 要创建一个新的异常,我们可以这样做:

    >>> class MyAppError(Exception): 
    
    ...     pass
    

    这将创建一个新的、独特的异常类别,我们的库或应用程序可以使用。

  2. 当处理异常时,我们可以像这样隐藏根本原因异常:

    >>> try: 
    
    ...     None.some_method(42)  # Raises an exception 
    
    ... except AttributeError as exception: 
    
    ...     raise MyAppError("Some Known Problem") from None
    

在这个例子中,我们引发模块的唯一 MyAppError 异常类的新实例。新的异常将不会与根本原因 AttributeError 异常有任何联系。

2.10.3 它是如何工作的...

Python 异常类都有一个记录异常原因的地方。我们可以使用 raise Visible from RootCause 语句设置此 cause 属性。这是通过异常上下文隐式完成的。

当这个异常被引发时,看起来是这样的:

>>> try: 

...     None.some_method(42) 

... except AttributeError as exception: 

...     raise MyAppError("Some Known Problem") from None 

Traceback (most recent call last): 

... 

MyAppError: Some Known Problem

根本原因已被隐藏。如果我们省略 raise 语句中的 from None,那么异常将包含两部分,并且会复杂得多。当显示根本原因时,输出看起来更像是这样:

Traceback (most recent call last): 

  File "<doctest recipe_09.txt[3]>", line 2, in <module> 

    None.some_method(42) 

    ^^^^^^^^^^^^^^^^ 

AttributeError: ’NoneType’ object has no attribute ’some_method’ 

During handling of the above exception, another exception occurred: 

Traceback (most recent call last): 

  File ... 

    exec... 

  File ... 

    raise... 

MyAppError: Some Known Problem

这显示了底层的 AttributeError 异常。这可能是一个无助于调试的实现细节,最好不将其打印在异常的显示中。

异常更有用的部分(一些细节被省略了...)跟在初始(可能是不相关的)根本原因信息之后。

2.10.4 更多内容...

异常有许多内部属性。这些包括 causecontexttracebacksuppress_context。整体的异常上下文在 context 属性中。如果通过 raise from 语句提供,原因在 cause 中。异常的上下文是可用的,但可以抑制其打印。

2.10.5 相关阅读

  • 在 利用异常匹配规则 菜谱中,我们探讨了设计异常处理语句时的考虑因素。

  • 在避免 except 子句的潜在问题的菜谱中,我们探讨了设计异常处理语句时的一些额外考虑。

2.11 使用 with 语句管理上下文

在许多情况下,我们的脚本将与外部资源纠缠在一起。最常见的情况是磁盘文件和连接到外部主机的网络连接。一个常见的错误是永远保留这些纠缠,无用地占用这些资源。这些有时被称为内存泄漏,因为每次打开一个新文件而不关闭之前使用的文件时,可用的内存都会减少。

我们希望隔离每个纠缠,以确保资源被正确获取和释放。想法是在我们的脚本使用外部资源的环境中创建一个上下文。在上下文结束时,我们的程序不再绑定到资源,我们希望得到保证,资源将被释放。

2.11.1 准备工作

假设我们想要将数据行写入 CSV 格式的文件。完成之后,我们想要确保文件已关闭,并且释放了各种操作系统资源——包括缓冲区和文件句柄。我们可以在上下文管理器中这样做,这保证了文件将被正确关闭。

由于我们将处理 CSV 文件,我们可以使用 csv 模块来处理格式化的细节:

>>> import csv

我们还将使用 pathlib 模块来定位我们将要处理文件:

>>> from pathlib import Path

为了有东西可以写入,我们将使用这个愚蠢的数据源:

>>> some_source = [ 

...     [2,3,5], 

...     [7,11,13], 

...     [17,19,23]]

我们还需要一个工作目录。在示例中,我们使用当前工作目录下的数据。我们可以使用终端窗口命令创建此目录,或者我们可以从 Python 内部创建它:


>>> Path.cwd().mkdir("data", exists=ok=True)

这将给我们一个了解 with 语句的上下文。

2.11.2 如何做...

  1. 通过打开 Path 或使用 urllib.request.urlopen()创建网络连接来创建上下文。其他常见的上下文包括创建归档,如 zip 文件和 tar 文件。以下是打开文件的必要上下文创建:

    >>> target_path = Path.cwd() / "data" / "test.csv" 
    
    >>> with target_path.open(’w’, newline=’’) as target_file:
    
  2. 包含所有在 with 语句内缩进的加工:

    >>> target_path = Path.cwd() / "data" / "test.csv" 
    
    >>> with target_path.open(’w’, newline=’’) as target_file: 
    
    ...     writer = csv.writer(target_file) 
    
    ...     writer.writerow([’column’, ’data’, ’heading’]) 
    
    ...     writer.writerows(some_source)
    
  3. 当我们将文件用作上下文管理器时,文件将在缩进的上下文块结束时自动关闭。即使引发异常,文件也会被正确关闭。在上下文完成后和资源释放后,缩进完成后的处理:

    >>> target_path = Path.cwd() / "data" / "test.csv" 
    
    >>> with target_path.open(’w’, newline=’’) as target_file: 
    
    ...     writer = csv.writer(target_file) 
    
    ...     _ = writer.writerow([’column’, ’data’, ’heading’]) 
    
    ...     writer.writerows(some_source) 
    
    >>> print(f’finished writing {target_path.name}’) 
    
    finished writing test.csv
    

with 上下文之外的语句将在上下文关闭后执行。命名资源——由 target_path.open()打开的文件——将被正确关闭。

(我们将 writerow()方法的结果赋值给变量 _。这是一个避免显示此结果的技巧。它是数字 21,告诉我们写了多少个字符。)

即使在 with 上下文中抛出异常,文件仍然会被正确关闭。上下文管理器会通知异常。它可以关闭文件并允许异常传播。

2.11.3 它是如何工作的...

上下文管理器会通知围绕缩进代码块的三项重要事件:

  • 进入上下文

  • 没有异常的正常退出上下文

  • 由于异常而退出上下文

上下文管理器将在所有情况下将我们的程序从外部资源中解耦。文件可以被关闭。网络连接可以被断开。数据库事务可以被提交或回滚。锁可以被释放。

我们可以通过在 with 语句中包含一个手动异常来实验这个。这可以显示文件被正确关闭:

>>> try: 

...     with target_path.open(’w’, newline=’’) as target_file: 

...         writer = csv.writer(target_file) 

...         _ = writer.writerow([’column’, ’data’, ’heading’]) 

...         _ = writer.writerow(some_source[0]) 

...         raise Exception("Testing") 

... except Exception as exc: 

...     print(f"{target_file.closed=}") 

...     print(f"{exc=}") 

target_file.closed=True 

exc=Exception(’Testing’) 

>>> print(f"finished writing {target_path.name}") 

finished writing test.csv

在这个例子中,我们将实际工作封装在 try 语句中。这允许我们在将第一行数据写入 CSV 文件后抛出异常。因为异常处理在 with 上下文之外,所以文件被正确关闭。所有资源都得到释放,所写入的部分可以由其他程序正确访问和使用。

输出确认了预期的文件状态:

target_file.closed=True 

exc=Exception(’Testing’)

这表明文件被正确关闭。它还显示了与异常相关的消息,以确认这是我们手动抛出的异常。这种技术允许我们与像数据库连接和网络连接这样的昂贵资源一起工作,并确保这些资源不会“泄漏”。

资源泄漏是在资源没有正确释放回操作系统时常用的一个描述。这就像一个池子慢慢被排空,应用程序停止工作,因为没有更多的可用操作系统网络套接字或文件句柄。with 语句可以用来正确地将我们的 Python 应用程序与操作系统资源解耦。

2.11.4 更多内容...

Python 为我们提供了一系列上下文管理器。我们注意到一个打开的文件是一个上下文,就像由urllib.request.urlopen()创建的打开网络连接一样。

对于所有文件操作和所有网络连接,我们应该始终使用 with 语句作为上下文管理器。很难找到这个规则的例外。

结果表明,十进制模块使用上下文管理器来允许对十进制算术执行方式的本地化更改。我们可以使用decimal.localcontext()函数作为上下文管理器来更改计算中隔离的舍入规则或精度。

我们也可以定义自己的上下文管理器。contextlib模块包含函数和装饰器,可以帮助我们在不明确提供它们的情况下创建资源周围的上下文管理器。

当使用锁时,with 语句上下文管理器是获取和释放锁的理想方式。查看 docs.python.org/3/library/threading.html#with-locks 了解由 threading 模块创建的锁对象与上下文管理器之间的关系。

2.11.5 参考信息

  • 查看 PEP-343 了解 with 语句的起源。

  • 第九章中的许多食谱将使用这项技术。包括 使用 CSV 模块读取定界文件、使用正则表达式读取复杂格式 和 读取 HTML 文档 等食谱都将使用 with 语句。

加入我们的社区 Discord 空间

加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

PIC

第三章:3

函数定义

函数定义是将一个大问题分解成更小问题的方法。数学家们已经这样做了几百年。这是一种将我们的 Python 编程打包成智力上可管理的块的方法。

我们将在这些食谱中查看多种函数定义技术。这包括处理灵活参数的方法以及根据某些高级设计原则组织参数的方法。

我们还将查看 typing 模块以及我们如何为我们的函数创建更正式的类型提示。使用类型提示将使我们的代码准备就绪,以便我们可以使用像 mypy 这样的工具来确认在整个程序中正确地使用了数据类型。类型提示不是必需的,但它们通常可以识别潜在的不一致性,使我们能够编写防止问题的代码。

在本章中,我们将查看以下食谱:

  • 函数参数和类型提示

  • 设计具有可选参数的函数

  • 使用超级灵活的关键字参数

  • 使用*分隔符强制关键字参数

  • 使用/分隔符定义位置参数

  • 根据部分函数选择参数顺序

  • 使用 RST 标记编写清晰的文档字符串

  • 围绕 Python 的堆限制设计递归函数

  • 使用 script-library 开关编写可测试的脚本

3.1 函数参数和类型提示

通过多个 Python 增强提案,类型提示变得更加复杂。mypy 工具是验证这些类型提示的一种方式,以确保提示和代码一致。本书中展示的所有示例都已通过 mypy 工具检查。

这种额外的语法对于提示是可选的。它在运行时用途有限,并且没有性能开销。

3.1.1 准备工作

我们需要下载并安装 mypy 工具。通常,这是通过以下终端命令完成的:

(cookbook3) % python -m pip install mypy

使用 python -m pip 命令确保 pip 命令将与当前活动的虚拟环境相关联。在这个例子中,提示显示了名为 cookbook3 的虚拟环境。

我们还可以使用 pyright 工具来检查类型提示。

例如,为了说明类型提示,我们将查看一些颜色计算。这些计算中的第一个是从在 HTML 页面样式表中常用颜色代码中提取红色、绿色和蓝色值。值的编码方式有很多种,包括字符串、整数和元组。以下是数据类型的一些变体:

  • 例如,一个以#开头的前导六位十六进制字符的字符串,如"#C62D42"

  • 一个六位十六进制字符的字符串,例如,"C62D42"

  • 例如,一个 Python 数值,比如 0xC62D42

  • 例如,一个包含 R, G 和 B 整数的三个元组,(198, 45, 66)

对于字符串和数字,我们直接使用类型名称,str 或 int。对于元组,我们使用更复杂的看起来像 tuple[int, int, int]。

目标是三个整数值。从字符串或整数到三个值的转换涉及两个单独的步骤:

  1. 如果值是字符串,则使用 int() 函数将其转换为单个整数。

  2. 对于单个整数值,使用 >> 和 & 运算符将整数拆分为三个单独的值。这是将单个整数值 hx_int 转换为三个单独的 r, g, b 值的核心计算:

     r, g, b = (hx_int >> 16) & 0xFF, (hx_int >> 8) & 0xFF, hx_int & 0xFF
    

一个单独的 RGB 整数有三个单独的值,这些值通过位移操作组合在一起。红色值左移了 16 位。为了提取这个组件,使用 >> 运算符将值右移 16 位。& 运算符应用 0xff 作为“掩码”,仅保存可能更大的数字的 8 位。为了提取绿色组件,右移 8 位。蓝色值占据最低的有效 8 位。

3.1.2 如何实现...

对于某些函数,从工作实现开始并添加提示可能最简单。以下是它是如何工作的:

  1. 不添加任何提示编写函数:

    def hex2rgb_1(hx_int): 
    
        if isinstance(hx_int, str): 
    
            if hx_int[0] == "#": 
    
                hx_int = int(hx_int [1:], 16) 
    
            else: 
    
                hx_int = int(hx_int, 16) 
    
        r, g, b = (hx_int >> 16) & 0xff, (hx_int >> 8) & 0xff, hx_int & 0xff 
    
        return r, g, b
    
  2. 添加结果提示。它基于返回语句。在这个例子中,返回是一个包含三个整数的元组,tuple[int, int, int]。

  3. 添加参数提示。在这种情况下,我们有两个参数的替代类型:它可以是字符串或整数。在类型提示的正式语言中,这是两种类型的联合。参数可以描述为 Union[str, int] 或 str | int。如果使用 Union,则必须从 typing 模块导入定义。

将提示合并到一个函数中,得到以下定义:

def hex2rgb(hx_int: int | str) -> tuple[int, int, int]: 

    if isinstance(hx_int, str): 

        if hx_int[0] == "#": 

            hx_int = int(hx_int[1:], 16) 

        else: 

            hx_int = int(hx_int, 16) 

    r, g, b = (hx_int >> 16) & 0xff, (hx_int >> 8) & 0xff, hx_int & 0xff 

    return r, g, b

3.1.3 它是如何工作的...

这些类型提示在 Python 代码执行时没有影响。提示是为了让人阅读和供外部工具,如 mypy,验证。工具可以确认 hx_int 变量始终用作整数或字符串。

在 r, g, b = 赋值语句中,预期 hx_int 的值是一个整数。mypy 工具可以确认运算符适用于整数值,并且返回类型与计算类型匹配。

我们可以通过在代码中插入 reveal_type(hx_int) 函数来观察 mypy 工具对类型的分析。这个语句具有函数语法;它仅在运行 mypy 工具时使用。我们只有在运行 mypy 时才会看到这个输出,我们必须在尝试对模块进行任何其他操作之前删除这额外的代码行。

当我们在 recipe_01_reveal.py 文件上在 shell 提示符运行 mypy 时,输出看起来是这样的:

(cookbook3) % mypy src/ch03/recipe_01_reveal.py 

src/ch03/recipe_01_reveal.py:15: note: Revealed type is "builtins.int" 

Success: no issues found in 1 source file

从 reveal_type(hx_int) 行输出的结果告诉我们,mypy 确定在第一个 if 语句完成后,变量将具有整数值。一旦我们看到了揭示的类型信息,我们需要从文件中删除 reveal_type(hx_int) 行。

3.1.4 更多内容...

让我们看看一个相关的计算。这个计算将 RGB 数值转换为色调-饱和度-亮度 (HSL) 值。这些 HSL 值可以用来计算补色。一个额外的算法,将 HSL 转换回 RGB 值,可以帮助为网页编码颜色:

  • RGB 到 HSL:我们将仔细研究这个,因为它有复杂的类型提示。

  • HSL 补色:关于“最佳”补色可能存在多种理论。我们将略过这些细节。

  • HSL 到 RGB:这将是最后一步,但我们将忽略这个计算的细节。

我们不会仔细研究两个实现。它们并不特别复杂,但这些计算细节可能会分散对类型和类型提示的理解。请参阅 www.easyrgb.com/en/math.php

我们首先通过一个类似这样的存根定义来草拟函数的定义:def function_name() -> return_type:

def rgb_to_hsl_t(rgb: tuple[int, int, int]) -> tuple[float, float, float]: 

    ...

这可以帮助我们可视化多个相关函数,以确保它们都具有一致的类型。其他两个函数有类似的存根:

def hsl_comp_t(hsl: tuple[float, float, float]) -> tuple[float, float, float]: 

    ...
def hsl_to_rgb_t(hsl: tuple[float, float, float]) -> tuple[int, int, int]: 

    ...

在写下这个初始的存根定义列表之后,我们可以看到一些类型提示在略有不同的上下文中被重复。这表明我们需要创建一个单独的命名类型来避免重复细节。我们将为重复的类型细节提供一个名称:

from typing import TypeAlias 

RGB_a: TypeAlias = tuple[int, int, int] 

HSL_a: TypeAlias = tuple[float, float, float]
 def rgb_to_hsl(color: RGB_a) -> HSL_a: 

    ...
 def hsl_complement(color: HSL_a) -> HSL_a: 

    ...
 def hsl_to_rgb(color: HSL_a) -> RGB_a: 

    ...

本概述中各种函数的介绍可以帮助确保每个函数使用数据的方式与其他函数保持一致。

RGB_a 和 HSL_a 名称包含一个后缀 _a 以帮助区分这些类型别名与这个食谱中的其他示例。在实际应用中,像 _a 这样的后缀字符串来显示名称是别名将会造成视觉上的杂乱,应该避免。

如第一章中提到的 使用 NamedTuples 简化元组中的项目访问,我们可以为这些元组类型提供一组更具描述性的名称:

from typing import NamedTuple 

class RGB(NamedTuple): 

    red: int 

    green: int 

    blue: int

我们定义了一个独特的新 NamedTuple 子类,称为 RGB。使用名称可以帮助阐明代码背后的意图。

3.1.5 参见

  • mypy 项目包含大量信息。有关类型提示如何工作的更多信息,请参阅 mypy.readthedocs.io

  • pyright 项目是另一个有用的类型提示工具。有关更多信息,请参阅 microsoft.github.io/pyright

3.2 设计具有可选参数的函数

当我们定义一个函数时,我们通常需要可选参数。这使我们能够编写更加灵活且易于阅读的函数。

我们也可以将其视为创建一组密切相关函数的方法。每个函数都有略微不同的参数集合——称为签名——但所有函数都共享相同的简单名称。这有时被称为“重载”函数。在类型模块中,@overload 装饰器可以帮助在更复杂的情况下创建类型提示。

可选参数的一个例子是内置的 int() 函数。此函数有两个签名:

  • int(str) -> int。例如,int('355') 的值为 355。可选的基数参数默认值为 10。

  • int(str, base) -> int。例如,int('163', 16) 的值为 355。在这种情况下,基数参数的值是 16。

3.2.1 准备工作

许多游戏依赖于骰子集合。赌场游戏 Craps 使用两个骰子。像 Zonk(或贪婪或一万)这样的游戏使用六个骰子。有一个可以处理所有这些变化的掷骰子函数是很有用的。

3.2.2 如何操作...

我们有两种设计具有可选参数的函数的方法:

  • 从一般到特殊:首先设计最通用的解决方案,并为最常见的情况提供默认值。

  • 特殊到一般:首先设计几个相关的函数。然后我们将它们合并成一个通用的函数,该函数涵盖所有情况,并特别指定一个原始函数作为默认行为。

我们首先探讨从特殊到一般的方法,因为这通常更容易从一个具体例子开始。

特殊到一般设计

在整个示例中,我们将使用略有不同的名称,因为函数在演变过程中。这简化了不同版本的单元测试和比较。我们将这样进行:

  1. 编写一个游戏函数。我们将从 Craps 游戏开始,因为它似乎是最简单的:

    import random 
    
    def die() -> int: 
    
        return random.randint(1, 6) 
    
    def craps() -> tuple[int, int]: 
    
        return (die(), die())
    

    我们定义了一个函数,die(),来封装有关标准骰子的一个基本事实。通常使用五个柏拉图立体,产生四面体、六面体、八面体、十二面体和二十面体骰子。randint() 表达式假设是一个六面的立方体。

  2. 编写下一个游戏函数。我们将继续到 Zonk 游戏:

    def zonk() -> tuple[int, ...]: 
    
      return tuple(die() for x in range(6))
    

    我们使用生成器表达式创建了一个包含六个骰子的元组对象。我们将在第九章节中深入探讨生成器表达式。

    zonk() 函数体内的生成器表达式有一个变量 x,这是必需的语法,但该值被忽略。这也常见于写作 tuple(die() for _ in range(6))。变量 _ 是一个有效的 Python 变量名,通常在需要变量名但从未使用时使用。

  3. 定位 craps() 和 zonk() 函数中的共同特征。在这种情况下,我们可以重构 craps() 函数的设计,使其遵循 zonk() 函数的模式。而不是构建 exactly two evaluations of the die() 函数,我们可以引入一个基于 range(2) 的生成器表达式,该表达式将评估 die() 函数两次:

    def craps_v2() -> tuple[int, ...]: 
    
        return tuple(die() for x in range(2))
    

    合并两个函数。这通常涉及暴露之前是字面值的变量:

    def dice_v2(n: int) -> tuple[int, ...]: 
    
        return tuple(die() for x in range(n))
    

    这提供了一个通用的函数,涵盖了 Craps 和 Zonk 游戏的需求。

  4. 识别最常见的使用情况,并将此作为任何引入的参数的默认值。如果我们最常见的模拟是 Craps,我们可能会这样做:

    def dice_v3(n: int = 2) -> tuple[int, ...]: 
    
        return tuple(die() for x in range(n))
    

    现在,我们可以使用 dice_v3() 来玩 Craps 游戏。在 Zonk 游戏的第一轮中,我们需要使用表达式 dice_v3(6)。

  5. 检查类型提示以确保它们描述了参数和返回值。在这种情况下,我们有一个整数值的参数,返回值是一个整数元组,描述为 tuple[int, ...]。

在整个示例中,名称从 dice() 发展到 dice_v2(),然后到 dice_v3()。这可以使得在菜谱中更容易看到差异。一旦编写了最终版本,删除其他版本并将这些函数的最终版本重命名为 dice()、craps() 和 zonk() 是有意义的。它们演变的历程可能成为一篇博客文章,但不需要保留在代码中。

从一般到特殊的设计

在遵循从一般到特殊策略时,我们首先确定所有需求。预见所有替代方案可能很困难,这使得这个任务更具挑战性。我们通常会通过向需求中引入变量来完成这项工作:

  1. 总结掷骰子需求。我们可能从如下列表开始:

    • Craps:两个骰子

    • Zonk 的第一轮:六个骰子

    • Zonk 的后续轮次:一到六个骰子

  2. 用显式参数替换任何字面值重写需求。我们将用参数 n 替换我们所有的数字。这个参数将取值为 2、6 或 1 ≤ n ≤ 6 范围内的值。我们想确保我们已经正确地参数化了每个不同的函数。

  3. 编写符合一般模式的函数:

    def dice_d1(n): 
    
        return tuple(die() for x in range(n))
    

    在第三种情况——Zonk 的后续轮次——中,我们确定了一个由应用程序在玩 Zonk 时施加的约束 1 ≤ n ≤ 6。

  4. 为最常见的用例提供一个默认值。如果我们最常见的模拟是骰子游戏 Craps,我们可能会这样做:

    def dice_d2(n=2): 
    
        return tuple(die() for x in range(n))
    
  5. 添加类型提示。这些将描述参数和返回值。在这种情况下,我们有一个整数值的参数,返回值是一个整数元组,描述为 tuple[int, ...]:

    def dice(n: int=2) -> tuple[int, ...]: 
    
        return tuple(die() for x in range(n))
    

现在,我们可以使用这个 dice() 函数来玩 Craps。在 Zonk 的第一轮中,我们需要使用 dice(6)。

在这个菜谱中,名称不需要通过多个版本演变。名称演变只在书中对单元测试每个示例有用。

这个版本看起来与之前菜谱中的 dice_v2() 函数完全一样。这不是偶然的——两种设计策略通常会在一个共同解决方案上汇聚。

3.2.3 它是如何工作的...

Python 提供参数值的规则允许确保每个参数都给出了一个参数值。我们可以将这个过程想象成这样:

  1. 在存在默认值的地方,设置这些参数。默认值使这些参数成为可选的。

  2. 对于没有名称的参数——例如,dice(2)——参数值按位置分配给参数。

  3. 对于具有名称的参数——例如,dice(n=2)——参数值按名称分配给参数。

  4. 如果任何参数仍然缺少值,则引发 TypeError 异常。

规则还允许我们将位置值与命名值混合。这通过提供默认值使一些参数成为可选的。

3.2.4 更多...

写出我们更通用函数的专用版本有助于编写函数。这些函数可以简化应用程序:

def craps_v3(): 

    return dice(2) 

def zonk_v3(): 

    return dice(6)

我们的应用程序功能 - craps_v3() 和 zonk_v3() - 依赖于一个通用函数,dice()。

这些形成了依赖层,使我们不必了解太多细节。这种分层抽象的想法有时被称为分块,是一种通过隔离细节来管理复杂性的方法。

3.2.5 参考以下内容

  • 我们将在本章后面的基于部分函数选择参数顺序食谱中扩展这些想法。

  • 我们已经使用了涉及不可变对象的可选参数。在本食谱中,我们专注于数字。在第四章中,我们将探讨可变对象,它们具有可以更改的内部状态。在避免为函数参数使用可变默认值食谱中,我们将探讨可选值的额外考虑。

3.3 使用超级灵活的关键字参数

一些设计问题涉及在给出足够已知值时求解一个未知数的简单方程。例如,速率、时间和距离有一个简单的线性关系。我们可以求解任何一个,当给出其他两个时。

有三个与 r × t = d 相关的解:

  • d = r × t

  • r = dt

  •  d t = r

例如,在设计电路时,基于欧姆定律使用一组类似的方程。在这种情况下,方程将电阻、电流和电压联系起来。

在某些情况下,我们希望有一个可以根据已知和未知的内容执行三种不同计算的实施方案。

3.3.1 准备工作

我们将构建一个单一的功能,通过体现所有三种解决方案,可以解决任何两个已知值的速率-时间-距离(RTD)计算。通过微小的变量名更改,这适用于许多现实世界的问题。

我们不一定需要一个单一的值作为答案。我们可以通过创建一个包含三个值的 Python 小字典来稍微泛化这一点;其中两个是已给出的,一个是计算得出的。我们将在第五章中更详细地探讨字典。

当有问题时,我们将使用警告模块而不是引发异常:

 import warnings

有时,产生一个可疑的结果比停止处理更有帮助。

3.3.2 如何实现...

  1. 为每个未知数求解方程。有三个单独的表达式:

    • distance = rate * time

    • rate = distance / time

    • time = distance / rate

  2. 根据其中一个值在未知时为 None 的条件,将每个表达式包裹在一个 if 语句中:

     if distance is None: 
    
            distance = rate * time 
    
        elif rate is None: 
    
            rate = distance / time 
    
        elif time is None: 
    
            time = distance / rate
    
  3. 请参考第二章的设计复杂的 if...elif 链配方,以获取设计这些复杂 if...elif 链的指导。包括 Else-Raise 选项的变体:

        else: 
    
            warnings.warning("Nothing to solve for")
    
  4. 构建最终的字典对象:

        return dict(distance=distance, rate=rate, time=time)
    
  5. 使用具有默认值 None 的关键字参数将所有这些封装成一个函数。这导致参数类型为 Optional[float],通常表示为 float | None。返回类型是一个具有字符串键的字典,总结为 dict[str, float | None]。它看起来像这样:

    def rtd( 
    
        distance: float | None = None, 
    
        rate: float | None = None, 
    
        time: float | None = None, 
    
    ) -> dict[str, float | None]: 
    
        if distance is None and rate is not None and time is not None: 
    
            distance = rate * time 
    
        elif rate is None and distance is not None and time is not None: 
    
            rate = distance / time 
    
        elif time is None and distance is not None and rate is not None: 
    
            time = distance / rate 
    
        else: 
    
            warnings.warn("Nothing to solve for") 
    
        return dict(distance=distance, rate=rate, time=time)
    

类型提示往往会使函数定义变得非常长,以至于不得不跨越五行代码。这么多可选值的呈现很难总结!

我们可以这样使用生成的函数:

>>> rtd(distance=31.2, rate=6) 

{’distance’: 31.2, ’rate’: 6, ’time’: 5.2}

这表明以 6 节的速度航行 31.2 海里需要 5.2 小时。

为了得到格式良好的输出,我们可能这样做:

>>> result = rtd(distance=31.2, rate=6) 

>>> (’At {rate}kt, it takes ’ 

... ’{time}hrs to cover {distance}nm’).format_map(result) 

’At 6kt, it takes 5.2hrs to cover 31.2nm’

为了打断长字符串,我们使用了第二章的设计复杂的 if...elif 链配方中的知识。

为了使警告更加明显,可以使用警告模块设置一个过滤器,将警告提升为错误。使用表达式 warnings.simplefilter('error') 将警告转换为可见的异常。

3.3.3 它是如何工作的...

由于我们已经为所有参数提供了默认值,因此我们可以为三个参数中的任意两个提供参数值,然后函数可以求解第三个参数。这使我们不必编写三个单独的函数。

返回字典作为最终结果并不是这个问题的关键。这是一个方便的方式来展示输入和输出。它允许函数返回统一的结果,无论提供了哪些参数值。

3.3.4 更多...

我们有这个问题的另一种公式,它涉及更多的灵活性。Python 函数有一个所有其他关键字参数,前缀为**。

我们可以利用灵活的关键字参数并坚持要求所有参数都作为关键字提供:

def rtd2(**keywords: float) -> dict[str, float | None]: 

    rate = keywords.get(’rate’) 

    time = keywords.get(’time’) 

    distance = keywords.get(’distance’) 

    # etc.

关键字类型提示表明,这些参数的所有值都将为 float 对象。在极少数情况下,不是所有关键字参数的类型都相同;在这种情况下,一些重新设计可能有助于使类型更清晰。

这个版本使用字典的 get()方法在字典中查找给定的键。如果键不存在,则提供一个默认值 None。

字典的 get()方法允许第二个参数,即默认值,如果键不存在,则可以提供而不是 None。

这种开放式设计具有更大的灵活性,这是一个潜在的优势。一个潜在的缺点是,实际的参数名称难以辨认,因为它们不是函数定义的一部分,而是函数体的一部分。我们可以遵循 Writing better docstrings with RST markup 配方并提供一个好的文档字符串。然而,似乎更好的做法是将参数名称明确地作为 Python 代码的一部分提供,而不是通过文档隐式地提供。

这还有另一个,并且更加深远的缺点。问题在以下不良示例中暴露出来:

>>> rtd2(distnace=31.2, rate=6) 

{’distance’: None, ’rate’: 6, ’time’: None}

这不是我们想要的行为。拼写错误的“distance”没有被报告为 TypeError 异常。拼写错误的参数名称在任何地方都没有被报告。为了揭示这些错误,我们需要添加一些编程来从关键字字典中弹出项目,并在移除预期名称后报告剩余名称的错误:

def rtd3(**keywords: float) -> dict[str, float | None]: 

    rate = keywords.pop("rate", None) 

    time = keywords.pop("time", None) 

    distance = keywords.pop("distance", None) 

    if keywords: 

        raise TypeError( 

           f"Invalid keyword parameter: {’’.join(keywords.keys())}")

这种设计将检测拼写错误。额外的处理表明,显式参数名称可能比无限制名称集合的灵活性更好。

3.3.5 参考信息

  • 我们查看第二章中 Writing better docstrings with RST markup 配方中函数的文档。

3.4 使用*分隔符强制关键字参数

有一些情况下,一个函数会有大量的位置参数。从实用主义的角度来看,具有三个以上参数的函数可能会让人困惑。大量的传统数学似乎都集中在单参数和双参数函数上。似乎没有太多常见的数学运算符涉及三个或更多操作数。

当难以记住参数的所需顺序时,这表明参数太多。

3.4.1 准备工作

我们将查看一个用于准备风寒表并写入 CSV 格式输出文件的函数。我们需要提供一系列温度、一系列风速以及我们想要创建的文件信息。这有很多参数。

显然温度,即风寒温度,T[wc],的一个公式是:

Twc(Ta,V ) = 13.2 + 0.6215Ta − 11.37V 0.16 + 0.3965TaV 0.16

风寒温度,Twc ,基于空气温度,Ta ,以摄氏度为单位,以及风速,V ,以公里每小时为单位。

对于美国人来说,这需要一些转换:

  • 将温度,Ta ,从华氏度,∘F ,转换为摄氏度,∘C T = 5(F−-32)- a 9

  • 将风速,V ,从英里每小时,Vmph ,转换为公里每小时:V = 1.609344Vmph

  • 结果,Twc ,需要从∘C 转换回∘F  9Twc- F = 32+ 5

我们不会将这些美国转换折叠到解决方案中。我们将把这留给你作为练习。

计算风寒温度的函数,T_wc(),看起来像这样:

def T_wc(T: float, V: float) -> float: 

    return 13.12 + 0.6215*T - 11.37*V**0.16 + 0.3965*T*V**0.16

这个函数有一个不寻常的名字,T_wc()。我们匹配了 T[wc] 的正式定义,而不是强制执行 PEP-8 规则,即函数名以小写字母开头。在这种情况下,似乎坚持使用文献中使用的名称,而不是基于语言习惯强加名称会更好。

创建风寒表的 一种方法 是创建如下所示的东西:

import csv 

from typing import TextIO 

def wind_chill( 

    start_T: int, stop_T: int, step_T: int, 

    start_V: int, stop_V: int, step_V: int, 

    target: TextIO 

) -> None: 

    """Wind Chill Table.""" 

    writer= csv.writer(target) 

    heading = [’’]+[str(t) for t in range(start_T, stop_T, step_T)] 

    writer.writerow(heading) 

    for V in range(start_V, stop_V, step_V): 

        row = [float(V)] + [ 

            T_wc(T, V) 

            for T in range(start_T, stop_T, step_T) 

        ] 

        writer.writerow(row)

在我们到达设计问题之前,让我们看看基本处理。我们期望使用此函数的人已经使用 with 上下文打开了一个输出文件。这遵循了第二章中 Managing a context using the with statement 的配方。在这个上下文中,我们为 CSV 输出文件创建了一个写入。我们将在第十一章中更深入地探讨这一点。

标题变量的值包括一个列表字面量和构建列表的推导式。我们将在第四章中探讨列表。我们将在第九章中探讨推导式和生成器表达式。

同样,表格的每一行都是由一个表达式构建的,该表达式将单个浮点值与列表推导式相结合。列表由通过 wind-chill 函数,T_wc() 计算的值组成。我们根据表格中的行提供风速,V。我们还根据表格中的列提供温度,T。

wind_chill() 函数的整体定义提出了一个问题:wind_chill() 函数有七个不同的位置参数。当我们尝试使用这个函数时,我们最终得到如下代码:

>>> from pathlib import Path 

>>> p = Path(’data/wc1.csv’) 

>>> with p.open(’w’,newline=’’) as target: 

...     wind_chill(0, -45, -5, 0, 20, 2, target)

所有这些数字是什么?我们能做些什么来帮助解释所有这些数字背后的目的?

3.4.2 如何做到...

当我们拥有大量参数时,要求使用关键字参数而不是位置参数是有帮助的。我们可以在两组参数之间使用 * 作为分隔符。

在我们的例子中,结果函数定义具有以下存根定义:

def wind_chill_k( 

    *, 

    start_T: int, stop_T: int, step_T: int, 

    start_V: int, stop_V: int, step_V: int, 

    target: Path 

) -> None:

让我们看看它在实践中如何使用不同类型的参数:

  1. 当我们尝试使用令人困惑的位置参数时,我们会看到如下情况:

     >>> wind_chill_k(0, -45, -5, 0, 20, 2, target) 
    
    Traceback (most recent call last): 
    
    ... 
    
    TypeError: wind_chill_k() takes 0 positional arguments but 7 were given
    
  2. 我们必须使用具有显式参数名称的函数,如下所示:

     >>> p = Path(’data/wc2.csv’) 
    
    >>> with p.open(’w’, newline=’’) as output_file: 
    
    ...     wind_chill_k(start_T=0, stop_T=-45, step_T=-5, 
    
    ...     start_V=0, stop_V=20, step_V=2, 
    
    ...     target=output_file)
    

这种强制使用关键字参数的使用迫使我们每次使用这个看似复杂的函数时都要写一个更长但更清晰的语句。

3.4.3 它是如何工作的...

当 * 字符用作参数定义时,它将两个参数集合分开:

  • 在 * 之前,我们列出可以按位置或按关键字命名的参数值。在这个例子中,我们没有这些参数。

  • 在 * 之后,我们列出必须用关键字给出的参数值。在我们的例子中,这是所有参数。

print() 函数是这一点的示例。它有三个仅关键字参数用于输出文件、字段分隔符字符串和行结束字符串。

3.4.4 更多内容...

当然,我们可以将这种技术与各种参数的默认值结合起来。例如,我们可以对此进行修改,从而引入一个单一的默认值:

import sys 

from typing import TextIO 

def wind_chill_k2( 

    *, 

    start_T: int, stop_T: int, step_T: int, 

    start_V: int, stop_V: int, step_V: int, 

    target: TextIO = sys.stdout 

) -> None: 

    ...

我们现在可以用两种方式使用这个函数:

  • 这里有一种在控制台上打印表格的方法,使用默认的目标:

    >>> wind_chill_k2( 
    
    ...     start_T=0, stop_T=-45, step_T=-5, 
    
    ...     start_V=0, stop_V=20, step_V=2)
    
  • 这里有一种使用显式目标写入文件的方法:

    >>> import pathlib 
    
    >>> path = pathlib.Path("data/wc3.csv") 
    
    >>> with path.open(’w’, newline=’’) as output_file: 
    
    ...     wind_chill_k2(target=output_file, 
    
    ...     start_T=0, stop_T=-45, step_T=-5, 
    
    ...     start_V=0, stop_V=20, step_V=2)
    

我们可以对这些更改更有信心,因为必须按名称提供参数。我们不必仔细检查以确保参数的顺序。

作为一种一般模式,我们建议在函数有三个以上参数时这样做。一两个参数很容易记住。大多数数学运算符是一元或二元。虽然第三个参数仍然可能很容易记住,但第四个(及以后的)参数将变得非常难以回忆。

3.4.5 参考以下内容

  • 参考基于部分函数选择参数顺序的选择参数顺序的食谱以了解此技术的另一种应用。

3.5 使用斜杠(/)分隔仅位置参数

我们可以在参数列表中使用斜杠(/)字符将参数分为两组。在斜杠之前,所有参数值以位置方式工作。在斜杠参数之后,参数值可以是位置方式,也可以使用名称。

这应该用于满足以下所有条件的函数:

  • 只使用少数几个位置参数(不超过三个)。

  • 而且它们都是必需的。

  • 而顺序是如此明显,任何改变都可能令人困惑。

这一直是标准库的一个特性。例如,math.sin() 函数只能使用位置参数。正式定义如下:

>>> help(math.sin) 

Help on built-in function sin in module math: 

sin(x, /) 

    Return the sine of x (measured in radians).

尽管有一个 x 参数名称,但我们不能使用这个名称。如果我们尝试使用它,我们会看到以下异常:

>>> import math 

>>> math.sin(x=0.5) 

Traceback (most recent call last): 

... 

TypeError: math.sin() takes no keyword arguments

x 参数只能以位置方式提供。help() 函数的输出提供了一个如何使用斜杠(/)分隔符来实现这一点的建议。

3.5.1 准备工作

一些内部内置函数使用仅位置参数;设计模式在我们的函数中也可能很有帮助。为了有用,必须有非常少的仅位置参数。由于大多数数学运算符有一个或两个操作数,这表明一个或两个仅位置参数可能很有用。

我们将考虑两个函数,用于将美国使用的华氏温度系统和世界上几乎所有其他地方使用的摄氏温度系统之间的单位转换:

  • ∘ F 转换为 ∘ C  5(F−32) C = ---9---

  • ∘C 转换为 ∘F F = 32 + 9C- 5

这些函数每个只有一个参数,使其成为仅位置参数的合理示例。

3.5.2 如何操作...

  1. 定义函数:

    def F_1(c: float) -> float: 
    
        return 32 + 9 * c / 5
    
  2. 在仅位置参数之后添加/参数分隔符:

    def F_2(c: float, /) -> float: 
    
        return 32 + 9 * c / 5
    

在这些例子中,我们在函数名称后加上 _1 和 _2 后缀,以便清楚地说明每个步骤对应的定义。这是同一函数的两个版本,它们应该有相同的名称。它们被分开以展示函数编写的历史;这并不是一个实用的命名约定,除非在写书时,一些部分完成的函数有自己的单元测试。

3.5.3 它是如何工作的...

/分隔符将参数名称分为两组。在/之前是必须按位置提供参数值的参数:不能使用命名参数值。在/之后是允许使用名称的参数。

让我们看看温度转换的一个稍微复杂一点的版本:

def C(f: float, /, truncate: bool=False) -> float: 

    c = 5 * (f - 32) / 9 

    if truncate: 

        return round(c, 0) 

    return c

这个函数有一个名为 f 的仅位置参数。它还有一个截断参数,可以通过名称提供。这导致有三种使用此函数的不同方式,如下面的例子所示:

>>> C(72) 

22.22222222222222 

>>> C(72, truncate=True) 

22.0 

>>> C(72, True) 

22.0

第一个例子显示了仅位置参数和没有四舍五入的输出。这是一个看起来复杂的价值。

第二个例子使用命名参数样式将非位置参数截断设置为 True。第三个例子提供了两种参数值的位置。

3.5.4 更多内容...

这可以与*分隔符结合,创建非常复杂的函数签名。参数可以分解为三组:

  • /分隔符之前的参数必须按位置提供。这些必须是第一个。

  • /分隔符之后的参数可以通过位置或名称提供。

  • *分隔符之后的参数必须只按名称提供。这些名称是最后提供的,因为它们永远不会按位置匹配。

3.5.5 参考内容

  • 有关分隔符的详细信息,请参阅使用分隔符强制关键字参数的配方。

3.6 基于部分函数选择参数顺序

“部分函数”这个术语被广泛用来描述函数的部分应用。一些参数值是固定的,而另一些则变化。我们可能有一个函数,f (a,b,c) ,其中a b 有固定的值。有了这些固定值,我们就有了一个函数的新版本,fp(c)

当我们查看复杂函数时,我们有时会看到我们使用函数的方式中存在一种模式。例如,我们可能会多次评估一个函数,其中一些参数值由上下文固定,而其他参数值则随着处理细节的变化而变化。有一些固定的参数值暗示了一个部分函数。

创建部分函数可以通过避免重复特定上下文中固定的参数值来简化我们的编程。

3.6.1 准备工作

我们将查看 haversine 公式的版本。这个公式计算地球表面上两点,p[1] = (lon[1],lat[1]) 和 p[2] = (lon[2],lat[2]) 之间的距离:

 ∘ ------------------------------------------------- a = sin2(lat2 −-lat1)+ cos(lat1)cos(lat2) sin2(lat2 −-lat1) 2 2 c = 2arcsin a

重要的计算得到两点之间的中心角,c。角度以弧度为单位。我们必须通过乘以地球的平均半径以某些给定的单位来将这个角度转换为距离。如果我们把角度 c 乘以 3,959 英里的半径,我们将把角度转换为英里。

这里是这个函数的实现:

from math import radians, sin, cos, sqrt, asin 

MI = 3959 

NM = 3440 

KM = 6372 

def haversine( 

    lat_1: float, lon_1: float, 

    lat_2: float, lon_2: float, R: float 

) -> float: 

    """Distance between points. 

    R is Earth’s radius. 

    R=MI computes in miles. Default is nautical miles. 

    >>> round(haversine(36.12, -86.67, 33.94, -118.40, R=6372.8), 5) 

    2887.25995 

    """ 

    _lat = radians(lat_2) - radians(lat_1) 

    _lon = radians(lon_2) - radians(lon_1) 

    lat_1 = radians(lat_1) 

    lat_2 = radians(lat_2) 

    a = sqrt( 

        sin(_lat / 2) ** 2 + 

        cos(lat_1) * cos(lat_2) * sin(_lon / 2) ** 2 

    ) 

    return R * 2 * asin(a) 

doctest 示例使用了一个地球半径,多了一个未在其他地方使用的额外小数点。这个示例的输出将与网上找到的其他示例相匹配。

我们经常遇到的问题是 R 的值在特定上下文中很少改变。一个上下文可能在整个应用程序中使用公里,而另一个上下文使用海里。我们希望为特定上下文强制一个默认值 R = NM,在给定上下文中得到海里,而无需编辑模块。

我们将查看几种提供一致参数值的方法。

3.6.2 如何实现...

在某些情况下,整体上下文将确定一个参数的单个值。这个值很少改变。以下是为参数提供一致值的三种常见方法:

  • 将函数包装在一个新函数中,该函数提供默认值。

  • 创建一个带有默认值的偏函数。这有两个进一步的改进:

    • 我们可以将默认值作为关键字参数提供。

    • 我们可以将默认值作为位置参数提供。

我们将在本食谱中分别查看这些不同的变体。

包装一个函数

这里是我们如何稍微修改函数并创建一个包装器的示例:

  1. 将一些参数设置为位置参数,将一些参数设置为关键字参数。我们希望上下文特征——那些很少改变的——是关键字参数。更频繁改变的参数应保留为位置参数:

    def haversine_k( 
    
        lat_1: float, lon_1: float, 
    
        lat_2: float, lon_2: float, *, R: float 
    
    ) -> float: 
    
        ... # etc.
    

    我们可以遵循使用 * 分隔符强制关键字参数食谱。

  2. 我们可以编写一个包装函数,该函数将应用所有位置参数,不进行修改。它将作为长期上下文的一部分提供额外的关键字参数:

    def nm_haversine_1(*args): 
    
        return haversine_k(*args, R=NM)
    

    我们在函数声明中有 *args 构造来接受所有位置参数值作为一个单一的元组,args。我们在评估 haversine() 函数时使用类似外观的 *args 来将元组展开为该函数的所有位置参数值。

在这种情况下,所有类型都是 float。我们可以使用 *args: float 提供合适的提示。这并不总是有效,并且这种处理参数的方式——虽然看起来简单——可能会隐藏问题。

使用关键字参数创建部分函数

定义作为部分函数工作良好的函数的一种方法是通过使用关键字参数:

  1. 我们可以遵循使用 * 分隔符强制关键字参数的配方来做这件事。我们可能会改变基本的海里函数,使其看起来像这样:

    def haversine_k( 
    
        lat_1: float, lon_1: float, 
    
        lat_2: float, lon_2: float, *, R: float 
    
    ) -> float: 
    
        ... # etc.
    
  2. 使用关键字参数创建部分函数:

    from functools import partial 
    
    nm_haversine_3 = partial(haversine, R=NM)
    

partial() 函数从一个现有函数和一个具体的参数值集合中构建一个新的函数。nm_haversine_3() 函数在构建部分函数时为 R 提供了一个特定的值。

我们可以像使用任何其他函数一样使用它:

>>> round(nm_haversine_3(36.12, -86.67, 33.94, -118.40), 2) 

1558.53

我们得到海里单位的答案,这使得我们能够轻松地进行与航海相关的计算。将 R=NM 设置为固定值使代码看起来稍微简单一些,并且更加可信。消除了一个计算中 R 值可能不正确的情况。

使用位置参数创建部分函数

如果我们尝试使用 partial() 与位置参数一起,我们被限制在部分定义中提供最左边的参数值。这让我们想到函数的前几个参数可能是被部分函数或包装器隐藏的候选者:

  1. 我们需要更改基本的海里函数,将 R 参数放在第一位。这使得定义部分函数稍微容易一些。以下是更改后的定义:

    def p_haversine( 
    
        R: float, 
    
        lat_1: float, lon_1: float, lat_2: float, lon_2: float 
    
    ) -> float: 
    
        # etc.
    
  2. 使用位置参数创建部分函数

    from functools import partial 
    
    nm_haversine_4 = partial(p_haversine, NM) 
    

    partial() 函数从一个现有函数和一个具体的参数值集合中构建一个新的函数。nm_haversine_4() 函数在构建部分函数时为第一个参数 R 提供了一个特定的值。

我们可以像使用任何其他函数一样使用它:

>>> round(nm_haversine_4(36.12, -86.67, 33.94, -118.40), 2) 

1558.53

我们得到海里单位的答案,这使得我们能够轻松地进行与航海相关的计算,代码可以使用没有重复 R=NM 参数值的烦恼细节的海里函数版本。

3.6.3 它是如何工作的...

部分函数本质上与包装函数相同。我们可以在程序的其他更复杂的部分中自由构建部分函数。请注意,创建部分函数时,在考虑位置参数的顺序时会导致一些额外的考虑:

  • 如果我们尝试在包装器中使用 *args,这些必须定义在最后。所有这些参数都变成了匿名参数。这种匿名性意味着像 mypy 这样的工具可能难以确认参数是否被正确使用。文档也不会显示必要的细节。

  • 在创建部分函数时,最左边的位置参数最容易提供值。

  • 任何在 * 分隔符之后定义的关键字参数也是作为部分定义的一部分提供的好选择。

这些考虑可以让我们将最左边的参数视为一种上下文:这些参数预计很少改变,并且可以通过部分函数定义更容易地提供。

3.6.4 更多...

还有另一种封装函数的方法——我们也可以构建一个 lambda 对象。以下示例也将有效:

nm_haversine_L = lambda *args: haversine_k(*args, R=NM)

这依赖于 haversine_k()函数的定义,其中 R 参数被标记为关键字参数。如果没有这种位置参数和关键字参数值之间的明确分离,这个 lambda 定义将导致 mypy 发出警告。如果我们使用原始的 haversine()函数,警告会告诉我们 R 可能得到多个值。

lambda 对象是一个去除了其名称和主体的函数。函数定义简化为仅包含两个基本要素:

  • 在这个例子中,参数列表为*args。

  • 一个单一的表达式,即结果,haversine_k(*args, R=NM)。lambda 不能有任何语句。

lambda 方法使得创建类型提示变得困难,这限制了它的实用性。此外,PEP-8建议不应该将 lambda 赋值给变量。

3.6.5 参考信息

3.7 使用 RST 标记编写清晰的文档字符串

我们如何清晰地记录函数的功能?我们能提供示例吗?当然可以,我们真的应该这样做。在第二章的包括描述和文档配方和使用 RST 标记编写更好的文档字符串配方中,我们查看了一些基本的文档技术。这些配方介绍了用于模块文档字符串的 ReStructuredText (RST)。

我们将扩展这些技术来编写 RST 格式的函数文档字符串。当我们使用 Sphinx 这样的工具时,我们的函数文档字符串将变成优雅的文档,描述我们的函数功能。

3.7.1 准备工作

在使用*分隔符强制关键字参数的配方中,我们查看了一个根据温度和风速计算风寒的函数。

在这个配方中,我们将展示几个带有名称尾随 _0 的函数的不同版本。从实用主义的角度来看,这种名称更改不是一个好主意。然而,为了使本书中这个函数的演变清晰,给每个新变体一个独特的名称似乎是有帮助的。

我们需要用更完整的文档来注释这个函数。

3.7.2 如何操作...

我们通常为函数描述编写以下内容:

  • 概述

  • 描述

  • 参数

  • 返回值

  • 异常

  • 测试用例

  • 任何其他似乎有意义的内容

这是我们将如何为函数创建文档。我们可以将类似的方法应用于类的成员函数,甚至是一个模块。

  1. 编写概要。不需要适当的主题。不要写 This function computes...;我们可以从 Computes.... 开始。没有必要过度强调上下文:

    def T_wc_1(T, V): 
    
        """Computes the wind chill temperature."""
    

    为了帮助阐明本书中此函数 docstring 的演变,我们在名称后附加了后缀 _1。

  2. 编写描述并提供详细信息:

    def T_wc_2(T, V): 
    
        """Computes the wind chill temperature. 
    
        The wind-chill, :math:‘T_{wc}‘, 
    
        is based on air temperature, T, and wind speed, V. 
    
        """
    

    在这种情况下,我们在描述中使用了小块排版数学。:math: 解释文本角色使用 LaTeXmath 排版。像 Sphinx 这样的工具可以使用 MathJax 或 jsMath 来处理数学排版。

  3. 描述参数。对于位置参数,通常使用 :param name: description. Sphinx 可以容忍多种变体,但这是常见的。对于必须使用关键字参数的情况,通常使用 :key name: 作为描述的前缀。

    def T_wc_3(T: float, V: float): 
    
        """Computes the wind chill temperature 
    
        The wind-chill, :math:‘T_{wc}‘, 
    
        is based on air temperature, T, and wind speed, V. 
    
        :param T: Temperature in C 
    
        :param V: Wind Speed in kph 
    
        """
    
  4. 使用 :returns:: 描述返回值

    def T_wc_4(T: float, V: float) -> float: 
    
        """Computes the wind chill temperature 
    
        The wind-chill, :math:‘T_{wc}‘, 
    
        is based on air temperature, T, and wind speed, V. 
    
        :param T: Temperature in C 
    
        :param V: Wind Speed in kph 
    
        :returns: Wind-Chill temperature in C 
    
        """
    
  5. 识别可能引发的重要异常。使用 :raises exception: 标记来定义异常的原因。有几种可能的变体,但 :raises exception: 似乎很受欢迎:

    def T_wc_5(T: float, V: float) -> float: 
    
        """Computes the wind chill temperature 
    
        The wind-chill, :math:‘T_{wc}‘, 
    
        is based on air temperature, T, and wind speed, V. 
    
        :param T: Temperature in C 
    
        :param V: Wind Speed in kph 
    
        :returns: Wind-Chill temperature in C 
    
        :raises ValueError: for wind speeds under 4.8 kph or T above 10C 
    
        """
    
  6. 如果可能,包括一个 doctest 测试用例:

    def T_wc(T: float, V: float) -> float: 
    
        """Computes the wind chill temperature 
    
        The wind-chill, :math:‘T_{wc}‘, 
    
        is based on air temperature, T, and wind speed, V. 
    
        :param T: Temperature in C 
    
        :param V: Wind Speed in kph 
    
        :returns: Wind-Chill temperature in C 
    
        :raises ValueError: for wind speeds under 4.8 kph or T above 10C 
    
        >>> round(T_wc(-10, 25), 1) 
    
        -18.8
    
  7. 编写任何附加的注释和有用的信息。我们可以在 docstring 中添加以下内容:

     See https://en.wikipedia.org/wiki/Wind_chill 
    
        .. math:: 
    
            T_{wc}(T_a, V) = 13.2 + 0.6215 T_a - 11.37 V ^ {0.16} + 0.3965 T_a V ^ {0.16}
    

    我们还包含了一个指向总结风冷计算并链接到更多详细信息页面的维基百科页面。

    我们还包含了一个 ..  math:: 指令,其中包含函数中使用的 LaTeX 公式。这通常会很好地排版,提供代码的可读版本。

3.7.3 它是如何工作的...

关于 docstrings 的更多信息,请参阅第二章的 包括描述和文档 菜单。虽然 Sphinx 很受欢迎,但它不是唯一可以从 docstring 注释创建文档的工具。Python 标准库中的 pydoc 工具也可以从 docstring 注释生成外观良好的文档。

Sphinx 工具依赖于 Docutils 包的核心 RST 处理功能。有关更多信息,请参阅 pypi.python.org/pypi/docutils

RST 规则相对简单。这个菜谱中的大多数附加功能都利用了 RST 的解释文本角色。我们每个 :param T:, :returns:, 和 :raises ValueError: 构造都是一个文本角色。RST 处理器可以使用这些信息来决定内容的样式和结构。样式通常包括一个独特的字体。上下文可能是 HTML 定义列表格式。

3.7.4 还有更多...

在许多情况下,我们还需要在函数和类之间包含交叉引用。例如,我们可能有一个准备风冷表的函数。这个函数的文档可能包括对 T_wc() 函数的引用。

Sphinx 将使用特殊的:func:文本角色生成这些交叉引用:

def wind_chill_table() -> None: 

    """Uses :func:‘T_wc‘ to produce a wind-chill 

    table for temperatures from -30C to 10C and 

    wind speeds from 5kph to 50kph. 

    """ 

    ... # etc.

我们已经使用:func:‘Twc‘在 RST 文档中的一个函数到另一个函数创建了一个引用。Sphinx 将这些转换为适当的超链接。

3.7.5 参见

  • 请参阅第二章中的包含描述和文档和使用 RST 标记编写更好的 docstrings 配方,以了解其他展示 RST 如何工作的配方。

3.8 设计围绕 Python 栈限制的递归函数

一些函数可以使用递归公式明确且简洁地定义。这里有这种方法的两个常见例子。

阶乘函数有以下的递归定义:

 ( |{ n! = 1 if n = 0, |( n × (n− 1)! if n > 0.

计算斐波那契数 F[n]的递归规则如下定义:

 ( |{ F = 1 if n = 0∨ n = 1, n |( Fn −1 + Fn −2 if n > 1.

这些都涉及一个具有简单定义值的案例和一个涉及根据同一函数的其他值计算函数值的案例。

我们遇到的问题是 Python 对这些类型的递归函数评估施加了上限。虽然 Python 的整数可以轻松计算 1000!的值,但栈限制阻止了我们随意计算。

从实用主义的角度来看,文件系统是一个递归数据结构的例子。每个目录都包含子目录。递归函数定义可以用于目录树。具有定义值的案例来自处理非目录文件。

我们通常可以将递归设计重构为消除递归,并用迭代来替换。在进行递归消除时,我们希望尽可能保留原始数学的清晰度。

3.8.1 准备工作

许多递归函数定义遵循阶乘函数的模式。这有时被称为尾递归,因为递归情况可以写在函数体的末尾:

def fact_r(n: int) -> int: 

    if n == 0: 

        return 1 

    return n * fact_r(n - 1)

函数中的最后一个表达式指的是同一个函数,但使用了不同的参数值。

我们可以重新表述这一点,避免 Python 中的递归限制。

3.8.2 如何实现...

尾递归也可以描述为一种缩减。我们将从一个值集合开始,然后将其缩减为一个单一值:

  1. 展开规则以显示所有细节:n! = n×(n−1)×(n−2)× (n − 3) ×⋅⋅⋅× 1。这有助于确保我们理解递归规则。

  2. 编写一个循环或生成器来创建所有值:N = {n,n − 1,n − 2,n − 3,…,1}。在 Python 中,这可以像 range(1, n+1)这样简单。在某些情况下,我们可能需要对基本值应用一些转换函数:N = {f(i)∣1 ≤ i < n + 1}。这是一个列表推导式;请参阅第四章中的构建列表 – 字面量、追加和推导式。

  3. 结合减少函数。在这种情况下,我们正在使用乘法计算一个大的乘积。我们可以将其总结为∏ [1≤x<n+1]x。

    这里是一个 Python 的实现:

    def prod_i(int_iter: Iterable[int]) -> int: 
    
        p = 1 
    
        for x in int_iter: 
    
            p *= x 
    
        return p
    

    math 模块中有一个等效的函数。我们不必像上面那样写出来,可以使用 from math import prod。

prod_i()函数可以如下使用来计算阶乘值:

>>> prod_i(range(1, 6)) 

120 

>>> fact(5) 

120

这工作得很好。我们已经将 prod_i()函数优化成了一个迭代函数。这次修订避免了递归版本可能遇到的栈溢出问题。

注意,range 对象是惰性的;它不会创建一个大的列表对象,从而避免了大量内存的分配。range 对象返回单个值,当它们被 prod_i()函数消耗时。

3.8.3 它是如何工作的...

尾递归定义很方便,因为它简短且易于记忆。数学家喜欢它,因为它可以帮助阐明函数的含义。

许多静态、编译型语言以与我们在此处展示的技术类似的方式创建优化代码。这是通过在虚拟机的字节码——或者实际的机器码——中注入一个特殊指令来实现的,以重新评估函数而不创建新的栈帧。Python 没有这个功能。实际上,这种优化将递归转换成一种 while 语句:

def loop_fact(n: int) -> int: 

    p = n 

    while n != 1: 

        n = n-1 

        p *= n 

    return p

特殊字节码指令的注入将导致运行快速、不透露中间修订的代码。然而,生成的指令可能不会与源文本完美匹配,这可能导致定位错误存在困难。

3.8.4 更多...

计算 F[n]斐波那契数涉及一个额外的问题。如果我们不小心,我们会多次计算很多值:

例如,为了计算 F[5] = F[4] + F[3],我们将评估以下内容:

F5 = (F3 + F2 )+ (F2 + F1)

展开 F[3]和 F[2]的定义显示了一些冗余的计算。

斐波那契问题涉及两个递归。如果我们天真地写出它,可能看起来像这样:

def fibo(n: int) -> int: 

    if n <= 1: 

        return 1 

    else: 

        return fibo(n-1) + fibo(n-2)

将像这个例子这样的东西转换成尾递归是困难的。我们有两种方法可以减少这个计算复杂度:

  • 使用记忆化

  • 重新表述问题

记忆化技术在 Python 中应用起来很简单。我们可以使用@functools.cache 作为装饰器。它看起来是这样的:

from functools import cache 

@cache 

def fibo_r(n: int) -> int: 

    if n < 2: 

        return 1 

    else: 

        return fibo_r(n - 1) + fibo_r(n - 2)

添加这个装饰器将优化更复杂的递归。

重新表述问题意味着从新的角度看待它。在这种情况下,我们可以考虑计算所有斐波那契数,直到并包括所需的 F[n]。我们只想要这个序列中的最后一个值。计算多个中间值可以相对高效。

这里是一个执行此操作的生成器函数:

from collections.abc import Iterator 

def fibo_iter() -> Iterator[int]: 

    a = 1 

    b = 1 

    yield a 

    while True: 

        yield b 

        a, b = b, a + b

这个函数是斐波那契数的无限迭代。它使用 Python 的 yield 来以懒加载的方式发出值。当客户端函数使用这个迭代器时,序列中的下一个数字是在消耗每个数字时计算的。

这是一个消耗值并也对无限迭代器施加上限的函数:

def fibo_i(n: int) -> int: 

    for i, f_i in enumerate(fibo_iter()): 

        if i == n: 

            break 

    return f_i

这个函数消耗来自 fibo_iter() 迭代器的值序列。当达到所需的数字时,break 语句结束 for 语句。

我们已经优化了递归解决方案,并将其转换为避免栈溢出潜在问题的迭代。

3.8.5 参见

  • 请参阅第二章中的 避免 break 语句的潜在问题 菜单。

3.9 使用脚本库开关编写可测试的脚本

创建 Python 脚本文件通常非常容易。当我们向 Python 提供脚本文件时,它立即运行。在某些情况下,没有函数或类定义;脚本文件是 Python 语句的序列。

这些脚本文件很难进行测试。此外,它们也很难重用。当我们想要从脚本文件集合中构建更大、更复杂的应用程序时,我们通常被迫将脚本重构成一个或多个函数。

3.9.1 准备工作

假设我们有一个名为 haversine() 的方便的 haversine 距离函数实现,它位于名为 recipe_11.py 的文件中。

该文件包含本章中 基于部分函数选择参数顺序 所示的函数和定义。这包括一个部分函数 nm_haversine(),用于计算海里距离。脚本还包含以下顶级代码:

source_path = Path("data/waypoints.csv") 

with source_path.open() as source_file: 

    reader = csv.DictReader(source_file) 

    start = next(reader) 

    for point in reader: 

        d = nm_haversine( 

            float(start[’lat’]), 

            float(start[’lon’]), 

            float(point[’lat’]), 

            float(point[’lon’]) 

        ) 

        print(start, point, d) 

        start = point

这个 Python 脚本打开一个文件,data/wapypoints.csv,并对该文件进行一些处理。虽然这很方便使用,但我们无法轻松测试它。

如果我们尝试在单元测试中导入 haversine() 函数,我们将执行脚本的其它部分。我们如何重构这个模块,以便在不打印 wapypoints.csv 文件中航点之间距离显示的情况下导入有用的函数?

3.9.2 如何做...

编写 Python 脚本可以被称为一种吸引人的麻烦;它看起来简单吸引人,但很难有效地进行测试。以下是我们将脚本转换成可测试和可重用库的方法:

  1. 识别执行脚本工作的语句。这意味着区分定义和动作。如 import、def 和 class 这样的语句是定义性的——它们创建对象但不直接执行计算或产生输出的动作。几乎所有其他语句都执行某些动作。因为一些赋值语句可能是类型提示定义的一部分,或者可能创建有用的常量,所以这种区分完全是基于意图的。

  2. 在我们的例子中,有一些赋值语句比行动更像是定义。这些赋值语句类似于 def 语句;它们只设置稍后使用的变量。以下是一些通常的定义性语句:

    from math import radians, sin, cos, sqrt, asin 
    
    from functools import partial 
    
    MI = 3959 
    
    NM = 3440 
    
    KM = 6373 
    
    def haversine( 
    
        lat_1: float, lon_1: float, 
    
        lat_2: float, lon_2: float, *, R: float) -> float: 
    
        ...  # etc.
    
     nm_haversine = partial(haversine, R=NM)
    

    模块中的其余语句旨在采取行动以产生打印结果。

  3. 将行动包装成一个函数。尽量选择一个描述性的名称。如果没有更好的名称,可以使用 main()。在这个例子中,行动计算距离,所以我们将函数命名为 distances()。

    def distances_draft(): 
    
        source_path = Path("data/waypoints.csv") 
    
        with source_path.open() as source_file: 
    
            reader = csv.DictReader(source_file) 
    
            start = next(reader) 
    
            for point in reader: 
    
                d = nm_haversine( 
    
                    float(start[’lat’]), 
    
                    float(start[’lon’]), 
    
                    float(point[’lat’]), 
    
                    float(point[’lon’]) 
    
                ) 
    
                print(start, point, d) 
    
                start = point
    

    在上述例子中,我们将函数命名为 distances_draft() 以确保它与更最终的版本明显不同。实际上,在代码向完成演化的过程中使用这样的不同名称并不必要,除非在编写书籍时,单元测试中间步骤是必要的。

  4. 在可能的情况下,提取字面量并将它们转换为参数。这通常是将字面量简单移动到具有默认值的参数中的操作。

    def distances( 
    
        source_path: Path = Path("data/waypoints.csv") 
    
    ) -> None: 
    
        ...  # etc.
    

    这使得脚本可重用,因为路径现在是一个参数而不是一个假设。

  5. 在脚本文件中包含以下 if 语句作为唯一的高级行动语句:

    if __name__ == "__main__": 
    
        distances()
    

我们已经将脚本的行动包装成一个函数。顶级行动脚本现在被一个 if 语句包裹,这样在导入时就不会执行,而是在直接运行脚本时执行。

3.9.3 它是如何工作的...

对于 Python 来说,一个模块的导入本质上等同于作为脚本运行该模块。文件中的语句按顺序从上到下执行。

当我们导入一个文件时,我们通常对执行 defclass 语句感兴趣。我们可能对一些定义有用全局变量的赋值语句感兴趣。有时,我们可能对执行主程序不感兴趣。

当 Python 运行脚本时,它会设置一些内置的特殊变量。其中之一是 __name__。这个变量的值取决于文件被执行的上下文:

  • 从命令行执行的最高级脚本:在这种情况下,内置特殊名称 __name__ 的值被设置为 "main"。

  • 由于导入语句而正在执行的文件:在这种情况下,__name__ 的值是从读取文件并执行 Python 语句创建的模块的名称。

"main" 的标准名称一开始可能看起来有点奇怪。为什么在所有情况下不使用文件名?这个特殊名称被分配是因为 Python 脚本可以从许多来源读取。它可以是一个文件。Python 也可以从 stdin 管道读取,或者可以通过使用 -c 选项在 Python 命令行中提供。

3.9.4 更多...

我们现在可以围绕可重用库构建有用的工作。我们可能会创建一个看起来像这样的应用程序脚本文件:

from pathlib import Path 

from ch03.recipe_11 import distances 

if __name__ == "__main__": 

    for trip in ’trip_1.csv’, ’trip_2.csv’: 

        distances(Path(’data’) / trip)

目标是将实际解决方案分解为两个功能集合:

  • 类和函数的定义

  • 一个非常小的以动作为导向的脚本,使用定义来完成有用的工作

我们通常从一个将这两组功能合并在一起的脚本开始。这种脚本可以被视为一个峰值解决方案。一旦我们确定它可行,我们的峰值解决方案就可以向更精细的解决方案进化。峰值或锤子是登山装备的一部分,使我们能够安全地攀登。

在开始使用峰值(spike)之后,我们可以提升我们的设计,并将代码重构为定义和动作。然后测试可以导入模块来测试各种定义,而不执行可能会覆盖重要文件的行动。

3.9.5 参见

  • 在第七章中,我们探讨了类定义。这些是除了函数定义之外,另一种广泛使用的定义性语句。

  • 我们在第十一章中讨论的 使用 CSV 模块读取定界文件 的配方也解决了 CSV 文件读取的问题。

加入我们的社区 Discord 空间

加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

PIC

第四章:4

内置数据结构第一部分:列表和集合

Python 拥有丰富的内置数据结构。这些数据结构有时被称为“容器”或“集合”,因为它们包含一系列单独的项目。这些结构涵盖了广泛的常见编程场景。

我们将概述内置的各种集合以及它们解决的问题。概述之后,我们将详细探讨列表和集合。

内置的元组和字符串类型是第一章数字、字符串和元组的一部分。这些结构是序列,因此在许多方面与列表集合相似。然而,字符串和元组似乎与不可变的数字有更多的共同点。

下一章,第五章,将探讨字典,以及一些与列表和集合相关的高级主题。特别是,它将探讨 Python 如何处理可变集合对象的引用。这在需要将列表或集合作为参数的函数定义方式上有影响。

在本章中,我们将探讨以下配方,所有这些都与 Python 的内置数据结构相关:

  • 选择数据结构

  • 构建列表 - 字面量、追加和推导式

  • 切片和切块列表

  • 缩小列表 - 删除、移除和弹出

  • 编写与列表相关的类型提示

  • 反转列表的副本

  • 构建集合 - 字面量、添加、推导式和运算符

  • 缩小集合 - remove()、pop()和差集

  • 编写与集合相关的类型提示

4.1 选择数据结构

Python 提供了一些内置数据结构来帮助我们处理数据集合。将数据结构功能与我们要解决的问题相匹配可能会令人困惑。

我们如何选择使用哪种结构?

4.1.1 准备工作

在我们将数据放入集合之前,我们需要考虑我们将如何收集数据,以及我们拥有集合后我们将做什么。一个重要的问题是如何在集合中识别特定的项目。Python 提供了多种选择。

4.1.2 如何做...

  1. 编程是否关注值的存不存在?一个例子是验证输入值。当用户输入集合中的内容时,他们的输入是有效的;否则,输入无效。简单的成员资格测试建议使用集合:

    def confirm() -> bool: 
    
        yes = {"yes", "y"} 
    
        no = {"no", "n"} 
    
        while (answer := input("Confirm: ")).lower() not in (yes | no): 
    
            print("Please respond with yes or no") 
    
        return answer in yes
    

    集合以无特定顺序的方式存储项目。如果顺序很重要,那么列表更合适。

  2. 我们是否将通过在集合中的位置来识别项?一个例子包括输入文件中的行——行号是其在集合中的位置。当我们使用索引或位置来识别项时,我们必须使用列表:

    >>> month_name_list = ["Jan", "Feb", "Mar", "Apr", 
    
    ... "May", "Jun", "Jul", "Aug", 
    
    ... "Sep", "Oct", "Nov", "Dec"] 
    
    >>> month_name_list[8] 
    
    ’Sep’ 
    
    >>> month_name_list.index("Feb") 
    
    1
    

    我们已经创建了一个名为month_name_list的列表,其中包含 12 个字符串项,并按照特定顺序排列。我们可以通过提供索引位置来选择一个项。我们还可以使用index()方法来返回列表中项的索引位置。Python 中的列表索引值始终从零开始。虽然列表具有简单的成员资格测试,但对于非常大的列表,这种测试可能会很慢,如果需要执行许多此类测试,那么集合可能是一个更好的选择。

    如果集合中的项数是固定的——例如,RGB 颜色有三个值——这表明元组而不是列表是一个更好的选择。如果项的数量将增长和变化,那么列表集合比元组集合是一个更好的选择。

  3. 我们是否将通过与项的索引不同的键值来识别集合中的项?一个例子可能包括字符字符串(例如单词)与表示这些单词频率的整数之间的映射。另一个例子可能是一个颜色名称与该颜色的 RGB 元组之间的映射。我们将在第五章“内置数据结构第二部分:字典”中查看映射和字典。重要的区别是映射不像列表那样通过数值索引位置来定位项。

  4. 考虑集合中项的可变性(以及字典中的键)。集合中的每个项都必须是不可变对象。数字、字符串和元组都是不可变的,可以收集到集合中。由于列表、字典和集合对象是可变的,因此不能用作集合中的项。例如,不可能构建一个由列表对象组成的集合。

    我们可以选择将每个列表项转换为一个不可变的元组对象,而不是创建一组列表项。同样,字典键也必须是不可变的。我们可以使用数字、字符串或元组作为字典键。我们不能使用列表、集合或任何其他可变对象作为字典键。

4.1.3 它是如何工作的...

每个 Python 内置的集合都提供了一组独特的功能。集合还提供了大量重叠的功能。对于 Python 新手程序员来说,挑战在于将每个集合的独特功能映射到他们试图解决的问题。

collections.abc模块提供了一种通过内置容器类的路线图。该模块定义了我们使用的具体类背后的抽象基类(ABC)。我们将使用这些定义中的名称来指导我们了解其功能。

从 ABC 中,我们可以看到有三类总共有六种实现选择的集合:

  • 集合:它的独特之处在于项要么是成员,要么不是。这意味着重复项被忽略:

    • 可变集合:内置的 set 集合

    • 不可变集合:内置的 frozenset 集合

  • 序列:其独特之处在于项目提供了索引位置:

    • 可变序列:内置的列表集合

    • 不可变序列:内置的 tuple 集合。这是第一章中一些菜谱的主题。

  • 映射:其独特之处在于每个项目都有一个键,该键指向一个值:

    • 可变映射:内置的 dict 集合。这是第五章的主题。

    • 不可变映射:有趣的是,没有内置的 frozen 映射。

Python 的库提供了这些核心集合类型的额外实现。collections 模块包括:

  • namedtuple:一个元组,为元组中的每个项目提供名称。使用 rgb_color.red 比使用 rgb_color[0] 更清晰。

  • deque:一个双端队列。它是一个可变序列,对从两端推入和弹出进行了优化。我们可以用列表做类似的事情,但当需要两端的变化时,deque 更有效率。

  • defaultdict:一个 dict,可以为缺失的键提供一个默认值。

  • Counter:一个 dict,设计用来计数键的出现的次数。这有时被称为多重集合或包。

  • ChainMap:一个 dict,可以将多个字典组合成一个映射。

此外,还有一个较旧的 OrderedDict 类。这个类保留了键的创建顺序。从 Python 3.7 开始,普通字典的键保留了创建顺序,使得 OrderedDict 类变得冗余。

4.1.4 更多...

Python 标准库中还有更多。我们还可以使用 heapq 模块,它定义了一种列表,充当高性能优先队列。bisect 模块包括快速搜索排序列表的方法。这使得我们可以创建一个列表对象,其性能可以接近字典的非常快速的查找。

我们可以在总结网页上找到数据结构的描述,如这个:thealgorist.com。我们将快速查看四个额外的数据结构家族:

  • 数组:Python 的 array 模块支持密集填充的值数组。numpy 模块也提供了非常复杂化的数组处理。

  • 树:通常,树结构可以用来创建集合、顺序列表或键值映射。我们可以将树视为构建集合或字典的实现技术。我们通常使用对象和类定义来构建树结构。

  • 哈希:Python 使用哈希来实现字典和集合。这导致速度良好,但可能消耗大量内存。

  • 图:Python 没有内置的图数据结构。然而,我们可以通过一个字典轻松地表示图结构,其中每个节点都有一个相邻节点的列表。像 NetworkX、Pyoxigraph 和 RDFLib 这样的外部库支持复杂的图数据库。

通过一点巧妙的方法,我们可以在 Python 中实现几乎任何类型的数据结构。虽然内置结构通常具有基本功能,但我们可能能够找到可以投入使用的内置结构。我们将在第五章内置数据结构第二部分:字典中探讨映射和字典。

4.1.5 参见

4.2 构建列表 – 字面量、追加和推导式

如果我们决定根据容器中每个项目的位置创建一个集合——列表——我们有几种构建这种结构的方法。我们将探讨从单个项目组装列表对象的各种方法。

在某些情况下,我们需要一个列表,因为它允许重复的值,与集合不同。这在统计工作中很常见。另一种称为多重集的结构,对于允许重复的统计导向集合也很有用。这个集合在标准库中作为 collections.Counter 提供。

4.2.1 准备工作

假设我们需要对一些文件大小进行统计分析。以下是一个简短的脚本,它将为我们提供一些文件的大小:

>>> from pathlib import Path 

>>> home = Path.cwd() / "data" 

>>> for path in sorted(home.glob(’*.csv’)): 

...     print(path.stat().st_size, path.name) 

260 binned.csv 

250 ch14_r03.csv 

2060 ch14_r04.csv 

45 craps.csv 

225 fuel.csv 

156 fuel2.csv 

28 output.csv 

19760 output_0.csv 

19860 output_1.csv 

19645 output_2.csv 

19971 output_3.csv 

19588 output_4.csv 

...

我们已经使用 pathlib.Path 对象来表示文件系统中的目录。glob()方法扩展所有与给定模式匹配的名称。

我们希望累积一个包含各种文件大小的列表对象。从该列表中,我们可以计算总大小和平均大小。

4.2.2 如何做...

我们有许多创建列表对象的方法:

  • 字面量:我们可以使用方括号[]字符包围的值序列创建列表的字面量显示。例如,[1, 2, 3]。Python 需要匹配一个开方括号[和一个闭方括号]来看到一条完整的逻辑行,因此字面量可以跨越物理行。有关更多信息,请参阅第二章编写长行代码食谱。

  • 转换函数:我们可以使用 list()函数将其他数据集合转换为列表。

  • 追加方法:我们有允许我们逐个构建列表的方法。这些方法包括 append()、extend()和 insert()。我们将在本食谱的使用 append()方法构建列表部分探讨 append()方法。

  • 理解:理解是一种特殊的生成器表达式,它从源对象计算出一个列表。我们将在本食谱的编写列表理解部分详细探讨这一点。

创建列表的前两种方法是单个 Python 表达式。最后两种更复杂,我们将展示每种方法的配方。

使用 append()方法构建列表

  1. 使用字面语法[]或 list()函数创建一个空列表:

    >>> file_sizes = []
    
  2. 通过 append()方法遍历一些数据源。将项目添加到列表中:

    >>> home = Path.cwd() / "data" 
    
    >>> for path in sorted(home.glob(’*.csv’)): 
    
    ...     file_sizes.append(path.stat().st_size) 
    
    >>> print(file_sizes) 
    
    [260, 250, 2060, 45, 225, 156, 28, 19760, 19860, 19645, 19971, 19588, 19999, 20000, 20035, 19739, 19941, 215, 412, 28, 166, 0, 1810, 0, 0, 16437, 20295] 
    
    >>> print(sum(file_sizes)) 
    
    240925
    

当我们打印列表时,Python 会以字面符号显示它。如果我们需要将列表复制并粘贴到另一个脚本中,这很方便。

非常重要的是要注意,append()方法不返回任何值。append()方法会修改列表对象,并且不返回任何内容。

编写列表推导式

列表推导式的目标是创建一个对象,它占据字面表达式的语法角色:

  1. 写出包围要构建的列表对象的[]括号。

  2. 写出数据源。这将包括目标变量。请注意,for 子句的末尾没有冒号,因为我们不是在写一个完整的语句:

    [... for path in home.glob(’*.csv’)]
    
  3. 在 for 子句前加上一个表达式来评估,以创建每个值,这些值来自目标变量的值。再次强调,由于这只是一个单一的表达式,我们在这里不能使用复杂语句:

    [path.stat().st_size 
    
      for path in home.glob(’*.csv’)]
    

这是一个列表对象构建的例子:

>>> [path.stat().st_size 

...     for path in sorted(home.glob(’*.csv’))] 

[260, 250, 2060, 45, 225, 156, 28, 19760, 19860, 19645, 19971, 19588, 19999, 20000, 20035, 19739, 19941, 215, 412, 28, 166, 0, 1810, 0, 0, 16437, 20295]

现在我们已经创建了一个列表对象,我们可以将其分配给一个变量,并对数据进行其他计算和总结。

列表推导式是围绕一个中心生成器表达式构建的,在语言手册中称为推导式。推导式有两个部分:数据表达式子句和 for 子句。数据表达式子句会被反复评估,由 for 子句中分配的变量驱动。

我们可以用 list()函数替换包围要构建的列表对象的[]括号。当我们考虑数据结构可能发生变化的可能性时,使用显式的 list()函数有一个优点。我们可以轻松地将 list()替换为 set()或 Counter(),以利用核心生成器,同时创建一个不同的集合类型。

4.2.3 它是如何工作的...

Python 列表对象具有动态大小。当项目被追加或插入,或者列表通过另一个序列的项目扩展时,大小会调整。同样,当项目被弹出或删除时,大小会缩小。

在罕见的情况下,我们可能想要创建一个具有给定初始大小的列表,然后分别设置项目的值。我们可以使用列表推导式来完成这个操作,如下所示:

>>> sieve = [True for i in range(100)]

这将创建一个包含 100 个初始项的列表,每个项都是 True。我们可能需要这种初始化来实现埃拉托斯特尼筛法(Sieve of Eratosthenes)算法:

>>> sieve[0] = sieve[1] = False 

>>> for p in range(100): 

...     if sieve[p]: 

...         for n in range(p*2, 100, p): 

...             sieve[n] = False 

>>> prime = [p for p in range(100) if sieve[p]]

筛选集合包含一系列 True 和 False 值。每个 True 的索引位置是一个素数。从 p² 开始的每个素数 p 的倍数被设置为 False。素数集合是一个值序列,p 是 sieve[p] 表达式为 True 的素数。

4.2.4 更多内容...

创建列表对象的一个常见目标是能够总结它。我们可以使用各种 Python 函数来做这件事。以下是一些示例:

>>> sizes = list(path.stat().st_size 

...     for path in home.glob(’*.csv’)) 

>>> sum(sizes) 

240925 

>>> max(sizes) 

20295 

>>> min(sizes) 

0 

>>> from statistics import mean 

>>> round(mean(sizes), 3) 

8923.148

我们使用了内置的 sum()、min() 和 max() 方法来生成这些文档大小的描述性统计信息。哪个索引文件是最小的?我们想知道值列表中最小值的索引位置。我们可以使用 index() 方法来做这件事:

>>> sizes.index(min(sizes)) 

1

我们找到了最小值,然后使用 index() 方法定位那个最小值的索引位置。

扩展列表的其他方法

我们可以扩展列表对象,也可以将其插入到列表的中间或开头。我们有两种扩展列表的方法:我们可以使用 + 运算符,或者我们可以使用 extend() 方法。以下是一个使用 + 运算符将两个列表合并的示例:

>>> home = Path.cwd() / "src" 

>>> ch3 = list(path.stat().st_size 

...     for path in home.glob(’ch03/*.py’)) 

>>> ch4 = list(path.stat().st_size 

...     for path in home.glob(’ch04/*.py’)) 

>>> len(ch3) 

16 

>>> len(ch4) 

6 

>>> final = ch3 + ch4 

>>> len(final) 

22 

>>> sum(final) 

34853

我们创建了一个包含文档大小的列表,文档名称类似于 Chapter_03/.py。然后我们创建了一个包含文档大小的第二个列表,其名称模式略有不同,Chapter_04/.py。然后我们将这两个列表合并成一个最终的列表。

我们可以在列表中的任何特定位置之前插入一个值。insert() 方法接受一个项目的位置;新值将在给定位置之前:

>>> p = [3, 5, 11, 13] 

>>> p.insert(0, 2) 

>>> p 

[2, 3, 5, 11, 13] 

>>> p.insert(3, 7) 

>>> p 

[2, 3, 5, 7, 11, 13]

我们已经向列表对象中插入了两个新值。与 append() 和 extend() 方法一样,insert() 方法不返回任何值。它修改了列表对象。

4.2.5 参考信息

  • 参考关于切片和切块列表的配方,了解复制列表和从列表中挑选子列表的方法。

  • 参考关于缩小列表 - 删除、移除和弹出的配方,了解从列表中移除项目的其他方法。

  • 在反转列表副本的配方中,我们将查看如何反转列表。

  • 本文提供了一些关于 Python 集合内部工作方式的见解:wiki.python.org/moin/TimeComplexity

    在查看表格时,重要的是要注意表达式 O(1) 表示成本基本上是常数。表达式 O(n) 表示成本随着集合大小的增长而增长。

4.3 切片和切块列表

有很多次我们想要从列表中挑选项目。最常见的一种处理方式是将列表的第一个项目视为特殊情况。这导致了一种头尾处理方式,其中我们对待列表的头与对待列表尾的项目不同。

我们可以使用这些技术来复制列表。

4.3.1 准备工作

我们有一个用于记录大型帆船燃油消耗的电子表格。它的行看起来像这样:

|


|


|


|


|

日期 引擎开启 燃料高度
引擎关闭 燃料高度
其他备注

|


|


|


|


|

10/25/2013 08:24:00 AM 29
01:15:00 PM 27
平静大海 – 锚定所罗门岛

|


|


|


|


|

10/26/2013 09:12:00 AM 27
06:25:00 PM 22
choppy – 锚定在杰克逊溪

|


|


|


|


|

表 4.1:帆船燃油使用示例

在这个数据集中,燃料是通过高度来测量的。这是因为使用了深度为英寸的视距计,校准。对于所有实际目的,油箱是矩形的,所以显示的深度可以转换成体积,因为我们知道 31 英寸的深度大约是 75 加仑。

这个电子表格数据的例子没有正确规范化。理想情况下,所有行都遵循数据的第一范式:一行应该有相同的内容,每个单元格应该只有原子值。在这个数据中,有三种子类型的行:

  1. 三行组的第一行包含引擎开启日期、时间和一个测量值。

  2. 一组中的第二行包含引擎关闭时间和一个测量值。

  3. 第三行有一些不太有用的备注。

这种非规范化数据包括以下两个问题:

  • .csv 文件有四行标题。(第四行是一个空白行,在此格式良好的书中未显示。)这是 csv 模块无法直接处理的事情。

  • 每天的旅行分布在三行中。这些行必须合并起来,以便更容易计算经过的时间和使用的燃料英寸数。

我们可以使用如下定义的函数来读取数据:

import csv 

from pathlib import Path 

def get_fuel_use(path: Path) -> list[list[str]]: 

    with path.open() as source_file: 

        reader = csv.reader(source_file) 

        log_rows = list(reader) 

    return log_rows

我们已经使用 csv 模块读取了日志细节。csv.reader()函数返回的对象是可迭代的。为了将项目收集到一个单独的列表中,我们对可迭代对象应用了 list()函数;这从读取器创建了一个列表对象。

原始 CSV 文件的每一行都是一个列表。以下是第一行和最后一行的样子:

>>> log_rows[0] 

[’date’, ’engine on’, ’fuel height’] 

>>> log_rows[-1] 

[’’, "choppy -- anchor in jackson’s creek", ’’] 

对于这个食谱,我们将使用列表索引表达式的扩展来从行列表中切片项。切片,就像索引表达式一样,跟在列表对象后面的[]字符中。Python 提供了几种切片表达式的变体,以便我们可以从行列表中提取有用的子集。

4.3.2 如何操作...

  1. 我们需要做的第一件事是从行列表中删除四行标题。我们将使用两个部分切片表达式来通过第四行分割列表:

    >>> head, tail = log_rows[:4], log_rows[4:] 
    
    >>> head[0] 
    
    [’date’, ’engine on’, ’fuel height’] 
    
    >>> head[-1] 
    
    [’’, ’’, ’’] 
    
    >>> tail[0] 
    
    [’10/25/13’, ’08:24:00 AM’, ’29’] 
    
    >>> tail[-1] 
    
    [’’, "choppy -- anchor in jackson’s creek", ’’]
    

    我们使用 log_rows[:4]和 log_rows[4:]将列表切分成两部分。第一个切片表达式选择前四行;这被分配给 head 变量。第二个切片表达式选择从第 4 行到列表末尾的行。这被分配给 tail 变量。这是我们关心的表格行。

  2. 我们将使用带有步骤的切片来选择有趣的行。切片的 start:stop:step 版本将根据步骤值分组选择行。在我们的例子中,我们将取两个切片。一个切片从行零开始——“引擎开启”行——另一个切片从行一开始——“引擎关闭”行。

    这里是每第三行的切片,从行零开始:

    >>> pprint(tail[0::3], width=64) 
    
    [[’10/25/13’, ’08:24:00 AM’, ’29’], 
    
     [’10/26/13’, ’09:12:00 AM’, ’27’]]
    

    我们已经使用了来自 pprint 模块的 pprint() 函数来使输出更容易阅读。

    从行一开始,每第三行有额外的数据:

    >>> pprint(tail[1::3], width=48) 
    
    [[’’, ’01:15:00 PM’, ’27’], 
    
     [’’, ’06:25:00 PM’, ’22’]]
    
  3. 这两个切片然后可以组合在一起以创建一对对的列表:

    >>> paired_rows = list(zip(tail[0::3], tail[1::3])) 
    
    >>> pprint(paired_rows) 
    
    [([’10/25/13’, ’08:24:00 AM’, ’29’], [’’, ’01:15:00 PM’, ’27’]), 
    
     ([’10/26/13’, ’09:12:00 AM’, ’27’], [’’, ’06:25:00 PM’, ’22’])]
    

    这给我们一个由三个元组的对组成的序列。这非常接近我们可以处理的东西。

  4. 展平结果:

     >>> paired_rows = list(zip(tail[0::3], tail[1::3])) 
    
    >>> combined = [a+b for a, b in paired_rows] 
    
    >>> pprint(combined) 
    
    [[’10/25/13’, ’08:24:00 AM’, ’29’, ’’, ’01:15:00 PM’, ’27’], 
    
     [’10/26/13’, ’09:12:00 AM’, ’27’, ’’, ’06:25:00 PM’, ’22’]]
    

    我们已经使用了一个列表推导式从 构建列表 – 字面量、追加和推导式 章节中结合每对行的两个元素以创建一个单行。这有更规范化的数据描述每段航程。

从结果列表中,我们现在可以计算时间差以得到船的运行时间。我们可以计算高度差以估计每段旅程中消耗的燃料。这个包含五个有用项——日期、时间、高度、时间、高度——的单行列表包含了所需的所有数据。它还有一个通常包含空字符串的列。

4.3.3 它是如何工作的...

切片操作符有几种不同的形式:

  • [:]: 开始和结束是隐含的。表达式 S[:] 将创建序列 S 的一个副本。

  • [start:stop]: 这将选择一个子列表,从起始索引开始,到停止索引之前结束。Python 使用半开区间。起始索引包含在内,而停止索引不包含。

  • [::step]: 开始和结束是隐含的,包括整个序列。步骤——通常不等于一——意味着我们将使用步骤从开始跳过列表。对于给定的步骤,s,和大小为 |L| 的列表,索引值是 i ∈{s×n∣n ∈ℕ and 0 ≤ s×n < |L|}。

  • [start::step]: 起始是给定的,但停止是隐含的。想法是起始是一个偏移量,步骤应用于该偏移量。对于给定的起始,a,步骤,s,和大小为 |L| 的列表,索引值是 i ∈{s × n + a∣n ∈ℕ and 0 ≤ s × n + a < |L|}。

切片技术适用于列表、元组、字符串以及任何其他类型的序列。切片不会导致集合被修改;相反,切片将序列的一部分制作成副本。现在,源集合中的项在集合之间共享。

4.3.4 更多...

在反转列表副本的菜谱中,我们将探讨切片表达式的一种更复杂的使用方法。

序列的副本被称为浅拷贝,因为将会有两个集合,每个集合都包含对相同底层对象的引用。我们将在制作浅拷贝和深拷贝的对象的菜谱中详细探讨这一点。

对于这个特定的例子,我们还有另一种将多行数据重新结构化为单行数据的方法:我们可以使用生成器函数。我们将在第九章中在线查看函数式编程技术。

4.3.5 参考信息

  • 请参考构建列表 - 字面量、追加和推导的菜谱,了解创建列表的方法。

  • 请参考缩小列表 - 删除、移除和弹出的菜谱,了解从列表中删除项的其他方法。

  • 在反转列表副本的菜谱中,我们将探讨如何反转列表。

  • pandas 包提供了一些与 CSV 文件一起工作的额外方法。

4.4 缩小列表 - 删除、移除和弹出

有很多次我们想要从列表集合中删除项。我们可能从列表中删除项,然后处理剩下的项。

移除不需要的项与使用 filter() 创建只包含所需项的副本的效果相似。区别在于,过滤后的副本将比从列表中删除项使用更多的内存。我们将展示两种从可变列表中删除不需要项的技术。

4.4.1 准备工作

我们有一个用于记录大型帆船燃油消耗的电子表格。请参阅表 4.1 中的数据。

关于此数据的更多背景信息,请参考本章前面的切片和切块列表菜谱。get_fuel_use() 函数将收集原始数据。需要注意的是,此数据的结构——每个事实分布在三个单独的行中——非常糟糕,需要相当多的注意来重建更有用的东西。

原始 CSV 文件的每一行都是一个列表。这些列表中的每一个都包含三个项。删除包含标题和无用数据的某些行是至关重要的。

4.4.2 如何操作...

我们将探讨几种从列表中删除项的方法:

  • del 语句。

  • remove() 方法。

  • pop() 方法。

  • 我们还可以使用切片赋值来替换列表中的项。

del 语句

我们可以使用 del 语句从列表中删除项目。我们可以提供一个对象和一个切片来从列表对象中删除一组行。以下是 del 语句的示例:

>>> del log_rows[:4] 

>>> log_rows[0] 

[’10/25/13’, ’08:24:00 AM’, ’29’] 

>>> log_rows[-1] 

[’’, "choppy -- anchor in jackson’s creek", ’’] 

del 语句删除了前四行,留下了我们真正需要处理的行。然后我们可以将这些行组合起来,使用 Slicing and dicing a list 配方进行总结。

remove()方法

我们可以使用 remove()方法从列表中删除项目。给定一个特定的值,它会从列表中删除匹配的项目。

我们可能有一个看起来像这样的列表:

 >>> row = [’10/25/13’, ’08:24:00 AM’, ’29’, ’’, ’01:15:00 PM’, ’27’]

我们可以删除列表中的无用“”项目:

>>> row.remove(’’) 

>>> row 

[’10/25/13’, ’08:24:00 AM’, ’29’, ’01:15:00 PM’, ’27’]

注意到 remove()方法不返回值。它会在原地修改列表。

如构建列表 – 字面量、追加和推导配方中所述,以下代码是不正确的:

a = [’some’, ’data’] 

a = a.remove(’data’)

这绝对是错误的。这将使 a 变为 None。

pop()方法

我们可以使用 pop()方法从列表中删除项目。这是基于它们的索引从列表中删除项目。

我们可能有一个看起来像这样的列表:

>>> row = [’10/25/13’, ’08:24:00 AM’, ’29’, ’’, ’01:15:00 PM’, ’27’]

这包含了一个无用的“”字符串。我们可以找到要弹出项目的索引,然后删除它。以下示例将此代码分解为几个独立的步骤:

>>> target_position = row.index(’’) 

>>> target_position 

3 

>>> row.pop(target_position) 

’’ 

>>> row 

[’10/25/13’, ’08:24:00 AM’, ’29’, ’01:15:00 PM’, ’27’]

注意到 pop()方法做了两件事:

  • 它通过修改列表对象来删除一个项目。

  • 它还返回了被删除的项目。

这种修改和返回值的组合很少见,使得这种方法独特。

切片赋值

我们可以通过在赋值语句的左侧使用切片表达式来替换列表中的项目。这让我们可以替换列表中的项目。当替换的大小不同时,它允许我们扩展或收缩列表。这导致了一种使用切片赋值从列表中删除项目的技术。

我们将从位置 3 有一个空值的行开始。这看起来像这样:

>>> row = [’10/25/13’, ’08:24:00 AM’, ’29’, ’’, ’01:15:00 PM’, ’27’] 

>>> target_position = row.index(’’) 

>>> target_position 

3

我们可以将一个空列表赋给从索引位置 3 开始到索引位置 4 之前的切片。这将用一个零项切片替换一个单项切片,从而从列表中删除该项目:

>>> row[3:4] = [] 

>>> row 

[’10/25/13’, ’08:24:00 AM’, ’29’, ’01:15:00 PM’, ’27’]

del 语句和 remove()、pop()等方法似乎清楚地表明了从集合中删除项目的意图。切片赋值可能不太明确,因为它没有明显的函数名。然而,它确实可以很好地用于删除可以用切片表达式描述的一组项目。

4.4.3 它是如何工作的...

因为列表是一个可变对象,所以我们可以从列表中删除项目。这种技术不适用于元组或字符串,因为它们是不可变的。

我们只能删除列表中存在的索引的项目。如果我们尝试删除索引超出允许范围的项,我们将得到 IndexError 异常。

以下示例尝试从一个索引值为零、一和二的列表中删除索引为三的项目:

>>> row = [’’, ’06:25:00 PM’, ’22’] 

>>> del row[3] 

Traceback (most recent call last): 

... 

IndexError: list assignment index out of range

4.4.4 更多内容...

在 Python 的几个地方,从列表对象中删除可能会变得复杂。如果我们在一个 for 语句中使用列表对象,我们无法从列表中删除项。这样做会导致迭代控制与底层对象的内部状态之间出现意外的冲突。

假设我们想要从列表中删除所有偶数项。以下是一个不正确工作的示例:

>>> data_items = [1, 1, 2, 3, 5, 8, 10, 

... 13, 21, 34, 36, 55] 

>>> for f in data_items: 

...     if f % 2 == 0: 

...         data_items.remove(f) 

>>> data_items 

[1, 1, 3, 5, 10, 13, 21, 36, 55]

源列表有几个偶数值。结果是明显不正确的;10 和 36 的值仍然在列表中。为什么有些偶数值项被留在列表中?

让我们看看处理 data_items[5]时会发生什么;它的值为 8。当 remove(8)方法被评估时,该值将被移除,列表中的所有后续值都将向前滑动一个位置。10 值将移动到 5 的位置,即 8 值之前的位置。迭代控制值将前进到下一个位置,该位置将包含 13。10 值将不会被处理。

我们有几种方法可以避免删除时跳过的问题:

  • 制作列表的副本:

    >>> for f in data_items[:]: 
    
    ...     if f % 2 == 0: 
    
    ...         data_items.remove(f)
    
  • 使用 while 语句并显式维护索引值:

    >>> position = 0 
    
    >>> while position != len(data_items): 
    
    ...     f = data_items[position] 
    
    ...     if f % 2 == 0: 
    
    ...         data_items.remove(f) 
    
    ...     else: 
    
    ...         position += 1
    

    我们设计了一个 while 语句,只有当 data_items[position]的值为奇数时才增加位置变量。如果值为偶数,则该值将被移除,这也意味着列表中的其他项将向前移动一个位置;保持位置变量的值不变是至关重要的。

  • 我们也可以以相反的顺序遍历列表。表达式 range(len(row)-1, -1, -1)将产生从-1 开始的递减索引。这是因为负索引值从列表的末尾向前工作。row[-1]是最后一个项。

4.4.5 参见

  • 请参阅构建列表 - 字面量、追加和推导配方,了解创建列表的方法。

  • 请参阅切片和切块列表配方,了解复制列表和从列表中选取子列表的方法。

  • 在反转列表副本配方中,我们将查看如何反转列表。

4.5 编写与列表相关的类型提示

typing 模块提供了一些描述列表对象内容的必要类型定义。主要类型定义是 list,我们可以用列表中项的类型来参数化它。它通常看起来像 list[int]。

4.5.1 准备工作

我们将查看一个包含两种类型的元组的列表。一些元组是简单的 RGB 颜色。其他元组是某些计算结果的 RGB 颜色。这些是从浮点值而不是整数构建的。我们可能有一个类似这样的异构列表结构:

(’ Brick_Red’, (198, 45, 66)), 

(’ color1’, (198.00, 100.50, 45.00)), 

(’ color2’, (198.00, 45.00, 142.50)), 

]

列表中的每一项都是一个包含颜色名称和 RGB 值元组的二元组。RGB 值表示为整数或浮点值的三个元组。这可能会难以用类型提示来描述。

我们有两个与此数据相关的函数。第一个从 RGB 值创建颜色代码。

重要的规则是将每个组件(红色、绿色或蓝色)视为一个 8 位数字,一个介于 0 到 255 之间的值。这三个值通过将红色值左移 16 位和绿色值左移 8 位来组合。Python 的<<运算符执行必要的位移动。|运算符执行“或”操作,将移动的位组合成一个新的整数值。

这个函数的类型提示并不复杂:

def hexify(r: float, g: float, b: float) -> str: 

    return f’#{int(r) << 16 | int(g) << 8 | int(b):06X}’

:06X 格式规范产生一个 6 位的十六进制值。

另一种方法是使用表达式 f"#{int(r):02X}{int(g):02X}{int(b):02X}"将每个颜色视为一个单独的十六进制数字对。这使用了三个:02X 格式规范来为每个颜色组件产生 2 位的十六进制值。

当我们使用这个函数从 RGB 数字创建颜色字符串时,它看起来像这样:

>>> hexify(198, 45, 66) 

’#C62D42’

然而,另一个函数可能有些令人困惑。这个函数将一个复杂的颜色列表转换成另一个包含十六进制颜色代码的列表:

def source_to_hex_0(src): 

    return [ 

        (n, hexify(*color)) for n, color in src 

    ]

我们需要添加类型提示以确保这个函数正确地将颜色列表从数字形式转换为字符串代码形式。

我们在函数名上添加了一个 _0 后缀来区分它和随后的示例。这并不是最佳实践,但我们发现它有助于澄清像这本书中展示的代码:

4.5.2 如何做...

我们首先通过添加类型提示来描述输入列表的各个项目,例如之前显示的 scheme 变量:

  1. 首先定义结果类型。通常,关注结果并从源数据回溯到产生预期结果所需的数据是有帮助的。在这种情况下,结果是包含颜色名称和颜色十六进制代码的两个元组的列表。我们可以将其描述为 list[tuple[str, str]],但这种总结隐藏了一些重要的细节。我们更喜欢以下方式暴露这些细节:

    ColorCode = tuple[str, str] 
    
    ColorCodeList = list[ColorCode] 
    

    这个列表可以看作是同质的;每个项目都将匹配 ColorCode 类型定义。

  2. 定义源类型。在这种情况下,我们有两种稍微不同的颜色定义类型。虽然它们往往重叠,但它们的起源不同,处理历史有时有助于作为类型提示的一部分:

    from typing import Union 
    
    RGB_I = tuple[int, int, int] 
    
    RGB_F = tuple[float, float, float] 
    
    ColorRGB = tuple[str, Union[RGB_I, RGB_F]] 
    
    ColorRGBList = list[ColorRGB]
    

    我们定义了基于整数的 RGB 三元组为 RGB_I,基于浮点数的 RGB 三元组为 RGB_F。这两种替代类型组合成 ColorRGB 元组定义。这是一个二元组;第二个元素可以是 RGB_I 类型或 RGB_F 类型的实例。存在联合类型意味着这个列表是异构的。

    我们也可以使用 RGB_I | RGB_F 代替 Union[RGB_I, RGB_F]。

  3. 更新函数以包含类型提示。输入将是一个类似于之前显示的模式对象的列表。结果将是一个与 ColorCodeList 类型描述相匹配的列表:

    def source_to_hex(src: ColorRGBList) -> ColorCodeList: 
        return [ 
    
            (n, hexify(*color)) for n, color in src 
    
        ]
    

4.5.3 它是如何工作的...

list[T] 类型提示需要一个单一值 T 来描述可以成为此列表一部分的所有对象类型。对于同质列表,类型是直接声明的。对于异构列表,必须使用 Union 来定义可能存在的各种类型。

我们采取的方法将类型提示分解为两层:

  • 一个描述集合中单个项目的“基础”层。我们定义了三种原始项目类型:RGB_I 和 RGB_F 类型,以及由此产生的 ColorCode 类型。

  • 多个“组合”层,将基础类型组合成复合对象的描述。在这种情况下,ColorRGB、ColorRGBList 和 ColorCodeList 都是复合类型定义。

一旦命名了类型,然后使用定义函数、类和方法使用这些名称。

在阶段定义类型很重要,以避免长而复杂的类型提示,这些类型提示不会提供任何关于正在处理的对象的有用见解。避免像这样的类型描述是好的:

list[tuple[str, Union[tuple[int, int, int], tuple[float, float, float]]]]

虽然这在技术上正确,但由于其复杂性,难以理解。将复杂类型分解成有用的组件描述有助于理解。

4.5.4 更多...

类型提示假设列表中的每个项目都是单一类型。语法 list[T] 表示所有项目都是类型 T。

在异构列表的情况下,具有多个不同类型,我们需要定义类型的联合。我们可以从 typing 模块导入 Union 类型。或者我们可以使用 | 来为列表提供替代类型。

使用类似 list[RGB_I | RGB_F] 的结构描述了一个包含混合类型项目的列表。

4.5.5 参见

  • 在第一章,使用 NamedTuples 简化元组中的项目访问的配方提供了一些澄清元组类型提示的替代方法。

  • 编写与集合相关的类型提示的配方从集合类型的视角涵盖了这一点。

4.6 反转列表的副本

一些算法以相反的顺序产生结果。通常会在列表中收集输出,然后反转列表。作为一个例子,我们将查看将数字转换为特定基数时通常是从最低有效位到最高有效位生成的。我们通常希望以最高有效位首先显示值。这导致需要反转列表中数字的顺序。

4.6.1 准备工作

假设我们在进行基数之间的转换。我们将查看一个数字在基数中的表示方式,以及如何从数字计算该表示。

任何值 v 都可以定义为给定基数 b 中各种数字 d[n] 的多项式函数。一个四位数将具有 ⟨d[3],d[2],d[1],d[0]⟩ 作为数字序列。

注意,我们在这里使用的顺序与 Python 列表中项目的通常顺序相反。

这个由数字组成的序列的值 v 由以下多项式给出:

v = dn × bn + dn−1 × bn− 1 + dn− 2 × bn−2 + ⋅⋅⋅+ d1 × b+ d0

例如,十六进制数 0xBEEF 有以下数字:⟨B = 11,E = 14,E = 14,F = 15⟩,基数 b = 16:

48879 = 11 × 163 + 14 × 162 + 14× 16 + 15

有许多情况下基数不是某个数的连续幂。例如,ISO 日期格式有一个混合基数,涉及每周 7 天,每天 24 小时,每小时 60 分钟,每分钟 60 秒。

而不是 b⁴, b³, b², b¹ = b, 和 b⁰ = 1,我们有 7 × 24 × 60 × 60, 24 × 60 × 60, 60 × 60, 和 60 作为计算多项式的各种值。

给定一个周数,一周中的某一天,一个小时,一个分钟和一个秒,我们可以在给定的年份内计算秒数时间戳 t[s]:

ts = (((w × 7 + d)× 24 + h)× 60 + m )× 60 + s

例如:

>>> week = 13 

>>> day = 2 

>>> hour = 7 

>>> minute = 53 

>>> second = 19 

>>> t_s = (((week*7+day)*24+hour)*60+minute)*60+second 

>>> t_s 

8063599

这显示了如何将给定的时间点转换为时间戳。我们如何反转这个计算?我们如何从整体时间戳中获取各个字段?

我们需要使用 divmod 风格的除法。有关背景信息,请参阅在真除法和取整除法之间选择配方。

将秒数时间戳 t[s] 转换为单独的周、天和时间字段的算法如下:

t[m]; s = ⌊ts- 60⌋; t[s] mod 60
t[h]; m = ⌊tm- 60⌋; t[m] mod 60
t[d]; h = ⌊th24⌋; t[h] mod 24
w; d = ⌊td7-⌋; t[d] mod 7

这有一个实用的模式,导致实现。它产生值的顺序是相反的:

>>> t_s = 8063599 

>>> fields = [] 

>>> for base in 60, 60, 24, 7: 

...     t_s, f = divmod(t_s, base) 

...     fields.append(f) 

>>> fields.append(t_s) 

>>> fields 

[19, 53, 7, 2, 13]

我们已经四次应用了 divmod() 函数,从以秒为单位的时间戳中提取秒、分钟、小时、天和周。它们的顺序是错误的。我们如何将它们反转?

4.6.2 如何做到这一点...

我们有三种方法:我们可以使用 reverse() 方法,我们可以使用 [::-1] 切片表达式,或者我们可以使用 reversed() 内置函数。以下是 reverse() 方法:

>>> fields_copy1 = fields.copy() 

>>> fields_copy1.reverse() 

>>> fields_copy1 

[13, 2, 7, 53, 19]

我们创建了原始列表的副本,这样我们就可以保留一个未修改的副本以与修改后的副本进行比较。这使得跟踪示例变得更容易。我们应用了 reverse() 方法来反转列表的副本。

这将修改列表。与其他修改方法一样,它不会返回一个有用的值。使用像 a = b.reverse(); 这样的语句是不正确的,a 的值始终是 None。

这是一个带有负步长的切片表达式:

>>> fields_copy2 = fields[::-1] 

>>> fields_copy2 

[13, 2, 7, 53, 19]

在这个例子中,我们创建了一个使用隐含的起始和结束位置以及步长为 -1 的切片 [::-1]。这以相反的顺序选择列表中的所有项以创建一个新的列表。

原始列表在切片操作中明确没有被修改。这创建了一个副本。检查 fields 变量的值以确认它没有改变。

这就是如何使用 reversed() 函数来创建一个值的列表的反向副本:

>>> fields_copy3 = list(reversed(fields)) 

>>> fields_copy3 

[13, 2, 7, 53, 19]

在这个例子中,使用 list()函数很重要。reversed()函数是一个生成器,我们需要消耗生成器中的项来创建一个新的列表。

4.6.3 工作原理...

正如我们在切片和切块列表配方中提到的,切片符号相当复杂。使用具有负步长的切片将按从右到左的顺序创建一个副本(或子集),而不是默认的从左到右的顺序。

很重要要区分这三种方法:

  • reverse()方法修改列表对象本身。与 append()和 remove()等方法一样,此方法没有返回值。因为它改变了列表,所以它不返回值。

  • [::-1]切片表达式创建了一个新的列表。这是原始列表的浅拷贝,顺序被反转。

  • reversed()函数是一个生成器,它以相反的顺序产生值。当值被 list()函数消耗时,它创建列表的副本。

4.6.4 相关内容

  • 请参考制作浅拷贝和深拷贝的对象配方以获取有关浅拷贝是什么以及为什么我们可能想要制作深拷贝的更多信息。

  • 请参考构建列表 - 字面量、追加和推导式配方了解创建列表的方法。

  • 请参考切片和切块列表配方了解复制列表和从列表中选取子列表的方法。

  • 请参考缩小列表 - 删除、移除和弹出配方了解从列表中删除项的其他方法。

4.7 构建集合 - 字面量、添加、推导式和运算符

如果我们决定仅基于一个项的存在来创建一个集合(即集合),我们有几种构建这种结构的方法。由于集合的窄焦点,项目没有顺序——没有相对位置——并且项目不能重复。我们将探讨从单个项目的来源组装集合集合的多种方法。

集合运算符与集合论数学中定义的运算符平行。这些运算符对于在集合之间进行大量比较很有帮助。我们将探讨这些运算符,以及集合类的方法。

集合有一个重要的约束:它们只包含不可变对象。非正式地说,不可变对象没有内部状态可以改变。数字是不可变的,字符串也是如此,不可变对象的元组也是。正式地说,不可变对象有一个内部哈希值,hash()函数将显示这个值。

下面是如何在实际中做到这一点:

>>> a = "string" 

>>> hash(a) 

... # doctest: +SKIP 

4964286962312962439 

>>> b = ["list", "of", "strings"] 

>>> hash(b) 

Traceback (most recent call last): 

... 

TypeError: unhashable type: ’list’

a 变量的值是一个不可变的字符串,它有一个哈希值。另一方面,b 变量是一个可变列表,没有哈希值。我们可以创建包含字符串等不可变对象的集合,但如果尝试将可变对象放入集合中,将会引发 TypeError 异常。

4.7.1 准备工作

假设我们需要对一个复杂应用程序中模块之间的依赖关系进行分析。以下是可用数据的一部分:

>>> import_details = [ 

... (’Chapter_12.ch12_r01’, [’typing’, ’pathlib’]), 

... (’Chapter_12.ch12_r02’, [’typing’, ’pathlib’]), 

... (’Chapter_12.ch12_r03’, [’typing’, ’pathlib’]), 

... (’Chapter_12.ch12_r04’, [’typing’, ’pathlib’]), 

... (’Chapter_12.ch12_r05’, [’typing’, ’pathlib’]), 

... (’Chapter_12.ch12_r06’, [’typing’, ’textwrap’, ’pathlib’]), 

... (’Chapter_12.ch12_r07’, [’typing’, ’Chapter_12.ch12_r06’, ’Chapter_12.ch12_r05’, ’concurrent’]), 

... (’Chapter_12.ch12_r08’, [’typing’, ’argparse’, ’pathlib’]), 

... (’Chapter_12.ch12_r09’, [’typing’, ’pathlib’]), 

... (’Chapter_12.ch12_r10’, [’typing’, ’pathlib’]), 

... (’Chapter_12.ch12_r11’, [’typing’, ’pathlib’]), 

... (’Chapter_12.ch12_r12’, [’typing’, ’argparse’])

此列表中的每个项目命名了一个模块及其导入的模块列表。我们可以对这个模块之间的关系集合提出许多问题。我们希望计算一个简短的依赖列表,从而从列表中删除重复项。

4.7.2 如何实现...

我们有许多创建集合对象的方法:

  • 字面量:我们可以使用一系列由字符包围的值来创建集合的文本显示。它看起来像这样:{value, ...}。Python 需要匹配字面量开头的{和结尾的},以看到完整的逻辑行,因此字面量可以跨越物理行。有关更多信息,请参阅第二章中编写长行代码的配方。

    注意,我们无法使用{}创建一个空集合;这是一个空字典。我们必须使用 set()来创建一个空集合。

  • 转换函数:我们可以使用 set()函数将其他数据集合转换为集合。我们可以转换不可变项的列表,或字典的键,或不可变项的元组。

  • 添加方法:set 方法中的 add()会将一个项目添加到集合中。此外,集合可以通过 union()方法或|运算符创建。

  • 推导式:推导式是一种特殊的生成器表达式,它使用一个表达式来定义成员资格,从而描述集合中的项目。我们将在本配方编写集合推导式部分中详细探讨这一点。

创建集合的前两种方法是单个 Python 表达式。最后两种更复杂,我们将为每种方法提供配方。

使用 add 方法构建集合

我们的数据源集合是一个包含子列表的列表。我们希望总结每个子列表中的项目:

  1. 创建一个空集合,可以添加项目。与列表不同,没有空集合的缩写语法,因此我们必须使用 set()函数:

     >>> all_imports = set()
    
  2. 编写一个 for 语句来遍历 import_details 集合中的每个二元组。这需要一个嵌套的 for 语句来遍历每对中导入列表中的每个名称。使用 all_imports 集合的 add()方法创建一个包含重复项的完整集合:

     >>> for item, import_list in import_details: 
    
    ...     for name in import_list: 
    
    ...         all_imports.add(name) 
    
    >>> all_imports == {’Chapter_12.ch12_r06’, ’textwrap’, 
    
    ...     ’Chapter_12.ch12_r05’, ’pathlib’, ’concurrent’, 
    
    ...     ’argparse’, ’typing’} 
    
    True
    

此结果总结了多行细节,显示了导入的不同项目的集合。请注意,这里的顺序是任意的,并且每次执行示例时都可能不同。

任意的顺序意味着用于确认此代码正确性的 doctest 示例不能简单地显示预期的结果。请参阅第十五章中处理常见的 doctest 问题的配方,以获取有关使用 doctest 的更多建议。

编写集合推导式

集合推导式的目标是创建一个占据语法角色的对象,类似于集合字面量:

  1. 编写包围要构建的集合对象的括号:

    >>> {} 
    
    {}
    
  2. 编写数据源。这将包括目标变量。我们有两个嵌套列表,因此我们需要使用两个 for 子句。注意,for 子句的末尾没有冒号,因为我们没有写一个完整的语句:

    >>> {... 
    
    ...     for item, import_list in import_details 
    
    ...         for name in import_list 
    
    ... } 
    
    {Ellipsis}
    

    现在,我们将表达式的结果写成了特殊的省略号对象。一旦我们完成这个表达式,我们将用更有用的东西替换它。

  3. 在 for 子句前加上创建目标集合每个值的表达式。在这种情况下,我们只想从整体导入详情列表中的每一对项目中的导入列表中获取名称:

    >>> names = {name 
    
    ...     for item, import_list in import_details 
    
    ...         for name in import_list} 
    
    >>> names ==  {’Chapter_12.ch12_r06’, ’Chapter_12.ch12_r05’, 
    
    ...     ’typing’, ’concurrent’, ’argparse’, ’textwrap’, ’pathlib’} 
    
    True
    

集合推导不能有重复项,所以这总是有不同值的。

就像列表推导一样,集合推导围绕一个中心生成器表达式构建。这个推导的核心生成器表达式有一个数据表达式子句和一个 for 子句。

我们可以用 set() 函数替换包围的 { 和 } 语法。当我们考虑更改数据结构的可能性时,使用显式的 set() 函数有优势。我们可以轻松地将 set() 替换为 frozenset()、list() 或 Counter()。

4.7.3 它是如何工作的...

集合是不可变对象的集合。每个不可变的 Python 对象都有一个哈希值,这些数字哈希码用于优化在集合中定位项目。我们可以想象实现依赖于一个桶的数组,数字哈希值将我们引导到桶中,以查看项目是否存在于该桶中。

哈希值不一定唯一。哈希桶的数组是有限的,这意味着可能发生哈希冲突。当两个不同的对象都具有相同的哈希值时,就会发生冲突。这导致了一些处理任何冲突的开销。

我们可以创建两个将产生哈希冲突的整数:

>>> import sys 

>>> v1 = 7 

>>> v2 = 7+sys.hash_info.modulus 

>>> v1 

7 

>>> v2 

2305843009213693958 

>>> hash(v1) 

7 

>>> hash(v2) 

7

尽管这两个对象具有相同的哈希值,但哈希冲突处理将保持这两个对象在集合中相互分离。

4.7.4 更多内容...

我们有几种方法可以向集合中添加项目:

  • 示例使用了 add() 方法。这对于单个项目是有效的。

  • 我们可以使用 union() 方法。这个方法就像一个运算符——它创建一个新的结果集。它不会修改操作数集合中的任何一个。

  • 我们可以使用 update() 方法用另一个集合中的项目更新一个集合。这会修改集合,但不返回任何值。

对于这些技术中的大多数,我们需要从我们要添加的项目创建一个单元素集合。以下是一些示例:

>>> collection = {1} 

>>> collection 

{1} 

>>> item = 3 

>>> collection.union({item}) 

{1, 3} 

>>> collection 

{1}

在前面的例子中,我们已从 item 变量的值创建了一个单元素集合 {item}。然后我们使用了 union() 方法来计算一个新的集合,这是集合集合和 {item} 集合的并集。

注意,union() 创建了一个新对象,并保留了原始集合不变。这里还有一个使用并集运算符 | 的另一种替代方案:

>>> collection = collection | {item} 

>>> collection 

{1, 3} 

我们还可以使用 update() 方法来修改集合:

>>> collection.update({4}) 

>>> collection 

{1, 3, 4}

类似于 update() 和 add() 这样的方法会修改集合对象。因为它们会修改集合,所以它们不会返回值。这与列表集合的方法类似。通常,修改集合的方法不会返回值。这个模式的唯一例外是 pop() 方法,它既会修改集合对象又会返回被弹出的值。

Python 有许多集合运算符。这些是我们可以用于复杂集合表达式的普通运算符符号:

  • | 用于集合并集,通常表示为 A ∪ B

  • & 用于集合交集,通常表示为 A ∩ B

  • ^ 用于集合对称差集,通常表示为 A △ B

    • 用于集合减法,通常表示为 A ∖ B

4.7.5 参考内容

  • 在缩小集合 – remove(), pop(), 和 difference 菜谱中,我们将探讨如何通过删除或替换项目来更新集合。

4.8 缩小集合 – remove(), pop(), 和 difference

Python 给我们几种从集合集合中删除项目的方法。我们可以使用 remove() 方法来删除一个特定的项目。我们可以使用 pop() 方法来删除(并返回)一个任意项目。

此外,我们可以使用集合交集、差集和对称差集运算符:&、- 和 ^ 来计算一个新的集合。这些将产生一个新的集合,它是给定输入集合的子集。

4.8.1 准备工作

有时,我们会有包含复杂和多变格式的日志文件。以下是从一个长而复杂的日志中摘取的一小段:

>>> log = """ 

... [2016-03-05T09:29:31-05:00] INFO: Processing ruby_block[print IP] action run (@recipe_files::/home/slott/ch4/deploy.rb line 9) 

... [2016-03-05T09:29:31-05:00] INFO: Installed IP: 111.222.111.222 

... [2016-03-05T09:29:31-05:00] INFO: ruby_block[print IP] called 

...

... (Skipping some details)
 ... """

我们需要在这个日志中找到所有类似于 IP: 111.222.111.222 的文本。这些是具有 4 个数字字段的 IPv4 地址。

这是我们如何创建匹配项集合的方法:

>>> import re 

>>> pattern = re.compile(r"IP: \d+\.\d+\.\d+\.\d+") 

>>> matches = set(pattern.findall(log)) 

>>> matches 

{’IP: 111.222.111.222’}

我们这个日志的问题是有一些无关的匹配项。日志文件中还有一些看起来相似但却是我们需要忽略的占位符或占位值。在完整的日志中,我们还会找到包含类似 IP: 1.2.3.4 这样的文本的行,这是一个占位符,不是一个有意义的地址。结果证明,存在一组不相关的值。

这是在集合交集和集合减法可以非常有帮助的地方。

4.8.2 如何做到...

  1. 创建一个我们想要忽略的项目集合,作为一个集合字面量:

    >>> to_be_ignored = {’IP: 0.0.0.0’, ’IP: 1.2.3.4’}
    
  2. 收集日志中的所有条目。我们将使用前面展示的 re 模块来完成此操作。我们将看到以下结果:

    >>> matches = {’IP: 111.222.111.222’, ’IP: 1.2.3.4’} 
    
  3. 使用集合减法从匹配项集合中删除项目。以下有两个示例:

    >>> matches - to_be_ignored 
    
    {’IP: 111.222.111.222’} 
    
    >>> matches.difference(to_be_ignored) 
    
    {’IP: 111.222.111.222’}
    

    这两个都是返回新集合作为结果的运算符。这两个都不会修改底层集合对象。

结果表明,difference() 方法可以与任何可迭代的集合一起工作,包括列表和元组。虽然允许混合集合和列表,但这可能会造成混淆,并且为它们编写类型提示可能会很具挑战性。

我们经常在语句中使用这些,例如以下示例:

>>> valid_matches = matches - to_be_ignored 

>>> valid_matches 

{’IP: 111.222.111.222’}

这将把结果集合分配给一个新的变量,valid_matches,这样我们就可以对这个新集合进行所需的处理。

我们还可以使用 remove()pop() 方法来移除特定项目。当无法移除项目时,remove() 方法会引发异常。我们可以利用这种行为来确认项目是否在集合中,并移除它。

4.8.3 它是如何工作的...

集合对象跟踪项目的成员资格。一个项目要么在集合中,要么不在。我们指定要移除的项目。移除项目不依赖于索引位置或键值。

集合运算符允许复杂的集合计算。我们可以从目标集合中移除一个集合中的任何项目,计算集合差或集合减法。

4.8.4 更多内容...

我们有几种其他方法可以从集合中移除项目:

  • 在这个例子中,我们使用了 difference() 方法和 - 操作符。difference() 方法的行为类似于操作符,并创建一个新的集合。

  • 我们还可以使用 difference_update() 方法。这将就地修改一个集合。它不会返回任何值。

  • 我们可以使用 remove() 方法移除单个项目。

  • 我们还可以使用 pop() 方法移除任意项目。这在这个例子中不太适用,因为我们无法控制从集合中弹出的项目。

下面是 difference_update() 方法的样子:

>>> valid_matches = matches.copy() 

>>> valid_matches.difference_update(to_be_ignored) 

>>> valid_matches 

{’IP: 111.222.111.222’}

我们应用了 difference_update() 方法来从 valid_matches 集合中移除不希望的项目。由于 valid_matches 集合被修改了,没有返回值。此外,由于这是一个副本,此操作不会修改原始的 matches 集合。

我们可以使用以下示例来使用 remove() 方法。请注意,如果项目不在集合中,remove() 将引发异常:

>>> valid_matches = matches.copy() 

>>> for item in to_be_ignored: 

...     if item in valid_matches: 

...         valid_matches.remove(item) 

>>> valid_matches 

{’IP: 111.222.111.222’}

在尝试移除项目之前,我们测试了项目是否在 valid_matches 集合中,以避免引发 KeyError 异常。使用 if 语句是避免引发异常的一种方法。另一种方法是使用 try: 语句来抑制当项目不存在时引发的异常。

我们还可以使用 pop() 方法来移除任意项目。这种方法很特别,因为它既修改了集合,又返回了被移除的项目。然而,我们无法控制哪个项目被弹出,这使得它不适合这个例子。

4.9 编写与集合相关的类型提示

typing 模块提供了一些基本类型定义,用于描述集合对象的内部内容。主要类型定义是 set,我们可以用集合中项目的类型来参数化它。我们将使用 set[int] 来描述由整数组成的集合。这与编写与列表相关的类型提示食谱相平行。

4.9.1 准备工作

类似于 Zonk(也称为 10,000 或 Greed)的掷骰子游戏需要将一组随机骰子分组到“手”中。虽然规则各不相同,但手有几种模式,包括:

  • 三张相同的牌。

  • 一组五个递增的骰子(1-2-3-4-5 或 2-3-4-5-6 是两种组合)。

  • 一组六个递增的骰子(称为“大顺子”)。

  • 一个“王牌”手。这至少有一个不是三张相同或顺子的 1 骰子。

  • 一组相同的六个骰子。虽然罕见,但并非不可能。

我们将使用以下类和函数定义来创建骰子手牌:

import random 

class Die(str, Enum): 

    d_1 = "\u2680" 

    d_2 = "\u2681" 

    d_3 = "\u2682" 

    d_4 = "\u2683" 

    d_5 = "\u2684" 

    d_6 = "\u2685" 

def zonk(n: int = 6) -> tuple[Die, ...]: 

    faces = list(Die) 

    return tuple(random.choice(faces) for _ in range(n))

Die 类的定义通过提供适当的 Unicode 字符来枚举标准骰子的六个面。

当我们评估 zonk()函数时,它看起来是这样的。

>>> zonk() 

(<Die.d_6: ’’>, <Die.d_1: ’’>, <Die.d_1: ’’>, 

 <Die.d_6: ’’>, <Die.d_3: ’’>, <Die.d_2: ’’>)

这向我们展示了一副包含两个六、两个一、一个二和一个三的手牌。在检查手牌以寻找模式时,我们通常会创建复杂的一组对象。

4.9.2 如何实现...

一个用于分析骰子模式的功能通过从六个骰子实例创建一个 set[Die]对象来工作。这个集合揭示了许多信息:

  • 当集合中独特的值只有一个骰子时,那么所有六个骰子都有相同的值。

  • 当集合中独特的值有五个骰子时,这可能是小顺子。这需要额外的检查以查看独特的值集合是否为 1-5 或 2-6,这两个是有效的小顺子。

  • 当集合中独特的值有六个不同项时,那么这必须是一个大顺子。

  • 对于两个独特的骰子值,至少会有一个三个一的情况。可能会有四个或五个一的情况,但这些被计分为三个一,剩余的骰子不计分。

  • 对于集合中的三个或四个独特骰子,可能会有三个一的情况。需要更详细地分析集合以查看确切的模式。

我们可以通过查看独特骰子集合的基数来区分许多模式。剩余的区别可以通过查看计数模式来实现。为此,collections.Counter 对象将很有用。

下面是如何编写这种基于集合的分析:

  1. 定义集合中每个项目的类型。在这个例子中,Die 类是项目类。我们将使用 set[Die]和 Counter[Die]类型。

  2. 使用 Die 实例的手牌中的独特值创建集合对象。下面是评估函数如何开始:

    import collections 
    
    def eval_zonk_6(hand: tuple[Die, ...]) -> str: 
    
        assert len(hand) == 6, "Only works for 6-dice zonk." 
    
        unique: set[Die] = set(hand)
    
  3. 有两个小顺子的定义:1-5 和 2-6:

     faces = list(Die) 
    
        small_straights = [ 
    
            set(faces[:-1]), set(faces[1:]) 
    
        ]
    

    我们可以在分析函数的主体中构建这两个集合,以展示它们是如何被使用的。从实用主义的角度来看,small_straights 的值应该只计算一次。

    我们不能构建这两个集合实例的集合,因为集合对象是可变的。我们可以通过构建两个 frozenset 对象来代替构建列表。

  4. 检查简单的情况。集合中不同元素的数量直接识别了几种手牌:

     if len(unique) == 6: 
    
            return "large straight" 
    
        elif len(unique) == 5 and unique in small_straights: 
    
            return "small straight" 
    
        elif len(unique) == 2: 
    
            return "three of a kind" 
    
        elif len(unique) == 1: 
    
            return "six of a kind"
    
  5. 当集合中有三个或四个独特值时,可以使用计数来总结模式。这种频率计数的模式可以总结为 set[int]:

     elif len(unique) in {3, 4}: 
    
            # 4 unique: wwwxyz (good) or wwxxyz (bad) 
    
            # 3 unique: xxxxyz, xxxyyz (good) or xxyyzz (bad) 
    
            frequencies: set[int] = set( 
    
                collections.Counter(hand).values())
    
  6. 对于三个或四个不同的 Die 值的情况,这些可以形成各种模式。如果至少有一个 Die 的频率为三个或四个,那么这就是一个计分组合。如果没有其他匹配,并且有一个骰子显示一,那么这就是最小得分:

     if 3 in frequencies or 4 in frequencies: 
    
                return "three of a kind" 
    
            elif Die.d_1 in unique: 
    
                return "ace" 
    
  7. 是否还有剩余的条件?这涵盖了所有可能的骰子基数和频率吗?剩余的情况包括没有“一个”出现的成对和单数集合。在上述 if 语句之后,我们可以提供一个单独的返回语句来收集所有其他情况到一个单一的、不计分的 Zonk 中:

     return "Zonk!"
    

这显示了两种使用集合评估数据项集合模式的方法。第一个集合,set[Die],检查了唯一 Die 值的整体模式。第二个集合,set[int],检查了 Die 值频率的模式。

4.9.3 它是如何工作的...

集合的基本属性是成员资格。当我们从 Die 实例的集合中计算集合时,我们使用 set[Die]类型提示来描述这个结构。

类似地,当我们查看频率分布时,只有几种不同的模式。将计数转换为具有 set[int]类型提示的值的集合,描述了这种额外的结构。

4.9.4 更多内容...

一组没有单一、统一类型的项可能会令人困惑。我们可以使用 set[T1 | T2]来描述一个可以包含 T1 或 T2 类型的项的集合。

计算骰子手的分数取决于哪些骰子是获胜模式的一部分。这意味着当结果是三张牌时,评估函数需要返回一个更复杂的结果。为了确定分数,我们需要考虑三种情况:

  • Dice 类中哪个值出现了三次或六次。这决定了基础分数。通常,1 被赋予 1,000 分,2 到 6 被赋予 200 到 600 分。

  • 抛出两个三重组合是可能的;这种模式也需要区分。这通常得分 2,000 点,无论显示的数字是多少。

  • 对于顺子和“王牌”手牌,分配了简单的固定分数。小顺子可能是 1,000 分。大顺子 2,000 分。单独的 1 可能只得分 50 分。

我们有两个单独的条件来识别表示三张牌模式的唯一值模式。该函数需要一些重构,以正确识别出现三次或更多次的骰子值以及被忽略的骰子值。我们将这个额外的设计留作读者的练习。

4.9.5 参考以下内容

  • 参考本章中的编写列表相关类型提示配方以了解更多关于列表类型提示的信息。

加入我们的社区 Discord 空间

加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

PIC

第五章:5

内置数据结构第二部分:字典

从第四章开始,内置数据结构第一部分:列表和集合,我们开始探讨 Python 丰富的内置数据结构集合。这些数据结构有时被称为“容器”或“集合”,因为它们包含一系列单独的项目。

在本章中,我们将介绍字典结构。字典是从键到值的映射,有时也称为关联数组。将映射与两个序列(列表和集合)分开似乎是有道理的。

本章还将探讨一些与 Python 处理可变集合对象引用相关的更高级主题。这会影响函数的定义方式。

在本章中,我们将探讨以下菜谱,所有这些都与 Python 的内置数据结构相关:

  • 创建字典 – 插入和更新

  • 缩小字典 – pop() 方法和 del 语句

  • 编写与字典相关的类型提示

  • 理解变量、引用和赋值

  • 创建对象的浅拷贝和深拷贝

  • 避免为函数参数使用可变默认值

我们将从如何创建一个字典开始。

5.1 创建字典 – 插入和更新

字典是 Python 的一种映射类型。内置类型 dict 提供了许多基础特性。在 collections 模块中定义了一些这些特性的常见变体。

正如我们在第四章开头的 选择数据结构 菜谱中提到的,当我们有一个需要将键映射到给定值的键时,我们将使用字典。例如,我们可能想要将一个单词映射到该单词的复杂定义,或者将某个值映射到该值在数据集中出现的次数。

5.1.1 准备工作

我们将探讨一个用于定位事务处理各个阶段的算法。这依赖于为每个请求分配一个唯一的 ID,并在事务期间包含每个日志记录中该 ID。由于多线程服务器可能同时处理多个请求,每个请求的事务阶段将不可预测地交错。按请求 ID 重新组织日志有助于隔离每个事务。

这里是一个模拟的三个并发请求的日志条目序列:

[2019/11/12:08:09:10,123] INFO #PJQXB^{}eRwnEGG?2%32U path="/openapi.yaml" method=GET 

[2019/11/12:08:09:10,234] INFO 9DiC!B^{}nXxnEGG?2%32U path="/items?limit=x" method=GET 

[2019/11/12:08:09:10,235] INFO 9DiC!B^{}nXxnEGG?2%32U error="invalid query" 

[2019/11/12:08:09:10,345] INFO #PJQXB^{}eRwnEGG?2%32U status="200" bytes="11234" 

[2019/11/12:08:09:10,456] INFO 9DiC!B^{}nXxnEGG?2%32U status="404" bytes="987" 

[2019/11/12:08:09:10,567] INFO >~UL>~PB_R>&nEGG?2%32U path="/category/42" method=GET

行很长,可能随意换行以适应书籍的边距。每一行都有一个时间戳。示例中显示的每个记录的严重级别都是 INFO。接下来的 20 个字符是一个事务 ID。之后是针对事务中特定步骤的日志信息。

以下正则表达式定义了日志记录:

import re 

log_parser = re.compile(r"\[(.*?)\] (\w+) (\S+) (.*)")

此模式捕获每个日志条目的四个字段。有关正则表达式的更多信息,请参阅第一章的 使用正则表达式进行字符串解析 菜谱。

解析这些行将产生一个四元组序列。结果对象看起来像这样:

[(’2019/11/12:08:09:10,123’, 

  ’INFO’, 

  ’#PJQXB^{}eRwnEGG?2%32U’, 

  ’path="/openapi.yaml" method=GET’), 

 (’2019/11/12:08:09:10,234’, 

  ’INFO’, 

  ’9DiC!B^{}nXxnEGG?2%32U’, 

  ’path="/items?limit=x" method=GET’),
... details omitted ...
 (’2019/11/12:08:09:10,567’, 

  ’INFO’, 

  ’>~UL>~PB_R>&nEGG?2%32U’, 

  ’path="/category/42" method=GET’)]

我们需要知道每个唯一路径被请求的频率。这意味着忽略一些日志记录并从其他记录中收集数据。从路径字符串到计数的映射是一种优雅地收集这些数据的方法。我们将详细讨论如何实现。稍后,我们将查看 collections 模块中的一些替代实现。

5.1.2 如何实现...

我们有多种构建字典对象的方法:

  • 文字:我们可以通过使用由 {} 字符包围的键值对序列来创建字典的显示。我们在键和关联的值之间使用冒号。文字看起来像这样:{"num": 355, "den": 113}。

  • 转换函数:一个由两个元组组成的序列可以转换成这样的字典:dict([(‘num’,355),(‘den’,113)]). 每个元组变成一个键值对。键必须是不可变对象,如字符串、数字或不可变对象的元组。我们也可以这样构建字典:dict(num=355, den=113). 每个参数名称都变成一个键。这限制了字典键为字符串,这些字符串也是有效的 Python 变量名。

  • 插入:我们可以使用字典 [key] = value 语法在字典中设置或替换一个值。我们将在本菜谱的后面讨论这个问题。

  • 理解:与列表和集合类似,我们可以编写一个字典理解来从某些数据源构建字典。

通过设置项构建字典

我们通过创建一个空字典然后向其中设置项来构建字典:

  1. 创建一个空字典以映射路径到计数。我们也可以使用 dict() 来创建一个空字典。由于我们将创建一个计数路径使用次数的直方图,我们将它命名为 histogram:

    >>> histogram = {}
    

    我们也可以使用函数 dict() 而不是字面量值 {} 来创建一个空字典。

  2. 对于每条日志行,过滤掉那些在索引为 3 的项中没有以 path 开头的值的那些行:

    >>> for line in log_lines: 
    
    ...     path_method = line[3]  # group(4) of the original match 
    
    ...     if path_method.startswith("path"):
    
  3. 如果路径不在字典中,我们需要添加它。一旦 path_method 字符串的值在字典中,我们就可以根据数据中的键在字典中增加值。

     ...         if path_method not in histogram: 
    
    ...             histogram[path_method] = 0 
    
    ...         histogram[path_method] += 1
    

这种技术将每个新的 path_method 值添加到字典中。一旦确定 path_method 键在字典中,我们就可以增加与该键关联的值。

通过理解构建字典

每条日志行的最后一个字段有一个或两个字段。可能有一个像 path="/openapi.yaml" method=GET 这样的值,包含两个属性 path 和 method,或者一个像 error="invalid query" 这样的值,只有一个属性 error。

我们可以使用以下正则表达式来分解每行的最终字段:

param_parser = re.compile( 

    r’(\w+)=(".*?"|\w+)’ 

)

这个正则表达式的 findall()方法将基于匹配文本提供一个包含两个元组的序列。然后我们可以从匹配组的序列构建一个字典:

  1. 对于每条日志行,应用正则表达式以创建一对序列:

    >>> for line in log_lines: 
    
    ...     name_value_pairs = param_parser.findall(line[3])
    
  2. 使用字典推导式,将第一个匹配组作为键,第二个匹配组作为值:

     ...     params = {match[0]: match[1] for match in name_value_pairs} 
    

我们可以打印出参数值,我们会看到如下示例中的字典:

{’path’: ’"/openapi.yaml"’, ’method’: ’GET’} 

{’path’: ’"/items?limit=x"’, ’method’: ’GET’} 

{’error’: ’"invalid query"’}

使用字典作为每个日志记录的最终字段,使得分离重要信息变得更容易。

5.1.3 工作原理...

字典的核心功能是从不可变键到任何类型值对象的映射。在第一个示例中,我们使用了不可变的字符串作为键,整数作为值。我们在类型提示中描述它为 dict[str, int]。

理解+=赋值语句的工作方式很重要。+=的实现基本上是这样的:

histogram[customer] = histogram[customer] + 1

从字典中获取的直方图[客户]值被计算出一个新值,并将结果用于更新字典。

确保字典键对象是不可变的。我们不能使用列表、集合或字典作为字典映射的键。然而,我们可以将列表转换为不可变的元组,或者将集合转换为 frozenset,这样我们就可以使用这些更复杂对象中的一个作为键。在本食谱中显示的示例中,我们使用了不可变的字符串作为每个字典的键。

5.1.4 更多...

我们不必使用 if 语句来添加缺失的键。我们可以使用字典的 setdefault()方法。使用 collections 模块中的类甚至更容易。

这是使用 collections 模块中的 defaultdict 类的版本:

>>> from collections import defaultdict

>>> histogram = defaultdict(int) 

>>> for line in log_lines: 

...     path_method = line[3]  # group(4) of the match 

...     if path_method.startswith("path"): 

...         histogram[path_method] += 1

我们创建了一个 defaultdict 实例,它将使用 int()函数初始化任何未知的键值。我们将函数对象 int 提供给了 defaultdict 构造函数。defaultdict 实例将评估给定的函数以创建默认值。

这允许我们使用 histogram[path_method] += 1。如果与 path_method 键关联的值之前在字典中,该值将增加并放回字典中。如果 path_method 键不在字典中,则调用 int()函数不带任何参数;这个默认值将被增加并放入字典中。

我们还可以通过创建 Counter 对象来累积频率计数。我们可以如下从原始数据构建 Counter 对象:

>>> from collections import Counter 

>>> filtered_paths = ( 

...     line[3] 

...     for line in log_lines 

...     if line[3].startswith("path") 

... ) 

>>> histogram = Counter(filtered_paths) 

>>> histogram 

Counter({’path="/openapi.yaml" method=GET’: 1, ’path="/items?limit=x" method=GET’: 1, ’path="/category/42" method=GET’: 1})

首先,我们使用生成表达式创建了一个过滤路径数据的迭代器;这被分配给了 filtered_paths。然后我们从数据源创建了一个 Counter;Counter 类将扫描数据并计算不同出现的次数。

5.1.5 参见

  • 在收缩字典 – pop() 方法和 del 语句的菜谱中,我们将探讨如何通过删除项来修改字典。

5.2 收缩字典 – pop() 方法和 del 语句

字典的一个常见用途是作为关联存储:它保持键和值对象之间的关联。这意味着我们可能对字典中的项执行任何 CRUD 操作:

  • 创建一个新的键值对。

  • 获取与键关联的值。

  • 更新与键关联的值。

  • 从字典中删除键(及其对应的值)。

5.2.1 准备工作

大量的处理支持围绕一个(或多个)不同的共同值对项目进行分组的需求。我们将回到本章中创建字典 – 插入和更新菜谱中显示的日志数据。

我们将使用一个迭代器算法,该算法使用交易 ID 作为字典中的键。这个键的值将是交易的步骤序列。在非常长的日志中,我们通常不想在巨大的字典中保存每一笔交易。当我们达到交易序列的终止时,我们可以产生交易日志条目的列表。一个函数可以消费这个迭代器,独立地处理每一批交易。

5.2.2 如何做...

这个菜谱的上下文将需要一个条件为 match := log_parser.match(line) 的 if 语句。这将应用正则表达式,并将结果收集在 match 变量中。给定这个上下文,更新或从字典中删除的处理如下:

  1. 这个函数使用 defaultdict 类和两个额外的类型提示,可迭代和迭代器:

    from collections import defaultdict 
    
    from collections.abc import Iterable, Iterator
    
  2. 定义一个 defaultdict 对象来保存交易步骤。键是 20 个字符的字符串。值是日志记录的列表。在这种情况下,每个日志记录都将从源文本解析成单个字符串的元组:

    LogRec = tuple[str, ...] 
    
    def request_iter_t(source: Iterable[str]) -> Iterator[list[LogRec]]: 
    
        requests: defaultdict[str, list[LogRec]] = defaultdict(list)
    
  3. 定义每个日志条目组的键:

     for line in source: 
    
            if match := log_parser.match(line): 
    
                id = match.group(3)
    
  4. 使用日志记录更新字典项:

     requests[id].append(tuple(match.groups()))
    
  5. 如果这个日志记录完成了一笔交易,作为生成器函数的一部分产生这个组。然后从字典中删除交易,因为它已经完成:

     if match.group(4).startswith(’status’): 
    
                    yield requests[id] 
    
                    del requests[id]
    
  6. 最后,可能会有一个非空的请求字典。这反映了在日志文件切换时正在进行的交易。

5.2.3 它是如何工作的...

因为字典是一个可变对象,所以我们可以从字典中删除键。del 语句将删除与键关联的键和值对象。在这个例子中,当数据表明交易完成时,键被删除。一个处理平均每秒 10 笔交易的繁忙的 Web 服务器在 24 小时内将看到 864,000 笔交易。如果每笔交易平均有 2.5 条日志条目,文件中至少将有 2,160,000 行。

如果我们只想知道每个资源的耗时,我们不想在内存中保留包含 864,000 个事务的整个字典。我们更愿意将日志转换成一个中间摘要文件以供进一步分析。

这种临时数据的概念使我们把解析的日志行累积到一个列表实例中。每行新内容都追加到属于该事务的适当列表中。当找到最后一行时,这些行可以从字典中清除。

5.2.4 更多内容...

在示例中,我们使用了 del 语句。pop()方法也可以使用。如果给定的项目在字典中找不到,del 语句将引发 KeyError 异常。

pop()方法看起来是这样的:

        requests.pop(id)

这将就地修改字典,如果存在则删除项目,或者引发 KeyError 异常。

当 pop()方法提供一个默认值时,如果找不到键,它可以返回给定的默认值而不是引发异常。在任何情况下,键将不再存在于字典中。请注意,此方法既修改了集合又返回了一个值。

popitem()方法将从字典中删除一个键值对。这些对以最后进入,最先出来(LIFO)的顺序返回。这意味着字典也是一种栈。

5.2.5 参见

  • 在创建字典 – 插入和更新菜谱中,我们探讨了如何创建字典并将它们填充键和值。

5.3 编写与字典相关的类型提示

当我们查看集合和列表时,我们通常期望列表(或集合)中的每个项目都是相同类型。当我们查看面向对象的类设计时,在第七章中,我们将看到如何一个公共超类可以成为紧密相关的对象类型家族的共同类型。虽然在一个列表或集合集合中可以有异构类型,但通常处理起来相当复杂,需要匹配语句来进行适当的类型匹配。然而,字典可以用来创建类型的区分联合。特定的键值可以用来定义字典中存在哪些其他键。这意味着一个简单的 if 语句可以区分异构类型。

5.3.1 准备工作

我们将查看两种类型的字典类型提示,一种用于同质值类型,另一种用于异构值类型。我们将查看最初是这些类型之一的数据字典,但后来被转换成更复杂的类型定义。

我们将从一个以下 CSV 文件开始:

date,engine on,fuel height on,engine off,fuel height off 

10/25/13,08:24:00,29,13:15:00,27 

10/26/13,09:12:00,27,18:25:00,22 

10/28/13,13:21:00,22,06:25:00,14

这描述了乘帆船进行的多日旅行中的三个独立阶段。燃料是通过油箱中的高度来测量的,而不是使用浮子或其他仪表的间接方法。因为油箱大约是矩形的,31 英寸的深度大约是 75 加仑的燃料。

5.3.2 如何做...

csv.DictReader 的初始使用将导致具有同质类型定义的字典:

  1. 定位字典中键的类型。当读取 CSV 文件时,键是字符串,类型为 str。

  2. 定位字典中值的类型。当读取 CSV 文件时,值是字符串,类型为 str。

  3. 使用 dict 类型提示组合类型。这产生 dict[str, str]。

这里是一个从 CSV 文件读取数据的示例函数:

import csv 

from pathlib import Path 

def get_fuel_use(source_path: Path) -> list[dict[str, str]]: 

    with source_path.open() as source_file: 

        rdr = csv.DictReader(source_file) 

        data: list[dict[str, str]] = list(rdr) 

    return data

get_fuel_use() 函数产生与源数据匹配的值。在这种情况下,它是一个将字符串列名映射到字符串单元格值的字典。

这份数据本身难以处理。常见的第二步是对源行应用转换以创建更有用的数据类型。我们可以用类型提示来描述结果:

  1. 确定所需的各种值类型。在这个例子中,有五个字段,三种不同类型,如下所示:

    • 日期字段是一个 datetime.date 对象。

    • 引擎字段是一个 datetime.time 对象。

    • 燃料高度字段是一个整数,但我们知道它将在浮点上下文中使用,因此我们将直接创建一个浮点数。

    • 引擎关闭字段是一个 datetime.time 对象。

    • 燃料高度字段也是一个浮点值。

  2. 从 typing 模块导入 TypedDict 类型定义。

  3. 定义具有新异构字典类型的 TypedDict 子类。

    import datetime 
    
    from typing import TypedDict 
    
    class History(TypedDict): 
    
        date: datetime.date 
    
        start_time: datetime.time 
    
        start_fuel: float 
    
        end_time: datetime.time 
    
        end_fuel: float
    

    这部分是第七章的预告 7。它展示了一种非常简单的类定义。在这种情况下,类是具有五个特定键的字典,所有这些键都是必需的,并且必须具有给定类型的值。

在这个例子中,我们还重命名了字段,使它们成为有效的 Python 名称。用 _ 替换标点是明显的第一步。我们还更改了一些,因为 CSV 文件中的列名看起来很别扭。

执行转换的函数可能看起来像以下示例:

from collections.abc import Iterable, Iterator 

def make_history(source: Iterable[dict[str, str]]) -> Iterator[History]: 

    for row in source: 

        yield dict( 

            date=datetime.datetime.strptime( 

                row[’date’], "%m/%d/%y").date(), 

            start_time=datetime.datetime.strptime( 

                row[’engine on’], ’%H:%M:%S’).time(), 

            start_fuel=float(row[’fuel height on’]), 

            end_time=datetime.datetime.strptime( 

                row[’engine off’], ’%H:%M:%S’).time(), 

            end_fuel=float(row[’fuel height off’]), 

        )

这个函数消耗初始 dict[str, str] 字典的实例,并创建由 History 类描述的字典的实例。以下是这两个函数如何一起工作的:

>>> from pprint import pprint 

>>> source_path = Path("data/fuel2.csv") 

>>> fuel_use = make_history(get_fuel_use(source_path)) 

>>> for row in fuel_use: 

...     pprint(row) 

{’date’: datetime.date(2013, 10, 25), 

 ’end_fuel’: 27.0, 

 ’end_time’: datetime.time(13, 15), 

 ’start_fuel’: 29.0, 

 ’start_time’: datetime.time(8, 24)} 

{’date’: datetime.date(2013, 10, 26), 

 ’end_fuel’: 22.0, 

 ’end_time’: datetime.time(18, 25), 

 ’start_fuel’: 27.0, 

 ’start_time’: datetime.time(9, 12)} 

{’date’: datetime.date(2013, 10, 28), 

 ’end_fuel’: 14.0, 

 ’end_time’: datetime.time(6, 25), 

 ’start_fuel’: 22.0, 

 ’start_time’: datetime.time(13, 21)}

这展示了如何通过 make_history() 函数处理 get_fuel_use() 函数的输出,以创建一个字典的可迭代序列。每个结果字典都将源数据转换为更实用的类型。

5.3.3 它是如何工作的...

字典的核心类型提示命名了键类型和值类型,形式为 dict[key, value]。TypedDict 类允许我们更具体地描述字典键与广泛值域之间的绑定。

重要的是要注意,类型提示仅由 mypy 等程序检查。这些提示对运行时没有影响。例如,我们可以编写如下语句:

result: History = {’date’: 42}

这个语句声称结果字典将匹配 History 类型定义中的类型提示。然而,字典字面量在 'date' 字段类型不正确,并且许多其他字段缺失。虽然这会执行,但会从 mypy 引发错误。

运行 mypy 程序会显示如下列表中的错误:

(cookbook3) % python -m mypy src/ch05/recipe_04_bad.py 

src/ch05/recipe_04_bad.py:18: error: Missing keys ("start_time", "start_fuel", "end_time", "end_fuel") for TypedDict "History"  [typeddict-item] 

Found 1 error in 1 file (checked 1 source file)

对于运行时数据验证,像 Pydantic 这样的项目可以非常有帮助。

5.3.4 更多...

字典键异质性的常见情况之一是可选项。类型提示 Optional[str]或 str | None 描述了这一点。在字典中很少需要这样做,因为它可以更简单地省略整个键值对。

假设我们需要 History 类型的两个变体:

  • 在此配方中较早展示的变体,其中所有字段都存在。

  • 两个“不完整”的记录,一个没有关机时间或结束燃油高度,另一个变体没有开机时间或起始燃油高度。这两个记录可能用于有动力过夜航行。

在这种情况下,我们可能需要使用 NotRequired 注解这些字段。生成的类定义将如下所示:

from typing import TypedDict, NotRequired 

class History2(TypedDict): 

   date: datetime.date 

   start_time: NotRequired[datetime.time] 

   start_fuel: NotRequired[float] 

   end_time: NotRequired[datetime.time] 

   end_fuel: NotRequired[float] 

此记录允许字典值有很大的可变性。它需要使用 if 语句来确定数据中存在的字段组合。此外,它还需要在 make_history()函数中进行一些更复杂的处理,以根据 CSV 文件中的空列创建这些变体记录。

TypedDict 和 NamedTuple 类型定义之间存在一些相似之处。将 TypedDict 更改为 NamedTuple 将创建一个命名元组类而不是类型字典类。

由于 NamedTuple 类有一个 _asdict()方法,因此可以从命名元组生成与 TypedDict 结构匹配的字典。

与 TypedDict 提示匹配的字典是可变的。然而,NamedTuple 的子类是不可变的。这是这两个类型提示之间的一个主要区别。更重要的是,字典使用 row[‘date’]语法通过键‘date’来引用一个项目。命名元组使用 row.date 语法通过名称来引用一个项目。

5.3.5 参见

  • 使用 NamedTuples 简化元组中的项目访问的配方提供了关于 NamedTuple 类型提示的更多详细信息。

  • 关于列表的类型提示,请参阅第四章中的编写与列表相关的类型提示配方。

  • 第四章中的编写与集合相关的类型提示配方从集合类型的角度涵盖了这一点。

  • 对于运行时数据验证,像 Pydantic 这样的项目可以非常有帮助。请参阅docs.pydantic.dev/latest/

5.4 理解变量、引用和赋值

变量实际上是如何工作的?当我们将可变对象分配给两个变量时会发生什么?当两个变量共享对公共可变对象的引用时,行为可能会令人困惑。

这是核心原则:Python 共享引用;它不会复制数据。

为了了解这个关于引用共享的规则意味着什么,我们将创建两个数据结构:一个是可变的,另一个是不可变的。

5.4.1 准备工作

我们将查看两种类型的序列,尽管我们也可以用两种类型的集合做类似的事情:

>>> mutable = [1, 1, 2, 3, 5, 8] 

>>> immutable = (5, 8, 13, 21)

我们将查看当这些对象的引用被共享时会发生什么。

我们可以用类似的方式与集合和 frozenset 进行比较。我们无法轻松地这样做,因为 Python 没有提供方便的不可变映射。

5.4.2 如何实现...

这个配方将展示如何观察当有两个引用到底层可变对象时的“超距作用”。我们将在制作浅拷贝和深拷贝对象配方中查看防止这种情况的方法。以下是查看可变和不可变集合之间差异的步骤:

  1. 将每个集合分配给一个额外的变量。这将创建对该结构的两个引用:

    >>> mutable_b = mutable 
    
    >>> immutable_b = immutable 
    

    现在我们有两个引用到列表 [1, 1, 2, 3, 5, 8] 和两个引用到元组 (5, 8, 13, 21)。

  2. 我们可以使用 is 运算符来确认这一点。这确定两个变量是否引用了同一个底层对象:

    >>> mutable_b is mutable 
    
    True 
    
    >>> immutable_b is immutable 
    
    True
    
  3. 对集合的两个引用之一进行更改。对于列表类型,我们有像 extend() 或 append() 这样的方法。在这个例子中,我们将使用 + 运算符:

    >>> mutable += [mutable[-2] + mutable[-1]]
    

    我们可以用类似的方法对不可变结构进行操作:

    >>> immutable += (immutable[-2] + immutable[-1],)
    
  4. 看看引用可变结构的另外两个变量。因为这两个变量是同一个底层列表对象的引用,每个变量都显示了当前状态:

    >>> mutable_b 
    
    [1, 1, 2, 3, 5, 8, 13] 
    
    >>> mutable is mutable_b 
    
    True
    
  5. 看看引用不可变结构的两个变量。最初,两个变量共享一个公共对象。当执行赋值语句时,创建了一个新的元组,只有一个变量更改以引用新的元组:

     >>> immutable_b 
    
    (5, 8, 13, 21) 
    
    >>> immutable 
    
    (5, 8, 13, 21, 34)
    

5.4.3 它是如何工作的...

两个变量 mutable 和 mutable_b 仍然引用同一个底层对象。正因为如此,我们可以使用任何一个变量来更改对象,并看到更改反映在另一个变量的值上。

两个变量 immutable_b 和 immutable 最初引用的是同一个对象。因为该对象不能就地修改,对其中一个变量的更改意味着将新对象分配给该变量。另一个变量仍然牢固地附加到原始对象上。

在 Python 中,变量是一个附加到对象的标签。我们可以把它们想象成我们暂时贴在对象上的鲜艳颜色的粘性便签。可以对对象附加多个标签。是赋值语句将变量名放置在对象上。

考虑以下声明:

immutable += (immutable[-2] + immutable[-1],)

这与以下语句具有相同的效果:

immutable = immutable + (immutable[-2] + immutable[-1],)

等号右侧的表达式从不可变元组的上一个值创建一个新的元组。然后赋值语句将标签 immutable 分配给新铸造的对象。

将值赋给变量有两种可能的行为:

  • 对于提供适当原地赋值操作符定义的可变对象,如+=,赋值会被转换为特殊方法;在这种情况下,__iadd__()。特殊方法将修改对象的内部状态。

  • 对于不提供如+=等赋值定义的不可变对象,赋值会被转换为=++操作符将构建一个新的对象,并将变量名附加到这个新对象上。之前引用被替换对象的变量不受影响;它们将继续引用旧对象。

Python 计算一个对象被引用的次数。当引用计数变为零时,该对象在任何地方都不再被使用,可以从内存中移除。

5.4.4 更多...

一些语言除了对象外还有原始类型。在这些语言中,+=语句可以利用硬件指令的特性来调整原始类型的值。

Python 没有这种优化。数字是不可变对象;没有特殊的指令来调整它们的值。考虑以下赋值语句:

a = 355 

a += 113

处理过程不会调整对象 355 的内部状态。int类不提供__iadd__()特殊方法。将创建一个新的不可变整数对象。这个新对象被标记为 a。之前分配给 a 的旧值不再需要,存储可以被回收。

5.4.5 参见

  • 在制作浅拷贝和深拷贝对象的菜谱中,我们将探讨如何复制可变结构以防止共享引用。

  • 此外,请参阅避免为函数参数使用可变默认值以了解 Python 中引用共享方式的另一种后果。

  • 对于 CPython 实现,一些对象可以是永生的。有关此实现细节的更多信息,请参阅PEP 683

5.5 制作浅拷贝和深拷贝对象

在本章中,我们讨论了赋值语句如何共享对象引用。对象通常不会被复制。

考虑以下赋值语句:

a = b

这创建了两个指向相同底层对象的引用。如果 b 变量的值具有可变类型,如列表、集合或字典类型,那么使用 a 或 b 进行更改将更新底层可变对象。更多背景信息,请参阅理解变量、引用和赋值菜谱。

大多数情况下,我们希望这种行为。这对于向函数提供可变对象并在函数中有一个局部变量来修改函数外部创建的对象是理想的。在罕见的情况下,我们希望实际上从单个原始对象创建两个独立的对象。

当两个变量引用相同的底层对象时,有两种方法可以断开这种连接:

  • 制作结构的浅拷贝

  • 创建结构的深拷贝

5.5.1 准备工作

Python 不会自动复制一个对象。我们已经看到了几种创建副本的语法:

  • 序列 - 列表,以及 str、bytes 和 tuple 类型:我们可以使用序列[:] 通过使用空切片表达式来复制序列。这是序列的一个特例。

  • 几乎所有集合都有一个 copy() 方法。

  • 调用一个类型,以该类型的实例作为唯一参数,返回一个副本。例如,如果 d 是一个字典,dict(d) 将创建 d 的浅拷贝。

重要的是,这些都是浅拷贝。当两个集合是浅拷贝时,它们各自包含对相同底层对象的引用。如果底层对象是不可变的,如元组、数字或字符串,这种区别并不重要。

例如,如果我们有 a = [1, 1, 2, 3],我们就无法对 a[0] 进行任何修改。a[0] 中的数字 1 没有内部状态。我们只能替换对象。

然而,当我们有一个涉及可变对象的集合时,问题就出现了。首先,我们会创建一个对象,然后创建一个副本:

>>> some_dict = {’a’: [1, 1, 2, 3]} 

>>> another_dict = some_dict.copy()

这个例子创建了一个字典的浅拷贝。这两个副本看起来很相似,因为它们都包含对相同对象的引用。有一个共享的引用到不可变的字符串 'a' 和一个共享的引用到可变的列表 [1, 1, 2, 3]。我们可以显示 another_dict 的值来看到它看起来像我们开始时的 some_dict 对象:

>>> another_dict 

{’a’: [1, 1, 2, 3]}

当我们更新字典副本中的共享列表时,会发生以下情况。我们将更改 some_dict 的值,并看到结果也出现在 another_dict 中:

>>> some_dict[’a’].append(5) 

>>> another_dict 

{’a’: [1, 1, 2, 3, 5]}

我们可以使用 id() 函数看到项是共享的:

>>> id(some_dict[’a’]) == id(another_dict[’a’]) 

True

因为两个 id() 值相同,所以它们是相同的底层对象。与键 'a' 关联的值在 some_dict 和 another_dict 中都是相同的可变列表。我们也可以使用 is 操作符来看到它们是相同的对象。

这种对浅拷贝的修改适用于包含所有其他可变对象类型作为项的列表集合:

因为我们不能创建一个包含可变对象的集合,所以我们实际上不必考虑创建共享项的集合的浅拷贝。

一个元组可以包含可变对象。虽然元组是不可变的,但元组内的对象是可变的。

元组的不可变性不会神奇地传播到元组内的项。

如果我们想要完全断开两个副本的连接?我们如何创建深拷贝而不是浅拷贝?

5.5.2 如何实现...

Python 通常通过共享引用来工作。它不情愿地复制对象。默认行为是创建浅拷贝,共享集合中项的引用。以下是创建深拷贝的方法:

  1. 导入 copy 模块:

    >>> import copy
    
  2. 使用 copy.deepcopy() 函数复制一个对象及其包含的所有可变项:

    >>> some_dict = {’a’: [1, 1, 2, 3]} 
    
    >>> another_dict = copy.deepcopy(some_dict)
    

这将创建没有共享引用的副本。对一个副本的可变内部项的更改不会对其他任何地方产生任何影响:

>>> some_dict[’a’].append(5) 

>>> some_dict 

{’a’: [1, 1, 2, 3, 5]} 

>>> another_dict 

{’a’: [1, 1, 2, 3]}

我们更新了 some_dict 中的某个项,但它对另一个 _dict 中的副本没有影响。我们可以使用 id()函数看到这些对象是不同的:

>>> id(some_dict[’a’]) == id(another_dict[’a’]) 

False

由于 id 值不同,这些是不同的对象。我们还可以使用 is 运算符来查看它们是不同的对象。

5.5.3 它是如何工作的...

创建浅复制相对简单。我们甚至可以用自己的版本编写算法,使用推导式(包含生成器表达式):

>>> copy_of_list = [item for item in some_list] 

>>> copy_of_dict = {key:value for key, value in some_dict.items()}

在列表的情况下,新列表的项是源列表中项的引用。同样,在字典的情况下,键和值是源字典中键和值的引用。

deepcopy()函数使用递归算法来查看每个可变集合中的每个项。

对于具有列表类型的对象,概念算法类似于以下内容:

from typing import Any 

def deepcopy_json(some_obj: Any) -> Any: 

    match some_obj: 

        case int() | float() | tuple() | str() | bytes() | None: 

            return some_obj 

        case list() as some_list: 

            list_copy: list[Any] = [] 

            for item in some_list: 

                list_copy.append(deepcopy_json(item)) 

            return list_copy 

        case dict() as some_dict: 

            dict_copy: dict[Any, Any] = {} 

            for key in some_dict: 

                dict_copy[key] = deepcopy_json(some_dict[key]) 

            return dict_copy 

        case _: 

            raise ValueError(f"can’t copy {type(some_obj)}") 

这可以用于 JSON 文档中使用的类型集合。对于第一种情况子句中的不可变类型,没有必要进行复制;这些类型之一的对象不能被修改。对于 JSON 文档中使用的两种可变类型,构建空结构,然后插入每个项的副本。处理涉及递归以确保——无论嵌套多深——所有可变项都被复制。

deepcopy()函数的实际实现处理了 JSON 规范之外的额外类型。这个例子旨在展示深度复制函数的一般思想。

5.5.4 参见

  • 在理解变量、引用和赋值的配方中,我们探讨了 Python 如何倾向于创建对象的引用。

5.6 避免为函数参数使用可变默认值

在第 3 章中,我们探讨了 Python 函数定义的许多方面。在设计具有可选参数的函数的配方中,我们展示了处理可选参数的配方。当时,我们没有深入探讨提供可变结构引用作为默认值的问题。我们将仔细研究函数参数可变默认值的后果。

5.6.1 准备工作

让我们想象一个函数,它要么创建,要么更新一个可变的 Counter 对象。我们将称之为 gather_stats()。

理想情况下,一个小型数据收集函数可能看起来像这样:

from collections import Counter 

from random import randint, seed 

def gather_stats_bad( 

    n: int, 

    samples: int = 1000, 

    summary: Counter[int] = Counter() 

) -> Counter[int]: 

    summary.update( 

      sum(randint(1, 6) 

      for d in range(n)) for _ in range(samples) 

    ) 

    return summary

这展示了函数的一个糟糕设计。它有两个场景:

  1. 第一个场景为摘要参数没有提供任何参数值。当省略此参数时,函数创建并返回一个统计集合。以下是这个故事的例子:

    >>> seed(1) 
    
    >>> s1 = gather_stats_bad(2) 
    
    >>> s1 
    
    Counter({7: 168, 6: 147, 8: 136, 9: 114, 5: 110, 10: 77, 11: 71, 4: 70, 3: 52, 12: 29, 2: 26})
    
  2. 第二个场景允许我们为摘要参数提供一个显式的参数值。当提供此参数时,此函数更新给定的对象。以下是这个故事的例子:

    >>> seed(1) 
    
    >>> mc = Counter() 
    
    >>> gather_stats_bad(2, summary=mc) 
    
    Counter... 
    
    >>> mc 
    
    Counter({7: 168, 6: 147, 8: 136, 9: 114, 5: 110, 10: 77, 11: 71, 4: 70, 3: 52, 12: 29, 2: 26})
    

    我们已经设置了随机数种子以确保两个随机值序列是相同的。我们提供了一个 Counter 对象以确认结果是一致的。

问题出现在我们执行上述第一个场景之后的以下操作:

>>> seed(1) 

>>> s3b = gather_stats_bad(2) 

>>> s3b 

Counter({7: 336, 6: 294, 8: 272, 9: 228, 5: 220, 10: 154, 11: 142, 4: 140, 3: 104, 12: 58, 2: 52})

这个例子中的值是不正确的。它们被加倍了。出了点问题。这只有在多次使用默认场景时才会发生。这段代码可以通过一个简单的单元测试套件并看起来是正确的。

正如我们在制作浅拷贝和深拷贝对象的配方中看到的,Python 更喜欢共享引用。共享的一个后果是,由 s1 变量引用的对象和由 s3b 变量引用的对象是同一个对象:

>>> s1 is s3b 

True

这意味着当为 s3b 变量的对象创建时,s1 变量引用的对象的值发生了变化。从这个例子中,应该很明显,该函数正在更新一个单一、共享的集合对象,并返回共享集合的引用。

这个 gather_stats_bad() 函数的摘要参数使用的默认值导致结果值由一个单一、共享的对象构建。我们如何避免这种情况?

5.6.2 如何操作...

解决可变默认参数问题的有两种方法:

  • 提供一个不可变的默认值。

  • 改变设计。

我们首先看看不可变默认值。改变设计通常是一个更好的主意。为了看到为什么改变设计更好,我们将展示一个纯粹的技术解决方案。

当我们为函数提供默认值时,默认对象只创建一次,并且之后永远共享。这里有一个替代方案:

  1. 将任何可变的默认参数值替换为 None:

    def gather_stats_good( 
    
        n: int, 
    
        samples: int = 1000, 
    
        summary: Counter[int] | None = None 
    
    ) -> Counter[int]:
    
     def gather_stats_good( 
    
        n: int, 
    
        summary: Counter[int] | None = None, 
    
        samples: int = 1000, 
    
    ) -> Counter[int]:
    
  2. 添加一个 if 语句来检查 None 参数值,并用一个全新的、正确的可变对象替换它:

     if summary is None: 
    
            summary = Counter()
    

    这将确保每次函数在没有参数值的情况下评估时,我们都会创建一个全新的、可变的对象。我们将避免反复共享单个可变对象。

5.6.3 它是如何工作的...

如我们之前所提到的,Python 更喜欢共享引用。它很少在没有显式使用 copy 模块或对象的 copy() 方法的情况下创建对象的副本。因此,函数参数值的默认值将是共享对象。Python 不会为默认参数值创建全新的对象。

永远不要为函数参数的默认值使用可变默认值。

而不是使用可变对象(例如,集合、列表或字典)作为默认值,使用 None。

在大多数情况下,我们应该考虑改变设计,根本不提供默认值。相反,定义两个单独的函数。一个函数更新参数值,另一个函数使用这个函数,但提供一个全新的、空的、可变的对象。

对于这个例子,它们可能被称为 create_stats() 和 update_stats(),具有明确的参数:

def update_stats( 

    n: int, 

    summary: Counter[int], 

    samples: int = 1000, 

) -> Counter[int]: 

    summary.update( 

        sum(randint(1, 6) 

        for d in range(n)) for _ in range(samples)) 

    return summary 

def create_stats(n: int, samples: int = 1000) -> Counter[int]: 

    return update_stats(n, Counter(), samples)

注意,update_stats()函数的 summary 参数不是可选的。同样,create_stats()函数也没有定义 summary 对象参数。

可选可变参数的想法并不好,因为作为参数默认值的可变对象被重复使用。

5.6.4 更多内容...

在标准库中,有一些示例展示了如何创建新的默认对象的一个酷技术。许多地方使用工厂函数作为参数。这个函数可以用来创建一个全新的可变对象。

为了利用这种设计模式,我们需要修改 update_stats()函数的设计。我们不再在函数中更新现有的 Counter 对象。我们将始终创建一个全新的对象。

这里有一个调用工厂函数来创建有用默认值的函数:

from collections import Counter 

from collections.abc import Callable, Iterable, Hashable 

from typing import TypeVar, TypeAlias 

T = TypeVar(’T’, bound=Hashable) 

Summarizer: TypeAlias = Callable[[Iterable[T]], Counter[T]] 

def gather_stats_flex( 

    n: int, 

    samples: int = 1000, 

    summary_func: Summarizer[int] = Counter 

) -> Counter[int]: 

    summary = summary_func( 

        sum(randint(1, 6) 

        for d in range(n)) for _ in range(samples)) 

    return summary

对于这个版本,我们定义了 Summarizer 类型为一个接受一个参数的函数,该函数将创建一个 Counter 对象。默认值使用 Counter 类作为单参数函数。我们可以用任何单参数函数来覆盖 summary_func 函数,该函数将收集细节而不是总结。

这里是一个使用 list 而不是 collections.Counter 的例子:

>>> seed(1) 

>>> gather_stats_flex(2, 12, summary_func=list) 

[7, 4, 5, 8, 10, 3, 5, 8, 6, 10, 9, 7]

在这个例子中,我们提供了 list 函数来创建一个包含单个随机样本的列表。

这里是一个不带参数值的例子。每次使用时,它都会创建一个新的 collections.Counter 对象:

>>> seed(1) 

>>> gather_stats_flex(2, 12) 

Counter({7: 2, 5: 2, 8: 2, 10: 2, 4: 1, 3: 1, 6: 1, 9: 1})

在这个例子中,我们使用默认的 summary_func 值来评估函数,它从随机样本中创建一个 collections.Counter 对象。

5.6.5 参见

  • 参见创建字典 – 插入和更新食谱,它展示了 defaultdict 集合的工作方式。

加入我们的社区 Discord 空间

加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

图片

第六章:6

用户输入和输出

软件的关键目的是产生有用的输出。在许多可能的输出中,一种简单的输出类型是显示有用结果的文本。Python 通过 print()函数支持这一点。

input()函数与 print()函数类似。input()函数从控制台读取文本,使我们能够向程序提供数据。使用 print()和 input()在应用程序的输入和输出之间创建了一种优雅的对称性。

向程序提供输入有许多其他常见方式。解析命令行对许多应用程序很有帮助。我们有时需要使用配置文件来提供有用的输入。数据文件和网络连接是提供输入的更多方式。这些方法各不相同,需要单独考虑。在本章中,我们将关注 input()和 print()的基础知识。

在本章中,我们将探讨以下配方:

  • 使用 print()函数的特性

  • 使用 input()和 getpass()获取用户输入

  • 使用 f”{value=}”字符串进行调试

  • 使用 argparse 获取命令行输入

  • 使用 invoke 获取命令行输入

  • 使用 cmd 创建命令行应用程序

  • 使用 OS 环境设置

似乎最好从 print()函数开始,并展示它可以做的一些事情。毕竟,应用程序的输出通常最有用。

6.1 使用 print()函数的特性

在许多情况下,print()函数是我们首先了解的函数。第一个脚本通常是以下内容的变体:

 >>> print("Hello, world.") 

Hello, world.

print()函数可以显示多个值,项目之间有有帮助的空格。

当我们这样写时:

 >>> count = 9973 

>>> print("Final count", count) 

Final count 9973

我们可以看到,为我们包含了空格分隔符。此外,在函数中提供的值之后,通常会打印一个换行符,通常表示为\n 字符。

我们能否控制这种格式?我们能否更改提供的额外字符?

6.1.1 准备工作

考虑这个用于记录大型帆船燃油消耗的电子表格。CSV 文件中的行看起来像这样:

 date,engine on,fuel height on,engine off,fuel height off 

10/25/13,08:24:00,29,13:15:00,27 

10/26/13,09:12:00,27,18:25:00,22 

10/28/13,13:21:00,22,06:25:00,14

关于此数据的更多信息,请参阅第四章中的缩小集合 – remove()、pop()和 difference 和切片和切块列表配方。由于油箱内部没有传感器,燃油的深度是通过油箱侧面的玻璃面板观察到的。知道油箱大约是矩形的,深度约为 31 英寸,容量约为 72 加仑,可以将深度转换为体积。

这里是一个使用此 CSV 数据的示例。此函数读取文件并返回由每一行构建的字段列表:

 from pathlib import Path 

import csv 

def get_fuel_use(source_path: Path) -> list[dict[str, str]]: 

    with source_path.open() as source_file: 

        rdr = csv.DictReader(source_file) 

        return list(rdr)

这里是一个从 CSV 文件中读取和打印行的示例:

 >>> source_path = Path("data/fuel2.csv") 

>>> fuel_use = get_fuel_use(source_path) 

>>> for row in fuel_use: 

...     print(row) 

{’date’: ’10/25/13’, ’engine on’: ’08:24:00’, ’fuel height on’: ’29’, ’engine off’: ’13:15:00’, ’fuel height off’: ’27’} 

{’date’: ’10/26/13’, ’engine on’: ’09:12:00’, ’fuel height on’: ’27’, ’engine off’: ’18:25:00’, ’fuel height off’: ’22’} 

{’date’: ’10/28/13’, ’engine on’: ’13:21:00’, ’fuel height on’: ’22’, ’engine off’: ’06:25:00’, ’fuel height off’: ’14’}

print()函数的输出,如这里所示的长行,使用起来具有挑战性。让我们看看如何使用 print()函数的附加功能来改进这个输出。

6.1.2 如何做...

我们有两种方式来控制 print()函数的输出格式:

  • 设置字段分隔符字符串,sep。默认值是一个空格字符。

  • 设置行结束字符串,end。默认值是\n 字符。

这个配方将展示几个变体:

  1. 读取数据:

     >>> fuel_use = get_fuel_use(Path("data/fuel2.csv"))
    
  2. 对于数据中的每一项,进行任何有用的数据转换:

     >>> for leg in fuel_use: 
    
    ...     start = float(leg["fuel height on"]) 
    
    ...     finish = float(leg["fuel height off"])
    
  3. 以下替代方案展示了不同的包含分隔符的方法:

    • 使用 sep 和 end 的默认值打印标签和字段:

       ...     print("On", leg["date"], "from", leg["engine on"], 
      
      ...         "to", leg["engine off"], 
      
      ...         "change", start-finish, "in.") 
      
      On 10/25/13 from 08:24:00 to 13:15:00 change 2.0 in. 
      
      On 10/26/13 from 09:12:00 to 18:25:00 change 5.0 in. 
      
      On 10/28/13 from 13:21:00 to 06:25:00 change 8.0 in. 
      

      当我们查看输出时,我们可以看到在每一项之间插入了一个空格。

    • 在准备数据时,我们可能希望使用类似于 CSV 的格式,可能使用非简单逗号的列分隔符。我们可以使用" | "作为 sep 参数的字符串值来打印标签和字段:

       ...     print(leg["date"], leg["engine on"], 
      
      ...         leg["engine off"], start-finish, sep=" | ") 
      
      10/25/13 | 08:24:00 | 13:15:00 | 2.0 
      
      10/26/13 | 09:12:00 | 18:25:00 | 5.0 
      
      10/28/13 | 13:21:00 | 06:25:00 | 8.0 
      

      在这种情况下,我们可以看到每一列都有给定的分隔符字符串。由于没有更改结束设置,每个 print()函数都产生了一条独特的输出行。

    • 这是我们如何更改默认标点符号以强调字段名称和值的示例。我们可以使用"="作为 sep 参数的字符串值和", "作为 end 参数的值来打印标签和字段:

       ...     print("date", leg["date"], sep="=", end=", ") 
      
      ...     print("on", leg["engine on"], sep="=", end=", ") 
      
      ...     print("off", leg["engine off"], sep="=", end=", ") 
      
      ...     print("change", start-finish, sep="=") 
      
      date=10/25/13, on=08:24:00, off=13:15:00, change=2.0 
      
      date=10/26/13, on=09:12:00, off=18:25:00, change=5.0 
      
      date=10/28/13, on=13:21:00, off=06:25:00, change=8.0 
      

      由于行尾使用的字符串已更改为", ",因此 print()函数的每次使用不再产生单独的行。为了看到正确的行尾,最后的 print()函数有一个默认的 end 值。我们也可以使用 end="\n"的参数值来明确地表示换行符的存在。

6.1.3 它是如何工作的...

print()函数的定义包括几个必须以关键字形式提供的参数。其中两个是 sep 和 end 关键字参数,分别具有空格和换行符的默认值。

使用 print()函数的 sep 和 end 参数对于比这些简单示例更复杂的情况可能会变得相当复杂。而不是处理一系列复杂的 print()函数请求,我们可以使用字符串的 format()方法,或者使用 f-string。

6.1.4 更多...

sys 模块定义了两个始终可用的标准输出文件:sys.stdout 和 sys.stderr。通常,print()函数可以被视为 sys.stdout.write()的一个便捷包装器。

我们可以使用 file=关键字参数将内容写入标准错误文件,而不是写入标准输出文件:

 >>> import sys 

>>> print("Red Alert!", file=sys.stderr) 

我们导入了 sys 模块,以便我们可以访问标准错误文件。我们使用它来写入一条不会成为标准输出流一部分的消息。

由于这两个文件始终可用,使用 OS 文件重定向技术通常效果很好。当我们的程序的主要输出写入 sys.stdout 时,它可以在 OS 级别进行重定向。用户可能会输入一个类似这样的 shell 命令行:

 % python myapp.py < input.dat > output.dat

这将为 sys.stdin 提供 input.dat 文件作为输入。当这个 Python 程序写入 sys.stdout 时,输出将由 OS 重定向到 output.dat 文件。

在某些情况下,我们需要打开额外的文件。在这种情况下,我们可能会看到这样的编程:

 >>> from pathlib import Path 

>>> target_path = Path("data")/"extra_detail.log" 

>>> with target_path.open(’w’) as target_file: 

...     print("Some detailed output", file=target_file) 

...     print("Ordinary log") 

Ordinary log

在本例中,我们已为输出打开了一个特定的路径,并使用 with 语句将打开的文件分配给 target_file。然后我们可以将此作为 print()函数中的 file=值来写入此文件。因为文件是一个上下文管理器,所以离开 with 语句意味着文件将被正确关闭;所有 OS 资源都将从应用程序中释放。所有文件操作都应该用 with 语句的上下文包装,以确保资源得到适当的释放。

6.1.5 相关阅读

  • 对于更多格式化选项,请参阅使用 f”{value=}"字符串进行调试食谱。

  • 关于本例中输入数据的更多信息,请参阅第四章中的收缩集合 – remove(), pop(), 和 difference 和切片和切块列表食谱。

  • 关于文件操作的一般信息,请参阅第八章。

6.2 使用 input()和 getpass()获取用户输入

一些 Python 脚本依赖于从用户那里收集输入。有几种方法可以做到这一点。一种流行的技术是使用控制台以交互方式提示用户输入。

有两种相对常见的情况:

  • 普通输入:这将提供输入字符的有帮助的回显。

  • 安全、无回显输入:这通常用于密码。输入的字符不会显示,提供了一定程度的隐私。我们使用 getpass 模块中的 getpass()函数来完成这项工作。

作为交互式输入的替代方案,我们将在本章后面的使用 argparse 获取命令行输入食谱中探讨一些其他方法。

input()和 getpass()函数只是从控制台读取的两种实现选择。结果是,获取字符字符串只是收集有用数据的第一步。输入还需要进行验证。

6.2.1 准备工作

我们将探讨一种从人那里读取复杂结构的技术。在这种情况下,我们将使用年、月和日作为单独的项目。然后这些项目被组合起来创建一个完整的日期。

这里有一个快速的用户输入示例,省略了所有验证考虑。这是糟糕的设计:

 from datetime import date 

def get_date1() -> date: 

    year = int(input("year: ")) 

    month = int(input("month [1-12]: ")) 

    day = int(input("day [1-31]: ")) 

    result = date(year, month, day) 

    return result

虽然使用 input()函数非常容易,但它缺少许多有用的功能。当用户输入无效日期时,这可能会引发一个可能令人困惑的异常。

我们经常需要将 input()函数与数据验证处理包装起来,使其更有用。日历很复杂,我们不愿意在未警告用户的情况下接受 2 月 31 日,这不是一个正确的日期。

6.2.2 如何实现...

  1. 如果输入是密码或类似需要编辑的内容,则 input()函数不是最佳选择。如果涉及密码或其他秘密,则使用 getpass.getpass()函数。这意味着当涉及秘密时,我们需要以下导入:

     from getpass import getpass
    

    否则,当不需要秘密输入时,我们将使用内置的 input()函数,不需要额外的导入。

  2. 确定将使用哪个提示。在我们的例子中,我们提供了一个字段名称和有关预期数据类型的提示作为 input()或 getpass()函数的提示字符串参数。这有助于将输入与文本到整数的转换分开。这个配方不遵循之前显示的片段;它将操作分解为两个独立的步骤。首先,获取文本值:

     year_text = input("year: ")
    
  3. 确定如何单独验证每个项目。最简单的情况是一个具有涵盖所有内容的单个规则的单个值。在更复杂的情况下——就像这个例子——每个单独的元素都是一个具有范围约束的数字。在后续步骤中,我们将查看验证组合项目:

     year = int(year_text)
    

    将输入和验证包装成如下所示的 while-try 块:

     year = None 
    
        while year is None: 
    
            year_text = input("year: ") 
    
            try: 
    
                year = int(year_text) 
    
            except ValueError as ex: 
    
                print(ex)
    

此处应用单个验证规则,即 int(year_txt)表达式,以确保输入是整数。while 语句导致输入和转换步骤的重复,直到 year 变量的值为 None。

对于错误输入抛出异常为我们提供了一些灵活性。我们可以通过扩展额外的异常类来满足输入必须满足的其他条件。

此处理过程仅涵盖年份字段。我们还需要获取月份和日期字段的值。这意味着我们需要为复杂日期对象的这三个字段分别编写三个几乎相同的循环。为了避免复制和粘贴几乎相同的代码,我们需要重构此处理过程。

我们将定义一个新的函数 get_integer(),用于通用数字值输入。以下是完整的函数定义:

 def get_integer(prompt: str) -> int: 

    while True: 

        value_text = input(prompt) 

        try: 

            value = int(value_text) 

            return value 

        except ValueError as ex: 

            print(ex) 

我们可以将这些组合成一个整体过程,以获取日期的三个整数。这将涉及类似 while-try 设计模式,但应用于组合对象。它看起来像这样:

 def get_date2() -> date: 

    while True: 

        year = get_integer("year: ") 

        month = get_integer("month [1-12]: ") 

        day = get_integer("day [1-31]: ") 

        try: 

            result = date(year, month, day) 

            return result 

        except ValueError as ex: 

            print(f"invalid, {ex}")

这使用围绕 get_integer()函数序列的单独 while-try 处理序列来获取构成日期的各个值。然后,它使用 date()构造函数从单个字段创建日期对象。如果由于组件无效,无法构建日期对象——作为一个整体——则必须重新输入年、月和日以创建一个有效的日期。

6.2.3 它是如何工作的...

我们需要将输入问题分解为几个相互独立但密切相关的子问题。为此,想象一个转换步骤的塔。在最底层是与用户的初始交互。我们确定了两种处理这种交互的常见方法:

  • input():此函数提示并从用户那里读取

  • getpass.getpass():此函数提示并读取输入(如密码)而不显示回显

这两个函数提供了基本的控制台交互。如果需要更复杂的交互,还有其他库可以提供。例如,Click 项目有一些有用的提示功能。参见click.palletsprojects.com/en/7.x/

Rich 项目具有极其复杂的终端交互。参见rich.readthedocs.io/en/latest/

在基础之上,我们构建了几个验证处理的层级。层级如下:

  • 数据类型验证:这使用内置的转换函数,如 int()或 float()。这些函数对无效文本引发 ValueError 异常。

  • 域验证:这使用 if 语句来确定值是否符合任何特定应用程序的约束。为了保持一致性,如果数据无效,也应引发 ValueError 异常。

  • 组合对象验证:这是特定于应用程序的检查。在我们的例子中,组合对象是 datetime.date 的一个实例。这也倾向于对无效的日期引发 ValueError 异常。

可能对值施加的约束类型有很多。我们使用了有效的日期约束,因为它特别复杂。

6.2.4 更多内容...

我们有几种涉及略微不同方法的用户输入替代方案。我们将详细探讨这两个主题:

  • 复杂文本:这将涉及简单使用 input()和更复杂的源文本解析。而不是提示单个字段,可能更好的是接受 yyyy-mm-dd 格式的字符串,并使用 strptime()解析器提取日期。这不会改变设计模式;它用稍微复杂一些的东西替换了 int()或 float()。

  • 通过 cmd 模块进行交互:这涉及一个更复杂的类来控制交互。我们将在使用 cmd 创建命令行应用程序的配方中详细探讨这一点。

可以从 JSON 模式定义中提取潜在输入验证规则列表。此类型列表包括布尔值、整数、浮点数和字符串。在 JSON 模式中定义的许多常见字符串格式包括日期时间、时间、日期、电子邮件、主机名、IPv4 和 IPv6 格式的 IP 地址以及 URI。

用户输入验证规则的另一个来源可以在 HTML5 标签的定义中找到。此列表包括颜色、日期、datetime-local、电子邮件、文件、月份、数字、密码、电话号码、时间、URL 和周年的格式。

6.2.5 参见

  • 在本章中查看使用 cmd 创建命令行应用程序的配方以了解复杂交互。

  • 查看使用使用 argparse 获取命令行输入的配方以从命令行收集用户输入。

  • 在 SunOS 操作系统的参考资料中,该系统现在由 Oracle 拥有,其中包含一组提示不同类型用户输入的命令:docs.oracle.com/cd/E19683-01/816-0210/6m6nb7m5d/index.html

6.3 使用 f”{value=}”字符串进行调试

Python 中可用的重要调试和设计工具之一是 print()函数。在使用 print()函数的功能食谱中显示的两个格式选项提供的灵活性不多。我们有更多的灵活性使用 f"string"格式。我们将基于第一章、数字、字符串和元组中显示的一些食谱。

6.3.1 准备工作

让我们看看一个涉及一些中等复杂计算的多步骤过程。我们将计算一些样本数据的平均值和标准差。给定这些值,我们将定位所有高于平均值一个标准差的项:

 >>> import statistics 

>>> size = [2353, 2889, 2195, 3094, 

...     725, 1099, 690, 1207, 926, 

...     758, 615, 521, 1320] 

>>> mean_size = statistics.mean(size) 

>>> std_size = statistics.stdev(size) 

>>> sig1 = round(mean_size + std_size, 1) 

>>> [x for x in size if x > sig1] 

[2353, 2889, 3094]

这个计算有几个工作变量。最终的列表推导式涉及其他三个变量,mean_size、std_size 和 sig1。使用这么多值来过滤大小列表,很难可视化正在发生的事情。了解计算的步骤通常很有帮助;显示中间变量的值可能非常有帮助。

6.3.2 如何做...

f"{name=}"字符串将同时包含字面字符串 name=和 name 表达式的值。这通常是一个变量,但可以使用任何表达式。使用这个与 print()函数结合的例子如下:

 >>> print( 

...     f"{mean_size=:.2f}, {std_size=:.2f}" 

... ) 

mean_size=1414.77, std_size=901.10

我们可以使用{name=}将任何变量放入 f-string 中并查看其值。上述代码中的这些例子包括格式说明符:.2f 作为后缀,以显示四舍五入到两位小数的值。另一个常见的后缀是!r,用于显示对象的内部表示;我们可能会使用 f"{name=!r}"。

6.3.3 它是如何工作的...

关于格式选项的更多背景信息,请参阅第一章中构建复杂的 f-string 字符串食谱。

这个功能有一个非常实用的扩展。我们可以在 f-string 中的=左侧使用任何表达式。这将显示表达式及其计算出的值,为我们提供更多的调试信息。

6.3.4 更多...

我们可以使用 f-string 的扩展表达式功能包括额外的计算,这些计算不仅仅是局部变量的值:

 >>> print( 

...     f"{mean_size=:.2f}, {std_size=:.2f}," 

...     f" {mean_size + 2 * std_size=:.2f}" 

... ) 

mean_size=1414.77, std_size=901.10, mean_size + 2 * std_size=3216.97

我们已经计算了一个新值,mean_size+2*std_size,它只出现在格式化输出中。这使得我们可以在不创建额外变量的情况下显示中间计算结果。

6.3.5 参见

  • 参考第一章中的“使用 f-strings 构建复杂的字符串”食谱,了解更多可以使用 f-strings 和字符串的 format()方法完成的事情。

  • 参考本章前面的“使用 print()函数的特性”食谱,了解其他格式化选项。

6.4 使用 argparse 获取命令行输入

对于某些应用,在没有太多人工交互的情况下从操作系统命令行获取用户输入可能更好。我们更愿意解析命令行参数值,然后执行处理或报告错误。

例如,在操作系统级别,我们可能想运行这样的程序:

 % python ch06/distance_app.py -u KM 36.12,-86.67 33.94,-118.40 

From 36.12,-86.67 to 33.94,-118.4 in KM = 2886.90

在%的操作系统提示符下,我们输入了一个命令,python ch06/distance_app.py。此命令有一个可选参数,-u KM,以及两个位置参数 36.12,-86.67 和 33.94,-118.40。

如果用户输入错误,交互可能看起来像这样:

 % python ch06/distance_app.py -u KM 36.12,-86.67 33.94,-118asd 

usage: distance_app.py [-h] [-u {NM,MI,KM}] p1 p2 

distance_app.py: error: argument p2: could not convert string to float: ’-118asd’

-118asd 的无效参数值会导致错误消息。用户可以按上箭头键获取之前的命令行,进行更改,然后再次运行程序。交互式用户体验委托给操作系统命令行处理。

6.4.1 准备工作

我们需要做的第一件事是重构我们的代码,创建三个单独的函数:

  • 一个从命令行获取参数的函数。

  • 一个执行实际工作的函数。目的是定义一个可以在各种环境中重用的函数,其中之一是使用命令行参数。

  • 一个主函数,它收集参数并使用适当的参数值调用实际工作函数。

下面是我们的实际工作函数,display():

 from ch06.distance_computation import haversine, MI, NM, KM 

def display( 

        lat1: float, lon1: float, lat2: float, lon2: float, r: str 

) -> None: 

    r_float = {"NM": NM, "KM": KM, "MI": MI}[r] 

    d = haversine(lat1, lon1, lat2, lon2, R=r_float) 

    print(f"From {lat1},{lon1} to {lat2},{lon2} in {r} = {d:.2f}")

我们已从 ch06.distance_computation 模块导入了核心计算函数 haversine()。这是基于第三章中“基于部分函数选择参数顺序”食谱中显示的计算:

下面是函数在 Python 内部使用时的样子:

 >>> display(36.12, -86.67, 33.94, -118.4, ’NM’) 

From 36.12,-86.67 to 33.94,-118.4 in NM = 1558.53

此函数有两个重要的设计特点。第一个特点是它避免了引用由参数解析创建的 argparse.Namespace 对象的功能。我们的目标是拥有一个可以在多个不同环境中重用的函数。我们需要将用户界面的输入和输出元素分开。

第二个设计特点是该功能显示另一个函数计算出的值。这是将一个较大的问题分解为两个较小问题的分解。我们将打印输出的用户体验与基本计算分离。(这两个方面都相当小,但分离这两个方面的原则很重要。)

6.4.2 如何实现...

  1. 定义整体参数解析函数:

     def get_options(argv: list[str]) -> argparse.Namespace:
    
  2. 创建解析器对象:

     parser = argparse.ArgumentParser()
    
  3. 将各种类型的参数添加到解析器对象中。有时,这很困难,因为我们仍在改进用户体验。很难想象人们会如何使用程序以及他们可能提出的所有问题。在我们的例子中,我们有两个必需的位置参数和一个可选参数:

    • 第一点:纬度和经度

    • 第二点:纬度和经度

    • 可选的距离单位;我们将提供海里作为默认值:

     parser.add_argument("-u", "--units", 
    
            action="store", choices=("NM", "MI", "KM"), default="NM") 
    
        parser.add_argument("p1", action="store", type=point_type) 
    
        parser.add_argument("p2", action="store", type=point_type)
    

    我们添加了可选和必需参数的混合。-u 参数以短横线开头,表示它是可选的。支持较长的双短横线版本 --units 作为替代。

    必需的位置参数不带前缀命名。

  4. 评估步骤 2 中创建的解析器对象的 parse_args() 方法:

     options = parser.parse_args(argv) 
    

默认情况下,解析器使用 sys.argv 的值,即用户输入的命令行参数值。当我们能够提供明确的参数值时,测试会更加容易。

下面是最终的函数:

 def get_options(argv: list[str]) -> argparse.Namespace: 

    parser = argparse.ArgumentParser() 

    parser.add_argument("-u", "--units", 

        action="store", choices=("NM", "MI", "KM"), default="NM") 

    parser.add_argument("p1", action="store", type=point_type) 

    parser.add_argument("p2", action="store", type=point_type) 

    options = parser.parse_args(argv) 

    return options

这依赖于一个 point_type() 函数,该函数既验证字符串,又把字符串转换成(纬度,经度)两个元素的元组。下面是这个函数的定义:

 def point_type(text: str) -> tuple[float, float]: 

    try: 

        lat_str, lon_str = text.split(",") 

        lat = float(lat_str) 

        lon = float(lon_str) 

        return lat, lon 

    except ValueError as ex: 

        raise argparse.ArgumentTypeError(ex)

如果出现任何问题,将引发异常。从这个异常中,我们将引发 ArgumentTypeError 异常。这个异常被 argparse 模块捕获,并导致它向用户报告错误。

这里是结合选项解析器和输出显示功能的主体脚本:

 def main(argv: list[str] = sys.argv[1:]) -> None: 

    options = get_options(argv) 

    lat_1, lon_1 = options.p1 

    lat_2, lon_2 = options.p2 

    display(lat_1, lon_1, lat_2, lon_2, r=options.r) 

if __name__ == "__main__": 

    main()

此主体脚本将用户输入连接到显示的输出。错误消息的详细信息和处理帮助被委托给 argparse 模块。

6.4.3 它是如何工作的...

参数解析器分为三个阶段:

  1. 通过创建一个 ArgumentParser 类的实例来创建一个解析器对象,从而定义整体上下文。

  2. 使用 add_argument() 方法添加单个参数。这些参数可以包括可选参数以及必需参数。

  3. 解析实际的命令行输入,通常基于 sys.argv。

一些简单的程序可能只有几个可选参数。一个更复杂的程序可能有更多可选参数。

通常,文件名作为位置参数。当程序读取一个或多个文件时,文件名可以按如下方式在命令行中提供:

 % python some_program.py *.rst

我们使用了 Linux shell 的通配符功能:*.rst 字符串被扩展成匹配命名规则的文件列表。这是 Linux shell 的一个特性,发生在 Python 解释器开始之前。这个文件列表可以使用以下定义的参数进行处理:

 parser.add_argument(’file’, type=Path, nargs=’*’)

命令行上所有不以 - 字符开头的参数都是位置参数,并且它们被收集到由解析器构建的对象的 file 值中。

然后,我们可以使用以下方式处理每个给定的文件:

 for filename in options.file: 

        process(filename)

对于 Windows 程序,shell 不会从通配符模式中获取文件名。这意味着应用程序必须处理包含通配符字符(如 "*" 和 "?”)的文件名。Python 的 glob 模块可以帮助处理这个问题。此外,pathlib 模块可以创建 Path 对象,这些对象包括用于在目录中定位匹配文件名的通配符功能。

6.4.4 更多内容...

我们可以处理哪些类型的参数?在常见使用中有很多参数风格。所有这些变体都是使用解析器的 add_argument() 方法定义的:

  • 简单选项:形式为 -o 或 --option 的参数通常定义可选功能。这些使用的是 ‘store_true’ 或 ‘store_false’ 动作。

  • 带值的选项:我们展示了 -r unit 作为带值的选项。‘store’ 动作是保存值的方式。

  • 增加计数的选项:动作 ‘count’ 和默认值 =0 允许重复的选项。例如,详细和非常详细的日志选项 -v 和 -vv。

  • 累积列表的选项:动作 ‘append’ 和默认值 [] 可以累积多个选项值。

  • 显示版本号:可以使用特殊动作 ‘version’ 创建一个将显示版本号并退出的参数。

  • 位置参数在其名称中不带有前导 ‘-’。它们必须按照将使用的顺序定义。

argparse 模块使用 -h 和 --help 将显示帮助信息并退出。除非使用具有 ‘help’ 动作的参数更改,否则这些选项都是可用的。

这涵盖了命令行参数处理的常见情况。通常,当我们编写自己的应用程序时,我们会尝试利用这些常见的参数风格。如果我们努力遵循广泛使用的参数风格,我们的用户更有可能理解我们的应用程序是如何工作的。

6.4.5 参见

  • 我们在 使用 input() 和 getpass() 获取用户输入 的菜谱中探讨了如何获取交互式用户输入。

  • 我们将在 使用 OS 环境设置 的菜谱中查看如何添加更多灵活性。

6.5 使用 invoke 获取命令行输入

invoke 包不是标准库的一部分。它需要单独安装。通常,这是通过以下终端命令完成的:

(cookbook3) % python -m pip install invoke

使用 python -m pip 命令确保我们将使用与当前活动虚拟环境一起的 pip 命令,显示为 cookbook3。

请参阅本章中的 使用 argparse 获取命令行输入 菜谱。它描述了一个类似以下的工作命令行应用程序:

 % RECIPE=7  # invoke

命令始终是 invoke。Python 路径信息用于定位名为 tasks.py 的模块文件,以提供可以调用的命令的定义。剩余的命令行值提供给 tasks 模块中定义的函数。

6.5.1 准备工作

使用 invoke 时,我们通常会创建一个双层设计。这两层是:

  • 一个从命令行获取参数、执行所需的验证或转换,并调用函数执行真实工作的函数。这个函数将被装饰为@task。

  • 执行真实工作的函数。如果这个函数被设计成不直接引用命令行选项,那么这会有所帮助。目的是定义一个可以在各种环境中重用的函数,其中之一就是使用来自命令行的参数。

在某些情况下,这两个函数可以合并成一个。这种情况通常发生在 Python 被用作包装器,为底层提供简单接口,而底层应用却异常复杂时。在这种应用中,Python 包装器可能只做很少的处理,参数值验证与应用的“真实工作”之间没有有用的区别。

在本章的使用 argparse 获取命令行输入配方中,定义了 display()函数。这个函数执行应用的“真实工作”。当与 invoke 一起工作时,这种设计将继续使用。

6.5.2 如何做...

  1. 定义一个描述可以调用的任务的函数。通常,提供一些关于各种参数的帮助信息是至关重要的,这可以通过向@task 装饰器提供参数名称和帮助文本的字典来完成:

     import sys 
    
    from invoke.tasks import task 
    
    from invoke.context import Context
    
     @task( 
    
        help={ 
    
            ’p1’: ’Lat,Lon’, 
    
            ’p2’: ’Lat,Lon’, 
    
            ’u’: ’Unit: KM, MI, NM’}) 
    
    def distance( 
    
            context: Context, p1: str, p2: str, u: str = "KM" 
    
    ) -> None: 
    
        """Compute distance between two points. 
    
        """
    

    函数的文档字符串成为 invoke distance --help 命令提供的帮助文本。提供一些有助于用户理解各种命令将做什么以及如何使用它们的内容非常重要。

    Context 参数是必需的,但在这个例子中不会使用。该对象在调用多个单独的任务时提供一致的环境。它还提供了运行外部应用程序的方法。

  2. 对各种参数值进行所需的转换。使用清洗后的值评估“真实工作”函数:

     try: 
    
            lat_1, lon_1 = point_type(p1) 
    
            lat_2, lon_2 = point_type(p2) 
    
            display(lat_1, lon_1, lat_2, lon_2, r=u) 
    
        except (ValueError, KeyError) as ex: 
    
            sys.exit(f"{ex}\nFor help use invoke --help distance") 
    

    我们已经使用 sys.exit()来生成错误信息。也可以抛出异常,但这会显示长的跟踪信息,可能并不有用。

6.5.3 它是如何工作的...

invoke 包检查给定 Python 函数的参数,并构建必要的命令行解析选项。参数名称成为选项的名称。在示例 distance()函数中,p1、p2 和 u 的参数分别成为命令行选项--p1、--p2 和-u。这使得我们可以在运行应用时灵活地提供参数。值可以是按位置提供,也可以通过使用选项标志提供。

6.5.4 更多...

invoke 最重要的特性是它能够作为其他二进制应用程序的包装器。提供给每个任务的 Context 对象提供了更改当前工作目录和运行任意 OS 命令的方法。这包括更新子进程环境、捕获输出和错误流、提供输入流以及许多其他功能。

我们可以使用 invoke 在单个包装器下组合多个应用程序。这可以通过提供一个统一的接口来简化复杂的应用程序集合,该接口通过单个任务定义模块实现。

例如,我们可以组合一个计算两点之间距离的应用程序,以及一个处理连接一系列点的完整路线的 CSV 文件的应用程序。

整体设计可能看起来像这样:

@task 

def distance(context: Context, p1: str, p2: str, u: str) -> None: 

    ...  # Shown earlier 

@task 

def route(context: Context, filename: str) -> None: 

    if not path(filename).exists(): 

        sys.exit(f"File not found {filename}") 

    context.run("python some_app.py {filename}", env={"APP_UNITS": "NM"})

context.run()方法将调用任意的 OS 级命令。env 参数值提供了更新环境变量的命令。

6.5.5 参见

  • 应用程序集成的附加配方在第十四章应用程序集成:组合中展示。

  • www.pyinvoke.org网页包含了关于 invoke 的所有文档。

6.6 使用 cmd 创建命令行应用程序

有几种方法可以创建交互式应用程序。使用 input()和 getpass()获取用户输入配方探讨了 input()和 getpass.getpass()等函数。使用 argparse 获取命令行输入配方展示了如何使用 argparse 模块创建用户可以从 OS 命令行与之交互的应用程序。

我们还有另一种创建交互式应用程序的方法:使用 cmd 模块。此模块将提示用户输入,然后调用我们提供的类的一个特定方法。

这里是一个交互示例:

 ] dice 5 

Rolling 5 dice 

] roll 

[5, 6, 6, 1, 5] 

]

我们输入了 dice 5 命令来设置骰子的数量。之后,roll 命令显示了掷出五个骰子的结果。help 命令将显示可用的命令。

6.6.1 准备工作

cmd.Cmd 应用程序的核心特性是一个读取-评估-打印循环(REPL)。当存在多个单独的状态变化和许多密切相关用于执行这些状态变化的命令时,这种应用程序运行良好。

我们将使用一个简单的、有状态的骰子游戏。想法是有一把骰子,其中一些可以掷出,而另一些是冻结的。这意味着我们的 Cmd 类定义必须有一些属性来描述一把骰子的当前状态。

命令将包括以下内容:

  • dice 设置骰子的数量

  • roll 掷骰子

  • reroll 重新掷选定的骰子,其他骰子保持不变

6.6.2 如何实现...

  1. 导入 cmd 模块以使 cmd.Cmd 类定义可用。由于这是一个游戏,还需要随机模块:

     import cmd 
    
    import random
    
  2. 定义一个扩展到 cmd.Cmd:

     class DiceCLI(cmd.Cmd):
    
  3. 在 preloop() 方法中定义任何所需的初始化:

     def preloop(self) -> None: 
    
            self.n_dice = 6 
    
            self.dice: list[int] | None = None  # no roll has been made. 
    
            self.reroll_count = 0
    

    此方法在处理开始时评估一次。

    初始化也可以在 init() 方法中完成。然而,这样做稍微复杂一些,因为它必须与 Cmd 类的初始化协作。

  4. 对于每个命令,创建一个 do_command() 方法。方法名将是命令,前面加上 do_ 字符。任何命令之后的用户输入文本将作为方法的参数值提供。方法定义中的文档字符串注释是命令的帮助文本。以下是由 do_roll() 方法定义的 roll 命令:

     def do_roll(self, arg: str) -> bool: 
    
            """Roll the dice. Use the dice command to set the number of dice.""" 
    
            self.dice = [random.randint(1, 6) for _ in range(self.n_dice)] 
    
            print(f"{self.dice}") 
    
            return False
    
  5. 解析和验证使用它们的命令的参数。用户在命令之后的输入将作为方法第一个位置参数的值提供。以下是由 do_dice() 方法定义的 dice 命令:

     def do_dice(self, arg: str) -> bool: 
    
            """Sets the number of dice to roll.""" 
    
            try: 
    
                self.n_dice = int(arg) 
    
            except ValueError: 
    
                print(f"{arg!r} is invalid") 
    
                return False 
    
            self.dice = None 
    
            print(f"Rolling {self.n_dice} dice") 
    
            return False
    
  6. 编写主脚本。这将创建此类的实例并执行 cmdloop() 方法:

     if __name__ == "__main__": 
    
        game = DiceCLI() 
    
        game.cmdloop()
    

    cmdloop() 方法处理提示、收集输入和根据用户的输入执行适当方法的细节。

6.6.3 它是如何工作的...

Cmd 类包含大量内置功能来显示提示、从用户那里读取输入,然后根据用户的输入定位适当的方法。

例如,当我们输入 dice 5 这样的命令时,Cmd 超类的内置方法将从输入中删除第一个单词 dice,并将其前缀为 do_。然后它将尝试使用行剩余部分的参数值,即 5,来评估该方法。

如果我们输入了一个没有匹配 do_*() 方法的命令,命令处理器将写入一个错误信息。这是自动完成的;我们不需要编写任何代码来处理无效的命令输入。

一些方法,如 do_help(),已经是应用程序的一部分。这些方法将总结其他 do_* 方法。当我们的方法有一个文档字符串时,这将通过内置的帮助功能显示。

Cmd 类依赖于 Python 的内省功能。类的实例可以检查方法名以定位所有以 do_ 开头的方法。内省是一个高级主题,将在第八章中简要介绍。

6.6.4 更多...

Cmd 类有多个可以添加交互功能的地方:

  • 我们可以定义特定的 help_*() 方法,使其成为帮助主题的一部分。

  • 当任何 do_*() 方法返回非 False 值时,循环将结束。我们可能想要添加一个 do_quit() 方法来返回 True。

  • 如果输入流被关闭,将提供一个 EOF 命令。在 Linux 中,使用 ctrl-d 将关闭输入文件。这导致 do_EOF() 方法,它应该使用 return True。

  • 我们可能提供一个名为 emptyline() 的方法来响应空白行。

  • 当用户的输入与任何 do_*() 方法都不匹配时,将评估 default() 方法。

  • postloop() 方法可以在循环结束后进行一些处理。这是一个写总结的好地方。

此外,我们还可以设置一些属性。这些是与方法定义平级的类级变量:

  • 提示属性是要写入的提示字符串。介绍属性是在第一个提示之前要写入的介绍性文本。对于我们的示例,我们可以这样做:

     class DiceCLI2(cmd.Cmd): 
    
        prompt = "] " 
    
        intro = "A dice rolling tool. ? for help."
    
  • 我们可以通过设置 doc_header、undoc_header、misc_header 和 ruler 属性来定制帮助输出。

目标是能够创建一个尽可能直接处理用户交互的整洁类。

6.6.5 相关内容

  • 我们将在第七章和第八章中查看类定义。

6.7 使用操作系统环境设置

有几种方式来看待我们软件用户提供的输入:

  • 交互式输入:这是根据应用程序的要求由用户提供的。请参阅 使用 input() 和 getpass() 获取用户输入 的配方。

  • 命令行参数:这些是在程序启动时提供的。请参阅 使用 argparse 获取命令行输入 和 使用 invoke 获取命令行输入 的配方。

  • 环境变量:这些是操作系统级别的设置。有几种方式可以设置它们:

    • 在命令行中,当运行应用程序时。

    • 在用户选择的 shell 的配置文件中设置。例如,如果使用 zsh,这些文件是 ~/.zshrc 文件和 ~/.profile 文件。也可以有系统范围的文件,如 /etc/zshrc 文件。

    • 在 Windows 中,有环境变量的高级设置选项。

  • 配置文件:这些是特定于应用程序的。它们是第十三章的主题。

环境变量可以通过 os 模块获得。

6.7.1 准备工作

在 使用 argparse 获取命令行输入 的配方中,我们将 haversine() 函数包装在一个简单的应用程序中,该应用程序解析命令行参数。我们创建了一个这样工作的程序:

 % python ch06/distance_app.py -u KM 36.12,-86.67 33.94,-118.40 

From 36.12,-86.67 to 33.94,-118.4 in KM = 2886.90 

"""

在使用这个版本的应用程序一段时间后,我们可能会发现我们经常使用海里来计算从我们船锚定的地方的距离。我们真的希望有一个输入点的默认值以及 -r 参数的默认值。

由于一艘船可以在多个地方锚定,我们需要在不修改实际代码的情况下更改默认设置。一个“缓慢变化”的参数值的概念与操作系统环境变量很好地吻合。它们可以持久存在,但相对容易更改。

我们将使用两个操作系统环境变量:

  • UNITS 将具有默认的距离单位。

  • HOME_PORT 可以有一个锚点。

我们希望能够做到以下几点:

 % UNITS=NM 

% HOME_PORT=36.842952,-76.300171 

% python ch06/distance_app.py 36.12,-86.67 

From 36.12,-86.67 to 36.842952,-76.300171 in NM = 502.23

6.7.2 如何实现...

  1. 导入 os 模块。要解析的默认命令行参数集来自 sys.argv,因此还需要导入 sys 模块。应用程序还将依赖于 argparse 模块:

     import os 
    
    import sys 
    
    import argparse
    
  2. 导入应用程序需要的任何其他类或对象:

     from ch03.recipe_11 import haversine, MI, NM, KM 
    
    from ch06.recipe_04 import point_type, display
    
  3. 定义一个函数,该函数将使用环境值作为可选命令行参数的默认值:

     def get_options(argv: list[str] = sys.argv[1:]) -> argparse.Namespace:
    
  4. 从操作系统环境设置中收集默认值。这包括所需的任何验证:

     default_units = os.environ.get("UNITS", "KM") 
    
        if default_units not in ("KM", "NM", "MI"): 
    
            sys.exit(f"Invalid UNITS, {default_units!r} not KM, NM, or MI") 
    
        default_home_port = os.environ.get("HOME_PORT")
    

    注意,使用 os.environ.get()允许应用程序在环境变量未设置的情况下包含一个默认值。

  5. 创建解析器对象。为从环境变量中提取的相关参数提供默认值:

     parser = argparse.ArgumentParser() 
    
        parser.add_argument("-u", "--units", 
    
            action="store", choices=("NM", "MI", "KM"), 
    
            default=default_units 
    
        ) 
    
        parser.add_argument("p1", action="store", type=point_type) 
    
        parser.add_argument( 
    
            "p2", nargs="?", action="store", type=point_type, 
    
            default=default_home_port 
    
        )
    
  6. 进行任何额外的验证以确保参数设置正确。在这个例子中,可能没有为 HOME_PORT 设置值,也没有为第二个命令行参数提供值。

    这需要使用 if 语句和调用 sys.exit():

     options = parser.parse_args(argv) 
    
        if options.p2 is None: 
    
            sys.exit("Neither HOME_PORT nor p2 argument provided.")
    
  7. 返回包含有效参数集的最终选项对象:

     return options
    

这将使-u 参数和第二个点成为可选的。如果这些参数从命令行中省略,参数解析器将使用配置信息提供默认值。

sys.exit()提供的错误代码有细微的区别。当应用程序因命令行问题失败时,通常返回状态码 2,但 sys.exit()会将值设置为 1。一个稍微更好的方法是使用 parser.error()方法。这样做需要重构,在获取和验证环境变量值之前创建 ArgumentParser 实例。

6.7.3 它是如何工作的...

我们已经使用操作系统环境变量来创建默认值,这些值可以被命令行参数覆盖。如果环境变量已设置,则该字符串将作为默认值提供给参数定义。如果没有设置环境变量,则应用程序将使用默认值。在 UNITS 变量的情况下,在这个例子中,如果操作系统环境变量未设置,应用程序将使用公里作为默认值。

我们已经使用操作系统环境来设置默认值,这些值可以被命令行参数值覆盖。这支持环境提供可能由多个命令共享的一般上下文的概念。

6.7.4 更多...

使用 argparse 获取命令行输入的配方展示了处理从 sys.argv 中可用的默认命令行参数的略微不同的方法。第一个参数是正在执行的 Python 应用程序的名称,通常与参数解析不相关。

sys.argv 的值将是一个字符串列表:

 [’ch06/distance_app.py’, ’-u’, ’NM’, ’36.12,-86.67’]

在处理过程中,我们不得不在某些时候跳过 sys.argv[0] 的初始值。通常,应用程序需要将 sys.argv[1:] 提供给解析器。这可以在 get_options() 函数内部完成。这也可以在 main() 函数评估 get_options() 函数时完成。正如本例所示,这也可以在为 get_options() 函数创建默认参数值时完成。

argparse 模块允许我们为参数定义提供类型信息。提供类型信息可以用于验证参数值。在许多情况下,一个值可能有一组有限的选项,这组允许的选项可以作为参数定义的一部分提供。这样做可以创建更好的错误和帮助信息,提高应用程序运行时的用户体验。

6.7.5 参见

  • 我们将在第十三章节中探讨处理配置文件的多种方法。

加入我们的社区 Discord 空间

加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

PIC

第七章:7

类和对象的基础

计算的目的在于处理数据。我们通常将处理和数据封装到单个定义中。我们可以将具有共同属性集的对象组织成类,以定义它们的内部状态和共同行为。类的每个实例都是一个具有独特内部状态和行为的独立对象。

这种状态和行为的概念特别适用于游戏的工作方式。当构建类似交互式游戏的东西时,用户的操作会更新游戏状态。玩家的每个可能动作都是一个改变游戏状态的方法。在许多游戏中,这会导致大量的动画来展示状态之间的转换。在单人街机风格的游戏中,敌人或对手通常会是独立的对象,每个对象都有一个基于其他敌人动作和玩家动作而变化的内部状态。

另一方面,如果我们考虑一副牌或掷骰子游戏,可能的状态可能非常少。像 Zonk 这样的游戏涉及玩家掷(并重新掷)骰子,只要他们的分数提高。如果随后的掷骰子未能改善他们的骰子组合,他们的回合就结束了。手牌的状态是由构成得分子集的骰子池,通常推到桌子的一个边上。在一个六骰子的游戏中,将有从一到六个得分骰子作为不同的状态。此外,当所有骰子都是得分骰子时,玩家可以通过重新掷所有骰子来再次开始掷骰子的过程。这导致了一个额外的“超常”状态,玩家也必须记住。

面向对象设计的目的是使用对象的属性来定义当前状态。每个对象都被定义为类似对象的类的一个实例。我们用 Python 编写类定义,并使用这些定义来创建对象。类中定义的方法会在对象上引起状态变化。

在本章中,我们将探讨以下食谱:

  • 使用类来封装数据和处理

  • 类定义的基本类型提示

  • 设计具有大量处理的类

  • 使用 typing.NamedTuple 来表示不可变对象

  • 使用数据类来表示可变对象

  • 使用冻结的数据类来表示不可变对象

  • 使用 slots 优化小对象

  • 使用更复杂的集合

  • 扩展内置集合 – 一个可以进行统计的列表

  • 使用属性来表示延迟属性

  • 创建上下文和上下文管理器

  • 使用多个资源管理多个上下文

面向对象设计的主旨相当广泛。在本章中,我们将介绍一些基本概念。我们将从一些基础概念开始,例如类定义如何封装类的所有实例的状态和处理细节。

7.1 使用类封装数据和处理

类设计受到 SOLID 设计原则的影响。单一责任和接口隔离原则提供了有用的建议。综合考虑,这些原则建议我们,一个类应该有方法,这些方法专注于单一、明确的责任。

考虑类的一种另一种方式是作为一个紧密相关的函数组,这些函数使用共同的数据。我们称这些为处理数据的函数。类定义应该包含处理对象数据的最小方法集合。

我们希望基于狭窄的责任分配创建类定义。我们如何有效地定义责任?设计一个类的好方法是什么?

7.1.1 准备工作

让我们看看一个简单的、有状态的对象——一对骰子。这个背景是一个模拟简单游戏如 Craps 的应用程序。

软件对象可以看作是类似事物——名词。类的行为可以看作是动词。这种与名词和动词的认同给我们提供了如何有效地设计类以有效工作的线索。

这引导我们进入几个准备步骤。我们将通过使用一对骰子进行游戏模拟来提供这些步骤的具体示例。我们按以下步骤进行:

  1. 写下描述类实例所做事情的简单句子。我们可以称之为问题陈述。专注于单动词句子,只关注名词和动词是至关重要的。以下是一些例子:

    • Craps 游戏有两个标准的骰子。

    • 每个骰子有六个面,点数从一到六。

    • 玩家掷骰子。虽然作者和编辑更喜欢主动语态版本,“玩家掷骰子”,但骰子通常被其他对象所作用,使得被动语态句子稍微更有用。

    • 骰子的总和改变了 Craps 游戏的状态。这些规则与骰子是分开的。

    • 如果两个骰子匹配,这个数字被描述为“硬掷”。如果两个骰子不匹配,掷骰子被描述为“易掷”。

  2. 识别句子中的所有名词。在这个例子中,名词包括骰子、面、点数和玩家。名词识别不同类别的对象,可能是合作者,如玩家和游戏。名词也可能识别对象的属性,如面和点数。

  3. 识别句子中的所有动词。动词通常成为所讨论的类的成员方法。在这个例子中,动词包括 roll 和 match。

这些信息有助于定义对象的状态和行为。拥有这些背景信息将帮助我们编写类定义。

7.1.2 如何做...

由于我们编写的模拟涉及骰子的随机投掷,我们将依赖于 from random import randint 提供有用的 randint() 函数。定义类的步骤如下:

  1. 使用类声明开始编写类:

     class Dice:
    
  2. init() 方法的主体中初始化对象的属性。我们将使用 faces 属性来模拟骰子的内部状态。需要一个 self 变量来确保我们引用的是类的给定实例的属性。我们将在每个属性上提供类型提示,以确保在整个类定义中正确使用:

     def __init__(self) -> None: 
    
            self.faces: tuple[int, int] = (0, 0)
    
  3. 根据描述中的动词定义对象的方法。当玩家掷骰子时,roll() 方法可以设置两个骰子面上的值。我们通过设置 self 对象的 faces 属性来实现这一点:

     def roll(self) -> None: 
    
            self.faces = (randint(1,6), randint(1,6))
    

    此方法会改变对象的内部状态。我们选择不返回任何值。

  4. 玩家掷骰子后,total() 方法有助于计算骰子的总和:

     def total(self) -> int: 
    
            return sum(self.faces)
    
  5. 可以提供额外的方法来回答有关骰子状态的问题。在这种情况下,当两个骰子都匹配时,总和是通过“困难的方式”得到的:

     def hardway(self) -> bool: 
    
            return self.faces[0] == self.faces[1] 
    
        def easyway(self) -> bool: 
    
            return self.faces[0] != self.faces[1] 
    

7.1.3 它是如何工作的...

核心思想是使用普通的语法规则——名词、动词和形容词——作为识别类基本特征的一种方式。在我们的例子中,骰子是真实的事物。我们尽量避免使用抽象术语,如随机化器或事件生成器。描述真实事物的可触摸特征更容易,然后定义一个实现来匹配这些可触摸特征。

掷骰子的想法是一个我们可以通过方法定义来模拟的物理动作。这个掷骰子的动作会改变对象的状态。在极少数情况下——36 次中的 1 次——下一个状态会恰好与之前的状态相同。

下面是使用 Dice 类的一个示例:

  1. 首先,我们将使用一个固定值来初始化随机数生成器,以便我们可以得到一个固定的结果序列:

     >>> import random 
    
    >>> random.seed(1)
    
  2. 我们将创建一个 Dice 对象,并将其分配给变量 d1。然后我们可以使用 roll() 方法设置其状态。然后我们将查看 total() 方法以查看掷出了什么。我们将通过查看 faces 属性来检查状态:

     >>> d1 = Dice() 
    
    >>> d1.roll() 
    
    >>> d1.total() 
    
    7 
    
    >>> d1.faces 
    
    (2, 5)
    

7.1.4 更多...

捕获导致状态变化的必要内部状态和方法是良好类设计的第一步。我们可以使用缩写 SOLID 总结一些有用的设计原则:

  • 单一职责原则:一个类应该有一个明确定义的责任。

  • 开放/封闭原则:一个类应该对扩展开放——通常通过继承——但对修改封闭。我们应该设计我们的类,以便我们不需要调整代码来添加或更改功能。

  • 李斯克夫替换原则:我们需要设计继承,使得子类可以替代父类使用。

  • 接口隔离原则:在编写问题陈述时,我们希望确保协作类尽可能少地依赖。在许多情况下,这一原则将引导我们将大问题分解成许多小的类定义。

  • 依赖倒置原则:一个类直接依赖于其他类并不理想。如果类依赖于抽象,并且用具体实现类替换抽象类,则更好。

目标是创建具有必要行为并遵循设计原则的类,以便它们可以被扩展和重用。

7.1.5 参考内容

  • 请参阅使用属性实现懒属性的配方,我们将探讨选择积极属性和懒属性之间的选择。

  • 在第八章中,我们将更深入地探讨类设计技术。

  • 请参阅第十五章,了解如何为类编写适当的单元测试配方。

7.2 类定义的必要类型提示

类名也是一个类型提示,允许变量直接引用应该定义与变量相关联的对象的类。这种关系使工具如 mypy 能够推理我们的程序,以确保对象引用和方法引用似乎与代码中的类型提示相匹配。

除了类名之外,我们将在类定义中的三个常见位置使用类型提示:

  • 在方法定义中,我们将使用类型提示来注释参数和返回类型。

  • __init__()方法中,我们可能需要为定义对象状态的实例变量提供提示。

  • 在类的整体属性中。这些不是常见的,这里的类型提示也很少。

7.2.1 准备工作

我们将检查一个具有各种类型提示的类。在这个例子中,我们的类将模拟一把骰子。我们将允许重新掷选定的骰子,使类的实例具有状态。

骰子集合可以通过第一次掷骰子来设置,其中所有骰子都被掷出。该类允许掷出骰子子集的后续掷骰。同时也会计算掷骰子的次数。

类型提示将反映骰子集合的性质、整数计数、浮点平均值以及整个手牌的字符串表示。这将展示一系列类型提示及其编写方式。

7.2.2 如何实现...

  1. 此定义将涉及随机数以及集合和列表的类型提示。我们导入 random 模块:

     import random 
    
  2. 定义类。这创建了一个新类型:

     class Dice:
    
  3. 类级别的变量很少需要类型提示。它们几乎总是通过赋值语句创建的,这些语句使类型信息对人类或像 mypy 这样的工具来说很清晰。在这种情况下,我们希望我们的骰子类的所有实例共享一个共同的随机数生成器对象:

     RNG = random.Random()
    
  4. init()方法创建了定义对象状态的实例变量。在这种情况下,我们将保存一些配置细节和一些内部状态。init()方法还有初始化参数。通常,我们会在这些参数上放置类型提示。其他内部状态变量可能需要类型提示来显示其他类方法将分配哪些类型的值。在这个例子中,faces 属性没有初始值;我们声明当它被设置时,它将是一个 List[int]对象:

     def __init__(self, n: int, sides: int = 6) -> None: 
    
            self.n_dice = n 
    
            self.sides = sides 
    
            self.faces: list[int] 
    
            self.roll_number = 0
    
  5. 计算新导出值的方法可以用它们的返回类型信息进行注解。这里有三个例子,用于返回字符串表示、计算总和以及计算骰子的平均值。这些函数的返回类型分别是 str、int 和 float,如下所示:

     def __str__(self) -> str: 
    
            return ", ".join( 
    
                f"{i}: {f}" 
    
                for i, f in enumerate(self.faces) 
    
            ) 
    
        def total(self) -> int: 
    
            return sum(self.faces) 
    
        def average(self) -> float: 
    
            return sum(self.faces) / self.n_dice
    
  6. 对于有参数的方法,我们在参数上以及返回类型上包含类型提示。在这种情况下,改变内部状态的方法也会返回值。两个方法的返回值都是骰子面的列表,描述为 list[int]。reroll()方法的参数是要重新掷的骰子集合。这表示为 set[int],需要一组整数。Python 比这要灵活一些,我们将探讨一些替代方案:

     def first_roll(self) -> list[int]: 
    
            self.roll_number = 0 
    
            self.faces = [ 
    
                self.RNG.randint(1, self.sides) 
    
                for _ in range(self.n_dice) 
    
            ] 
    
            return self.faces 
    
        def reroll(self, positions: set[int]) -> list[int]: 
    
            self.roll_number += 1 
    
            for p in positions: 
    
                self.faces[p] = self.RNG.randint(1, self.sides) 
    
            return self.faces
    

7.2.3 工作原理...

类型提示信息被程序如 mypy 使用,以确保在整个应用程序中正确使用类的实例。

如果我们尝试编写如下函数:

 def example_mypy_failure() -> None: 

    d = Dice(2.5) 

    d.first_roll() 

    print(d)

使用浮点值作为 n 参数创建 Dice 类实例的尝试与类型提示发生冲突。Dice 类的 init()方法的提示称参数值应该是整数。mypy 程序报告如下:

src/ch07/recipe_02_bad.py:9: error: Argument 1 to "Dice" has incompatible type "float"; expected "int"  [arg-type]

如果我们尝试执行应用程序,它将在另一个地方引发 TypeError 异常。错误将在评估 d.first_roll()方法时显现。异常在这里被引发,因为 init()方法的主体可以很好地处理任何类型的值。提示称期望特定的类型,但在运行时,可以提供任何对象。在执行期间不会检查提示。

类似地,当我们使用其他方法时,mypy 程序会检查我们的方法使用是否与类型提示定义的期望相匹配。这里有一个另一个例子:

 r1: list[str] = d.first_roll()

这个赋值语句中,r1 变量的类型提示与 first_roll()方法返回值的类型提示不匹配。这种冲突是由 mypy 检测到的,并报告为“赋值中不兼容的类型”错误。

7.2.4 更多内容...

在这个例子中,有一个类型提示过于具体。用于重新掷骰子的 reroll()函数有一个 positions 参数。positions 参数在 for 语句中使用,这意味着对象必须是某种可迭代对象。

错误在于提供了一个类型提示 set[int],这仅仅是许多可迭代对象中的一种。我们可以通过将类型提示从非常具体的 set[int]切换到更通用的 Iterable[int]来泛化这个定义。

放宽提示意味着任何集合、列表或元组对象都可以作为此参数的有效参数值。所需的唯一其他代码更改是导入 collections.abc 模块中的 Iterable。

for 语句有一个从可迭代集合获取迭代器对象、将值赋给变量和执行缩进体的特定协议。这个协议由 Iterable 类型提示定义。有许多这样的基于协议的类型,它们允许我们提供与 Python 固有的类型灵活性相匹配的类型提示。

7.2.5 参考信息

  • 在第三章的函数参数和类型提示配方中,展示了多个类似的概念。

  • 在第四章中,编写与列表相关的类型提示和编写与集合相关的类型提示配方解决了额外的详细类型提示。

  • 在第五章中,编写与字典相关的类型提示配方也解决了类型提示。

7.3 设计具有大量处理的类

有时,一个对象将包含定义其内部状态的所有数据。然而,有些情况下,一个类不持有数据,而是设计用来对存储在单独容器中的数据进行处理进行整合。

这种设计的典型例子是统计算法,这些算法通常位于被分析的数据之外。数据可能在一个内置的列表或 Counter 对象中;处理定义在数据容器之外的一个类中。

7.3.1 准备工作

在对已经总结成组或箱的数据进行分析是很常见的。例如,我们可能有一个包含大量工业过程测量的巨大数据文件。

为了了解背景,请参阅 NIST 气溶胶粒子尺寸案例研究:www.itl.nist.gov/div898/handbook/pmc/section6/pmc62.htm

与分析大量的原始数据相比,首先总结重要的变量,然后分析总结后的数据通常要快得多。总结数据可以保存在一个 Counter 对象中。数据看起来是这样的:

 data = Counter({7: 80, 

         6: 67, 

         8: 62, 

         9: 50,
... Details omitted ...
 2: 3, 

         3: 2, 

         1: 1})

键(7、6、8、9 等等)是反映粒子大小的代码。实际尺寸从 109 到 119 不等。从 s 代码计算实际尺寸 c,公式为 c = ⌊2(s − 109)⌋。

(NIST 背景信息中没有提供单位。由于大量数据反映了电子芯片晶圆和制造过程,单位可能非常小。)

我们希望在不需要强制与原始大量数据集一起工作的前提下,计算这个 Counter 对象上的某些统计数据。一般来说,设计用于存储和处理数据的类有两种一般的设计策略:

  • 扩展存储类定义,在这个例子中是 Counter,以添加统计处理。我们将在扩展内置集合 – 具有统计功能的列表食谱中详细说明。

  • 在一个提供所需额外功能的类中包装 Counter 对象。当我们这样做时,我们还有两个选择:

    • 暴露底层 Counter 对象。我们将关注这一点。

    • 编写特殊方法以使包装器看起来也是一个集合,封装 Counter 对象。我们将在第八章中探讨这一点。

对于这个食谱,我们将专注于包装变体,其中我们定义一个统计计算类,该类公开一个 Counter 对象。我们有两种设计这种计算密集型处理的方法:

  • 积极实现会在尽可能早的时候计算统计数据。这些值成为简单的属性。我们将关注这个选择。

  • 懒惰方法不会在需要通过方法函数或属性获取值之前进行任何计算。我们将在使用属性进行懒惰属性食谱中探讨这一点。

两种设计的基本算法是相同的。唯一的问题是计算工作何时完成。

7.3.2 如何实现...

  1. 从 collections 模块导入适当的类。计算使用 math.sqrt()。务必添加所需的导入 math:

     from collections import Counter 
    
    import math
    
  2. 使用描述性的名称定义类:

     class CounterStatistics:
    
  3. 编写 init()方法以包含数据所在的对象。在这种情况下,类型提示是 Counter[int],因为 Counter 对象中使用的键将是整数:

     def __init__(self, raw_counter: Counter[int]) ->  None: 
    
            self.raw_counter = raw_counter
    
  4. init()方法中初始化任何其他可能有用的局部变量。由于我们将积极计算值,最积极的时间是在对象创建时。我们将编写对一些尚未定义的函数的引用:

     self.mean = self.compute_mean() 
    
            self.stddev = self.compute_stddev()
    
  5. 定义所需的方法以计算各种值。以下是计算平均值的示例:

     def compute_mean(self) -> float: 
    
            total, count = 0.0, 0 
    
            for value, frequency in self.raw_counter.items(): 
    
                total += value * frequency 
    
                count += frequency 
    
            return total / count
    
  6. 这是我们如何计算标准差的方法:

     def compute_stddev(self) -> float: 
    
            total, count = 0.0, 0 
    
            for value, frequency in self.raw_counter.items(): 
    
                total += frequency * (value - self.mean) ** 2 
    
                count += frequency 
    
            return math.sqrt(total / (count - 1))
    

注意,这个计算需要首先计算平均值,并创建 self.mean 实例变量。从无已知平均值到已知平均值再到已知标准差这种内部状态变化是一个潜在的复杂性,需要清晰的文档说明。

本例的原始数据位于www.itl.nist.gov/div898/handbook//datasets/NEGIZ4.DAT。由于数据前有 50 行标题文本,这个文件的结构显得很复杂。此外,文件不是常见的 CSV 格式。因此,处理汇总数据更容易。

本书代码库中包含一个名为 data/binned.csv 的文件,其中包含分箱的摘要数据。该数据有三个列:size_code、size 和 frequency。我们只对 size_code 和 frequency 感兴趣。

这是我们可以从该文件构建合适的 Counter 对象的方法:

 >>> from pathlib import Path 

>>> import csv 

>>> from collections import Counter 

>>> data_path = Path.cwd() / "data" / "binned.csv" 

>>> with data_path.open() as data_file: 

...     reader = csv.DictReader(data_file) 

...     extract = { 

...         int(row[’size_code’]): int(row[’frequency’]) 

...         for row in reader 

...     } 

>>> data = Counter(extract)

我们使用字典推导来创建从 size_code 到该代码值频率的映射。然后将其提供给 Counter 类,从现有的摘要构建 Counter 对象 data。我们可以将此数据提供给 CounterStatistics 类,从分箱数据中获得有用的摘要统计信息。这看起来像以下示例:

 >>> stats = CounterStatistics(data) 

>>> print(f"Mean: {stats.mean:.1f}") 

Mean: 10.4 

>>> print(f"Standard Deviation: {stats.stddev:.2f}") 

Standard Deviation: 4.17

我们提供了数据对象来创建 CounterStatistics 类的实例。创建这个实例也将立即计算摘要统计信息。不需要额外的显式方法评估。这些值作为 stats.mean 和 stats.stddev 属性可用。

计算统计信息的处理成本最初就支付了。正如我们下面将看到的,任何对底层数据的更改都可以关联一个非常小的增量成本。

7.3.3 它是如何工作的...

这个类封装了两个复杂算法,但不包括这些算法的任何数据。数据被单独保存在 Counter 对象中。我们编写了一个高级规范来处理,并将其放置在 init()方法中。然后我们编写了实现指定处理步骤的方法。我们可以设置所需的所有属性,这使得这是一种非常灵活的方法。

这种设计的优点是,属性值可以重复使用。计算平均值和标准差的成本只支付一次;每次使用属性值时,不需要进一步处理。

这种设计的缺点是,对底层 Counter 对象状态的任何更改都将使 CounterStatistics 对象的状态过时且不正确。例如,如果我们添加了数百个更多数据值,平均值和标准差就需要重新计算。当底层 Counter 对象不会改变时,急切计算值的设计是合适的。

7.3.4 更多内容...

如果我们需要对有状态、可变对象进行计算,我们有几种选择:

  • 封装 Counter 对象并通过 CounterStatistics 类进行更改。这需要小心地暴露数据收集足够多的方法。我们将把这种设计推迟到第八章(ch012.xhtml#x1-4520008)。

  • 使用延迟计算。参见本章中的使用属性进行延迟属性配方。

  • 添加一个方法来实现计算平均值和标准差,这样在更改底层 Counter 对象后可以重新计算这些值。这导致重构 init()方法以使用这种新的计算方法。我们将把这个留作读者的练习。

  • 编写文档说明每次底层 Counter 对象发生变化时创建新的 CounterStatistics 实例的要求。这不需要代码,只需明确说明对象状态上的约束即可。

7.3.5 参见

  • 在扩展内置集合 – 统计列表的菜谱中,我们将探讨一种不同的设计方法,其中这些新的汇总函数被用来扩展类定义。

  • 我们将在使用属性创建懒属性的菜谱中探讨不同的方法。这个替代菜谱将使用属性按需计算属性。

  • 在第八章中,也探讨了 wrap=extend 设计选择。

7.4 使用 typing.NamedTuple 创建不可变对象

在某些情况下,一个对象是一个相对复杂数据的容器,但实际上并没有对数据进行很多处理。实际上,在许多情况下,我们将定义一个不需要任何独特方法函数的类。这些类是相对被动的数据项容器,没有太多的处理。

在许多情况下,Python 的内置容器类 – 列表、集合或字典 – 可以覆盖你的用例。小问题是,访问字典或列表中项的语法并不像访问对象属性那样优雅。

我们如何创建一个类,使我们能够使用 object.attribute 语法而不是更复杂的 object[‘attribute’] 语法?

7.4.1 准备工作

任何类型的类设计都有两种情况:

  • 它是无状态的(或不可变的)吗?它是否包含永远不会改变的值的属性?这是一个 NamedTuple 的好例子。

  • 它是有状态的(或可变的)吗?是否会有一个或多个属性的状态变化?这是 Python 类定义的默认情况。一个普通类是有状态的。我们可以使用使用 dataclasses 创建可变对象的菜谱来简化创建有状态对象的过程。

我们将定义一个类来描述具有点数和花色的简单扑克牌。由于牌的点数和花色不会改变,我们将为这个创建一个小型的无状态类。typing.NamedTuple 类是这类类定义的便捷基类。

7.4.2 如何做...

  1. 我们将定义无状态对象为 typing.NamedTuple 的子类:

     from typing import NamedTuple 
    
  2. 将类名称定义为 NamedTuple 的扩展。包括具有各自类型提示的属性:

     class Card(NamedTuple): 
    
        rank: int 
    
        suit: str
    

这是我们如何使用这个类定义来创建 Card 对象的方法:

 >>> eight_hearts = Card(rank=8, suit=’\N{White Heart Suit}’) 

>>> eight_hearts 

Card(rank=8, suit=’’) 

>>> eight_hearts.rank 

8 

>> eight_hearts.suit 

’’ 

>>> eight_hearts[0]

我们创建了一个名为 Card 的新类,它有两个属性名称:rank 和 suit。在定义了类之后,我们可以创建类的实例。我们构建了一个单张 Card 对象,eight_hearts,其点数为八,花色为 ♡。

我们可以用其名称或其元组内的位置来引用这个对象的属性。当我们使用 eight_hearts.rank 或 eight_hearts[0]时,我们会看到 rank 属性的值,因为该属性在属性名称序列中定义在第一位。

这种类型的对象是不可变的。以下是一个尝试更改实例属性的示例:

 >>> eight_hearts.suit = ’\N{Black Spade Suit}’ 

Traceback (most recent call last): 

... 

AttributeError: can’t set attribute

我们尝试更改 eight_hearts 对象的 suit 属性。这引发了一个 AttributeError 异常,表明命名元组的实例是不可变的。

元组可以包含任何类型的对象。

当元组包含可变项,如列表、集合或字典时,这些对象保持可变。

只有顶层包含的元组是不可变的。元组内的列表、集合或字典是可变的。

7.4.3 它是如何工作的...

typing.NamedTuple 类让我们定义一个具有明确定义属性列表的新子类。自动创建了一些方法,以提供最小级别的 Python 行为。我们可以看到一个实例将显示一个可读的文本表示,显示各种属性的值。

对于命名元组子类,其行为基于内置元组实例的工作方式。属性的顺序定义了元组之间的比较。例如,我们的 Card 定义首先列出 rank 属性。这意味着我们可以很容易地按等级排序牌。对于等级相同的两张牌,花色将按顺序排序。因为命名元组也是元组,所以它很好地作为集合的成员或字典的键。

在这个例子中,rank 和 suit 这两个属性作为类定义的一部分命名,但作为实例变量实现。为我们创建了一个元组的 new()方法的变体。该方法有两个参数与实例变量名称匹配。自动创建的方法将在对象创建时将参数值分配给实例变量。

7.4.4 更多...

我们可以向这个类定义添加方法。例如,如果每张牌都有一个点数,我们可能希望扩展类,使其看起来像以下示例:

 class CardPoints(NamedTuple): 

    rank: int 

    suit: str 

    def points(self) -> int: 

        if 1 <= self.rank < 10: 

            return self.rank 

        else: 

            return 10

我们编写了一个 CardsPoints 类,它有一个 points()方法,该方法返回分配给每个等级的点数。这种点规则适用于像克里比奇这样的游戏,而不适用于像黑杰克这样的游戏。

因为这是一个元组,所以方法不能添加新的属性或更改属性。在某些情况下,我们通过其他元组构建复杂的元组。

7.4.5 参见

  • 在设计大量处理的类的配方中,我们查看了一个完全处理且几乎没有数据的类。它作为这个类的完全对立面。

7.5 使用 dataclasses 处理可变对象

我们已经记录了 Python 中的两种一般类型的对象:

  • 不可变:在设计过程中,我们会询问是否有属性具有永远不会改变的值。如果答案是肯定的,请参阅使用 typing.NamedTuple 为不可变对象菜谱,它提供了一种为不可变对象构建类定义的方法。

  • 可变:一个或多个属性会有状态变化吗?在这种情况下,我们可以从头开始构建一个类,或者我们可以利用 @dataclass 装饰器从一些属性和类型提示中创建一个类定义。这个案例是这个菜谱的重点。

我们如何利用 dataclasses 库来帮助设计可变对象?

7.5.1 准备工作

我们将仔细研究一个具有内部状态的可变对象,以表示一副牌。虽然单个卡片是不可变的,但它们可以被插入到一副牌中并从一副牌中移除。在像克里比(Cribbage)这样的游戏中,手牌会有许多状态变化。最初,六张牌被分给两位玩家。玩家将各自放下一对牌来创建克里比。然后,剩下的四张牌交替出牌,以创造得分机会。然后,手牌在隔离状态下计数,得分机会的混合略有所不同。庄家从计数克里比中的牌中获得额外的手牌得分。(是的,最初是不公平的,但发牌轮流进行,所以最终是公平的。)

我们将研究一个简单的集合来存放卡片,并丢弃形成克里比的两张卡片。

7.5.2 如何操作...

  1. 为了定义数据类,我们将导入 @dataclass 装饰器:

     from dataclasses import dataclass
    
  2. 使用 @dataclass 装饰器定义新的类:

     @dataclass 
    
    class CribbageHand:
    
  3. 使用适当的类型提示定义各种属性。在这个例子中,我们期望玩家拥有一组由 list[CardPoints] 表示的卡片集合。因为每张卡片都是唯一的,我们也可以使用 set[CardPoints] 类型提示:

     cards: list[CardPoints]
    
  4. 定义任何会改变对象状态的函数:

     def to_crib(self, card1: CardPoints, card2: CardPoints) -> None: 
    
            self.cards.remove(card1)
    

这是完整的类定义,正确缩进:

 @dataclass 

class CribbageHand: 

    cards: list[CardPoints] 

    def to_crib(self, card1: CardPoints, card2: CardPoints) -> None: 

        self.cards.remove(card1) 

        self.cards.remove(card2)

这个定义提供了一个单一的实例变量 self.cards,它可以被任何编写的函数使用。因为我们提供了类型提示,所以 mypy 程序可以检查类以确保它被正确使用。

这是创建这个 CribbageHand 类实例时的样子:

 >>> cards = [ 

... CardPoints(rank=3, suit=’\N{WHITE DIAMOND SUIT}’), 

... CardPoints(rank=6, suit=’\N{BLACK SPADE SUIT}’), 

... CardPoints(rank=7, suit=’\N{WHITE DIAMOND SUIT}’), 

... CardPoints(rank=1, suit=’\N{BLACK SPADE SUIT}’), 

... CardPoints(rank=6, suit=’\N{WHITE DIAMOND SUIT}’), 

... CardPoints(rank=10, suit=’\N{WHITE HEART SUIT}’)] 

>>> ch1 = CribbageHand(cards) 

>>> from pprint import pprint 

>>> pprint(ch1) 

CribbageHand(cards=[CardPoints(rank=3, suit=’’), 

                    CardPoints(rank=6, suit=’’), 

                    CardPoints(rank=7, suit=’’), 

                    CardPoints(rank=1, suit=’’), 

                    CardPoints(rank=6, suit=’’), 

                    CardPoints(rank=10, suit=’’)]) 

>>> [c.points() for c in ch1.cards] 

[3, 6, 7, 1, 6, 10] 

在以下示例中,玩家决定(可能不明智)将 3♢ 和 A♠ 卡片放进行克里比:

 >>> ch1.to_crib( 

...     CardPoints(rank=3, suit=’\N{WHITE DIAMOND SUIT}’), 

...     CardPoints(rank=1, suit=’\N{BLACK SPADE SUIT}’)) 

>>> pprint(ch1) 

CribbageHand(cards=[CardPoints(rank=6, suit=’’), 

                    CardPoints(rank=7, suit=’’), 

                    CardPoints(rank=6, suit=’’), 

                    CardPoints(rank=10, suit=’’)]) 

>>> [c.points() for c in ch1.cards] 

[6, 7, 6, 10]

在 to_crib() 方法从手中移除两张卡片后,剩余的四张卡片被显示出来。然后创建了一个新的列表推导式,包含剩余四张卡片的点数。

7.5.3 它是如何工作的...

@dataclass 装饰器帮助我们定义一个具有几个有用方法以及从命名变量及其类型提示中抽取的属性列表的类。我们可以看到,一个实例显示了一个可读的文本表示,显示了各种属性的值。

属性作为类定义的一部分命名,但实际上作为实例变量实现。在这个例子中,只有一个属性,cards。为我们创建了一个非常复杂的 init()方法。在这个例子中,它将有一个与每个实例变量名称匹配的参数,并将参数值分配给匹配的实例变量。

@dataclass 装饰器有几个选项可以帮助我们选择我们想要的类特性。以下是我们可以选择的选项和默认设置:

  • init=True:默认情况下,将创建一个 init()方法,其参数与实例变量相匹配。

  • repr=True:默认情况下,将创建一个 repr()方法来返回显示对象状态的字符串。

  • eq=True:默认情况下,提供了 eq()和 ne()方法。这些方法实现了==和!=运算符。

  • order=False:不会自动创建 lt(), le(), gt(), 和 ge()方法。这些方法实现了<, <=, >, 和 >=运算符。

  • unsafe_hash=False:通常,可变对象没有哈希值,不能用作字典的键或集合的元素。可以自动添加 hash()方法,但这很少是可变对象的一个明智选择,这就是为什么这个选项被称为“不安全”的哈希。

  • frozen=False:这创建了一个不可变对象。有关更多详细信息,请参阅本章中的使用冻结数据类创建不可变对象配方。

由于为我们编写了大量的代码,我们可以专注于类定义的属性。我们可以编写真正有特色的函数,避免编写具有明显定义的“样板”方法。

7.5.4 更多...

一副牌需要一种初始化方法来提供 Card 对象的集合。一个默认的 init()方法可以填充这个集合。

考虑创建一副牌,而不是一手牌。初始牌组是一个不需要初始化方法来设置实例变量的数据类的例子。相反,牌组需要一个没有参数的自定义 init()方法;它总是创建相同的 52 个 Card 对象集合。这意味着我们将使用 init=False 在@dataclass 装饰器中定义这个方法,用于 Deck 类定义。

@dataclass 定义的一般模式是提供类级别的名称,这些名称既用于定义实例变量,也用于创建初始化方法 init()。这涵盖了状态对象的一个常见用例。

然而,在某些情况下,我们想要定义一个不用于创建实例变量但将保留为类级别变量的类级别变量。这可以通过 ClassVar 类型提示来完成。ClassVar 类型表示一个不是实例变量或 init()方法部分的类级别变量。

在以下示例中,我们将创建一个具有花色字符串序列的类变量:

 import random 

from typing import ClassVar 

@dataclass(init=False) 

class Deck: 

    SUITS: ClassVar[tuple[str, ...]] = ( 

    ’\N{Black Club Suit}’, 

    ’\N{White Diamond Suit}’, 

    ’\N{White Heart Suit}’, 

    ’\N{Black Spade Suit}’ 

    ) 

    cards: list[CardPoints] 

    def __init__(self) -> None: 

        self.cards = [ 

            CardPoints(rank=r, suit=s) 

            for r in range(1, 14)

此示例类定义提供了一个类级变量 SUITS,它是 Deck 类的一部分。此变量是用于定义花色的字符的元组。

cards 变量有一个提示表明它将具有 list[CardPoints] 类型。此信息被 mypy 程序用于确认 __init__() 方法的主体正确初始化了此属性。它还确认此属性被其他类适当地使用。

7.5.5 参考信息

  • 查看使用 typing.NamedTuple 构建无状态对象 菜单了解如何为无状态对象构建类定义。

  • 使用类封装数据和处理 菜单涵盖了构建不使用 @dataclass 装饰器创建的额外方法的类技术。

7.6 使用冻结数据类实现不可变对象

在 使用 typing.NamedTuple 构建无状态对象 菜单中,我们看到了如何定义具有固定属性集的类。这些属性可以通过 mypy 程序进行检查,以确保它们被正确使用。在某些情况下,我们可能想使用稍微更灵活的数据类来创建不可变对象。

使用数据类的一个潜在原因是因为它比 NamedTuple 子类具有更复杂的字段定义。另一个潜在原因是能够自定义初始化和创建的哈希函数。由于 NamedTuple 实质上是一个元组,因此在此类中调整实例的行为的能力有限。

7.6.1 准备工作

我们将重新审视定义具有等级和花色的简单扑克牌的想法。等级可以通过介于 1(A)和 13(K)之间的整数来表示。花色可以通过集合 {‘♠’,‘♡’,‘♢’,‘♣’} 中的单个 Unicode 字符来表示。由于牌的等级和花色不会改变,我们将创建一个小的、冻结的数据类来表示这一点。

7.6.2 如何实现...

  1. dataclasses 模块导入 dataclass 装饰器:

     from dataclasses import dataclass
    
  2. 使用 @dataclass 装饰器开始类定义,使用 frozen=True 选项确保对象是不可变的。我们还包含了 order=True 以定义比较运算符,允许将此类实例按顺序排序:

     @dataclass(frozen=True, order=True) 
    
    class Card:
    
  3. 为此类每个实例的属性提供属性名称和类型提示:

     rank: int 
    
        suit: str
    

我们可以在代码中如下使用这些对象:

 >>> eight_hearts = Card(rank=8, suit=’\N{White Heart Suit}’) 

>>> eight_hearts 

Card(rank=8, suit=’’) 

>>> eight_hearts.rank 

8 

>>> eight_hearts.suit 

’’

我们已经创建了一个具有特定等级和花色属性的 Card 类实例。由于该对象是不可变的,任何尝试更改状态的操作都将导致一个异常,如下面的示例所示:

 >>> eight_hearts.suit = ’\N{Black Spade Suit}’ 

Traceback (most recent call last): 

... 

dataclasses.FrozenInstanceError: cannot assign to field ’suit’

这显示了尝试更改冻结数据类实例的属性。dataclasses.FrozenInstanceError 异常被抛出以表示此类操作是不允许的。

7.6.3 它是如何工作的...

这个 @dataclass 装饰器向类定义中添加了多个内置方法。正如我们在使用 dataclasses 处理可变对象配方中提到的,有一些特性可以被启用或禁用。每个特性可能会让我们在类定义中包含一个或多个单独的方法。

7.6.4 更多内容...

@dataclass 初始化方法相当复杂。我们将探讨一个有时很有用的特性,用于定义可选属性。

考虑一个可以持有牌手的类。虽然常见用例提供了牌集来初始化手,但我们也可以有在游戏中逐步构建的手,从空集合开始,并在游戏过程中添加牌。

我们可以使用 dataclasses 模块中的 field() 函数定义这种可选属性。field() 函数允许我们提供一个函数来构建默认值,称为 default_factory。我们将在以下示例中这样使用它:

 from dataclasses import dataclass, field 

@dataclass(frozen=True, order=True) 

class Hand: 

    cards: list[CardPoints] = field(default_factory=list)

Hand dataclass 有一个单一属性,cards,它是一个 CardPoints 对象的列表。field() 函数提供了一个默认工厂:如果没有提供初始值,将执行 list() 函数来创建一个新的空列表。

我们可以使用这个 dataclass 创建两种类型的手。以下是一个传统示例,其中我们处理六张牌:

 >>> cards = [ 

... CardPoints(rank=3, suit=’\N{WHITE DIAMOND SUIT}’), 

... CardPoints(rank=6, suit=’\N{BLACK SPADE SUIT}’), 

... CardPoints(rank=7, suit=’\N{WHITE DIAMOND SUIT}’), 

... CardPoints(rank=1, suit=’\N{BLACK SPADE SUIT}’), 

... CardPoints(rank=6, suit=’\N{WHITE DIAMOND SUIT}’), 

... CardPoints(rank=10, suit=’\N{WHITE HEART SUIT}’)] 

>>> 

>>> h = Hand(cards)

The Hands() 类型期望一个单一属性,与该类中属性的定义相匹配。这是可选的,我们可以像以下示例中那样构建一个空手:

 >>> crib = Hand() 

>>> d3 = CardPoints(rank=3, suit=’\N{WHITE DIAMOND SUIT}’) 

>>> h.cards.remove(d3) 

>>> crib.cards.append(d3) 

>>> from pprint import pprint 

>>> pprint(crib) 

Hand(cards=[CardPoints(rank=3, suit=’’)])

在这个例子中,我们创建了一个没有参数值的 Hand() 实例,并将其分配给 crib 变量。由于 cards 属性是用提供了一个 default_factory 的字段定义的,因此将使用 list() 函数为 cards 属性创建一个空列表。

7.6.5 另请参阅

  • 使用 dataclasses 处理可变对象配方涵盖了使用 dataclasses 避免编写类定义复杂性的额外主题。

7.7 使用 slots 优化小对象

对象的一般情况允许动态属性集合。对于基于元组类的固定属性集合的对象有一个特殊情况。我们在使用 typing.NamedTuple 处理不可变对象配方中探讨了这两个。

存在一个折衷方案。我们也可以定义一个具有固定数量属性的对象,但属性的值可以更改。通过将类从无限属性集合转换为固定属性集,我们发现我们还可以节省内存和处理时间。

我们如何创建具有固定属性集的优化类?

7.7.1 准备工作

通常,Python 允许向对象添加属性。这可能是不可取的,尤其是在处理大量对象时。大多数类定义使用字典的方式的灵活性在内存使用上是有代价的。使用特定的 slots 名称将类限制在命名属性上,从而节省内存。

例如,Cribbage 纸牌游戏有几个组成部分:

  • 一副牌。

  • 两名玩家,他们将轮流担任庄家和对手的角色。

这个小领域的事物似乎适合作为类定义的候选。每位玩家都有一手牌和一个分数。玩家的角色是一个有趣的复杂因素。两个角色之间有一些重要差异。

  • 作为庄家的玩家将获得 crib 牌。

  • 如果起始牌是 JACK,庄家角色将为此获得分数。

  • 对手先出第一张牌。

  • 对手先计算他们的手牌。

  • 庄家从他们的手中出牌,但计算他们的手牌和 crib。

比赛的特定顺序和计分方式很重要,因为第一个通过 120 分的玩家就是赢家,无论游戏处于何种状态。

看起来 Cribbage 游戏包括一副牌和两名玩家。属于庄家的 crib(底牌)可以被视为游戏整体的一个特性。当新一轮游戏开始时,我们将探讨如何切换庄家和对手的角色。

7.7.2 如何实现...

在创建类时,我们将利用 slots 特殊名称:

  1. 定义一个具有描述性名称的类:

     class Cribbage:
    
  2. 定义属性名称列表。这标识了允许此类实例的唯一两个属性。任何尝试添加另一个属性都将引发 AttributeError 异常:

     __slots__ = (’deck’, ’players’, ’crib’, ’dealer’, ’opponent’)
    
  3. 添加一个初始化方法。这必须为命名槽位创建实例变量:

     def __init__( 
    
                self, 
    
                deck: Deck, 
    
                player1: Player, 
    
                player2: Player 
    
        ) -> None: 
    
            self.deck = deck 
    
            self.players = [player1, player2] 
    
            random.shuffle(self.players) 
    
            self.dealer, self.opponent = self.players 
    
            self.crib = Hand()
    

    Deck 类的定义在本章的使用 dataclasses 创建可变对象配方中展示。

  4. 添加更新集合的方法。在这个例子中,我们定义了一个切换角色的方法。

     def new_deal(self) -> None: 
    
            self.deck.shuffle() 
    
            self.players = list(reversed(self.players)) 
    
            self.dealer, self.opponent = self.players 
    
            self.crib = Hand() 
    

以下是我们可以使用此类构建一手牌的方法。我们需要 Card 类的定义,基于使用 typing.NamedTuple 创建不可变对象配方中的示例:

 >>> deck = Deck() 

>>> c = Cribbage(deck, Player("1"), Player("2")) 

>>> c.dealer 

Player(name=’2’) 

>>> c.opponent 

Player(name=’1’) 

>>> c.new_deal() 

>>> c.dealer 

Player(name=’1’) 

>>> c.opponent 

Player(name=’2’)

初始的 Cribbage 对象是用 Deck 和两个 Player 实例创建的。这三个对象填充了牌和玩家槽位。然后 init()方法随机化玩家,使其中一名成为庄家,另一名成为对手。crib 被初始化为一个空的 Hand 实例。

new_deal()方法会对 Cribbage 实例的状态进行多项更改。这可以通过检查庄家和对手属性来揭示。

如果我们尝试创建一个新属性,会发生以下情况:

 >>> c.some_other_attribute = True 

Traceback (most recent call last): 

... 

AttributeError: ’Cribbage’ object has no attribute ’some_other_attribute’ 

我们尝试在 Cribbage 对象 c 上创建一个名为 some_other_attribute 的属性。这引发了一个 AttributeError 异常。使用 slots 意味着不能向类的实例添加新属性。

7.7.3 它是如何工作的...

当我们创建一个对象实例时,该过程中的步骤部分由对象的类和内置的 type() 函数定义。隐式地,一个类有一个特殊的 new() 方法,用于处理创建新、空对象所需的内部管理。之后,init() 方法创建并初始化属性。

Python 有三个创建类实例的基本路径:

  • 当我们定义一个类而没有做任何不寻常的事情时,默认行为是由内置的 object 和 type() 函数定义的。每个实例都包含一个 dict 属性,用于存储所有其他属性。因为对象的属性保存在字典中,所以我们可以自由地添加、更改和删除属性。这种灵活性需要为每个实例内部的字典对象使用额外的内存。

  • slots 行为避免了创建 dict 属性。因为对象只有 slots 序列中命名的属性,所以我们不能添加或删除属性。我们可以更改定义的属性值。这种缺乏灵活性意味着每个对象使用的内存更少。

  • 元组子类的行为定义了不可变对象。创建这些类的一个简单方法是以 typing.NamedTuple 作为父类。一旦构建,实例就是不可变的,不能被更改。虽然可以直接从元组中派生,但 NamedTuple 的额外功能似乎使这成为理想的选择。

一个大型应用程序可能会受到内存使用的限制,将具有最大实例数的类切换到 slots 可以提高性能。

7.7.4 更多...

可以调整 new() 方法的工作方式,用不同类型的字典替换默认的 dict 属性。这是一个高级技术,因为它暴露了类和对象的内部工作原理。

Python 依赖于元类来创建类的实例。默认的元类是 type 类。其思想是元类提供了一些用于创建每个对象的功能。一旦创建了空对象,类的 init() 方法将初始化这个空对象。

通常,元类会提供一个 new() 方法的定义,如果需要定制对象,可能还会提供 prepare()。Python 语言参考文档中有一个广泛使用的例子,它调整了用于创建类的命名空间。

更多详情,请参阅 docs.python.org/3/reference/datamodel.html#metaclass-example

7.7.5 参见

  • 不可变对象或完全灵活对象的更常见情况在 使用 typing.NamedTuple 创建不可变对象 章节中进行了介绍。

7.8 使用更复杂的集合

Python 拥有丰富的内置集合。在第四章中,我们对其进行了详细探讨。在选择数据结构的配方中,我们提供了一个决策树,以帮助从可用的选择中定位适当的数据结构。

当我们考虑标准库中的内置类型和其他数据结构时,我们有更多的选择,需要做出的决定也更多。我们如何为我们的问题选择正确的数据结构?

7.8.1 准备工作

在我们将数据放入集合之前,我们需要考虑我们将如何收集数据,以及一旦我们拥有它,我们将如何处理这个集合。始终存在的一个大问题是我们在集合中如何识别特定的项目。我们将探讨一些关键问题,这些问题需要我们回答,以帮助我们选择适合我们需求的适当集合。

这里是一些替代集合的概述。collections 模块包含许多内置集合的变体。以下是一些包括的内容:

  • deque:一个双端队列。这是一个可变序列,对从两端推入和弹出进行了优化。请注意,类名以小写字母开头;这在 Python 中是不典型的。

  • defaultdict:一种可以为一个缺失的键提供默认值的映射。请注意,类名以小写字母开头;这在 Python 中是不典型的。

  • Counter:一种设计用来计算不同键出现次数的映射。这有时被称为多重集或包。

  • ChainMap:一种将多个字典组合成一个单一映射的映射。

heapq 模块包含一个优先队列实现。这是一个专门化的库,利用内置的列表序列来保持项目排序。

bisect 模块包含搜索排序列表的方法。这在大字典功能和列表功能之间产生了一些重叠。

此外,collections 模块中还有一个 OrderedDict 类。从 Python 3.7 开始,普通字典的键按创建顺序保留,这使得 OrderedDict 类变得冗余。

7.8.2 如何实现...

我们需要回答一些问题来决定是否需要一个库数据集合而不是内置集合:

  1. 这种结构是生产者和消费者之间的缓冲吗?算法的某个部分产生数据项,而另一个部分消费数据项吗?

    • 队列用于先入先出(FIFO)处理。项目在一端插入,从另一端消费。我们可以使用 list.append()和 list.pop(0)来模拟这个过程,尽管 collections.deque 将更高效;我们可以使用 deque.append()和 deque.popleft()。

    • 栈用于后进先出(LIFO)处理。项目从同一端插入和消耗。我们可以使用 list.append()和 list.pop()来模拟这一点,尽管 collections.deque 将更高效;我们可以使用 deque.append()和 deque.pop()。

    • 优先队列(或堆队列)按某种顺序保持队列排序,这种顺序与到达顺序不同。我们可以通过使用 list.append()、list.sort(key=lambda x:x.priority)和 list.pop(-1)操作来模拟这一点,以保持项目按优先级排序。每次插入后进行排序可能会使其效率低下。使用 heapq 模块可能更高效。heapq 模块有用于创建和更新堆的函数。

  2. 我们应该如何处理字典中的缺失键?

    • 抛出异常。这是内置的 dict 类的工作方式。

    • 创建一个默认项。这是 collections.defaultdict 的工作方式。我们必须提供一个返回默认值的函数。常见的例子包括 defaultdict(int)和 defaultdict(float)来使用默认值 0 或 0.0。我们还可以使用 defauldict(list)和 defauldict(set)来创建字典-of-list 或字典-of-set 结构。

    • 用于创建计数字典的 defaultdict(int)非常常见,以至于 collections.Counter 类正是这样做的。

  3. 我们希望如何处理字典中键的顺序?通常,Python 3.6 以上版本会保持键的插入顺序。如果我们想有不同的顺序,我们将不得不手动排序它们。

  4. 我们将如何构建字典?

    • 我们有一个简单的算法来创建项。在这种情况下,一个内置的 dict 对象可能就足够了。

    • 我们有多个需要合并的字典。这可能在读取配置文件时发生。我们可能有一个单独的配置、系统范围的配置以及默认的应用程序配置,所有这些都需要使用 ChainMap 集合合并成一个单一的字典。

7.8.3 它是如何工作的...

数据处理有两个主要资源约束:

  • 存储

  • 时间

我们的所有编程都必须遵守这些约束。在大多数情况下,这两者是相反的:我们为了减少存储使用而做的事情往往会增加处理时间,而我们为了减少处理时间而做的事情会增加存储使用。算法和数据结构设计寻求在约束之间找到一个最佳平衡。

时间方面通过复杂度指标形式化。描述算法复杂性的方法有很多:

  • 复杂度 O(1)不随数据量的大小而改变。对于某些集合,实际的长期平均整体几乎接近 O(1),但有少数例外。许多字典操作是 O(1)。向列表中添加元素,以及从列表末尾弹出元素非常快,使得 LIFO 栈非常高效。从列表前面弹出元素是 O(n),这使得由简单列表构建的 FIFO 队列相当昂贵;deque 类和 heapq 模块通过更好的设计来解决这个问题。

  • 被描述为 O(log n)的复杂度意味着成本的增长速度低于数据量 n 的增长速度。二分查找模块允许我们通过将列表分成两半来更有效地搜索排序后的列表。请注意,首先对列表进行排序是 O(nlog n),因此需要大量的搜索来分摊排序的成本。

  • 被描述为 O(n)的复杂度意味着成本随着数据量 n 的增长而增长。在列表中查找一个项目具有这种复杂度。如果项目在列表的末尾,必须检查所有 n 个项目。集合和映射没有这个问题,并且具有接近 O(1)的复杂度。

  • 被描述为 O(nlog n)的复杂度比数据量增长得更快。排序列表通常具有这种复杂度。因此,最小化或消除大量数据的排序是有帮助的。

  • 有些情况甚至更糟。一些算法的复杂度为 O(n²)、O(2^n),甚至 O(n!)。我们希望通过巧妙的设计和良好的数据结构选择来避免这些非常昂贵的算法。在实践中,这些算法可能会具有欺骗性。我们可能能够设计出一个 O(2^n)的算法,在 n 为 3 或 4 的小测试用例中似乎表现良好。在这些情况下,组合数只有 8 或 16 种。如果实际数据涉及 70 个项目,组合数将达到 10²²的数量级,一个有 22 位数的数字。

标准库中可用的各种数据结构反映了时间和存储之间的许多权衡。

7.8.4 更多内容...

作为具体和极端的例子,让我们看看搜索一个特定事件序列的 Web 日志文件。我们有两个总体设计策略:

  • 使用类似 file.read().splitlines()的方法将所有事件读入一个列表结构中。然后我们可以使用 for 语句遍历列表,寻找事件的组合。虽然初始读取可能需要一些时间,但由于日志全部在内存中,搜索将会非常快。

  • 从日志文件中逐个读取和处理事件。当一个日志条目是搜索到的模式的一部分时,只保存这个事件在日志的子集中是有意义的。我们可能使用一个以会话 ID 或客户端 IP 地址作为键,事件列表作为值的 defaultdict。这将花费更长的时间来读取日志,但内存中的结果结构将比所有日志条目的列表小得多。

第一个算法,将所有内容读入内存,可能非常不切实际。在一个大型网络服务器上,日志可能涉及数百 GB 的数据。日志可能太大,无法放入任何计算机的内存中。

第二种方法有几个替代实现:

  • 单进程:这里大多数 Python 食谱的一般方法假设我们正在创建一个作为单个进程运行的应用程序。

  • 多进程:我们可能会将逐行搜索扩展为多进程应用程序,使用 multiprocessing 或 concurrent.futures 包。这些包让我们创建一组工作进程,每个进程可以处理可用数据的一个子集,并将结果返回给一个消费者,该消费者将结果组合起来。在现代多处理器、多核计算机上,这可以是非常有效的资源利用方式。

  • 多主机:极端情况需要多个服务器,每个服务器处理数据的一个子集。这需要在主机之间进行更复杂的协调以共享结果集。通常,使用 Dask 或 Spark 这样的框架来处理这类工作效果很好。虽然 multiprocessing 模块相当复杂,但像 Dask 这样的工具更适合大规模计算。

我们经常将大搜索分解为映射和归约处理。映射阶段对集合中的每个项目应用一些处理或过滤。归约阶段将映射结果组合成摘要或聚合对象。在许多情况下,有一个复杂的 Map-Reduce 阶段层次结构应用于先前 Map-Reduce 操作的结果。

7.8.5 相关阅读

  • 请参阅第四章中的选择数据结构配方,以了解选择数据结构的基础决策集。

7.9 扩展内置集合 – 一个能进行统计的列表

在设计具有大量处理的类的配方中,我们查看了一种区分复杂算法和集合的方法。我们展示了如何将算法和数据封装到不同的类中。另一种设计策略是扩展集合以包含有用的算法。

我们如何扩展 Python 的内置集合?我们如何向内置列表添加功能?

7.9.1 准备工作

我们将创建一个复杂的列表类,其中每个实例都可以计算列表中项目的总和和平均值。这需要应用仅将数字放入列表中;否则,将引发 ValueError 异常。

我们将展示一些方法,这些方法明确使用生成器表达式作为可以包含额外处理的地方。我们不会使用 sum(self),而是强调 sum(v for v in self),因为有两个常见的未来扩展:sum(m(v) for v in self) 和 sum(v for v in self if f(v))。这些是映射和过滤的替代方案,其中映射函数 m(v) 应用于每个项目;或者过滤函数 f(v) 应用于通过或拒绝每个项目。例如,计算平方和将映射应用于计算每个值的平方,然后再求和。

7.9.2 如何实现...

  1. 为列表选择一个同时也能进行简单统计的名字。将类定义为内置列表类的扩展:

     class StatsList(list[float]):
    

    我们可以坚持使用通用的类型提示 list。这通常太宽泛了。由于结构将包含数字,使用更窄的提示 list[float]更合理。

    当处理数值数据时,mypy 将 float 类型视为 float 和 int 的超类,从而节省了我们定义显式 Union[float, int]的需要。

  2. 将额外的处理定义为方法。self 变量将是一个继承了超类所有属性和方法的对象。在这种情况下,超类是 list[float]。我们在这里使用生成器表达式作为一个可能包含未来更改的地方。以下是一个 sum()方法:

     def sum(self) -> float: 
    
            return sum(v for v in self)
    
  3. 这里是另一个我们经常应用于列表的方法。它计算项目数量并返回大小。我们使用生成器表达式使其易于添加映射或过滤条件,如果需要的话:

     def size(self) -> float: 
    
            return sum(1 for v in self)
    
  4. 这里是平均数方法:

     def mean(self) -> float: 
    
            return self.sum() / self.size()
    
  5. 这里有一些额外的函数。sum2()方法计算列表中值的平方和。这用于计算方差。然后使用方差来计算列表中值的标准差。与之前的 sum()和 count()方法不同,那里没有映射,在这种情况下,生成器表达式包括一个映射转换:

     def sum2(self) -> float: 
    
            return sum(v ** 2 for v in self) 
    
        def variance(self) -> float: 
    
            return ( 
    
              (self.sum2() - self.sum() ** 2 / self.size()) 
    
              / (self.size() - 1) 
    
            ) 
    
        def stddev(self) -> float: 
    
            return math.sqrt(self.variance())
    

StatsList 类的定义继承了内置列表对象的所有特性。它通过我们添加的方法进行了扩展。以下是创建此集合中实例的示例:

 >>> subset1 = StatsList([10, 8, 13, 9, 11]) 

>>> data = StatsList([14, 6, 4, 12, 7, 5]) 

>>> data.extend(subset1)

我们从字面列表对象中创建了两个 StatsList 对象,分别是 subset1 和 data。我们使用了从列表超类继承的 extend()方法来合并这两个对象。以下是结果对象:

 >>> data 

[14, 6, 4, 12, 7, 5, 10, 8, 13, 9, 11]

这是我们可以使用这个对象上定义的额外方法的方式:

 >>> data.mean() 

9.0 

>>> data.variance() 

11.0

我们已经展示了 mean()和 variance()方法的结果。内置列表类的所有特性也存在于我们的扩展中。

7.9.3 它是如何工作的...

类定义的一个基本特征是继承的概念。当我们创建一个超类-子类关系时,子类继承超类的所有特性。这有时被称为泛化-特殊化关系。超类是一个更通用的类;子类更特殊,因为它添加或修改了特性。

所有内置类都可以扩展以添加功能。在这个例子中,我们添加了一些统计处理,创建了一个特殊的数字列表子类。

两种设计策略之间存在重要的紧张关系:

  • 扩展:在这种情况下,我们扩展了一个类以添加功能。这些特性与这个单一的数据结构紧密相连,我们无法轻易地将其用于不同类型的序列。

  • 包装:在设计具有大量处理功能的类时,我们保持了处理与集合的分离。这导致在处理两个对象时出现一些复杂性。

很难说其中哪一种本质上优于另一种。在许多情况下,我们会发现包装可能具有优势,因为它似乎更适合 SOLID 设计原则。然而,通常会有一些情况,扩展内置集合是合适的。

7.9.4 更多内容...

概化的想法可能导致抽象超类。由于抽象类是不完整的,它需要一个子类来扩展它并提供缺失的实现细节。我们不能创建一个抽象类的实例,因为它会缺少使其有用的功能。

正如我们在第四章的选择数据结构配方中提到的,所有内置集合都有抽象超类。我们不仅可以从一个具体类开始,还可以从一个抽象基类开始我们的设计。

例如,我们可以这样开始一个类定义:

 from collections.abc import MutableMapping 

class MyFancyMapping(MutableMapping[int, int]): 

    ... # etc.

为了完成这门课程,我们需要为一些特殊方法提供实现:

  • __getitem__

  • __setitem__

  • __delitem__

  • __iter__

  • __len__

这些方法在抽象类中都不存在;在 Mapping 类中没有具体的实现。一旦我们为每个方法提供了可行的实现,我们就可以创建新子类实例。

7.9.5 参考信息

  • 在设计大量处理类的配方中,我们采取了不同的方法。在那个配方中,我们将复杂的算法留在了另一个类中。

7.10 使用属性懒加载

在设计大量处理类的配方中,我们定义了一个类,它会急切地计算集合中数据的许多属性。那里的想法是尽可能早地计算值,这样属性就不会有进一步的计算成本。

我们将其描述为急切处理,因为工作尽可能早地完成。另一种方法是懒处理,其中工作尽可能晚地完成。

如果我们有一些很少使用且计算成本很高的值,我们该怎么办?我们如何最小化初始计算,并且只在真正需要时计算值?

7.10.1 准备工作...

对于背景信息,请参阅 NIST 气溶胶粒子尺寸案例研究:www.itl.nist.gov/div898/handbook/pmc/section6/pmc62.htm

在本章中,请参阅设计大量处理类的配方以获取更多关于此数据集的详细信息。与处理原始数据相比,处理包含在 Counter 对象中的摘要信息可能会有所帮助。配方展示了从粒子大小到数量的映射,以及特定大小被测量的次数。

我们想计算这个 Counter 的一些统计数据。我们有两个总体策略来完成这项工作:

  • 扩展:我们在扩展内置集合 – 执行统计的列表的菜谱中详细介绍了这一点,我们将在第八章中查看其他扩展类的例子。

  • 包装:我们可以将 Counter 对象包装在另一个只提供所需功能的类中。我们将在第八章中探讨这一点。

在包装时的一种常见变体是创建一个与数据收集对象分开的统计计算对象。这种包装的变体通常会导致一个优雅的解决方案。

无论我们选择哪种类架构,我们还有两种设计处理的方式:

  • 贪婪:这意味着我们将尽快计算统计值。这是设计具有大量处理的类菜谱中采用的方法。

  • 懒惰:这意味着我们不会在需要之前通过方法函数或属性进行任何计算。在扩展内置集合 – 执行统计的列表的菜谱中,我们向集合类添加了方法。这些额外的方法是惰性计算的例子。统计值仅在需要时才进行计算。

两种设计的基本数学是相同的。唯一的问题是何时进行计算。

7.10.2 如何实现...

  1. 使用描述性的名称定义类:

     class LazyCounterStatistics:
    
  2. 编写初始化方法以包含此对象将要连接的对象。我们定义了一个方法函数,它接受一个 Counter 对象作为参数值。这个 Counter 对象被保存为 Counter_Statistics 实例的一部分:

     def __init__(self, raw_counter: Counter[int]) -> None: 
    
            self.raw_counter = raw_counter
    
  3. 定义一些有用的辅助方法。这些方法中的每一个都用@property 装饰,使其表现得像简单的属性:

     @property 
    
        def sum(self) -> float: 
    
            return sum( 
    
                f * v 
    
                for v, f in self.raw_counter.items() 
    
            ) 
    
        @property 
    
        def count(self) -> float: 
    
            return sum( 
    
                f 
    
                for v, f in self.raw_counter.items() 
    
            )
    
  4. 定义所需的各种值的必要方法。以下是对平均值的计算。这也用@property 装饰。其他方法可以像属性一样引用,尽管它们是正确的方法函数:

     @property 
    
        def mean(self) -> float: 
    
            return self.sum / self.count
    
  5. 这是我们如何计算标准差的方法。注意,我们一直在使用 math.sqrt()。务必在 Python 模块中添加所需的 import math 语句:

     @property 
    
        def sum2(self) -> float: 
    
            return sum( 
    
                f * v ** 2 
    
                for v, f in self.raw_counter.items() 
    
            ) 
    
        @property 
    
        def variance(self) -> float: 
    
          return ( 
    
              (self.sum2 - self.sum ** 2 / self.count) / 
    
              (self.count - 1) 
    
          ) 
    
        @property 
    
        def stddev(self) -> float: 
    
            return math.sqrt(self.variance)
    

为了展示这是如何工作的,我们将这个类的实例应用于一些汇总数据。这本书的代码库包括一个 data/binned.csv 文件,它包含汇总数据的分箱。这些数据有三个列:size_code,size 和 frequency。我们只对 size_code 和 frequency 感兴趣。

这是我们如何从这个文件中构建一个合适的 Counter 对象的方法:

 >>> from pathlib import Path 

>>> import csv 

>>> from collections import Counter 

>>> data_path = Path.cwd() / "data" / "binned.csv" 

>>> with data_path.open() as data_file: 

...     reader = csv.DictReader(data_file) 

...     extract = { 

...         int(row[’size_code’]): int(row[’frequency’]) 

...         for row in reader 

...     } 

>>> data = Counter(extract)

我们使用字典推导来创建从 size_code 到该代码值频率的映射。然后,我们将它提供给 Counter 类,从现有的汇总中构建一个名为 data 的 Counter 对象。

这是我们如何分析 Counter 对象的方法:

 >>> stats = LazyCounterStatistics(data) 

>>> print(f"Mean: {stats.mean:.1f}") 

Mean: 10.4 

>>> print(f"Standard Deviation: {stats.stddev:.2f}") 

Standard Deviation: 4.17

我们提供了数据对象来创建 LazyCounterStatistics 类的实例,即 stats 变量。当我们打印 stats.mean 属性和 stats.stddev 属性的值时,将调用方法来进行适当的值计算。

计算成本只有在客户端对象请求 stats.mean 或 stats.stddev 属性值时才会支付。这将引发一系列计算来计算这些值。

当底层数据发生变化时,整个计算将再次执行。在高度动态数据的罕见情况下,这可能很昂贵。在更常见的情况下,分析先前汇总的数据,这相当高效。

7.10.3 它是如何工作的...

当值很少使用时,懒计算的思路效果很好。在这个例子中,计数在计算方差和标准差时被计算了两次。

在值频繁重新计算的情况下,一个简单的懒设计在某些情况下可能不是最优的。这通常是一个容易解决的问题。我们总是可以创建额外的局部变量来缓存中间结果,而不是重新计算。我们将在本食谱的后面讨论这个问题。

为了使这个类看起来像它已经执行了急切计算,我们使用了@property 装饰器。这使得一个方法看起来像是一个属性。这只能适用于没有参数值的方法。

在所有情况下,一个被急切计算的属性可以被一个懒属性所替代。创建急切属性变量的主要原因是优化计算成本。在计算结果可能不会被总是使用的情况下,懒属性可以避免昂贵的计算。

7.10.4 更多内容...

有一些情况下,我们可以进一步优化一个属性,以限制在值变化时所做的额外计算量。这需要仔细分析使用案例,以便理解底层数据的更新模式。

在数据被加载到集合中并执行分析的情况下,我们可以缓存结果以避免第二次计算。我们可能做如下操作:

 from typing import cast 

class CachingLazyCounterStatistics: 

    def __init__(self, raw_counter: Counter[int]) -> None: 

        self.raw_counter = raw_counter 

        self._sum: float | None = None 

        self._count: float | None = None 

    @property 

    def sum(self) -> float: 

        if self._sum is None: 

            self._sum = sum( 

                f * v 

                for v, f in self.raw_counter.items() 

            ) 

        return self._sum

这种技术使用两个属性来保存求和和计数计算的值,self._sum 和 self._count。这些值将只计算一次,并在需要时返回,无需额外的计算成本。

类型提示显示这些属性是可选的。一旦 self._sum 和 self._count 的值被计算出来,这些值就不再是可选的,但将始终存在。我们用 cast()类型提示向像 mypy 这样的工具描述这一点。这个提示告诉类型检查工具将 self._sum 视为一个 float 对象,而不是 float | None 对象。这个函数没有成本,因为它什么也不做;它的目的是注释处理过程,以显示设计意图。

如果 raw_counter 对象的状态从不改变,这种缓存优化是有帮助的。在一个更新底层 Counter 的应用程序中,这个缓存的值会过时。这种类型的应用程序需要在底层 Counter 更新时重置 self._sum 和 self._count 的内部缓存值。

7.10.5 参见...

  • 在设计具有大量处理的类的配方中,我们定义了一个类,它急切地计算了许多属性。这代表了一种管理计算成本的不同策略。

7.11 创建上下文和上下文管理器

许多 Python 对象表现得像上下文管理器。其中一些最明显的例子是文件对象。我们通常使用 with path.open() as file:来在一个可以保证资源释放的上下文中处理文件。在第二章中,使用 with 语句管理上下文的配方涵盖了使用基于文件的上下文管理器的基础知识。

我们如何创建自己的类,使其充当上下文管理器?

7.11.1 准备工作

我们将在第三章的基于部分函数选择参数顺序配方中查看一个函数。这个配方介绍了一个函数,haversine(),它有一个上下文类似的参数,用于将答案从无量纲弧度调整到有用的单位,如公里、海里或美国英里。在许多方面,这个距离因子是一种上下文,用于定义所进行的计算类型。

我们希望能够使用 with 语句来描述一个变化不快的对象;实际上,这种变化充当了一种边界,定义了计算的范围。我们可能想要使用如下代码:

 >>> with Distance(r=NM) as nm_dist: 

...     print(f"{nm_dist(p1, p2)=:.2f}") 

...     print(f"{nm_dist(p2, p3)=:.2f}") 

nm_dist(p1, p2)=39.72 

nm_dist(p2, p3)=30.74

Distance(r=NM)构造函数提供了上下文的定义,创建了一个新的对象 nm_dist,该对象已配置为以海里为单位执行所需的计算。这只能在 with 语句体中使用。

这个 Distance 类定义可以看作是创建了一个部分函数,nm_dist()。这个函数为使用 haversine()函数的多个后续计算提供了一个固定的单位参数,r。

有许多其他方法可以创建部分函数,包括 lambda 对象、functools.partial()函数和可调用对象。我们在第三章的基于部分函数选择参数顺序配方中探讨了部分函数的替代方案。

7.11.2 如何做...

上下文管理器类有两个特殊方法,我们需要定义:

  1. 从一个有意义的类名开始:

     class Distance:
    
  2. 定义一个初始化器,创建上下文的任何独特功能。在这种情况下,我们想要设置使用的距离单位:

     def __init__(self, r: float) -> None: 
    
            self.r = r
    
  3. 定义 __enter__() 方法。当 with 语句块开始时,会调用此方法。语句 with Distance(r=NM) as nm_dist 执行了两件事。首先,它创建了 Distance 类的实例,然后调用该对象的 __enter__() 方法以启动上下文。__enter__() 方法的返回值通过 as 子句分配给一个局部变量。这并不总是必需的。对于简单情况,上下文管理器通常返回自身。如果此方法需要返回同一类别的实例,请注意类尚未完全定义,必须提供类名类型提示作为字符串。对于这个配方,我们将返回一个函数,类型提示基于 Callable

     def __enter__(self) -> Callable[[Point, Point], float]: 
    
            return self.distance
    
  4. 定义 __exit__() 方法。当上下文结束时,将调用此方法。这是释放资源和进行清理的地方。在这个例子中,不需要做更多的事情。任何异常的详细信息都提供给此方法;方法可以静默异常或允许其传播。如果 __exit__() 方法的返回值为 True,则异常将被静默。返回值 FalseNone 将允许异常在 with 语句外部可见:

     def __exit__( 
    
            self, 
    
            exc_type: type[Exception] | None, 
    
            exc_val: Exception | None, 
    
            exc_tb: TracebackType | None 
    
        ) -> bool | None: 
    
            return None
    
  5. 创建一个类(或定义此类的函数)以在上下文中工作。在这种情况下,该方法将使用第三章中单独定义的 haversine() 函数:

     def distance(self, p1: Point, p2: Point) -> float: 
    
            return haversine( 
    
                p1.lat, p1.lon, p2.lat, p2.lon, R=self.r 
    
            )
    

大多数上下文管理器类需要相当多的导入:

 from collections.abc import Callable 

from types import TracebackType 

from typing import NamedTuple

此类已定义为与 Point 类的对象一起工作。这可以是 NamedTuple@dataclass 或其他提供所需两个属性的类。以下是 NamedTuple 的定义:

 class Point(NamedTuple): 

    lat: float 

    lon: float

此类定义提供了一个名为 Point 的类,具有所需的属性名称。

7.11.3 它是如何工作的...

上下文管理器依赖于 with 语句执行大量操作。

我们将把以下结构放在显微镜下观察:

 >>> p1 = Point(38.9784, -76.4922) 

>>> p2 = Point(36.8443, -76.2922) 

>>> nm_distance = Distance(r=NM) 

>>> with nm_distance as nm_calc: 

...     print(f"{nm_calc(p1, p2)=:.2f}") 

nm_calc(p1, p2)=128.48

第一行创建了 Distance 类的一个实例。该实例的 r 参数值等于常数 NM,这使得我们能够在海里进行计算。Distance 实例被分配给 nm_distance 变量。

with 语句开始执行时,通过执行 __enter__() 方法来通知上下文管理器对象。在这种情况下,__enter__() 方法返回的值是一个函数,类型为 Callable[[Point, Point], float]。该函数接受两个 Point 对象并返回一个浮点结果。as 子句将此函数对象分配给 nm_calc 名称。

print() 函数使用 nm_calc 对象来完成其工作。该对象是一个函数,将从两个 Point 实例计算距离。

当 with 语句结束时,exit() 方法将被执行。对于更复杂的上下文管理器,这可能涉及关闭文件或释放网络连接。可能需要许多种类的上下文清理。在这种情况下,不需要做任何清理上下文的事情。

这有一个优点,就是定义了一个固定边界,在这个边界内使用部分函数。在某些情况下,上下文管理器内部的计算可能涉及数据库或复杂的网络服务,从而导致更复杂的 exit() 方法。

7.11.4 更多内容…

exit() 方法的操作对于充分利用上下文管理器至关重要。在先前的例子中,我们使用了以下“什么都不做”的 exit() 方法:

 def __exit__( 

        self, 

        exc_type: type[Exception] | None, 

        exc_val: Exception | None, 

        exc_tb: TracebackType | None 

    ) -> bool | None: 

        # Cleanup goes here. 

        return None 

这里的问题是允许任何异常正常传播。我们经常看到任何清理处理替换了 # Cleanup goes here. 注释。这就是缓冲区被刷新、文件被关闭和错误日志消息被写入的地方。

有时,我们需要处理特定的异常细节。考虑以下交互会话的片段:

 >>> p1 = Point(38.9784, -76.4922) 

>>> p2 = Point(36.8443, -76.2922) 

>>> with Distance(None) as nm_dist: 

...     print(f"{nm_dist(p1, p2)=:.2f}") 

Traceback (most recent call last): 

... 

TypeError: unsupported operand type(s) for *: ’NoneType’ and ’int’

Distance 对象使用 r 参数值设置为 None 进行初始化。虽然这段代码会导致像 mypy 这样的工具发出警告,但从语法上是有效的。然而,TypeError 的 traceback 并不指向 Distance;它指向 haversine() 函数中的一行代码。

我们可能想报告一个 ValueError 而不是这个 TypeError。下面是 Distance 类的一个变体,它隐藏了 TypeError,用 ValueError 替换它:

 class Distance_2: 

    def __init__(self, r: float) -> None: 

        self.r = r 

    def __enter__(self) -> Callable[[Point, Point], float]: 

        return self.distance 

    def __exit__( 

        self, 

        exc_type: type[Exception] | None, 

        exc_val: Exception | None, 

        exc_tb: TracebackType | None 

    ) -> bool | None: 

        if exc_type is TypeError: 

            raise ValueError(f"Invalid r={self.r!r}") 

        return None 

    def distance(self, p1: Point, p2: Point) -> float: 

        return haversine(p1.lat, p1.lon, p2.lat, p2.lon, R=self.r)

这显示了如何在 exit() 方法中检查异常的详细信息。提供的信息与 sys.exc_info() 函数类似,包括异常的类型、异常对象以及一个具有 types.TracebackType 类型的 traceback 对象。

7.11.5 参见

  • 在第二章的使用 with 语句管理上下文 节中,我们介绍了使用基于文件的上下文管理器的基础知识。

7.12 使用多个资源管理多个上下文

我们经常使用上下文管理器与打开的文件一起使用。因为上下文管理器可以保证操作系统资源被释放,这样做可以防止资源泄漏。它可以用来防止在没有将所有缓冲区刷新到持久存储的情况下关闭文件。

当处理多个资源时,通常意味着需要多个上下文管理器。例如,如果我们有三个打开的文件,我们可能需要三个嵌套的 with 语句?我们如何优化或简化多个 with 语句?

7.12.1 准备工作

我们将查看创建包含多个腿的行程计划。我们的起始数据收集是一个定义我们路线的点列表。例如,穿越切萨皮克湾可能涉及从马里兰州安纳波利斯出发,航行到索尔 omon 岛、弗吉尼亚州的 Deltaville,然后到弗吉尼亚州的诺福克。为了规划目的,我们希望将其视为三条腿,而不是四个点。一条腿有距离,需要时间穿越:计算时间、速度和距离是规划问题的本质。

在运行配方之前,我们将先进行一些基础定义。首先是单个点的定义,具有纬度和经度属性:

 @dataclass(frozen=True) 

class Point: 

    lat: float 

    lon: float

可以使用如下语句构建一个点:p = Point(38.9784, -76.4922)。这让我们可以在后续计算中引用 p.lat 和 p.lon。使用属性名称使代码更容易阅读。

一条腿是一对点。我们可以如下定义它:

 @dataclass 

class Leg: 

    start: Point 

    end: Point 

    distance: float = field(init=False)

我们已将其创建为可变对象。距离属性具有由 dataclasses.field()函数定义的初始值。使用 init=False 表示在初始化对象时不会提供该属性;它必须在初始化后提供。

这是一个上下文管理器,用于从点实例创建 Leg 对象。这与创建上下文和上下文管理器配方中显示的上下文管理器类似。这里有一个微小但重要的区别。init()保存一个值给 self.r 来设置距离单位上下文。默认值是海里:

 from types import TracebackType 

class LegMaker: 

    def __init__(self, r: float=NM) -> None: 

        self.last_point: Point | None = None 

        self.last_leg: Leg | None = None 

        self.r = r 

    def __enter__(self) -> "LegMaker": 

        return self 

    def __exit__( 

        self, 

        exc_type: type[Exception] | None, 

        exc_val: Exception | None, 

        exc_tb: TracebackType | None 

    ) -> bool | None: 

        return None

重要的方法 waypoint()接受一个航点并创建一个 Leg 对象。第一个航点,即航行的起点,将返回 None。所有后续的点将返回一个 Leg 对象:

 def waypoint(self, next_point: Point) -> Leg | None: 

        leg: Leg | None 

        if self.last_point is None: 

            # Special case for the first leg 

            self.last_point = next_point 

            leg = None 

        else: 

            leg = Leg(self.last_point, next_point) 

            d = haversine( 

                leg.start.lat, leg.start.lon, 

                leg.end.lat, leg.end.lon, 

                R=self.r 

            ) 

            leg.distance = round(d) 

            self.last_point = next_point 

        return leg

此方法使用缓存的点对象 self.last_point 和下一个点 next_point 来创建一个 Leg 实例,然后更新该实例。

如果我们想在 CSV 格式中创建输出文件,我们需要使用两个上下文管理器:一个用于创建 Leg 对象,另一个用于管理打开的文件。我们将把这个复杂的多上下文处理放入一个单独的函数中。

7.12.2 如何做到这一点...

  1. 我们将使用 csv 和 pathlib 模块。此外,此配方还将使用 Iterable 类型提示和 dataclasses 模块中的 asdict 函数:

     from collections.abc import Iterable 
    
    import csv 
    
    from dataclasses import asdict 
    
    from pathlib import Path
    
  2. 由于我们将创建 CSV 文件,我们需要定义用于 CSV 输出的标题:

     HEADERS = ["start_lat", "start_lon", "end_lat", "end_lon", "distance"]
    
  3. 定义一个函数,将复杂对象转换为适合写入每一行数据的字典。输入是一个 Leg 对象;输出是一个具有与 HEADERS 列表中列名匹配的键的字典:

     def flat_dict(leg: Leg) -> dict[str, float]: 
    
        struct = asdict(leg) 
    
        return dict( 
    
            start_lat=struct["start"]["lat"], 
    
            start_lon=struct["start"]["lon"], 
    
            end_lat=struct["end"]["lat"], 
    
            end_lon=struct["end"]["lon"], 
    
            distance=struct["distance"], 
    
        )
    
  4. 定义一个具有意义名称的函数。我们将提供两个参数:一个点对象列表和一个 Path 对象,显示 CSV 文件应该创建的位置。我们已使用 Iterable[Point]作为类型提示,因此此函数可以接受任何可迭代的点实例集合:

     def make_route_file( 
    
        points: Iterable[Point], target: Path 
    
    ) -> None:
    
  5. 使用单个 with 语句启动两个上下文。这将调用两个 enter()方法来为工作准备两个上下文。这一行可能会很长:

     with ( 
    
            LegMaker(r=NM) as legger, 
    
            target.open(’w’, newline=’’) as csv_file 
    
        ):
    
  6. 一旦上下文准备好工作,我们可以创建一个 CSV 写入器并开始写入行:

     writer = csv.DictWriter(csv_file, HEADERS) 
    
            writer.writeheader() 
    
            for point in points: 
    
                leg = legger.waypoint(point) 
    
                if leg is not None: 
    
                    writer.writerow(flat_dict(leg))
    
  7. 在上下文结束时,进行任何最终的汇总处理。这不是在 with 语句体的缩进内;它与 with 关键字本身的缩进级别相同:

     print(f"Finished creating {target}")
    

    通过将此消息放在 with 上下文之外,它提供了重要的证据,表明文件已正确关闭,所有的计算都已完成。

7.12.3 它是如何工作的...

复合的 with 语句为我们创建了一系列上下文管理器。所有的管理器都将使用它们的 enter()方法来开始处理,并且可选地返回一个可以在上下文中使用的对象。LegMaker 类定义了一个返回 LegMaker 实例的 enter()方法。Path.open()方法返回一个 TextIO 对象;这些也是上下文管理器。

当 with 语句结束时上下文退出,将调用所有上下文管理器的 exit()方法。这允许每个上下文管理器执行任何最终的清理。在 TextIO 对象的情况下,这将关闭外部文件,释放正在使用的任何 OS 资源。

在 LegMaker 对象的情况下,上下文退出时没有进行最终的清理处理。创建了一个 LegMaker 对象;从 enter()方法返回的值是这个对象方法的引用。legger 可调用对象将继续在上下文外部正确运行。这是一个特殊的情况,在没有在 exit()方法中进行清理的情况下发生。如果需要防止进一步使用 legger 可调用对象,那么 exit()方法需要在 LegMaker 对象内部进行显式的状态改变,以便抛出异常。一种方法是在 exit()方法中将 self.r 值设置为 None,这将防止进一步使用 waypoint()方法。

7.12.4 更多内容...

上下文管理器的任务是隔离资源管理的细节。最常见的情况是文件和网络连接。我们已经展示了在算法周围使用上下文管理器来帮助管理带有单个 Point 对象的缓存。

当处理非常大的数据集时,使用压缩通常很有帮助。这可以在处理周围创建不同的上下文。内置的 open()方法通常在 io 模块中分配给 io.open()函数。这意味着我们通常可以用 bz2.open()这样的函数替换 io.open()来处理压缩文件。

我们可以用类似这样的事物替换一个未压缩的文件上下文管理器:

 import bz2 

def make_route_bz2(points: Iterable[Point], target: Path) -> None: 

    with ( 

        LegMaker(r=NM) as legger, 

        bz2.open(target, "wt") as archive 

    ): 

        writer = csv.DictWriter(archive, HEADERS) 

        writer.writeheader() 

        for point in points: 

            leg = legger.waypoint(point) 

            if leg is not None: 

                writer.writerow(flat_dict(leg)) 

    print(f"Finished creating {target}")

我们已经用 bz2.open(path)替换了原始的 path.open()方法。其余的上下文处理保持不变。这种灵活性允许我们在数据量增长时,最初处理文本文件,然后将其转换为压缩文件。

7.12.5 参见

  • 在第二章的使用 with 语句管理上下文 菜单中,我们介绍了基于文件上下文管理器的使用基础。

  • 创建上下文和上下文管理器 菜单涵盖了创建一个上下文管理器类的核心内容。

加入我们的社区 Discord 空间

加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

PIC

第八章:8

更高级的类设计

在第七章中,我们探讨了涵盖类设计基础的一些菜谱。在这一章中,我们将更深入地探讨 Python 类和类设计。

在第七章的 设计大量处理的类 和 使用属性实现懒属性 菜谱中,我们确定了一个面向对象编程的核心设计选择,“封装与扩展”决策。添加功能的一种方式是通过扩展创建一个新的子类。添加功能的另一种技术是封装一个现有的类,使其成为新类的一部分。

除了直接继承之外,Python 中还有一些其他类扩展技术可用。一个 Python 类可以从多个超类继承功能。我们称这种设计模式为混入(mixin)。

在第四章和第五章中,我们探讨了核心内置数据结构。我们可以结合和扩展这些集合定义功能,以创建更复杂的数据结构或具有附加功能的数据结构。

在这一章中,我们将探讨以下菜谱:

  • 在继承和组合之间选择 – “是”问题

  • 通过多重继承分离关注点

  • 利用 Python 的鸭子类型

  • 管理全局和单例对象

  • 使用更复杂的结构 – 列表映射

  • 创建具有可排序对象的类

  • 从复杂对象的列表中删除

Python 中有大量的面向对象类设计技术。我们将从一个基础设计概念开始:在从基类继承和使用封装来扩展之间做出设计选择。

8.1 在继承和组合之间选择 – “是”问题

在第六章的 使用 cmd 创建命令行应用程序 菜谱和第七章的 扩展内置集合 – 执行统计的列表 菜谱中,我们探讨了类的扩展。在这两种情况下,菜谱中实现的类都是 Python 内置类的一个子类。

通过继承进行扩展的想法有时被称为泛化-特殊化关系。它也可以称为“是”关系。有一个重要的语义问题:

  • 我们的意思是子类的实例也是超类的实例吗?这是一个“是”关系,是继承的一个例子,其中我们扩展了一个类,改变了功能的实现细节。

  • 或者我们的意思是不是别的?可能是指组合或关联,有时称为“有”关系。在这种情况下,我们可能包装另一个类,添加或删除功能。

SOLID 设计原则之一,Liskov 替换原则要求任何子类都应该是超类的一个适当替代。我们将探讨创建现有类新功能的继承和组合技术。

8.1.1 准备工作

对于这个食谱,我们将使用扑克牌的模型作为具体的例子。我们将探讨几种设计集合的方法。

两种实现的核心成分是底层的 Card 对象。我们可以使用 NamedTuple 来定义它:

 from typing import NamedTuple 

class Card(NamedTuple): 

    rank: int 

    suit: str 

Spades, Hearts, Diamonds, Clubs = (’\u2660’, ’\u2661’, ’\u2662’, ’\u2663’)

在本食谱的其余部分,我们将使用这个 Card 类。重要的是表示一副牌或牌手的各种集合类型;它们在支持的功能类型上都有相当大的重叠。

我们有几个常见的集合模式:

  • 聚合:某些对象被绑定到集合中,但对象有适当的独立存在。虽然牌对象可以被聚合到牌手集合中,但当牌手对象被删除时,牌对象仍然存在。

  • 组合:集合中的某些对象没有独立的存在。一副牌手不能没有玩家而存在。当一个玩家实例离开游戏时,牌手对象也必须被移除。

  • 继承(也称为“是一个”关系):这是指一副牌是一个具有一些额外功能的牌手。我们可以扩展内置的类如列表来实现这一点。

在设计数据库时,区分聚合和组合非常重要,因为此时关注的是对象的持久性。在 Python 中,这种区分是一个小的细微差别。普通的 Python 内存管理会保留由集合或变量仍然引用的对象。我们将两者都视为组合的例子。

一旦理解了关系,就有两条不同的路径:组合或聚合或继承和扩展。

8.1.2 如何实现...

这个食谱有两个独立的迷你食谱:聚合和继承。

组合或聚合

在另一个类的实例变量中包装集合对象有两种常见的变体,有时称为组合和聚合。在 Python 中,这种细微的差别并不重要。以下是使用组合设计集合的方法:

  1. 定义集合类。

    为了区分本书中的类似例子,名称带有“_W”后缀以表明它是一个包装器。这不是一个普遍推荐的做法;它只在这里用来强调本食谱中类定义之间的区别。

    这是类的定义:

     class Deck_W:
    
  2. 使用这个类的__init__()方法作为提供底层集合对象的一种方式。这也会初始化任何有状态的变量。我们可能创建一个用于发牌的迭代器:

     def __init__(self, cards: list[Card]) -> None: 
    
            self.cards = cards 
    
            self.deal_iter = iter(self.cards)
    

    这使用类型提示 list[Card]来显示将要包装的源集合。

  3. 为聚合对象提供适当的方法。shuffle()方法随机化内部列表对象。它还创建了一个迭代器,由 deal()方法用来遍历列表。我们在 deal()上提供了类型提示,以明确它返回 Card 实例:

     def shuffle(self) -> None: 
    
            random.shuffle(self.cards) 
    
            self.deal_iter = iter(self.cards) 
    
        def deal(self) -> Card: 
    
            return next(self.deal_iter)
    

这是我们如何使用 Deck_W 类的方法。我们将使用 Card 对象列表。在这种情况下,domain 变量是由生成所有 52 种 13 个花色和四种花色的组合的列表推导式创建的:

 >>> domain = list( 

...     Card(r+1,s) 

...         for r in range(13) 

...             for s in (Spades, Hearts, Diamonds, Clubs) 

... ) 

>>> len(domain) 

52

我们可以使用这个集合中的项目,domain,来创建一个共享相同底层 Card 对象的第二个聚合对象。我们将从 Card 对象列表构建 Deck_W 对象:

 >>> d = Deck_W(domain) 

>>> import random 

>>> random.seed(1) 

>>> d.shuffle() 

>>> [d.deal() for _ in range(5)] 

[Card(rank=13, suit=’’), Card(rank=3, suit=’’), Card(rank=10, suit=’’), Card(rank=6, suit=’’), Card(rank=1, suit=’’)]

继承和扩展

这里是定义扩展内置对象集合的类的做法:

  1. 首先定义扩展类为内置集合的子类。为了区分本书中的类似示例,名称有一个 _X 后缀。子类关系是一个正式声明——Deck_X 实例也是一种列表。以下是类定义:

     class Deck_X(list[Card]):
    
  2. 初始化实例不需要额外的代码,因为我们将从列表类继承 init()方法。

  3. 更新牌组不需要额外的代码,因为我们将在 Deck_X 实例中添加、更改或删除项目时使用列表类的其他方法。

  4. 向扩展对象提供适当的新功能。shuffle()方法随机化整个对象。这里的集合是 self,因为此方法是列表类的一个扩展。deal()对象依赖于 shuffle()方法创建的迭代器来遍历列表,返回 Card 实例:

     def shuffle(self) -> None: 
    
            random.shuffle(self) 
    
            self.deal_iter = iter(self) 
    
        def deal(self) -> Card: 
    
            return next(self.deal_iter)
    

这是我们如何使用 Deck_X 类的方法。首先,我们将构建一副牌:

 >>> dx = Deck_X( 

... Card(r+1,s) 

...     for r in range(13) 

...         for s in (Spades, Hearts, Diamonds, Clubs) 

... ) 

>>> len(dx) 

52

在 Deck_X 实现中仅使用特定的牌组功能看起来与 Deck_W 实现完全相同:

 >>> import random 

>>> random.seed(1) 

>>> dx.shuffle() 

>>> [dx.deal() for _ in range(5)] 

[Card(rank=13, suit=’’), Card(rank=3, suit=’’), Card(rank=10, suit=’’), Card(rank=6, suit=’’), Card(rank=1, suit=’’)]

正如我们在下面的 There’s more...中将要看到的,因为 Deck_X 是列表,它具有列表对象的所有方法。当为他人设计框架时,这可能是一个坏主意。当设计应用程序时,很容易避免使用额外的功能。

8.1.3 它是如何工作的...

Python 通过一个聪明的搜索算法实现继承的概念,用于查找对象类的(方法)和属性。搜索过程如下:

  1. 检查对象的类以获取方法或属性名称。

  2. 如果在直接类中未定义名称,则将在所有父类中搜索该方法或属性。方法解析顺序(MRO)定义了搜索这些类的顺序。

通过遍历父类确保两件事:

  • 任何超类中定义的所有方法都对子类可用。

  • 任何子类都可以覆盖方法以替换超类方法。super()函数搜索父类以查找被覆盖的定义。

由于这个原因,列表类的子类继承了父类的所有特性。它是内置列表类的一个特殊扩展。

这也意味着所有方法都有可能被子类覆盖。一些语言有锁定方法以防止扩展的方法。由于 Python 没有这个限制,子类可以覆盖任何方法。

super() 函数允许子类通过包装超类方法版本来添加功能。使用它的一个方法如下:

 class SomeClass(Parent): 

    def some_method(self) -> None: 

        # do something extra 

        super().some_method()

在这种情况下,类的 some_method() 方法将执行一些额外操作,然后使用该方法超类版本。这为我们提供了一种方便的方式来扩展类的选定方法。我们可以在添加子类特有的功能的同时保留超类的特性。

8.1.4 更多内容...

在 Deck_W 和 Deck_X 这两个定义之间有一些巨大的差异。在包装时,我们得到我们定义的确切方法而没有其他方法。在使用继承时,我们从超类接收大量方法定义。这导致 Deck_X 类中一些可能不受欢迎的额外行为:

  • 我们可以使用各种集合作为创建 Deck_X 实例的来源。这是因为列表类具有将 Python 集合转换为列表的许多特性。Deck_W 类仅适用于提供 shuffle() 方法隐式所需方法的序列。此外,list[Card] 的类型提示将导致像 mypy 这样的程序在使用其他来源集合时引发错误。

  • Deck_X 实例可以在 deal() 方法支持的核心理解迭代之外进行切片和索引。

  • 由于 Deck_X 类是列表,它也可以直接与 iter() 函数一起使用;它可以在不使用 deal() 方法的情况下作为 Card 对象的可迭代来源。

这些差异也是决定使用哪种技术的重要部分。如果额外的功能是可取的,那么这表明继承是合适的。如果额外的功能造成问题,那么组合可能是一个更好的选择。

8.1.5 参见

  • 我们已经在第四章中探讨了内置集合。在第七章中,我们也探讨了如何定义简单的集合。

  • 在 设计具有大量处理的类 的配方中,我们探讨了使用一个处理处理细节的单独类来包装一个类。我们可以将其与第七章的 使用属性进行懒加载属性 配方进行对比,在那里我们将复杂的计算作为属性放入类中;这种设计依赖于扩展。

8.2 通过多重继承分离关注点

在本章前面的 在继承和组合之间选择 – “是”问题 配方中,我们探讨了定义一个 Deck 类的想法,该类是玩牌对象的组合。为了这个示例的目的,我们将每个 Card 对象视为仅具有等级和花色属性。这造成了两个小问题:

  • 卡片的显示总是显示数字等级。我们没有看到 J、Q 或 K。相反,我们看到 11、12 和 13。同样,A 牌显示为 1 而不是 A。

  • 许多游戏,如克里比奇,为每个等级分配点值。通常,面牌有 10 点。剩余的牌的点数与它们的等级相匹配。

Python 的多重继承让我们在保持单个基本 Card 类的同时处理卡牌游戏规则的变化。使用多重继承让我们将特定游戏的规则与通用牌的特性分开。我们可以将基类定义与提供所需功能的混合类组合起来。

Python 的多重继承依赖于一个称为 C3 的巧妙算法,用于将各种父类解析为单个列表,以有用的顺序。当我们组合多个类时,它们将具有共同的父类,现在有多个引用。C3 算法创建了一个尊重所有父子关系的线性列表。

8.2.1 准备工作

对 Card 类的实际扩展需要是两个特性集的混合。Python 允许我们定义一个包含多个父类特性的类。这个模式有两个部分:

  1. 核心特性:这是等级和花色。这还包括一个方法,以字符串形式优雅地显示 Card 对象的值,使用“J”、“Q”和“K”表示宫廷牌,以及“A”表示 A 牌。

  2. 混合特性:这些都是不那么重要的、特定于游戏的特性,例如分配给每张特定牌的点数。

工作应用程序依赖于从基本特性和混合特性构建的特性组合。

8.2.2 如何操作...

此配方将创建两个类层次结构,一个用于基本的 Card 类,另一个用于特定游戏的特性,包括克里比奇点值:

  1. 定义基本类。这是一个通用的 Card 类,适用于 2 到 10 的等级:

     @dataclass(frozen=True) 
    
    class Card: 
    
        """Superclass for cards""" 
    
        rank: int 
    
        suit: str 
    
        def __str__(self) -> str: 
    
            return f"{self.rank:2d} {self.suit}"
    
  2. 定义子类以实现特殊化。我们需要 Card 类的两个子类——AceCard 类和 FaceCard 类,如下面的代码所示:

     class AceCard(Card): 
    
        def __str__(self) -> str: 
    
            return f" A {self.suit}" 
    
    class FaceCard(Card): 
    
        def __str__(self) -> str: 
    
            names = {11: "J", 12: "Q", 13: "K"} 
    
            return f" {names[self.rank]} {self.suit}"
    

    每个类都覆盖了内置的 __str__() 方法以提供不同的行为。

  3. 定义混合类所需的核心特性。使用 typing.Protocol 超类以确保各种实现都提供了所需的功能。协议需要 rank 属性,它将在基本类中定义。混合类中将定义一个 points() 方法。以下是它的样子:

     from typing import Protocol 
    
    class PointedCard(Protocol): 
    
        rank: int 
    
        def points(self) -> int: 
    
            ...
    

    当编写类型提示类时,主体可以是 ... 因为这将由像 mypy 这样的工具忽略。

  4. 定义混合子类以添加额外的功能。对于 Cribbage 游戏,某些牌的点数等于牌的等级,面牌是 10 点:

     class CribbagePoints(PointedCard): 
    
        def points(self) -> int: 
    
            return self.rank
    
     class CribbageFacePoints(PointedCard): 
    
        def points(self) -> int: 
    
            return 10
    
  5. 创建最终的实体类定义以组合基本类和所有所需的混合类:

     class CribbageCard(Card, CribbagePoints): 
    
        pass 
    
    class CribbageAce(AceCard, CribbagePoints): 
    
        pass 
    
    class CribbageFace(FaceCard, CribbageFacePoints): 
    
        pass
    

    注意,CribbagePoints 混合类被用于 Card 和 AceCard 类,这使得我们可以重用代码。

  6. 定义一个函数(或类)根据输入参数创建适当的对象。这通常被称为工厂函数或工厂类。被创建的对象都将被视为 Card 类的子类,因为它是基类列表中的第一个:

     def make_cribbage_card(rank: int, suit: str) -> Card: 
    
        if rank == 1: 
    
            return CribbageAce(rank, suit) 
    
        elif 2 <= rank < 11: 
    
            return CribbageCard(rank, suit) 
    
        elif 11 <= rank: 
    
            return CribbageFace(rank, suit) 
    
        else: 
    
            raise ValueError(f"invalid rank {rank}")
    

我们可以使用 make_cribbage_card() 函数创建一副洗好的牌,如下面的示例交互会话所示:

 >>> import random 

>>> random.seed(1) 

>>> deck = [make_cribbage_card(rank+1, suit) for rank in range(13) for suit in  SUITS] 

>>> random.shuffle(deck) 

>>> len(deck) 

52 

>>> [str(c) for c in deck[:5]] 

[’ K ’, ’ 3 ’, ’10 ’, ’ 6 ’, ’ A ’] 

我们可以评估每个 Card 对象的 points() 方法:

 >>> sum(c.points() for c in deck[:5]) 

30

手中有两张面牌,加上 3、6 和 A,所以总分数是 30。

8.2.3 它是如何工作的...

Python 查找对象方法(或属性)的机制如下:

  1. 在实例中搜索该属性。

  2. 在类中搜索该方法或属性。

  3. 如果在直接类中未定义名称,则会在所有父类中搜索该方法或属性。父类是按照称为方法解析顺序(Method Resolution Order,MRO)的顺序搜索的。

我们可以使用类的 mro() 方法显示 MRO。以下是一个示例:

 >>> c.__class__.__name__ 

’CribbageCard’ 

>>> from pprint import pprint 

>>> pprint(c.__class__.mro()) 

[<class ’recipe_02.CribbageCard’>, 

 <class ’recipe_02.Card’>, 

 <class ’recipe_02.CribbagePoints’>, 

 <class ’recipe_02.PointedCard’>, 

 <class ’typing.Protocol’>, 

 <class ’typing.Generic’>, 

 <class ’object’>]

CribbageCard 类的 mro() 方法显示了用于解析名称的顺序。因为类对象使用内部字典来存储方法定义,所以搜索是极快的基于哈希的属性名称查找。

8.2.4 更多...

我们可以将几种设计关注点以混合类的形式分离出来:

  • 持久性和状态表示:一个混合类可以添加方法来管理转换为一致的 CSV 或 JSON 表示法等外部表示。

  • 安全性:一个混合类可以添加执行一致授权检查的方法,这些检查适用于多个基类。

  • 日志记录:一个混合类可以引入一个具有跨各种类定义一致的 logger。

  • 事件信号和变更通知:一个混合类可能会报告对象状态的变化,以便一个或多个 GUI 小部件可以刷新显示。

例如,我们将创建一个混合类来向牌引入日志记录。我们将以必须首先在超类列表中提供的方式定义此类。由于它在 MRO 列表中较早,因此它使用 super() 函数来使用 MRO 列表中后续类定义的方法。

此类将为具有 PointedCard 协议定义的每个对象添加 logger 属性:

 import logging 

class Logged(Card, PointedCard): 

    def __init__(self, rank: int, suit: str) -> None: 

        self.logger = logging.getLogger(self.__class__.__name__) 

        super().__init__(rank, suit) 

    def points(self) -> int: 

        p = super().points()  # type: ignore [safe-super] 

        self.logger.debug("points {0}", p) 

        return p

注意,我们使用了 super().init()来执行任何其他类定义的 init()方法。这些初始化的顺序来自类 MRO。最简单的方法是有一个定义对象基本特征的类,而所有其他混入则通过向基本对象添加额外方法的形式添加功能。

我们为 points()提供了覆盖定义。这将搜索 MRO 列表中的其他类以查找 points()方法的实现。然后它将记录由另一个混入类中的方法计算的结果。

type: ignore [safe-super]注释是给像 mypy 这样的严格类型检查工具的提示。当我们查看 PointedCard 协议的定义时,没有为这个方法提供定义。从工具对类层次结构的检查来看,调用 super().points()可能是不安全的。我们确信在实际情况中不会发生这种情况,因为混入将始终存在以定义 points()方法。我们将 super()的不安全使用标记为要忽略的错误。

这里有一些包含 Logged 混入功能的类:

 class LoggedCribbageAce(Logged, AceCard, CribbagePoints): 

    pass 

class LoggedCribbageCard(Logged, Card, CribbagePoints): 

    pass 

class LoggedCribbageFace(Logged, FaceCard, CribbageFacePoints): 

    pass

这些类都是基于三个独立的类定义构建的。由于 Logged 类首先提供,我们确保所有类都有一致的日志记录。我们还确保 Logged 中的任何方法都可以使用 super()来定位在类定义中跟随其后的类列表中的实现。

要使用这些类,我们需要定义一个 make_logged_card()函数来使用这些新类。

8.2.5 参见

  • 当类创建时计算方法解析顺序。使用的算法称为 C3。这个过程最初是为 Dylan 语言开发的,现在也被 Python 使用。C3 算法确保每个父类只被搜索一次。它还确保了超类之间的相对顺序被保留;子类将在检查任何父类之前被搜索。更多信息请参阅dl.acm.org/doi/10.1145/236337.236343

  • 在考虑多重继承时,始终需要考虑是否将包装器作为比子类更好的设计。参见在继承和组合之间选择 – “是”问题配方。

8.3 利用 Python 的鸭子类型

当设计涉及继承时,通常从超类到一个或多个子类之间存在明确的关系。在本章的在继承和组合之间选择 – “是”问题配方以及第七章中的扩展内置集合 – 做统计的列表配方中,我们查看涉及适当的子类-超类关系的扩展。

为了有可以相互替换使用的类(“多态类”),某些语言要求有一个共同的超类。在许多情况下,共同类并没有对所有方法进行具体实现;它被称为抽象超类。

Python 不需要共同的超类。标准库提供了 abc 模块,以支持在有助于阐明类之间关系的情况下创建抽象类。

与使用具有共同超类的多态类相比,Python 依赖于鸭子类型来建立等价性。这个名字来源于以下引言:

当我看到一只既像鸭子走路又像鸭子游泳还像鸭子嘎嘎叫的鸟时,我就称那只鸟为鸭子。

(詹姆斯·惠特科姆·赖利)

在 Python 类关系的情况下,如果两个对象具有相同的方法和属性,这些相似之处与具有共同超类具有相同的效果。不需要对共同类进行正式的定义。

这个配方将展示如何利用鸭子类型的概念来创建多态类。这些类的实例可以相互替换,使我们能够拥有更灵活的设计。

8.3.1 准备工作

在某些情况下,为多个松散相关的实现选择定义一个超类可能会有些尴尬。例如,如果一个应用程序分布在几个模块中,可能很难提取一个共同的超类并将其单独放在一个模块中,以便在其他模块中导入。与其提取一个共同的抽象,有时创建将通过“鸭子测试”的类更容易:各种类具有相同的方法和属性;因此,它们实际上是可互换的,是多态类。

8.3.2 如何实现...

我们将定义一对类来展示这是如何工作的。这两个类都将模拟掷两个骰子的过程。我们将创建两个具有足够共同特征的独立实现,这样它们就可以互换使用:

  1. 从一个具有所需方法和属性的类开始,Dice1。在这个例子中,我们将有一个属性,dice,它保留上一次掷骰子的结果,以及一个方法,roll(),它改变骰子的状态:

     import random 
    
    class Dice1: 
    
        def __init__(self, seed: int | None = None) -> None: 
    
            self._rng = random.Random(seed) 
    
            self.roll() 
    
        def roll(self) -> tuple[int, ...]: 
    
            self.dice = ( 
    
                self._rng.randint(1, 6), 
    
                self._rng.randint(1, 6)) 
    
            return self.dice
    
  2. 定义另一个类,Dice2,具有相同的方法和属性。这里是一个稍微复杂一些的定义,它创建了一个与 Dice1 类具有相同签名的类:

     import random 
    
    class Die: 
    
        def __init__(self, rng: random.Random) -> None: 
    
            self._rng = rng 
    
        def roll(self) -> int: 
    
            return self._rng.randint(1, 6) 
    
    class Dice2: 
    
        def __init__(self, seed: int | None = None) -> None: 
    
            self._rng = random.Random(seed) 
    
            self._dice = [Die(self._rng) for _ in range(2)] 
    
            self.roll() 
    
        def roll(self) -> tuple[int, ...]: 
    
            self.dice = tuple(d.roll() for d in self._dice) 
    
            return self.dice
    

到目前为止,两个类,Dice1 和 Dice2,可以自由互换。这里是一个接受任一类作为参数的函数,创建一个实例,并产生几个掷骰子的结果:

 from collections.abc import Iterator 

def roller( 

    dice_class: type[Dice1 | Dice2], 

    seed: int | None = None, 

    *, 

    samples: int = 10 

) -> Iterator[tuple[int, ...]]: 

    dice = dice_class(seed) 

    for _ in range(samples): 

        yield dice.roll()

我们可以使用 Dice1 类或 Dice2 类作为 dice 参数值的函数。type[Dice1 | Dice2]类型提示指定了多个等效类的联合。此函数在 dice 参数中创建给定类的实例,甚至可以提供种子值。使用已知的种子可以创建可重复的结果,这对于单元测试通常是必需的,也用于重现涉及随机选择的统计研究。

以下交互会话显示了 roller()函数被应用于两个类:

 >>> list(roller(Dice1, 1, samples=5)) 

[(1, 3), (1, 4), (4, 4), (6, 4), (2, 1)] 

>>> list(roller(Dice2, 1, samples=5)) 

[(1, 3), (1, 4), (4, 4), (6, 4), (2, 1)]

由 Dice1 和 Dice2 构建的对象有足够的相似性,以至于它们无法区分。

8.3.3 它是如何工作的...

我们创建了两个具有相同属性和方法集合的类。这是鸭子类型的核心。由于 Python 通过遍历字典序列来搜索匹配的名称,因此类不需要有共同的超类就可以互换。

定义相关类的联合可能会有所帮助。另一个选择是定义一个类都遵守的通用协议。对于每个类来说,虽然不是必须显式地从协议定义中继承,但这样做可以使读者更清晰地理解。像 mypy 这样的工具可以判断一个类是否符合协议,这正是鸭子类型的工作方式。

8.3.4 更多内容...

在 roller()函数的定义中,我们使用了以下类型提示:dice: type[Dice1 | Dice2]。

使用如下代码可以使这种做法更明确:

Dice = Dice1 | Dice2

这可以很容易地扩展,因为当添加新的替代定义时。客户端类可以使用 type[Dice]来引用替代方案的联合。

另一个选择是定义一个协议。协议定义了一个具有仅共享各种实现共同特征的通用类型:

 class DiceP(Protocol): 

    def roll(self) -> tuple[int, ...]: 

        ...

这有助于在开发过程的后期创建类型提示。在创建替代实现之后,很容易定义一个联合各种选择的类型。如果出现更多实现,它们可以被添加到联合中。

协议允许更容易地扩展替代方案。协议仅定义了实现的有关特征。这通常是通过重构客户端方法签名的属性来完成的,使其引用协议类。

8.3.5 参见

  • 鸭子类型问题在在继承和组合之间选择 – “是”问题配方中是隐含的;如果我们利用鸭子类型,我们也在声称两个类不是同一件事。当我们绕过继承时,我们隐含地声称 is-a 关系不成立。

  • 当查看通过多重继承分离关注点配方时,我们也能够利用鸭子类型来创建可能没有简单继承层次结构的复合类。

8.4 管理全局和单例对象

Python 环境包含许多隐式全局对象。这些对象提供了一种方便的方式来处理其他对象的集合。因为集合是隐式的,所以我们避免了显式初始化代码的烦恼。

这的一个例子是 random 模块中的隐式随机数生成对象。当我们评估 random.random() 时,我们实际上是在使用 random.Random 类的一个实例。

因为一个模块只导入一次,所以模块实现了 Singleton 设计模式。我们可以依赖这项技术来实现这些全局单例。

其他例子包括以下内容:

  • 可用的数据编码器和解码器(编解码器)集合。编解码器模块有一个编码器和解码器的注册表。我们可以向这个注册表中添加编码和解码。

  • webbrowser 模块有一个已知浏览器的注册表。

  • numbers 模块有一个数字数据类型的注册表。这允许模块定义一个新的数字类型实现并将其添加到已知类型的混合中。

  • logging 模块维护一个命名记录器的集合。getLogger() 函数试图找到一个现有的记录器;如果需要,它会创建一个新的记录器。

  • re 模块有一个编译正则表达式的缓存。这节省了重新编译在方法或函数内部定义的正则表达式的时间。

本食谱将展示如何处理像用于编解码器、浏览器和数字类的注册表这样的隐式全局对象。

8.4.1 准备工作

一组函数都可以与由模块创建的隐式全局对象一起工作。好处是允许其他模块共享一个公共对象,而无需编写任何显式协调共享的代码。

这可能会让阅读你代码的人感到困惑。

共享全局状态的想法可能会变成一个设计噩梦。进一步使共享对象变得隐式可能会使问题更加复杂。

观察 Python 标准库中的示例,有两个重要的模式。首先,有一个狭窄的焦点。其次,注册表的更新仅限于添加新实例。

例如,我们将定义一个包含全局单例对象的模块。我们将在第十三章中更详细地讨论模块。

我们的全球对象将是一个计数器,我们可以用它来累积来自几个独立模块或对象的集中化数据。我们将使用这个全局对象来计数应用程序中的事件。计数提供了已完成工作的摘要,并检查以确认所有工作都已完成。

目标是能够编写如下内容:

 for row in source: 

    count(’input’) 

    some_processing() 

print(counts())

some_processing() 函数可能会使用类似 count('reject') 来计算被拒绝的输入行数。这个函数可能会调用其他也使用 count() 函数来记录处理证据的函数。

这两个独立函数都引用了一个共享的全局计数器:

  • count(key) 增加一个全局 Counter,并返回给定键的当前值。

  • counts() 提供所有的计数器值。

8.4.2 如何实现...

处理全局状态信息有两种常见的方式:

  • 使用模块全局变量,因为模块是单例对象。

  • 使用类级别的变量(在某些编程语言中称为静态)。在 Python 中,类定义也是一个可以共享的单例对象。

我们将单独介绍这些小技巧,从模块全局变量开始。

模块全局变量

我们可以这样做来创建一个对模块全局的变量:

  1. 创建一个模块文件。这将是一个包含定义的 .py 文件。我们将它命名为 counter.py。

  2. 如果需要,定义一个全局单例类的类。在我们的例子中,我们可以使用这个定义来创建一个 collections.Counter 对象:

     from collections import Counter
    
  3. 定义全局单例对象的唯一实例。我们在名称前使用了前导下划线使其稍微不那么显眼。它——技术上——不是私有的。然而,它被许多 Python 工具和实用程序优雅地忽略:

     _global_counter: Counter[str] = Counter()
    

    标记全局变量的常见习惯是使用全大写的名称。这似乎对被视为常量的全局变量更重要。在这种情况下,这个变量将被更新,使用全大写名称似乎有些误导。

  4. 定义两个函数以使用全局对象 _global_counter。这些函数封装了计数器实现的细节:

     def count(key: str, increment: int = 1) -> None: 
    
        _global_counter[key] += increment 
    
    def counts() -> list[tuple[str, int]]: 
    
        return _global_counter.most_common() 
    

现在,我们可以编写在多个地方使用 count() 函数的应用程序。然而,计数的事件完全集中在一个对象中,该对象作为模块的一部分定义。

我们可能有这样的代码:

 >>> from counter import * 

>>> from recipe_03 import Dice1 

>>> d = Dice1(1) 

>>> for _ in range(1000): 

...     if sum(d.roll()) == 7: 

...         count(’seven’) 

...     else: 

...         count(’other’) 

>>> print(counts()) 

[(’other’, 833), (’seven’, 167)]

我们已从 counter 模块中导入了 count()counts() 函数。我们还导入了 Dice1 类作为一个方便的对象,用于创建事件序列。当我们创建 Dice1 的实例时,我们提供一个初始化来强制特定的随机种子。这给出了可重复的结果。

这种技术的优点是,多个模块都可以在 counter 模块中共享全局对象。所需的一切只是一个导入语句。不需要进一步的协调或开销。

类级别的“静态”变量

我们可以这样做来创建一个对类定义的所有实例全局的变量:

  1. __init__() 方法之外定义一个类的变量。这个变量是类的一部分,而不是实例的一部分。为了清楚地表明这个属性是由类的所有实例共享的,ClassVar 类型提示很有帮助。在这个例子中,我们决定使用前导下划线,这样类级别的变量就不会被视为公共接口的一部分:

     from collections import Counter 
    
    from typing import ClassVar 
    
    class EventCounter: 
    
        _class_counter: ClassVar[Counter[str]] = Counter()
    
  2. 添加更新和从类级别的 _class_counter 属性中提取数据的方法。这些方法将使用 @classmethod 装饰器来表明它们是直接由类使用的,而不是由实例使用的。不使用 self 变量;相反,使用 cls 变量作为方法适用于类的提醒:

     @classmethod 
    
        def count(cls, key: str, increment: int = 1) -> None: 
    
            cls._class_counter[key] += increment 
    
        @classmethod 
    
        def counts(cls) -> list[tuple[str, int]]: 
    
            return cls._class_counter.most_common()
    

非常重要的是要注意,_class_counter 属性是类的一部分,被称为 cls._class_counter。我们不使用 self 实例变量,因为我们不是指代类的实例;我们是指代类定义中的一部分变量,作为装饰有 @classmethod 的方法的第一个参数提供。

这就是我们可以使用这个类的方法。

 >>> from counter import * 

>>> EventCounter.count(’input’) 

>>> EventCounter.count(’input’) 

>>> EventCounter.count(’filter’) 

>>> EventCounter.counts() 

[(’input’, 2), (’filter’, 1)]

由于所有这些操作都更新 EventCounter 类,每个都会增加共享变量。

共享的全局状态必须谨慎使用。

它们可能与类的实际工作无关。焦点必须狭窄且限于非常有限的状态变化。

当有疑问时,显式共享对象将是一个更好的设计策略,但将涉及更多的代码。

8.4.3 它是如何工作的...

Python 导入机制使用 sys.modules 来跟踪哪些模块被加载。一旦模块在这个映射中,它就不会再次被加载。这意味着模块内定义的任何变量都将是一个单例:只有一个实例。

在一个模块中,只能创建一次类定义。这意味着类的内部状态变化也遵循单例设计模式。

我们如何在这两种机制之间进行选择?选择基于由多个类共享全局状态所造成的混淆程度。正如在类级别的“静态”变量中的前一个示例所示,我们可以有多个变量共享一个共同的 Counter 对象。如果隐式共享的全局状态看起来很令人困惑,那么模块级全局变量是一个更好的选择。在模块级全局变量令人困惑的情况下,应通过普通可见变量显式共享状态。

8.4.4 更多内容...

共享的全局状态可以被称为面向对象编程的反面。面向对象编程的一个理想是封装单个对象中的状态变化。如果过于广泛地使用,全局变量会破坏封装单个对象状态的想法。

在某种意义上,一个模块是一种类似于类的结构。模块是一个命名空间,具有模块级变量来定义状态和类似于方法的模块函数。

需要公共全局状态的一个例子通常出现在尝试为应用程序定义配置参数时。拥有一个单一、统一的配置,并在多个模块中广泛共享,可能会有所帮助。当这些对象用于普遍的功能,如配置、审计、日志记录和安全时,全局变量可以帮助将单个、专注的横切关注点隔离到一个通用的类中,该类与应用程序特定的类分开。

另一个选择是显式创建一个配置对象。然后,可以将此配置对象作为参数提供给应用程序中的其他对象。

8.4.5 参见

  • 第十四章介绍了模块和应用设计中的其他主题。

8.5 使用更复杂的结构 – 列表映射

在第四章中,我们探讨了 Python 中可用的基本数据结构。那些菜谱通常单独查看各种结构。

我们将探讨一种常见的组合结构——从单个键到相关值列表的映射。这可以用来累积有关数据库或日志记录的详细信息,这些记录由给定的键标识。这个菜谱将把平铺的细节列表分割成按共享键值组织的列表。

这与类设计相融合,因为我们经常可以利用 Python 的内置类来完成这类工作。这可以减少我们必须编写的独特新代码的量。

8.5.1 准备工作

我们将探讨从一个字符串到我们设计的类的实例列表的映射。我们将从一个应用程序的原始日志条目开始,将每一行分解成单个字段,然后从数据字段创建单个事件对象。一旦我们有了这些对象,我们就可以将它们重新组织和分组到与常见属性值(如模块名称或消息严重性)关联的列表中。

在第五章中,我们探讨了 创建字典 – 插入和更新 菜谱中的日志数据。

第一步将是将日志行转换成更有用的逗号分隔值(CSV)格式。正则表达式可以提取各种语法组。有关解析工作如何进行的更多信息,请参阅第一章的 使用正则表达式进行字符串解析 菜谱。

原始数据看起来如下:

 [2016-04-24 11:05:01,462] INFO in module1: Sample Message One 

[2016-04-24 11:06:02,624] DEBUG in module2: Debugging 

[2016-04-24 11:07:03,246] WARNING in module1: Something might have gone wrong

每一行都可以用正则表达式解析成组件字段。我们将定义一个名为 NamedTuple 的子类,它有一个静态方法 from_line(),用于使用这四个字段创建类的实例。确保属性名称与正则表达式分组名称匹配,我们可以使用以下定义来构建类的实例:

 import re 

from typing import NamedTuple 

class Event(NamedTuple): 

    timestamp: str 

    level: str 

    module: str 

    message: str 

    @staticmethod 

    def from_line(line: str) -> ’Event | None’: 

        pattern = re.compile( 

            r"\[(?P<timestamp>.*?)\]\s+" 

            r"(?P<level>\w+)\s+" 

            r"in\s+(?P<module>\w+)" 

            r":\s+(?P<message>.*)" 

            ) 

        if log_line := pattern.match(line): 

            return Event(**log_line.groupdict()) 

        else: 

            return None 

我们的目标是按模块名称属性对日志消息进行分组。我们希望看到如下内容:

 >>> pprint(summary) 

{’module1’: [ 

   Event(’2016-04-24 11:05:01,462’, ’INFO’, ’module1’, ’Sample Message One’), 

   Event(’2016-04-24 11:07:03,246’, ’WARNING’, ’module1’, ’Something might have gone wrong’)], 

 ’module2’: [ 

   Event(’2016-04-24 11:06:02,624’, ’DEBUG’, ’module2’, ’Debugging’)] 

}

8.5.2 如何实现...

我们可以编写一个 summarize() 函数,将日志数据重新结构化为以下形式:

  1. 导入所需的模块和一些用于各种集合的类型提示:

     from collections import defaultdict 
    
    from collections.abc import Iterable
    

    defaultdict 类型是一个扩展了 MutableMapping 抽象基类的具体类。这与 Iterable 类型提示所在的模块分开,后者是一个抽象基类定义。

  2. 源数据类型,事件类,在准备就绪部分已经展示。

  3. 定义我们将要工作的摘要字典的整体类型提示:

     from typing import TypeAlias 
    
    Summary: TypeAlias = defaultdict[str, list[Event]]
    
  4. 开始定义一个函数,用于总结可迭代的事件实例源,并生成一个 Summary 对象:

     def summarize(data: Iterable[Event]) -> Summary:
    
  5. 将列表函数用作 defaultdict 的默认值。为这个集合创建一个类型提示也很有用:

     module_details: Summary = defaultdict(list)
    

    列表函数仅提供名称。使用 list() 的常见错误是评估函数并创建一个列表对象,该对象不是函数。错误消息如 TypeError: first argument must be callable or None 提醒我们,参数必须是函数名称。

  6. 遍历数据,将列表添加到每个键关联的列表中。defaultdict 对象将使用提供的 list() 函数来构建一个空列表,作为首次遇到每个新键时的值:

     for event in data: 
    
            module_details[event.module].append(event) 
    
        return module_details
    

summarize() 函数的结果是一个字典,它将模块名称字符串映射到该模块名称的所有日志行列表。数据看起来如下:

 >>> pprint(summary) 

defaultdict(<class ’list’>, 

            {’module1’: [Event(timestamp=’2016-04-24 11:05:01,462’, level=’INFO’, module=’module1’, message=’Sample Message One’), 

                         Event(timestamp=’2016-04-24 11:07:03,246’, level=’WARNING’, module=’module1’, message=’Something might have gone wrong’)], 

             ’module2’: [Event(timestamp=’2016-04-24 11:06:02,624’, level=’DEBUG’, module=’module2’, message=’Debugging’)]})

此映射的键是模块名称,映射中的值是该模块名称的行列表。我们现在可以专注于特定模块的分析。这似乎与总结结果的初始预期非常接近。

8.5.3 它是如何工作的...

当找不到键时,映射的行为有两种选择:

  • 内置的 dict 类在键缺失时抛出异常。这使得累积与预先不知道的键关联的值变得困难。

  • defaultdict 类在键缺失时评估一个创建默认值的函数。在许多情况下,该函数是 intfloat,以创建默认的数值 0 或 0.0。在这种情况下,该函数是 list,以创建一个空列表。

我们可以想象使用 set 函数而不是 list 来为缺失的键创建一个空的集合对象。这对于从键到共享该键的不可变对象集合的映射是合适的。

8.5.4 更多...

我们也可以将其作为一个扩展构建为内置的 dict 类:

 class ModuleEvents(dict[str, list[Event]]): 

    def __missing__(self, key: str) -> list[Event]: 

        self[key] = list() 

        return self[key]

我们提供了一个特殊 __missing__() 方法的实现。默认行为是抛出一个 KeyError 异常。这将在映射中创建一个新的空列表。

这允许我们使用如下代码:

 >>> event_iter = (Event.from_line(l) for l in log_data.splitlines()) 

>>> module_details = ModuleEvents() 

>>> for event in filter(None, event_iter):

用例与 defaultdict 相同,但集合类的定义稍微复杂一些。这允许进一步扩展以向 ModuleEvents 类添加功能。

8.5.5 相关阅读

  • 在第四章的 创建字典 – 插入和更新 菜谱中,我们探讨了使用映射的基础。

  • 在第四章的 避免为函数参数使用可变默认值 菜谱中,我们探讨了其他使用默认值的地方。

  • 在第七章的 使用更复杂的集合 菜谱中,我们探讨了使用 defaultdict 类的其他示例。

8.6 创建具有可排序对象的类

我们经常需要能够按顺序排列的对象。以日志记录为例,它们通常按日期和时间排序。我们大多数类定义都没有包括将对象按顺序排序所需的特性。许多配方都保持对象基于由 __hash__() 方法计算的内联哈希值和由 __eq__() 方法定义的相等性测试的映射或集合。

为了保持项目在排序集合中,我们需要实现 <><=>= 的比较方法。这些比较都是基于每个对象的属性值。

当我们扩展 NamedTuple 类时,适用于元组类的比较方法都是可用的。如果我们使用 @dataclass 装饰器定义类,默认情况下不会提供比较方法。我们可以使用 @dataclass(order=True) 来包含排序方法。对于这个配方,我们将查看一个不基于这些辅助工具的类。

8.6.1 准备工作

在 通过多重继承分离关注点 的配方中,我们使用两个类定义来定义扑克牌。Card 类层次定义了每张牌的基本特性。第二组混合类为每张牌提供了特定于游戏的特性。

核心定义,Card,是一个冻结的数据类。它没有 order=True 参数,并且没有正确地将牌按顺序排列。我们需要向这个 Card 定义添加功能,以创建可以正确排序的对象。

我们将假设另一个类定义,PinochlePoints,以遵循为 Pinochle 游戏分配牌点的规则。细节不重要;重要的是这个类实现了 points() 方法。

为了创建一个可排序的牌集合,我们需要向 Card 类定义系列添加另一个特性。我们需要定义用于比较运算符的四个特殊方法。

8.6.2 如何做...

要创建一个可排序的类定义,我们将创建一个比较协议,然后定义一个实现该协议的类,如下所示:

  1. 我们正在定义一个新的协议,像 mypy 这样的工具可以在比较对象时使用。这将描述混合类将应用于哪些类型的对象。我们称之为 CardLike,因为它适用于具有至少排名和花色这两个属性的任何类:

     from typing import Protocol, Any 
    
    class CardLike(Protocol): 
    
        rank: int 
    
        suit: str
    

    将类似-Something 作为协议名称的使用是 Pythonic 整体方法的一部分,即鸭子类型。而不是坚持类型层次结构,我们定义了作为新协议所需的最少特性。

  2. 扩展协议,我们可以创建具有比较特性的 SortableCard 子类。这个子类可以混合到任何符合协议定义的类中:

     class SortableCard(CardLike): 
    
  3. 将四个排序比较方法添加到 SortableCard 子类中。在这种情况下,我们使用符合 CardLike 协议的任何类的相关属性到一个元组中,然后使用 Python 的内置元组比较来处理元组中项的比较细节。以下是这些方法:

     def __lt__(self: CardLike, other: Any) -> bool: 
    
            return (self.rank, self.suit) < (other.rank, other.suit) 
    
        def __le__(self: CardLike, other: Any) -> bool: 
    
            return (self.rank, self.suit) <= (other.rank, other.suit) 
    
        def __gt__(self: CardLike, other: Any) -> bool: 
    
            return (self.rank, self.suit) > (other.rank, other.suit) 
    
        def __ge__(self: CardLike, other: Any) -> bool: 
    
            return (self.rank, self.suit) >= (other.rank, other.suit)
    
  4. 编写由基本 Card 类和两个混合类构建的复合类定义,以提供 Pinochle 和比较功能:

     class PinochleAce(AceCard, SortableCard, PinochlePoints): 
    
        pass 
    
    class PinochleFace(FaceCard, SortableCard, PinochlePoints): 
    
        pass 
    
    class PinochleNumber(Card, SortableCard, PinochlePoints): 
    
        pass
    
  5. 这组类没有简单的超类。我们将添加一个类型提示来创建一个公共定义:

     from typing import TypeAlias 
    
    PinochleCard: TypeAlias = PinochleAce | PinochleFace | PinochleNumber
    
  6. 现在我们可以创建一个函数,从之前定义的类中创建单个 PinochleCard 对象:

     def make_pinochle_card(rank: int, suit: str) -> PinochleCard: 
    
        if rank in (9, 10): 
    
            return PinochleNumber(rank, suit) 
    
        elif rank in (11, 12, 13): 
    
            return PinochleFace(rank, suit) 
    
        else: 
    
            return PinochleAce(rank, suit)
    

Pinochle 的复杂点规则被封装在 PinochlePoints 类中。我们省略了它们,因为点数根本不与六种牌等级平行。将复合类作为 Card 和 PinochlePoints 的基子类构建,可以准确地对牌进行建模,而不会过于复杂。

我们现在可以使用以下交互命令序列来创建对比较运算符做出响应的牌:

 >>> c1 = make_pinochle_card(9, ’’) 

>>> c2 = make_pinochle_card(10, ’’) 

>>> c1 < c2 

True 

>>> c1 == c1  # Cards match themselves 

True 

>>> c1 == c2 

False 

>>> c1 > c2 

False

实现等于(==)和不等于(!=)的相等比较是在基类 Card 中定义的。这是一个冻结的数据类。默认情况下,数据类包含相等测试方法。

这是一个构建特殊 48 张牌组的函数。它创建了 24 种不同等级和花色组合的每张牌的两个副本:

 def make_pinochle_deck() -> list[PinochleCard]: 

    return [ 

        make_pinochle_card(r, s) 

            for _ in range(2) 

                for r in range(9, 15) 

                    for s in SUITS 

    ]

SUITS 变量的值是四种 Unicode 字符,代表花色。make_deck() 函数内部的生成表达式使用 Pinochle 游戏中的 6 个等级构建每张牌的两个副本。

8.6.3 它是如何工作的...

Python 为许多事物使用特殊方法。语言中几乎每个运算符都是由特殊方法实现的。(少数例外是 is 运算符和 and、or、not。)在这个配方中,我们利用了四个排序运算符。

表达式 c1 <= c2 被评估为如果我们写了 c1.le(c2)。这种转换几乎适用于 Python 的所有运算符。

Python 语言参考将特殊方法组织成几个不同的组。在这个配方中,我们查看用于基本定制类的那些方法。

当我们处理这个类层次结构的实例时,看起来是这样的。第一个例子将创建一个 48 张牌的 Pinochle 牌组:

 >>> deck = make_pinochle_deck() 

>>> len(deck) 

48
 >>> import random 

>>> random.seed(4) 

>>> random.shuffle(deck) 

>>> [str(c) for c in sorted(deck[:12])] 

[’ 9 ’, ’10 ’, ’ J ’, ’ J ’, ’ J ’, ’ Q ’, ’ Q ’, ’ K ’, ’ K ’, ’ K ’, ’ A ’, ’ A ’]

上面的例子中重要的部分是 sorted() 函数的使用。因为我们已经定义了适当的比较运算符,我们可以对 PinochleCard 实例进行排序,并且它们按照从低等级到高等级的预期顺序呈现。

8.6.4 更多...

一点形式逻辑表明,我们实际上只需要详细实现两种比较。从一个相等方法和一个排序方法,所有其他方法都可以构建。例如,如果我们为小于(lt())和等于(eq())构建操作,我们可以根据以下等价规则计算其他三个比较:

  • a ≤ b ≡ a < b∨ a = b

  • a ≥ b ≡ b < a∨ a = b

  • a ⁄= b ≡ ¬(a = b)

Python 明确不会为我们做这类高级代数运算。我们需要仔细进行代数运算并实现必要的比较方法。

functools 库包括一个装饰器,@total_ordering,它可以生成这些缺失的比较方法。

8.6.5 参见

  • 参见通过多重继承分离关注点配方,了解卡片和卡牌游戏规则的必要定义。

  • 参见第七章(ch011_split_000.xhtml#x1-3760007)了解更多关于数据类和命名元组类的信息。

8.7 从复杂对象列表中删除

从列表中删除项目有一个有趣的结果。具体来说,当一个项目被删除时,所有后续的项目都会向前移动。规则是这样的:

在删除项目 y 时,items list[y+1:] 取代了 items list[y:]。

这是在删除选定的项目之外发生的副作用。由于列表中的项目可能会移动,这使得一次删除多个项目具有潜在的挑战性。

当列表包含具有 eq() 特殊方法定义的项目时,列表的 remove() 方法可以删除每个项目。当列表项目没有简单的 eq() 测试时,remove() 方法不起作用,这使得从列表中删除多个项目更具挑战性。

8.7.1 准备工作

对于这个例子,我们将使用字典列表,其中删除项目的天真方法不起作用。看到尝试重复搜索列表以删除项目可能会出错是有帮助的。

在这种情况下,我们有一些包含歌曲名称、作家和持续时间的资料。字典对象相当长。数据看起来像这样:

 >>> song_list = [ 

... {’title’: ’Eruption’, ’writer’: [’Emerson’], ’time’: ’2:43’}, 

... {’title’: ’Stones of Years’, ’writer’: [’Emerson’, ’Lake’], ’time’: ’3:43’}, 

... {’title’: ’Iconoclast’, ’writer’: [’Emerson’], ’time’: ’1:16’}, 

... {’title’: ’Mass’, ’writer’: [’Emerson’, ’Lake’], ’time’: ’3:09’}, 

... {’title’: ’Manticore’, ’writer’: [’Emerson’], ’time’: ’1:49’}, 

... {’title’: ’Battlefield’, ’writer’: [’Lake’], ’time’: ’3:57’}, 

... {’title’: ’Aquatarkus’, ’writer’: [’Emerson’], ’time’: ’3:54’} 

... ]

可以用以下方式定义这个复杂结构的每一行的类型提示:

 from typing import TypedDict 

class SongType(TypedDict): 

    title: str 

    writer: list[str] 

    time: str

一个更好的设计会使用 datetime.timedelta 来表示歌曲的时间。我们在配方中省略了这个复杂性。

整个歌曲列表可以描述为 list[SongType]。

这里有一个天真且明显无效的方法:

 def naive_delete(data: list[SongType], writer: str) -> None: 

    for index in range(len(data)): 

        if ’Lake’ in data[index][’writer’]: 

            del data[index]

由于项目被移动,计算出的索引值会跳过刚被删除的项目之后的那个项目。此外,由于列表变短,删除后的范围是错误的。这会导致索引错误,如下面的输出所示:

 >>> naive_delete(song_list, ’Lake’) 

Traceback (most recent call last): 

... 

IndexError: list index out of range

另一种失败的方法看起来是这样的:

 >>> remove = list(filter(lambda x: ’Lake’ in x[’writer’], song_list)) 

>>> for x in remove: 

...     song_list.remove(x)

这有一个问题,即每个 remove() 操作都必须从列表的开始搜索。对于非常大的列表,这种方法会很慢。

我们需要以避免多次遍历列表的方式组合搜索和删除操作。

8.7.2 如何做...

为了有效地从列表中删除多个项目,我们需要实现自己的列表索引处理函数,如下所示:

  1. 定义一个函数,通过删除选定的项目来更新列表对象:

     def incremental_delete( 
    
        data: list[SongType], 
    
        writer: str 
    
    ) -> None:
    
  2. 初始化一个索引值 i,从列表的第一个项目开始为零:

     i = 0
    
  3. 当 i 变量的值不等于列表的长度时,我们希望对状态进行更改,要么增加 i 值,要么缩小列表:

     while i != len(data):
    
  4. 如果 data[i] 的值是搜索的目标,我们可以删除它,缩小列表。

    否则,增加索引值 i,使其更接近列表的长度:

     if ’Lake’ in data[i][’writer’]: 
    
                del data[i] 
    
            else: 
    
                i += 1
    

这导致从列表中删除项目时不会出现索引错误,也不会多次遍历列表项,或者无法删除匹配的项目。

8.7.3 它是如何工作的...

目标是检查每个项目恰好一次,要么删除它,要么跳过它,将其留在原位。while 语句的设计源于观察那些朝着目标前进的语句:增加索引,删除一个项目。这些操作在有限的一组条件下有效:

  • 仅当项目不应该被删除时,增加索引才有效。

  • 仅当项目是匹配项时,删除项目才有效。

重要的是条件是互斥的。当我们使用 for 语句时,增量处理总是发生,这是一个不希望的特性。while 语句允许我们仅在项目应该留在原位时才进行增量。

8.7.4 更多内容...

相对于删除的开销,另一种方法是创建一个新列表,其中一些项目被拒绝。制作项目浅拷贝比从列表中删除项目快得多,但需要更多的存储空间。这是一个常见的关于时间和内存权衡的例子。

我们可以使用如下列表推导来创建一个只包含所需项目的新的列表:

 >>> [item 

...     for item in song_list 

...         if ’Lake’ not in item[’writer’] 

... ]

这将创建列表中选定项目的浅拷贝。我们不希望保留的项目将被忽略。有关浅拷贝的概念的更多信息,请参阅第四章中的“制作浅拷贝和深拷贝对象”配方。

我们也可以在复制操作中使用一个高阶函数,filter()。考虑以下示例:

 >>> list( 

...     filter( 

...         lambda item: ’Lake’ not in item[’writer’], 

...         song_list 

...     ) 

... )

filter() 函数有两个参数:一个 lambda 对象和原始数据集。在这种情况下,lambda 表达式用于决定哪些项目要传递。lambda 返回 False 的项目将被拒绝。

filter() 函数是一个生成器。这意味着我们需要收集所有项目以创建一个最终的列表对象。list() 函数是消耗生成器中所有项目的一种方式,将它们存储在它们创建并返回的集合对象中。

8.7.5 参考内容

  • 我们已经利用了两个其他配方:制作浅拷贝和深拷贝对象 和 切片和切块列表,它们在第四章中。

  • 我们将在第九章节中仔细研究过滤器和生成器表达式。

加入我们的社区 Discord 空间

加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

图片

第九章:9

函数式编程特性

函数式编程的思想是专注于编写小而表达性强的函数,以执行所需的数据转换。函数的组合通常可以创建比长串过程语句或复杂有状态对象的方法定义更简洁、更具有表现力的代码。本章重点介绍 Python 的函数式编程特性,而不是过程式或面向对象编程。

这提供了一种与本书其他地方严格面向对象的方法不同的软件设计途径。将对象与函数的组合允许在组装最佳组件集合时具有灵活性。

传统数学将许多事物定义为函数。多个函数可以组合起来,从之前的转换构建复杂的结果。当我们把数学运算符视为函数时,表达式 p = f(n,g(n))也可以写成两个单独的函数。我们可能会将其视为 p = f(n,b),其中 b = g(n)。

理想情况下,我们还可以从这两个函数创建一个复合函数:

p = f(n,g(n)) = g ∘f (n )

定义一个新的复合函数 g ∘ f,而不是嵌套函数,可以帮助阐明设计背后的意图。这种对组件的重构可以让我们将许多小的细节组合成更大的知识块,体现设计背后的概念。

由于编程通常与数据集合一起工作,我们经常会将函数应用到集合的所有项目上。这发生在进行数据库提取和转换以对齐来自不同源应用程序的数据时。这也发生在汇总数据时。将 CSV 文件转换为统计摘要这样常见的事情,就是从文本行到数据行的转换函数的组合,以及从数据行到平均值和标准差的转换。这与集合构造者或集合理解数学思想非常吻合。

应用一个函数到一组数据的三种常见模式:

  • 映射:这将对集合的所有元素应用一个函数,{m(x)|x ∈ S}。我们将某个函数 m(x)应用到较大的集合 S 中的每个项目 x 上。

  • 过滤:这使用一个函数从集合中选择元素,{x|x ∈ S if f(x)}。我们使用一个函数,f(x),来确定是否将较大的集合 S 中的每个项目 x 通过或拒绝。

  • 减少:这总结了集合中的项目。最常见的一种减少是将集合 S 中所有项目求和,表示为 ∑ [x∈S]x。其他常见的减少还包括找到最小项、最大项以及所有项的乘积。

我们经常将这些模式结合起来创建更复杂的复合应用程序。这里重要的是,像 m(x)和 f(x)这样的小函数可以通过内置的高阶函数如 map()、filter()和 reduce()进行组合。itertools 模块包含许多额外的更高阶函数,我们可以使用它们来构建应用程序。当然,我们也可以定义我们自己的高阶函数来组合较小的函数。

这些食谱中的一些将展示可以定义为使用@property 装饰器创建的类定义属性的运算。这是另一种可以限制有状态对象复杂性的设计选择。然而,在本章中,我们将尝试坚持一种函数式方法,即通过转换来创建新对象,而不是使用属性。

在本章中,我们将探讨以下食谱:

  • 使用 yield 语句编写生成器函数

  • 对集合应用转换

  • 使用堆叠的生成器表达式

  • 选择子集 – 三种过滤方式

  • 总结集合 – 如何减少

  • 组合 map 和 reduce 转换

  • 实现“存在”处理

  • 创建部分函数

  • 使用 yield from 语句编写递归生成器函数

我们将从创建函数开始,这些函数将产生一个可迭代的值序列。而不是创建一个完整的列表(或集合,或其他集合),生成器函数根据客户端操作的需求产生集合的各个项目。这节省了内存,并且可能节省时间。

9.1 使用 yield 语句编写生成器函数

生成器函数通常被设计为对集合中的每个项目应用某种类型的转换。生成器也可以创建数据。生成器被称为懒惰的,因为其产生的值必须由客户端消耗;值只有在客户端尝试消耗它们时才会被计算。客户端操作,如 list()函数或 for 语句,是常见的消费者示例。每次像 list()这样的函数需要值时,生成器函数必须使用 yield 语句产生一个值。

相反,一个普通函数可以被调用为急切模式。没有 yield 语句,函数将计算整个结果并通过 return 语句返回。

在我们无法将整个集合放入内存的情况下,一种懒惰的方法非常有帮助。例如,分析巨大的网络日志文件可以通过小批量进行,而不是创建一个庞大的内存集合。

在 Python 的类型提示语言中,我们经常使用 Iterator 泛型来描述生成器。我们需要使用类型来澄清这个泛型,例如 Iterator[str],以表明函数产生字符串对象。

生成器正在消耗的项目通常来自由 Iterable 泛型类型描述的集合。所有 Python 的内置集合都是可迭代的,文件也是如此。例如,字符串值的列表可以被视为 Iterable[str]。

从 collections.abc 模块中可以获得 Iterable 和 Iterator 类型。它们也可以从 typing 模块中导入。

yield 语句将普通函数转换为生成器。它将迭代计算并产生结果。

9.1.1 准备工作

我们将对一些网络日志数据进行生成器应用。我们将设计一个生成器,将原始文本转换为更有用的结构化对象。生成器函数用于隔离转换处理。这允许在初始转换之后灵活地应用过滤或汇总操作。

条目最初是看起来像这样的文本行:

[2016-06-15 17:57:54,715] INFO in ch10_r10: Sample Message One 

[2016-06-15 17:57:54,716] DEBUG in ch10_r10: Debugging 

[2016-06-15 17:57:54,720] WARNING in ch10_r10: Something might have gone wrong

我们在第八章的使用更复杂结构 - 列表映射食谱中看到了使用这种日志的其他示例。使用第一章的使用正则表达式进行字符串解析食谱中的正则表达式,我们可以将每一行分解成更有用的结构。

在日志的每一行中捕获详细信息,并将其存储在具有不同类型的对象中通常很有帮助。这有助于使代码更加专注,并有助于我们使用 mypy 工具来确认类型被正确使用。以下是一个 NamedTuple 类定义:

from typing import NamedTuple 

class RawLog(NamedTuple): 

    date: str 

    level: str 

    module: str 

    message: str

我们将从将字符串类型的可迭代源代码转换为字段元组的迭代器开始。之后,我们将再次应用该食谱,将日期属性从字符串转换为有用的日期时间对象。

9.1.2 如何做...

生成器函数是一个函数,所以食谱与第三章中显示的类似。我们首先定义函数,如下所示:

  1. 从 collections.abc 模块导入所需的类型提示。导入 re 模块以解析日志文件的行:

    import re 
    
    from collections.abc import Iterable, Iterator
    
  2. 定义一个遍历 RawLog 对象的函数。在函数名称中包含 _iter 可以帮助强调结果是迭代器,而不是单个值。参数是日志行的可迭代源:

    def parse_line_iter( 
    
        source: Iterable[str] 
    
    ) -> Iterator[RawLog]:
    
  3. parse_line_iter() 转换函数依赖于正则表达式来分解每一行。我们可以在函数内部定义它,以保持它与处理的其他部分的紧密绑定:

     pattern = re.compile( 
    
            r"\[(?P<date>.*?)\]\s+" 
    
            r"(?P<level>\w+)\s+" 
    
            r"in\s+(?P<module>.+?)" 
    
            r":\s+(?P<message>.+)", 
    
            re.X 
    
        )
    
  4. for 语句将消耗可迭代源代码的每一行,使我们能够单独创建并产生每个 RawLog 对象:

        for line in source:
    
  5. for 语句的主体可以使用匹配组将每个与模式匹配的字符串实例映射到一个新的 RawLog 对象:

            if match := pattern.match(line): 
    
                yield RawLog(*match.groups())
    

    不匹配的行将被静默丢弃。在大多数情况下,这似乎是合理的,因为日志可以充满来自各种来源的消息。

    没有 yield 语句的函数是“普通”的,并计算一个单一的结果。

这是如何使用这个函数从上面显示的样本数据中发出一系列 RawLog 实例的:

>>> from pprint import pprint 

>>> for item in parse_line_iter(log_lines): 

...     pprint(item) 

RawLog(date=’2016-04-24 11:05:01,462’, level=’INFO’, module=’module1’, message=’Sample Message One’) 

RawLog(date=’2016-04-24 11:06:02,624’, level=’DEBUG’, module=’module2’, message=’Debugging’) 

RawLog(date=’2016-04-24 11:07:03,246’, level=’WARNING’, module=’module1’, message=’Something might have gone wrong’)

我们也可以使用类似的方法将项目收集到一个列表对象中:

>>> details = list(parse_line_iter(log_lines))

在这个例子中,list()函数消耗了 parse_line_iter()函数产生的所有项目。生成器是一个相对被动的结构:直到需要数据,它不会做任何工作。

9.1.3 它是如何工作的...

Python 的每个内置集合类型都实现了一个特殊方法,iter(),用于生成一个迭代器对象。迭代器对象实现了 next()特殊方法,用于返回一个项目并推进迭代器的状态以返回下一个项目。这就是迭代器协议。内置的 next()函数评估迭代器对象的这个方法。

虽然 Python 内置的 collections 可以创建 Iterator 对象,但生成器函数也实现了这个协议。生成器在响应 iter()函数时会返回自身。在响应 next()函数时,生成器在 yield 语句处暂停执行,并提供一个值,该值成为 next()函数的结果。由于函数被暂停,它可以在另一个 next()函数被评估时恢复。

要了解 yield 语句是如何工作的,请看这个小型函数,它产生两个对象:

test_example_4_3 = """ 

>>> def gen_func(): 

...     print("pre-yield") 

...     yield 1 

...     print("post-yield")

当我们在生成器上评估 next()函数时,会发生以下情况:

>>> y = gen_func() 

>>> next(y) 

pre-yield 

1 

>>> next(y) 

post-yield

第一次评估 next()函数时,第一个 print()函数被评估,然后 yield 语句产生了一个值。

使用 next()函数恢复处理,并在两个 yield 语句之间的语句被评估。

接下来会发生什么?由于函数体中没有更多的 yield 语句,所以我们观察到以下情况:

>>> next(y) 

Traceback (most recent call last): 

...

在生成器函数的末尾会抛出 StopIteration 异常。这是 for 语句处理过程中预期到的。它被静默地吸收以跳出处理。

如果我们不使用像 list()或 for 语句这样的函数来消耗数据,我们会看到类似以下的内容:

>>> parse_line_iter(data) 

<generator object parse_line_iter at ...>

评估 parse_line_iter()函数返回的值是一个生成器。它不是一个项目集合,而是一个将按需从消费者那里产生项目,一次一个的对象。

9.1.4 更多内容...

我们可以将这个方法应用到转换每个 RawLog 对象中的日期属性。每行更精细的数据类型将遵循以下类定义:

import datetime 

from typing import NamedTuple 

class DatedLog(NamedTuple): 

    date: datetime.datetime 

    level: str 

    module: str 

    message: str

这有一个更有用的 datetime.datetime 对象作为时间戳。其他字段保持为字符串。

这是一个生成器函数——使用 for 语句和 yield 关键字,使其成为一个迭代器——用于将每个 RawLog 对象精炼成 DatedLog 对象:

def parse_date_iter( 

    source: Iterable[RawLog] 

) -> Iterator[DatedLog]: 

    for item in source: 

        date = datetime.datetime.strptime( 

            item.date, "%Y-%m-%d %H:%M:%S,%f" 

        ) 

        yield DatedLog( 

            date, item.level, item.module, item.message 

        )

将整体处理分解成小的生成器函数提供了几个显著的优势。首先,分解使得每个函数更加简洁,因为它专注于特定的任务。这使得这些函数更容易设计、测试和维护。其次,它使得整体组合更能够表达所做的工作。

我们可以将这两个生成器以以下方式组合:

>>> for item in parse_date_iter(parse_line_iter(log_lines)): 

...     print(item) 

DatedLog(date=datetime.datetime(2016, 4, 24, 11, 5, 1, 462000), level=’INFO’, module=’module1’, message=’Sample Message One’) 

DatedLog(date=datetime.datetime(2016, 4, 24, 11, 6, 2, 624000), level=’DEBUG’, module=’module2’, message=’Debugging’) 

DatedLog(date=datetime.datetime(2016, 4, 24, 11, 7, 3, 246000), level=’WARNING’, module=’module1’, message=’Something might have gone wrong’)

parse_line_iter()函数将消耗源数据中的行,当消费者需要时创建 RawLog 对象。parse_date_iter()函数是 RawLog 对象的消费者;从这些对象中,当消费者需要时,它创建 DatedLog 对象。外部的 for 语句是最终消费者,需要 DatedLog 对象。

在任何时候,内存中都不会存在大量中间对象。这些函数中的每一个都只处理一个对象,从而限制了内存的使用量。

9.1.5 参见

  • 在使用堆叠生成器表达式的菜谱中,我们将结合生成器函数,从简单组件构建复杂处理堆栈。

  • 在将转换应用于集合的菜谱中,我们将看到如何使用内置的 map()函数从简单的函数和可迭代的数据源创建复杂处理。

  • 在选择子集 - 三种过滤方式的菜谱中,我们将看到如何使用内置的 filter()函数也从简单的函数和可迭代的数据源构建复杂处理。

9.2 将转换应用于集合

我们经常定义生成器函数,目的是将函数应用于数据项的集合。生成器与集合的交互有几种方式。

在本章的使用 yield 语句编写生成器函数的菜谱中,我们创建了一个生成器函数,将数据从字符串转换成更复杂的对象。

生成器函数具有共同的架构,通常看起来是这样的:

def new_item_iter(source: Iterable[X]) -> Iterator[Y]: 

    for item in source: 

        new_item: Y = some_transformation(item) 

        yield new_item

yield 语句意味着结果将迭代生成。函数的类型提示强调它从源集合中消费项目。这个编写生成器函数的模板暴露了一个常见的设计模式。

从数学上讲,我们可以这样总结:

N = {m (x ) | x ∈ S}

新集合 N 是对源 S 中的每个项目 x 应用转换 m(x)的结果。这强调了转换函数 m(x),将其与消费源和产生结果的细节分开。在之前显示的 Python 示例中,这个函数被调用为 some_transformation()。

这个数学总结表明,for 语句可以被理解为围绕转换函数的一种支架。这种支架可以有两种额外的形式。我们可以写一个生成器表达式,或者我们可以使用内置的 map()函数。这个配方将检查所有三种技术。

9.2.1 准备工作

我们将查看使用 yield 语句编写生成器函数配方中的网络日志数据。这些数据中的日期是以字符串形式表示的,我们希望将其转换为合适的 datetime 对象,以便用于后续的计算。我们将利用之前配方中的 DatedLog 类定义。

使用 yield 语句编写生成器函数的配方使用了如下示例中的生成器函数,将一系列 RawLog 对象转换为一个更有用的 DatedLog 实例迭代器:

import datetime 

from recipe_01 import RawLog, DatedLog 

def parse_date_iter( 

    source: Iterable[RawLog] 

) -> Iterator[DatedLog]: 

    for item in source: 

        date = datetime.datetime.strptime( 

            item.date, "%Y-%m-%d %H:%M:%S,%f" 

        ) 

        yield DatedLog( 

            date, item.level, item.module, item.message 

        )

这个 parse_date_iter()函数在有趣的功能周围有大量的支架代码。for 和 yield 语句是支架的例子。另一方面,日期解析是函数中独特、有趣的部分。我们需要提取这个独特的处理过程,以便使用更灵活的支架。

9.2.2 如何实现...

为了使用不同的生成器函数应用方法,我们需要首先重构原始的 parse_date_iter()函数。这将提取一个 parse_date()函数,可以以多种方式使用。在此初始步骤之后,我们将展示三个独立的迷你配方,用于使用重构后的代码。

重构迭代器以定义一个函数,该函数可以将源类型的项目转换为结果类型的项目:

def parse_date(item: RawLog) -> DatedLog: 

    date = datetime.datetime.strptime( 

        item.date, "%Y-%m-%d %H:%M:%S,%f") 

    return DatedLog( 

        date, item.level, item.module, item.message)

这种转换可以通过三种方式应用于数据集合:生成器函数、生成器表达式和通过 map()函数。我们将首先重建原始生成器。

使用 for 和 yield 语句

我们可以使用 for 和 yield 语句将单行 parse_date()转换函数应用于集合中的每个项目。这在前面的使用 yield 语句编写生成器函数配方中已经展示过。下面是这个例子:

def parse_date_iter_y( 

    source: Iterable[RawLog] 

) -> Iterator[DatedLog]: 

    for item in source: 

        yield parse_date(item)

使用生成器表达式

我们可以使用生成器表达式将 parse_date()函数应用于集合中的每个项目。生成器表达式包括两个部分——映射函数和一个 for 子句——由括号()包围。这遵循了第四章中构建列表——字面量、追加和推导式配方中的模式:

  1. 写出包围生成器的括号()。

  2. 为数据源编写一个 for 子句,将每个项目分配给一个变量,在这个例子中是 item:

    (... for item in source)
    
  3. 在 for 子句前加上映射函数,应用于变量:

    (parse_date(item) for item in source)
    
  4. 该表达式可以是函数的返回值,该函数为源和结果表达式提供合适的类型提示。以下是整个函数,因为它非常小:

    def parse_date_iter_g( 
    
        source: Iterable[RawLog] 
    
    ) -> Iterator[DatedLog]: 
    
      return (parse_date(item) for item in source)
    

    该函数返回一个生成器表达式,它将 parse_date() 函数应用于源可迭代中的每个项目。

是的,这个函数很小,似乎不需要 def 语句和名称的开销。在某些情况下,类型提示可能会有所帮助,这使得这是一个合理的选择。

使用 map() 函数

我们可以使用 map() 内置函数将 parse_date() 函数应用于集合中的每个项目:

  1. 使用 map() 函数将转换应用于源数据:

    map(parse_date, source)
    
  2. 该表达式可以是函数的返回值,该函数为源和结果表达式提供合适的类型提示。以下是整个函数,因为它非常小:

    def parse_date_iter_m( 
    
        source: Iterable[RawLog] 
    
    ) -> Iterator[DatedLog]: 
    
        return map(parse_date, source)
    

map() 函数是一个迭代器,它将 parse_date() 函数应用于源可迭代中的每个项目。它产生由 parse_date() 函数创建的对象。

重要的是要注意,parse_date 名称不带 () 是对函数对象的引用。

认为函数必须被评估,并包含额外的、不必要的 () 的使用是一个常见的错误。

这三种技术是等效的。

9.2.3 它是如何工作的...

map() 函数替换了一些常见的代码,这些代码在处理周围充当脚手架。它执行 for 语句的工作。它将给定的函数应用于源可迭代中的每个项目。

我们可以如下定义我们自己的 map() 版本:

def my_map2(f: Callable[[P], Q], source: Iterable[P]) -> Iterator[Q]: 

    return (f(item) for item in source) 

正如我们所见,它们的行为是相同的。不同的代码受众可能有不同的偏好。我们提供的指导是选择使代码的意义和意图对阅读代码的受众最清晰的风格。

9.2.4 更多...

在这个例子中,我们使用了 map() 函数将一个函数应用于单个可迭代集合中的每个项目。结果证明,map() 函数可以做得更多。map() 函数可以处理多个序列。

考虑这个函数和这两个数据源:

>>> def mul(a, b): 

...     return a * b 

>>> list_1 = [2, 3, 5, 7] 

>>> list_2 = [11, 13, 17, 23]

我们可以将 mul() 函数应用于从每个数据源中抽取的配对序列:

>>> list(map(mul, list_1, list_2)) 

[22, 39, 85, 161]

这使我们能够使用从序列中提取的参数值的不同类型的操作合并几个值序列。

9.2.5 参见

  • 在本章后面的 使用堆叠的生成器表达式 菜单中,我们将查看堆叠生成器。我们将从多个单个映射操作构建一个复合函数,这些操作以各种类型的生成器函数的形式编写。

9.3 使用堆叠的生成器表达式

在本章前面的使用 yield 语句编写生成器函数配方中,我们创建了一个简单的生成器函数,它对数据执行单个转换。作为一个实际问题,我们经常希望对传入的数据应用几个函数。

我们如何堆叠或组合多个生成器函数以创建一个复合函数?

9.3.1 准备工作

此配方将对源数据应用几种不同的转换。将有三行重组以合并为单行,数据转换将源字符串转换为有用的数字或日期时间戳,以及过滤掉无用的行。

我们有一个电子表格,用于记录大型帆船的燃料消耗。

有关此数据的详细信息,请参阅第四章中的切片和切块列表配方。我们将在第十一章中的使用 CSV 模块读取分隔文件配方中更详细地查看解析。

我们希望对列表-of-lists-of-strings 对象的每一行级别的列表应用多个转换:

  • 排除数据中存在的三行标题(以及任何空白行)。

  • 将三个物理文本行合并为一个逻辑数据行。

  • 将分隔的日期和时间字符串转换为日期时间对象。

  • 将燃料高度从字符串转换为浮点数,理想情况下以加仑(或升)为单位,而不是英寸。

我们的目标是创建一组生成器函数。假设我们已经将生成器函数的结果分配给一个变量,datetime_gen,这些转换允许我们拥有如下所示的软件:

>>> total_time = datetime.timedelta(0) 

>>> total_fuel = 0 

>>> for row in datetime_gen: 

...     total_time += row.engine_off - row.engine_on 

...     total_fuel += ( 

...         float(row.engine_on_fuel_height) - 

...         float(row.engine_off_fuel_height) 

... ) 

>>> print( 

... f"{total_time.total_seconds()/60/60 = :.2f}, " 

... f"{total_fuel = :.2f}")

我们需要设计一个复合函数来创建这个 datetime_gen 生成器。

9.3.2 如何实现...

我们将将其分解为三个独立的迷你配方:

  • 重新排列行。

  • 排除标题行。

  • 创建更有用的行对象。

我们将从将三个物理行重组为一个逻辑行开始。

重新排列行

我们将首先创建一个 row_merge()函数来重新排列数据:

  1. 我们将使用命名元组来定义组合逻辑行的类型:

    from typing import NamedTuple 
    
    class CombinedRow(NamedTuple): 
    
        # Line 1 
    
        date: str 
    
        engine_on_time: str 
    
        engine_on_fuel_height: str 
    
        # Line 2 
    
        filler_1: str 
    
        engine_off_time: str 
    
        engine_off_fuel_height: str 
    
        # Line 3 
    
        filler_2: str 
    
        other_notes: str 
    
        filler_3: str
    

    原始数据有空单元格;我们称它们为 filler_1、filler_2 和 filler_3。保留这些垃圾列可以更容易地调试问题。

  2. CSV 读取器创建的源行将具有 list[str]类型;我们将为此类型提供一个别名,RawRow。函数的定义将接受 RawRow 实例的可迭代对象。它是一个 CombinedRow 对象的迭代器:

    from typing import TypeAlias 
    
    from collections.abc import Iterable, Iterator 
    
    RawRow: TypeAlias = list[str] 
    
    def row_merge( 
    
        source: Iterable[RawRow] 
    
    ) -> Iterator[CombinedRow]: 
    
  3. 函数的主体将消耗源迭代器中的行,跳过空行,构建一个定义 CombinedRow 对象的簇。当第一列非空时,任何之前的簇就完成了,它被产出,然后开始一个新的簇。最后一个簇也需要被产出:

     cluster: RawRow = [] 
    
        for row in source: 
    
            if all(len(col) == 0 for col in row): 
    
                continue 
    
            elif len(row[0]) != 0: 
    
                # Non-empty column 1: line 1 
    
                if len(cluster) == 9: 
    
                    yield CombinedRow(*cluster) 
    
                cluster = row.copy() 
    
            else: 
    
                # Empty column 1: line 2 or line 3 
    
                cluster.extend(row) 
    
        if len(cluster) == 9: 
    
            yield CombinedRow(*cluster)
    

这个初始转换可以用来将 CSV 单元格值的行序列转换为 CombinedRow 对象,其中每个来自三个不同行的字段值都有它们自己的独特属性:

这个转换的第一个输出行将是标题行。下一部分是一个函数来删除这一行:

排除标题行

源 CSV 文件的前三行文本将创建一个不太有用的 CombinedRow 对象。我们将排除一个带有标签而不是数据的行:

  1. 定义一个函数来处理一个由 CombinedRow 对象组成的可迭代集合,创建一个 CombinedRow 对象的迭代器:

    def skip_header_date( 
    
        source: Iterable[CombinedRow] 
    
    ) -> Iterator[CombinedRow]:
    
  2. 函数体消费源数据中的每一行,并产出好的行。它使用 continue 语句来拒绝不想要的行:

      for row in source: 
    
        if row.date == "date": 
    
            continue 
    
        yield row
    

这可以与之前配方中显示的 row_merge()函数结合使用,以提供良好数据的迭代器:

要使合并的数据真正有用,需要几个转换步骤。接下来,我们将查看其中之一,创建正确的 datetime.datetime 对象:

创建更有用的行对象

每行中的日期和时间作为单独的字符串并不很有用。我们将编写的函数可以比这个配方中前两个步骤有稍微不同的形式,因为它适用于每个单独的行。单行转换看起来像这样:

  1. 定义一个新的 NamedTuple 类,指定一个对时间值更有用的类型:

    import datetime 
    
    from typing import NamedTuple 
    
    class DatetimeRow(NamedTuple): 
    
        date: datetime.date 
    
        engine_on: datetime.datetime 
    
        engine_on_fuel_height: str 
    
        engine_off: datetime.datetime 
    
        engine_off_fuel_height: str 
    
        other_notes: str
    
  2. 定义一个映射函数,将一个 CombinedRow 实例转换为一个单一的 DatetimeRow 实例:

    def convert_datetime(row: CombinedRow) -> DatetimeRow:
    
  3. 这个函数的主体将执行多个日期时间计算并创建一个新的 DatetimeRow 实例:

        travel_date = datetime.datetime.strptime( 
    
            row.date, "%m/%d/%y").date() 
    
        start_time = datetime.datetime.strptime( 
    
            row.engine_on_time, "%I:%M:%S %p").time() 
    
        start_datetime = datetime.datetime.combine( 
    
            travel_date, start_time) 
    
        end_time = datetime.datetime.strptime( 
    
            row.engine_off_time, "%I:%M:%S %p").time() 
    
        end_datetime = datetime.datetime.combine( 
    
            travel_date, end_time) 
    
        return DatetimeRow( 
    
            date=travel_date, 
    
            engine_on=start_datetime, 
    
            engine_off=end_datetime, 
    
            engine_on_fuel_height=row.engine_on_fuel_height, 
    
            engine_off_fuel_height=row.engine_off_fuel_height, 
    
            other_notes=row.other_notes 
    
        )
    

现在我们可以堆叠转换函数来合并行、排除标题行并执行日期时间转换。处理过程如下:

>>> row_gen = row_merge(log_rows) 

>>> tail_gen = skip_header_date(row_gen) 

>>> datetime_gen = (convert_datetime(row) for row in tail_gen)

我们已经将重新格式化、过滤和转换问题分解为三个单独的函数。这三个步骤中的每一个都只完成整体工作的一个小部分。我们可以单独测试这三个函数。比能够测试更重要的是能够修复或修改一个步骤,而不会完全破坏整个转换堆栈:

9.3.3 它是如何工作的...

当我们编写一个生成器函数时,参数值可以是一个项目集合,或者它可以是一个任何其他类型的可迭代项目源。由于生成器函数是迭代器,因此可以通过堆叠它们来创建一个生成器函数的管道。一个生成器的结果成为堆叠中下一个生成器的输入:

由这个配方创建的 datetime_gen 对象是由三个独立的生成器组成的组合。一个 for 语句可以从 datetime_gen 生成器表达式收集值。该语句的主体可以打印详细信息并计算正在生成的对象的摘要:

此设计强调每个阶段的微小、增量操作。管道的某些阶段将消耗多个源行以生成单个结果行,在处理过程中重新构建数据。其他阶段则消耗并转换单行,这使得它们可以通过生成器表达式来描述。

整个管道由客户端的需求驱动。请注意,此处理中没有并发。每个函数在 yield 语句处“暂停”,直到客户端通过内置的 next()函数要求更多数据。

最重要的是,可以单独调试和测试各个转换步骤。这种分解有助于创建更健壮和可靠的软件。

9.3.4 更多内容...

为了使这些数据变得有用,需要进行一些其他转换。我们希望将开始和结束时间戳转换为持续时间。我们还需要将燃油高度值转换为浮点数,而不是字符串。

我们有几种方法来处理这些派生数据计算:

  1. 我们可以在我们的生成器函数堆栈中创建额外的转换步骤。这反映了急切计算方法。

  2. 我们还可以在类定义中添加@property 方法。这是一种惰性计算;只有在需要属性值时才会执行。

为了急切地计算额外的燃油高度和体积值,我们可以再次应用设计模式。首先,定义具有所需字段的额外命名元组类。然后,定义一个转换函数,将高度从字符串转换为浮点数。还要定义一个将高度从英寸转换为加仑的转换。这些额外的函数将是小型且易于测试的。

我们现在有一个复杂的计算,它由多个小型(几乎)完全独立的块定义。每个函数只执行创建一行所需的工作,将开销保持在最低。我们可以修改一个部分,而不必深入思考其他部分的工作方式。

9.3.5 参见

  • 请参阅使用 yield 语句编写生成器函数的说明以了解生成器函数的简介。

  • 请参阅第四章中的切片和切块列表说明,以获取有关燃油消耗数据集的更多信息。

  • 请参阅组合 map 和 reduce 转换说明,了解另一种组合操作的方法。

  • 选择子集的三种过滤方法说明详细介绍了过滤函数。

9.4 选择子集 – 三种过滤方法

选择相关行的子集可以称为过滤数据集合。我们可以将过滤视为拒绝不良行或包含期望行。有几种方法可以将过滤函数应用于数据项集合。

在使用堆叠生成器表达式的配方中,我们编写了 skip_header_date()生成器函数来排除一组数据中的某些行。skip_header_date()函数结合了两个元素:一个用于通过或拒绝项的规则,以及数据源。这个生成器函数有一个通用的模式,如下所示:

from collections.abc import Iterable, Iterator 

from typing import TypeVar 

T = TypeVar("T")
 def data_filter_iter( 

    source: Iterable[T] 

) -> Iterator[T]: 

    for item in source: 

        if should_be_passed(item): 

            yield item

这个 data_filter_iter()函数的类型提示强调它是一个可迭代的,从可迭代的源集合中消费类型为 T 的项。对每个项应用一些表达式以确定其是否有效。这个表达式可以定义为单独的函数。我们可以定义相当复杂的过滤器。

设计模式可以总结如下:

N = {x | x ∈ S if f(x)}

新集合 N 是源 S 中的每个项 x,其中过滤器函数 f(x)为真。这个总结强调了过滤器函数 f(x),将其与消耗源和产生结果的技术细节区分开来。

这个数学总结表明 for 语句几乎只是脚手架代码。因为它不如过滤规则重要,所以它可以帮助重构生成器函数并从其他处理中提取过滤功能。

将 for 语句视为脚手架,我们还能如何将过滤器应用于集合中的每个项?我们可以使用两种额外的技术:

  • 我们可以编写一个生成器表达式。

  • 我们可以使用内置的 filter()函数。

这两个都需要重构生成器函数——skip_header_date(),如前所述,在使用堆叠生成器表达式的配方中——以提取决策表达式,使其与周围的 for 和 if 脚手架分离。从这个函数中,我们然后可以转向创建生成器表达式,并使用 filter()函数。

9.4.1 准备工作

在这个配方中,我们将查看本章使用堆叠生成器表达式配方中的燃料消耗数据。关于这些数据的详细信息,请参阅第四章中的切片和切块列表配方。

我们使用了两个生成器函数。第一个,row_merge(),将物理行重新组织成逻辑行。使用命名元组 CombinedRow 为行数据提供了更有用的结构。第二个生成器函数 skip_header_date()拒绝了数据表中的标题行,传递了有用的数据行。

我们将重写 skip_header_date()函数来展示三种不同的提取有用数据的方法。

9.4.2 如何做到...

本配方的第一部分将把“良好数据”规则从生成器函数中重构出来,使其更具有通用性。

  1. 从以下大纲开始编写生成器函数的草稿版本:

    def skip_header_date( 
    
        source: Iterable[CombinedRow] 
    
    ) -> Iterator[CombinedRow]: 
    
      for row in source: 
    
        if row.date == "date": 
    
            continue 
    
        yield row
    
  2. if 语句中的表达式可以被重构为一个函数,该函数可以应用于数据的一行,产生一个 bool 值:

    def pass_non_date(row: CombinedRow) -> bool: 
    
        return row.date != "date"
    
  3. 原始的生成器函数现在可以简化:

    def skip_header_date_iter( 
    
        source: Iterable[CombinedRow] 
    
    ) -> Iterator[CombinedRow]: 
    
        for item in source: 
    
            if pass_non_date(item): 
    
                yield item
    

pass_non_date()函数可以用三种方式使用。如所示,它可以由一个生成器函数使用。它也可以用在生成器表达式中,以及与 filter()函数一起使用。接下来,我们将看看如何编写一个表达式。

在生成器表达式中使用 filter

生成器表达式包括三个部分——项目、一个 for 子句和一个 if 子句——所有这些都包含在括号()中。

  1. 从一个 for 子句开始,将对象分配给一个变量。这个源来自某个可迭代的集合,在这个例子中称为 source:

    (... for item in source)
    
  2. 因为这是一个过滤器,结果表达式应该是 for 子句中的变量:

    (item for item in source)
    
  3. 使用 filter 规则函数 pass_non_date()编写一个 if 子句。

    (item for item in source if pass_non_date(source))
    
  4. 这个生成器表达式可以是一个函数的返回值,该函数为源表达式和结果表达式提供了合适的类型提示。以下是整个函数,因为它非常小:

    def skip_header_gen( 
    
        source: Iterable[CombinedRow] 
    
    ) -> Iterator[CombinedRow]: 
    
        return ( 
    
            item 
    
            for item in source 
    
            if pass_non_date(item) 
    
        )
    

    这个函数返回生成器表达式的结果。这个函数并没有做很多,但它确实给表达式应用了一个名称和一组类型提示。

skip_header_gen()函数使用一个生成器表达式,该表达式将 pass_non_date()函数应用于源集合中的每个项目,以确定它是否通过并保留,或者是否被拒绝。

结果与上面显示的原始 skip_header_date()函数相同。

使用 filter()函数

使用 filter()函数包括两个部分——决策函数和数据来源——作为参数:

  1. 使用 filter()函数将函数应用于源数据:

    filter(pass_non_date, source)
    

filter()函数是一个迭代器,它将给定的函数 pass_non_date()作为规则应用于给定的可迭代对象 data 中的每个项目,以决定是否通过或拒绝。它产生那些 pass_non_date()函数返回 True 的行。

重要的是要注意,没有括号的 pass_non_date 名称是对一个函数对象的引用。

常见的错误是认为函数必须被评估,并包含额外的、不必要的括号使用。

9.4.3 它是如何工作的...

生成器表达式必须包含一个 for 子句以提供数据项的来源。可选的 if 子句可以应用一个条件,保留一些项同时拒绝其他项。在 if 子句中放置一个过滤器条件可以使表达式清晰并表达算法。

生成器表达式有一个重要的限制。作为表达式,它们不能使用 Python 的面向语句的特性。try-except 语句,用于处理异常数据条件,通常很有帮助。

9.4.4 更多内容...

有时,很难编写一个简单的规则来定义有效数据或拒绝无效数据。在许多情况下,可能无法使用简单的字符串比较来识别要拒绝的行。当文件中充满了无关信息时,这种情况就会发生;手动准备的电子表格就存在这个问题。在某些情况下,没有简单的正则表达式可以帮助描述有效数据。

我们经常遇到数据,其中确定有效性的最简单方法就是尝试转换,并将异常的存在或不存在转换为布尔条件。

考虑以下函数,以确定数据行是否具有有效的日期:

import datetime 

def row_has_date(row: CombinedRow) -> bool: 

    try: 

        datetime.datetime.strptime(row.date, "%m/%d/%y") 

        return True 

    except ValueError as ex: 

        return False

这将尝试转换日期。它将拒绝不符合基本格式规则的无效字符字符串。它还将拒绝 2/31/24;虽然数字字符串是有效的,但这不是一个真实日期。

9.4.5 参见

  • 在本章前面的使用堆叠生成器表达式配方中,我们将类似这样的函数放入生成器堆栈中。我们通过将多个作为生成器函数编写的单个映射和过滤操作组合起来,构建了一个复合函数。

9.5 汇总集合 – 如何 reduce

Reduction 是计算集合的总和或最大值等摘要的通用概念。计算均值或方差等统计度量也是 reduce。在本配方中,我们将探讨几种摘要或 reduce 技术。

在本章的介绍中,我们提到 Python 支持三种优雅的处理模式:map、filter 和 reduce。我们在应用于集合的转换配方中看到了映射的示例,在选择子集 – 三种过滤方式配方中看到了过滤的示例。

第三种常见的模式是 reduce。在设计大量处理类的配方和扩展内置集合 – 执行统计的列表配方中,我们看到了计算多个统计值的类定义。这些定义几乎完全依赖于内置的 sum()函数。这是更常见的 reduce 操作之一。

在本配方中,我们将探讨一种泛化求和的方法,从而可以编写多种不同类型的类似 reduce。泛化 reduce 的概念将使我们能够在一个可靠的基础上构建更复杂的算法。

9.5.1 准备工作

最常见的 reduce 操作包括求和、最小值、最大值。这些操作非常常见,因此它们是内置的。另一方面,平均值和方差是在统计模块中定义的 reduce。数学模块有一个 sum 的变体,即 fsum(),它特别适用于浮点数值集合。

求和是财务报告的骨架。它是自使用笔和纸进行财务报告以来电子表格所使用的本质。

求和的数学帮助我们了解运算符是如何将值集合转换为单个值的。下面是使用运算符 + 应用到集合 C = {c[0],c[1],c[2],…,c[n]} 中的值来思考求和函数的数学定义的一种方法:

∑ ci = c0 + c1 + c2 + ⋅⋅⋅+ cn + 0 ci∈C

我们通过将加法运算符 + 拼接到 C 中的值序列来扩展 sum 的定义。

拼接涉及两个项目:一个二元运算符和一个基本值。对于 sum,运算符是 +,基本值是零。对于 product,运算符是 ×,基本值是一。基本值需要是给定运算符的单位元素。

我们可以将这个概念应用到许多算法中,可能简化定义。在这个配方中,我们将定义一个乘积函数。这是 ∏ 运算符,类似于 ∑ 运算符。

9.5.2 如何操作...

下面是如何定义一个实现数字集合乘积的减少操作:

  1. 从 functools 模块导入 reduce() 函数:

    from functools import reduce
    
  2. 选择运算符。对于 sum,它是 +。对于 product,它将是 ×。这些可以用各种方式定义。下面是长版本。稍后还会展示定义必要的二元运算符的其他方式:

    def mul(a: int, b: int) -> int: 
    
        return a * b
    
  3. 选择所需的基本值。对于 sum 的加法单位值是零。对于 product 的乘法单位值是一:

    def prod(values: Iterable[float]) -> float: 
    
        return reduce(mul, values, 1)
    
     def prod(values: Iterable[int]) -> int: 
    
        return reduce(mul, values, 1)
    

我们可以使用这个 prod() 函数来定义其他函数。一个例子是阶乘函数。它看起来是这样的:

def factorial(n: int) -> int: 

    return prod(range(1, n+1))

有多少种六张牌的克里比奇牌手是可能的?二项式计算使用阶乘函数来计算从 52 张牌的牌堆中抽取 6 张牌的方法数:

 ( ) 52 = ----52!--- 6 6!(52− 6)!

下面是一个 Python 实现:

>>> factorial(52) // (factorial(6) * factorial(52 - 6)) 

20358520

对于任何给定的洗牌,大约有 2000 万种不同的克里比奇牌手可能出现。

9.5.3 它是如何工作的...

reduce() 函数的行为好像它有如下定义:

T = TypeVar("T") 

def my_reduce( 

    fn: Callable[[T, T], T], 

    source: Iterable[T], 

    initial: T | None = None 

) -> T:

类型提示显示必须有一个统一的类型 T,它适用于折叠的运算符和折叠的初始值。给定的函数 fn() 必须组合两个类型为 T 的值并返回另一个类型为 T 的值。reduce() 函数的结果也将是这个类型。

此外,在 Python 中,reduce 操作将从左到右遍历值。它将在源集合的前一个结果和下一个项目之间应用给定的二元函数 fn()。当考虑非交换运算符(如减法或除法)时,这个额外细节很重要。

9.5.4 更多...

我们将探讨三个额外的主题。首先,定义操作的方法。之后,我们将探讨在逻辑简化:任意和所有中将 reduce 应用于布尔值。最后,在恒等元素中,我们将探讨各种运算符使用的恒等元素。

操作定义

当为 reduce()函数设计新应用时,我们需要提供一个二元运算符。有三种方法来定义必要的二元运算符。首先,我们可以使用完整的函数定义,如上所示在配方中。还有两种选择。我们可以使用 lambda 对象而不是完整的函数:

from collections.abc import Callable
lmul: Callable[[int, int], int] = lambda a, b: a * b

lambda 对象是一个匿名函数,简化为仅包含两个基本元素:参数和返回表达式。lambda 内部没有语句,只有一个表达式。

我们将 lambda 对象赋值给变量 lmul,这样我们就可以使用表达式 lmul(2, 3)来将 lambda 对象应用于参数值。

当操作是 Python 的内置运算符之一时,我们还有另一个选择——从 operator 模块导入定义:

from itertools import takewhile

这对于所有内置的算术运算符都适用。

考虑正在使用的运算符的复杂性是至关重要的。执行 reduce 操作会将运算符的复杂性增加 n 倍。当应用于集合中的 n 个元素时,O(1)的操作变为 O(n)。对于我们所展示的运算符,如加法和乘法,这符合我们的预期。比 O(1)更复杂的运算符可能会变成性能噩梦。

在下一节中,我们将探讨逻辑简化函数。

逻辑简化:任意和所有

从概念上讲,我们似乎应该能够使用布尔运算符 and 和 or 来进行 reduce()操作。实际上,这涉及到一些额外的考虑。

Python 的布尔运算符具有短路特性:当我们评估表达式 False and 3 / 0 时,结果仅为 False。and 运算符右侧的表达式 3 / 0 永远不会被评估。or 运算符类似:当左侧为 True 时,右侧永远不会被评估。

如果我们想确保一个布尔值序列全部为真,自己构建 reduce()将会做太多工作。一旦看到初始的 False,就没有必要处理剩余的项目。and 和 or 的短路特性与 reduce()函数不匹配。

内置函数 any()和 all()另一方面,是使用逻辑运算符的简化。实际上,any()函数相当于使用 or 运算符的 reduce()。同样,all()函数的行为就像是一个使用 and 运算符的 reduce()。

恒等元素

通常,用于归约的运算符必须有一个恒等元素。这作为初始值提供给 reduce() 函数。当它们应用于空序列时,恒等元素也将是结果。以下是一些常见示例:

  • sum([]) 是零。

  • math.prod([]) 是一个例子。

  • any([]) 是 False。

  • all([]) 是 True。

给定操作的恒等值是定义问题。

在任何() 和 all() 的特定情况下,可以考虑基本折叠操作。恒等元素总是可以折叠而不改变结果。以下是 all() 以显式折叠和运算符的形式看起来是怎样的:

b0 和 b1 和 b2 和 ... 和 bn 和 True

如果 b[0],b[1],b[2],...,b[n] 中的所有值都是 True,那么额外的 True 不会改变值。如果 b[0],b[1],b[2],...,b[n] 中的任何值是 False,同样地,额外的 True 也不会产生影响。

当集合中没有值时,all() 的值是恒等元素,即 True。

9.5.5 参见

  • 在本章中,查看 使用堆叠的生成表达式 菜单,了解在何种情况下可以将 sum() 函数应用于计算总小时数和总燃料量。

9.6 map 和 reduce 转换的结合

在本章的其他菜谱中,我们一直在查看 map、filter 和 reduce 操作。我们已经单独查看过这些函数:

  • 对集合应用转换 菜单展示了 map() 函数。

  • 选择子集 – 三种过滤方式 菜单展示了 filter() 函数。

  • 总结集合 – 如何归约 菜单展示了 reduce() 函数。

许多算法将涉及创建组合函数,这些函数结合了更基本的操作。此外,我们还需要考虑使用迭代器和生成函数的一个深刻限制。

这里有一个这个限制的例子:

>>> typical_iterator = iter([0, 1, 2, 3, 4]) 

>>> sum(typical_iterator) 

10 

>>> sum(typical_iterator) 

0

我们通过手动将 iter() 函数应用于一个字面量列表对象来创建一个值序列的迭代器。第一次 sum() 函数从 typical_iterator 消费值时,它消费了所有五个值。下一次我们尝试将任何函数应用于 typical_iterator 时,将没有更多的值可以消费;迭代器将看起来是空的。根据定义,恒等值(对于求和来说是 0)是结果。

迭代器只能产生一次值。

在值被消费后,迭代器看起来像是一个空集合。

这个一次性约束将迫使我们缓存中间结果,当我们需要对数据进行多次减少时。创建中间集合对象将消耗内存,因此在处理非常大的数据集时需要仔细设计。(处理大量数据集是困难的。Python 提供了一些创建可行解决方案的方法;它并不能神奇地使问题消失。)

要对一个集合应用复杂的转换,我们通常会发现可以单独实现的 map、filter 和 reduce 操作的实例。然后,这些操作可以组合成复杂的复合操作。

9.6.1 准备工作

在本章前面使用堆叠生成器表达式的配方中,我们查看了一些帆船数据。电子表格组织得很差,需要多个步骤来对数据进行更有用的结构化。

在那个配方中,我们查看了一个用于记录大型帆船燃油消耗的电子表格。关于这些数据的详细信息,请参阅第四章中的切片和切块列表配方。我们将在第十一章的使用 CSV 模块读取定界文件配方中更详细地查看解析。

在使用堆叠生成器表达式的配方中,初始处理创建了一系列操作以改变数据的组织结构,过滤掉标题,并计算一些有用的值。我们需要补充两个额外的减少步骤来获取一些平均值和方差信息。这些统计数据将帮助我们更全面地理解数据。我们将在此基础上进行一些额外的步骤。

9.6.2 如何实现...

我们将从目标代码行作为设计目标开始。在这种情况下,我们希望有一个函数来计算每小时的燃油使用量。这遵循了一个常见的三步处理模式。首先,我们使用 row_merge()对数据进行归一化。其次,我们使用映射和过滤通过 clean_data_iter()创建更实用的对象。

第三步应该看起来像以下内容:

>>> round( 

...     total_fuel(clean_data_iter(row_merge(log_rows))), 

...     3 

... ) 

7.0

我们的目标函数 total_fuel()被设计成与几个用于清理和整理原始数据的函数一起工作。我们将从归一化开始,然后定义最终的汇总函数,如下所示:

  1. 从前面的配方中导入函数以重用初始准备:

    from recipe_03 import row_merge, CombinedRow
    
  2. 定义由清理和丰富步骤创建的目标数据结构。在这个例子中,我们将使用可变的数据类。来自归一化 CombinedRow 对象的字段可以直接初始化。其他五个字段将通过几个单独的函数积极计算。在 init()方法中没有计算的字段必须提供一个初始值 field(init=False):

    import datetime 
    
    from dataclasses import dataclass, field 
    
    @dataclass 
    
    class Leg: 
    
        date: str 
    
        start_time: str 
    
        start_fuel_height: str 
    
        end_time: str 
    
        end_fuel_height: str 
    
        other_notes: str 
    
        start_timestamp: datetime.datetime = field(init=False) 
    
        end_timestamp: datetime.datetime = field(init=False) 
    
        travel_hours: float = field(init=False) 
    
        fuel_change: float = field(init=False) 
    
        fuel_per_hour: float = field(init=False)
    
  3. 定义整体数据清洗和丰富数据函数。这将从源 CombinedRow 对象构建丰富的 Leg 对象。我们将从七个更简单的函数构建它。实现是一个 map()和 filter()操作的堆栈,它将从源字段推导数据:

    from collections.abc import Iterable, Iterator 
    
    def clean_data_iter( 
    
        source: Iterable[CombinedRow] 
    
    ) -> Iterator[Leg]: 
    
        leg_iter = map(make_Leg, source) 
    
        fitered_source = filter(reject_date_header, leg_iter) 
    
        start_iter = map(start_datetime, fitered_source) 
    
        end_iter = map(end_datetime, start_iter) 
    
        delta_iter = map(duration, end_iter) 
    
        fuel_iter = map(fuel_use, delta_iter) 
    
        per_hour_iter = map(fuel_per_hour, fuel_iter) 
    
        return per_hour_iter
    

    每个语句都使用前一个语句产生的迭代器。

  4. 编写 make_Leg()函数,从 CombinedRow 实例创建 Leg 实例:

    def make_Leg(row: CombinedRow) -> Leg: 
    
        return Leg( 
    
            date=row.date, 
    
            start_time=row.engine_on_time, 
    
            start_fuel_height=row.engine_on_fuel_height, 
    
            end_time=row.engine_off_time, 
    
            end_fuel_height=row.engine_off_fuel_height, 
    
            other_notes=row.other_notes, 
    
        )
    
  5. 编写 reject_date_header()函数,用于 filter()移除标题行:

    def reject_date_header(row: Leg) -> bool: 
    
        return not (row.date == "date")
    
  6. 编写数据转换函数。我们将从两个日期和时间字符串开始,它们需要变成一个单一的 datetime 对象:

    def timestamp( 
    
        date_text: str, time_text: str 
    
    ) -> datetime.datetime: 
    
        date = datetime.datetime.strptime( 
    
            date_text, "%m/%d/%y").date() 
    
        time = datetime.datetime.strptime( 
    
            time_text, "%I:%M:%S %p").time() 
    
        timestamp = datetime.datetime.combine( 
    
            date, time) 
    
        return timestamp
    
  7. 使用额外的值修改 Leg 实例:

    def start_datetime(row: Leg) -> Leg: 
    
          row.start_timestamp = timestamp( 
    
            row.date, row.start_time) 
    
          return row 
    
    def end_datetime(row: Leg) -> Leg: 
    
          row.end_timestamp = timestamp( 
    
            row.date, row.end_time) 
    
          return row
    

    这种原地更新方法是一种优化,以避免创建中间对象。

  8. 从时间戳计算派生持续时间:

    def duration(row: Leg) -> Leg: 
    
        travel_time = row.end_timestamp - row.start_timestamp 
    
        row.travel_hours = round( 
    
            travel_time.total_seconds() / 60 / 60, 
    
            1 
    
        ) 
    
        return row
    
  9. 计算分析所需的其他任何指标:

    def fuel_use(row: Leg) -> Leg: 
    
        end_height = float(row.end_fuel_height) 
    
        start_height = float(row.start_fuel_height) 
    
        row.fuel_change = start_height - end_height 
    
        return row 
    
    def fuel_per_hour(row: Leg) -> Leg: 
    
        row.fuel_per_hour = row.fuel_change / row.travel_hours 
    
        return row
    

最终的 fuel_per_hour()函数的计算依赖于整个前面的计算堆栈。每个这些计算都是单独进行的,以阐明和隔离计算细节。这种方法允许对隔离的计算进行更改。最重要的是,它允许将每个计算作为一个单独的单元进行测试。

9.6.3 它是如何工作的...

核心概念是从一系列小步骤构建一个复合转换。由于每个步骤在概念上是不同的,这使得理解组合变得相对容易。

在这个菜谱中,我们使用了三种类型的转换:

  • 结构变化。一个初始生成函数将物理行分组到逻辑行中。

  • 过滤器。一个生成函数拒绝无效的行。

  • 丰富。正如我们所见,有两种设计方法可以丰富数据:懒加载和急加载。懒加载方法可能涉及仅在需要时计算的方法或属性。这种设计显示了急加载计算,其中许多字段值是由处理管道构建的。

各种丰富方法通过更新状态化的 Leg 对象、设置计算列值来实现。使用这种状态化对象需要严格按照顺序执行各种丰富转换,因为其中一些(如 duration())依赖于其他转换先执行。

我们现在可以设计目标计算函数:

from statistics import * 

def avg_fuel_per_hour(source: Iterable[Leg]) -> float: 

    return mean(row.fuel_per_hour for row in source) 

def stdev_fuel_per_hour(source: Iterable[Leg]) -> float: 

    return stdev(row.fuel_per_hour for row in source)

这符合我们的设计目标,即能够在原始数据上执行有意义的计算。

9.6.4 更多内容...

正如我们所提到的,我们只能对可迭代数据源中的项目进行一次迭代。如果我们想计算多个平均值,或者平均值以及方差,我们需要使用稍微不同的设计模式。

为了计算多个数据摘要,通常最好创建某种类型的具体对象,该对象可以被反复总结:

def summary(raw_data: Iterable[list[str]]) -> None: 

    data = tuple(clean_data_iter(row_merge(raw_data))) 

    m = avg_fuel_per_hour(data) 

    s = 2 * stdev_fuel_per_hour(data) 

    print(f"Fuel use {m:.2f} {s:.2f}")

在这里,我们从清洗和丰富后的数据创建了一个非常大的元组。从这个元组中,我们可以产生任意数量的迭代器。这使我们能够计算任意数量的不同摘要。

我们还可以使用 itertools 模块中的 tee()函数进行此类处理。由于克隆迭代器的实例保持其内部状态的方式,这可能导致效率低下的处理。通常,创建一个中间结构(如列表或元组)比使用 itertools.tee()更好。

设计模式将多个转换应用于源数据。我们使用单独的映射、过滤和减少操作堆叠构建了它。

9.6.5 参考信息

  • 在本章中查看使用堆叠生成器表达式的配方,了解 sum 函数可以应用于计算总小时数和总燃料量的上下文。

  • 在本章中查看总结集合 – 如何减少的配方,了解 reduce()函数的一些背景信息。

  • 有关分布式 map-reduce 处理的更多信息,请参阅Python High Performance

  • 我们在使用属性进行懒属性的配方中查看懒性属性,在第七章。此外,这个配方还探讨了 map-reduce 处理的某些重要变体。

9.7 实现“存在”处理

我们一直在研究的处理模式都可以用全称量化符∀来概括,意味着对于所有。这一直是所有处理定义的隐含部分:

  • 映射:对于源中的所有项,S,应用映射函数,m(x)。我们可以使用全称量化符:∀[x∈S]m(x)。

  • 过滤:这也意味着对于源中的所有项,S,传递那些过滤函数,f(x),为真的项。在这里,我们也可以使用全称量化符:∀[x∈S]x if f(x)。

  • 减少:对于源中的所有项,使用给定的运算符和基数值来计算摘要。在运算符∑[x∈S]x 和∏[x∈S]x 的定义中隐含了全称量化。

将这些通用函数与仅对我们定位单个项感兴趣的情况进行对比。我们经常将这些情况描述为搜索,以表明至少存在一个项满足条件。这可以用存在量化符∃来描述,意味着存在。

我们需要使用 Python 的一些附加功能来创建生成器函数,当第一个值匹配某个谓词时停止。我们希望模拟内置的 any()和 all()函数的短路功能。

9.7.1 准备工作

以一个存在性测试的例子为例,考虑一个确定一个数是素数还是合数的函数。素数没有除了 1 和它自己之外的因子。具有多个因子的数被称为合数。数字 42 是合数,因为它有 2、3 和 7 作为素数因子。

判断一个数是否为素数等同于证明它不是合数。对于任何合数(或非素数)数,n,规则是这样的:

¬P (n ) = ∃2≤i<n(n ≡ 0 mod i)

如果存在一个值 i,介于 2 和该数本身之间,可以整除该数,则一个数 n 不是素数。为了测试一个数是否是素数,我们不需要知道所有的因子。单个因子的存在表明该数是合数。

整体思路是遍历候选数字的范围,在找到因子时退出迭代。在 Python 中,这种从 for 语句中的提前退出是通过 break 语句完成的,将语义从“对所有”转换为“存在”。因为 break 是一个语句,所以我们不能轻易使用生成器表达式;我们被迫编写生成器函数。

(费马测试通常比我们在这些例子中使用的更有效,但它不涉及简单地搜索因子的存在。我们使用它作为搜索的示例,而不是作为良好素性测试的示例。)

9.7.2 如何做到...

为了构建这种搜索函数,我们需要创建一个生成器函数,当它找到第一个匹配项时将完成处理。一种方法是使用 break 语句,如下所示:

  1. 定义一个生成器函数以跳过项,直到通过测试。生成器可以产生通过谓词测试的第一个值。生成器通过将谓词函数 fn()应用于某些类型 T 的项序列中的项来工作:

    from collections.abc import Callable, Iterable, Iterator 
    
    from typing import TypeVar 
    
    T = TypeVar("T") 
    
    def find_first( 
    
        fn:  Callable[[T], bool], source: Iterable[T] 
    
    ) -> Iterator[T]: 
    
        for item in source: 
    
            if fn(item): 
    
                yield item 
    
                break 
    
  2. 为此应用定义特定的谓词函数。由于我们正在测试是否为素数,我们正在寻找任何可以整除目标数 n 的值。以下是所需的表达式类型:

    lambda i: n % i == 0
    
  3. 使用给定的值范围和谓词应用 find_first()搜索函数。如果因子可迭代有项,则 n 是合数。否则,因子可迭代中没有值,这意味着 n 是素数:

    import math 
    
    def prime(n: int) -> bool: 
    
        factors = find_first( 
    
            lambda i: n % i == 0, 
    
            range(2, int(math.sqrt(n) + 1)) ) 
    
        return len(list(factors)) == 0
    

实际上,我们不需要测试介于两个数和 n 之间的每个数来查看 n 是否为素数。只需要测试满足 2 ≤ i < ⌊√n-⌋的值 i。

9.7.3 它是如何工作的...

在 find_first()函数中,我们引入了 break 语句来停止处理源可迭代。当 for 语句停止时,生成器将到达函数的末尾并正常返回。

消费此生成器值的客户端函数将收到 StopIteration 异常。find_first()函数可以引发异常,但这不是错误;它是可迭代已处理输入值的信号。

在这种情况下,StopIteration 异常意味着以下两种情况之一:

  • 如果之前已经产生了值,则该值是 n 的因子。

  • 如果没有产生值,则 n 是素数。

这种提前从 for 语句中退出的微小变化在生成器函数的意义上产生了巨大的差异。find_first()生成器将停止处理,而不是处理所有源值。

9.7.4 更多...

在 itertools 模块中,find_first() 函数有一个替代方案。takewhile() 函数使用谓词函数从输入中取值,只要谓词函数为真。当谓词变为假时,函数停止消费和产生值。

要使用 takewhile() 函数,我们需要反转我们的因子测试。我们需要消费非因子值,直到我们找到第一个因子。这导致 lambda 表达式从 lambda i: n % i == 0 变为 lambda i: n % i != 0。

让我们看看一个测试,看看 47 是否为质数。我们需要检查 2 到 √ --- 49 = 7 范围内的数字:

>>> from itertools import takewhile 

>>> n = 47 

>>> list(takewhile(lambda i: n % i != 0, range(2, 8))) 

[2, 3, 4, 5, 6, 7]

对于像 47 这样的质数,没有任何测试值是因子。所有这些非因子测试值都通过了 takewhile() 谓词,因为它是始终为真的。结果列表与原始测试值集合相同。

对于一个合数,非因子测试值将是测试值的一个子集。由于找到了一个因子,一些值已被排除。

itertools 模块中还有许多其他函数可以用来简化复杂的 map-reduce 应用。我们鼓励您仔细研究这个模块。

9.7.5 参见

  • 在本章前面的 使用堆叠的生成表达式 菜谱中,我们广泛使用了不可变类定义。

  • 有关小于 200 万的质数相关的一个具有挑战性的问题,请参阅 projecteuler.net/problem=10。问题的某些部分看起来很明显。然而,测试所有这些数字是否为质数可能很困难。

  • itertools 模块提供了许多函数,可以简化函数设计。

  • 在标准库之外,像 Pyrsistent 这样的包提供了函数式编程组件。

9.8 创建一个部分函数

当我们查看 reduce()、sorted()、min() 和 max() 等函数时,我们会看到我们经常会遇到一些参数值几乎不会改变,或者在特定上下文中实际上是固定的。例如,我们可能会在几个地方需要编写类似以下内容:

reduce(operator.mul, ..., 1)

在 reduce() 的三个参数值中,只有一个——要处理的可迭代对象——实际上会改变。操作符和初始值参数值基本上固定在 operator.mul 和 1。

显然,我们可以为这个定义一个新的函数:

from collections.abc import Iterable 

from functools import reduce 

import operator 

def prod(iterable: Iterable[float]) -> float: 

    return reduce(operator.mul, iterable, 1)

Python 有几种方法可以简化这个模式,这样我们就不必重复编写样板 def 和 return 语句。

这个菜谱的目标与提供一般默认值不同。部分函数不提供覆盖默认值的方法。部分函数在定义时绑定特定的值。想法是能够创建许多部分函数,每个函数在预先绑定特定的参数值。这有时也被称为闭包,但应用于一些参数。参见第三章中的基于部分函数选择参数顺序以获取更多部分函数定义的示例。

9.8.1 准备工作

一些统计建模使用标准化值进行,有时称为 z 分数。想法是将原始测量值标准化到一个可以轻松与正态分布比较的值,并且可以轻松与可能以不同单位测量的相关数字比较。

计算过程如下:

z = x−-μ- σ

在这里,x 是一个原始值,μ是总体均值,σ是总体标准差。z 的值将具有 0 的均值和 1 的标准差,提供标准化值。我们可以使用这个值来发现异常值——那些可疑地远离均值的值。我们预计(大约)99.7%的 z 值将在-3 和+3 之间。

我们可以定义一个函数来计算标准分数,如下所示:

def standardize(mean: float, stdev: float, x: float) -> float: 

    return (x - mean) / stdev

这个 standardize()函数将从原始分数 x 计算 z 分数。当我们在这个实际应用中使用这个函数时,我们会看到参数有两种类型的值:

  • 均值和 stdev 参数的值基本上是固定的。一旦我们计算了总体值,我们就必须反复将相同的两个值提供给 standardize()函数。

  • 每次评估 standardize()函数时,x 参数的值都会变化。

让我们处理一个包含两个变量 x 和 y 的数据样本集合。这些对由 DataPair 类定义:

from dataclasses import dataclass 

@dataclass 

class DataPair: 

    x: float 

    y: float 

例如,我们将计算 x 属性的标准化值。这意味着计算 x 值的平均值和标准差。然后,我们需要将平均值和标准差值应用于标准化我们收集中的数据。计算过程如下:

>>> import statistics 

>>> mean_x = statistics.mean(item.x for item in data_1) 

>>> stdev_x = statistics.stdev(item.x for item in data_1) 

>>> for DataPair in data_1: 

...     z_x = standardize(mean_x, stdev_x, DataPair.x) 

...     print(DataPair, z_x)

每次评估 standardize()函数时提供 mean_x 和 stdev_x 值可能会使算法充斥着不重要的细节。

我们可以使用部分函数简化使用具有两个固定参数值和一个可变参数的 standardize()的使用。

9.8.2 如何实现...

为了简化使用具有多个固定参数值的函数,我们可以创建一个部分函数。这个菜谱将展示两种创建部分函数的方法,作为独立的迷你菜谱:

  • 使用 functools 模块中的 partial()函数从完整的 standardize()函数构建一个新的函数

  • 创建一个 lambda 对象来提供不变的参数值

使用 functools.partial()

  1. 从 functools 模块导入 partial() 函数:

    from functools import partial
    
  2. 使用 partial() 创建一个新的函数。我们提供基本函数,以及需要包含的位置参数。当定义 partial 时未提供的任何参数必须在评估 partial 时提供:[firstline=79,lastline=79,gobble=4][python]src/ch09/recipe˙08.py

我们已经为 standardize() 函数的前两个参数,即平均值和标准差,提供了固定值。现在我们可以使用 z() 函数的单个值,z(a),它将评估表达式 standardize(mean_x, stdev_x, a)。

创建 lambda 对象

  1. 定义一个绑定固定参数的 lambda 对象:[firstline=105,lastline=105,gobble=8][python]src/ch09/recipe˙08.py

  2. 将此 lambda 分配给变量以创建一个可调用的对象,z():[firstline=105,lastline=105,gobble=4][python]src/ch09/recipe˙08.py

这为 standardize() 函数的前两个参数,即平均值和标准差,提供了固定值。现在我们可以使用 z() lambda 对象的单个值,z(a),它将评估表达式 standardize(mean_x, stdev_x, a)。

9.8.3 工作原理...

这两种技术都创建了一个可调用的对象——一个函数——名为 z(),它已经将 mean_x 和 stdev_x 的值绑定到前两个位置参数。使用这两种方法中的任何一种,我们现在都可以进行如下处理:

[firstline=107,lastline=108,gobble=4][python]src/ch09/recipe˙08.py

我们已经将 z() 函数应用于每一组数据。因为 z() 是一个部分函数并且已经应用了一些参数,所以它的使用被简化了。

创建 z() 函数的两种技术之间有一个显著的区别:

  • partial() 函数绑定参数的实际值。任何后续对这些变量所做的更改都不会改变创建的部分函数的定义。

  • lambda 对象绑定变量名,而不是值。任何后续对变量值的更改都会改变 lambda 的行为方式。

我们可以稍微修改 lambda 以绑定特定值而不是名称:

[firstline=131,lastline=131,gobble=4][python]src/ch09/recipe˙08.py

这将提取当前 mean_x 和 stdev_x 的值以创建 lambda 对象参数的默认值。mean_x 和 stdev_x 的值现在与 lambda 对象 z() 的正确操作无关。

9.8.4 更多内容...

在创建部分函数时,我们可以提供关键字参数值以及位置参数值。虽然这在一般情况下工作得很好,但还有一些情况它不起作用。

我们开始这个配方时查看 reduce() 函数。有趣的是,这个函数是函数不能轻易转换为部分函数的一个例子。参数的顺序不是创建部分函数的理想顺序,并且它不允许通过名称提供参数值。

reduce() 函数似乎是这样定义的:

def reduce(function, iterable, initializer=None)

如果这是实际的定义,我们可以这样做:

prod = partial(reduce(mul, initializer=1))

实际上,前面的例子会引发一个 TypeError。它不起作用,因为 reduce() 的定义不接受关键字参数值。因此,我们无法轻松地创建使用它的部分函数。

这意味着我们被迫使用以下 lambda 技术:

>>> from operator import mul 

>>> from functools import reduce 

>>> prod = lambda x: reduce(mul, x, 1)

在 Python 中,函数是一个对象。我们已经看到了许多函数可以作为其他函数的参数的方式。接受或返回另一个函数作为参数的函数有时被称为高阶函数。

同样,函数也可以返回一个函数对象作为结果。这意味着我们可以创建一个像这样的函数:

from collections.abc import Sequence, Callable 

import statistics 

def prepare_z(data: Sequence[DataPair]) -> Callable[[float], float]: 

    mean_x = statistics.mean(item.x for item in data_1) 

    stdev_x = statistics.stdev(item.x for item in data_1) 

    return partial(standardize, mean_x, stdev_x)

这里,我们定义了一个在类型为 DataPair 的序列上的函数,这些是 (x,y) 样本。我们计算了每个样本的 x 属性的平均值和标准差。然后我们创建了一个部分函数,可以根据计算出的统计数据标准化分数。这个函数的结果是一个我们可以用于数据分析的函数。

以下示例显示了如何使用这个新创建的函数:

>>> z = prepare_z(data_1) 

>>> for DataPair in data_1: 

...     print(DataPair, z(DataPair.x))

prepare_z() 函数的结果是一个可调用对象,它将根据计算出的平均值和标准差标准化分数。

9.8.5 参考阅读

  • 参见第三章中的 Picking an order for parameters based on partial functions 以获取部分函数定义的更多示例。

9.9 使用 yield from 语句编写递归生成器函数

许多算法可以简洁地表示为递归。在围绕 Python 的堆限制设计递归函数的配方中,我们查看了一些可以优化以减少函数调用的递归函数。

当我们查看某些数据结构时,我们发现它们涉及递归。特别是,JSON 文档(以及 XML 和 HTML 文档)可以具有递归结构。JSON 文档是一个复杂对象,它可以包含其内的其他复杂对象。

在许多情况下,使用生成器处理这些结构具有优势。在这个配方中,我们将查看处理递归数据结构的方法。

9.9.1 准备工作

在这个配方中,我们将查看一种在复杂、递归数据结构中搜索所有匹配值的方法。当处理复杂的 JSON 文档时,它们通常包含 dict-of-dict、dict-of-list、list-of-dict 和 list-of-list 结构。当然,JSON 文档不仅限于两层;dict-of-dict 实际上可以意味着 dict-of-dict-of...。同样,dict-of-list 可以意味着 dict-of-list-of...。搜索算法必须遍历整个结构以查找特定的键或值。

一个具有复杂结构的文档可能看起来像这样:

document = { 

    "field": "value1", 

    "field2": "value", 

    "array": [ 

        {"array_item_key1": "value"}, 

        {"array_item_key2": "array_item_value2"} 

    ], 

    "object": { 

        "attribute1": "value", 

        "attribute2": "value2" 

    }, 

}

值 "value" 可以在三个地方找到:

  • ["array", 0, "array_item_key1"]:此路径从顶级字段名为 array 开始,然后访问列表中的第 0 个项目,然后是名为 array_item_key1 的字段。

  • ["field2"]:此路径只有一个字段名,其中找到了值。

  • ["object", "attribute1"]:此路径以顶级字段 object 开头,然后是该字段的子字段 attribute1。

find_value()函数应在搜索整个文档以查找目标值时产生所有这些路径。这个算法的核心是深度优先搜索。此函数的输出必须是一个路径列表,用于标识目标值。每个路径将是一个字段名序列或字段名与索引位置的混合。

9.9.2 如何操作...

我们将从概述深度优先算法开始,以访问 JSON 文档中的所有节点。

  1. 从处理整体数据结构中每个替代结构的函数草图开始。以下是导入和一些类型提示:

    from collections.abc import Iterator 
    
    from typing import Any, TypeAlias 
    
    JSON_DOC: TypeAlias = ( 
    
        None | str | int | float | bool | dict[str, Any] | list[Any] 
    
    ) 
    
    Node_Id: TypeAlias = Any
    

    这里是函数的草图:

    def find_value_sketch( 
    
        value: Any, 
    
        node: JSON_DOC, 
    
        path: list[Node_Id] | None = None 
    
    ) -> Iterator[list[Node_Id]]: 
    
        if path is None: 
    
            path = [] 
    
        match node: 
    
            case dict() as dnode: 
    
                pass  # apply find_value to each key in dnode 
    
            case list() as lnode: 
    
                pass  # apply find_value to each item in lnode 
    
            case _ as pnode: # str, int, float, bool, None 
    
                if pnode == value: 
    
                    yield path
    
  2. 这里是一个起始版本,用于查看字典的每个键。这替换了前面代码中的# apply find_value to each key in dnode 行。测试以确保递归正常工作:[firstline=58,lastline=60,gobble=8][python]src/ch09/recipe˙10.py

  3. 将内部的 for 循环替换为 yield from 语句:[firstline=98,lastline=100,gobble=8][python]src/ch09/recipe˙10.py

  4. 这也必须应用于列表情况。开始检查列表中的每个项目:[firstline=62,lastline=64,gobble=8][python]src/ch09/recipe˙10.py

  5. 将内部的 for 循环替换为 yield from 语句:[firstline=102,lastline=104,gobble=8][python]src/ch09/recipe˙10.py

当完整时,完整的深度优先 find_value()搜索函数将看起来像这样:

def find_value( 

    value: Any, 

    node: JSON_DOC, 

    path: list[Node_Id] | None = None 

) -> Iterator[list[Node_Id]]: 

    if path is None: 

        path = [] 

    match node: 

        case dict() as dnode: 

            for key in sorted(dnode.keys()): 

                yield from find_value( 

                    value, node[key], path + [key]) 

        case list() as lnode: 

            for index, item in enumerate(lnode): 

                yield from find_value( 

                    value, item, path + [index]) 

        case _ as pnode: 

            # str, int, float, bool, None 

            if pnode == value: 

                yield path

当我们使用 find_value()函数时,它看起来像这样:

>>> places = list(find_value(’value’, document)) 

>>> places 

[[’array’, 0, ’array_item_key1’], [’field2’], [’object’, ’attribute1’]]

结果列表有三个项目。这些项目中的每一个都是一个键列表,这些键构成了一条到具有“value”目标值的项的路径。

9.9.3 它是如何工作的...

关于背景信息,请参阅本章中的使用 yield 语句编写生成器函数配方。

yield from 语句是以下内容的简写:

[firstline=135,lastline=136,gobble=8][python]src/ch09/recipe˙10.py

yield from 语句让我们能够编写简洁的递归算法,该算法将表现得像一个迭代器,并正确地产生多个值。它节省了样板 for 语句的开销。

这也可以用于不涉及递归函数的上下文中。在任何涉及可迭代结果的上下文中使用 yield from 语句是完全合理的。对于递归函数来说,这是一个方便的简化,因为它保留了清晰的递归结构。

9.9.4 还有更多...

另一种常见的定义方式是使用 append 操作来组装项目列表。我们可以将其重写为一个迭代器,以避免构建和修改列表对象的开销。

在分解一个数字时,我们可以定义一个数字 x 的质因数集合,如下所示:

 ( |{ F(x) = x if x is prime |( x √-- n∪ F (n) if x ≡ 0 mod n and 2 ≤ n ≤ x

如果值 x 是质数,它在质因子集合中只有它自己。否则,必须有一些质数 n,它是 x 的最小因子。我们可以从这个数字 n 开始组装一个因子集合,然后追加 xn 的所有因子。为了确保只找到质因子,n 必须是质数。如果我们从 2 开始搜索 n 的递增值,我们将在找到合数因子之前找到质数因子。

一种急切的方法是构建一个完整的因子列表。一种懒惰的方法可以是生成器,为消费者提供因子。以下是一个急切构建列表的函数:

import math 

def factor_list(x: int) -> list[int]: 

    limit = int(math.sqrt(x) + 1) 

    for n in range(2, limit): 

        q, r = divmod(x, n) 

        if r == 0: 

            return [n] + factor_list(q) 

    return [x]

这个 factor_list() 函数将构建一个列表对象。如果找到一个因子 n,它将以该因子开始一个列表。然后,它将使用 x // n 的值构建的因子扩展列表。如果没有 x 的因子,那么这个值是质数,并且它将返回一个只包含 x 值的列表。

(这有一个效率低下的原因,因为它以这种方式搜索合数和质数。例如,在测试 2 和 3 之后,它还将测试 4 和 6,即使它们是合数,并且它们的所有因子都已经测试过了。这个例子集中在列表构建上,而不是高效地分解数字。)

我们可以通过用 yield from 语句替换递归调用将此重写为迭代器。函数将看起来像这样:

def factor_iter(x: int) -> Iterator[int]: 

    limit = int(math.sqrt(x) + 1) 

    for n in range(2, limit): 

        q, r = divmod(x, n) 

        if r == 0: 

            yield n 

            yield from factor_iter(q) 

            return 

    yield x

当找到一个因子时,该函数将产生因子 n,然后通过递归调用 factor_iter() 找到所有其他因子。如果没有找到因子,该函数将产生质数 x,不再产生其他内容。

使用迭代器允许此函数的客户从因子构建任何类型的集合。我们不仅可以创建列表对象,还可以使用 collections.Counter 类创建多重集。它看起来像这样:

>>> from collections import Counter 

>>> Counter(factor_iter(384)) 

Counter({2: 7, 3: 1})

这表明:

384 = 27 × 3

在某些情况下,这种多重集可能比简单的因子列表更容易处理。

重要的是,这个多重集是直接从 factor_iter() 迭代器创建的,而没有创建任何中间列表对象。这种优化让我们能够构建不强制消耗大量内存的复杂算法。

9.9.5 参见

  • 在本章前面的围绕 Python 栈限制设计递归函数配方中,我们介绍了递归函数的核心设计模式。这个配方提供了一种创建结果的替代方法。

  • 关于背景,请参阅本章中的使用 yield 语句编写生成器函数配方。

加入我们的社区 Discord 空间

加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

图片

第十章:10

使用类型匹配和注释进行工作

本章将探讨我们如何与具有各种数据类型的数据结构一起工作。这通常意味着检查属性的类型、元组的一个元素或字典中的一个值。

在前面的章节中,我们避免过多地关注数据验证的考虑。在本章中,我们将仔细检查输入值,以确保它们符合预期的数据类型和值范围。

这种数据验证是一种类型检查。它验证的值域比整数或字符串等非常广泛的类要窄。应用程序必须检查对象的值,以确保它们适用于预期的目的。

一些数据结构,如 JSON 或 XML 文档,可以包含各种数据类型的对象。一个常见的情况可以总结为第一范式(1NF),其中集合中的每个项目都是同一类型。然而,这并不普遍。当解析复杂的文件,如编程语言语句时,我们会看到一系列不同的数据类型。不同类型的存在意味着应用程序软件不能简单地假设一个单一、一致的类型,而必须处理可用的数据。

在本章中,我们将探讨与类型和类型匹配相关的多个配方:

  • 使用类型提示进行设计

  • 使用内置的类型匹配函数

  • 使用 match 语句

  • 处理类型转换

  • 使用 Pydantic 实现更严格的类型检查

  • 包含运行时有效值检查

10.1 使用类型提示进行设计

函数定义中的注释在 2006 年引入到语言语法中,没有正式的语义。注释的想法附带了一系列潜在的使用案例,其中之一是类型检查。在 2014 年,类型提示的想法得到了巩固和正式化,成为了一个类型模块和一些相关工具,包括 mypy 工具。

几年前,注释是一种通用的语法,类型提示是注释的一个特定用例。到 2017 年,注释的其他用途已被弃用,并且注释语法明确专注于类型提示。虽然注释和类型提示之间曾经存在细微的差别,但这种区别已经消失,留下了两个同义词。

使用类型提示有三个重要方面:

  • 类型提示是可选的。我们可以不使用类型提示来编写 Python 代码。

  • 类型提示可以逐步应用。应用程序的一部分可以有提示,而另一部分则没有。像 mypy 这样的工具可以容忍带有和不带有提示的代码的混合。

  • 类型提示在运行时不会被使用,并且没有性能开销。

在整本书中,我们将提示视为良好软件设计的必要条件。它们与单元测试和连贯的文档一样重要,这两者也是技术上的可选项,但对于可信赖的软件来说是必不可少的。我们发现它们通过强制执行一定程度的严谨性和正式性来帮助防止问题。

Python 的处理依赖于鸭子类型规则。有关更多背景信息,请参阅第八章,特别是 Leveraging Python’s duck typing 食谱。我们有两种广泛的设计模式可供选择:

  • 具有共同超类的一严格层次结构。

  • 利用鸭子类型,一组类可以具有共同特性,通常定义为指定相关特性的协议。

在这个食谱中,我们将探讨两种设计包含类型提示且可由 mypy 等工具检查的代码的方法。

10.1.1 准备工作

我们将研究一个涉及处理在源文件中混合在一起的两个不同类型数据的问题。在这种情况下,我们将使用具有大量数据文件的数据目录的内容进行分类。此外,我们还有一个 src 目录,其中包含大量包含应用程序程序和脚本的子目录。我们想要创建一组数据结构来表示两种不同的数据文件类:

  • 未被任何应用程序程序或脚本命名的数据文件

  • 被一个或多个应用程序程序引用的数据文件

10.1.2 如何做...

设计此类程序有两种广泛的方法:

  • 首先概述数据类型和转换,然后编写代码以适应这些类型。

  • 首先编写代码,然后为工作代码添加类型提示。

都不能说是最好的。在许多情况下,两者是并行发展的。

我们将在本食谱中分别查看这些内容的各种变体。

首先类型提示设计

我们将处理各种类的对象。在这个变体中,我们将首先定义类型提示,然后填写所需的处理。以下是从类定义开始定义分类器和相关类的方法:

  1. 定义两个子类。在这个例子中,我们将它们称为未引用文件和引用文件。对于每个类,写一句话来描述每个类实例的独特用途。这些将作为类定义的起点。

  2. 选择合适的可用类。这可能是一个具有可变属性的普通类、一个 NamedTuple 或一个@dataclass。通常从@dataclass 开始可以提供最大的灵活性。在命名元组、dataclasses 和冻结数据 classes 之间切换涉及最小的语法变化:

    from pathlib import Path 
    
    from dataclasses import dataclass 
    
    @dataclass 
    
    class Referenced: 
    
        """Defines a data file and applications that reference it."""
    

    未引用类定义将与适当的文档字符串相似。

  3. 添加定义每个实例状态的属性和值。对于 Referenced 类,这是 Path 以及每个有引用的源文件的 Path 对象集合。这两个属性定义看起来是这样的:

    datafile: Path 
    
        recipes: list[Path] 
    

    对于 Unreferenced 类,实际上并没有很多其他属性,除了路径。这提出了一个有趣的问题:这值得一个单独的类声明,还是它可以简单地是一个 Path 对象?

    由于 Python 允许类型别名和类型联合,实际上不需要 Unreferenced 类;现有的 Path 就足够了。提供这个类型别名是有帮助的:

    from typing import TypeAlias 
    
    Unreferenced: TypeAlias = Path
    
  4. 正式化这些不同类的联合。

    ContentType: TypeAlias = Unreferenced | Referenced
    

现在我们有了类型定义,我们可以编写一个函数,该函数是 ContentType 类联合的迭代器。这个函数将产生一系列 Unreferenced 和 Referenced 对象,每个数据文件一个。

函数可能看起来是这样的:

def datafile_iter(base: Path) -> Iterator[ContentType]: 

    data = (base / "data") 

    code = (base / "src") 

    for path in sorted(data.glob("*.*")): 

        if not path.is_file(): 

            continue 

        used_by = [ 

            chap_recipe.relative_to(code) 

            for chap_recipe in code.glob("**/*.py") 

            if ( 

                chap_recipe.is_file() 

                and "__pycache__" not in chap_recipe.parts 

                and ".venv" not in chap_recipe.parts 

                and "ch10" not in chap_recipe.parts 

                and path.name in chap_recipe.read_text() 

            ) 

        ] 

        if used_by: 

            yield Referenced(path.relative_to(data), used_by) 

        else: 

            yield path.relative_to(data)

datafile_iter()函数会跳过数据目录中的任何非文件名。它还会跳过一些源代码目录、pycache 和.venv。此外,我们必须忽略第十章中的一些文件,因为这些文件将包含数据文件名称的测试用例,从而产生令人困惑的结果。

如果数据文件名出现在源文件中,引用将被保存在 used_by 集合中。具有非空 used_by 集合的文件将创建一个 Referenced 实例。其余的文件是 Path 对象;由于 TypeAlias,这些也被识别为 Unreferenced 实例。我们不需要正式地将 Path 对象转换为 Unreferenced 类型。像 mypy 这样的工具将使用 TypeAlias 来查看等价性,而无需任何额外的代码。

结果迭代器提供了一系列不同类型的对象。在使用 match 语句的配方中,我们将探讨处理不同类型对象的便捷方法。

首先进行代码设计

我们将处理多种类的对象。在这个变体中,我们将首先定义处理程序,然后加入类型提示以阐明我们的意图。以下是从函数定义开始定义分类器和相关类的方法:

  1. 从提供所需参数的函数定义开始:

    def datafile_iter(base): 
    
        data = (base / "data") 
    
        code = (base / "src")
    
  2. 编写处理程序以累积所需的数据值。在这种情况下,我们需要遍历数据文件名称。对于每个数据文件,我们需要在所有源文件中查找引用。

        for path in sorted(data.glob("*.*")): 
    
            if not path.is_file(): 
    
                continue 
    
            used_by = [ 
    
                chap_recipe.relative_to(code) 
    
                for chap_recipe in code.glob("**/*.py") 
    
                if ( 
    
                        chap_recipe.is_file() 
    
                        and "__pycache__" not in chap_recipe.parts 
    
                        and ".venv" not in chap_recipe.parts 
    
                        and "ch10" not in chap_recipe.parts 
    
                        and path.name in chap_recipe.read_text() 
    
                ) 
    
            ]
    
  3. 决定函数的各种输出需要什么。在某些情况下,我们可以产生包含各种可用值的元组对象。

            if used_by: 
    
                yield (path.relative_to(data), used_by) 
    
            else: 
    
                yield path.relative_to(data)
    

    对于源中没有引用的路径,我们产生 Path 对象。对于源中有引用的路径,我们可以产生数据 Path 和源 Path 实例的列表。

  4. 对于具有更复杂内部状态的对象,考虑引入类定义以正确封装状态。对于这个例子,引入一个具有引用的数据文件类型是有意义的。这将导致用以下类似的 NamedTuple 替换一个简单、匿名的元组:

    from typing import NamedTuple 
    
    class Referenced(NamedTuple): 
    
        datafile: Path 
    
        recipes: list[Path]
    

    这反过来又导致对 Referenced 实例的 yield 语句进行修订。

    yield Referenced(path.relative_to(data), used_by) 
    
  5. 回顾函数定义以添加类型提示。

    def datafile_iter_2(base: Path) -> Iterator[Path | Referenced]:
    

调方程序的两个变体中的处理几乎相同。差异在于如何最好地呈现结果的选择。在前一个例子中,创建了一个显式的联合类型 Content_Type。对于这个版本,联合类型是隐式的。

10.1.3 它是如何工作的...

Python 的鸭子类型允许在设计中有很大的灵活性。我们可以从类型定义开始,也可以从代码开始并添加类型提示。最终的代码往往会相似,因为它对相同的数据执行相同的处理。

代码优先或类型优先的选择可能会导致对性能或优化的洞察。每个选择都强调最终代码的特定属性。代码优先的方法可能强调简单的处理,而类型优先可能强调正在处理的对象的统一性。选择方法也可能源于作者对 Python 类型的舒适度。

在某些情况下,编写类型提示的过程可能会暗示算法或优化。这可能导致对已编写代码的有益重构。

需要注意的是,类型提示的存在与否对性能没有影响。任何性能的提升(或损失)都是普通的设计问题,使用类型提示可能会使这些问题更加明显。

10.1.4 更多内容...

当将一个大问题分解成更小的部分时,小部分之间的接口是设计过程中必须早期做出的关键设计决策。对数据结构的早期决策通常会导致整体上采用类型优先的设计过程。面向外部的组件必须具有明确定义接口。支持这些外部组件的函数或方法可以设计得更加自由,约束更少。

这导致在复杂软件的整体架构中首先考虑类型,当在更详细的层次上工作时,保留类型优先或代码优先的设计选择。当我们考虑分布式应用程序——如网络服务——其中服务器和客户端位于不同的机器上时,我们发现类型优先是必不可少的。

随着代码量的增加,类型提示的重要性也在增加。很难将大量细节记住在脑海中。有一个类型提示来总结更复杂的数据结构可以减少代码周围的细节杂乱。

在分布式计算环境中,我们经常需要考虑某些组件可能不是 Python 程序。在这些情况下,我们无法共享 Python 类型提示。这意味着我们被迫使用存在于 Python 之外的模式定义,但它提供了对 Python 类型的所需映射。

跨越语言的这类正式定义的例子包括 JSON Schema、Protocol Buffers、AVRO 以及许多其他。JSON Schema 方法是典型的,并且被许多 Python 工具支持。在本章后面,我们将探讨使用 Pydantic,它支持使用 JSON Schema 定义数据。

10.1.5 参见

  • 在第十一章的阅读 JSON 和 YAML 文档配方中,我们将重新使用 JSON 文档来处理复杂数据。

  • 在本章后面的使用 match 语句配方中,我们将探讨如何使用 match 语句处理各种类型的数据。这使得处理类型联合相对容易。

  • 在本章后面的使用 Pydantic 实现更严格的类型检查配方中,我们将探讨使用 pydantic 包进行更强的类型定义。

10.2 使用内置类型匹配函数

当我们有一个混合类型的对象集合时,我们通常需要区分这些类型。当我们使用自己定义的类时,我们可以定义正确多态的类。这通常不是使用 Python 的内部对象或处理涉及我们定义的类和 Python 内置类的数据集合的情况。

当我们完全使用自己的类时,我们可以设计它们具有共同的方法和属性,但根据涉及哪个子类提供不同的行为。这种设计符合 S.O.L.I.D 设计原则中的“L”原则:Liskov 替换原则。任何子类都可以替换基类使用,因为它们都有一个共同的方法定义集。有关更多信息,请参阅第八章。

这种以抽象驱动的设 计在 Python 中并不总是需要的。由于 Python 的鸭子类型,设计不需要一个共同的基类。在某些情况下,这甚至不切实际:我们可能有多种类型而没有统一的抽象。与内置类以及我们自己的类定义中的对象混合工作是非常常见的。我们不能对内置类施加多态性。

我们如何利用内置函数来编写对类型灵活的函数和方法?对于这个配方,我们将重用本章前面使用类型提示进行设计配方中的处理。

10.2.1 准备工作

在使用类型提示进行设计的配方中,我们定义了一个名为 datafile_iter()的函数,该函数发出两种不同的对象:Path 对象和 Referenced 对象。一个 Referenced 对象是一组 Path 实例的集合,显示一个被一个或多个应用程序使用的数据文件。一个独立的 Path 对象是一个未被任何应用程序使用的数据文件。这些未引用的路径是移除以减少杂乱的对象。

我们需要以不同的方式处理这两类对象。它们由单个生成函数 datafile_iter()创建。此函数发出一系列未引用和引用实例。这种混合意味着应用程序必须通过类型过滤对象。

应用程序将与一系列对象一起工作。这些对象将由以下定义的函数创建:

from collections.abc import Iterator 

DataFileIter: TypeAlias = Iterator[Unreferenced | Referenced] 

def datafile_iter(base: Path) -> DataFileIter:

datafile_iter()函数将生成一系列未引用和引用的对象。这将反映给定目录中文件的状态。一些将在源代码中有引用;其他则没有任何引用。请参阅使用类型提示进行设计配方中的此函数。

10.2.2 如何操作...

执行分析的应用程序函数将消费各种类型的对象。该函数设计如下:

  1. 从以下定义开始,该定义显示了消耗的类型:

    from collections.abc import Iterable 
    
    def analysis(source: Iterable[Unreferenced | Referenced]) -> None:
    
  2. 创建一个空列表,该列表将保存具有引用的数据文件。编写 for 语句以从源可迭代对象中消费对象,并填充该列表:

        good_files: list[Referenced] = [] 
    
        for file in source:
    
  3. 为了通过类型区分对象,我们可以使用 isinstance()函数来查看一个对象是否是给定类型的类。

    要区分类,请使用 isinstance()函数:

            if isinstance(file, Unreferenced): 
    
                print(f"delete {file}") 
    
            elif isinstance(file, Referenced): 
    
                good_files.append(file)
    
  4. 虽然技术上是不必要的,但似乎总是明智地包括一个 else 条件,在不太可能的情况下,如果 datafile_iter 函数以某种惊人的方式更改,则引发异常:

            else: 
    
                raise ValueError(f"unexpected type {type(file)}")
    

    关于此设计模式的更多信息,请参阅第二章中的设计复杂的 if...elif 链配方。

  5. 编写最终的总结:

        print(f"Keep {len(good_files)} files")
    

10.2.3 它是如何工作的...

isinstance()函数检查一个对象属于哪些类。第二个参数可以是单个类或替代类的元组。

重要的是要注意,一个对象通常有许多父类,形成一个从类对象起源的晶格。如果使用多重继承,可以通过超级类定义有大量的路径。isinstance()函数检查所有替代父类。

isinstance()函数不仅了解在应用程序中导入和定义的类,还了解 TypeAlias 名称。这为我们提供了很大的灵活性,可以在类型提示中使用有意义的名称。

在 Python 3.12 中,TypeAlias 构造可以替换为新的 type 语句:

type Unreferenced = Path

有关 mypy 工具对类型语句的支持的更多信息,请参阅Mypy Issue #15238

在这个问题得到解决之前,我们选择在这本书中使用 TypeAlias。

10.2.4 更多内容...

isinstance()函数是与 filter()高阶函数配合得很好的布尔函数。有关更多信息,请参阅第九章(ch013_split_000.xhtml#x1-5270004)中的选择子集 – 三种过滤方式配方。

除了内置的 isinstance()函数用于查询对象外,还有一个 iscsubclass()函数允许应用程序检查类型定义。区分类的实例和类对象很重要;iscsubclass()函数用于检查类型定义。iscsubclass()函数通常用于元编程:关注软件本身而不是应用程序数据的软件。当设计处理对象类型的函数而不是对象时,iscsubclass()函数是必要的。

在检查对象类型时,match 语句通常比 isinstance()函数更好。原因是 match 语句的 case 子句具有非常复杂的类型模式匹配,而 isinstance()函数仅限于确保对象在其父类中具有给定的类(或类元组中的类)。

10.2.5 另请参阅

  • 有关替代方案,请参阅使用 match 语句配方。

  • 有关与玩牌和它们所涉及的有趣类层次结构相关的多个配方,请参阅第七章(ch011_split_000.xhtml#x1-3760007)和第八章(ch012.xhtml#x1-4520008)。

10.3 使用 match 语句

定义一组紧密相关的类型的一个重要原因是为了区分应用于对象的处理方式。提供不同行为的一种技术是通过使用多态设计:多个子类提供了对公共函数的不同实现。当我们完全使用自己的类时,我们可以设计它们具有共同的方法和属性,但根据涉及哪个子类提供不同的行为。这一点在第八章(ch012.xhtml#x1-4520008)中有详细说明。

当与 Python 的内部对象一起工作,或者与涉及我们定义的类和 Python 内部部分内置类的数据集合一起工作时,通常不可能实现这一点。在这些情况下,简单地依赖类型匹配来实现不同的行为会更简单。本章中使用内置类型匹配函数配方中展示了一种方法。

我们还可以使用 match 语句来编写灵活的函数和方法,这些函数和方法可以处理各种类型的参数值。对于这个配方,我们将重用本章前面提到的使用类型提示进行设计和使用内置类型匹配函数配方中的处理过程。

10.3.1 准备工作

在使用类型提示进行设计配方中,我们定义了一个 datafile_iter()函数,该函数发出两种不同的对象类型:Path 对象和 Referenced 对象。

我们需要以不同的方式处理这两种类型的对象。这种混合意味着应用程序必须通过它们的类型来过滤它们。

10.3.2 如何做...

应用程序将处理一系列不同类型的对象。函数设计如下:

  1. 从以下定义开始,显示消耗的类型:

    from collections.abc import Iterable 
    
    def analysis(source: Iterable[Unreferenced | Referenced]) -> None:
    

    这个函数将消耗一个对象的可迭代序列。这个函数将计算具有引用的对象数量。它将建议删除没有引用的文件。

  2. 创建一个空列表,用于存储具有引用的数据文件。编写 for 语句以从源可迭代中消耗对象:

        good_files: list[Referenced] = [] 
    
        for file in source:
    
  3. 使用文件变量开始编写 match 语句:

            match file:
    
  4. 要处理各种类别的文件,创建显示必须匹配的对象类型的 case 语句。这些 case 语句在 match 语句内缩进:

    case Unreferenced() as unref: 
    
                    print(f"delete {unref}") 
    
                case Referenced() as ref: 
    
                    good_files.append(file) 
    
  5. 虽然技术上不是必需的,但似乎总是明智地包括一个 case : condition。 将匹配任何内容。在这个子句的主体中,如果 datafile_iter 函数以某种惊人的方式被更改,可能会抛出异常:

                case _: 
    
                    raise ValueError(f"unexpected type {type(file)}")
    

    更多关于这种设计模式的信息,请参阅第二章中的设计复杂的 if...elif 链配方。

  6. 编写最终的总结:

        print(f"Keep {len(good_files)} files")
    

10.3.3 它是如何工作的...

match 语句使用一系列 case 子句来建立与给定对象匹配的类。虽然存在许多不同的 case 子句,但一个常见的 case 是 case class() as name: variant,称为类模式。在括号内,我们可以提供子模式来匹配具有特定参数类型的对象。

对于这个例子,我们不需要更复杂的匹配模式。我们可以提供一个看起来像实例的东西——由类名和()组成——以表明 case 子句将匹配类的实例。不需要有关实例结构的任何额外细节。

case Unreferenced()的使用几乎看起来像表达式 Unreferenced()将创建 Unreferenced 类的一个实例。这里的意图不是创建一个对象,而是编写一个看起来非常像对象创建的表达式。这种语法有助于阐明使用 case 来匹配命名类中任何对象的意图。

其他模式允许匹配简单的字面值、序列和映射,以及类。此外,还有方法提供替代方案组,甚至可以通过与模式匹配一起使用的守卫条件应用额外的过滤。

case _ 子句是一个通配符子句。它将匹配在匹配语句中提供的任何内容。_ 变量名在这里有特殊意义,并且只能使用这个变量。

这个设计的关键是 case 定义的清晰性。这些比一系列 elif 子句中的 isinstance()函数评估更易于阅读。

10.3.4 更多内容...

我们将扩展这个配方,展示这些 case 子句中一些复杂的类型匹配。考虑我们想要从只包含一个引用它的应用程序列表中的引用文件中分离出来的情况。

我们正在寻找看起来像这个具体示例的对象:

single use: Referenced(datafile=PosixPath(’race_result.json’), recipes=[PosixPath(’ch11/recipe_06.py’)])

这种情况可以总结为 Referenced(_, [Path()])。我们想要匹配一个 Referenced 类的实例,其中第二个参数是一个包含单个 Path 实例的列表。

这变成了一个新的 case 子句。以下是新的、更具体的 case 子句,后面跟着更一般的 case 子句:

 case Referenced(_, [Path()]) as single: 

                print(f"single use: {single}") 

                good_files.append(single) 

            case Referenced() as multiple: 

                good_files.append(multiple)

匹配语句按顺序处理情况。更具体的情况必须先于更不具体的情况。如果我们颠倒这两个情况的顺序,case Referenced()将先匹配,而 case Referenced(_, [Path()])甚至不会被检查。最一般的情况,case _:,必须是最后的。

10.3.5 参考信息

  • 请参阅使用内置类型匹配函数的配方,了解使用内置 isinstance()函数的替代方法。

  • 请参阅第八章,了解与多态类设计相关的几个配方。有时,这可以减少对类型匹配的需求。

10.4 处理类型转换

Python 的一个有用特性是“数值塔”概念。请参阅 Python 标准库文档中的数值塔。这个概念是指数值可以从整数移动到有理数,再到实数,最后到复数,沿着塔“向上”移动。

数值转换基于这样的想法,即存在几个重叠的数值域。这些包括ℤ整数、ℚ有理数、ℙ无理数、ℝ实数和ℂ复数。这些形成了一个嵌套的集合系列:ℤ ⊂ℚ ⊂ℝ ⊂ℂ。此外,ℚ ∪ℙ = ℝ:实数包括有理数和无理数。

这些内置的数值类型遵循抽象概念:

  • ℂ通过复数类型实现。任何低于此类型的类型都可以转换为复数值。

  • ℝ由 float 类型支持。需要注意的是,float 涉及近似,并不完全符合实数的数学理想。当这个类中的运算符遇到 int 或分数值时,它将创建等效的 float 值。

  • ℚ使用 fractions 模块中的 Fraction 类。当 Fraction 类中的算术运算符遇到 int 时,它将静默地创建一个与整数具有相同值的 Fraction。z 1 = z。

  • ℤ是 int 类。

通常,Python 语言避免过多地转换为其他类型。例如,字符串不会自动解析以创建数值。需要使用显式的内置函数如 int()或 float()来处理包含数字的字符串。

我们经常希望自己的类型共享这种行为。我们希望我们的函数是灵活的,并在需要时将对象转换为其他类型。例如,我们可能希望允许纬度-经度点的多种表示。这些替代方案可能包括:

  • 一个包含两个浮点数值的元组

  • 一对字符串,每个字符串代表一个浮点值

  • 一个包含两个由逗号字符分隔的数值的单个字符串

与数值塔一样,我们自己的类定义需要将其他类型转换为所需的目标类型。

10.4.1 准备工作

我们将考虑一个计算地球上表面两点之间距离的函数。这涉及到一些巧妙的球面三角学。更多信息,请参阅第三章,特别是基于部分函数选择参数顺序配方。还可以参阅第七章中的创建上下文和上下文管理器配方。

函数定义如下:

def haversine( 

    lat_1: float, lon_1: float, 

    lat_2: float, lon_2: float, *, R: float) -> float: 

    ...  # etc.

这个定义需要将源数据转换为单个浮点值。在集成来自多个源的数据的应用中,这些转换非常常见,因此最好将它们集中到一个封装基本 haversine()计算的函数中。

我们需要一个这样的函数:

def distance( 

    *args: str | float | tuple[float, float], 

    R: float = NM 

) -> float:

此函数将计算定义为各种数据类型的点之间的距离。*args 参数意味着所有位置参数值将组合成一个单一的元组。必须应用一系列验证规则来理解这个元组。以下是我们将开始的规则:四个浮点值:直接使用这些值。例如:distance(36.12, -86.67, 33.94, -118.40, R=6372.8)。四个字符串:将这些字符串转换为浮点值。例如:distance("36.12", "-86.67", "33.94", "-118.40", R=6372.8)。两个字符串:解析每个字符串,以逗号分隔。每个字符串应包含两个浮点值。例如:distance("36.12,-86.67", "33.94,-118.40", R=6372.8)。两个元组:解包每个元组以确保它包含两个浮点值。例如:distance((36.12, -86.67), (33.94, -118.40), R=6372.8)。

理想情况下,也许支持这些组合也不错。我们将设计一个执行所需类型转换的函数。

10.4.2 如何实现...

包含类型转换的函数通常与底层处理分开构建。如果将这些处理方面的两个部分——转换和计算——分开,这有助于测试和调试:

  1. 导入所需的 literal_eval() 函数以转换预期为 Python 字面量的字符串:

    from ast import literal_eval
    

    使用这个函数,我们可以评估 literal_eval("2,3") 来得到一个正确的元组结果,(2, 3)。我们不需要使用正则表达式来分解字符串以查看文本的模式。

  2. 定义执行转换的距离函数:

    def distance( 
    
        *args: str | float | tuple[float, float], 
    
        R: float = NM 
    
    ) -> float:
    
  3. 开始匹配各种参数模式的匹配语句。

        match args:
    
  4. 编写单独的情况,从更具体到更不具体。从四个不同的浮点值开始,因为不需要进行转换。浮点值的元组具有更复杂的类型结构,但不需要任何转换。

            case [float(lat_1), float(lon_1), float(lat_2), float(lon_2)]: 
    
                pass 
    
            case ( 
    
                [[float(lat_1), float(lon_1)], 
    
                 [float(lat_2), float(lon_2)]] 
    
            ): 
    
                pass
    

    我们提供了 lat_1lon_1lat_2lon_2 变量,以便将 args 结构中的值绑定到变量名。这使我们免去了编写解包参数元组的赋值语句。使用 pass 语句占位符是因为不需要进行除解包数据结构之外的其他处理。

  5. 编写涉及提供的值转换的情况:

            case [str(s1), str(s2), str(s3), str(s4)]: 
    
                lat_1, lon_1, lat_2, lon_2 = ( 
    
                    float(s1), float(s2), float(s3), float(s4) 
    
                ) 
    
            case [str(ll1), str(ll2)]: 
    
                lat_1, lon_1 = literal_eval(ll1) 
    
                lat_2, lon_2 = literal_eval(ll2)
    

    当参数值是四个字符串时,我们提供了四个变量来解包这四个字符串。

    当参数模式是两个字符串时,我们提供了两个变量,ll1 和 ll2,每个变量都需要被转换为两个数字元组然后解包。

  6. 编写一个默认情况,它会匹配任何其他情况并引发异常:

    case _: 
    
                raise ValueError(f"unexpected types in {args!r}") 
    
  7. 现在参数已经被正确解包并且应用了任何转换,使用 haversine() 函数来计算所需的结果:

        return haversine(lat_1, lon_1, lat_2, lon_2, R=R)
    

10.4.3 它是如何工作的...

类型转换的基本功能是使用匹配语句为支持的类型提供适当的转换。在这个例子中,我们容忍了可以转换和解包的字符串和元组的混合,以定位所需的四个参数值。匹配语句有许多聪明的类型匹配规则。例如,表达式 ((float(f1), float(f2)), (float(f3), float(f4))) 将匹配两个元组,每个元组包含两个浮点值。此外,它从元组中解包值并将它们分配给四个变量。

转换值的机制也基于内置功能。float() 函数将数字字符串转换为浮点值或引发 ValueError 异常。

ast.literal_eval() 函数对于评估字符串形式的 Python 字面量非常方便。该函数由于仅限于字面值和一些简单数据结构(由字面值构建的元组、列表、字典和集合)而安全,因此不会评估危险的表达式。它允许我们直接将字符串 "36.12,-86.67" 解析为 (36.12, -86.67)。

10.4.4 更多...

使用独立的 case 子句使得添加额外的类型转换相对容易。例如,我们可能想要处理看起来像 {"lat": 36.12, "lon": -86.67} 的两个字典结构的元组。这可以与以下 case 匹配:

        case ( 

             {"lat": float(lat_1), "lon": float(lon_1)}, 

             {"lat": float(lat_2), "lon": float(lon_2)} 

        ): 

            pass

参数元组模式周围有括号(()),这使得它很容易被拆分成多行。从字典中提取的四个值将被绑定到四个目标变量上。

如果我们想要允许更多的灵活性,我们可以考虑这种情况:我们有两个类型模式的混合参数值。例如,distance("36.12,-86.67", (33.94, -118.40), R=6372.8)。这有两种不同的格式:一个字符串和一个包含一对浮点值的元组。

而不是列举所有可能的组合,我们可以将一对值的解析分解成一个单独的函数,parse(),它将对两个参数值应用相同的转换:

        case [p_1, p_2]: 

            lat_1, lon_1 = parse(p_1) 

            lat_2, lon_2 = parse(p_2)

这个新的 parse() 函数必须处理提供经纬度值的所有情况。这包括字符串、元组和映射。它看起来是这样的:

    def parse(item: Point | float) -> tuple[float, float]: 

        match item: 

            case [float(lat), float(lon)]: 

                pass 

            case {"lat": float(lat), "lon": float(lon)}: 

                pass 

            case str(sll): 

                lat, lon = literal_eval(sll) 

            case _: 

                raise ValueError(f"unexpected types in {item!r}") 

        return lat, lon

这将稍微简化 distance 函数中的 match 语句。重构后的语句只处理四种情况:

    match args: 

        case [float(lat_1), float(lon_1), float(lat_2), float(lon_2)]: 

            pass 

        case [str(s1), str(s2), str(s3), str(s4)]: 

            lat_1, lon_1, lat_2, lon_2 = float(s1), float(s2), float(s3), float(s4) 

        case [p_1, p_2]: 

            lat_1, lon_1 = parse(p_1) 

            lat_2, lon_2 = parse(p_2) 

        case _: 

            raise ValueError(f"unexpected types in {args!r}")

前两种情况处理提供了四个参数值的情况。第三种情况查看一对值,这些值可以是任何一对格式。

我们明确避免提供三个参数值的情况。这需要更多的注意来解释,因为三个参数值中的一个必须是经纬度对。其他两个值必须是分开的经纬度值。逻辑并不特别复杂,但细节偏离了这个配方的核心思想。

虽然这个配方侧重于内置类型,包括 str 和 float,但任何类型都可以使用。例如,可以很容易地在 case 子句中添加一个自定义的 Leg 类型,它具有起始和结束位置。

10.4.5 参见

  • 关于数字和转换的更多信息,请参阅第一章的 选择 float、decimal 和 fraction 之间的区别 配方。这提供了一些关于 float 近似限制的更多信息。

  • 关于 haversine() 函数的更多信息,请参阅第三章的 基于部分函数选择参数顺序 配方。还可以参阅第七章的 创建上下文和上下文管理器 配方。

10.5 使用 Pydantic 实现更严格的类型检查

对于大多数情况,Python 的内部处理将正确地处理许多简单的有效性检查。如果我们编写了一个将字符串转换为浮点数的函数,该函数将处理浮点值和字符串值。如果我们尝试将 float() 函数应用于 Path 对象,它将引发 ValueError 异常。

为了使类型提示可选,运行时类型检查是确保某些处理可以继续的最小检查级别。这与 mypy 等工具执行的严格检查截然不同。

类型提示不执行运行时处理。

Python(不使用任何附加包)在运行时不会进行数据类型检查或值范围检查。当操作符遇到它无法处理的类型时,会引发异常,而不考虑类型提示。

这意味着 Python 可能能够处理被提示排除的类型。可以编写一个窄提示,如 list[str]。具有给定函数体的 set[str] 对象也可能与 Pydantic 包一起工作。

在某些应用中,我们希望在运行时进行更强的检查。这些通常在需要使用扩展或插件的程序中很有帮助,我们希望确保额外的插件代码表现良好。

提供运行时类型检查的一种方法是通过使用 Pydantic 包。此模块允许我们定义带有运行时类型检查的复杂对象,以及可以广泛共享的模式定义管理。

在第五章,在 创建字典 – 插入和更新 食谱中,我们查看了一个需要解析成更有用结构的日志文件。在第九章,在 使用 yield 语句编写生成器函数 食谱中,我们查看了一个编写生成器函数的例子,该函数将解析并生成解析后的对象。我们将这些生成的对象称为 RawLog,没有类型检查或类型转换。我们应用了一个简单的转换来创建一个带有日期时间戳从文本转换为 datetime.datetime 对象的 DatedLog 实例。

Pydantic 包可以处理一些转换为 DatedLog 实例的转换,从而节省我们一些编程工作。此外,由于模式可以自动生成,我们可以构建 JSON Schema 定义并执行 JSON 序列化,而无需进行大量复杂的工作。

必须下载并安装 Pydantic 包。通常,这可以通过以下终端命令完成:

(cookbook3) % python -m pip install pydantic

使用 python -m pip 命令确保我们将使用与当前活动虚拟环境一起的 pip 命令,在示例中显示为 cookbook3。

10.5.1 准备工作

日志数据中的日期时间戳以字符串值表示。我们需要解析这些值以创建适当的 datetime 对象。为了使本食谱中的内容更集中,我们将使用 Flask 编写的 Web 服务器生成的简化日志。

条目最初是类似以下的文本行:

[2016-06-15 17:57:54,715] INFO in ch10_r10: Sample Message One 

[2016-06-15 17:57:54,716] DEBUG in ch10_r10: Debugging 

[2016-06-15 17:57:54,720] WARNING in ch10_r10: Something might have gone wrong

我们在第八章的使用更复杂结构 - 列表映射配方中看到了其他处理这种日志的例子。使用第一章中的使用正则表达式进行字符串解析配方中的 RE,我们可以将每一行分解成更有用的结构。

观察其他配方,用于解析的正则表达式具有一个重要的特性。在 (?P...) 组中使用的名称被特别设计为普通的 Python 属性名称。这将很好地与我们将要构建的类定义相匹配。

我们需要定义一个类,以有用的形式捕获每条日志行的基本内容。我们将使用 Pydantic 包来定义和填充这个类。

10.5.2 如何操作...

  1. 为了创建这个类定义,我们需要以下导入:

    import datetime 
    
    from enum import StrEnum 
    
    from typing import Annotated 
    
    from pydantic import BaseModel, Field
    
  2. 为了正确验证具有多个值的字符串,需要一个 Enum 类。我们将定义 StrEnum 的子类来列出有效的字符串值。每个类级别变量提供了一个名称和用于名称序列化的字符串字面量:

    class LevelClass(StrEnum): 
    
        DEBUG = "DEBUG" 
    
        INFO = "INFO" 
    
        WARNING = "WARNING" 
    
        ERROR = "ERROR"
    

    在这个类中,Python 属性名称和字符串字面量相匹配。这不是一个要求。对于这个枚举字符串值的集合来说,这碰巧很方便。

  3. 这个类将是 pydantic 包中的 BaseModel 类的子类:

    class LogData(BaseModel):
    

    BaseModel 类必须是任何使用 pydantic 特性的模型的超类。

  4. 我们将定义每个字段,字段名称与解析字段使用的正则表达式中的组名称相匹配。这不是一个要求,但它使得从正则表达式匹配对象的部分组字典构建 LogData 类的实例变得非常容易:

        date: datetime.datetime 
    
        level: LevelClass 
    
        module: Annotated[str, Field(pattern=r’^\w+$’)] 
    
        message: str
    

    日期被定义为 datetime.datetime 实例。从 BaseModel 类继承的方法将处理这个转换。级别是 LevelClass 的实例。同样,BaseModel 的特性将为我们处理这个转换。我们使用了 Annotated 类型来提供类型,str,以及一个注解参数,Field(...)。这将由 BaseModel 的方法用于验证字段的内容。

这是一个生成器函数,用于读取和解析日志记录:

from typing import Iterable, Iterator 

def logdata_iter(source: Iterable[str]) -> Iterator[LogData]: 

    for row in source: 

        if match := pattern.match(row): 

            l = LogData.model_validate(match.groupdict()) 

            yield l

这将使用正则表达式模式,pattern 来解析每条记录。组字典,match.groupdict() 将包含组名称和解析后的文本。BaseModel 的 model_validate() 方法将从编译的正则表达式创建的字典构建 LogData 类的实例。

当我们使用这个 logdata_iter 函数来创建 LogData 类的实例时,它看起来像以下示例:

>>> from pprint import pprint 

>>> pprint(list(logdata_iter(data.splitlines()))) 

[LogData(date=datetime.datetime(2016, 6, 15, 17, 57, 54, 715000), level=<LevelClass.INFO: ’INFO’>, module=’ch10_r10’, message=’Sample Message One’), 

 LogData(date=datetime.datetime(2016, 6, 15, 17, 57, 54, 716000), level=<LevelClass.DEBUG: ’DEBUG’>, module=’ch10_r10’, message=’Debugging’), 

 LogData(date=datetime.datetime(2016, 6, 15, 17, 57, 54, 720000), level=<LevelClass.WARNING: ’WARNING’>, module=’ch10_r10’, message=’Something might have gone wrong’)]

此函数已将文本行转换为填充了适当的 Python 对象(datetime.datetime 实例和来自 LevelClass 的枚举值)的 LogData 对象:进一步,它验证了模块名称以确保它们匹配特定的正则表达式模式。

10.5.3 它是如何工作的...

Pydantic 包包括许多用于数据验证和类定义的工具。Python 的类型使用,以及更详细的 Annotated 类型,提供了帮助我们定义类成员的语法,包括数据转换和数据验证。在这个例子中,转换是隐含的;类提供了目标类型,从 BaseModel 类继承的方法确保源数据被正确转换为所需的目标类型。

这个小的类定义有三个不同类型的类型提示:

  • 日期和级别字段涉及转换为目标类型。

  • 模块字段使用注解类型为属性提供了 Pydantic Field 定义。正则表达式模式将检查每个字符串值以确保它匹配所需的模式。

  • 提供的消息字段提供了一个简单的类型,该类型将与源数据类型匹配。对于此字段不会执行任何额外的验证。

@dataclass 和 BaseModel 子类的工作方式之间有一些相似之处。Pydantic 包提供了比 dataclass 定义更为复杂的定义。例如,@dataclass 不执行类型检查或任何自动数据转换。在定义 dataclass 时提供的类型信息主要对像 mypy 这样的工具感兴趣。相比之下,BaseModel 的子类执行了更多的自动化转换和运行时类型检查。

DataModel 的子类附带了许多方法。

model_dump_json()model_validate_json() 方法对于网络服务特别有帮助,在这些服务中,应用程序通常与以 JSON 表示法表示的对象状态的 RESTful 传输一起工作。这些可以序列化为换行符分隔的文件,以便将多个复杂对象收集到以标准化物理格式存储的文件中。

Pydantic 包通常非常快。当前版本涉及编译为提供非常高性能的 Python 扩展。显然,缺少许多 Pydantic 功能的数据类将更快,但功能更少。然而,额外的数据验证通常值得额外的开销。

10.5.4 更多...

与 Pydantic 一起工作的一个好处是自动支持 JSON Schema 定义和 JSON 序列化。

这显示了我们可以如何获取模型的 JSON Schema:

>>> import json 

>>> print(json.dumps(LogData.model_json_schema(), indent=2))

JSON Schema 的细节很长,与 Python 类的定义相匹配。我们省略了输出。

我们可以将这些 LogData 实例以 JSON 表示法进行序列化。以下是它的样子:

>>> for record in logdata_iter(data.splitlines()): 

...     print(record.model_dump_json()) 

{"date":"2016-06-15T17:57:54.715000","level":"INFO","module":"ch10_r10","message":"Sample Message One"} 

{"date":"2016-06-15T17:57:54.716000","level":"DEBUG","module":"ch10_r10","message":"Debugging"} 

{"date":"2016-06-15T17:57:54.720000","level":"WARNING","module":"ch10_r10","message":"Something might have gone wrong"}

我们已经使用 model_dump_json()方法将对象序列化为 JSON 文档。这使我们能够将来自各种来源的文档转换为通用格式。这使得围绕通用格式创建分析处理变得容易,将解析、合并和验证与分析和分析处理的有趣结果分开。

10.5.5 参考信息

  • 有关一些可能的附加验证规则,请参阅包含运行时有效值检查配方。

  • 有关数据类的更多信息,请参阅第七章中的使用数据类处理可变对象配方。Pydantic 对数据类的变体通常比数据类模块更有用。

  • 有关以 JSON 格式读取数据的更多信息,请参阅第十一章中的读取 JSON 和 YAML 文档配方。

10.6 包含运行时有效值检查

数据分析通常涉及大量的“数据处理”:处理无效数据或异常数据。源应用程序软件的变化很常见,导致数据文件的新格式,当解析这些文件时,可能会给下游分析应用带来问题。企业流程或政策的变更可能会导致新的数据类型或新的编码值,这可能会干扰分析处理。

类似地,当与机器和机器人(有时称为物联网)一起工作时,设备在启动时或无法正常工作时提供无效数据是很常见的。在某些情况下,当不良数据到达时,可能需要发出警报。在其他情况下,超出范围的数据需要被悄悄忽略。

Pydantic 包提供了非常复杂的验证函数,允许我们有两个选择:

  • 将数据从非标准格式转换为 Python 对象。

  • 对于无法转换或未能通过更具体领域检查的数据,抛出异常。

在某些情况下,我们还需要验证生成的对象在内部是否一致。这通常意味着必须检查几个字段是否相互一致。这被称为模型验证,它与单独字段验证是不同的。

验证的概念可以扩展。它可以包括拒绝无效数据,以及过滤掉对于特定应用来说有效但无趣的数据。

10.6.1 准备工作

我们正在查看美国国家海洋和大气管理局(NOAA)关于海岸潮汐的数据。移动一艘大型帆船意味着确保有足够的水让它浮起来。这个约束条件要求检查已知浅且难以通过的地区的潮汐高度预测。

特别是,一个名为 El Jobean 的地方,位于 Myakka 河上,有一个浅滩,在穿越时需要小心。我们可以从 NOAA 潮汐和流速网站获取潮汐预测。这个网页允许输入日期范围并下载给定日期范围的潮汐预测文本文件。

生成的文本文件看起来如下所示:

NOAA/NOS/CO-OPS 

Disclaimer: These data are based upon the latest information available as of the date of your request, and may differ from the published tide tables. 

Daily Tide Predictions 

StationName: EL JOBEAN, MYAKKA RIVER 

State: FL 

Stationid: 8725769 

... 

Date           Day    Time    Pred    High/Low 

2024/04/01      Mon    04:30  -0.19  L 

2024/04/01      Mon    20:07  1.91    H 

...

这些数据几乎符合 CSV 格式,但一些怪癖使其难以处理。以下是一些复杂因素:

  • 在有用的列标题行之前,文件有 19 行数据。

  • 列使用制表符(\t)作为分隔符,而不是逗号。

  • 相关数据的标题行中隐藏了一些多余的空格。

以下函数将提供干净的 CSV 行以供进一步处理:

import csv 

from collections.abc import Iterator 

from typing import TextIO 

def tide_table_reader(source: TextIO) -> Iterator[dict[str, str]]: 

    line_iter = iter(source) 

    for line in line_iter: 

        if len(line.rstrip()) == 0: 

            break 

    header = next(line_iter).rstrip().split(’\t’) 

    del header[1]  # Extra tab in the header 

    reader = csv.DictReader(line_iter, fieldnames=header, delimiter=’\t’) 

    yield from reader

标题注释中的额外制表符用于处理标题,其中包含一个额外的空格字符。这个标题行在日期和日期列名称之间有两个制表符(\t):

 ’Date \t\tDay\tTime\tPred\tHigh/Low\n’

有关从列表中删除项的技术,请参阅第四章中的切片和切块列表配方。

这个列名称列表可以用来构建一个 DictReader 实例以消费其余的数据。(有关 CSV 文件,请参阅第十一章中的使用 CSV 模块读取分隔文件配方。)

我们可以使用 Pydantic 验证功能将每个字典转换为类实例。

10.6.2 如何做到这一点...

核心数据模型将验证数据行,创建一个类的实例。我们可以向这个类添加功能以处理特定于应用程序的处理。以下是构建这个类的方法:

  1. 从每行中的数据类型导入开始,加上 BaseModel 类和一些相关类:

    import datetime 
    
    from enum import StrEnum 
    
    from typing import Annotated 
    
    from pydantic import BaseModel, Field, PlainValidator
    
  2. 定义高/低列的值域。这两个代码作为 Enum 子类的枚举:

    class HighLow(StrEnum): 
    
        high = "H" 
    
        low = "L"
    
  3. 由于日期文本不是 Pydantic 使用的默认格式,我们需要定义一个验证函数,该函数将从给定的字符串生成日期对象:

    def validate_date(v: str | datetime.date) -> datetime.date: 
    
        match v: 
    
            case datetime.date(): 
    
                return v 
    
            case str(): 
    
                return datetime.datetime.strptime(v, "%Y/%m/%d").date() 
    
            case _: 
    
                raise TypeError("can’t validate {v!r} of type {type(v)}")
    

    Pydantic 验证器可以用于内部 Python 对象以及来自源 CSV 文件或 JSON 文档的字符串。当应用于 datetime.date 对象时,不需要额外的转换。

  4. 定义模型。字段定义的 validation_alias 参数将从字典中的源字段中提取数据,该字段与类中目标属性名称不完全相同:

    class TideTable(BaseModel): 
    
        date: Annotated[ 
    
            datetime.date, 
    
            Field(validation_alias=’Date ’), 
    
            PlainValidator(validate_date)] 
    
        day: Annotated[ 
    
            str, Field(validation_alias=’Day’)] 
    
        time: Annotated[ 
    
            datetime.time, Field(validation_alias=’Time’)] 
    
        prediction: Annotated[ 
    
            float, Field(validation_alias=’Pred’)] 
    
        high_low: Annotated[ 
    
            HighLow, Field(validation_alias=’High/Low’)]
    

    每个字段使用 Annotated 类型来定义基本类型,以及验证字符串和将它们转换为该类型所需的其他详细信息。

    天字段(包含星期几)实际上并不有用。它是从日期派生出来的数据。出于调试目的,这个数据被保留。

给定这个类,我们可以用它来验证来自一系列字典实例的模型实例。它看起来是这样的:

>>> tides = [TideTable.model_validate(row) for row in dict_rows] 

>>> tides[0] 

TideTable(date=datetime.date(2024, 4, 1), day=’Mon’, time=datetime.time(4, 30), prediction=-0.19, high_low=<HighLow.low: ’L’>) 

>>> tides[-1] 

TideTable(date=datetime.date(2024, 4, 30), day=’Tue’, time=datetime.time(19, 57), prediction=1.98, high_low=<HighLow.high: ’H’>)

这个对象序列包含太多数据。我们可以使用 Pydantic 来过滤数据,并只传递有用的行。我们将通过修改这个类定义并创建一个包含要传递的数据规则的替代方案来实现这一点。

10.6.3 它是如何工作的...

BaseModel 类包括一些与类属性注释类型提示一起工作的操作。考虑这个类型提示:

    date: Annotated[ 

        datetime.date, 

        Field(validation_alias=’Date ’), 

        PlainValidator(validate_date)]

这提供了一个基础类型 datetime.date。它提供了一个 Field 对象,可以从字典中提取名为 'Date' 的字段,并对其应用验证规则。最后,PlainValidator 对象提供了一个一步验证规则,应用于源数据。validate_date() 函数被编写为接受已验证的日期对象,并将字符串对象转换为日期对象。这允许验证用于原始数据以及 Python 对象。

在这个例子中,我们的应用程序涉及对数据域的某些缩小。有三个重要标准:

  • 我们只对高潮预测感兴趣。

  • 我们希望潮高至少比基准线高 1.5 英尺(45 厘米)。

  • 我们需要这个操作在 10:00 之后和 17:00 之前发生。

我们可以利用 Pydantic 来执行额外的验证,以缩小数据域。这些额外的验证可以拒绝低于 1.5 英尺的最小潮高。

10.6.4 更多内容...

我们可以将此模型扩展以添加验证规则,将有效行的域缩小到符合我们基于一天中的时间和潮高选择标准的那些行。我们将在任何数据转换之后应用这些更窄的数据验证规则。这些规则将引发 ValidationError 异常。这扩展了从 pydantic 包中的导入。

我们将定义一系列额外的验证函数。以下是一个为低潮数据引发异常的验证器:

    BaseModel, Field, PlainValidator, AfterValidator, ValidationError 

) 

def pass_high_tide(hl: HighLow) -> HighLow: 

    assert hl == HighLow.high, f"rejected low tide" 

    return hl

assert 语句对于这个任务来说非常简洁。这也可以用 if 和 raise 来完成。

类似的验证器可以引发异常,对于超出可接受时间窗口的数据:

def pass_daylight(time: datetime.time) -> datetime.time: 

    assert datetime.time(10, 0) <= time <= datetime.time(17, 0) 

    return time

最后,我们可以将这些额外的验证器组合到注释类型定义中:

class HighTideTable(BaseModel): 

    date: Annotated[ 

        datetime.date, 

        Field(validation_alias=’Date ’), 

        PlainValidator(validate_date)] 

    time: Annotated[ 

        datetime.time, 

        Field(validation_alias=’Time’), 

        AfterValidator(pass_daylight)]  # Range check 

    prediction: Annotated[ 

        float, 

        Field(validation_alias=’Pred’, ge=1.5)]  # Minimum check 

    high_low: Annotated[ 

        HighLow, 

        Field(validation_alias=’High/Low’), 

        AfterValidator(pass_high_tide)]  # Required value check

额外的验证器将拒绝不符合我们狭窄要求的标准的数据。输出将只包含高潮,潮高大于 1.5 英尺,并且在白天。

这些数据形成了一个高潮表实例的序列,如下所示:

>>> from pathlib import Path 

>>> data = Path("data") / "tide-table-2024.txt" 

>>> with open(data) as tide_file: 

...     for ht in high_tide_iter(tide_table_reader(tide_file)): 

...         print(repr(ht)) 

HighTideTable(date=datetime.date(2024, 4, 7), time=datetime.time(15, 42), prediction=1.55, high_low=<HighLow.high: ’H’>) 

... 

HighTideTable(date=datetime.date(2024, 4, 10), time=datetime.time(16, 42), prediction=2.1, high_low=<HighLow.high: ’H’>) 

... 

HighTideTable(date=datetime.date(2024, 4, 26), time=datetime.time(16, 41), prediction=2.19, high_low=<HighLow.high: ’H’>)

我们省略了一些行,只显示第一行、中间的一行和最后一行。这些是具有 Python 对象属性的高潮表对象,适合进一步分析和处理。

Pydantic 设计的一般方法意味着组合原始数据字段、转换数据和过滤数据的规则都是分开的。我们可以放心地更改这些规则之一,而不用担心破坏应用程序的其他部分。

这个配方包括三种检查方法:

  • 范围检查以确保连续值在允许的范围内。AfterValidator 用于确保字符串被转换为时间。

  • 确保连续值超过限制的最小检查。对于数字,这可以通过字段定义直接完成。

  • 必要值检查以确保离散值具有所需的其中一个值。AfterValidator 用于确保字符串被转换为枚举类型。

这些类型的检查在基本类型匹配之后执行,并用于应用更窄的验证规则。

10.6.5 参见

  • 在第十一章中,我们将更深入地探讨读取数据文件。

  • 参见 使用 Pydantic 实现更严格的类型检查 菜单以获取使用 Pydantic 的更多示例。Pydantic 使用编译的 Python 扩展来应用验证规则,几乎没有开销。

加入我们的社区 Discord 空间

加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

PIC

第十一章:11

输入/输出、物理格式和逻辑布局

计算通常与持久数据一起工作。可能有源数据需要分析,或者使用 Python 的输入和输出操作创建输出。在游戏中探索的地牢图是游戏应用程序将输入的数据。图像、声音和电影是某些应用程序输出的数据,由其他应用程序输入。甚至通过网络发送的请求也会涉及输入和输出操作。所有这些的共同之处在于数据文件的概念。术语“文件”具有许多含义:

  • 操作系统(OS)使用文件作为在设备上组织数据字节的方式。解析字节是应用程序软件的责任。两种常见的设备在操作系统文件的功能方面提供了不同的变体:

    • 块设备,如磁盘或固态驱动器(SSD):这类设备上的文件可以定位任何特定的字节,这使得它们特别适合数据库,因为任何行都可以在任何时候进行处理。

    • 字符设备,如网络连接、键盘或 GPS 天线。这类设备上的文件被视为传输中的单个字节流。无法向前或向后查找;字节必须被捕获在缓冲区中,并按到达顺序进行处理。

  • “文件”这个词还定义了 Python 运行时使用的数据结构。统一的 Python 文件抽象封装了各种操作系统文件实现。当我们打开 Python 文件时,Python 抽象、操作系统实现以及块设备上的底层字节集合或字符设备的字节流之间存在绑定。

Python 为我们提供了两种常见模式来处理文件内容:

  • 在“b”(二进制)模式下,我们的应用程序看到的是字节,而不进行进一步解释。这有助于处理具有复杂编码的媒体数据,如图像、音频和电影。我们通常会导入像 pillow 这样的库来处理图像文件编码为字节以及从字节解码的细节。

  • 在“t”(文本)模式下,文件的字节是字符串值的编码。Python 字符串由 Unicode 字符组成,有多种方案用于将字节解码为文本以及将文本编码为字节。通常,操作系统有一个首选的编码,Python 会尊重这一点。UTF-8 编码很受欢迎。从实用主义的角度来看,文件可以具有任何可用的 Unicode 编码,并且可能不清楚使用了哪种编码来创建文件。

此外,Python 模块如 shelve 和 pickle 有独特的方式来表示比简单字符串更复杂的 Python 对象。有几种 pickle 协议可用;它们都基于二进制模式文件操作。

在本章中,我们将讨论 Python 对象的序列化。序列化创建了一系列字节,以表示 Python 对象的状态。反序列化是相反的过程:它从文件的字节中恢复 Python 对象的状态。保存和传输对象状态的表示是 REST 网络服务背后的基本概念。

当我们处理来自文件的数据时,我们有两个常见的问题:

  • 数据的物理格式:我们需要知道如何解释文件上的字节来重建 Python 对象。字节可能代表 JPEG 编码的图像或 MPEG 编码的电影。一个非常常见的例子是表示 Unicode 文本的文件字节,组织成行。通常,物理格式问题由 Python 库如 csv、json 和 pickle 等处理。

  • 数据的逻辑布局:给定的数据集合可能有灵活的位置来存储数据项。CSV 列或 JSON 字段的排列可以不同。在数据包含标签的情况下,逻辑布局是清晰的。如果没有标签,布局是位置性的,需要一些额外的模式信息来识别哪些数据项占据了各种位置。

物理格式解码和逻辑布局模式对于解释文件上的数据都是必不可少的。我们将探讨处理不同物理格式的多个食谱。我们还将探讨将我们的程序与逻辑布局的一些方面分离的方法。

在本章中,我们将探讨以下食谱:

  • 使用 pathlib 处理文件名

  • 替换文件同时保留旧版本

  • 使用 CSV 模块读取分隔文件

  • 使用 dataclasses 简化处理 CSV 文件

  • 使用正则表达式读取复杂格式

  • 读取 JSON 和 YAML 文档

  • 读取 XML 文档

  • 读取 HTML 文档

为了处理文件,我们将从帮助控制操作系统文件系统的对象开始。Python 的 pathlib 模块描述了文件和设备的目录结构的常见特性。此模块在多个操作系统上具有一致的行为,使得 Python 程序在 Linux、macOS 和 Windows 上可以以类似的方式工作。

11.1 使用 pathlib 处理文件名

大多数操作系统使用包含文件的目录树结构。从根目录到特定文件的路径通常表示为一个字符串。以下是一个示例路径:

/Users/slott/Documents/Writing/Python Cookbook/src/ch11/recipe_01.py

这个完整路径名列出了包含在(未命名的)根目录中的七个命名目录。最后的名称有一个 recipe_01 的前缀和 .py 的后缀。

我们可以将其表示为一个字符串,并解析该字符串以定位目录名称、文件名和后缀字符串。这样做在 macOS 和 Linux 操作系统之间不可移植,它们使用"/"作为分隔符,而 Windows 使用""作为分隔符。此外,Windows 文件可能还有设备名称作为路径的前缀。

处理文件名中的“/”或目录名中的“.”等边缘情况会使字符串处理变得不必要地困难。我们可以通过使用 pathlib.Path 对象而不是字符串来简化解析和许多文件系统操作。

11.1.1 准备工作

重要的是要区分三个概念:

  • 一个标识文件的路径,包括文件名

  • 文件元数据,如创建时间戳和所有权,存储在目录树中

  • 文件内容

文件内容与目录信息无关。多个目录条目链接到相同内容是很常见的。这可以通过硬链接来完成,其中目录信息在多个路径之间共享,以及软链接,其中一种特殊类型的文件包含对另一个文件的引用。

通常,文件名有一个后缀(或扩展名),用作关于物理格式的提示。以.csv 结尾的文件名很可能是一个可以解释为行和列数据的文本文件。这种名称与物理格式之间的绑定不是绝对的。文件后缀只是一个提示,可能会出错。

在 Python 中,pathlib 模块处理所有与路径相关的处理。该模块在路径之间做出几个区分:

  • 可能或可能不指向实际文件的纯路径

  • 解析的具体路径;这些指的是一个实际文件

这种区分使我们能够为应用可能创建或引用的文件创建纯路径。我们也可以为实际存在于操作系统上的文件创建具体路径。应用程序通常将纯路径解析为具体路径。

虽然 pathlib 模块可以在 Linux 路径对象和 Windows 路径对象之间做出区分,但这种区分很少需要。使用 pathlib 的一个重要原因是,我们希望处理与底层操作系统的细节隔离。

本节中的所有迷你食谱都将利用以下内容:

>>> from pathlib import Path

我们还假设使用 argparse 模块来收集文件或目录名称。有关 argparse 的更多信息,请参阅第六章中的使用 argparse 获取命令行输入食谱。我们将使用一个 options 变量作为命名空间,该命名空间包含食谱处理的输入文件名或目录名。作为一个例子,我们将使用以下 Namespace 对象:

>>> from argparse import Namespace 

>>> options = Namespace( 

...     input=’/path/to/some/file.csv’, 

...     file1=’data/ch11_file1.yaml’, 

...     file2=’data/ch11_file2.yaml’, 

... ) 

通常,我们会定义 argparse 选项使用 type=Path,以便参数解析为我们创建 Path 对象。为了展示 Path 对象的工作方式,路径信息以字符串值的形式提供。

11.1.2 如何操作...

在以下迷你食谱中,我们将展示一些常见的路径名操作:

  • 通过更改输入文件名后缀来创建输出文件名

  • 创建具有不同名称的多个同级输出文件

  • 比较文件日期以查看哪个更新

  • 查找所有匹配给定模式的文件

前两个反映了处理目录到文件路径的技术;使用 Path 对象比进行复杂的字符串操作要容易得多。最后两个收集有关计算机上具体路径和相关文件的信息。

通过更改输入文件名后缀来创建输出文件名

通过更改输入名称的后缀来创建输出文件名的以下步骤:

  1. 从输入文件名字符串创建一个 Path 对象:

    >>> input_path = Path(options.input) 
    
    >>> input_path 
    
    PosixPath(’/path/to/some/file.csv’)
    

    显示 PosixPath 类,因为作者正在使用 macOS。在 Windows 机器上,该类将是 WindowsPath。

  2. 使用 with_suffix()方法创建输出 Path 对象:

    >>> output_path = input_path.with_suffix(’.out’) 
    
    >>> output_path 
    
    PosixPath(’/path/to/some/file.out’)
    

所有文件名解析都由 Path 类无缝处理。这不会创建具体的输出文件;它只是为它创建了一个新的 Path 对象。

创建具有不同名称的多个同级输出文件

通过更改输入名称的后缀来创建具有不同名称的多个同级输出文件:

  1. 从输入文件名字符串创建一个 Path 对象:

    >>> input_path = Path(options.input)
    
  2. 从文件名中提取父目录和基本名称。基本名称是没有后缀的名称:

    >>> input_directory = input_path.parent 
    
    >>> input_stem = input_path.stem
    
  3. 构建所需的输出名称。对于此示例,我们将追加 _pass 到基本名称并构建完整的 Path 对象:

    >>> output_stem_pass = f"{input_stem}_pass" 
    
    >>> output_stem_pass 
    
    ’file_pass’
    
    >>> output_path = ( 
    
    ...     input_directory / output_stem_pass 
    
    ... ).with_suffix(’.csv’) 
    
    >>> output_path 
    
    PosixPath(’/path/to/some/file_pass.csv’)
    

/运算符从 Path 组件组装一个新的 Path。我们需要将/运算放在括号中,以确保它首先执行,在更改后缀之前创建一个新的 Path 对象。

比较文件日期以查看哪个更新

以下是比较文件日期以查看哪个更新的步骤:

  1. 从输入文件名字符串创建 Path 对象。Path 类将正确解析字符串以确定路径的元素:

    >>> file1_path = Path(options.file1) 
    
    >>> file2_path = Path(options.file2)
    

    在探索此示例时,请确保 options 对象中的名称是实际文件。

  2. 使用每个 Path 对象的 stat()方法获取文件的戳记。在 stat 对象中,st_mtime 属性提供了文件的最新修改时间:

    >>> file1_path.stat().st_mtime 
    
    1572806032.0 
    
    >>> file2_path.stat().st_mtime 
    
    1572806131.0
    

这些值是以秒为单位的时间戳。您的值将取决于您系统上的文件。如果我们想要一个对大多数人来说都合理的时间戳,我们可以使用 datetime 模块从这个值创建一个更有用的对象:

>>> import datetime 

>>> mtime_1 = file1_path.stat().st_mtime 

>>> datetime.datetime.fromtimestamp(mtime_1) 

datetime.datetime(2019, 11, 3, 13, 33, 52)

我们可以使用多种方法来格式化 datetime 对象。

查找所有匹配给定模式的文件

以下是要查找所有匹配给定模式的文件的步骤:

  1. 从输入目录名创建 Path 对象:

    >>> directory_path = Path(options.file1).parent 
    
    >>> directory_path 
    
    PosixPath(’data’)
    
  2. 使用 Path 对象的 glob() 方法定位此目录中所有匹配给定模式的文件。对于不存在的目录,迭代器将为空。在模式中使用 ** 将递归遍历目录树:

    >>> from pprint import pprint 
    
    >>> pprint(sorted(directory_path.glob("*.csv"))) 
    
    PosixPath(’data/binned.csv’),
    

    我们省略了结果中的许多文件。

glob() 方法是一个迭代器,我们使用了 sorted() 函数来消费这个迭代器的值,并从它们创建一个单独的列表对象。

11.1.3 它是如何工作的...

在操作系统中,查找文件的目录序列是通过文件系统路径实现的。在某些情况下,可以使用简单的字符串表示来总结路径。然而,字符串表示使得许多路径操作变成了复杂的字符串解析问题。字符串对于操作操作系统路径来说是一个无用的不透明抽象。

Path 类定义简化了路径操作。Path 实例上的这些属性、方法和运算符包括以下示例:

  • .parent 提取父目录。

  • .parents 列出所有封装的目录。

  • .name 是最终名称。

  • .stem 是最终名称的基名(不带任何后缀)。

  • .suffix 是最终的后缀。

  • .suffixes 是后缀值序列,用于与 file.tag.gz 类型的名称一起使用。

  • .with_suffix() 方法用新的后缀替换文件的后缀。

  • .with_name() 方法用新的名称替换路径中的名称。

  • / 操作符从 Path 和字符串组件构建 Path 对象。

具体路径代表实际的文件系统资源。对于具体的路径对象,我们可以对目录信息进行一系列额外的操作:

  • 确定这种目录条目是什么类型;即普通文件、目录、链接、套接字、命名管道(或 FIFO)、块设备或字符设备。

  • 获取目录详细信息,包括时间戳、权限、所有权、大小等信息。

  • 解除链接(即删除)目录条目。请注意,解除普通文件的链接与删除空目录是不同的。我们将在本食谱的 There’s more... 部分中探讨这一点。

  • 将文件重命名以将其放置在新的路径中。我们也会在本食谱的 There’s more... 部分中探讨这一点。

几乎我们可以对文件目录执行的所有操作都可以使用 pathlib 模块来完成。少数例外是 os 模块的一部分,因为它们通常是特定于操作系统的。

11.1.4 更多内容...

除了操作路径和收集有关文件的信息外,我们还可以对文件系统进行一些更改。两个常见的操作是重命名文件和解除链接(或删除)文件。我们可以使用多种方法来更改文件系统:

  • .unlink() 方法删除普通文件。它不会删除目录。

  • .rmdir() 方法删除空目录。删除包含文件的目录需要两步操作:首先解除目录中所有文件的联系,然后删除目录。

  • .rename() 方法将文件重命名为新路径。

  • .replace() 方法在目标已存在的情况下不会引发异常来替换文件。

  • .symlink_to() 方法创建一个指向现有文件的软链接文件。

  • .hardlink_to() 方法创建一个操作系统硬链接;现在两个不同的目录条目将拥有底层文件内容。

我们可以通过内置的 open() 函数或 open() 方法来打开一个路径。有些人喜欢看到 open(some_path),而有些人则更喜欢 some_path.open()。两者都做同样的事情:创建一个打开的文件对象。

我们可以使用 mkdir() 方法创建目录。此方法有两个关键字参数:

  • exist_ok=False 是默认值;如果目录已存在,将引发异常。将此更改为 True 使代码对现有目录具有容错性。

  • parents=False 是默认值;不会创建父目录,只有路径中的最底层目录。将此更改为 True 将创建整个路径,包括父目录和子目录。

我们还可以以大字符串或字节对象的形式读取和写入文件:

  • .read_text() 方法将文件作为单个字符串读取。

  • .write_text() 方法使用给定的字符串创建或替换文件。

  • .read_bytes() 方法将文件作为单个字节实例读取。

  • .write_bytes() 方法使用给定的字节创建或替换文件。

还有更多文件系统操作,如更改所有权或更改权限。这些操作在 os 模块中可用。

11.1.5 参见

  • 在本章后面的 [在保留先前版本的同时替换文件 菜谱中,我们将探讨如何利用路径对象的功能来创建一个临时文件,然后将临时文件重命名为替换原始文件。

  • 在第六章的 使用 argparse 获取命令行输入 菜谱中,我们查看了一种非常常见的方法,即使用字符串来创建路径对象。

  • os 模块提供了一些比 pathlib 提供的更不常用的文件系统操作。

11.2 在保留先前版本的同时替换文件

我们可以利用 pathlib 模块的功能来支持各种文件名操作。在第 使用 pathlib 处理文件名 菜谱中,我们查看了一些管理目录、文件名和文件后缀的最常见技术。

一个常见的文件处理需求是以安全的方式创建输出文件;也就是说,无论应用程序如何或在哪里失败,应用程序都应该保留任何先前的输出文件。

考虑以下场景:

  1. 在时间 T[0] 时,有一个来自 long_complex.py 应用程序先前运行的 valid output.csv 文件。

  2. 在时间 T[1] 时,我们开始使用新数据运行 long_complex.py 应用程序。它开始覆盖 output.csv 文件。直到程序完成,字节将不可用。

  3. 在时间 T[2] 时,应用程序崩溃。output.csv 文件的局部内容是无用的。更糟糕的是,时间 T[0] 的有效文件也不再可用,因为它被覆盖了。

在这个菜谱中,我们将探讨在失败情况下创建输出文件的一种安全方法。

11.2.1 准备工作

对于不跨越物理设备的文件,安全文件输出通常意味着使用临时名称创建文件的新副本。如果新文件可以成功创建,则应使用单个原子重命名操作替换旧文件。

我们希望有以下功能:

  • 重要的输出文件必须始终以有效状态保存。

  • 应用程序写入文件的临时版本。命名此文件有许多约定。有时,在文件名上放置额外的字符,如 ~ 或 #,以指示它是临时的工作文件;例如,output.csv~。我们将使用更长的后缀,.new;例如,output.csv.new。

  • 文件的先前版本也被保留。有时,先前版本有一个 .bak 后缀,表示“备份”。我们将使用更长的后缀,称为 output.csv.old。这也意味着任何先前的 .old 文件都必须作为最终输出的一部分被删除;只保留一个版本。

为了创建一个具体的例子,我们将使用一个包含非常小但珍贵的部分数据的文件:一系列商对象。以下是商类的定义:

from dataclasses import dataclass, asdict, fields 

@dataclass 

class Quotient: 

    numerator: int 

    denominator: int 

以下函数将对象写入 CSV 格式的文件:

import csv 

from collections.abc import Iterable 

from pathlib import Path 

def save_data( 

    output_path: Path, data: Iterable[Quotient] 

) -> None: 

    with output_path.open("w", newline="") as output_file: 

        headers = [f.name for f in fields(Quotient)] 

        writer = csv.DictWriter(output_file, headers) 

        writer.writeheader() 

        for q in data: 

            writer.writerow(asdict(q))

如果在将数据对象写入文件时出现问题,我们可能会留下一个损坏的、不可用的文件。我们将用另一个函数包装此函数,以提供可靠的写入。

11.2.2 如何操作...

我们通过导入所需的类开始创建一个包装函数:

  1. 定义一个函数来封装 save_data() 函数以及一些额外功能。函数签名与 save_data() 函数相同:

  2. 保存原始后缀,并在后缀末尾创建一个带有 .new 的新名称。这是一个临时文件。如果它正确写入,没有异常,那么我们可以重命名它,使其成为目标文件:

        ext = output_path.suffix 
    
        output_new_path = output_path.with_suffix(f’{ext}.new’) 
    
        save_data(output_new_path, data)
    

    save_data() 函数是封装在此函数中的创建新文件的原始过程。

  3. 在用新文件替换旧文件之前,删除任何先前的备份副本。如果存在,我们将解除 .old 文件的链接:

        output_old_path = output_path.with_suffix(f’{ext}.old’) 
    
        output_old_path.unlink(missing_ok=True)
    
  4. 现在,我们可以保留任何先前的良好文件,其名称为 .old:

        try: 
    
            output_path.rename(output_old_path) 
    
        except FileNotFoundError as ex: 
    
            # No previous file. That’s okay. 
    
            pass
    
  5. 最后一步是将临时的 .new 文件变为官方输出:

        try: 
    
            output_new_path.rename(output_path) 
    
        except IOError as ex: 
    
            # Possible recovery... 
    
            output_old_path.rename(output_path)
    

这个多步骤过程使用两个重命名操作:

  • 将先前的版本重命名为带有后缀 .old 的备份版本。

  • 将带有 .new 后缀的新版本重命名为文件的当前版本。

|


11.2.3 它是如何工作的...

此过程涉及三个独立的操作系统操作:一个解除链接和两个重命名。这是为了确保保留一个 .old 文件,并可以使用它来恢复之前良好的状态。

这里是一个显示各种文件状态的时序表。我们将内容标记为版本 0(一些旧数据)、版本 1(当前有效数据)和版本 2(新创建的数据):

由于这些操作是串行应用的,因此在保留旧文件和重命名新文件之间存在一个非常小的时间间隔,应用程序失败将无法替换新文件。我们将在下一节中探讨这一点。






|



| T[0] | | 版本 0 | 版本 1 | |

|

* * *

|

|

|

|



| T[5] | 将 .csv.new 重命名为 .csv 后 | 版本 1 | 版本 2 | |

如果有 .csv 文件,它是当前的有效文件。



| T[2] | 创建后,关闭 | 版本 0 | 版本 1 | 版本 2 |

|


|


|


|


|


|

* * *

|

|

|

|

|

|



一个路径对象有一个 replace() 方法。这总是覆盖目标文件,如果覆盖现有文件则没有警告。rename() 和 replace() 之间的选择取决于我们的应用程序如何处理在文件系统中可能留下旧版本文件的情况。在这个菜谱中,我们使用了 rename() 来尝试避免在多个问题的情况下覆盖文件。


|

* * *

|



虽然有几次失败的机会,但关于哪个文件是有效的没有歧义:





由于这些操作实际上并不涉及复制文件,因此这些操作都非常快且可靠。然而,它们并不保证一定能工作。文件系统的状态可以被任何具有正确权限的用户更改,因此在创建替换旧文件的新的文件时需要小心。

|

|

T[3] 解除链接 .csv.old 后 版本 1 版本 2

|

|


|


|

为了确保输出文件有效,一些应用程序会采取额外步骤,在文件中写入一个最终的校验和行,以提供明确的证据,表明文件是完整且一致的。

|

|

|

T[4] 将 .csv 重命名为 .csv.old 后 版本 1 版本 2

|

|

|


|


|

|


| T[1] | 创建中 | 版本 0 | 版本 1 | 如果使用将出现损坏 |

|

* * *

|

|

| 时间 | 操作 | .csv.old | .csv | .csv.new |

|

|


表 11.1:文件操作时间线

|

|


|

* * *

|

| | | | | |

  • |

  • 如果没有 .csv 文件,那么 .csv.old 文件是一个有效的备份副本,应用于恢复。参见 T[4] 时刻,了解这种情况。

|

|

11.2.4 更多内容...

在某些企业应用中,输出文件被组织成基于时间戳命名的目录。这些操作可以通过 pathlib 模块优雅地处理。例如,我们可能有一个用于旧文件的存档目录。这个目录包含带有日期戳的子目录,用于存储临时或工作文件。

然后,我们可以执行以下操作来定义一个工作目录:

[firstline=58,lastline=58,gobble=4][python]src/ch11/recipe˙02.py [firstline=60,lastline=65,gobble=4][python]src/ch11/recipe˙02.py

mkdir() 方法将创建预期的目录。通过包含 parents=True 参数,任何需要的父目录也将被创建。这可以在应用程序第一次执行时创建 archive_path 非常方便。exists_ok=True 将避免在存档目录已存在时引发异常。

对于某些应用,使用 tempfile 模块创建临时文件可能是合适的。此模块可以创建保证唯一的文件名。这允许复杂的服务器进程在无需考虑文件名冲突的情况下创建临时文件。

11.2.5 参见

  • 在本章前面的使用 pathlib 处理文件名配方中,我们探讨了 Path 类的基本原理。

  • 在第十五章,我们将探讨一些编写单元测试的技术,以确保本配方示例代码的部分行为正确。

  • 在第六章,创建上下文和上下文管理器配方展示了有关使用 with 语句确保文件操作正确完成以及释放所有 OS 资源的更多细节。

  • shutil 模块提供了一系列用于复制文件和包含文件的目录的方法。这个包反映了 Linux shell 程序(如 cp)以及 Windows 程序(如 copy 和 xcopy)的功能。

11.3 使用 CSV 模块读取分隔符文件

一种常用的数据格式是逗号分隔值(CSV)。我们可以将逗号字符视为众多候选分隔符之一。例如,CSV 文件可以使用 | 字符作为数据列之间的分隔符。这种对非逗号分隔符的泛化使得 CSV 文件特别强大。

我们如何处理各种 CSV 格式的数据?

11.3.1 准备工作

文件内容的摘要称为模式。区分模式的两个方面是至关重要的。

CSV 文件的字节物理格式编码文本行。对于 CSV 文件,文本使用行分隔符字符(或字符序列)和列分隔符字符组织成行和列。许多电子表格产品将使用 ,(逗号)作为列分隔符,将 \r\n 字符序列作为行分隔符。使用的标点符号字符的具体组合称为 CSV 语法。

此外,当列数据包含分隔符之一时,可以引用列数据。最常见的引用规则是用"字符包围列值。为了在列数据中包含引号字符,引号字符被加倍。例如,"He said, ""Thanks."""。

文件中数据的逻辑布局是一系列存在的数据列。在 CSV 文件中处理逻辑布局有几种常见情况:

  • 文件可能有一行标题。这与 csv 模块的工作方式很好地吻合。如果标题也是合适的 Python 变量名,那么这会更有帮助。模式在文件的第一行中明确声明。

  • 文件没有标题,但列位置是固定的。在这种情况下,我们可以在打开文件时在文件上施加标题。从实用主义的角度来看,这涉及一些风险,因为很难确认数据符合施加的模式。

  • 如果文件没有标题且列位置不固定。在这种情况下,需要额外的外部模式信息来解释数据列。

当然,任何数据都可能出现的某些常见复杂问题。有些文件不是第一范式(1NF)。在 1NF 中,每一行都是独立于所有其他行的。当一个文件不处于这种范式时,我们需要添加一个生成器函数来重新排列数据以形成 1NF 行。请参阅第四章中的 Slicing and dicing a list 配方,以及第九章中的 Using stacked generator expressions 配方,以了解其他显示如何规范化数据结构的配方。

我们将查看一个包含从帆船日志中记录的一些实时数据的 CSV 文件。这是 waypoints.csv 文件。数据如下所示:

lat,lon,date,time 

32.8321666666667,-79.9338333333333,2012-11-27,09:15:00 

31.6714833333333,-80.93325,2012-11-28,00:00:00 

30.7171666666667,-81.5525,2012-11-28,11:35:00

这些数据包含文件第一行中命名的四个列:lat、lon、date 和 time。这些描述了一个航点,需要重新格式化以创建更有用的信息。

11.3.2 如何实现...

在开始编写任何代码之前,检查数据文件以确认以下功能:

  • 列分隔符字符是’,’,这是默认值。

  • 行分隔符字符是’\r\n’,在 Windows 和 Linux 中都广泛使用。

  • 有一个单行标题。如果不存在,当创建读取器对象时,应单独提供标题。

一旦格式得到确认,我们就可以开始创建所需的函数,如下所示:

  1. 导入 csv 模块和 Path 类:

    import csv 
    
    from pathlib import Path
    
  2. 定义一个 raw()函数,从指向文件的 Path 对象中读取原始数据:

    def raw(data_path: Path) -> None:
    
  3. 使用 Path 对象在 with 语句中打开文件。从打开的文件构建读取器:

        with data_path.open() as data_file: 
    
            data_reader = csv.DictReader(data_file)
    
  4. 消费(并处理)可迭代读取器的数据行。这正确地缩进在 with 语句内:

            for row in data_reader: 
    
                print(row)
    

raw()函数的输出是一系列看起来如下所示的字典:

{’lat’: ’32.8321666666667’, ’lon’: ’-79.9338333333333’, ’date’: ’2012-11-27’, ’time’: ’09:15:00’}

我们现在可以通过将列作为字典项来处理数据,使用例如 row[’date’]这样的语法。使用列名比通过位置引用列更具有描述性;例如,row[0]难以理解。

为了确保我们正确地使用了列名,可以使用 typing.TypedDict 类型提示来提供预期的列名。

11.3.3 它是如何工作的...

csv 模块处理解析物理格式的工作。这使行彼此分离,并在每行内分离列。默认规则确保每条输入行被视为单独的行,并且列由逗号分隔。

当我们需要将列分隔符字符作为数据的一部分使用时会发生什么?我们可能会有这样的数据:

lan,lon,date,time,notes 

32.832,-79.934,2012-11-27,09:15:00,"breezy, rainy" 

31.671,-80.933,2012-11-28,00:00:00,"blowing ""like stink"""

注释列在第一行有数据,包括逗号分隔符字符。CSV 的规则允许列的值被引号包围。默认情况下,引号字符是"。在这些引号字符内,列和行分隔符字符被忽略。

为了在引号字符串中嵌入引号字符,该字符被加倍。第二个示例行显示了如何通过将引号字符加倍来编码值"like stink",当它们是列值的一部分时。

CSV 文件中的值始终是字符串。像 7331 这样的字符串值可能看起来像数字,但它在 csv 模块处理时始终是文本。这使得处理简单且统一,但可能对我们的 Python 应用程序来说有些尴尬。

当从手动准备的电子表格中保存数据时,数据可能会揭示桌面软件内部数据显示规则的古怪之处。在桌面软件中显示为日期的数据在 CSV 文件中存储为浮点数。

日期作为数字的问题有两个解决方案。一个是向源电子表格中添加一个列,以正确格式化日期数据为字符串。理想情况下,这是使用 ISO 规则完成的,以便日期以 YYYY-MM-DD 格式表示。另一个解决方案是将电子表格中的日期识别为从某个纪元日期以来的秒数。纪元日期随着各种工具的版本略有不同,但通常是 1900 年 1 月 1 日。(一些电子表格应用程序使用 1904 年 1 月 1 日。)

11.3.4 更多内容...

正如我们在第九章的结合 map 和 reduce 转换食谱中看到的,通常有一个包括清理和转换源数据的处理流程。这种堆叠生成器函数的想法让 Python 程序能够处理大量数据。一次读取一行可以避免将所有数据读入一个庞大的内存列表。在这个特定例子中,没有需要消除的额外行。然而,每个列都需要转换成更有用的形式。

在第十章中,许多配方使用 Pydantic 来执行这些类型的数据转换。参见 使用 Pydantic 实现更严格的类型检查 配方,了解这种替代方法的示例。

为了将数据转换成更有用的形式,我们将定义一个行级清洗函数。一个函数可以将此清洗函数应用于源数据中的每一行。

在这个例子中,我们将创建一个字典对象,并插入从输入数据派生出的额外值。这个 Waypoint 字典的核心类型提示如下:

import datetime 

from typing import TypeAlias, Any 

Raw: TypeAlias = dict[str, Any] 

Waypoint: TypeAlias = dict[str, Any]

基于此对 Waypoint 类型的定义,clean_row() 函数可能看起来像这样:

def clean_row( 

    source_row: Raw 

) -> Waypoint: 

    ts_date = datetime.datetime.strptime( 

        source_row["date"], "%Y-%m-%d").date() 

    ts_time = datetime.datetime.strptime( 

        source_row["time"], "%H:%M:%S").time() 

    return dict( 

        date=source_row["date"], 

        time=source_row["time"], 

        lat=source_row["lat"], 

        lon=source_row["lon"], 

        lat_lon=( 

            float(source_row["lat"]), 

            float(source_row["lon"]) 

        ), 

        ts_date=ts_date, 

        ts_time=ts_time, 

        timestamp = datetime.datetime.combine( 

            ts_date, ts_time 

        ) 

    )

clean_row() 函数从原始字符串数据中创建几个新的列值。名为 lat_lon 的列包含一个包含正确浮点值的二元组,而不是字符串。我们还解析了日期和时间值,分别创建了 datetime.date 和 datetime.time 对象。我们将日期和时间合并成一个单一的有用值,即时间戳列的值。

一旦我们有一个用于清洗和丰富我们数据的行级函数,我们就可以将此函数映射到源数据中的每一行。我们可以使用 map(clean_row, reader) 或者我们可以编写一个体现此处理循环的函数:

def cleanse(reader: csv.DictReader[str]) -> Iterator[Waypoint]: 

    for row in reader: 

        yield clean_row(row)

这可以用来从每一行提供更有用的数据:

def display_clean(data_path: Path) -> None: 

    with data_path.open() as data_file: 

        data_reader = csv.DictReader(data_file) 

        clean_data_reader = cleanse(data_reader) 

        for row in clean_data_reader: 

            pprint(row)

这些清洗和丰富的行看起来如下:

>>> data = Path("data") / "waypoints.csv" 

>>> display_clean(data) 

{’date’: ’2012-11-27’, 

 ’lat’: ’32.8321666666667’, 

 ’lat_lon’: (32.8321666666667, -79.9338333333333), 

 ’lon’: ’-79.9338333333333’, 

 ’time’: ’09:15:00’, 

 ’timestamp’: datetime.datetime(2012, 11, 27, 9, 15), 

 ’ts_date’: datetime.date(2012, 11, 27), 

 ’ts_time’: datetime.time(9, 15)} 

...

新的列,如 lat_lon,包含正确的数值而不是字符串。时间戳值包含完整的日期时间值,可用于计算航点之间经过的时间的简单计算。

11.3.5 参见

  • 参见第九章中的 结合 map 和 reduce 转换 配方,以获取有关处理流程或堆栈的想法的更多信息。

  • 参见第四章中的 切片和切块列表 配方,以及第九章中的 使用堆叠生成表达式 配方,以获取有关处理不正确 1NF 的 CSV 文件的更多信息。

  • 关于 with 语句的更多信息,请参阅第七章中的 创建上下文和上下文管理器 配方。

  • 在第十章中,许多配方使用 Pydantic 来执行这些类型的数据转换。参见 使用 Pydantic 实现更严格的类型检查 配方,了解这种替代方法的示例。

  • 查看 www.packtpub.com/product/learning-pandas-second-edition/9781787123137 学习 pandas,了解使用 pandas 框架处理 CSV 文件的方法。

11.4 使用 dataclasses 简化 CSV 文件的工作

一种常用的数据格式称为逗号分隔值 (CSV)。Python 的 csv 模块有一个非常方便的 DictReader 类定义。当一个文件包含一行标题时,标题行的值成为用于所有后续行的键。这为数据的逻辑布局提供了很大的灵活性。例如,列顺序并不重要,因为每个列的数据都由标题行中的一个名称标识。

使用字典迫使我们编写,例如,row[’lat’] 或 row[’date’] 来引用特定列中的数据。内置的 dict 类没有提供派生数据。如果我们切换到数据类,我们将获得许多好处:

  • 更好的属性语法,如 row.lat 或 row.date。

  • 派生值可以是延迟属性。

  • 冻结的数据类是不可变的,对象可以作为字典的键和集合的成员。

我们如何使用数据类改进数据访问和处理?

11.4.1 准备工作

我们将查看一个包含从帆船日志中记录的实时数据的 CSV 文件。此文件是 waypoints.csv 文件。有关更多信息,请参阅本章中的 使用 CSV 模块读取定界文件 烹饪配方。数据如下所示:

lat,lon,date,time 

32.8321666666667,-79.9338333333333,2012-11-27,09:15:00 

31.6714833333333,-80.93325,2012-11-28,00:00:00 

30.7171666666667,-81.5525,2012-11-28,11:35:00

第一行包含一个标题,命名了四个列,lat、lon、date 和 time。数据可以通过 csv.DictReader 对象读取。我们希望进行更复杂的工作,因此我们将创建一个 @dataclass 类定义,封装数据和我们需要执行的处理。

11.4.2 如何操作...

我们需要从一个反映可用数据的数据类开始,然后我们可以使用这个数据类与字典读取器一起使用:

  1. 导入所需的各种库的定义:

    from dataclasses import dataclass, field 
    
    import datetime 
    
    from collections.abc import Iterator
    
  2. 定义一个专注于输入的数据类,精确地像源文件中那样出现。我们称这个类为 RawRow。在一个复杂的应用程序中,一个比 RawRow 更有描述性的名称会更合适。这个属性定义可能会随着源文件组织的变化而变化:

    @dataclass 
    
    class RawRow: 
    
        date: str 
    
        time: str 
    
        lat: str 
    
        lon: str 
    

    实际上,企业文件格式很可能在引入新软件版本时发生变化。在发生变化时,将文件模式正式化为类定义通常有助于单元测试和问题解决。

  3. 定义第二个数据类,其中对象由源数据类的属性构建。这个第二类专注于应用程序的实际工作。在这个例子中,源数据在一个名为 raw 的单个属性中。从这个源数据计算的字段都使用 field(init=False) 初始化,因为它们将在初始化之后计算:

    @dataclass 
    
    class Waypoint: 
    
        raw: RawRow 
    
        lat_lon: tuple[float, float] = field(init=False) 
    
        ts_date: datetime.date = field(init=False) 
    
        ts_time: datetime.time = field(init=False) 
    
        timestamp: datetime.datetime = field(init=False)
    
  4. __post_init__() 方法添加到急切初始化所有派生字段:

     def __post_init__(self) -> None: 
    
            self.ts_date = datetime.datetime.strptime( 
    
                self.raw.date, "%Y-%m-%d" 
    
            ).date() 
    
            self.ts_time = datetime.datetime.strptime( 
    
                self.raw.time, "%H:%M:%S" 
    
            ).time() 
    
            self.lat_lon = ( 
    
                float(self.raw.lat), 
    
                float(self.raw.lon) 
    
            ) 
    
            self.timestamp = datetime.datetime.combine( 
    
                self.ts_date, self.ts_time 
    
            )
    
  5. 给定这两个数据类定义,我们可以创建一个迭代器,它将接受来自 csv.DictReader 对象的单独字典并创建所需的 Waypoint 对象。中间表示 RawRow 是一个便利,这样我们就可以将属性名称分配给源数据列:

    def waypoint_iter(reader: csv.DictReader[str]) -> Iterator[Waypoint]: 
    
        for row in reader: 
    
            raw = RawRow(**row) 
    
            yield Waypoint(raw)
    

waypoint_iter() 函数从输入字典创建 RawRow 对象,然后从 RawRow 实例创建最终的 Waypoint 对象。这个两步过程有助于隔离对源或处理的代码变更。

我们可以使用以下函数来读取和显示 CSV 数据:

def display(data_path: Path) -> None: 

    with data_path.open() as data_file: 

        data_reader = csv.DictReader(data_file) 

        for waypoint in waypoint_iter(data_reader): 

            pprint(waypoint)

11.4.3 它是如何工作的...

在这个例子中,源数据类 RawRow 类被设计成与输入文档相匹配。字段名称和类型与 CSV 输入类型相匹配。由于名称匹配,RawRow(**row) 表达式将从 DictReader 字典创建 RawRow 类的实例。

从这个初始的或原始的数据中,我们可以推导出更有用的数据,如 Waypoint 类定义所示。__post_init__() 方法将 self.raw 属性中的初始值转换成多个更有用的属性值。

这种分离使我们能够管理应用软件的以下两种常见变更:

  1. 由于电子表格是手动调整的,源数据可能会发生变化。这是常见的:一个人可能会更改列名或更改列的顺序。

  2. 随着应用程序焦点的扩展或转移,所需的计算可能会发生变化。可能会添加更多派生列,或者算法可能会改变。

将程序的各种方面解开,以便我们可以让它们独立演变,这是很有帮助的。收集、清理和过滤源数据是这种关注点分离的一个方面。由此产生的计算是一个独立的方面,与源数据的格式无关。

11.4.4 更多...

在许多情况下,源 CSV 文件将具有不直接映射到有效 Python 属性名称的标题。在这些情况下,源字典中存在的键必须映射到列名。这可以通过扩展 RawRow 类定义以包括一个构建 RawRow 数据类对象的 @classmethod 来管理。

以下示例定义了一个名为 RawRow_HeaderV2 的类。这个定义反映了具有不同列名标题的变体电子表格:

@dataclass 

class RawRow_HeaderV2: 

    date: str 

    time: str 

    lat: str 

    lon: str 

    @classmethod 

    def from_csv(cls, csv_row: dict[str, str]) -> "RawRow_HeaderV2": 

        return RawRow_HeaderV2( 

            date = csv_row[’Date of Travel (YYYY-MM-DD)’], 

            time = csv_row[’Arrival Time (HH:MM:SS)’], 

            lat = csv_row[’Latitude (degrees N)’], 

            lon = csv_row[’Logitude (degrees W)’],

RawRow_HeaderV2 类的实例是通过表达式 RawRow_HeaderV2.from_csv(row) 构建的。这些对象与 RawRow 类兼容。这两个类中的任何一个对象也可以转换成 Waypoint 实例。

对于与各种数据源一起工作的应用程序,这类“原始数据转换”dataclasses 可以方便地将逻辑布局中的细微变化映射到一致的内部结构,以便进一步处理。随着输入转换类的数量增加,需要额外的类型提示。例如,以下类型提示为输入格式的变化提供了一个通用名称:

Raw: TypeAlias = RawRow | RawRow_HeaderV2

这种类型提示有助于统一原始的 RawRow 和替代的 RawRow_HeaderV2 类型,它们是具有兼容功能的替代定义。最重要的功能是使用生成器逐行处理数据,以避免创建包含所有数据的庞大列表对象。

11.4.5 参考信息

  • 本章前面的使用 CSV 模块读取分隔符文件配方也涵盖了 CSV 文件读取。

  • 在第六章的使用 dataclasses 处理可变对象配方中,也介绍了使用 Python 的 dataclasses 的方法。

11.5 使用正则表达式读取复杂格式

许多文件格式缺乏 CSV 文件那种优雅的规律性。一个相当难以解析的常见文件格式是 Web 服务器日志文件。这些文件往往具有复杂的数据,没有单一的、统一的分隔符字符或一致的引号规则。

当我们在第九章的使用 yield 语句编写生成器函数配方中查看简化的日志文件时,我们看到行如下所示:

[2016-05-08 11:08:18,651] INFO in ch09_r09: Sample Message One 

[2016-05-08 11:08:18,651] DEBUG in ch09_r09: Debugging 

[2016-05-08 11:08:18,652] WARNING in ch09_r09: Something might have gone wrong

在这个文件中使用了各种标点符号。csv 模块无法解析这种复杂性。

我们希望编写出像 CSV 处理一样优雅简单的程序。这意味着我们需要封装日志文件解析的复杂性,并将这一方面与分析和汇总处理分开。

11.5.1 准备工作

解析具有复杂结构的文件通常涉及编写一个函数,该函数的行为类似于 csv 模块中的 reader()函数。在某些情况下,创建一个类似于 DictReader 类的简单类可能更容易。

读取复杂文件的核心功能是将一行文本转换成字典或单个字段值的元组。这项工作的部分通常可以通过 re 包来完成。

在我们开始之前,我们需要开发(并调试)一个正则表达式,以便正确解析输入文件的每一行。有关此信息,请参阅第一章中的使用正则表达式进行字符串解析配方。

对于这个例子,我们将使用以下代码。我们将定义一个模式字符串,其中包含一系列用于行中各种元素的正则表达式:

import re 

pattern_text = ( 

    r"\[(?P<date>.*?)]\s+" 

    r"(?P<level>\w+)\s+" 

    r"in\s+(?P<module>\S+?)" 

    r":\s+(?P<message>.+)" 

    ) 

pattern = re.compile(pattern_text, re.X)

我们使用了 re.X 选项,这样我们可以在正则表达式中包含额外的空白。这可以通过分隔前缀和后缀字符来帮助使其更易于阅读。

当我们编写正则表达式时,我们将要捕获的有兴趣的子字符串用 () 括起来。在执行 match() 或 search() 操作后,生成的 Match 对象将包含匹配的子字符串的捕获文本。Match 对象的 groups() 方法和 Match 对象的 groupdict() 方法将提供捕获的字符串。

这是此模式的工作方式:

>>> sample_data = ’[2016-05-08 11:08:18,651] INFO in ch10_r09: Sample Message One’ 

>>> match = pattern.match(sample_data) 

>>> match.groups() 

(’2016-05-08 11:08:18,651’, ’INFO’, ’ch10_r09’, ’Sample Message One’) 

>>> match.groupdict() 

{’date’: ’2016-05-08 11:08:18,651’, ’level’: ’INFO’, ’module’: ’ch10_r09’, ’message’: ’Sample Message One’}

我们在 sample_data 变量中提供了一行样本数据。生成的 Match 对象有一个 groups() 方法,它返回每个有趣的字段。match 对象的 groupdict() 方法的值是一个字典,其中包含在正则表达式中括号内的 ?P 前缀提供的名称。

11.5.2 如何做到...

此配方分为两个小配方。第一部分定义了一个 log_parser() 函数来解析单行,而第二部分则将 log_parser() 函数应用于输入的每一行。

定义解析函数

执行以下步骤以定义 log_parser() 函数:

  1. 定义编译后的正则表达式对象:

    import re 
    
    pattern_text = ( 
    
        r"\[(?P<date>.*?)]\s+" 
    
        r"(?P<level>\w+)\s+" 
    
        r"in\s+(?P<module>\S+?)" 
    
        r":\s+(?P<message>.+)" 
    
        ) 
    
    pattern = re.compile(pattern_text, re.X) 
    
  2. 定义一个类来模拟生成的复杂数据对象。这可以具有额外的派生属性或其他复杂计算。最小化地,NamedTuple 必须定义解析器提取的字段。字段名称应与正则表达式捕获名称在 (?P...) 前缀中匹配:

    from typing import NamedTuple 
    
    class LogLine(NamedTuple): 
    
        date: str 
    
        level: str 
    
        module: str 
    
        message: str
    
  3. 定义一个接受一行文本作为参数并生成解析后的 LogLine 实例的函数:

    def log_parser(source_line: str) -> LogLine:
    
  4. 将正则表达式应用于创建一个匹配对象。我们将其分配给 match 变量,并检查它是否不为 None:

        if match := pattern.match(source_line):
    
  5. 当 match 的值为非 None 时,返回一个包含此输入行各种数据的有用数据结构:

            data = match.groupdict() 
    
            return LogLine(**data)
    
  6. 当匹配为 None 时,记录问题或引发异常以停止处理:

        raise ValueError(f"Unexpected input {source_line=}")
    

使用 log_parser() 函数

此部分的配方将应用 log_parser() 函数到输入文件的每一行:

  1. 从 pathlib 模块中导入有用的类和函数定义:

    >>> from pathlib import Path 
    
    >>> from pprint import pprint
    
  2. 创建标识文件的 Path 对象:

    >>> data_path = Path("data") / "sample.log"
    
  3. 使用 Path 对象以 with 语句打开文件。从打开的文件对象 data_file 创建日志文件读取器。在这种情况下,我们将使用内置的 map() 函数将 log_parser() 函数应用于源文件的每一行:

    >>> with data_path.open() as data_file: 
    
    ...     data_reader = map(log_parser, data_file)
    
  4. 读取(并处理)各种数据行。对于此示例,我们将打印每一行:

    ...     for row in data_reader: 
    
    ...         pprint(row)
    

输出是一系列看起来如下所示的 LogLine 元组:

LogLine(date=’2016-06-15 17:57:54,715’, level=’INFO’, module=’ch09_r10’, message=’Sample Message One’) 

LogLine(date=’2016-06-15 17:57:54,715’, level=’DEBUG’, module=’ch09_r10’, message=’Debugging’) 

LogLine(date=’2016-06-15 17:57:54,715’, level=’WARNING’, module=’ch09_r10’, message=’Something might have gone wrong’)

我们可以对这些元组实例进行比原始文本行更有意义的处理。这允许我们通过严重程度级别过滤数据,或根据提供消息的模块创建计数器。

11.5.3 它是如何工作的...

此日志文件处于第一范式(1NF):数据组织成代表独立实体或事件的行。每一行都有一致的属性或列数,每一列都有原子数据或无法进一步有意义的分解。然而,与 CSV 文件不同的是,这种特定格式需要复杂的正则表达式来解析。

在我们的日志文件示例中,时间戳包含多个单独的元素——年、月、日、小时、分钟、秒和毫秒——但进一步分解时间戳的价值不大。将其用作单个日期时间对象并从中提取详细信息(如一天中的小时)比将单个字段组装成新的复合数据更有帮助。

在复杂的日志处理应用程序中,可能有几种不同类型的消息字段。可能需要使用不同的模式来解析这些消息类型。当我们需要这样做时,我们发现日志中的各种行在格式和属性数量方面并不一致,这违反了 1NF 假设之一。

我们通常遵循使用 CSV 模块读取定界文件配方中的设计模式,这样读取复杂的日志文件几乎与读取简单的 CSV 文件相同。实际上,我们可以看到主要区别在于一行代码:

[firstline=93,lastline=93,gobble=8][python]src/ch11/recipe˙05.py

与以下内容比较:

[firstline=95,lastline=95,gobble=8][python]src/ch11/recipe˙05.py

这种并行结构允许我们在许多输入文件格式之间重用分析函数。这使我们能够创建一个库,该库可以用于多个数据源。它可以帮助使分析应用程序在数据源更改时具有弹性。

11.5.4 更多...

在读取非常复杂的文件时,最常见的操作之一是将它们重写为更容易处理的格式。我们通常会想将数据保存为 CSV 格式以供以后处理。

其中一些与第七章中的使用多个资源管理多个上下文配方类似。这个配方展示了多个打开的文件处理上下文。我们将从一个文件中读取并写入到另一个文件中。

文件写入过程如下:

import csv 

def copy(data_path: Path) -> None: 

    target_path = data_path.with_suffix(".csv") 

    with target_path.open("w", newline="") as target_file: 

        writer = csv.DictWriter(target_file, LogLine._fields) 

        writer.writeheader() 

        with data_path.open() as data_file: 

            reader = map(log_parser, data_file) 

            writer.writerows(row._asdict() for row in reader)

该脚本的第一个部分定义了一个用于目标文件的 CSV 写入器。输出文件的路径 target_path 基于输入名称 data_path。后缀被更改为.csv。

使用 newline=’’选项关闭了换行符,以打开目标文件。这允许 csv.DictWriter 类插入适合所需 CSV 方言的换行符。

创建一个 DictWriter 对象以写入指定的文件。列标题的序列由 LogLines 类定义提供。这确保输出 CSV 文件将包含正确、一致的列名。

writeheader() 方法将列名写入输出文件的第一行。这使得读取文件稍微容易一些,因为提供了列名。CSV 文件的第一行可以包含显式的模式定义,显示存在哪些数据。

源文件已按前一个菜谱所示打开。由于 csv 模块编写器的工作方式,我们可以将读取生成器表达式提供给 writer 的 writerows() 方法。writerows() 方法将消耗读取生成器产生的所有数据。这将反过来消耗由打开的文件产生的所有行。

我们不需要编写任何显式的 for 语句来确保处理所有输入行。writerows() 函数为我们保证了这一点。

输出文件如下所示:

date,level,module,message 

"2016-06-15 17:57:54,715",INFO,ch09_r10,Sample Message One 

"2016-06-15 17:57:54,715",DEBUG,ch09_r10,Debugging 

"2016-06-15 17:57:54,715",WARNING,ch09_r10,Something might have gone wrong

该文件已从相对复杂的输入格式转换为更简单的 CSV 格式,适合进一步分析和处理。

11.5.5 参考内容

  • 有关 with 语句的更多信息,请参阅第七章的 创建上下文和上下文管理器 菜单。

  • 第九章的 使用 yield 语句编写生成器函数 菜谱展示了这种日志格式的其他处理方式。

  • 在本章前面的 使用 CSV 模块读取分隔文件 菜谱中,我们探讨了这种通用设计模式的其它应用。

  • 在本章前面的 使用数据类简化 CSV 文件处理 菜谱中,我们探讨了其他复杂的 CSV 处理技术。

11.6 读取 JSON 和 YAML 文档

JavaScript 对象表示法 (JSON) 通常用于序列化数据。有关详细信息,请参阅 json.org。Python 包含 json 模块,以便使用这种表示法序列化和反序列化数据。

JSON 文档被广泛应用于网络应用程序。在 RESTful 网络客户端和服务器之间使用 JSON 表示法的文档交换数据是很常见的。这两个应用堆栈的层级通过通过 HTTP 协议发送的 JSON 文档进行通信。

YAML 格式是 JSON 表示法的更复杂和灵活的扩展。有关详细信息,请参阅 yaml.org。任何 JSON 文档都是有效的 YAML 文档。反之则不然:YAML 语法更复杂,包括一些在 JSON 中无效的构造。

要使用 YAML,必须安装一个额外的模块:

(cookbook3) % python -m pip install pyyaml

PyYAML 项目提供了一个流行且功能良好的 yaml 模块。请参阅 pypi.org/project/PyYAML/.

在本菜谱中,我们将使用 json 模块来解析 Python 中的 JSON 格式数据。

11.6.1 准备工作

我们在 race_result.json 文件中收集了一些帆船比赛结果。这个文件包含了关于队伍、比赛段以及各个队伍完成每个单独比赛段顺序的信息。JSON 优雅地处理了这些复杂的数据。

总分可以通过计算每个比赛段的完成位置来得出:得分最低的是总冠军。在某些情况下,当一艘船未参赛、未完成比赛或被取消比赛资格时,会有 null 值。

在计算队伍的总分时,null 值被分配一个比比赛船只数量多一个的分数。如果有七艘船,那么队伍因未能完成比赛而得到八分,这是一个相当大的惩罚。

数据具有以下架构。在整个文档中有两个字段:

  • legs:一个字符串数组,显示起点和终点。

  • teams:一个包含每个队伍详细信息的对象数组。在每个 teams 对象中,有几个数据字段:

    • name:字符串形式的队伍名称。

    • position:一个包含整数和 nulls 的数组,表示位置。这个数组中项目的顺序与 legs 数组中项目的顺序相匹配。

数据看起来如下:

{ 

  "teams": [ 

    { 

      "name": "Abu Dhabi Ocean Racing", 

      "position": [ 

        1, 

        3, 

        2, 

        2, 

        1, 

        2, 

        5, 

        3, 

        5 

      ] 

    }, 

... 

  ], 

  "legs": [ 

    "ALICANTE - CAPE TOWN", 

    "CAPE TOWN - ABU DHABI", 

    "ABU DHABI - SANYA", 

    "SANYA - AUCKLAND", 

    "AUCKLAND - ITAJA\u00cd", 

    "ITAJA\u00cd - NEWPORT", 

    "NEWPORT - LISBON", 

    "LISBON - LORIENT", 

    "LORIENT - GOTHENBURG" 

  ] 

}

我们只展示了第一支队伍的详细信息。在这场特定的比赛中,总共有七支队伍。每个队伍由一个 Python 字典表示,其中包含队伍的名称和他们在每个比赛段的完成位置历史。对于这里展示的队伍,阿布扎比海洋赛车队,他们在第一段比赛中获得第一名,然后在下一段比赛中获得第三名。他们的最差表现是在第七段和第九段比赛中获得第五名,这两段比赛是从美国罗德岛纽波特到葡萄牙里斯本,以及从法国洛里昂到瑞典哥德堡。

JSON 格式的数据看起来像包含列表的 Python 字典。这种 Python 语法和 JSON 语法的重叠可以被视为一种愉快的巧合:它使得从 JSON 源文档构建的 Python 数据结构更容易可视化。

JSON 有一组小的数据结构:null、布尔值、数字、字符串、列表和对象。这些直接映射到 Python 类型中的对象。json 模块为我们将这些源文本转换为 Python 对象。

其中一个字符串包含一个 Unicode 转义序列,\u00cd,而不是实际的 Unicode 字符Í。这是一种常见的用于编码超出 128 个 ASCII 字符的字符的技术。json 模块中的解析器为我们处理了这一点。

在这个例子中,我们将编写一个函数来解开这个文档,并显示每个比赛段的队伍完成情况。

11.6.2 如何实现...

这个食谱将首先导入必要的模块。然后我们将使用这些模块将文件内容转换为有用的 Python 对象:

  1. 我们需要 json 模块来解析文本。我们还需要一个 Path 对象来引用文件:

    import json 
    
    from pathlib import Path
    
  2. 定义一个 race_summary() 函数来从给定的 Path 实例读取 JSON 文档:

    def race_summary(source_path: Path) -> None: 
    
  3. 通过解析 JSON 文档创建一个 Python 对象。通常,使用 source_path.read_text() 读取由 Path 对象命名的文件是最简单的。我们将此字符串提供给 json.loads() 函数进行解析。对于非常大的文件,可以将打开的文件传递给 json.load() 函数:

        document = json.loads(source_path.read_text())
    
  4. 显示数据:文档对象包含一个包含两个键的字典,teams 和 legs。以下是遍历每个 legs 的方法,显示队伍在 legs 中的位置:

        for n, leg in enumerate(document[’legs’]): 
    
            print(leg) 
    
            for team_finishes in document[’teams’]: 
    
                print( 
    
                    team_finishes[’name’], 
    
                    team_finishes[’position’][n])
    

每个队伍的数据将是一个包含两个键的字典:name 和 position。我们可以深入到队伍的详细信息中,以获取第一个队伍的名称:

>>> document[’teams’][6][’name’] 

’Team Vestas Wind’

我们可以查看 legs 字段,以查看每个赛段的名称:

>>> document[’legs’][5] 

’ITAJA - NEWPORT’

11.6.3 它是如何工作的...

JSON 文档是 JavaScript 对象表示法中的数据结构。JavaScript 程序可以轻松解析文档。其他语言必须做更多工作来将 JSON 转换为本地数据结构。

JSON 文档包含三种结构:

  • 映射到 Python 字典的对象:JSON 的语法与 Python 类似:{"key": "value", ...}。

  • 映射到 Python 列表的数组:JSON 语法使用 [item, ...],这也与 Python 类似。

  • 原始值:有五种值类别:字符串、数字、true、false 和 null。字符串用 " 和 " 包围,并使用各种 \ 转义序列,这与 Python 的类似。数字遵循浮点值规则。其他三个值是简单的字面量;这些与 Python 的 True、False 和 None 字面量平行。

    作为特殊情况,没有小数点的数字成为 Python int 对象。这是 JSON 标准的扩展。

没有提供其他数据类型的支持。这意味着 Python 程序必须将复杂的 Python 对象转换为更简单的表示,以便它们可以用 JSON 语法进行序列化。

相反,我们经常应用额外的转换来从简化的 JSON 表示中重建复杂的 Python 对象。json 模块有一些地方可以应用额外的处理来创建更复杂的 Python 对象。

11.6.4 更多...

通常,一个文件包含一个单一的 JSON 文档。JSON 标准没有提供一种简单的方法来将多个文档编码到单个文件中。如果我们想分析一个网络日志,例如,原始的 JSON 标准可能不是保存大量信息的最佳表示法。

有一些常见扩展,如换行符分隔的 JSON(ndjson.org)和 JSON Lines,jsonlines.org,用于定义将多个 JSON 文档编码到单个文件中的方式。

当这些方法处理文档集合时,还有一个额外的问题我们需要解决:序列化(和反序列化)复杂对象,例如 datetime 对象。

当我们将 Python 对象的状态表示为文本字符的字符串时,我们已经序列化了对象的状态。许多 Python 对象需要被保存到文件或传输到另一个进程。这类传输需要对象状态的表示。我们将分别查看序列化和反序列化。

序列化复杂的数据结构

如果我们创建的 Python 对象仅限于内置类型 dict、list、str、int、float、bool 和 None 的值,那么序列化到 JSON 的效果最好。这个 Python 类型子集可以用来构建 json 模块可以序列化的对象,并且可以由多种不同语言编写的许多程序广泛使用。

一种常用的、不易序列化的数据结构是 datetime.datetime 对象。

避免在尝试序列化不寻常的 Python 对象时引发 TypeError 异常,可以通过两种方式之一实现。我们可以在构建文档之前将数据转换为 JSON 友好的结构,或者我们可以在 JSON 序列化过程中添加一个默认类型处理器,这样我们就可以提供一个可序列化的数据版本。

在将 datetime 对象序列化为 JSON 之前转换为字符串,我们需要对底层数据进行更改。由于序列化问题而篡改数据或 Python 的数据类型似乎有些尴尬。

序列化复杂数据的另一种技术是提供一个在序列化过程中由 json 模块使用的函数。此函数必须将复杂对象转换为可以安全序列化的东西。在下面的示例中,我们将 datetime 对象转换为简单的字符串值:

def default_date(object: Any) -> Any: 

    match object: 

        case datetime.datetime(): 

            return {"$date$": object.isoformat()} 

    return object

我们定义了一个函数,default_date(),它将对 datetime 对象应用特殊的转换规则。任何 datetime.datetime 实例都将被替换为一个具有明显键 – "\(date\)" – 和字符串值的字典。这个字典可以通过 json 模块进行序列化。

我们将这个序列化辅助函数提供给 json.dumps() 函数。这是通过将 default_date() 函数分配给默认参数来完成的,如下所示:

>>> example_date = datetime.datetime(2014, 6, 7, 8, 9, 10) 

>>> document = {’date’: example_date} 

>>> print( 

...     json.dumps(document, default=default_date, indent=2) 

... ) 

{ 

  "date": { 

    "$date$": "2014-06-07T08:09:10" 

  } 

}

当 json 模块无法序列化一个对象时,它将对象传递给给定的默认函数 default_date()。在任何给定的应用程序中,我们需要扩展这个函数以处理我们可能想要在 JSON 表示法中序列化的多种 Python 对象类型。如果没有提供默认函数,当对象无法序列化时将引发异常。

反序列化复杂的数据结构

当反序列化 JSON 以创建 Python 对象时,有一个钩子可以用来将数据从 JSON 字典转换为更复杂的 Python 对象。这被称为 object_hook,它在 json.loads() 函数的处理过程中被使用。此钩子用于检查每个 JSON 字典,以查看是否应该从字典实例创建其他内容。

我们提供的函数将创建一个更复杂的 Python 对象,或者简单地返回未修改的原始字典对象:

def as_date(object: dict[str, Any]) -> Any: 

    if {’$date$’} == set(object.keys()): 

        return datetime.datetime.fromisoformat(object[’$date$’]) 

    return object

此函数将检查每个解码的对象,以查看对象是否只有一个字段,并且该单个字段是否命名为"\(date\)"。如果是这种情况,整个对象的价值将替换为 datetime.datetime 对象。返回类型是 Any 和 dict[str, Any]的联合,以反映两种可能的结果:要么是某个对象,要么是原始字典。

我们通过 json.loads()函数使用 object_hook 参数提供了一个函数,如下所示:

>>> source = ’’’{"date": {"$date$": "2014-06-07T08:09:10"}}’’’ 

>>> json.loads(source, object_hook=as_date) 

{’date’: datetime.datetime(2014, 6, 7, 8, 9, 10)}

这解析了一个非常小的 JSON 文档。所有对象都提供给 as_date()对象钩子。在这些对象中,有一个字典符合包含日期的标准。从 JSON 序列化中找到的字符串值构建了一个 Python 对象。

Pydantic 包提供了一系列序列化功能。关于如何使用此包的配方在第十章中有展示。

11.6.5 参见

  • 本章后面的读取 HTML 文档配方将展示我们如何从 HTML 源准备这些数据。

  • 第十章中的使用 Pydantic 实现更严格的类型检查配方涵盖了 Pydantic 包的一些功能。

11.7 读取 XML 文档

XML 标记语言广泛用于以序列化形式表示对象的状态。有关详细信息,请参阅www.w3.org/TR/REC-xml/。Python 包含多个用于解析 XML 文档的库。

XML 被称为标记语言,因为感兴趣的内容被标记为标签,使用起始和结束来书写,用于定义数据结构。整个文件文本包括内容和 XML 标记。

由于标记与文本交织在一起,因此必须使用一些额外的语法规则来区分标记和文本。文档必须使用<代替<,>代替>,以及&代替&在文本中。此外,"也用于在属性值中嵌入一个"字符。在大多数情况下,XML 解析器在消费 XML 时会处理这种转换。

因此,示例文档将包含以下项目:

<team><name>Team SCA</name><position>...</position></team>

标签包含标签,其中包含团队的名称文本。《position>标签包含关于团队在每个赛段完成位置的数据。

整个文档形成一个大型、嵌套的容器集合。我们可以将文档视为一个树,其根标签包含所有其他标签及其嵌入的内容。在标签之间,可以有额外的内容。在某些应用中,标签结束之间的额外内容完全是空白。

下面是我们将要查看的文档的开始部分:

<?xml version="1.0"?> 

<results> 

    <teams> 

        <team> 

            <name> 

                Abu Dhabi Ocean Racing 

            </name> 

            <position> 

                <leg n="1"> 

                    1 

                </leg> 

               ... 

            </position> 

            ... 

        </team> 

        ... 

   </teams> 

    <legs> 

        <leg n="1"> 

            ALICANTE - CAPE TOWN 

        </leg> 

        ... 

    </legs> 

</results>

最高层容器是标签。在这个容器内有一个标签。在标签内,每个团队的数据都以标签的形式重复出现。我们使用...来表示文档中省略的部分。

使用正则表达式解析 XML 非常困难。正则表达式不擅长处理 XML 中存在的递归和重复类型。我们需要更复杂的解析器来处理嵌套标签的语法。

有两个二进制库,分别是 xml.sax 和 xml.parsers.expat 模块的一部分,用于解析 XML。这些库的优点是速度非常快。

此外,xml.etree 包中还有一个非常复杂的工具集。我们将专注于使用此包中的 ElementTree 类来解析和分析 XML 文档。这具有提供大量有用功能的优势,如 XPath 搜索,以在复杂文档中查找标签。

11.7.1 准备工作

我们已经收集了一些帆船赛成绩,保存在 race_result.xml 文件中。该文件包含有关团队、赛段以及各个团队完成每个赛段顺序的信息。有关此数据的更多信息,请参阅本章中的阅读 JSON 和 YAML 文档配方。

此数据的根标签是一个文档。它具有以下模式:

  • 标签包含单个标签,命名每条赛道的名称。每个标签将包含一个起始港和一个结束港的文本。

  • 标签包含多个标签,包含每个团队的详细信息。每个团队的数据都使用内部标签进行结构化:

    • 标签包含团队名称。

    • 标签包含多个标签,表示给定腿的完成位置。每个腿都有编号,编号与标签中的腿定义相匹配。

在 XML 表示法中,应用程序数据出现在两种地方。第一种是在起始标签和结束标签之间——例如,阿布扎比海洋赛车,有文本“阿布扎比海洋赛车”,以及标签。

此外,数据还将作为标签的属性出现;例如,在中。标签是,具有属性 n,其值为"1"。一个标签可以有无限数量的属性。

标签指出了 XML 的一个有趣问题。这些标签包括作为属性的腿号,而腿的位置由标签内的文本给出。没有真正的模式或偏好来指定有用数据的位置。理想情况下,它总是在标签之间,但这通常不是真的。

XML 允许混合内容模型。这反映了 XML 与文本混合的情况,其中文本位于 XML 标签内外。以下是一个混合内容的示例:

<p> 

This has <strong>mixed</strong> content. 

</p>

标签的内容是文本和标签的混合。我们在本食谱中处理的数据不依赖于这种混合内容模型,这意味着所有数据都在单个标签或标签的属性中。标签之间的空白可以忽略。

11.7.2 如何实现...

我们将定义一个函数,将 XML 文档转换为包含腿描述和团队结果的字典:

  1. 我们需要 xml.etree 模块来解析 XML 文本。我们还需要一个 Path 对象来引用文件。我们将 ElementTree 类的较短名称分配给了 XML:

    import xml.etree.ElementTree as XML 
    
    from pathlib import Path 
    
    from typing import cast
    

    cast() 函数是必需的,用于强制工具如 mypy 将结果视为给定类型。这使得我们可以忽略 None 结果的可能性。

  2. 定义一个函数来从给定的 Path 实例读取 XML 文档:

    def race_summary(source_path: Path) -> None:
    
  3. 通过解析 XML 文本创建一个 Python ElementTree 对象。通常,使用 source_path.read_text() 读取由路径指定的文件是最简单的。我们提供了这个字符串给 XML.fromstring() 方法进行解析。对于非常大的文件,增量解析器有时更有帮助。以下是针对较小文件的版本:

        source_text = source_path.read_text(encoding=’UTF-8’) 
    
        document = XML.fromstring(source_text)
    
  4. 显示数据。XML 元素对象有两个用于导航 XML 结构的有用方法,即 find() 和 findall() 方法,分别用于定位标签的第一个实例和所有实例。使用这些方法,我们可以创建一个包含两个键的字典,"teams" 和 "legs":

        legs = cast(XML.Element, document.find(’legs’)) 
    
        teams = cast(XML.Element, document.find(’teams’)) 
    
        for leg in legs.findall(’leg’): 
    
            print(cast(str, leg.text).strip()) 
    
            n = leg.attrib[’n’] 
    
            for team in teams.findall(’team’): 
    
                position_leg = cast(XML.Element, 
    
                    team.find(f"position/leg[@n=’{n}’]")) 
    
                name = cast(XML.Element, team.find(’name’)) 
    
                print( 
    
                    cast(str, name.text).strip(), 
    
                    cast(str, position_leg.text).strip() 
    
                )
    

    标签内,有许多单独的 标签。每个标签都具有以下结构:

    <leg n="1">ALICANTE - CAPE TOWN</leg>
    

    Python 表达式 leg.attrib[’n’] 从给定元素中提取名为 n 的属性值。表达式 leg.text.strip() 将找到 标签内的所有文本,并去除额外的空白。

    元素的 find() 和 findall() 方法使用 XPath 语法来定位标签。我们将在本食谱的 There’s more... 部分中详细检查这些功能。

重要的是要注意,find() 函数的结果具有 XML.Element | None 的类型提示。对于 None 结果的可能性,我们有两种处理方法:

  • 使用 if 语句来处理结果为 None 的情况。

  • 使用 cast(XML.Element, tag.find(...)) 来声明结果永远不会是 None。如果标签缺失,引发的异常将有助于诊断源文档与我们的消费者应用程序处理期望之间的不匹配。

对于比赛的每一腿,我们需要打印完成位置,这些位置包含在 标签内。在这个标签内,我们需要找到具有给定腿上该团队完成位置的适当 标签。为此,我们使用复杂的 XPath 搜索,f"position/leg[@n=’{n}’]",根据具有特定属性值的 标签的存在来定位特定的 标签。n 的值是腿号。对于第九腿,n=9,f-string 将是 "position/leg[@n=’9’]"。这将定位包含具有属性 n 等于 9 的 标签的 标签。

由于 XML 支持混合内容模型,内容中的所有 \n、\t 和空格字符在解析操作中都被完美保留。我们很少希望保留这些空白字符,因此在使用 strip() 方法在有意义的内容前后删除任何多余的字符是有意义的。

11.7.3 它是如何工作的...

XML 解析模块将 XML 文档转换为基于标准化的文档对象模型(DOM)的相当复杂的树结构。在 xml.etree 模块的情况下,文档将由 Element 对象构建,这些对象通常代表标签和文本。

XML 还可以包含处理指令和注释。在这里,我们将忽略它们,专注于文档结构和内容。

每个元素实例都有标签的文本、标签内的文本、标签的部分属性和尾部。标签是 内部的名称。属性是跟在标签名称后面的字段,例如, 标签有一个名为 leg 的标签名称和一个名为 n 的属性。在 XML 中,值始终是字符串;任何转换为不同数据类型都是使用该数据的应用程序的责任。

文本包含在标签的开始和结束之间。因此,一个如 <name>Team SCA</name> 的标签具有 "Team SCA" 作为代表 <name> 标签的元素的文本属性值。

注意,标签还有一个尾部属性。考虑以下两个标签的序列:

<name>Team SCA</name> 

<position>...</position>

在 标签关闭后和 标签打开前有一个 \n 空白字符。这些额外的文本被收集到 标签的尾部属性中。当在混合内容模型中工作时,这些尾部值可能很重要。在元素内容模型中工作时,尾部值通常是空白字符。

11.7.4 更多内容...

由于我们不能简单地将在 XML 文档转换为 Python 字典,我们需要一种方便的方法来搜索文档的内容。ElementTree 类提供了一种搜索技术,这是 XML 路径语言(XPath)的部分实现,用于指定 XML 文档中的位置。XPath 表示法为我们提供了相当大的灵活性。

XPath 查询与 find() 和 findall() 方法一起使用。以下是如何找到所有团队名称的方法:

>>> for tag in document.findall(’teams/team/name’): 

...     print(tag.text.strip()) 

Abu Dhabi Ocean Racing 

Team Brunel 

Dongfeng Race Team 

MAPFRE 

Team Alvimedica 

Team SCA 

Team Vestas Wind

XPath 查询查找顶级 标签。在该标签内,我们想要 标签。在这些标签内,我们想要 标签。这将搜索所有这种嵌套标签结构的实例。

11.7.5 参见

  • 与 XML 文档相关的安全问题有很多。有关更多信息,请参阅 OWASP XML 安全速查表

  • lxml 库扩展了元素树库的核心功能,提供了额外的功能。

  • 本章后面的 阅读 HTML 文档 菜谱展示了我们如何从 HTML 源准备这些数据。

11.8 阅读 HTML 文档

网络上大量的内容都是使用 HTML 呈现的。浏览器将数据渲染得非常漂亮。我们可以编写应用程序从 HTML 页面中提取内容。

解析 HTML 涉及两个复杂因素:

  • 与现代 XML 不同的古老 HTML 方言

  • 可以容忍不正确 HTML 并创建正确显示的浏览器

第一个复杂因素是 HTML 和 XML 的历史。现代 HTML 是 XML 的一个特定文档类型。历史上,HTML 从自己的独特文档类型定义开始,基于较老的 SGML。这些原始 SGML/HTML 概念被修订和扩展,以创建一种新的语言,XML。在从遗留 HTML 到基于 XML 的 HTML 的过渡期间,网络服务器使用各种过渡文档类型定义提供内容。大多数现代网络服务器使用前缀来声明文档是正确结构的 XML 语法,使用 HTML 文档模型。一些网络服务器将在前缀中使用其他 DOCTYPE 引用,并提供不是正确 XML 的 HTML。

解析 HTML 的另一个复杂因素是浏览器的设计。浏览器有义务渲染网页,即使 HTML 结构不佳甚至完全无效。设计目标是向用户提供反映内容的东西——而不是显示错误消息,指出内容无效。

HTML 页面可能充满了问题,但在浏览器中仍然可以显示一个看起来不错的页面。

我们可以使用标准库的 html.parser 模块,但它并不像我们希望的那样有帮助。Beautiful Soup 包提供了更多有用的方法来解析 HTML 页面到有用的数据结构。这个包可以在 Python 包索引(PyPI)上找到。请参阅pypi.python.org/pypi/beautifulsoup4

这必须使用以下终端命令下载和安装:

(cookbook3) % python -m pip install beautifulsoup4

11.8.1 准备工作

我们已经收集了一些历史帆船赛结果,保存在 Volvo Ocean Race.html 文件中。这个文件包含有关团队、航段以及各个团队完成每个航段的顺序的信息。它已被从沃尔沃海洋赛网站抓取,并在浏览器中打开时看起来很棒。有关此数据的更多信息,请参阅本章中的阅读 JSON 和 YAML 文档配方。

虽然 Python 的标准库有 urllib 包来获取文档,但通常使用 Requests 包来读取网页。

通常,一个 HTML 页面具有以下整体结构:

<html> 

<head>...</head> 

<body>...</body> 

</html>

在标签内,将有元数据、指向 JavaScript 库的链接以及指向层叠样式表(CSS)文档的链接。内容位于标签中。

在这种情况下,比赛结果位于标签内的 HTML

标签中。该表格具有以下结构:

<table> 

   <thead> 

      ... 

   </thead> 

   <tbody> 

       ... 

   </tbody> 

</table>
标签定义了表格的列标题。有一个单独的行标签,其中包含表格标题标签标签包含每个团队和比赛的成果行。每个表格行标签包含带有团队名称及其结果的行:
<tr class="ranking-item"> 

   <td class="ranking-position">3</td> 

   <td class="ranking-avatar"><img src="img/..."></td> 

   <td class="ranking-team"> Dongfeng Race Team</td> 

   <td class="ranking-number">2</td> 

   <td class="ranking-number">2</td> 

   <td class="ranking-number">1</td> 

   <td class="ranking-number">3</td> 

   <td class="ranking-number" tooltipster 

   data="<center><strong>RETIRED</strong><br> Click for more info</center>" data-theme="tooltipster-3" 

   data-position="bottom" data-htmlcontent="true"> 

   <a href="/en/news/8674_Dongfeng-Race-Team-breaks-mast-crew-safe.html" 

   target="_blank">8</a> 

   <div class="status-dot dot-3"></div></td> 

   ... more columns ... 

</tr>
标签有一个 class 属性,它定义了此行的 CSS 样式。这个 class 属性可以帮助我们的数据收集应用程序定位相关内容。
,这些标签包含列标题。对于示例数据,每个标签看起来像这样:
<th tooltipster data="<strong>ALICANTE - CAPE TOWN</strong>" data-theme="tooltipster-shadow" data-htmlcontent="true" data-position="top"> 

LEG 1</th>

重要的显示是每个赛段的标识符;在这个例子中是 LEG 1。这是

标签的文本内容。还有一个由 JavaScript 函数使用的属性值,data。这个属性值是腿的名称,当鼠标悬停在列标题上时显示。

表格数据标签。以下是从 HTML 中典型的
标签也有 class 属性。对于这个精心设计的数据,class 属性阐明了单元格的内容。并不是所有的 CSS 类名都像这些定义得那么好。

其中一个单元格——带有 tooltipster 属性——没有文本内容。相反,这个单元格有一个标签和一个空的

标签。这个单元格还包含几个属性,包括 data 等。这些属性由 JavaScript 函数用于在单元格中显示更多信息。

这里还有一个复杂性,即 data 属性包含实际上是 HTML 内容的文本。解析这部分文本需要创建一个单独的 BeautifulSoup 解析器。

11.8.2 如何实现...

我们将定义一个函数,将 HTML

转换为包含腿描述和团队结果的字典:

  1. 从 bs4 模块导入 BeautifulSoup 类以解析文本。我们还需要一个 Path 对象来引用文件:

    from bs4 import BeautifulSoup 
    
    from pathlib import Path 
    
    from typing import Any
    
  2. 定义一个函数,从给定的 Path 实例读取 HTML 文档:

    def race_extract(source_path: Path) -> dict[str, Any]:
    
  3. 从 HTML 内容创建 soup 结构。我们将将其分配给一个变量,soup。作为替代,我们也可以使用 Path.read_text()方法来读取内容:

        with source_path.open(encoding="utf8") as source_file: 
    
            soup = BeautifulSoup(source_file, "html.parser")
    
  4. 从 soup 对象中,我们需要导航到第一个

标签。在其内部,我们需要找到第一个和标签。通过使用标签名作为属性来导航到第一个实例的标签:

    thead_row = soup.table.thead.tr  # type: ignore [union-attr]

使用一个特殊的注释来抑制 mypy 警告。# type: ignore [union-attr]是必需的,因为每个标签属性的类型提示为 Tag | None。对于某些应用程序,可以使用额外的 if 语句来确认存在的标签的预期组合。

  • 我们必须从每一行的每个

  • 单元格中累积标题数据:

        legs: list[tuple[str, str | None]] = [] 
    
        for tag in thead_row.find_all("th"): # type: ignore [union-attr] 
    
            leg_description = ( 
    
                tag.string, tag.attrs.get("data") 
    
            ) 
    
            legs.append(leg_description)
    
  • 要找到表格的内容,我们导航到

    和标签:

        tbody = soup.table.tbody # type: ignore [union-attr]
    
  • 我们需要访问所有的

  • 标签。在每一行中,我们希望将所有有一个名为 td 的标签名称和一个名为 class 的属性。值通常是字符串,但在少数情况下,值可以是字符串列表。Tag 对象的字符串属性是标签内的内容;在这种情况下,它是一个非常短的字符串,1。

    HTML 是一个混合内容模型。在查看给定标签的子标签时,将有一个子标签和子 NavigableText 对象的序列,这些子标签和子 NavigableText 对象可以自由混合。

    BeautifulSoup 解析器类依赖于一个底层库来完成一些解析工作。使用内置的 html.parser 模块来做这个工作是最简单的。其他替代方案提供了一些优势,如更好的性能或更好地处理损坏的 HTML。

    11.8.4 更多...

    Beautiful Soup 的 Tag 对象代表文档结构的层次结构。在标签之间有几种导航方式。在这个配方中,我们依赖于 soup.html 与 soup.find("html")相同的方式。我们还可以通过属性值进行搜索,包括类和 id。这些通常提供了关于内容的意义信息。

    在某些情况下,一个文档将有一个精心设计的组织,通过 id 属性或 class 属性进行搜索将找到相关数据。以下是一个使用 HTML 类属性进行给定结构典型搜索的例子:

    >>> ranking_table = soup.find(’table’, class_="ranking-list")
    

    注意,我们必须在我们的 Python 查询中使用 class_ 来搜索名为 class 的属性。class 是一个 Python 的保留字,不能用作参数名称。考虑到整个文档,我们正在搜索任何

    标签的内容转换为团队名称和团队位置集合,这取决于 td 标签的属性:

        teams: list[dict[str, Any]] = [] 
    
        for row in tbody.find_all("tr"): # type: ignore [union-attr] 
    
            team: dict[str, Any] = { 
    
                "name": None, 
    
                "position": []} 
    
            for col in row.find_all("td"): 
    
                if "ranking-team" in col.attrs.get("class"): 
    
                    team["name"] = col.string 
    
                elif ( 
    
                        "ranking-number" in col.attrs.get("class") 
    
                    ): 
    
                    team["position"].append(col.string) 
    
                elif "data" in col.attrs: 
    
                    # Complicated explanation with nested HTML 
    
                    # print(col.attrs, col.string) 
    
                    pass 
    
            teams.append(team)
    
  • 一旦提取了腿和团队,我们就可以创建一个有用的字典,它将包含这两个集合:

        document = { 
    
            "legs": legs, 
    
            "teams": teams, 
    
        } 
    
        return document
    
  • 我们创建了一个腿的列表,显示了每个腿的顺序和名称,并解析了表格的主体以创建一个字典-列表结构,其中包含给定团队的每个腿的结果。生成的对象看起来像这样:

    >>> source_path = Path("data") / "Volvo Ocean Race.html" 
    
    >>> race_extract(source_path) 
    
    {’legs’: [(None, None), 
    
              (’LEG 1’, ’<strong>ALICANTE - CAPE TOWN’), 
    
              (’LEG 2’, ’<strong>CAPE TOWN - ABU DHABI</strong>’), 
    
              (’LEG 3’, ’<strong>ABU DHABI - SANYA</strong>’), 
    
              (’LEG 4’, ’<strong>SANYA - AUCKLAND</strong>’), 
    
              (’LEG 5’, ’<strong>AUCKLAND - ITAJA</strong>’), 
    
              (’LEG 6’, ’<strong>ITAJA - NEWPORT</strong>’), 
    
              (’LEG 7’, ’<strong>NEWPORT - LISBON</strong>’), 
    
              (’LEG 8’, ’<strong>LISBON - LORIENT</strong>’), 
    
              (’LEG 9’, ’<strong>LORIENT - GOTHENBURG</strong>’), 
    
              (’TOTAL’, None)], 
    
     ’teams’: [ 
    
        {’name’: ’Abu Dhabi Ocean Racing’, 
    
         ’position’: [’1’, ’3’, 
    
                  ’2’, ’2’, 
    
                  ’1’, ’2’,
    
     {’name’: ’Team Vestas Wind’, 
    
         ’position’: [’4’, 
    
                    None, 
    
                    None, 
    
                    None, 
    
                    None, 
    
                    None, 
    
                    None, 
    
                    ’2’, 
    
                    ’6’, 
    
                    ’60’]}]}
    

    在表格的主体中,许多单元格的最终比赛位置为 None,而特定

    标签的数据属性中有一个复杂值。解析此文本中嵌入的 HTML 遵循配方中显示的模式,使用另一个 BeautifulSoup 实例。

    11.8.3 它是如何工作的...

    BeautifulSoup 类将 HTML 文档转换为基于文档对象模型(DOM)的相当复杂对象。生成的结构将由 Tag、NavigableString 和 Comment 类的实例组成。

    每个 Tag 对象都有一个名称、字符串和属性。名称是<和>字符内的单词。属性是跟随标签名称的字段。例如,

    1
    标签。这将找到网页中的第一个此类标签。由于我们知道只有一个这样的标签,这种基于属性的搜索有助于区分我们试图找到的内容和网页上的任何其他表格数据。

    11.8.5 参见

    • Requests包可以极大地简化与复杂网站交互所需的代码。

    • 查看关于 robots.txt 文件和 RFC 9309 机器人排除协议 的信息,请访问 www.robotstxt.org 网站。

    • 阅读 JSON 和 YAML 文档 和 阅读 XML 文档 的配方,如本章前面所示,都使用了类似的数据。示例数据是通过使用这些技术从原始 HTML 页面抓取创建的。

    加入我们的社区 Discord 空间

    加入我们的 Python Discord 工作空间,讨论并了解更多关于本书的信息:packt.link/dHrHU

    图片

    第十二章:12

    使用 Jupyter Lab 进行图形和可视化

    通过数据可视化,许多问题都得到了简化。人眼特别适合识别关系和趋势。给定一个潜在关系(或趋势)的显示,转向更正式的统计方法来量化关系是有意义的。

    Python 提供了许多图形工具。对于数据分析目的,最受欢迎的是 matplotlib。这个包提供了许多图形功能。它与 Jupyter Lab 集成良好,为我们提供了一个交互式环境来可视化和分析数据。

    在 Jupyter Lab 中可以进行大量的 Python 开发。虽然很棒,但这并不是一个完美的集成开发环境(IDE)。一个小的缺点是交互式笔记本依赖于全局变量,这对于编写模块或应用程序来说并不理想。当将笔记本转换为可重用的模块时,使用全局变量可能会导致混淆。

    除了运行 Python 代码和显示图形外,Jupyter Lab 笔记本还可以渲染 Markdown 语法中的单元格。这使得我们能够围绕数据的图形分析编写非常漂亮的文档。这包括正确渲染数学公式的功能。它允许我们在相关代码附近包含类似 e^(πi) + 1 = 0 的数学。

    我们将使用的两个包不是 Python 标准发行版的一部分。我们需要安装它们。在某些情况下,使用 Conda 等工具可以帮助安装这些大型且复杂的包。然而,在许多情况下,如果我们的计算机是广泛支持的类型之一,那么 PIP 安装将有效。

    python -m pip install matplotlib jupyterlab
    

    使用 python -m pip 命令可以确保我们将使用与当前活动虚拟环境兼容的 pip 命令。

    这将以类似以下的一行结束:

    Installing collected packages: pyparsing, pillow, numpy, kiwisolver, fonttools, cycler, contourpy, matplotlib
    

    这一行后面将跟一个列表,列出添加到当前虚拟环境中的新包。

    注意,Jupyter Lab 将会使用 IPython 的 Python 实现。这个实现包括一些非常有用的附加功能,这些功能对于管理浏览器和 Jupyter Lab 服务器之间的复杂客户端-服务器连接非常有帮助。

    与标准的 Python 实现相比,最明显的区别是一个独特的提示符。IPython 使用 In [n]: 作为提示符。数字 n 在会话期间会增加。可以使用提示符的编号来回忆特定的先前命令和输出。

    在本章中,我们将探讨以下食谱:

    • 启动笔记本并使用 Python 代码创建单元格

    • 将数据导入笔记本

    • 使用 pyplot 创建散点图

    • 直接使用坐标轴创建散点图

    • 向 markdown 单元格添加细节

    • 在笔记本中包含单元测试用例

    12.1 使用 Python 代码启动笔记本并创建单元格

    我们将使用终端窗口输入一个命令来启动实验室服务器。jupyter lab 命令将做两件事:

    • 它将启动 Jupyter Lab 的后端,数值计算服务器组件。

    • 它还将尝试启动一个连接到该服务器组件的浏览器窗口。

    我们与实验室中各种笔记本的其余交互将通过浏览器进行。

    在极少数浏览器未启动的情况下,日志将提供可以在您选择的浏览器中使用的链接。IPython 运行时会使用各种已安装的包。

    对于这个第一个菜谱,我们将关注启动和停止 Jupyter Lab 服务器以及创建笔记本的管理方面。

    12.1.1 准备工作

    我们将启动一个 Jupyter Lab 会话并创建一个笔记本,以确保环境正常工作并且所有必需的组件都已安装。

    使用终端窗口启动 Jupyter Lab 有时可能会令人困惑。许多程序员习惯于在集成开发环境(IDE)中创建和测试代码。启动 Jupyter Lab 通常是在终端窗口中完成的,而不是在 Python 编辑器或交互式 Python REPL 会话中。

    12.1.2 如何操作...

    1. 打开一个终端窗口。切换到一个可以访问数据和笔记本文件夹的工作目录。确保正确的虚拟环境已激活。Jupyter Lab 服务器限制在启动它的目录中工作。输入以下命令以启动 Jupyter Lab:

      (coobook3) % python -m jupyter lab
      

      这将输出服务器执行的操作日志。在行块中将有连接到服务器的安全 URL。通常,也会打开一个浏览器窗口。

    2. 在浏览器窗口中,Jupyter Lab 窗口将显示启动器选项卡。它看起来像图 12.1 中显示的页面。

      PIC

      图 12.1:Jupyter Lab 启动器

    3. 在启动器中的笔记本部分单击 Python 3 (ipykernel) 图标。这将打开一个名为 Untitled.ipynb 的 Jupyter Notebook。

      PIC

      图 12.2:准备工作的 Jupyter 笔记本

      此选项卡顶部有一个笔记本级别的菜单栏,其中包含一系列图标,用于保存笔记本、添加单元格、剪切单元格、复制单元格和粘贴单元格等。

      笔记本菜单栏上的 ▸ 图标将执行单元格的 Python 代码或格式化单元格的 Markup 内容。这也可以经常通过 Shift+Enter 键盘组合来完成。

      剩余的图标将停止运行的内核并重新启动它。▸▸ 图标将重新启动笔记本,运行所有单元格。下拉菜单允许您在代码、markdown 和原始单元格之间进行选择。我们将大部分时间用于创建代码和 markdown 单元格。

      初始内容有一个标签 [ ],以及一个文本框,我们可以在此输入代码。随着我们添加更多命令,这个区域将被代码单元格及其输出填充。

    4. 为了确保我们拥有所有必需的包,请将以下代码输入到第一个单元格中:

      from matplotlib import pyplot as plt 
      
      from pydantic import BaseModel
      

      使用 Shift+Enter 键盘组合或点击笔记本菜单栏上的▸图标来执行此单元格中的代码。如果它工作正常,这确认了当前虚拟环境中已安装我们所需的一切。

    12.1.3 它是如何工作的...

    Jupyter Lab 的许多功能之一是创建和编辑笔记本。我们可以直接输入代码并执行代码,将结果保存供他人审阅。这允许在获取、分析和共享数据的方式上具有很大的灵活性。

    由于我们还可以打开 Python 控制台并编辑 Python 模块,我们可以使用 Jupyter Lab 作为 IDE 进行大量的开发工作。可以将笔记本导出为脚本文件。这允许将一系列代表许多好想法的单元格转换为模块或应用程序。

    面对新的问题或新的数据时,通常鼓励使用笔记本作为记录实验的方式。显示失败的单元格反映了学到的经验教训,值得保留。当然,显示成功的单元格有助于引导同事通过学习过程。

    Jupyter Lab 环境设计为可以在各种容器配置中使用。两种常见的容器架构是:

    • 一个大型分析主机,与分析师的笔记本电脑分开。

    • 一台笔记本电脑既作为 Jupyter Lab 服务器进程的主机,也作为浏览器会话的主机。这是为这个配方创建的环境。

    目的是能够扩展并处理一个非常大的数据集,在一个非常昂贵、非常大的主机上。这个数值计算主机运行 Jupyter Lab 和笔记本的内核。我们的笔记本电脑只运行浏览器和终端窗口。

    12.1.4 更多内容...

    工作笔记本需要保存,否则当前状态将丢失。尽早保存并经常保存,以确保不会丢失任何宝贵的结果。有两种方法可以在使用完毕后停止 Jupyter Lab 服务器。

    • 从 Jupyter Lab 浏览器窗口。

    • 从终端窗口。

    要从浏览器窗口停止处理,使用文件菜单。在菜单底部是关闭菜单项。这将停止服务器并断开浏览器会话。

    要从终端窗口停止处理,使用 Control+C (Ĉ) 两次来停止处理。输入 Ĉ 一次将得到提示“关闭此 Jupyter 服务器 (y/[n])?”。需要第二次 Ĉ(或选择 y)来停止进程。

    12.1.5 相关内容

    • 在 将数据导入笔记本 部分中,我们将超越基础知识,并加载包含数据的笔记本。

    • 参考关于 Jupyter Lab 的深入书籍 Learning Jupyter

    12.2 将数据导入笔记本

    作为样本数据分析问题,我们将查看包含四个紧密相关的样本系列的数据集合。文件名为 anscombe.json。每个数据系列是一个(x,y)数据对的序列和系列名称,表示为 Python 字典。系列键是系列的名称。数据键是数据对的列表。这四个系列有时被称为安斯康姆四重奏。

    我们将创建一个笔记本来摄取数据。为了开始工作,这个初始配方将专注于普通的 Python 表达式,以确认数据已正确加载。在后面的配方中,我们将使用可视化和统计方法来查看两个变量之间是否存在相关性。

    12.2.1 准备工作

    有几个初步步骤:

    1. 确保 Jupyter Lab 服务器正在运行。如果它没有运行,请参阅使用 Python 代码启动笔记本并创建单元格配方以启动服务器并打开笔记本。

    2. 定位此服务器的浏览器窗口。在启动服务器时,通常会显示一个连接到服务器的浏览器窗口。

    3. 为这个配方启动一个新的笔记本。

    如果我们关闭浏览器应用程序,或者如果 Python 无法启动我们首选的浏览器,那么 Jupyter Lab 服务器将运行,但没有任何明显的浏览器窗口可以连接到服务器。我们可以通过使用以下 jupyter 命令进行查询来定位服务器:

    (cookbook3) % python -m jupyter server list
    

    这将识别所有正在运行的服务器。它将提供一个连接到服务器的 URL。输出可能看起来像以下这样:

    (cookbook3) % jupyter server list 
    
    Currently running servers: 
    
    http://localhost:8888/?token=85e8ad8455cd154bd3253ba0339c783ea60c56f836f7b81c :: /Users/slott/Documents/Writing/Python/Python Cookbook 3e
    

    在您计算机上的输出中显示的实际 URL 可以用来连接到服务器。

    12.2.2 如何操作...

    1. 由于我们将以 JSON 格式读取文件,第一个单元格可以是一个包含所需导入语句的代码单元格。

      import json 
      
      from pathlib import Path
      

      在单元格中使用 Enter(或 Return)键可以添加代码行。

      使用 Shift+Enter(或 Shift+Return)将执行单元格的代码并打开一个新的单元格以输入更多代码。点击菜单栏上的▸图标执行代码,然后点击菜单栏上的+图标添加一个新的空单元格也会发生这种情况。

      执行一个单元格后,单元格标签的数字将被填写;第一个单元格显示[1],表示单元格已被执行。

    2. 将整个“读取和提取数据”过程放入一个单元格中是有意义的:

      source_path = Path.cwd().parent.parent / "data" / "anscombe.json" 
      
      with source_path.open() as source_file: 
      
          all_data = json.load(source_file) 
      
      [data[’series’] for data in all_data]
      

      从当前工作目录到数据的路径假设笔记本位于 src/ch12 文件夹中,src 文件夹是 data 文件夹的同级文件夹。如果您的项目结构不是这样,那么 source_path 的计算需要更改。

      当我们执行这个单元格时,我们将看到这个集合中四个数据系列的名称。它们看起来像这样:

      PIC

      图 12.3:包含两个代码单元格的 Jupyter 笔记本

    3. 我们可以查看这个源中的(x,y)对。一个包含表达式的单元格足以显示表达式的值:

      all_data[0][’data’]
      
    4. 我们可以定义一个类来保存这些数据对。由于数据是 JSON 格式,并且 pydantic 包提供了非常好的 JSON 解析功能,我们可以考虑扩展 BaseModel 类。

      这需要重写单元格 1,以扩展导入序列以包括:

      import json 
      
      from pathlib import Path 
      
      from pydantic import BaseModel
      

      我们可以点击▸▸图标来重启笔记本,运行所有单元格。当我们回到顶部并做出更改时,这是必不可少的。

      在页面顶部,笔记本启动标签上方,有一个更高层次的菜单栏,包括文件、编辑、查看、运行、内核、标签页、设置和帮助等选项。内核菜单中的“重启内核”和“运行所有单元格...”项与笔记本菜单栏中的▸▸图标具有相同的功能。

    5. 定义一个用于 X-Y 对的类。然后,定义一个用于单个对序列的类:

      class Pair(BaseModel): 
      
          x: float 
      
          y: float 
      
      class Series(BaseModel): 
      
          series: str 
      
          data: list[Pair]
      
    6. 然后,我们可以从 all_data 对象中填充类实例:

      clean_data = [Series.model_validate(d) for d in all_data]
      
    7. 使用 clean_data[0] 这样的表达式来查看特定的序列。

    8. 我们有一个尴尬的问题,即每个序列在 clean_data 序列中都有一个位置和一个名称。使用映射比使用序列更好:

      quartet = {s.series: s for s in clean_data}
      
    9. 使用 quartet[’I’] 这样的表达式来查看特定的序列。

    12.2.3 它是如何工作的...

    每个单元格中的 Python 代码与第十一章中 Reading JSON and YAML documents 菜单中使用的代码没有显著差异。我们使用了 Jupyter Lab 来启动一个 IPython 内核,以评估每个单元格中的代码。

    当单元格的代码以表达式结束,Jupyter Notebook 将显示任何非 None 输出。这类似于命令行 REPL。在单元格中显示最终表达式的结果不需要 print() 函数。

    Jupyter 笔记本界面是一种独特的访问 Python 的方式,提供了一个丰富的交互式环境。在巧妙的编辑和显示功能之下,语言和库仍然是 Python。

    12.2.4 更多...

    在审查笔记本后,很明显我们可以优化一些处理过程。创建 all_data 和 clean_data 对象并没有真正的益处。真正的目标是与 quartet 对象一起工作。

    我们可以使用以下单元格来解析和加载序列:

    source_path = Path.cwd().parent.parent / "data" / "anscombe.json" 
    
    with source_path.open() as source_file: 
    
        json_document = json.load(source_file) 
    
        source_data = (Series.model_validate(s) for s in json_document) 
    
        quartet = {s.series: s for s in source_data}
    

    在 Pydantic 类定义之后插入一个新单元格并使用此代码很有帮助。然后我们可以执行代码。可以使用 quartet[’IV’] 这样的表达式来确认数据已被加载。

    一个更完整的检查将是以下单元格:

    for name in quartet: 
    
        print(f"{name:3s} {len(quartet[name].data):d}")
    

    这显示了每个序列名称和数据序列中的点数。

    一旦这个工作正常,我们可以删除之前的单元格,添加 Markdown,并重新运行笔记本以确保它正常且整洁地显示要分析的数据。

    12.2.5 参见

    • 参见第十一章的 Reading JSON and YAML documents 菜单,了解更多关于 JSON 格式文件的信息。

    • 第十章的使用 Pydantic 实现更严格的类型检查配方涵盖了 Pydantic 包的一些功能。

    12.3 使用 pyplot 创建散点图

    matplotlib 项目可以产生各种图表和绘图类型,它极其复杂,这使得它在某些类型的分析中难以使用。

    在一个名为 pyplot 的子包中收集了特别有用的功能集。这个功能组反映了在 Jupyter Lab 中工作时的一些常见假设和优化,这些假设和优化工作得非常好。在其他上下文中,这些假设通常是限制性的。

    为了使事情更简单,pyplot 包将自动管理创建的图形。它将跟踪填充图片的任何子图。它将跟踪构成这些子图的各个轴和艺术家。

    更多信息,请参阅 matplotlib 教程中的图形的各个部分。此图确定了图形的各个元素以及用于创建或控制这些元素的 matplotlib 的各个部分。

    在将数据导入笔记本配方中,我们查看了一个包含四个数据序列的数据集。包含此数据部分的文件名为 anscombe.json。每个数据序列是一系列 (x,y) 数据对,每个序列都有一个名称。

    通用方法将是定义一些有用的类,这些类提供了对序列及其内部样本的有用定义。有了这些定义,我们可以读取 anscombe.json 文件以获取数据。一旦加载,我们就可以创建一个显示数据对的图形。

    12.3.1 准备工作

    有几个初步步骤:

    1. 启动 Jupyter Lab 服务器并定位其浏览器窗口。如果它没有运行,请参阅使用 Python 代码启动笔记本并创建单元格配方以启动服务器。有关定位在后台运行的服务器的建议,请参阅将数据导入笔记本。

    2. 为此配方创建一个新的笔记本。

    12.3.2 如何操作...

    1. 使用描述笔记本将包含内容的单元格开始笔记本。这应该是一个 Markdown 单元格,用于记录一些笔记:

      # Anscombe’s Quartet 
      
      The raw data has four series. The correlation coefficients are high. 
      
      Visualization shows that a simple linear regression model is misleading. 
      
      ## Raw Data for the Series
      
    2. 创建所需的导入,以便使用 Pydantic 加载 JSON 格式的数据。这将是一个代码单元格:

      import json 
      
      from pathlib import Path 
      
      from pydantic import BaseModel
      
    3. 定义两个类,分别定义数据序列。一个类包含单个 (x,y) 对。另一个是包含这些对及其序列名称的序列:

      class Pair(BaseModel): 
      
          x: float 
      
          y: float 
      
      class Series(BaseModel): 
      
          series: str 
      
          data: list[Pair]
      
    4. 编写一个包含读取数据所需代码的单元格,并创建一个包含清洗后数据的全局变量:

      source = Path.cwd().parent.parent / "data" / "anscombe.json" 
      
      with source.open() as source_file: 
      
          json_document = json.load(source_file) 
      
          source_data = (Series.model_validate(s) for s in json_document) 
      
          quartet = {s.series: s for s in source_data}
      

      clean_data 的值包含四个单独的 Series 对象列表。像 quartet['I'] 这样的表达式将揭示其中一个序列。

    5. 添加一个 Markdown 单元格,显示本笔记本下一部分将包含的内容:

      ## Visualization of each series
      
    6. 编写对 pyplot 包所需的导入。这通常被重命名为 plt 以简化 Jupyter 笔记本单元格中编写的代码:

      from matplotlib import pyplot as plt
      
    7. 对于单个系列,我们需要提取两个平行的数字序列。我们将使用 list comprehensions 在 clean_data[0].data 上提取一个序列的 x 值和第二个平行序列的 y 值:

      x = [p.x for p in quartet[’I’].data] 
      
      y = [p.y for p in quartet[’I’].data] 
      
    8. scatter()函数创建基本的散点图。我们提供两个平行序列:一个包含 x 值,另一个包含 y 值。title()函数将在图形上方放置一个标签。我们从一个系列名称构造了一个字符串。虽然不总是必要的,但有时需要 plt.show()来显示生成的图形:

      plt.scatter(x, y) 
      
      plt.title(f"Series {quartet[’I’].series}") 
      
      plt.show()
      

    生成的笔记本将包括如下单元格:

    PIC

    图 12.4:包含 Series I 图形的 Jupyter 笔记本

    12.3.3 它是如何工作的...

    基于 matplotlib 的底层包有一系列组件来支持图形和数据可视化。一个基础是后端组件的概念,以集成 Python 使用的广泛上下文、框架和平台。这还包括像 Jupyter Lab 这样的交互式环境。它还包括可以生成各种图像文件格式的静态、非交互式后端。

    matplotlib 的另一个基础元素是 API,它允许我们创建一个包含 Artist 对象的 Figure 对象。Artist 对象的集合将绘制我们希望在图形中看到的标题、坐标轴、数据点和线条。Artist 对象可以是交互式的,允许它们在数据变化或显示形状变化时刷新图形。

    在这种分析笔记本中,我们通常对一次性绘制的静态图形更感兴趣。我们的目标是保存笔记本供其他人查看和理解数据。最终目标是传达数据中的关系或趋势。

    pyplot 包包含对 matplotlib 整体 API 的简化。这些简化使我们免于跟踪用于创建图形显示在图形中的各种坐标轴实例的繁琐细节。

    12.3.4 更多内容...

    我们经常希望将几个紧密相关的图形作为单个图形的一部分来查看。在这种情况下,如果我们有一个文件中的四个数据系列,将所有四个图形放在一起似乎特别有帮助。

    这是通过 plt.figure()创建一个整体图形来完成的。在这个图形内部,每个 plt.subplot()函数可以创建一个独立的子图。图形的布局作为每个子图请求的一部分提供,作为三个数字:图形内垂直排列的图形数量、图形内水平排列的图形数量以及此特定图形在布局中的位置。

    我们可以使用 2, 2, n 来表示图形具有 2 × 2 的排列,并且这个特定的子图位置为 n。位置是从图形的左上角到右下角进行计算的。位置也可以跨越图形的多个部分,这样我们就可以有一个大图和多个小图。

    为了更容易提取每个图表的 x 和 y 属性,我们将修改 Series 类的定义。我们将添加两个属性,x 和 y,它们将提取所有序列值。这样重新定义 Series 类如下:

    class Series(BaseModel): 
    
        series: str 
    
        data: list[Pair] 
    
        @property 
    
        def x(self) -> list[float]: 
    
            return [p.x for p in self.data] 
    
        @property 
    
        def y(self) -> list[float]: 
    
            return [p.y for p in self.data]
    

    添加这些属性允许在 plt.scatter()函数中做一些轻微的简化。整个图形可以通过以下代码的单元创建:

    plt.figure(layout=’tight’) 
    
    for n, series in enumerate(quartet.values(), start=1): 
    
        title = f"Series {series.series}" 
    
        plt.subplot(2, 2, n) 
    
        plt.scatter(series.x, series.y) 
    
        plt.title(title)
    

    在更改新的 Series 类定义后,笔记本的单元必须从头开始重新运行。在运行菜单中,重启内核并运行所有单元的项将包含修订后的类并重新加载数据。

    对于图形的整体定义、散点图以及散点图的坐标轴,有许多选项和参数。此外,还有许多其他类型的图表可供选择。Matplotlib Examples Gallery展示了多种图表的示例。例如,在有一个 Counter 对象的情况下,我们可以使用 x 值的序列和高度值的序列,通过使用 bar()函数而不是 scatter()函数来创建条形图。查看第五章中的 Creating dictionaries – inserting and updating 配方,了解创建 Counter 对象以总结源数据频率的示例。

    12.3.5 参考信息

    • 查看第十一章中的 Reading JSON and YAML documents 配方,了解更多关于 JSON 格式文件的信息。

    • 第十章中的 Implementing more strict type checks with Pydantic 配方涵盖了使用 Pydantic 包的一些功能。

    • 本章前面的 Ingesting data into a notebook 配方深入探讨了 JSON 的加载。

    • 查看第Matplotlib Examples Gallery以了解多种图表的示例。

    12.4 直接使用坐标轴创建散点图

    许多常规的图形可视化可以使用 pyplot 模块中直接可用的函数来完成。在先前的配方中,我们使用了 scatter()函数来绘制显示两个变量之间关系的散点图。其他如 bar()、pie()和 hist()等函数将根据我们的原始数据创建其他类型的图表。然而,有时 pyplot 模块上现成的函数并不完全适用,我们希望在图像中做更多的事情。

    在这个配方中,我们将为每个子图添加一个图例框,以显示适合散点图数据的线性回归参数。

    在 将数据导入笔记本 菜单中,我们查看了一个包含四个数据序列的数据集。包含这些数据的文件被命名为 anscombe.json。每个数据序列是一个包含一系列 (x,y) 数据对的字典,以及序列的名称。

    通用方法将是定义一些类,这些类提供了对序列及其内部样本的有用定义。有了这些定义,我们可以读取 anscombe.json 文件以获取数据。一旦加载,我们就可以创建一个显示数据对的图表。

    Python 的统计模块提供了两个方便的函数,correlation() 和 regression(),帮助我们用一些参数注释每个图表。

    12.4.1 准备工作

    有几个初步步骤:

    1. 确保 Jupyter Lab 服务器正在运行,并找到其浏览器窗口。使用 Python 代码启动笔记本和创建单元格 菜单展示了如何启动服务器。将数据导入笔记本 提供了一些关于定位后台运行的服务器的额外建议。

    2. 为此菜谱启动一个新的笔记本。

    12.4.2 如何操作...

    1. 使用一个描述笔记本内容的单元格来启动笔记本。这应该是一个包含 Markdown 内容而不是代码内容的单元格:

      # Anscombe’s Quartet 
      
      Visualization with correlation coefficients and linear regression model. 
      
      ## Raw Data for the Series
      
    2. 创建所需的导入,以便使用 Pydantic 加载 JSON 格式的数据。这将是一个代码单元格:

      import json 
      
      from pathlib import Path 
      
      import statistics 
      
      from pydantic import BaseModel
      
    3. 定义两个类,分别定义每个数据序列。一个类包含单个 (x,y) 对:

      class Pair(BaseModel): 
      
          x: float 
      
          y: float
      

      第二个类是包含序列名称的成对数据,这包括两个方法来计算有用的统计摘要,如相关性和回归:

       return [p.y for p in self.data] 
      
          @property 
      
          def correlation(self) -> float: 
      
              return statistics.correlation(self.x, self.y) 
      
    4. 创建一个单元格来导入数据,创建一个将序列名称映射到相关 Series 实例的字典:

      source = Path.cwd().parent.parent / "data" / "anscombe.json" 
      
      with source.open() as source_file: 
      
          json_document = json.load(source_file) 
      
          source_data = (Series.model_validate(s) for s in json_document) 
      
          quartet = {s.series: s for s in source_data}
      
    5. 确认之前的单元格都正常工作。创建一个单元格来评估一个表达式,例如 quartet[‘I’].correlation。四舍五入后,结果将是 0.816。有趣的是,所有四个序列的结果几乎相同。

    6. 使用 figure() 函数创建一个图表的单元格,提供一个值为 ‘tight’ 的布局值可以产生一个看起来很好的图表。将这个值赋给一个变量是必要的,这样对象就可以在 plt.show() 函数显示之前保持存在:

      fig = plt.figure(layout=’tight’)
      

      在单元格中添加一行来创建一个用于显示整体图表中四个子图的轴集合。subplot_mosaic() 函数提供了大量的复杂布局功能。列表的列表结构将创建一个正方形网格。轴将被分配到具有四个不同键的字典 ax_dict 中。我们选择了与序列名称匹配的键,并使用列表的列表将它们定位在结果图表的行和列中:

      ax_dict = fig.subplot_mosaic( 
      
          [ 
      
              ["I", "II"], 
      
              ["III", "IV"], 
      
          ], 
      
      )
      
    7. 添加散点图和文字说明。我们还可以构建一个包含相关系数 r 的字符串。这个字符串可以放置在图表的右下角,使用相对位置(.95,.05)指定;这将通过 ax.transAxes 转换器转换为基于坐标轴大小的坐标。

      for name, ax in ax_dict.items(): 
      
          series = quartet[name] 
      
          ax.scatter(series.x, series.y) 
      
          ax.set_title(f"Series {name}") 
      
          eq1 = rf"$r = {series.correlation:.3f}$" 
      
          ax.text(.95, .05, f"{eq1}", 
      
                  fontfamily=’sans-serif’, 
      
                  horizontalalignment=’right’, verticalalignment=’bottom’, transform=ax.transAxes) 
      
      plt.show()
      

      在字符串周围使用\(,rf"\)r = {...}$",迫使 matplotlib 将 TeX 格式化规则应用于文本,创建一个正确格式化的数学方程。

    8. 在创建显式图表时,需要调用 show()函数的最终调用来显示图像:

      plt.show()
      

    12.4.3 它是如何工作的...

    Matplotlib 中的图形技术堆栈包括大量的 Artist 子类。每个子类都将创建最终图像的一部分。在这种情况下,我们使用了 subplot_mosaic()函数来创建四个子图对象,每个对象都有一组坐标轴。

    我们使用了坐标轴对象来显示数据,指定使用散点图组织。图表的标题和包含相关系数的文字块也在图表中绘制了细节。

    在某个时刻,显示可能会因为细节而变得杂乱。一个好的数据展示需要有一个信息点。关于如何向观众展示数据(好的和不好的方式)有许多有趣的书籍和文章。可以考虑 Packt Publishing 出版的《Python 开发者 Matplotlib 指南》作为学习更多关于数据可视化的途径。

    12.4.4 更多...

    相关系数表明,在序列中的 x 和 y 变量之间存在关系。我们可以使用线性回归来计算线性模型的参数,该模型在给定 x 值时预测 y 值。

    linear_regression()函数是标准库中的统计模块的一部分。该函数的结果是一个包含斜率和截距值的元组,这些值描述了一个线性关系,y = mx + b,其中 m 是斜率,b 是截距。

    我们可以更新这个笔记本的单元格来添加线性回归计算。这里有几个变化:

    1. 将 Series 类更改为添加执行线性回归计算的其他属性:

       @property 
      
          def regression(self) -> tuple[float, float]: 
      
              return statistics.linear_regression(self.x, self.y)
      
    2. 添加一个单元格来确认回归是否有效。该单元格可以显示表达式 quartet['I'].regression。结果将具有几乎为 0.5 的斜率和几乎为 3.0 的截距。有趣的是,这几乎适用于所有四个序列。

    3. 将子图标签更改为包括回归参数:

       lr = series.regression 
      
          eq1 = rf"$r = {series.correlation:.3f}$" 
      
          eq2 = rf"$Y = {lr.slope:.1f} \times X + {lr.intercept:.2f}$" 
      
          ax.text(.95, .05, f"{eq1}\n{eq2}", 
      
                  fontfamily=’sans-serif’, 
      
                  horizontalalignment=’right’, verticalalignment=’bottom’, transform=ax.transAxes) 
      
          ax.axline((0, lr.intercept), slope=lr.slope)
      

    在这些更改之后,重新启动内核并运行所有单元格将显示每个子图都显示了相关系数和从给定的 x 值预测 y 值的线的方程。

    axline()函数可以用来向每个子图添加回归线。我们提供了一个已知点,即(0,b)截距和斜率 m。该线自动约束在轴的范围之内。这可能会稍微增加视觉杂乱,或者可能有助于理解变量之间的关系:

        ax.axline((0, lr.intercept), slope=lr.slope)
    

    12.4.5 参考内容

    12.5 向 Markdown 单元格添加细节

    数据分析的目的在于提供对数值指标的深入洞察,以展示趋势和关系。一般来说,目标是帮助某人做出基于事实的决定。这个决定可能很简单,比如根据预期的行驶距离和充电时间来决定在旅行前给车辆充电。或者,它可能非常深刻,比如对医疗诊断做出有效的治疗方案。

    可视化是向观众展示数据以帮助他们理解的一个方面。与可视化相邻的是将展示的材料组织成一个连贯的故事。此外,我们还需要提供超出图表和图像的补充细节。请参阅 Packt 出版社的《经理的演示指南》,了解更多关于这个主题的信息。

    12.5.1 准备工作

    我们将更新一个包含 Markdown 格式的单元格的笔记本。我们可以从本章前面的配方中创建的一个笔记本开始。另一种选择是创建一个新的、空的笔记本,其中包含格式化文本。

    笔记本可以直接从 Jupyter Lab 导出为 PDF 文件。这是最快、最简单的发布途径。我们可能希望隐藏一些代码单元格,以避免这类出版物。

    为了获得更精致的结果,使用单独的格式化工具很有帮助。笔记本可以导出为 Markdown 文件(或重构文本或 LaTeX)。然后,像 Pandoc、Docutils 或 TeX 工具集这样的适当程序可以从导出的笔记本中创建文档。

    Quarto 和 Jupyter {Book}等工具也可以用来创建精致的结果。

    然而,基本要素是合理的组织、清晰的写作和笔记本单元格中的 Markdown 格式化。使用 Markdown 的一个有趣之处在于,单元格的内容基本上是静态的。笔记本的语法不会将计算值注入 Markdown 单元格。

    创建动态内容有两种方式:

    • 安装Python Markdown扩展。请参阅 Jupyter Lab 文档中的扩展。安装此扩展后,可以通过在周围添加{{和}}来在 Markdown 单元格中包含代码。

    • 在代码单元格中构建 Markdown 内容,然后将结果渲染为 Markdown。我们将在下一部分更深入地探讨这一点。

    12.5.2 如何实现...

    1. 从 IPython.display 模块导入所需的函数和类:

      from IPython.display import display, Markdown
      
    2. 创建一个用于渲染的 Markdown 对象:

      m = Markdown(rf""" 
      
      We can see that $r = {quartet[’I’].correlation:.2f}$; this is a strong correlation. 
      
      This leads to a linear regression result with $y = {r.slope:.1f} \times x + {r.intercept:.1f}$ as the best fit 
      
      for this collection of samples. 
      
      Interestingly, this is true for all four series in spite of the dramatically distinct scatter plots. 
      
      """)
      

      三引号字符串有两个前缀字符,r 和 f。这是一个“原始”格式化字符串。格式化字符串对于将 Python 对象注入文本至关重要。请参阅第一章中的使用 f-strings 构建复杂的字符串。

      由于 LaTeX 数学格式化需要广泛使用\字符,因此需要一个原始字符串。在这种情况下,我们明确不希望 Python 将\视为转义字符;我们需要确保这些字符保持原样,不受影响,并且以不变的方式提供给 Markdown 引擎。

      使用原始字符串意味着很难包含换行符。因此,最好使用可以跨越多行的三引号字符串。

    3. 使用 display()函数将单元格结果渲染为 Markdown 而不是未格式化的文本:

      display(m)
      

      这将在 Markdown 中创建包含计算结果的输出。

    12.5.3 工作原理...

    给定一个计算结果值的代码单元格,笔记本使用对象的 repl()方法来显示对象。对象可以定义额外的由 IPython 用于以不同方式格式化对象的函数。在这种情况下,使用 Markdown 类创建的对象被渲染为格式良好的文本。

    IPython.display 包包含许多有用的函数。display()函数允许 Python 代码单元格与基于浏览器的笔记本渲染进行交互。

    文本块和 Markdown 对象的创建是笔记本代码运行的后端计算核心的一部分。从这里,渲染的文本被发送到浏览器。这些文本也可以发送到其他外部工具,用于发布笔记本,从而为我们提供格式良好的单元格,这些单元格的内容是由笔记本计算得出的。

    12.5.4 更多...

    当我们转向共享笔记本时,我们通常有两个不同的场所:

    • 演示文稿,其中笔记本包含支持演讲者向利益相关者陈述的要点。

    • 发布,其中笔记本或从笔记本生成的文档被分发给利益相关者。

    在某些情况下,我们需要创建一个幻灯片和一份报告。这要求我们注意确保所有笔记本变体中的计算结果一致。一种方法是有两个最终报告笔记本,围绕导入具有数据获取和计算功能的核心笔记本构建。

    `%run 魔法命令可以被放入一个单元格中,以运行一个笔记本并收集结果变量。这也会显示来自 print() 的输出以及创建的任何图表。因为输出是单独显示的,所以核心笔记本应该专注于获取和计算结果,而不需要任何显示功能。

    对于演示文稿,使用 Jupyter Lab 页面右侧的属性检查器。这让我们可以为演示设置单元格的幻灯片类型。

    我们可以使用要点、可视化以及所有必要的支持信息创建 Markdown 内容。一旦我们有了内容,我们可以使用属性检查器标记单元格。最后,我们需要将笔记本保存为演示文稿。在文件菜单中,"保存并另存为笔记本..." 菜单项会显示一系列选项。Reveal.js Slides 将创建一个带有幻灯片演示的 HTML 文件。

    导出的 HTML 文档可以在浏览器中打开,以提供演示的支持视觉元素。它可以被发送给只想获取演示材料的人。

    要创建一个最终的文档(通常是 PDF 格式),我们有多种选择:

    • 导出为 AsciiDoc、Markdown 或 Restructured Text 格式。从这些格式中,可以使用 Pandoc、Docutils 或 Sphinx 等工具创建最终的输出文件。

    • 导出为 LaTeX。从这种格式,需要使用 TeXtools 来创建最终的 PDF。这些工具可能相当复杂,难以安装和维护,但结果非常出色。

    • 导出为 PDF。可能有 webpdf 选项,它使用 Playwright 和 Chromium 库来渲染 PDF。也可能有 Qtpdf 选项,它使用 Qt 库创建 PDF。

    Quarto 和 Jupyter {Book} 等工具也可以用来创建精美的输出。它们包括自己的发布工具,可以从笔记本中的 Markdown 创建最终的、出色的 PDF 文档。

    关于这个发布流程的一个重要注意事项是这条强制性指令:不要从笔记本中复制和粘贴。

    将结果从笔记本复制到文字处理文档中是一种引入错误和遗漏的方式。

    直接从笔记本发布可以消除由于存在两个(可能冲突的)计算结果副本而可能产生的错误。

    12.5.5 参见

    • 有关使用 ReStructured Text 记录代码的更多信息,请参阅第三章的 编写清晰的文档字符串。

    • 有关 Python 模块的文档,请参阅第二章的包含描述和文档。

    • 在本章中,有关需要发布的数据分析示例,请参阅使用 pyplot 创建散点图。

    12.6 在笔记本中包含单元测试用例

    没有测试套件,很难确定任何软件是可信的。在 Jupyter Notebook 中对代码进行单元测试可能会很尴尬。测试之所以困难,一个主要原因是笔记本通常用于处理大量数据。这意味着单个单元格中的计算可能需要非常长的时间才能完成。对于复杂的机器学习模型,这种耗时的处理是典型的。

    创建测试用例的一种方法是为单元测试创建一个“模板”笔记本。该模板可以被克隆,并将源路径值更改为读取真正感兴趣的大量数据。

    由于笔记本 .ipynb 文件是 JSON 格式,因此编写一个程序来确认用于生成预期结果的笔记本单元格与用于测试的模板笔记本(几乎)相同相对容易。预期特定文件名的单元格会发生变化;其余的预期保持完整。

    良好的笔记本设计将多语句单元格转换为函数(和类)定义。这意味着重要的结果是由具有测试用例的函数计算得出的。这些测试用例可以包含在函数的文档字符串中。我们将在第十五章中深入探讨 doctest。

    除了函数和类的 doctest 示例之外,我们还可以在单元格中使用 assert 语句来确认笔记本的单元格按预期工作。这个语句是 if-raise 语句对的简写。如果 assert 语句中的表达式不为真,则会引发 AssertException。这将停止笔记本,揭示问题。

    12.6.1 准备工作

    我们将从直接使用坐标轴创建散点图的笔记本开始,因为它有一个复杂的数据摄入单元格,可以将其转换为函数,以及一些可以补充 doctest 示例的类定义。

    12.6.2 如何做...

    1. 将数据摄入单元格重构为具有类似 ingest() 的名称的函数。参数应该是路径,返回值应该是包含四个 Anscombe 系列的字典。该单元格的原始副作用将在下面的单元格中创建。以下是函数定义:

      def ingest(source: Path) -> dict[str, Series]: 
      
          """ 
      
          >>> doctest example 
      
          """ 
      
          with source.open() as source_file: 
      
              json_document = json.load(source_file) 
      
              source_data = (Series.model_validate(s) for s in json_document) 
      
              quartet = {s.series: s for s in source_data} 
      
          return quartet
      

      我们还没有填写 doctest 示例;我们只留下了提醒文本。这些类型的示例是第十五章中食谱的主题。

    2. 添加一个单元格来摄入测试数据:

      source = Path.cwd().parent.parent / "data" / "anscombe.json" 
      
      quartet = ingest(source)
      
    3. 添加一些 assert 语句来显示四元对象预期的属性。这些语句将表达式和预期输出组合成单个语句:

       assert len(quartet) == 4, f"read {len(quartet)} series" 
      
      assert list(quartet.keys()) == ["I", "II", "III", "IV"], f"keys were {list(quartet.keys())}"
      

      通常,我们会用一个更正式的断言替换一个非正式的测试单元格。有一个包含类似 quartet.keys() 的表达式的单元格是很常见的。在开发笔记本时,我们会查看这个表达式的结果以确认数据导入是否成功。这个手动测试案例可以通过断言语句形式的自动化测试来升级。

    4. 请确保保存笔记本。我们假设它叫做 recipe_06.ipynb。

    5. 打开一个新的终端窗口并输入以下命令:

      (cookbook3) % jupyter execute src/ch12/recipe_06.ipynb
      

      笔记本应该完美执行。输出中有两条重要的信息:

      [NbClientApp] Executing src/ch12/recipe_06.ipynb 
      
      [NbClientApp] Executing notebook with kernel: python3
      

      这些行确认了正在使用的文件和内核。没有其他输出表明没有抛出异常。

    12.6.3 它是如何工作的...

    Jupyter 执行命令将启动内核并运行笔记本的单元格直到完成。这对于确认其工作情况非常有用。

    我们必须确保拒绝测试程序未能发现问题的假阴性。为了确保测试方法合理,我们可以在笔记本中注入一个失败的断言并观察预期的错误。

    添加一个如下所示的单元格:

    value = 355/113 
    
    assert value == 3.14, f"invald {value}"
    

    这将计算一个值,然后对该值做出一个明显错误的断言。当我们使用 jupyter 执行命令时,这将导致一个非常明显的失败。输出将如下所示:

    --------------------------------------------------------------------------- 
    
    AssertionError                            Traceback (most recent call last) 
    
    Cell In[4], line 2 
    
          1 value = 355/113 
    
    ----> 2 assert value == 3.14, f"invald {value}" 
    
    AssertionError: invald 3.1415929203539825
    

    操作系统状态码也将是非零的,表示未能正确执行。这提供了足够的证据,表明错误将产生嘈杂的、明确的失败。一旦我们确认这可以正常工作,我们就可以从笔记本中移除它,有信心其他测试真的会发现问题。

    12.6.4 更多...

    对于比较浮点值的特殊情况,我们不应该使用简单的 == 比较。如 Choosing between float, decimal, and fraction 中所述,浮点值是近似值,操作顺序的微小变化可能会影响最右边的数字。

    对于浮点值,math.isclose() 函数是必不可少的。回顾笔记本中的 Using axes directly to create a scatter plot。Series 类定义计算了相关性和线性回归值。我们可能创建一个如下所示的单元格来测试这一点:

    from math import isclose 
    
    test = Series( 
    
        series="test", 
    
        data=[Pair(x=2, y=4), Pair(x=3, y=6), Pair(x=5, y=10)] 
    
    ) 
    
    assert isclose(test.correlation, 1.0) 
    
    assert isclose(test.regression.slope, 2.0) 
    
    assert isclose(test.regression.intercept, 0.0)
    

    这个测试案例创建了一个样本 Series 对象。然后确认结果非常接近目标值。默认设置具有 10^(-9) 的相对容差值,这将包括九位数字。

    12.6.5 参考信息

    • 第十五章深入探讨了测试和单元测试。

    • 在第二章 Writing better docstrings with RST markup 中,也提到了 doctest 示例的想法。

    • 查看 Ingesting data into a notebook 以获取我们想要添加断言的种子笔记本。

    加入我们的 Discord 社区空间

    加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

    PIC

    第十三章:13

    应用程序集成:配置

    Python 的可扩展库概念为我们提供了对众多计算资源的丰富访问。该语言提供了使更多资源可用的途径。这使得 Python 程序特别擅长集成组件以创建复杂的复合处理。在本章中,我们将讨论创建复杂应用程序的基本原则:管理配置文件、日志记录以及允许自动化测试的脚本设计模式。

    这些新食谱借鉴了其他章节中食谱中的想法。具体来说,在第六章的使用 argparse 获取命令行输入、使用 cmd 创建命令行应用程序和使用 OS 环境设置的食谱中,展示了创建顶级(主)应用程序脚本的一些特定技术。回顾这些食谱可能有助于查看 Python 应用程序脚本的示例。在第十一章中,我们探讨了文件系统的输入和输出。

    在本章中,我们将探讨处理配置文件的各种方法。有许多文件格式可以用来存储长期配置信息:

    • configparser 模块处理的 INI 文件格式。

    • TOML 文件格式非常易于处理,但需要一个不是 Python 发行版中当前部分的附加模块。我们将在使用 TOML 作为配置文件的食谱中探讨这一点。

    • 属性文件格式是 Java 编程的典型格式,在 Python 中无需编写太多代码即可处理。一些语法与 Python 脚本和 TOML 文件重叠。从属性文件格式切换到 TOML 只需要将任何 name:value 更改为 name = "value",允许使用 TOML 解析器。

    • 对于 Python 脚本,具有赋值语句的文件看起来很像属性文件,并且可以使用 compile()和 exec()函数非常容易地处理。我们将在使用 Python 作为配置文件的食谱中探讨这一点。

    • 具有类定义的 Python 模块是一种使用 Python 语法但将设置隔离到单独类中的变体。这可以通过导入语句进行处理。我们将在使用类作为配置的命名空间的食谱中探讨这一点。

    本章中的一些食谱将扩展第七章和第八章中的一些概念。本章将应用这些概念来定义使用类定义配置文件。

    考虑配置文件中所需的信息类型非常重要。在配置文件中不小心包含密码或安全令牌可能会对数据的安全使用造成致命影响。在配置文件中包含个人信息也是一种常见的安全弱点。请参阅常见弱点枚举了解设计不良配置文件的其他更具体问题。

    在本章中,我们将探讨以下菜谱:

    • 查找配置文件

    • 使用 TOML 配置文件

    • 使用 Python 配置文件

    • 将类用作配置的命名空间

    • 设计用于组合的脚本

    • 使用日志进行控制和审计输出

    我们将从一个处理必须组合的多个配置文件的菜谱开始。这为用户提供了一些有用的灵活性。从那里,我们可以深入了解一些常见配置文件格式的具体细节。

    13.1 查找配置文件

    许多应用程序将具有配置选项的层次结构。这个层次结构的基础通常是应用程序中内置的默认值。这些可能由来自集中式配置文件的整个服务器(或集群)范围内的值补充。也可能有特定于用户的文件,或者甚至可能是启动程序时提供的配置文件。

    在许多情况下,配置参数是以文本文件的形式编写的,因此它们是持久的并且易于更改。在 Linux 中,常见的传统是将系统范围的配置放在/etc 目录中。用户的个人更改将放在他们的家目录中,通常命名为~username 或$HOME。

    在这个示例中,我们将看到应用程序如何支持配置文件的丰富层次结构。

    13.1.1 准备工作

    我们将使用的示例是一个模拟掷骰子的应用程序。该应用程序在第六章的几个菜谱中有所展示。6。具体来说,请查看使用 argparse 获取命令行输入和使用 cmd 创建命令行应用程序。

    我们将遵循 Bash shell 的设计模式,它会在以下位置查找配置文件:

    1. 它从/etc/profile 文件开始,适用于使用系统的每个人。

    2. 在读取该文件后,它会按以下顺序查找这些文件之一:

      1. ~/.bash_profile

      2. ~/.bash_login

      3. ~/.profile

    其他 shell,如 zsh,使用一些额外的文件,但遵循通过一系列文件顺序工作的模式。

    在符合 POSIX 标准的操作系统上,shell 将~展开为登录用户的家目录。通常,Python 的 pathlib 模块通过 Path.home()方法自动处理 Windows、Linux 和 macOS。

    在后面的菜谱中,我们将探讨解析和处理配置文件特定格式的各种方法。对于本菜谱的目的,我们不会选择特定的格式。相反,我们将假设已经定义了一个名为 load_config_file()的现有函数,该函数将从配置文件的 内容中加载特定的配置映射。

    函数看起来是这样的:

    def load_config_file(config_path: Path) -> dict[str, Any]: 
    
        """Loads a configuration mapping object with the contents 
    
        of a given file. 
    
        :param config_path: Path to be read. 
    
        :returns: mapping with configuration parameter value 
    
        """ 
    
        # Details omitted.
    

    我们将探讨实现此函数的多种不同方法。

    为什么有这么多选择?

    在讨论这类设计时,有时会出现一个相关话题——为什么有这么多选择?为什么不指定一个确切的位置?

    提供一个分布特有的变体,但另一个分布不典型的情况很常见。此外,用户的期望取决于他们已经熟悉的软件;这很难预测。当然,当处理 Windows 时,还可能出现更多仅适用于该平台的变体文件路径。出于这些原因,提供多个位置并允许用户或管理员选择他们偏好的位置更容易。

    13.1.2 如何做到这一点...

    我们将利用 pathlib 模块提供一种方便的方式来处理各种位置上的文件。我们还将使用 collections 模块提供非常有用的 ChainMap 类:

    1. 导入 Path 类和 ChainMap 类。还需要几个类型提示:

      from pathlib import Path 
      
      from collections import ChainMap 
      
      from typing import TextIO, Any
      
    2. 定义一个获取配置文件的整体函数:

      def get_config() -> ChainMap[str, Any]:
      
    3. 为配置文件的各个位置创建路径。这些被称为纯路径,并以潜在文件的名字开头。我们可以将这些位置分解为系统路径和一系列本地路径。以下是两个赋值语句:

       system_path = Path("/etc") / "some_app" / "config" 
      
          local_paths = [ 
      
          ".some_app_settings", 
      
          ".some_app_config", 
      
          ]
      
    4. 将应用程序的内置默认值定义为一个字典列表:

       configuration_items = [ 
      
              dict( 
      
                  some_setting="Default Value", 
      
                  another_setting="Another Default", 
      
                  some_option="Built-In Choice", 
      
              ) 
      
          ]
      

      每个单独的配置文件是从键到值的映射。这些映射对象中的每一个都被组合成一个列表;这成为最终的 ChainMap 配置映射。

    5. 如果存在系统范围的配置文件,则加载此文件:

       if system_path.exists(): 
      
              configuration_items.append( 
      
                  load_config_file(system_path))
      
    6. 遍历其他位置以查找要加载的文件。这将加载它找到的第一个文件,并使用 break 语句在找到第一个文件后停止:

       for config_name in local_paths: 
      
              config_path = Path.home() / config_name 
      
              if config_path.exists(): 
      
                  configuration_items.append( 
      
                      load_config_file(config_path)) 
      
                  break
      
    7. 反转列表并创建最终的 ChainMap 映射:

       configuration = ChainMap( 
      
              *reversed(configuration_items) 
      
          )
      

      列表需要反转,以便首先搜索附加在最后的本地文件,然后是系统设置,最后是应用程序默认设置。当然,可以按相反的顺序组装列表以避免 reversed()函数;我们把这个可能的变化留给你作为练习。

    一旦我们构建了配置对象,我们就可以像简单的映射一样使用最终的配置。此对象支持所有预期的字典操作。

    13.1.3 它是如何工作的...

    在第五章的 创建字典——插入和更新 配方中,我们探讨了使用字典的基本知识。在这里,我们将几个字典组合成一个链。当一个键不在链的第一个字典中时,则检查链中的后续字典。这是一种为映射中的每个键提供默认值的好方法。由于 ChainMap 几乎与内置的 dict 类无法区分,它允许在实现细节上具有很大的灵活性:任何可以读取以创建字典的配置文件都是完全可接受的。应用程序的其余部分可以基于字典,而不必暴露配置构建的细节。

    13.1.4 更多...

    单一的全局配置文件和本地配置文件替代名称集合之间的微妙区别并不理想。这种单例和选择列表之间的区别似乎没有特定的用途。通常,我们希望扩展这种设计,而微小的非对称性会导致复杂性。

    我们将考虑将配置修改为以下四个层级:

    1. 内置的默认值。

    2. 在像 /etc 或 /opt 这样的中心目录中的主机级配置。这通常用于此容器的 OS 或网络上下文的详细信息。

    3. 为运行应用程序的用户配置的 home 目录。这可能用于区分测试和生产实例。

    4. 当前工作目录中的本地文件。这可能由开发者或测试人员使用。

    这表明对配方进行修改以使用路径的嵌套列表。外层列表包含所有配置层级。在每个层级内,一个列表将包含配置文件的替代位置。

    local_names = (’.some_app_settings’, ’.some_app_config’) 
    
    config_paths = [ 
    
        [ 
    
            base / ’some_app’ / ’config’ 
    
            for base in (Path(’/etc’), Path(’/opt’)) 
    
        ], 
    
        [ 
    
            Path.home() / name 
    
            for name in local_names 
    
        ], 
    
        [ 
    
            Path.cwd() / name 
    
            for name in local_names 
    
        ], 
    
    ]
    

    这种 list[list[Path]] 结构提供了三个配置文件层级。每个层级都有多个替代名称。层级的顺序以及每个层级内的名称都很重要。较低层级提供对较高层级的覆盖。然后我们可以使用嵌套的 for 语句来检查所有替代位置。

    def get_config_2() -> ChainMap[str, Any]: 
    
        configuration_items = [ 
    
            DEFAULT_CONFIGURATION 
    
        ] 
    
        for tier_paths in config_paths: 
    
            for alternative in tier_paths: 
    
                if alternative.exists(): 
    
                    configuration_items.append( 
    
                        load_config_file(alternative)) 
    
                    break 
    
        configuration = ChainMap( 
    
            *reversed(configuration_items) 
    
        ) 
    
        return configuration
    

    我们已经将默认配置提取到一个名为 DEFAULT_CONFIGURATION 的全局变量中。我们明显地留下了名为 config_paths 的配置路径集合。不清楚这是否应该是全局的(并且使用全大写的全局变量名)或者是否应该是 get_config() 函数的一部分。我们通过使用小写名称并将其放在函数外部,采取了一部分两者的做法。

    config_paths 的值可能不会在其他地方需要,因此将其作为全局变量是一个糟糕的选择。然而,这却是一样可能会改变的东西——也许在下一个主要版本中——并且值得暴露出来以便进行更改。

    13.1.5 参见

    • 在本章的 使用 TOML 作为配置文件 和 使用 Python 作为配置文件 菜谱中,我们将探讨实现 load_config_file() 函数的方法。

    • 在第十五章的 模拟外部资源 菜谱中,我们将探讨测试此类函数的方法,这些函数与外部资源交互。

    • pathlib 模块是此处理的核心。此模块提供了 Path 类定义,它提供了关于操作系统文件的许多复杂信息。有关更多信息,请参阅第十一章的 使用 pathlib 处理文件名 菜谱。

    13.2 使用 TOML 作为配置文件

    Python 提供了多种打包应用程序输入和配置文件的方式。我们将探讨使用 TOML 语法编写文件,因为这种格式优雅且简单。有关此格式的更多信息,请参阅 toml.io/en/

    大多数 TOML 文件看起来与 INI 格式文件非常相似。这种重叠是有意为之的。在 Python 中解析时,TOML 文件将是一个嵌套字典结构。

    我们可能有一个这样的文件:

    [some_app] 
    
        option_1 = "useful value" 
    
        option_2 = 42 
    
    [some_app.feature] 
    
        option_1 = 7331
    

    这将变成如下所示的字典:

    {’some_app’: {’feature’: {’option_1’: 7331}, 
    
                  ’option_1’: ’useful value’, 
    
                  ’option_2’: 42}}
    

    [some_app.feature] 被称为“表格”。在键中使用 . 会创建一个嵌套表格。

    13.2.1 准备工作

    我们经常会使用本章前面展示的 寻找配置文件 菜谱,以检查给定配置文件的各种位置。这种灵活性对于创建易于在各种平台上使用的应用程序通常是必不可少的。

    在本菜谱中,我们将构建 寻找配置文件 菜谱中缺失的部分,即 load_config_file() 函数。以下是需要填写的模板:

    def load_config_file_draft(config_path: Path) -> dict[str, Any]: 
    
        """Loads a configuration mapping object with contents 
    
        of a given file. 
    
        :param config_path: Path to be read. 
    
        :returns: mapping with configuration parameter values 
    
        """ 
    
        # Details omitted.
    

    在本菜谱中,我们将填写 Details 省略行保留的空间,以加载 TOML 格式的配置文件。

    13.2.2 如何做...

    本菜谱将使用 tomllib 模块解析 YAML-TOML 文件:

    1. 导入 tomllib 模块以及 Path 定义和 load_config_file() 函数定义所需的类型提示:

      from pathlib import Path 
      
      from typing import Any 
      
      import tomllib
      
    2. 使用 tomllib.load() 函数加载 TOML 语法文档:

      import tomllib 
      
      def load_config_file(config_path: Path) -> dict[str, Any]: 
      
          """Loads a configuration mapping object with contents 
      
          of a given file. 
      
          :param config_path: Path to be read. 
      
          :returns: mapping with configuration parameter values 
      
          """ 
      
          with config_path.open(’b’) as config_file: 
      
              document = tomllib.load(config_file)
      

      Python 中 TOML 解析的一个不寻常的要求是我们需要在使用 load() 函数时以“二进制”模式打开文件。我们可以使用 'rb' 作为模式,以明确表示文件是为读取而打开的。

      另一种选择是使用 loads() 函数对一个文本块进行操作。它看起来像这样:

          document = tomllib.loads(config_path.read_text())
      

    此 load_config_file() 函数生成了所需的字典结构。它可以适应 寻找配置文件 菜谱中的设计,以使用 TOML 语法加载配置文件。

    13.2.3 它是如何工作的...

    如上所述,TOML 语法的理念是易于阅读,并直接映射到 Python 字典。TOML 语法与 INI 文件语法之间有一些有意重叠。它也与属性文件语法的某些方面有重叠。

    TOML 语法的核心是键值对,通常写作 key = value。键包括有效的 Python 符号。这意味着任何具有字典映射的数据类或 Pydantic 结构都可以映射到 TOML 语法中。

    TOML 的键可以包含连字符,这不是 Python 允许的名称的一部分。键也可以是引号字符串。这允许有相当广泛的替代键。这些特性在使用配置字典对象时可能需要一些谨慎。

    键也可以是点分隔的;这将创建子字典。以下是一个点分隔键的示例:

    some_app.option_1 = "useful value" 
    
    some_app.option_2 = 42 
    
    some_app.feature.option_1 = 7331
    

    这看起来与常用于 Java 应用程序的属性文件非常相似。这通过在点字符处分解键来创建嵌套字典。

    可用的值种类繁多,包括字符串值、整数值、浮点值和布尔值(使用 true 和 false 作为字面值)。此外,TOML 还识别 ISO 日期时间字符串;有关支持的格式,请参阅 RFC 3339

    TOML 允许两种数据结构:

    • 数组用 [] 括起来。我们可以使用 sizes = [1, 2, 3] 来创建一个 Python 列表值。

    • 可以使用 {} 包围一个或多个键值对来创建内联表格。例如,sample = {x = 10, y = 8.4} 创建了一个嵌套字典值。

    TOML 语法的一个重要特性是使用 [table] 作为嵌套字典的键。我们经常会看到这种情况:

    [some_app] 
    
        option_1 = "useful value" 
    
        option_2 = 42 
    
    [some_app.feature] 
    
        option_1 = 7331
    

    [some_app] 是一个字典的键,该字典包含缩进的键值对。TOML 语法中的 [some_app.feature] 定义了一个更深层次的字典。使用点分隔的键意味着字符串 "some_app" 将成为包含键 "feature" 的字典的键。与该键关联的值将是一个包含键 "option_1" 的字典。在 TOML 中,[table] 前缀用于嵌套值,创建了一个视觉组织,使得查找和更改配置设置更加容易。

    13.2.4 更多内容...

    TOML 语法用于描述 Python 项目的整体 pyproject.toml 文件。此文件通常有两个顶级表格:[project] 和 [build-system]。项目表将包含有关 [project] 的元数据。以下是一个示例:

    [project] 
    
    name = "python_cookbook_3e" 
    
    version = "2024.1.0" 
    
    description = "All of the code examples for Modern Python Cookbook, 3rd Ed." 
    
    readme = "README.rst" 
    
    requires-python = ">=3.12" 
    
    license = {file = "LICENSE.txt"}
    

    [build-system] 表格提供了有关安装模块、软件包或应用程序所需工具的信息。以下是一个示例:

    [build-system] 
    
    build-backend = ’setuptools.build_meta’ 
    
    requires = [ 
    
        ’setuptools’, 
    
    ]
    

    此文件提供了关于项目的一些基本信息。使用 TOML 语法使得阅读和更改相对容易。

    13.2.5 参见

    • 参考本章前面的查找配置文件配方,了解如何搜索多个文件系统位置以查找配置文件。我们可以轻松地将应用程序默认设置、系统级设置和个人设置分别存入不同的文件,并由应用程序组合。

    • 更多关于 TOML 语法的详细信息,请参阅toml.io/en/

    • 更多关于 pyproject.toml 文件的信息,请参阅 Python 打包权威机构文档pip.pypa.io/en/stable/reference/build-system/pyproject-toml/

    13.3 使用 Python 配置文件

    除了提供配置数据的 TOML 语法之外,我们还可以用 Python 符号编写文件;它既优雅又简单。由于配置文件是一个 Python 模块,因此它提供了极大的灵活性。

    13.3.1 准备工作

    Python 赋值语句对于创建配置文件来说特别优雅。语法可以简单、易于阅读且非常灵活。如果我们使用赋值语句,我们可以从单独的模块导入应用程序的配置细节。这个模块可以命名为 settings.py,以表明该模块专注于配置参数。

    由于 Python 将每个导入的模块视为一个全局 Singleton 对象,因此我们可以让应用程序的多个部分都使用 import settings 语句来获取当前全局应用程序配置参数的一致视图。我们不需要担心使用 Singleton 设计模式管理对象,因为 Python 已经包含了这部分。

    我们希望能够在文本文件中提供如下定义:

    """Weather forecast for Offshore including the Bahamas 
    
    """ 
    
    query = {’mz’: 
    
        [’ANZ532’, 
    
         ’AMZ117’, 
    
         ’AMZ080’] 
    
    } 
    
    base_url = "https://forecast.weather.gov/shmrn.php"
    

    此配置是一个 Python 脚本。参数包括两个变量,query 和 base_url。query 变量的值是一个包含单个键'mz'和一系列值的字典。

    这可以被视为一系列相关 URL 的规范,这些 URL 都与forecast.weather.gov/shmrn.php?mz=ANZ532类似。

    我们经常使用查找配置文件的配方来检查给定配置文件的各种位置。这种灵活性对于创建易于在各种平台上使用的应用程序通常是必不可少的。

    在这个配方中,我们将构建查找配置文件配方中缺失的部分,即 load_config_file()函数。以下是需要填写的模板:

    def load_config_file_draft(config_path: Path) -> dict[str, Any]: 
    
        """Loads a configuration mapping object with contents 
    
        of a given file. 
    
        :param config_path: Path to be read. 
    
        :returns: mapping with configuration parameter values 
    
        """ 
    
        # Details omitted.
    

    在这个配方中,我们将填充由# Details omitted 行保留的空间,以在 Python 格式中加载配置文件。

    13.3.2 如何做...

    我们可以利用 pathlib 模块来定位文件。我们还将利用内置的 compile()和 exec()函数来处理配置文件中的代码:

    1. 导入 Path 定义和 load_config_file()函数定义所需的类型提示:

      from pathlib import Path 
      
      from typing import Any
      
    2. 使用内置的 compile() 函数将 Python 模块编译成可执行形式。此函数需要源文本以及读取文本的文件名。文件名对于创建有用且正确的回溯消息至关重要:

      def load_config_file(config_path: Path) -> dict[str, Any]: 
      
          code = compile( 
      
              config_path.read_text(), 
      
              config_path.name, 
      
              "exec")
      

      在代码不来自文件的情况下,通常的做法是为文件名提供一个名称,如

    3. 执行由 compile() 函数创建的代码对象。这需要两个上下文。全局上下文提供了任何先前导入的模块,以及 builtins 模块。局部上下文是 locals 字典;这是新变量将被创建的地方:

       locals: dict[str, Any] = {} 
      
          exec( 
      
              code, 
      
              {"__builtins__": __builtins__}, 
      
              locals 
      
          ) 
      
          return locals
      

    此 load_config_file() 函数生成所需的字典结构。它可以适应从 查找配置文件 配方中加载配置文件的设计,使用 Python 语法。

    13.3.3 它是如何工作的...

    Python 语言细节——语法和语义——体现在内置的 compile() 和 exec() 函数中。三个基本步骤如下:

    1. 读取文本。

    2. 使用 compile() 函数编译文本以创建代码对象。

    3. 使用 exec() 函数执行代码对象。

    exec() 函数反映了 Python 处理全局和局部变量的方式。为此函数提供了两个命名空间(映射)。这些可以通过 globals() 和 locals() 函数访问。

    我们可以向 exec() 函数提供两个不同的字典:

    • 全局对象的字典。最常见的用途是提供对导入的模块的访问,这些模块始终是全局的。可以在该字典中提供 builtins 模块。在某些情况下,还需要添加其他模块,如 pathlib。

    • 一个用于 locals 的字典,它将由每个赋值语句创建(或更新)。此局部字典允许我们在执行设置模块时捕获创建的变量。

    exec() 函数将更新 locals 字典。我们预计全局字典不会被更新,并将忽略此集合发生的任何更改。

    13.3.4 更多内容...

    此配方建议配置文件完全是一系列 name = value 赋值语句。赋值语句使用 Python 语法,变量名和字面量语法也是如此。这允许配置利用 Python 的内置类型的大量集合。此外,Python 语句的全系列都可用。这导致了一些工程权衡。

    因为配置文件中可以使用任何语句,这可能导致复杂性。如果配置文件中的处理变得过于复杂,文件就不再是配置,而是应用程序的第一级部分。非常复杂的功能应该通过修改应用程序编程来实现,而不是通过配置设置进行黑客攻击。Python 应用程序包含完整的源代码,因为通常修复源代码比创建超复杂的配置文件更容易。目标是让配置文件提供值以定制操作,而不是提供插件功能。

    我们可能希望将操作系统环境变量作为配置的全局变量的一部分。这样做有助于确保配置值与当前环境设置匹配。这可以通过 os.environ 映射来完成。

    对于相关设置进行一些处理也是合理的。例如,编写一个包含多个相邻路径的配置文件可能会有所帮助:

    """Config with related paths""" 
    
    base = Path(os.environ.get("APP_HOME", "/opt/app")) 
    
    log = base / ’log’ 
    
    out = base / ’out’
    

    在许多情况下,设置文件是由可以信赖的人编辑的。尽管如此,错误仍然会发生,因此对提供给 exec()函数的全局字典中可用的函数保持谨慎是明智的。提供最窄的函数集以支持配置是推荐的做法。

    13.3.5 参见

    • 请参阅本章前面的查找配置文件配方,了解如何搜索多个文件系统位置以查找配置文件。

    13.4 使用类作为配置的命名空间

    Python 提供了多种打包应用程序输入和配置文件的方法。我们将继续探讨使用 Python 符号编写文件,因为它优雅且熟悉的语法可以导致易于阅读的配置文件。许多项目允许我们使用类定义来提供配置参数。这当然使用 Python 语法。它还使用类定义作为命名空间,以允许在单个模块中提供多个配置。使用类层次结构意味着可以使用继承技术来简化参数的组织。

    这避免了使用 ChainMap 来允许用户对通用设置进行特定覆盖。相反,它使用普通的继承。

    我们永远不会创建这些类的实例。我们将使用类定义的属性并依赖类继承方法来追踪属性的适当值。这与本章中的其他配方不同,因为它将生成一个 ConfigClass 对象,而不是一个 dict[str, Any]对象。

    在本配方中,我们将探讨如何使用 Python 类符号表示配置细节。

    13.4.1 准备工作

    Python 定义类属性的方式可以简单、易于阅读,并且相当灵活。我们可以通过一些工作,定义一个复杂的配置语言,允许某人快速且可靠地更改 Python 应用程序的配置参数。

    我们可以将这种语言基于类定义。这允许我们将多个配置选项打包在一个模块中。应用程序可以加载该模块并从模块中选择相关的类定义。

    我们希望能够提供如下所示的定义:

    class Configuration: 
    
        """ 
    
        Generic Configuration with a sample query. 
    
        """ 
    
        base = "https://forecast.weather.gov/shmrn.php" 
    
        query = {"mz": ["GMZ856"]}
    

    我们可以在 settings.py 文件中创建这个类定义来创建一个设置模块。要使用配置,主应用程序可以这样做:

    >>> from settings import Configuration 
    
    >>> Configuration.base 
    
    ’https://forecast.weather.gov/shmrn.php’
    

    应用程序将使用名为 settings 的模块名和名为 Configuration 的类名来收集设置。

    配置文件的存储位置遵循 Python 查找模块的规则。我们不需要自己实现配置的搜索,而是可以利用 Python 内置的 sys.path 搜索以及 PYTHONPATH 环境变量的使用。

    在这个菜谱中,我们将构建一个类似于查找配置文件菜谱的缺失部分,即 load_config_file()函数。然而,会有一个重要的区别:我们将返回一个对象而不是一个字典。然后我们可以通过属性名来引用配置值,而不是使用更繁琐的字典表示法。以下是需要填写的修订模板:

    ConfigClass = type[object] 
    
    def load_config_file_draft( 
    
        config_path: Path, classname: str = "Configuration" 
    
    ) -> ConfigClass: 
    
        """Loads a configuration mapping object with contents 
    
        of a given file. 
    
        :param config_path: Path to be read. 
    
        :returns: mapping with configuration parameter values 
    
        """ 
    
        # Details omitted.
    

    我们在本章的多个菜谱中使用了类似的模板。对于这个菜谱,我们添加了一个参数并更改了返回类型。在之前的菜谱中,没有 classname 参数,但在这里它被用来从由 config_path 参数命名的文件系统位置选择模块中的一个类。

    13.4.2 如何实现...

    我们可以利用 pathlib 模块来定位文件。我们将利用内置的 compile()和 exec()函数来处理配置文件中的代码:

    1. 导入 Path 定义以及 load_config_file()函数定义所需的类型提示:

      from pathlib import Path 
      
      import platform 
      
    2. 使用内置的 compile()函数将 Python 模块编译成可执行形式。此函数需要源文本以及从其中读取文本的文件名。文件名对于创建有用且正确的回溯消息是必不可少的:

      def load_config_file( 
      
          config_path: Path, classname: str = "Configuration" 
      
      ) -> ConfigClass: 
      
          code = compile( 
      
              config_path.read_text(), 
      
              config_path.name, 
      
              "exec")
      
    3. 执行由 compile()方法创建的代码对象。我们需要提供两个上下文。全局上下文可以提供 builtins 模块、Path 类和 platform 模块。局部上下文是新变量将被创建的地方:

       globals = { 
      
              "__builtins__": __builtins__, 
      
              "Path": Path, 
      
              "platform": platform} 
      
          locals: dict[str, ConfigClass] = {} 
      
          exec(code, globals, locals) 
      
          return locals[classname]
      

      这将在 locals()映射中定位命名的类并返回该类作为配置对象。这不会返回一个字典。

    在 load_config_file() 函数的这种变体中,产生了一个有用的结构,可以通过属性名称来访问。它并不提供 查找配置文件 菜单中预期的设计。由于它使用属性名称,因此产生的配置对象比简单的字典更有用。

    13.4.3 它是如何工作的...

    我们可以通过使用 compile() 和 exec() 来加载 Python 模块。从模块中,我们可以提取包含各种应用程序设置的单独类名。总体来说,它看起来像以下示例:

    >>> configuration = load_config_file( 
    
    ... Path(’src/ch13/settings.py’), ’Chesapeake’) 
    
    >>> configuration.__doc__.strip() 
    
    ’Weather for Chesapeake Bay’ 
    
    >>> configuration.query 
    
    {’mz’: [’ANZ532’]} 
    
    >>> configuration.base 
    
    ’https://forecast.weather.gov/shmrn.php’
    

    我们可以将任何类型的对象放入配置类的属性中。我们的示例显示了字符串列表和字符串。使用类定义时,任何类的任何对象都成为可能。

    我们可以在类声明中包含复杂的计算。我们可以使用这个功能来创建由其他属性派生出来的属性。我们可以执行任何类型的语句,包括 if 语句和 for 语句,来创建属性值。

    然而,我们不会创建给定类的实例。像 Pydantic 这样的工具将验证类的实例,但对于验证类定义并不有帮助。任何类型的验证规则都必须定义在用于构建结果配置类的元类中。此外,类的一般方法将不会被使用。如果需要一个类似函数的定义,它必须用 @classmethod 装饰器来使其有用。

    13.4.4 更多...

    使用类定义意味着我们将利用继承来组织配置值。我们可以轻松地创建 Configuration 的多个子类,其中一个将被选中用于应用程序。

    配置可能看起来像这样:

    class Configuration: 
    
        """ 
    
        Generic Configuration with a sample query. 
    
        """ 
    
        base = "https://forecast.weather.gov/shmrn.php" 
    
        query = {"mz": ["GMZ856"]} 
    
    class Bahamas(Configuration): 
    
        """ 
    
        Weather forecast for Offshore including the Bahamas 
    
        """ 
    
        query = {"mz": ["AMZ117", "AMZ080"]} 
    
    class Chesapeake(Configuration): 
    
        """ 
    
        Weather for Chesapeake Bay 
    
        """ 
    
        query = {"mz": ["ANZ532"]}
    

    我们的应用程序必须从 settings 模块中可用的类中选择一个合适的类。我们可能使用操作系统环境变量或命令行选项来指定要使用的类名。我们的程序可以这样执行:

    (cookbook3) % python3 some_app.py -c settings.Chesapeake
    

    这将定位 settings 模块中的 Chesapeake 类。然后,处理将基于该特定配置类中的详细信息。这个想法导致了 load_config_class() 函数的扩展。

    为了选择一个可用的类,我们可以通过在命令行参数值中查找 "." 分隔符来分隔模块名称和类名称:

    import importlib 
    
    def load_config_class(name: str) -> ConfigClass: 
    
        module_name, _, class_name = name.rpartition(".") 
    
        settings_module = importlib.import_module(module_name) 
    
        result: ConfigClass = vars(settings_module)[class_name] 
    
        return result
    

    我们不是手动编译和执行模块,而是使用了更高层次的 importlib 模块。此模块包含实现导入语句语法的函数。请求的模块被导入,然后编译和执行,结果模块对象被分配给名为 result 的变量。

    现在,我们可以如下使用这个函数:

    >>> configuration = load_config_class( 
    
    ... ’settings.Chesapeake’) 
    
    >>> configuration.__doc__.strip() 
    
    ’Weather for Chesapeake Bay’ 
    
    >>> configuration.query 
    
    {’mz’: [’ANZ532’]} 
    
    >>> configuration.base 
    
    ’https://forecast.weather.gov/shmrn.php’
    

    我们已经在 settings 模块中找到了 Chesapeake 配置类,并从中提取了应用程序需要的各种设置。

    13.4.5 参见

    • 我们将在第七章和第八章中详细探讨类定义。

    • 在本章中查看查找配置文件的配方,了解一种不使用类定义的替代方法。

    13.5 设计用于组合的脚本

    整体应用程序设计的一个重要部分是创建一个可以处理命令行参数和配置文件的脚本。此外,设计脚本以便它可以被测试以及与其他脚本组合成一个复合应用程序也非常重要。

    想法是这样的:许多好主意通过一系列阶段演变而来。这样的演变可能是一条以下路径:

    1. 这个想法最初是一系列单独的笔记本,用于处理更大任务的不同部分。

    2. 在探索和实验的初期阶段之后,这变成了一项简单的重复性任务。与其手动打开和点击来运行笔记本,不如将其保存到脚本文件中,然后可以从命令行运行这些脚本文件。

    3. 在将这一过程作为组织运营的常规部分的一段时间后,三部分脚本需要合并成一个单一的脚本。此时,需要进行重构。

    在将多个脚本组合成一个应用程序并发现意外问题时,重构是最痛苦的时刻。这通常是因为当多个脚本集成时,全局变量将被共享。

    在项目生命周期的早期,这是一个不那么痛苦的时刻。一旦创建了脚本,就应该努力设计脚本以便进行测试和组合到更大的应用程序中。

    13.5.1 准备工作

    在这个配方中,我们将探讨构成脚本良好设计的要素。特别是,我们想要确保在设计时考虑参数和配置文件。

    目标是拥有以下结构:

    • 对整个模块或脚本的一个文档字符串。

    • 导入语句。这些有一个内部顺序。像 isort 和 ruff 这样的工具可以处理这个问题。

    • 应用于脚本的类和函数定义。

    • 一个函数,用于将配置文件选项和运行时参数收集到一个单一的对象中,该对象可以被其他类和函数使用。

    • 一个执行有用工作的单个函数。这通常被称为 main(),但这个名字并没有什么神圣的。

    • 一个只有当模块作为脚本运行时才会执行的小块代码,而模块被导入时则不会执行:

       if __name__ == "__main__": 
      
          main()
      

    13.5.2 如何实现...

    以目标设计为目标,这里有一个方法:

    1. 首先在文件顶部编写一个总结文档字符串。开始时先有一个大致的想法,然后再添加细节。以下是一个示例:

       """ 
      
          Some Script. 
      
          What it does. How it works. 
      
          Who uses it. When do they use it. 
      
      """
      
    2. 导入语句跟在文档字符串后面。事先预见所有导入并不总是可能的。随着模块的编写和修改,导入将被添加和删除。

    3. 接下来是类和函数的定义。顺序对于在 def 或 class 语句中解析类型名称很重要。这意味着最基本类型定义必须首先进行。

      再次强调,在设计的第一波中,并不总是能够按照正确的顺序编写所有定义。重要的是保持它们在逻辑组织中的统一,并重新排列它们,以便阅读代码的人能够理解顺序。

    4. 编写一个函数(例如 get_config()),用于获取所有配置参数。通常,这包括两个部分;有时需要将它们分解为两个单独的函数,因为每个部分可能相当复杂。

    5. 然后是 main() 函数。它执行脚本的必要工作。当从笔记本演变而来时,这可以由单元格序列构建。

    6. 在最后添加 Main-Import 切换代码块:

      if __name__ == "__main__": 
      
          main()
      

    生成的模块将作为脚本正常工作。它还可以更容易地进行测试,因为测试工具如 pytest 可以导入模块,而无需在尝试处理数据时对文件系统进行更改。它可以与其他脚本集成,以创建有用的复合应用程序。

    13.5.3 它是如何工作的...

    设计脚本时的核心考虑是区分模块的两个用途:

    • 当从命令行运行时。在这种情况下,内置的全局变量 name 将具有 "main" 的值。

    • 当作为测试的一部分或作为更大、复合应用程序的一部分导入时。在这种情况下,name 将具有模块的名称值。

    当导入模块时,我们不希望它开始执行工作。在导入过程中,我们不希望模块打开文件、读取数据、进行计算或产生输出。所有这些工作都是在模块作为主程序运行时才能发生的事情。

    笔记本或脚本语句的原始单元格现在成为 main() 函数的主体,因此脚本将正常工作。然而,它也将以可以测试的形式存在。它还可以集成到更大、更复杂的应用程序中。

    13.5.4 更多内容...

    在开始将代码转换为应用程序时,main() 函数通常相当长。有两种方法可以使处理过程更清晰:

    • 显著的公告牌注释

    • 通过重构创建多个较小的函数

    我们可能从一个具有如下注释的脚本开始:

    # # Some complicated process 
    
    # 
    
    # Some additional markdown details. 
    
    # In[12]: 
    
    print("Some useful code here") 
    
    # In[21]: 
    
    print("More code here")
    

    In[n]: 注释由 JupyterLab 提供,用于识别笔记本中的单元格。我们可以创建类似这样的公告牌注释:

    #################################### 
    
    # Some complicated process         # 
    
    #                                  # 
    
    # Some additional markdown details.# 
    
    #################################### 
    
    print("Some useful code here") 
    
    #################################### 
    
    # Another step in the process      # 
    
    #################################### 
    
    print("More code here")
    

    这并不理想。这是一个可接受的临时措施,但这些步骤应该是适当的函数,每个函数都有一个文档字符串和测试用例。在那些没有适当文档字符串且缺乏利用文档字符串的文档生成器的语言中,公告牌注释是传统的。

    Python 有文档字符串和几个工具——如 Sphinx——可以从文档字符串创建文档。

    13.5.5 相关内容

    • 有关使用 argparse 从用户获取输入的背景信息,请参阅第六章中的使用 argparse 获取命令行输入配方。

    • 在本章中,请参阅使用 TOML 配置文件、使用 Python 配置文件和使用类作为配置命名空间的相关配方。

    • 本章后面的使用日志记录进行控制和审计输出配方探讨了日志记录。

    • 在第十四章的将两个应用程序合并为一个配方中,我们将探讨遵循此设计模式的应用程序组合方法。

    • 测试和集成的详细信息在其他章节中介绍。有关创建测试的详细信息,请参阅第十五章。有关组合应用程序的详细信息,请参阅第十四章。

    13.6 使用日志记录进行控制和审计输出

    当我们考虑一个应用程序时,我们可以将整体计算分解为三个不同的方面:

    • 收集输入

    • 将输入转换为输出的基本处理过程

    • 生成输出

    应用程序会产生几种不同的输出:

    • 主要输出有助于用户做出决策或采取行动。在某些情况下,这可能是通过 Web 服务器下载的 JSON 格式文档。它可能是一组更复杂的文档,这些文档一起创建一个 PDF 文件。

    • 确认程序完全且正确运行的控件信息。

    • 可以用来跟踪持久数据库中状态变化历史的审计摘要。

    • 任何指示应用程序为何无法工作的错误消息。

    将所有这些不同的方面都合并到写入标准输出的 print()请求中并不是最佳选择。实际上,这可能会导致混淆,因为太多的不同输出可能会在单个流中交错。

    操作系统为每个运行进程提供两个输出文件,标准输出和标准错误。这些在 Python 中通过 sys 模块的 sys.stdout 和 sys.stderr 名称可见。默认情况下,print()函数写入 sys.stdout 文件。我们可以更改目标文件,并将控制、审计和错误消息写入 sys.stderr。这是正确方向上的一个重要步骤。

    Python 还提供了日志记录包,可以用来将辅助输出导向一个单独的文件(以及/或其他输出通道,如数据库)。它还可以用来格式化和过滤额外的输出。

    在这个配方中,我们将探讨使用日志模块的好方法。

    13.6.1 准备工作

    满足各种输出需求的一种方法是创建多个记录器,每个记录器具有不同的意图。通常,记录器的命名与记录器关联的模块或类相关。我们也可以围绕审计或控制等整体目的命名记录器。

    记录器的名称构成一个层次结构,由..分隔。根记录器是所有记录器的父级,名称为""。这表明我们可以有专注于特定类、模块或功能的记录器家族。

    一组顶级记录器可以包括多个不同的关注领域,包括:

    • 错误将为警告和错误的所有记录器添加前缀。

    • 调试将为调试消息的所有记录器添加前缀。

    • 审计将命名带有计数和总计的记录器,用于确认数据已被完全处理。

    • 控制将命名提供有关应用程序运行时间、环境、配置文件和命令行参数值的记录器。

    在大多数情况下,将错误和调试放在单个记录器中是有帮助的。在其他情况下——例如,一个网络服务器——请求错误响应日志应与任何内部错误或调试日志分开。

    一个复杂的应用程序可能包含几个名为 audit.input 和 audit.output 的记录器,以显示消耗的数据计数和生成数据的计数。将这些记录器分开可以帮助关注数据提供者的问题。

    严重程度级别为每个记录器提供了一种过滤机制。在日志包中定义的严重程度级别包括以下内容:

    调试:这些消息通常不会显示,因为它们的目的是支持调试。上面,我们建议这是一种独特的调试类型。我们建议应用程序创建一个日志调试器,并使用普通的 INFO 消息进行调试条目。

    信息:这些消息提供了有关正常、愉快的处理过程的信息。

    警告:这些消息表明处理可能以某种方式受损。警告的最合理用例是当函数或类已被弃用时:它们仍然工作,但应该被替换。

    错误:处理无效,输出不正确或不完整。在长时间运行的服务器的情况下,单个请求可能存在问题,但服务器整体可以继续运行。

    严重:更严重的错误级别。通常,这用于长时间运行的服务器,其中服务器本身无法继续运行,即将崩溃。

    每个记录器都有与严重程度级别相似的方法名称。我们使用 info()方法以 INFO 严重程度级别写入消息。

    对于错误处理,严重级别大多是合适的。然而,调试日志记录器通常会生成大量需要单独保留的数据。此外,任何审计和控制输出似乎没有严重级别。严重级别似乎仅关注错误日志记录。因此,似乎更好的是具有如 debug.some_function 之类的名称的独立日志。然后我们可以通过启用或禁用这些日志记录器的输出以及配置严重级别为 INFO 来配置调试。

    13.6.2 如何实现...

    我们将在两个迷你食谱中查看类和函数中的日志记录。

    在类中记录日志

    1. 确保已导入日志记录模块。

    2. init()方法中,包括以下内容以创建错误和调试日志记录器:

       self.err_logger = logging.getLogger( 
      
                  f"error.{self.__class__.__name__}") 
      
              self.dbg_logger = logging.getLogger( 
      
                  f"debug.{self.__class__.__name__}")
      
    3. 在任何可能需要未来调试的方法中,使用调试日志记录器的函数将详细信息写入日志。虽然可以使用 f-string 来编写日志消息,但它们涉及将值插入文本的一些开销。当配置静默日志记录器的输出时,使用日志记录器的格式化选项和单独的参数值涉及的计算量略少:

       self.dbg_logger.info( 
      
                  "Some computation with %r", some_variable) 
      
              # Some complicated computation with some_variable 
      
              self.dbg_logger.info( 
      
                  "Result details = %r", result)
      
    4. 在几个关键位置,包含整体状态消息。这些通常在整体应用程序控制类中:

       # Some complicated input processing and parsing 
      
              self.err_logger.info("Input processing completed.") 
      

    在函数中记录日志

    1. 确保已导入日志记录模块。

    2. 对于更大和更复杂的函数,将日志记录器包含在函数内部是有意义的:

      def large_and_complicated(some_parameter: Any) -> Any: 
      
          dbg_logger = logging.getLogger("debug.large_and_complicated") 
      
          dbg_logger.info("some_parameter= %r", some_parameter)
      

      由于日志记录器被缓存,第一次调用 get_logger()时才会涉及任何显著的开销。所有后续请求都是字典查找。

    3. 对于较小的函数,全局定义日志记录器是有意义的。这有助于减少函数体内的视觉混乱:

       very_small_dbg_logger = logging.getLogger("debug.very_small") 
      
      def very_small(some_parameter: Any) -> Any: 
      
          very_small_dbg_logger.info("some_parameter= %r", some_parameter)
      

    注意,如果没有进一步的配置,将不会产生任何输出。这是因为每个日志记录器的默认严重级别将是 WARNING,这意味着处理程序将不会显示 INFO-或 DEBUG 级别的消息。

    13.6.3 它是如何工作的...

    引入日志记录到应用程序中有三个部分:

    • 使用 getLogger()函数创建 Logger 对象。

    • 使用类似于 info()或 error()的每个日志记录器的方法之一,将日志消息放置在重要的状态变化附近。

    • 当应用程序运行时,整体配置日志系统。这对于查看日志记录器的输出至关重要。我们将在本食谱的 There’s more...部分中探讨这一点。

    创建日志记录器可以通过多种方式完成。一种常见的方法是创建一个与模块同名的日志记录器:

    logger = logging.getLogger(__name__)
    

    对于顶级主脚本,这将具有 main 的名称。对于导入的模块,名称将与模块名称匹配。

    在更复杂的应用程序中,可能有各种日志记录器服务于各种目的。在这些情况下,仅仅将日志记录器命名为模块名称可能不足以提供所需级别的灵活性。

    还可以使用日志模块本身作为根记录器。这意味着一个模块可以使用 logging.info() 函数,例如。这不推荐,因为根记录器是匿名的,我们牺牲了使用记录器名称作为重要信息来源的可能性。

    这个配方建议根据受众或用例命名记录器。最顶层的名称——例如,debug.——将区分日志的受众或目的。这可以使将给定父记录器下的所有记录器路由到特定处理器变得容易。

    将日志消息与代码执行的重要状态变化相关联是有帮助的。

    记录的第三个方面是配置记录器,以便它们将请求路由到适当的目的地。默认情况下,如果没有进行任何配置,记录器实例将默默地忽略正在创建的各种消息。

    使用最小配置,我们可以在控制台上看到所有日志事件。这可以通过以下方式完成:

    if __name__ == "__main__": 
    
        logging.basicConfig(level=logging.INFO)
    

    13.6.4 更多内容...

    为了将不同的记录器路由到不同的目的地,我们需要更复杂的配置。通常,这超出了我们使用 basicConfig() 函数所能构建的内容。我们需要使用 logging.config 模块和 dictConfig() 函数。这可以提供完整的配置选项。使用此函数的最简单方法是使用 TOML 编写配置:

    version = 1 
    
    [formatters.default] 
    
        style = "{" 
    
        format = "{levelname}:{name}:{message}" 
    
    [formatters.timestamp] 
    
        style = "{" 
    
        format = "{asctime}//{levelname}//{name}//{message}" 
    
    [handlers.console] 
    
        class = "logging.StreamHandler" 
    
        stream = "ext://sys.stderr" 
    
        formatter = "default" 
    
    [handlers.file] 
    
        class = "logging.FileHandler" 
    
        filename = "data/write.log" 
    
        formatter = "timestamp" 
    
    [loggers] 
    
        overview_stats.detail = {handlers = ["console"]} 
    
        overview_stats.write = {handlers = ["file", "console"] } 
    
        root = {level = "INFO"}
    

    在这个 TOML 配置中,以下是一些关键点:

    • 版本键的值必须是 1。这是必需的。

    • 格式化程序表中的值定义了可用的日志格式。如果没有指定格式化程序,内置的格式化程序将只显示消息正文:

      • 示例中定义的默认格式化程序与 basicConfig() 函数创建的格式相匹配。这包括消息严重级别和记录器名称。

      • 示例中定义的新日期时间戳格式化程序是一个更复杂的格式,它包括记录的日期时间戳。为了使文件更容易解析,使用了 // 作为列分隔符。

    • 处理器表定义了记录器可用的处理器:

      • 控制台处理器写入 sys.stderr 流,并使用默认格式化程序。以 "ext://..." 开头的文本是配置文件如何引用在 Python 环境中定义的对象的方式——在这种情况下,来自 sys 模块的 sys.stderr 值。

      • 文件处理器使用 FileHandler 类将内容写入文件。打开文件的默认模式是 a,这将追加到任何现有的日志文件。配置指定了用于文件的日期时间戳格式化程序。

    • 记录器表为应用将使用的两个特定命名的记录器提供了配置。任何以 overview_stats.detail 开头的记录器名称将由控制台处理器处理。任何以 overview_stats.write 开头的记录器名称将同时发送到文件处理器和控制台处理器。

    • 特殊的根键定义了顶级记录器。在代码中引用时,它有一个名为 ""(空字符串)的名称。在配置文件中,它有根键。

      在根记录器上设置严重性级别将设置用于显示或隐藏此记录器所有子记录器消息的级别。这将显示严重性为 INFO 或更高的消息,包括警告、错误和严重错误。

    假设这个文件的内容存储在一个名为 config_toml 的变量中,包裹 main() 函数的配置将看起来像这样:

    if __name__ == "__main__": 
    
        logging.config.dictConfig( 
    
            tomllib.loads(config_toml)) 
    
        main() 
    
        logging.shutdown()
    

    这将启动日志记录到一个已知的状态。它将处理应用程序。它将最终化所有的日志缓冲区,并正确关闭任何文件。

    13.6.5 参考信息

    • 在本章前面的设计用于组合的脚本配方中查看,以了解此应用的补充部分。

    • 在本章中查看使用 TOML 作为配置文件的配方,了解更多关于解析 TOML 文档的信息。

    加入我们的社区 Discord 空间

    加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

    PIC

    第十四章:14

    应用集成:组合

    Python 语言被设计成允许扩展性。我们可以通过组合多个较小的组件来创建复杂的程序。在本章中,我们将探讨组合模块和脚本的方法。

    我们将探讨复合应用可能出现的复杂性以及集中一些功能(如命令行解析)的需求。这将使我们能够为各种密切相关程序创建统一的接口。

    我们将扩展第七章和第八章的一些概念,并将命令设计模式的思想应用到 Python 程序中。通过在类定义中封装特性,我们将发现组合和扩展程序更容易。

    在本章中,我们将探讨以下菜谱:

    • 将两个应用组合成一个

    • 使用命令设计模式组合多个应用

    • 在复合应用中管理参数和配置

    • 包装和组合 CLI 应用

    • 包装程序并检查输出

    我们将从将多个 Python 应用组合成一个更复杂的单一应用的方法开始。我们将扩展这一方法,应用面向对象设计技术,创建一个更加灵活的复合体。然后,我们将为复合应用应用统一的命令行参数解析。

    14.1 将两个应用组合成一个

    对于这个菜谱,我们将查看两个需要组合的脚本。一个脚本从马尔可夫链过程中输出数据,第二个脚本总结这些结果。

    这里重要的是,马尔可夫链应用(故意)有点神秘。为了几个菜谱的目的,我们将将其视为不透明的软件,可能是用另一种语言编写的。

    (本书的 GitHub 仓库中用 Pascal 编写了马尔可夫链,以便合理地保持其透明度。)

    以下是对马尔可夫链状态变化的描述:

    SSFGp””””””tuaro00000nacioi.....orclwn21610tteUt21668en21773(dte””””—faist0ila.lb1 o(l1rpi1oshpine—otd0i).n1t3)”9 ”

    图 14.1:马尔可夫链状态

    初始状态将成功、失败或生成一个“点”值。有多个值,每个值都有不同的概率,总和为 P = 0.667。GrowUntil 状态生成可能匹配点、不匹配点或指示失败的值。在非匹配和非失败的情况下,链会转回到此状态。匹配的确切概率取决于起始点值,这就是为什么状态转换被标记为三个概率的原因。

    生成器应用会输出一个包含一些配置细节和一系列单独样本的 TOML 格式文件。文件看起来像这样:

    # file = "../../data/ch14/data.csv" 
    
    # samples = 10 
    
    # randomize = 0 
    
    # ----- 
    
    outcome,length,chain 
    
    "Success",1,"7" 
    
    "Success",2,"10;10"
    

    摘要应用读取所有这些生成的文件,创建一些简单的统计数据来描述原始数据。这个总结最初是用 Jupyter Notebook 完成的。虽然这些可以通过 jupyter execute 命令执行,但另一种方法是保存 notebook 为脚本,然后执行该脚本。

    我们希望能够将这个生成器和摘要应用结合起来,以减少使用生成器时的手动步骤。结合多个应用有几种常见的方法:

    • 一个 shell 脚本可以运行生成器应用,然后运行摘要应用。

    • 一个 Python 程序可以实现高级操作,使用 runpy 模块运行这两个应用中的每一个。

    • 我们可以从每个应用的基本组件构建一个组合应用。

    在这个配方中,我们将查看通过编写一个新的组合应用来结合每个应用基本组件的第三条路径。

    14.1.1 准备工作

    在第十三章的设计组合脚本和使用日志进行控制和审计输出配方中,我们遵循了一个设计模式,该模式将输入收集、基本处理和输出生产分离。该设计模式的目标是将有趣的片段收集在一起,以组合和重新组合成更高级的结构。

    注意,这两个应用之间存在微小的不匹配。我们可以从数据库工程(以及电气工程)借用一个短语,称之为“阻抗不匹配”。

    在构建这个组合应用时,阻抗不匹配是一个基数问题。数据生成过程设计为比统计总结过程运行得更频繁。我们有几个选择来解决这个问题:

    • 完全重设计:我们可以将生成器重写为迭代器,按需生成多组样本。

    • 添加迭代器:我们可以构建一个组合应用来执行批量数据生成处理。所有数据生成完毕后,组合应用可以对其进行总结。

    在这些设计选择之间进行选择取决于这个应用的用户故事。它也可能取决于已经建立的用户基础。对于这个配方,用户希望遵循添加迭代器设计来创建一个组合过程,而不触及底层的生成器。

    在查看两个模块实现选择时,我们看到顶级应用有两个不同的设计模式:

    • markov_gen 模块有以下 main()函数定义:

      def main(argv: list[str] = sys.argv[1:]) -> None:
      
    • 另一方面,markov_summ 模块是一个脚本,从 notebook 导出。这个脚本不包含直接命令行界面(CLI),需要进行一些重写。有关详细信息,请参阅第十三章的设计组合脚本配方。

    为了创建一个更有用的脚本,我们需要添加一个 def main(): 行,并将整个脚本缩进到这个函数体内。在缩进的 main() 函数的末尾,可以添加 if name == "main": 块。如果没有创建一个可以被导入的函数,脚本将非常难以测试和集成。

    14.1.2 如何实现...

    1. 导入所需的其它模块:

      import argparse 
      
      import contextlib 
      
      import logging 
      
      from pathlib import Path 
      
      import sys
      
    2. 使用构成应用的应用导入模块。这通常在所有标准库模块之后完成:

      import markov_gen 
      
      import markov_summ 
      
    3. 创建一个新的函数来组合来自其他应用程序的现有函数。我们包括迭代在这个函数中,以满足生成 1,000 个样本文件的需求。它看起来像这样:

      def gen_and_summ(iterations: int, samples: int) -> None: 
      
          for i in range(iterations): 
      
              markov_gen.main( 
      
                  [ 
      
                      "--samples", str(samples), 
      
                      "--randomize", str(i + 1), 
      
                      "--output", f"data/ch14/markov_{i}.csv", 
      
                  ] 
      
              ) 
      
          markov_summ.main()
      
    4. 整体问题有两个参数,具有固定值:用户希望有 1,000 次迭代,每次 1,000 个样本。这提供了大量的大文件来工作。我们可以定义带有这些值的默认值的命令行参数:

      def get_options(argv: list[str]) -> argparse.Namespace: 
      
          parser = argparse.ArgumentParser(description="Markov Chain Generator and Summary") 
      
          parser.add_argument("-s", "--samples", type=int, default=1_000) 
      
          parser.add_argument("-i", "--iterations", type=int, default=10) 
      
          return parser.parse_args(argv)
      

      更多关于如何使用 argparse 模块的信息,请参阅第六章 6 中的食谱。

    5. 最终报告通过 markov_summ 应用程序中的 print() 函数发送到标准输出,sys.stdout。这并不理想,因此我们将使用 contextlib 上下文管理器将输出重定向到文件:

      def main(argv: list[str] = sys.argv[1:]) -> None: 
      
          options = get_options(argv) 
      
          target = Path.cwd() / "summary.md" 
      
          with target.open("w") as target_file: 
      
              with contextlib.redirect_stdout(target_file): 
      
                  gen_and_summ(options.iterations, options.samples)
      
    6. 现在的组合功能是一个新的模块,其中包含一个 main() 函数,我们可以从以下代码块中调用它:

      if __name__ == "__main__": 
      
          logging.basicConfig(stream=sys.stderr, level=logging.INFO) 
      
          main()
      

    这使我们得到了一个完全用 Python 编写的组合应用程序。我们可以为这个复合体编写单元测试,以及为构成整个应用程序的两个步骤编写测试。

    14.1.3 它是如何工作的...

    此设计的核心特性是从现有的、工作良好且经过测试的模块中导入有用的功能。这避免了复制粘贴编程的问题。从一个文件复制代码并将其粘贴到另一个文件意味着对其中一个所做的任何更改都不太可能应用到任何副本上。随着函数的各种副本逐渐分化,在一个地方修复的问题会在另一个地方出现。这种现象有时被称为代码腐化。

    当一个类或函数执行多项操作时,复制粘贴的方法会变得更加复杂。过多的特性会降低重用潜力。我们将此总结为重用逆幂律——一个类或函数的重用性,R(c),与该类或函数中特性数量的倒数,F(c),相关:

     1 R (c) ∝ F-(c)

    计数特性的想法当然取决于抽象级别。考虑将输入映射到输出的处理过程可能会有所帮助。过多的输入-处理-输出映射将限制重用。

    SOLID 设计原则为保持组件小而专注提供指导。这些原则适用于应用程序以及组件。特别是,单一职责原则建议应用程序应该只做一件事。拥有许多小型应用程序(如砖块)并容易组合,比拥有一个庞大而难以理解的应用程序要好。

    14.1.4 更多...

    我们将探讨应用程序的两个额外重工作业区域:

    • 结构:使用顶级 main() 函数将每个组件视为一个不透明的容器。在尝试创建复合应用程序时,我们可能需要重构组件模块以寻找更好的功能组织。

    • 记录日志:当多个应用程序组合在一起时,组合的日志可能会变得复杂。为了提高可观察性,我们可能需要重构日志。

    我们将依次进行这些操作。

    结构

    在某些情况下,重新排列软件以展示有用功能变得必要。例如,位于 markov_gen.py 模块内的 main() 函数依赖于 write_samples() 函数。此函数创建一个包含所需数量样本的单个文件,这些样本是 (结果, 链) 两个元组。

    摘要处理的输入是这些 (结果, 链) 两个元组的序列。复合应用程序实际上不需要处理 1,000 个单独的文件。它需要处理 1,000 个包含 1,000 个两个元组的集合。

    对此细节进行重构以展示此功能,将使复合应用程序可用。这可以使复合应用程序更容易理解和维护。

    记录日志

    在第十三章的 使用日志进行控制和审计输出 菜单中,我们探讨了如何使用日志模块进行控制、审计和错误输出。当我们构建复合应用程序时,我们必须结合来自每个原始应用程序的日志功能。

    复合应用程序的日志配置需要仔细检查。如果我们不确保在顶级应用程序中只进行一次日志配置,那么合并应用程序可能会导致多个、冲突的日志配置。复合应用程序可以遵循两种方法:

    • 复合应用程序管理日志配置。这可能意味着覆盖所有先前定义的日志记录器。这是默认行为,可以通过在 TOML 配置文档中明确 incremental = false 来表示。

    • 复合应用程序可以保留其他应用程序的日志记录器,仅修改配置。这不是默认行为,需要在 TOML 配置文档中包含 incremental = true。

    当组合 Python 应用程序,而这些应用程序没有正确地将日志配置隔离到代码块__name__ == "__main__"中时,使用增量配置可能会有所帮助。通常更容易重构日志配置,将其放入顶级代码块中;这允许复合应用程序更简单地配置所有组件的日志。

    14.1.5 参见

    • 在第十三章的设计用于组合的脚本菜谱中,我们探讨了可组合应用程序的核心设计模式。

    • 关于清洁架构和六边形架构的书籍和文章可能非常有帮助。关于设计模式的标题也有帮助,例如精通 Python 设计模式 - 第三版

    14.2 使用命令设计模式组合多个应用程序

    许多复杂的应用程序套件遵循与 Git 程序使用的设计模式类似的设计。有一个基本命令 git,以及多个子命令。这些包括 git pull、git commit 和 git push。

    这个设计的核心是这样一个想法:在共同的父命令下,有一系列单独的命令。Git 的每个不同功能都可以被视为一个执行特定功能的单独子类定义。

    14.2.1 准备工作

    我们将从两个命令构建一个复合应用程序。这是基于本章前面提到的将两个应用程序合并为一个菜谱。

    这些功能基于名为 markov_gen、markov_summ 和 markov_analysis 等模块。我们的想法是将单独的模块重构为一个遵循命令设计模式的单个类层次结构。

    这种设计模式有两个关键要素:

    1. 客户端类只依赖于抽象超类 Command 的方法。

    2. 命令超类的每个子类都具有相同的接口。我们可以用任何一个替换另一个。

    一个整体应用程序脚本可以创建和执行 Command 子类中的任何一个。

    注意,任何可以封装成类的功能都是这个设计的候选者。因此,有时需要重新设计以创建一个单一的 Facade 类,这对于设计不良、扩展过度的应用程序是必要的。

    14.2.2 如何做...

    我们将首先为所有相关命令创建一个超类。然后我们将扩展这个超类以包括整体应用程序中的子命令。

    1. 这里是 Command 超类:

      import argparse 
      
      class Command: 
      
          def __init__(self) -> None: 
      
              pass 
      
          def execute(self, options: argparse.Namespace) -> None: 
      
              pass 
      

      依赖于 argparse.Namespace 来为每个子类提供一个非常灵活的选项和参数集合是有帮助的。

      我们将在本章的“在复合应用程序中管理参数和配置”菜谱中使用这个方法。

    2. 为 Generate 类创建 Command 超类的子类。这将在这个类的 execute()方法中封装示例模块的处理和输出:

      from pathlib import Path 
      
      from typing import Any 
      
      import markov_gen 
      
      class Generate(Command): 
      
          def __init__(self) -> None: 
      
              super().__init__() 
      
              self.seed: Any | None = None 
      
              self.output: Path 
      
          def execute(self, options: argparse.Namespace) -> None: 
      
              self.output = Path(options.output) 
      
              with self.output.open("w") as target: 
      
                  markov_gen.write_samples(target, options) 
      
              print(f"Created {str(self.output)}")
      
    3. 为 Summarize 类创建 Command 超类的子类。对于这个类,我们将文件创建和文件处理封装到类的 execute() 方法中:

      import contextlib 
      
      import markov_summ_2 
      
      class Summarize(Command): 
      
          def execute(self, options: argparse.Namespace) -> None: 
      
              self.summary_path = Path(options.summary_file) 
      
              with self.summary_path.open("w") as result_file: 
      
                  output_paths = [Path(f) for f in options.output_files] 
      
                  outcomes, lengths = markov_summ_2.process_files(output_paths) 
      
                  with contextlib.redirect_stdout(result_file): 
      
                      markov_summ_2.write_report(outcomes, lengths)
      
    4. 整体组合处理可以通过以下 main()函数执行:

      def main() -> None: 
      
          options_1 = argparse.Namespace(samples=1000, output="data/x.csv") 
      
          command1 = Generate() 
      
          command1.execute(options_1) 
      
          options_2 = argparse.Namespace( 
      
              summary_file="data/report.md", output_files=["data/x.csv"] 
      
          ) 
      
          command2 = Summarize() 
      
          command2.execute(options_2)
      

    我们创建了两个命令:一个是 Generate 类的实例,另一个是 Summarize 类的实例。这些命令可以执行以提供生成和总结数据的组合功能。

    14.2.3 它是如何工作的...

    为各种子命令创建可互换的多态类是一种提供可扩展设计的便捷方式。Command 设计模式强烈鼓励每个单独的子类具有相同的签名。这样做使得创建和执行命令子类变得容易。此外,还可以添加符合此模式的新命令。

    SOLID 设计原则之一是 Liskov 替换原则(LSP)。它建议可以使用 Command 抽象类的任何子类来代替父类。

    每个 Command 实例都有一个一致的接口。使用 Command 设计模式使得确保 Command 子类可以相互替换变得容易。整体 main()脚本可以创建 Generate 或 Summarize 类的实例。替换原则意味着任何实例都可以执行,因为接口是相同的。这种灵活性使得解析命令行选项并创建可用类之一的实例变得容易。我们可以扩展这个想法,创建单个命令实例的序列。

    14.2.4 更多...

    这种设计模式的更常见扩展之一是提供组合命令。在将两个应用程序合并为一个的菜谱中,我们展示了创建组合的一种方法。这是另一种方法,基于定义一个新的 Command 类,该类实现了现有 Command 类实例的组合:

    class CmdSequence(Command): 
    
        def __init__(self, *commands: type[Command]) -> None: 
    
            super().__init__() 
    
            self.commands = [command() for command in commands] 
    
        def execute(self, options: argparse.Namespace) -> None: 
    
            for command in self.commands: 
    
                command.execute(options)
    

    本类将通过 *commands 参数接受其他 Command 类。从这些类中,它将构建单个类实例。

    我们可能像这样使用这个 CmdSequence 类:

    >>> from argparse import Namespace 
    
    >>> options = Namespace( 
    
    ...     samples=1_000, 
    
    ...     randomize=42, 
    
    ...     output="data/x.csv", 
    
    ...     summary_file="data/y.md", 
    
    ...     output_files=["data/x.csv"] 
    
    ... ) 
    
    >>> both_command = CmdSequence(Generate, Summarize) 
    
    >>> both_command.execute(options) 
    
    Created data/x.csv
    

    这种设计暴露了一些实现细节。特别是,两个类名和中间的 x.csv 文件似乎是多余的细节。

    如果我们专注于要组合的两个命令,我们可以创建一个稍微更好的 CmdSequence 子类。这个子类将有一个 init() 方法,遵循其他 Command 子类的模式:

    class GenSumm(CmdSequence): 
    
        def __init__(self) -> None: 
    
            super().__init__(Generate, Summarize) 
    
        def execute(self, options: argparse.Namespace) -> None: 
    
            self.intermediate = Path("data") / "ch14_r02_temporary.toml" 
    
            new_namespace = argparse.Namespace( 
    
                output=str(self.intermediate), 
    
                output_files=[str(self.intermediate)], 
    
                **vars(options), 
    
            ) 
    
            super().execute(new_namespace)
    

    这个类定义将两个其他类整合到已经定义的 CmdSequence 类结构中。super().init() 表达式调用父类初始化,并将 Generate 和 Summarize 类作为参数值。

    这提供了一个复合应用程序定义,它隐藏了如何使用文件从第一步传递数据到后续步骤的细节。这纯粹是复合集成的一个特性,不会导致构成复合的原应用程序中的任何变化。

    14.2.5 参见

    • 在第十三章的设计用于组合的脚本和使用日志进行控制和审计输出配方中,我们研究了这个复合应用程序的组成部分。

    • 在本章前面的将两个应用程序组合成一个配方中,我们研究了这个复合应用程序的组成部分。在大多数情况下,我们需要结合这些配方中的所有元素来创建一个有用的应用程序。

    • 我们将经常需要遵循在复合应用程序中管理参数和配置配方,该配方在本章的下一部分。

    • 对于其他高级设计模式,请参阅精通 Python 设计模式 – 第三版

    14.3 在复合应用程序中管理参数和配置

    当我们有一个复杂的单个应用程序套件(或系统)时,它们可能共享一些共同特性。在许多应用程序之间协调共同特性可能会变得尴尬。作为一个具体的例子,想象定义各种单字母缩略选项用于命令行参数。我们可能希望所有应用程序都使用-v 选项进行详细输出。确保所有应用程序之间没有冲突可能需要保留所有选项的某种主列表。

    这种常见的配置应该只保留在一个地方。理想情况下,它将是一个通用模块,在整个应用程序系列中使用。

    此外,我们通常希望将执行有用工作的模块与 CLI 分离。这让我们可以在不改变用户对如何使用应用程序的理解的情况下重构内部软件设计。

    在这个配方中,我们将探讨确保一系列应用程序可以重构而不会对 CLI 造成意外变化的方法。

    14.3.1 准备工作

    我们将想象一个由三个命令构建的应用程序套件。这是基于本章前面提到的将两个应用程序组合成一个配方中的应用程序。我们将有一个马尔可夫应用程序,具有三个子命令:马尔可夫 generate、马尔可夫 summarize 以及组合应用程序,马尔可夫 gensumm。

    我们将依赖于本章前面提到的使用命令设计模式配方组合多个应用程序的子命令设计。这将提供一个方便的命令子类层次结构:

    • 命令类是一个抽象超类。

    • 生成子类执行第十三章配方中的链生成函数,即 设计用于组合的脚本 配方。

    • Summarize 子类执行第十三章配方中的总结函数,即 使用日志进行控制和审计输出 配方。

    • GenSumm 子类可以执行结合链生成和总结,遵循 使用命令设计模式组合多个应用 配方的理念。

    为了创建一个简单的命令行应用程序,我们需要适当的参数解析。有关参数解析的更多信息,请参阅第六章 6。

    此参数解析将依赖于内置 argparse 模块的子命令解析能力。我们可以创建适用于所有子命令的通用命令选项集。我们还可以为每个不同的子命令创建独特选项。

    14.3.2 如何做到...

    这个配方将从考虑 CLI 命令的外观开始。第一个版本通常涉及一些原型或示例,以确保命令真正对用户有用。在了解用户的偏好后,我们可以更改在每个命令子类中实现参数定义的方式。

    1. 定义 CLI。这是一个用户体验(UX)设计练习。虽然大量的 UX 设计集中在网页和移动设备应用上,但核心原则也适用于 CLI 应用。之前我们提到,根应用将被命名为 markov。它将包含以下三个子命令:

      markov generate -o detail_file.csv -s samples 
      
      markov summarize -o summary_file.md detail_file.csv ... 
      
      markov gensumm -g samples
      

      gensumm 命令将生成和总结命令合并为单个操作,执行两项功能。

    2. 定义根 Python 应用程序。我们将它命名为 markov.py。通常有一个包含应用的包 main.py 文件。使用操作系统别名提供 UX 名称通常更简单。

    3. 我们将从 使用命令设计模式组合多个应用 配方中导入类定义。这包括命令超类以及 Generate、Summarize 和 GenSumm 子类。我们将通过添加一个额外的 arguments() 方法来扩展 Command 类,以设置此命令参数解析中的独特选项。这是一个类方法,在整个类上调用,而不是类的实例:

      class Command: 
      
          @classmethod 
      
          def arguments( 
      
                  cls, 
      
                  sub_parser: argparse.ArgumentParser 
      
          ) -> None: 
      
              pass 
      
          def __init__(self) -> None: 
      
              pass 
      
          def execute(self, options: argparse.Namespace) -> None: 
      
              pass
      
    4. 这里是生成子命令的独特选项。我们不会重复整个类定义,只重复新的 arguments() 方法。这创建了仅适用于 markov 生成子命令的独特参数:

      class Generate(Command): 
      
          @classmethod 
      
          def arguments( 
      
                  cls, 
      
                  generate_parser: argparse.ArgumentParser 
      
          ) -> None: 
      
              default_seed = os.environ.get("RANDOMSEED", "0") 
      
              generate_parser.add_argument( 
      
                  "-s", "--samples", type=int, default=1_000) 
      
              generate_parser.add_argument( 
      
                  "-o", "--output", dest="output") 
      
              generate_parser.add_argument( 
      
                  "-r", "--randomize", default=default_seed) 
      
              generate_parser.set_defaults(command=cls)
      
    5. 这里是 Summarize 子命令的新 arguments() 方法:

      class Summarize(Command): 
      
          @classmethod 
      
          def arguments( 
      
                  cls, 
      
                  summarize_parser: argparse.ArgumentParser 
      
          ) -> None: 
      
              summarize_parser.add_argument( 
      
                  "-o", "--output", dest="summary_file") 
      
              summarize_parser.add_argument( 
      
                  "output_files", nargs="*", type=Path) 
      
              summarize_parser.set_defaults(command=cls)
      
    6. 这里是复合命令 GenSumm 的新 arguments() 方法:

      class GenSumm(Command): 
      
          @classmethod 
      
          def arguments( 
      
                  cls, 
      
                  gensumm_parser: argparse.ArgumentParser 
      
          ) -> None: 
      
              default_seed = os.environ.get("RANDOMSEED", "0") 
      
              gensumm_parser.add_argument( 
      
                  "-s", "--samples", type=int, default=1_000) 
      
              gensumm_parser.add_argument( 
      
                  "-o", "--output", dest="summary_file.md") 
      
              gensumm_parser.add_argument( 
      
                  "-r", "--randomize", default=default_seed) 
      
              gensumm_parser.set_defaults(command=cls)
      
    7. 创建整体参数解析器。使用它来创建子解析器构建器。对于每个子命令,创建一个子解析器并添加仅适用于该命令的独特参数:

      import argparse 
      
      def get_options( 
      
              argv: list[str] 
      
      ) -> argparse.Namespace: 
      
          parser = argparse.ArgumentParser(prog="Markov") 
      
          subparsers = parser.add_subparsers() 
      
          generate_parser = subparsers.add_parser("generate") 
      
          Generate.arguments(generate_parser) 
      
          summarize_parser = subparsers.add_parser("summarize") 
      
          Summarize.arguments(summarize_parser) 
      
          gensumm_parser = subparsers.add_parser("gensumm") 
      
          GenSumm.arguments(gensumm_parser)
      
    8. 解析命令行值。在大多数情况下,参数定义包括验证规则。在这种情况下,还有一个额外的验证检查以确保提供了一个命令。以下是最终的解析和验证步骤:

          options = parser.parse_args(argv) 
      
          if "command" not in options: 
      
              parser.error("No command selected") 
      
          return options
      

    整体解析器包括三个子命令解析器。一个将处理 markov generate 命令,另一个处理 markov summarize,第三个处理 combined markov gensumm。每个子命令都有略微不同的选项组合。

    命令选项是通过 set_defaults()方法设置的。这也为要执行的命令提供了有用的附加信息。在这种情况下,我们提供了必须实例化的类。

    整个应用程序由以下 main()函数定义:

    from typing import cast 
    
    def main(argv: list[str] = sys.argv[1:]) -> None: 
    
        options = get_options(argv) 
    
        command = cast(type[Command], options.command)() 
    
        command.execute(options)
    

    生成的对象将有一个 execute()方法,它执行这个命令的实际工作。

    14.3.3 它是如何工作的...

    这个配方有两个部分:

    • 使用 Command 设计模式定义一组相关的类,这些类是多态的。有关更多信息,请参阅使用 Command 设计模式组合多个应用程序配方。

    • 使用 argparse 模块的功能来处理子命令。

    在这里重要的 argparse 模块功能是解析器的 add_subparsers()方法。这个方法返回一个对象来构建每个不同的子命令解析器。我们将这个对象分配给了 subparsers 变量。

    我们还使用了解析器的 set_defaults()方法为每个子解析器添加一个命令参数。这个参数将由一个子解析器定义的默认值填充。set_defaults()方法实际使用的赋值将显示调用了哪个子命令。

    考虑以下操作系统命令:

    (cookbook3) % markov generate -s 100 -o x.csv
    

    这个命令将被解析以创建一个类似于以下内容的 Namespace 对象:

    Namespace(command=<class ’__main__.Generate’>, output=’x.csv’, samples=100)
    

    Namespace 对象中的 command 属性是作为子命令定义的一部分提供的默认值。输出和样本的值来自-o 和-g 选项。

    14.3.4 更多...

    get_options()函数有一个显式的类列表,它被整合到整体命令中。如所示,有大量的代码行被重复,这可以被优化。我们可以提供一个数据结构来替换大量的代码行:

    def get_options_2(argv: list[str] = sys.argv[1:]) -> argparse.Namespace: 
    
        parser = argparse.ArgumentParser(prog="markov") 
    
        subparsers = parser.add_subparsers() 
    
        sub_commands = [ 
    
            ("generate", Generate), 
    
            ("summarize", Summarize), 
    
            ("gensumm", GenSumm), 
    
        ] 
    
        for name, subc in sub_commands: 
    
            cmd_parser = subparsers.add_parser(name) 
    
            subc.arguments(cmd_parser) 
    
        # The parsing and validating remains the same...
    

    这个对 get_options()函数的变体使用一系列的二元组来提供命令名称和实现该命令的相关类。遍历这个列表确保 Command 类的所有各种子类都以完全统一的方式被处理。

    14.3.5 参见

    • 请参阅第十三章中的设计用于组合的脚本和使用日志进行控制和审计输出配方,以了解专注于可组合性应用程序构建的基础。

    • 请参阅本章前面提到的将两个应用程序合并为一个配方,以了解本配方中使用的组件的背景信息。

    • 请参阅第六章的使用 argparse 获取命令行输入配方,了解更多关于参数解析的背景信息。

    • 其他用于创建 CLIs 的工具包括clickhydrainvoke

    14.4 包装和组合 CLI 应用程序

    一种常见的自动化类型涉及运行多个程序,其中一些不是 Python 应用程序。这种情况通常出现在集成多个工具时,这些工具通常是用于构建应用程序或文档的应用程序。由于程序不是用 Python 编写的,因此不可能对每个程序进行重构以创建一个复合 Python 应用程序。当使用非 Python 应用程序时,我们无法遵循本章前面展示的将两个应用程序合并为一个的配方。

    代替聚合 Python 组件,另一种选择是将其他程序用 Python 包装起来,创建一个复合应用程序。这种用例与编写 shell 脚本的用例非常相似。区别在于使用 Python 而不是 shell 语言。使用 Python 有一些优点:

    • Python 拥有丰富的数据结构集合。大多数 shell 语言仅限于字符串和字符串数组。

    • Python 有几个出色的单元测试框架。严格的单元测试使我们确信组合的应用程序将按预期工作。

    在这个配方中,我们将探讨如何在 Python 内部运行其他应用程序。

    14.4.1 准备工作

    在第十三章的设计用于组合的脚本配方中,我们确定了一个进行了一些处理并导致产生相当复杂结果的应用程序。为了本配方的目的,我们假设该应用程序不是用 Python 编写的。

    我们希望运行这个程序数千次,但不想将必要的命令复制粘贴到脚本中。此外,由于 shell 难以测试并且数据结构很少,我们希望避免使用 shell。

    对于这个配方,我们将以一个原生二进制应用程序(用 Rust、Go 或 Pascal 编写)的方式与一个应用程序合作。有两种方法可以探索这一点:

    • 本书 Git 仓库中的 markov_gen.pas 文件可以用来构建一个工作原生的二进制应用程序。Free Pascal Compiler 项目(www.freepascal.org)为大量平台提供了编译器。

    • 另一个常见的情况是需要使用 jupyter execute 命令执行一个 Jupyter 笔记本。我们无法直接导入笔记本,但必须通过一个单独的命令来执行它。

    另一种可以帮助探索这些设计选择的替代方案是让 Python 应用程序表现得像一个二进制可执行文件,通过在文件的第一行添加一个 shebang 行来实现。在许多情况下,以下内容可以用作 Python 脚本的第一行:

    #!/usr/bin/env python
    

    对于 macOS 和 Linux,使用以下命令更改文件的模式为可执行:

    % chmod +x your_application_file.py
    

    与原生二进制应用程序一起工作意味着我们无法导入包含应用程序的 Python 模块。相反,应用程序作为单独的 OS 进程运行。这限制了交互仅限于命令行参数值和 OS 环境变量。

    要运行原生二进制应用程序,我们使用 subprocess 模块。在 Python 中运行另一个程序有两种常见的设计模式:

    • 另一个程序不产生任何输出,或者我们不想在我们的 Python 程序中收集输出。第一种情况是典型的 OS 实用程序,当它们成功或失败时返回状态码。第二种情况是典型的更新文件并生成日志的程序。

    • 另一个程序产生输出;Python 包装器需要捕获并处理它。这可能发生在 Python 包装器需要采取额外措施来清理或重试失败的情况下。

    在这个菜谱中,我们将探讨第一个案例:输出不是我们需要捕获的内容。在包装程序并检查输出菜谱中,我们将探讨第二个案例,其中输出将由 Python 包装程序仔细检查。

    在许多情况下,使用 Python 包装现有应用程序的一个好处是能够彻底重新思考 UX。这让我们能够重新设计 CLI,使其更好地满足用户的需求。

    让我们看看如何包装通常使用 src/ch14/markov_gen 命令启动的程序。以下是一个示例:

    (cookbook3) % src/ch14/markov_gen -o data/ch14_r04.csv -s 100 -r 42 
    
    # file = "data/ch14_r04.csv" 
    
    # samples = 100 
    
    # randomize = 42
    

    输出文件名需要灵活,这样我们就可以运行程序数百次。这通常是通过将序列号插入文件名中实现的。例如,在 Python 中使用 f"data/ch14/samples_{n}.csv"来创建唯一的文件名。

    14.4.2 如何实现...

    在这个菜谱中,我们将首先创建一个小型演示应用程序。这是一个峰值解决方案(wiki.c2.com/?SpikeSolution)。这将用来确保我们理解其他应用程序的工作方式。一旦我们有了正确的 OS 命令,我们就可以将其包装在函数调用中,使其更容易使用:

    1. 导入 argparse、subprocess 模块和 Path 类。我们还需要 sys 模块:

      import argparse 
      
      import subprocess 
      
      from pathlib import Path 
      
      import sys
      
    2. 使用 subprocess 模块调用目标应用程序来编写核心处理。这可以单独测试以确保它可以执行应用程序。在这种情况下,subprocess.run()将执行给定的命令,并且如果状态非零,check=True 选项将引发异常。以下是一个演示基本处理的峰值解决方案:

       directory, n = Path("/tmp"), 42 
      
          filename = directory / f"sample_{n}.csv" 
      
          command = [ 
      
              "markov_gen", 
      
              "--samples", "10", 
      
              "--output", str(filename), 
      
          ] 
      
          subprocess.run(command, check=True)
      

      这个最小峰值可以运行以确保一切正常,然后再对峰值进行重构,使其更有用。

    3. 将峰值解决方案封装在一个反映所需行为的函数中。处理过程如下:

       def make_files(directory: Path, files: int = 100) -> None: 
      
          for n in range(files): 
      
              filename = directory / f"sample_{n}.csv" 
      
              command = [ 
      
                  "markov_gen", 
      
                  "--samples", "10", 
      
                  "--output", str(filename), 
      
              ] 
      
              subprocess.run(command, check=True)
      
    4. 编写一个解析命令行选项的函数。在这种情况下,有两个位置参数:一个目录和要生成的链样本数量。该函数如下所示:

      def get_options(argv: list[str]) -> argparse.Namespace: 
      
          parser = argparse.ArgumentParser() 
      
          parser.add_argument("directory", type=Path) 
      
          parser.add_argument("samples", type=int) 
      
          options = parser.parse_args(argv) 
      
          return options
      
    5. 编写一个主函数来进行解析和处理:

      def main(argv: list[str] = sys.argv[1:]) -> None: 
      
          options = get_options(argv) 
      
          make_files(options.directory, options.samples)
      

    现在我们有一个可以使用任何 Python 单元测试框架进行测试的函数。这可以让我们对基于现有非 Python 应用程序构建的可靠应用程序有真正的信心。

    14.4.3 它是如何工作的...

    subprocess 模块是 Python 运行其他程序的方式。run() 函数为我们做了很多事情。

    在 POSIX(如 Linux 或 macOS)环境中,步骤类似于以下序列:

    1. 准备子进程的 stdin、stdout 和 stderr 文件描述符。

    2. 调用一个类似于 os.execve() 函数的函数来启动子进程。

    3. 等待子进程完成并收集最终状态。

    操作系统 shell(如 bash)隐藏了这些细节,不让应用程序开发者和用户知道。同样,subprocess.run() 函数也隐藏了创建和等待子进程的细节。

    使用 subprocess 模块运行独立的可执行文件,允许 Python 将各种软件组件集成到一个统一的整体中。使用 Python 提供的数据结构集合比 shell 更丰富,提供了适当的异常处理而不是检查最终状态码,以及单元测试的方法。

    14.4.4 更多内容...

    我们将向这个脚本添加一个简单的清理功能。想法是所有输出文件都应该作为一个原子操作创建。

    为了清理,我们需要将核心处理封装在 try: 块中。我们将编写第二个函数,make_files_clean(),它使用原始的 make_files() 函数来包含清理功能。新的整体函数 make_files_clean() 将如下所示:

    def make_files_clean(directory: Path, files: int = 100) -> None: 
    
        """Create sample data files, with cleanup after a failure.""" 
    
        try: 
    
            make_files(directory, files) 
    
        except subprocess.CalledProcessError as ex: 
    
            # Remove any files. 
    
            for partial in directory.glob("sample_*.csv"): 
    
                partial.unlink() 
    
            raise
    

    异常处理块执行两项操作。首先,它从当前工作目录中删除任何不完整的文件。其次,它重新抛出原始异常,以便失败会传播到客户端应用程序。

    对于这个应用程序的任何测试用例都需要使用模拟对象。请参阅第十五章中的 模拟外部资源 菜谱。

    14.4.5 参见

    • 这种自动化通常与其他 Python 处理结合使用。请参阅第十三章中的 设计用于组合的脚本 菜谱。

    • 目标通常是创建一个组合应用程序;请参阅本章前面提到的 在组合应用程序中管理参数和配置 菜谱。

    • 关于这个配方的变体,请参阅本章接下来的 包装程序并检查输出 配方。

    14.5 包装程序并检查输出

    一种常见的自动化类型涉及包装程序。Python 包装器的优点是能够对输出文件进行详细的聚合和分析。Python 程序可能对子进程的输出进行转换、过滤或总结。

    在这个配方中,我们将了解如何在 Python 中运行其他应用,并收集和处理输出。

    14.5.1 准备工作

    在第十三章的 设计脚本以进行组合 配方中,我们确定了一个进行了一些处理的应用,导致创建了一个相当复杂的结果。我们希望运行这个程序几百次,但不想将必要的命令复制粘贴到脚本中。此外,由于 shell 难以测试并且数据结构很少,我们希望避免使用 shell。

    对于这个配方,我们将使用用某些编译语言(如 Ada、Fortran 或 Pascal)编写的本地二进制应用。这意味着我们无法简单地导入包含该应用的 Python 模块。相反,我们将通过使用 subprocess 模块运行一个单独的操作系统进程来执行此应用。在 Python 中运行另一个二进制程序有两个常见的用例:

    • 要么没有输出,要么我们不希望在我们的 Python 程序中处理输出文件。

    • 我们需要捕获并可能分析输出以检索信息或确定成功的程度。我们可能需要转换、过滤或总结日志输出。

    在这个配方中,我们将探讨第二种情况:必须捕获和总结输出。在本章的 包装和组合 CLI 应用 配方中,我们探讨了第一种情况,即输出被简单地忽略。

    这里是一个运行 markov_gen 应用的示例:

    (cookbook3) % RANDOMSEED=42 src/ch14/markov_gen --samples 5 --output t.csv 
    
    # file = "t.csv" 
    
    # samples = 5 
    
    # randomize = 42
    

    有三条输出行被写入到操作系统标准输出文件中,所有都是以 # 开头的。这些显示了正在创建的文件、样本数量以及正在使用的随机数生成器种子。这是数据正确创建的确认。

    我们希望从该应用中捕获这些行的详细信息并进行总结。所有生成的样本总数应与总结的样本数相匹配,以确认所有数据都已处理。

    14.5.2 如何做...

    我们将首先创建一个尖峰解决方案(wiki.c2.com/?SpikeSolution)来确认运行另一个应用所需的命令和参数。我们将把这个尖峰解决方案转换成一个函数,以捕获输出以供进一步分析。

    1. 导入 argparse 和 subprocess 模块以及 Path 类。我们还需要 sys 模块和 Any 类型提示:

      import argparse 
      
      from collections.abc import Iterable, Iterator 
      
      from pathlib import Path 
      
      import subprocess 
      
      import sys 
      
      from typing import Any
      
    2. 编写核心处理,使用子进程模块调用目标应用程序。以下是一个演示基本处理的实验解决方案:

       directory, n = Path("/tmp"), 42 
      
          filename = directory / f"sample_{n}.toml" 
      
          temp_path = directory / "stdout.txt" 
      
          command = [ 
      
              "src/ch14/markov_gen", 
      
              "--samples", "10", 
      
              "--output", str(filename), 
      
          ] 
      
          with temp_path.open("w") as temp_file: 
      
              process = subprocess.run( 
      
                  command, 
      
                  stdout=temp_file, check=True, text=True 
      
              ) 
      
          output_text = temp_path.read_text()
      

      这个实验做了两件事:它为子进程构建了一个复杂的命令行,并收集了子进程的输出。一个临时文件允许子进程模块运行创建一个非常大文件的进程。

      策略是创建一个具有这种最小实验的脚本,确保一切正常后再进行重构,使其变得更有用。

    3. 重构代码以创建一个运行命令并收集输出的函数。以下是一个command_output()函数的示例:

      def command_output( 
      
          temporary: Path, command: list[str] 
      
      ) -> str: 
      
          temp_path = temporary / "stdout" 
      
          with temp_path.open("w") as temp_file: 
      
              subprocess.run( 
      
                  command, 
      
                  stdout=temp_file, check=True, text=True 
      
              ) 
      
          output_text = temp_path.read_text() 
      
          temp_path.unlink() 
      
          return output_text
      
    4. 将实验的其余部分重构为生成命令的函数。这个函数作为生成器是有意义的,因为它可以创建一系列类似的命令。

      def command_iter(options: argparse.Namespace) -> Iterable[list[str]]: 
      
          for n in range(options.iterations): 
      
              filename = options.directory / f"sample_{n}.csv" 
      
              command = [ 
      
                  "src/ch14/markov_gen", 
      
                  "--samples", str(options.samples), 
      
                  "--output", str(filename), 
      
                  "--randomize", str(n+1), 
      
              ] 
      
              yield command
      
    5. 定义一个函数来解析命令的预期输出。我们将解析分解为一系列生成器,这些生成器创建正则表达式匹配对象,提取匹配的组,并构建反映内容的最终字典。该函数可能看起来像这样:

      def parse_output(result: str) -> dict[str, Any]: 
      
          matches = ( 
      
              re.match(r"^#\s*([^\s=]+)\s*=\s*(.*?)\s*$", line) 
      
              for line in result.splitlines() 
      
          ) 
      
          match_groups = ( 
      
              match.groups() 
      
              for match in matches 
      
              if match 
      
          ) 
      
          summary = { 
      
              name: value 
      
              for name, value in match_groups 
      
          } 
      
          return summary
      
    6. 这是提取命令输出中有用信息的高级函数。生成器函数看起来像这样:

      def summary_iter(options: argparse.Namespace) -> Iterator[dict[str, Any]]: 
      
          commands = command_iter(options) 
      
          with tempfile.TemporaryDirectory() as tempdir: 
      
              results = ( 
      
                  command_output(Path(tempdir), cmd) 
      
                  for cmd in commands 
      
              ) 
      
              for text in results: 
      
                  yield parse_output(text)
      

      这个函数将使用生成器表达式栈。有关更多背景信息,请参阅第九章中的使用堆叠的生成器表达式配方。由于这些都是生成器表达式,每个单独的结果都单独处理。这可以允许大文件一次消化为一个小摘要。

    7. 编写一个函数来解析命令行选项。在这种情况下,目标目录是一个位置参数,每个文件中的样本数量和要生成的文件数量是选项。该函数看起来像这样:

      def get_options(argv: list[str]) -> argparse.Namespace: 
      
          parser = argparse.ArgumentParser() 
      
          parser.add_argument("directory", type=Path) 
      
          parser.add_argument("-s", "--samples", type=int, default=1_000) 
      
          parser.add_argument("-i", "--iterations", type=int, default=10) 
      
          options = parser.parse_args(argv) 
      
          return options
      
    8. 将解析和执行组合到一个主函数中:

      def main(argv: list[str] = sys.argv[1:]) -> None: 
      
          options = get_options(argv) 
      
          parsed_results = list(summary_iter(options)) 
      
          print(f"Built {len(parsed_results)} files") 
      
          # print(parsed_results) 
      
          total = sum( 
      
              int(rslt[’samples’]) for rslt in parsed_results 
      
          ) 
      
          print(f"Total {total} samples")
      

    现在,我们可以运行这个新应用程序,使其执行底层应用程序并收集输出,从而生成有用的摘要。我们使用 Python 而不是 bash(或其他 shell)脚本构建了它。我们可以利用 Python 的数据结构和单元测试。

    14.5.3 它是如何工作的...

    子进程模块是 Python 程序如何在给定计算机上运行其他程序的方式。有关更多背景信息,请参阅本章中的包装和组合 CLI 应用程序配方。

    子进程模块为我们提供了访问操作系统最重要的部分之一:启动子进程并收集输出。底层操作系统可以将输出直接定向到控制台、文件或通过“管道”到另一个进程。启动子进程时的默认行为是继承父进程定义的三个标准文件:stdin、stdout 和 stderr。在这个配方中,我们用文件替换了默认的 stdout 分配,使我们能够收集(并分析)本应输出到控制台的输出。

    14.5.4 更多...

    一旦我们将 markov_gen 二进制应用程序封装在 Python 应用程序中,我们就有了许多可供选择的方案来改进输出。

    由于我们已经封装了底层应用程序,我们不需要更改此代码来更改其产生的结果。我们可以修改我们的包装程序,同时保持原始数据生成器不变。

    我们可以将 main() 函数重构,用处理过程替换 print() 函数,以创建更实用的格式。可能的重写将生成包含详细生成器信息的 CSV 文件:

    import csv 
    
    def main_2(argv: list[str] = sys.argv[1:]) -> None: 
    
        options = get_options(argv) 
    
        total_counter = 0 
    
        wtr = csv.DictWriter(sys.stdout, ["file", "samples", "randomize"]) 
    
        wtr.writeheader() 
    
        for summary in summary_iter(options): 
    
            wtr.writerow(summary) 
    
            total_counter += int(summary["samples"]) 
    
        wtr.writerow({"file": "TOTAL", "samples": total_counter})
    

    文件列表和样本数量可以用于为模型训练目的划分数据。

    我们可以通过创建功能层来分别构建有用的功能。在不修改底层应用程序的情况下,可以帮助我们执行回归测试,以确保核心统计有效性没有被添加新功能所损害。

    14.5.5 参见

    • 参见本章早些时候的 Wrapping and combining CLI applications 菜单,了解另一种实现此菜单的方法。

    • 这种自动化通常与其他 Python 处理结合使用。请参阅第十三章的 Designing scripts for composition 菜单。

    • 目标通常是创建一个组合应用程序;参见本章早些时候的 Managing arguments and configuration in composite applications 菜单。

    • 许多实际应用将支持更复杂的输出格式。有关处理复杂行格式的信息,请参阅第一章的 String parsing with regular expressions 菜单和第十一章的 Reading complex formats using regular expressions 菜单。第十一章的大部分内容与解析输入文件的细节相关。

    • 关于进程间通信的更多信息,请参阅 The Linux Documentation Project: Interprocess Communication Mechanisms

    加入我们的社区 Discord 空间

    加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

    PIC

    第十五章:15

    测试

    测试对于创建可工作的软件至关重要。以下是描述测试重要性的经典陈述:

    “任何没有自动化测试的程序功能实际上是不存在的”。

    (Kent Beck,《极限编程解释:拥抱变化》)

    我们可以区分几种测试类型:

    单元测试:

    这适用于独立的软件单元:函数、类或模块。单元在隔离状态下进行测试,以确认其正确性。

    集成测试
    这将组合单位以确保它们正确集成。
    系统测试
    这测试整个应用程序或一系列相互关联的应用程序,以确保软件组件套件正常工作。这也被称为端到端测试或功能测试。这通常用于验收测试,以确认软件适合使用。
    性能测试
    这确保了单元、子系统或整个系统满足性能目标(也常称为负载测试)。在某些情况下,性能测试包括对资源(如内存、线程或文件描述符)的研究。目标是确保软件适当地使用系统资源。这有时被称为基准测试,当目标是衡量资源使用而不是确保使用低于某个阈值时。

    这些是一些更常见的类型。在本章中,我们将重点关注单元测试,因为它是创建对软件可靠工作的信任的基础。其他测试形式建立在合理完整的单元测试的基础上。

    有时根据 Gherkin 语言总结测试场景是有帮助的。在这种测试规范语言中,每个场景都由 GIVEN-WHEN-THEN 步骤描述。以下是一个示例:

    Scenario: Binomial coefficient typical case. 
    
    Given n = 52 
    
    And k = 5 
    
    When The binomial coefficient is computed with c = binom(n, k) 
    
    Then the result, c, is 2,598,960
    

    这种编写测试的方法描述了给定的起始状态或安排,要执行的操作,以及关于操作后结果状态的一个或多个断言。这有时被称为“安排-执行-断言”模式。

    在本章中,我们将探讨以下食谱:

    • 使用 docstrings 进行测试

    • 测试引发异常的函数

    • 处理常见的 doctest 问题

    • 使用 unittest 模块进行单元测试

    • 结合 unittest 和 doctest 测试

    • 使用 pytest 模块进行单元测试

    • 结合 pytest 和 doctest 测试

    • 测试涉及日期或时间的事物

    • 测试涉及随机性的事物

    • 模拟外部资源

    我们将从在模块、类或函数的 docstring 中包含测试开始。这使得测试案例既充当设计意图的文档,也充当可验证的确认,确实按广告宣传的方式工作。

    15.1 使用文档字符串进行测试

    良好的 Python 代码在每个模块、类、函数和方法中都包含文档字符串。许多工具可以从文档字符串中创建有用、信息丰富的文档。请参考第三章(ch007_split_000.xhtml#x1-1610003)中关于如何创建文档字符串的示例菜谱 Writing clear documentation strings with RST markup。

    文档字符串的一个重要元素是具体的示例。文档字符串中提供的示例可以成为由 Python 的 doctest 工具执行的单元测试用例。

    在这个菜谱中,我们将探讨如何将示例转换为合适的自动化测试用例。

    15.1.1 准备工作

    我们将查看一个小的函数定义以及一个类定义。每个都将包含包含可以用于自动化测试的示例的文档字符串。

    我们将使用一个函数来计算两个数字的二项式系数。它显示了从大小为 k 的组中取 n 件事物的组合数。例如,如何计算一副 52 张牌被分成 5 张牌的手牌的方法如下:

    公式

    这可以通过一个类似这样的 Python 函数来实现:

    from math import factorial 
    
    def binom_draft(n: int, k: int) -> int: 
    
        return factorial(n) // (factorial(k) * factorial(n - k))
    

    通常,函数没有内部状态,这使得像这样的函数相对容易测试。这将是用于展示可用的单元测试工具的示例之一。

    我们还将查看一个使用一组数字的平均值和中位数进行懒计算的类。对象通常具有由各种 self. 属性定义的内部状态。状态变化通常是困难的。这与第七章(ch011_split_000.xhtml#x1-3760007)中展示的类类似。Designing classes with lots of processing 和 Using properties for lazy attributes 菜谱中都有类似的类。

    下面是 Summary 类的概述,省略了一些实现细节:

    from collections import Counter 
    
    class Summary: 
    
       def __init__(self) -> None: 
    
           self.counts: Counter[int] = collections.Counter() 
    
       def __str__(self) -> str: 
    
           ... 
    
       def add(self, value: int) -> None: 
    
           self.counts[value] += 1 
    
       @property 
    
       def mean(self) -> float: 
    
           ... 
    
       @property 
    
       def median(self) -> float: 
    
            ...
    

    add() 方法改变了 Summary 对象的内部状态。由于这个状态变化到 self.counts 属性,我们需要提供更复杂的示例来展示 Summary 类的实例是如何表现的。

    15.1.2 如何做...

    在这个菜谱中,我们将展示两种变体。第一种变体可以应用于 binom() 函数这样的函数,其中没有具有可变状态的对象。第二种更适合状态操作,如 Summary 类。我们将一起查看它们,因为它们非常相似,尽管它们适用于不同类型的应用。

    为函数编写示例

    这个菜谱首先创建函数的文档字符串,然后添加一个示例来说明函数的工作方式:

    1. 在文档字符串中开始一个总结:

      def binom(n: int, k: int) -> int: 
      
          """ 
      
          Computes the binomial coefficient. 
      
          This shows how many combinations exist of 
      
          *n* things taken in groups of size *k*.
      
    2. 包含参数定义和返回值定义:

       :param n: size of the universe 
      
          :param k: size of each subset 
      
          :returns: the number of combinations
      
    3. 在 Python 的 >>> 提示符下模拟使用该函数的示例:

       >>> binom(52, 5) 
      
          2598960
      
    4. 使用适当的引号关闭文档字符串:

       """
      

    为状态对象编写示例

    这个配方也是从编写文档字符串开始的。文档字符串将展示使用有状态对象来展示对象内部状态变化的几个步骤:

    1. 编写一个类级别的文档字符串,并包含一个摘要。这有助于在 doctest 示例之前留下一些空白行:

      class Summary: 
      
          """ 
      
          Computes summary statistics.
      
    2. 使用类如何工作的具体示例扩展类级别的文档字符串。在这种情况下,我们将展示 add()方法如何设置对象的状态。我们还将展示如何查询对象的状态:

       >>> s = Summary() 
      
          >>> s.add(8) 
      
          >>> s.add(9) 
      
          >>> s.add(9) 
      
          >>> round(s.mean, 2) 
      
          8.67 
      
          >>> s.median 
      
          9 
      
          >>> print(str(s)) 
      
          mean = 8.67 
      
          median = 9
      
    3. 使用三引号结束这个类的文档字符串:

       """
      

    因为这个例子使用了浮点值,所以我们已经在文档字符串示例中对平均值的结果进行了四舍五入。浮点值可能在所有平台上不具有相同的文本表示,并且精确的相等性测试可能会意外失败。

    运行测试

    当我们运行 doctest 程序时,我们通常会得到一个静默的响应,因为测试通过了。

    交互看起来像这样:

    (cookbook3) % python -m doctest recipe_01.py
    

    当测试通过时,没有输出。我们可以添加一个-v 命令行选项来查看运行测试的枚举。这有助于确认模块中找到了所有测试。

    当某件事不工作时会发生什么?我们将修改一个测试用例,使其有错误答案并强制失败。当我们运行 doctest 程序时——使用一个损坏的测试用例——我们会看到如下输出:

    (cookbook3) % python -m doctest recipe_01.py 
    
    ********************************************************************** 
    
    File "/Users/slott/Documents/Writing/Python/Python Cookbook 3e/src/ch15/recipe_01.py", line 29, in recipe_01.Summary 
    
    Failed example: 
    
        s.median 
    
    Expected: 
    
        10 
    
    Got: 
    
        9 
    
    ********************************************************************** 
    
    1 items had failures: 
    
       1 of   7 in recipe_01.Summary 
    
    ***Test Failed*** 1 failures.
    

    这显示了错误所在的位置。它显示了测试示例中的预期值,以及未能匹配预期答案的实际答案。通常——没有使用-v 选项——沉默意味着所有测试都成功通过。

    15.1.3 它是如何工作的...

    doctest 模块包括一个主程序以及几个函数,这些函数会扫描 Python 文件中的示例。扫描操作会寻找具有 Python REPL 特征模式的文本块:一个带有代码的>>>提示符,随后是显示代码响应的行,然后是一个空白行来结束示例输出。显然,这些必须格式化得与找到的 Python REPL 输出完全匹配。

    doctest 解析器从提示行和响应文本块创建一个小测试用例对象。有三种常见情况:

    • 没有预期的响应文本:我们在定义 Summary 类的 add()方法测试时看到了这种模式。

    • 单行响应文本:这由 Summary 类的 binom()函数和 mean()方法所体现。

    • 多行响应:响应由下一个>>>提示符或一个空白行界定。这由 Summary 类的 str()示例所体现。

    除非使用特殊注释,否则输出文本必须精确匹配预期文本。一般来说,每个空格都很重要。

    这种测试协议对软件设计施加了一些限制。函数和类必须设计成可以从>>>提示符工作。因为将非常复杂的对象作为文档字符串示例的一部分可能会变得尴尬,所以类设计必须足够简单,以便在交互式提示符中演示。这些限制通常有助于保持设计可理解。

    与预期结果的最终比较的简单性可能会造成一些复杂性。在示例中,我们将平均值四舍五入到两位小数。这是因为浮点数的显示可能会因平台而异。

    15.1.4 更多内容...

    测试设计中一个重要的考虑因素是识别边缘情况。边缘情况通常关注计算设计的极限。

    例如,二项式函数有两个边缘情况:

    ( )( ) n n 0 = n = 1

    我们可以将这些添加到示例中,以确保我们的实现是可靠的。这将导致一个看起来像以下的文档字符串:

     """ 
    
        Computes the binomial coefficient. 
    
        This shows how many combinations exist of 
    
        *n* things taken in groups of size *k*. 
    
        :param n: size of the universe 
    
        :param k: size of each subset 
    
        :returns: the number of combinations 
    
        >>> binom(52, 5) 
    
        2598960 
    
        >>> binom(52, 0) 
    
        1 
    
        >>> binom(52, 52) 
    
        1 
    
        """
    

    为了在源代码文件中保持示例的一致性,我们已经将此函数的名称更改为binom2。这个技巧让我们可以在单个 Python 模块中保持两个示例。

    在某些情况下,我们可能需要测试超出有效值范围的值。这些情况会引发异常,这意味着它们实际上不适合放入文档字符串中。示例可能会用应该永远不会发生的事情的细节来杂乱无章地解释说明。幸运的是,我们有一个地方可以放置额外的示例。

    除了阅读文档字符串外,该工具还会在一个名为__test__的全局变量中查找测试用例。此变量必须引用一个映射。映射的键将是测试用例名称,映射的值必须是 doctest 示例。通常,每个值都需要是一个三引号字符串。

    因为__test__变量中的示例不在文档字符串中,所以在使用内置的help()函数时它们不会显示。同样,当使用其他工具从源代码创建文档时,它们也不会显示。这可能是一个放置失败示例或复杂异常示例的地方。

    我们可能添加如下内容:

    __test__ = { 
    
        "GIVEN_binom_WHEN_0_0_THEN_1": """ 
    
            >>> binom(0, 0) 
    
            1 
    
            """, 
    
        "GIVEN_binom_WHEN_52_52_THEN_1": """ 
    
            >>> binom(52, 52) 
    
            1 
    
            """, 
    
    }
    

    我们可以使用这来测试那些不需要像文档字符串示例那样可见的测试。

    15.1.5 参见

    • 在本章后面的测试引发异常的函数和处理常见的 doctest 问题的食谱中,我们将探讨两种额外的 doctest 技术。

    • 关于无状态函数概念的更多背景信息,请参阅第三章和第九章。

    15.2 测试引发异常的函数

    Python 允许在包、模块、类、函数和方法中包含文档字符串。一个好的文档字符串应该包含一个如何使用该功能的示例。示例可能还需要包括常见的异常。然而,包含异常有一个复杂因素。

    当引发异常时,Python 创建的跟踪消息并不完全可预测。消息可能包括无法预测的对象 ID 值或可能根据测试执行的上下文略有变化的模块行号。doctest 的一般匹配规则精确地比较预期和实际结果。在本食谱中,我们将探讨额外的技术来增加灵活性。

    15.2.1 准备工作

    我们将查看一个小的函数定义以及一个类定义。每个都将包含包含可以用于正式测试的示例的文档字符串。

    我们将使用本章前面展示的使用文档字符串进行测试食谱中的函数,该函数计算两个数字的二项式系数。它显示了 n 个事物以 k 组取出的组合数。例如,它显示了 52 张牌的牌组如何被分成 5 张牌的手牌。

    这个函数执行简单的计算并返回一个值;它没有内部状态,使得每个请求都是独立的。我们希望在__test__变量中包含一些额外的测试用例,以显示当给定预期范围之外的值时会发生什么。

    15.2.2 如何做...

    我们首先运行之前定义的 binom 函数。这个输出提供了一个方便的模板来显示预期的输出:

    1. 在交互 Python 提示符下手动运行函数以收集实际的异常详情。复制并粘贴这些结果。

    2. 在模块的末尾创建一个全局的__test__变量。一种方法是从所有以 test_ 开头的全局变量中构建映射:

       recipe_02 
      
      2 items passed all tests: 
      
         2 tests in recipe_02.__test__.test_GIVEN_n_5_k_52_THEN_ValueError 
      
         3 tests in recipe_02.binom 
      
      5 tests in 3 items.
      
    3. 将每个测试用例定义为全局变量,包含包含 doctest 示例的文本块。这可以包括关于场景的额外说明。这些变量必须在创建最终的__test__映射之前设置。

    4. 将交互会话的输出粘贴进来。

      它将像这样开始:

      test_GIVEN_n_5_k_52_THEN_ValueError_1 = """ 
      
          GIVEN n=5, k=52 WHEN binom(n, k) THEN exception 
      
          >>> binom(52, -5) 
      
          Traceback (most recent call last): 
      
            File "/Users/slott/miniconda3/envs/cookbook3/lib/python3.12/doctest.py", line 1357, in __run 
      
              exec(compile(example.source, filename, "single", 
      
            File "<doctest recipe_02.__test__.test_GIVEN_n_5_k_52_THEN_ValueError[0]>", line 1, in <module> 
      
              binom(52, -5) 
      
            File "/Users/slott/Documents/Writing/Python/Python Cookbook 3e/src/ch15/recipe_02.py", line 29, in binom 
      
              return factorial(n) // (factorial(k) * factorial(n - k)) 
      
                                      ^^^^^^^^^^^^ 
      
          ValueError: factorial() not defined for negative values 
      
      """
      
    5. 将跟踪回溯详情替换为......保留初始行和最终异常。在要执行的行后添加一个 doctest 指令,通过放置# doctest: +ELLIPSIS来实现。它看起来像这样:

      test_GIVEN_n_5_k_52_THEN_ValueError_2 = """ 
      
          GIVEN n=5, k=52 WHEN binom(n, k) THEN exception 
      
          >>> binom(5, 52) 
      
          Traceback (most recent call last): 
      
          ... 
      
          ValueError: factorial() not defined for negative values 
      
      """
      

    我们现在可以使用这样的命令来测试整个模块的功能:

    (cookbook3) % python -m doctest recipe_02.py
    

    因为每个测试都是一个单独的全局变量,我们可以轻松地添加测试场景。所有以 test_ 开头的名称都将成为 doctest 工具使用的最终__test__映射的一部分。

    15.2.3 它是如何工作的...

    由于省略跟踪信息非常常见,doctest 工具识别跟踪信息上下文中的省略号(...)。省略号也在其他上下文中作为修改测试行为的许多指令之一可用。这些指令包含在执行测试操作的代码行的特殊注释中。它们也可以作为命令行上的通用指令提供。

    我们有两种处理包含异常的测试的额外方法:

    • 我们可以在将引发异常的代码行上使用#doctest: +IGNORE_EXCEPTION_DETAIL 指令。这让我们可以提供一个完整的跟踪错误消息。跟踪信息的细节被忽略,并且只有最后的异常行与预期值匹配。这使得将实际错误复制并粘贴到文档中成为可能。

    • 我们可以使用#doctest: +ELLIPSIS 指令,并用...替换跟踪消息的部分。此指令对于跟踪消息是多余的。

    显式指令的使用可以帮助清楚地表明意图。

    15.2.4 更多...

    有两个其他指令通常很有用:

    • +NORMALIZE_WHITESPACE:使用此指令允许在预期值中对空白有一些灵活性。

    • +SKIP:跳过测试。

    有几个更多的指令,但它们很少需要。

    15.2.5 参见

    • 在本章前面,请参阅使用文档字符串进行测试的配方。这个配方展示了 doctest 的基本知识。

    • 在本章中,接下来请参阅处理常见的 doctest 问题的配方。这展示了需要 doctest 指令的其他特殊案例。

    15.3 处理常见的 doctest 问题

    包含示例的文档字符串是良好 Python 编程的一部分。doctest 工具通过将预期的文本输出与实际文本进行字面匹配的方式来使用,这可能会使得对于没有一致文本表示的 Python 对象进行测试变得复杂。

    例如,对象的哈希值是随机的。这通常会导致集合集合中元素顺序不可预测。我们有几种选择来创建测试用例示例输出:

    • 编写可以容忍随机化的示例。一种技术是将集合的元素排序到定义的顺序中。

    • 指定 PYTHONHASHSEED 环境变量的特定值。

    除了简单的键或集合中项的位置变化之外,还有其他一些考虑因素。以下是一些其他关注点:

    • id()和 repr()函数可能会暴露内部对象 ID。无法对这些值做出保证。

    • 浮点值可能在平台之间有所不同。

    • 当前日期、时间和本地时区在测试用例中无法有意义地使用。

    • 使用默认种子生成的随机数难以预测。

    • OS 资源可能不存在,或者可能不在适当的状态。

    重要的是要注意,doctest 示例需要与文本完全匹配。这意味着我们的测试用例必须避免由哈希随机化或浮点实现细节引起的不可预测的结果。

    15.3.1 准备工作

    我们将查看这个配方的三个不同版本。第一个将包括一个输出包括集合内容的函数。由于集合中元素的顺序可能变化,这不像我们希望的那样容易测试。以下是函数定义:

    from string import ascii_letters 
    
    def unique_letters(text: str) -> set[str]: 
    
        letters = set(text.lower()) 
    
        non_letters = letters - set(ascii_letters) 
    
        return letters - non_letters
    

    测试 unique_letters() 函数很困难,因为集合内元素的顺序是不可预测的。

    第二个示例将是一个没有定义唯一 repr() 定义的类。repr() 方法的默认定义将暴露内部对象 ID。由于这些 ID 是可变的,因此测试结果也会变化。以下是类定义:

    class Point: 
    
        def __init__(self, lat: float, lon: float) -> None: 
    
            self.lat = lat 
    
            self.lon = lon 
    
        @property 
    
        def text(self) -> str: 
    
            ns_hemisphere = "S" if self.lat < 0 else "N" 
    
            ew_hemisphere = "W" if self.lon < 0 else "E" 
    
            lat_deg, lat_ms = divmod(abs(self.lat), 1.0) 
    
            lon_deg, lon_ms = divmod(abs(self.lon), 1.0) 
    
            return ( 
    
                f"{lat_deg:02.0f}{lat_ms*60:4.3f}{ns_hemisphere}" 
    
                f" {lon_deg:03.0f}{lon_ms*60:4.3f}{ew_hemisphere}" 
    
            )
    

    对于第三个示例,我们将查看一个实值函数,这样我们就可以处理浮点值:

    ϕ (n) = 1[1 + erf√n-] 2 2

    这个函数是标准 z 分数的累积概率密度函数。参见第九章中关于创建部分函数的 Creating a partial function 配方,了解更多关于标准化分数的概念。

    这里是 Python 实现:

    from math import sqrt, pi, exp, erf 
    
    def phi(n: float) -> float: 
    
        return (1 + erf(n / sqrt(2))) / 2 
    
    def frequency(n: float) -> float: 
    
        return phi(n) - phi(-n)
    

    phi() 和 frequency() 函数涉及一些相当复杂的数值处理。单元测试必须反映浮点精度问题。

    15.3.2 如何操作...

    我们将在三个小配方中查看集合排序和对象表示。我们首先查看集合排序,然后查看对象 ID,最后查看浮点值。

    编写具有不可预测集合排序的 doctest 示例

    1. 编写一个似乎能够捕捉本质的测试草案:

      >>> phrase = "The quick brown fox..." 
      
      >>> unique_letters(phrase) 
      
      {’b’, ’c’, ’e’, ’f’, ’h’, ’i’, ’k’, ’n’, ’o’, ’q’, ’r’, ’t’, ’u’, ’w’, ’x’}
      

      当这些字符串的哈希值恰好落入这个特定顺序时,这个测试将有效。

    2. 一种可能的解决方案是对结果进行排序以强制执行顺序。

      另一个替代方案是将输出与集合对象进行比较。这两个选择看起来像这样:

      >>> phrase = "The quick brown fox..." 
      
      >>> sorted(unique_letters(phrase)) 
      
      [’b’, ’c’, ’e’, ’f’, ’h’, ’i’, ’k’, ’n’, ’o’, ’q’, ’r’, ’t’, ’u’, ’w’, ’x’] 
      
      >>> (unique_letters(phrase) == 
      
      ...    {’b’, ’c’, ’e’, ’f’, ’h’, ’i’, ’k’, ’n’, ’o’, ’q’, ’r’, ’t’, ’u’, ’w’, ’x’} 
      
      ... ) 
      
      True
      

    第三个选择是将 PYTHONHASHSEED 环境变量设置为强制已知顺序。我们将在下面查看这个替代方案。

    编写具有对象 ID 的 doctest 示例

    理想情况下,我们的应用程序不会显示对象 ID。这些基本上是不可预测的。以下是我们可以做的事情:

    1. 定义一个快乐的路径 doctest 场景,以显示类正确执行其基本方法。在这种情况下,我们将创建一个 Point 实例,并使用文本属性来查看点的表示:

      >>> Point(36.8439, -76.2936).text 
      
      ’3650.634N 07617.616W’
      
    2. 当我们定义一个显示对象表示字符串的测试时,该测试将包括包含不可预测对象 ID 的结果。doctest 可能看起来像以下这样:

      >>> Point(36.8439, -76.2936) 
      
      <recipe_03.Point object at 0x107910610>
      

      我们需要通过使用 # doctest: +ELLIPSIS 指令来更改测试。这意味着更改测试中的 >>> Point(36.8439, -76.2936) 行,并在预期输出中显示的异常上使用省略号,使其看起来像这样:

      >>> Point(36.8439, -76.2936) # doctest: +ELLIPSIS 
      
      <recipe_03.Point object at ...>
      

    这类测试建议进行设计改进。通常最好定义 repr(). 另一个选择是避免使用 repr() 可能被使用的测试。

    为浮点值编写 doctest 示例

    在处理浮点值时,我们有两种选择。我们可以将值四舍五入到一定的小数位数。另一种选择是使用 math.isclose() 函数。我们将展示两者:

    1. 导入必要的库,并定义如前所述的 phi() 和 frequency() 函数。

    2. 对于每个示例,包括一个显式的 round() 使用:

      >>> round(phi(0), 3) 
      
      0.5 
      
      >>> round(phi(-1), 3) 
      
      0.159 
      
      >>> round(phi(+1), 3) 
      
      0.841
      
    3. 另一个选择是使用 math 模块中的 isclose() 函数:

      >>> from math import isclose 
      
      >>> isclose(phi(0), 0.5) 
      
      True 
      
      >>> isclose(phi(1), 0.8413, rel_tol=.0001) 
      
      True 
      
      >>> isclose(phi(2), 0.9772, rel_tol=1e-4) 
      
      True
      

    由于浮点值无法精确比较,最好显示已四舍五入到适当小数位数的值。对于示例的读者来说,有时使用 round() 更为方便,因为它可能比 isclose() 选项更容易可视化函数的工作方式。

    15.3.3 它是如何工作的...

    由于哈希随机化,集合使用的哈希键是不可预测的。这是一个重要的安全特性,用于抵御一种微妙的拒绝服务攻击。有关详细信息,请参阅 url:www.ocert.org/advisories/ocert-2011-003.html.

    自从 Python 3.7 开始,字典键的插入顺序得到了保证。这意味着构建字典的算法将提供一系列一致的键值序列。对于集合,并没有做出相同的顺序保证。有趣的是,由于整数哈希值的计算方式,整数集合往往具有一致的顺序。然而,其他类型对象的集合则不会显示一致的元素顺序。

    当面对如集合顺序或由 repr() 方法揭示的内部对象标识等不可预测的结果时,我们遇到了一个可测试性问题。我们可以要么修改软件使其更具可测试性,要么修改测试以容忍一些不可预测性。

    大多数浮点数实现都是相当一致的。然而,对于任何给定浮点数的最后几位,很少有正式的保证。与其相信所有位都恰好具有正确的值,不如通常将值四舍五入到与问题域中其他值一致的精度。

    对不可预测性的容忍可能过度,允许测试容忍错误。对于数学函数的更深入测试,hypothesis 包提供了定义稳健测试案例域的方法。

    15.3.4 更多...

    我们可以通过设置 PYTHONHASHSEED 环境变量来运行测试。在 Linux(以及 macOS X)中,我们可以在单个命令行语句中完成此操作:

    (cookbook3) % PYTHONHASHSEED=42 python3 -m doctest recipe_03.py
    

    这将在运行 doctest 时提供固定的、可重复的哈希随机化。我们还可以使用 PYTHONHASHSEED=0 来禁用哈希随机化。

    tox 工具有一个 --hashseed=x 选项,允许在运行测试之前将一致的哈希种子设置为整数值。

    15.3.5 参见

    • 特别是 Testing things that involve dates or times 的配方,datetime 的 now() 方法需要一些注意。

    • 测试涉及随机性的事物 的配方展示了如何测试涉及使用 random 模块的处理。

    • 我们将在本章后面的 Mocking external resources 配方中查看如何处理外部资源。

    15.4 使用 unittest 模块进行单元测试

    unittest 模块允许我们超越 doctest 使用的示例。每个测试用例可以有一个额外的场景,作为 TestCase 类的子类构建。这些使用比 doctest 工具使用的文本匹配更复杂的检查结果。

    unittest 模块还允许我们将测试打包在 docstrings 之外。这对于测试边缘情况可能很有帮助,这些边缘情况可能过于详细,无法作为有用的文档。通常,doctest 案例集中在幸福路径上——即最常见的使用案例,其中一切按预期工作。我们可以使用 unittest 模块更容易地定义偏离幸福路径的测试用例。

    这个配方将展示我们如何使用 unittest 模块创建更复杂的测试。

    15.4.1 准备工作

    有时,根据 Gherkin 语言的思路总结测试是有帮助的。在这种测试规范语言中,每个场景都由给定-当-然后步骤来描述。对于这种情况,我们有一个类似这样的场景:

    Scenario: Summary object can add values and compute statistics. 
    
    Given a Summary object 
    
    And numbers in the range 0 to 1000 (inclusive) shuffled randomly 
    
    When all numbers are added to the Summary object 
    
    Then the mean is 500 
    
    And the median is 500
    

    TestCase 类并不严格遵循这个三部分给定-当-然后(或安排-行动-断言)结构。TestCase 类通常有两个部分:

    • setUp() 方法必须实现测试用例的给定步骤。

    • runTest() 方法必须处理然后步骤,使用多种断言方法来确认实际结果与预期结果相匹配。

    当步骤可以在两种方法中任选其一。何时实现当步骤的选择通常与重用问题相关。例如,一个类或函数可能有多个方法来执行不同的操作或进行多个状态变化。在这种情况下,将每个当步骤与不同的然后步骤配对以确认正确操作是有意义的。runTest() 方法可以实现当和然后步骤。多个子类可以共享共同的 setUp() 方法。

    作为另一个例子,一个类层次结构可能为相同的算法提供多种不同的实现。在这种情况下,正确行为的然后步骤确认在 runTest() 方法中。每种不同的实现都有一个独特的子类,具有为给定和当步骤的独特 setup() 方法。

    对于需要执行一些清理剩余资源的测试,有一个可选的 tearDown() 方法。这超出了测试的基本场景规范。

    我们将为一个设计用来计算一些基本描述性统计的类创建一些测试。unittest 测试用例让我们能够定义比我们选择作为 doctest 示例的任何内容都要大的样本数据。我们可以轻松地使用数千个数据点,而不是作为评估性能的一部分的两个或三个。

    我们将要测试的大多数代码在本书前面的使用 docstrings 进行测试配方中已经展示过。

    因为我们没有查看实现细节,我们可以将其视为不透明盒测试;实现细节对测试者来说是未知的。

    我们希望确保当我们使用数千个样本时,该类能正确执行。我们还希望确保它运行得快;我们将将其作为整体性能测试的一部分,以及单元测试的一部分。

    15.4.2 如何做...

    我们需要创建一个单独的模块和该模块中的一个 TestCase 子类。像 pytest 这样的工具可以检测以 test_ 开头的测试模块,为我们这些额外的模块提供了一个命名约定。以下是如何创建与模块代码分离的测试的示例:

    1. 创建一个与待测试模块相关的文件。如果模块名为 summary.py,那么一个合适的测试模块名称将是 test_summary.py。使用 test_ 前缀可以使像 pytest 这样的工具更容易找到测试。

    2. 我们将使用 unittest 模块来创建测试类。我们还将使用 random 模块来打乱输入数据。我们还将导入待测试的模块:

      import unittest 
      
      import random 
      
      from recipe_01 import Summary
      
    3. 创建一个 TestCase 的子类。为这个类提供一个名称,以显示测试的意图。我们选择了一个包含三个步骤总结的名称:

      class GIVEN_Summary_WHEN_1k_samples_THEN_mean_median(unittest.TestCase):
      
    4. 在这个类中定义一个 setUp()方法来处理测试的 Given 步骤。我们创建了一个包含 1,001 个样本的集合,这些样本被随机排序:

       def setUp(self) -> None: 
      
              self.summary = Summary() 
      
              self.data = list(range(1001)) 
      
              random.shuffle(self.data)
      
    5. 定义一个 runTest()方法来处理测试的 When 步骤:

       def runTest(self) -> None: 
      
              for sample in self.data: 
      
                  self.summary.add(sample)
      
    6. 在 runTest()方法中添加断言以实现测试的 Then 步骤:

       self.assertEqual(500, self.summary.mean) 
      
              self.assertEqual(500, self.summary.median)
      

    如果我们的测试模块名为 recipe_04.py,我们可以使用以下命令在 recipe_04 模块中查找 TestCase 类并运行它们:

    (cookbook3) % python -m unittest recipe_04.py
    

    如果所有的断言都通过,那么测试套件将通过,整个测试运行将成功。

    15.4.3 它是如何工作的...

    TestCase 类用于定义一个测试用例。该类可以有一个 setUp()方法来创建单元和可能请求。该类必须至少有一个 runTest()方法来对单元提出请求并检查响应。

    单个测试通常是不够的。如果我们创建了三个独立的测试类在 recipe_04.py 模块中,那么我们会看到如下输出:

    (cookbook3) % python -m unittest recipe_04.py 
    
    ... 
    
    ---------------------------------------------------------------------- 
    
    Ran 3 tests in 0.003s 
    
    OK
    

    随着每个测试的通过,会显示一个.。这表明测试套件正在取得进展。总结显示了运行的测试数量和时间。如果有失败或异常,最后的计数将反映这些情况。

    最后,有一行总结。在这种情况下,它由 OK 组成,表明所有测试都通过了。

    如果我们包含一个失败的测试,当我们使用-v 选项来获取详细输出时,我们会看到以下输出:

    (cookbook3) % python -m unittest -v recipe_04.py 
    
    runTest (recipe_04.GIVEN_Summary_WHEN_1k_samples_THEN_mean_median.runTest) ... ok 
    
    test_mean (recipe_04.GIVEN_Summary_WHEN_1k_samples_THEN_mean_median_2.test_mean) ... FAIL 
    
    test_median (recipe_04.GIVEN_Summary_WHEN_1k_samples_THEN_mean_median_2.test_median) ... ok 
    
    test_mode (recipe_04.GIVEN_Summary_WHEN_1k_samples_THEN_mode.test_mode) ... ok 
    
    ====================================================================== 
    
    FAIL: test_mean (recipe_04.GIVEN_Summary_WHEN_1k_samples_THEN_mean_median_2.test_mean) 
    
    ---------------------------------------------------------------------- 
    
    Traceback (most recent call last): 
    
      File "/Users/slott/Documents/Writing/Python/Python Cookbook 3e/src/ch15/recipe_04.py", line 122, in test_mean 
    
        self.assertEqual(501, self.summary.mean) 
    
    AssertionError: 501 != 500.0 
    
    ---------------------------------------------------------------------- 
    
    Ran 4 tests in 0.004s 
    
    FAILED (failures=1)
    

    有一个最终的总结是 FAILED。这包括(failures=1)来显示有多少测试失败了。

    15.4.4 更多内容...

    在这些示例中,我们在 runTest()方法内部有两个 Then 步骤的断言。如果一个失败了,测试将作为一个失败停止,其他步骤将不会被执行。

    这是在这个测试设计中的一个弱点。如果第一个断言失败,我们可能得不到我们可能想要的全部诊断信息。我们应该避免在 runTest()方法中有多个其他独立的断言序列。

    当我们想要更多的诊断细节时,我们有两种一般的选择:

    • 使用多个测试方法而不是单个 runTest()。我们可以创建多个以 test_ 开头命名的多个方法。默认的测试加载器实现将在没有整体 runTest()方法的情况下,在执行每个单独的 test_ 方法之前执行 setUp()方法。这通常是将多个相关测试组合在一起的最简单方法。

    • 使用 TestCase 子类的多个子类,每个子类都有一个单独的 Then 步骤实现。当 setUp()被继承时,这将由每个子类共享。

    按照第一个选择,测试类将看起来像这样:

    class GIVEN_Summary_WHEN_1k_samples_THEN_mean_median_2(unittest.TestCase): 
    
        def setUp(self) -> None: 
    
            self.summary = Summary() 
    
            self.data = list(range(1001)) 
    
            random.shuffle(self.data) 
    
            for sample in self.data: 
    
                self.summary.add(sample) 
    
        def test_mean(self) -> None: 
    
            self.assertEqual(500, self.summary.mean) 
    
        def test_median(self) -> None: 
    
            self.assertEqual(500, self.summary.median)
    

    我们已经重构了 setUp()方法,包括测试的 Given 和 When 步骤。两个独立的 Then 步骤被重构为它们自己的单独的 test_mean()和 test_median()方法。这两个方法代替了 runTest()方法。

    由于每个测试都是单独运行的,所以对于计算平均值或中值的问题,我们会看到单独的错误报告。

    TestCase 类定义了众多断言,可以用作 Then 步骤的一部分。我们鼓励仔细研究 Python 标准库文档中的 unittest 部分,以查看所有可用的变体。

    在所有但最小的项目中,将测试文件隔离到单独的目录中是一种常见的做法,通常称为 tests。当这样做时,我们可以依赖 unittest 框架中的发现应用程序。

    unittest 加载器可以在给定的目录中搜索所有从 TestCase 类派生的类。这个类集合在更大的模块集合中成为完整的 TestSuite。

    我们可以使用 unittest 包的 discover 命令来完成这个操作:

    (cookbook3) % (cd src; python -m unittest discover -s ch15) 
    
    ............... 
    
    ---------------------------------------------------------------------- 
    
    Ran 15 tests in 0.008s 
    
    OK
    

    这将定位项目 tests 目录中所有测试模块中的所有测试用例。

    15.4.5 参见

    • 在本章的 Combining pytest and doctest tests 配方中,我们将结合 unittest 和 doctest。在本章后面的 Mocking external resources 配方中,我们将查看模拟外部对象。

    • 本章后面的使用 pytest 模块进行单元测试示例从 pytest 工具的角度覆盖了相同的测试用例。

    15.5 结合 unittest 和 doctest 测试

    在某些情况下,我们可能希望将针对 unittest 和 doctest 工具编写的测试组合在一起。关于使用 doctest 工具的示例,请参阅本章前面的使用文档字符串进行测试示例。关于使用 unittest 工具的示例,请参阅本章前面的使用 unittest 模块进行单元测试示例。

    doctest 示例是模块、类、方法和函数文档字符串的一个基本元素。unittest 用例通常位于一个单独的 tests 目录中,文件名符合 test_*.py 的模式。创建可信赖的软件的一个重要部分是运行尽可能广泛的测试。

    在这个示例中,我们将探讨将各种测试组合成一个整洁包的方法。

    15.5.1 准备工作

    我们将回顾本章前面提到的使用文档字符串进行测试示例。这个示例为名为 Summary 的类创建了一些统计计算测试。在那个示例中,我们在文档字符串中包含了示例。

    在本章前面的使用 unittest 模块进行单元测试示例中,我们编写了一些 TestCase 类,为这个类提供了额外的测试。

    作为上下文,我们假设有一个项目文件夹结构,如下所示:

    project-name/ 
    
        src/ 
    
            summary.py 
    
        tests/ 
    
            test_summary.py 
    
        README 
    
        pyproject.toml 
    
        requirements.txt 
    
        tox.ini
    

    这意味着测试既在 src/summary.py 模块中,也在 tests/test_summary.py 文件中。

    我们需要将所有测试组合成一个单一、全面的测试套件。

    示例中使用的模块名为 recipe_01.py,而不是像 summary.py 这样的更酷的名字。理想情况下,一个模块应该有一个易于记忆且意义明确的名称。本书内容相当庞大,名称是为了与整体章节和示例大纲相匹配。

    15.5.2 如何做...

    要结合 unittest 和 doctest 测试用例,我们将从一个现有的测试模块开始,并添加一个 load_tests()函数来合并相关的 doctest 与现有的 unittest 测试用例。必须提供一个名为 load_tests()的函数。这个名称是必需的,以便 unittest 加载器可以使用它:

    1. 要使用 doctest 测试,导入 doctest 模块。要编写 TestCase 类,导入 unittest 模块。我们还需要导入 random 模块,以便我们可以控制使用的随机种子:

      import unittest 
      
      import doctest 
      
      import random
      
    2. 导入包含 doctest 示例的模块:

      import recipe_01
      
    3. 要实现 load_tests 协议,在测试模块中定义一个 load_tests()函数。我们将自动由 unittest 发现的常规测试与 doctest 模块找到的附加测试相结合:

      def load_tests( 
      
          loader: unittest.TestLoader, standard_tests: unittest.TestSuite, pattern: str 
      
      ) -> unittest.TestSuite: 
      
          dt = doctest.DocTestSuite(recipe_01) 
      
          standard_tests.addTests(dt) 
      
          return standard_tests
      

    load_tests() 函数的加载器参数值是当前正在使用的测试用例加载器;这通常被忽略。standard_tests 参数值将是默认加载的所有测试。通常,这是 TestCase 所有子类的套件。该函数使用额外的测试更新此对象。模式值是提供给加载器的值,用于定位测试;这也被忽略。

    当我们从操作系统命令提示符运行它时,我们看到以下内容:

    (cookbook3) % python -m unittest -v recipe_05.py 
    
    test_mean (recipe_05.GIVEN_Summary_WHEN_1k_samples_THEN_mean_median.test_mean) ... ok 
    
    test_median (recipe_05.GIVEN_Summary_WHEN_1k_samples_THEN_mean_median.test_median) ... ok 
    
    Summary (recipe_01) 
    
    Doctest: recipe_01.Summary ... ok 
    
    Twc (recipe_01) 
    
    Doctest: recipe_01.Twc ... ok 
    
    GIVEN_binom_WHEN_0_0_THEN_1 (recipe_01.__test__) 
    
    Doctest: recipe_01.__test__.GIVEN_binom_WHEN_0_0_THEN_1 ... ok 
    
    GIVEN_binom_WHEN_52_52_THEN_1 (recipe_01.__test__) 
    
    Doctest: recipe_01.__test__.GIVEN_binom_WHEN_52_52_THEN_1 ... ok 
    
    binom (recipe_01) 
    
    Doctest: recipe_01.binom ... ok 
    
    binom2 (recipe_01) 
    
    Doctest: recipe_01.binom2 ... ok 
    
    ---------------------------------------------------------------------- 
    
    Ran 8 tests in 0.006s 
    
    OK
    

    这表明 unittest 测试用例以及 doctest 测试用例都被包含在内。

    15.5.3 的工作原理...

    unittest.main() 应用程序使用一个测试加载器来查找所有相关的测试用例。加载器被设计用来查找所有扩展 TestCase 的类。它还会寻找一个 load_tests() 函数。这个函数可以提供一系列额外的测试。它也可以在需要时执行非默认的测试搜索。

    通常,我们可以导入一个带有文档字符串的模块,并使用 DocTestSuite 从导入的模块构建测试套件。我们当然可以导入其他模块,甚至扫描 README 文件以获取更多测试示例。目标是确保代码和文档中的每个示例都实际可行。

    15.5.4 更多...

    在某些情况下,一个模块可能相当复杂;这可能导致多个测试模块。测试模块可能有如 tests/test_module_feature_X.py 这样的名称,以表明对一个非常复杂的模块的各个单独功能都有测试。测试用例的代码量可能相当大,保持功能分离可能是有帮助的。

    在其他情况下,我们可能有一个测试模块,它为几个不同但紧密相关的较小模块提供测试。一个单独的测试模块可能使用继承技术来覆盖一个包中的所有模块。

    当组合许多较小的模块时,load_tests() 函数中可能会构建多个套件。主体可能看起来像这样:

    import doctest 
    
    import unittest 
    
    import recipe_01 as ch15_r01 
    
    import recipe_02 as ch15_r02 
    
    import recipe_03 as ch15_r03 
    
    import recipe_05 as ch15_r04 
    
    def load_tests( 
    
        loader: unittest.TestLoader, standard_tests: unittest.TestSuite, pattern: str 
    
    ) -> unittest.TestSuite: 
    
        for module in (ch15_r01, ch15_r02, ch15_r03, ch15_r04): 
    
            dt = doctest.DocTestSuite(module) 
    
            standard_tests.addTests(dt) 
    
        return standard_tests
    

    这将把多个模块中的 doctest 示例合并到一个单一的、全面的测试套件中。

    15.5.5 参见

    • 关于 doctest 的示例,请参阅本章前面的 使用文档字符串进行测试 配方。关于 unittest 的示例,请参阅本章前面的 使用 unittest 模块进行单元测试 配方。

    15.6 使用 pytest 模块进行单元测试

    pytest 工具允许我们超越 doctest 在文档字符串中使用的示例。而不是使用 TestCase 的子类,pytest 工具让我们使用函数定义。pytest 方法使用 Python 内置的 assert 语句,使测试用例看起来相对简单。

    pytest 工具不是 Python 的一部分;它需要单独安装。使用如下命令:

    (cookbook3) % python -m pip install pytest
    

    在这个配方中,我们将探讨如何使用 pytest 简化我们的测试用例。

    15.6.1 准备工作

    Gherkin 语言可以帮助结构化测试。对于这个配方,我们有一个这样的场景:

    Scenario: Summary object can add values and compute statistics. 
    
    Given a Summary object 
    
    And numbers in the range 0 to 1000 (inclusive) shuffled randomly 
    
    When all numbers are added to the Summary object 
    
    Then the mean is 500 
    
    And the median is 500
    

    pytest 测试函数并不严格遵循 Gherkin 的三部分结构。测试函数通常有两个部分:

    • 如果需要,定义固定对象以建立 Given 步骤。固定对象旨在用于重用和组合。固定对象还可以在测试完成后拆除资源。

    • 函数的主体通常处理 When 步骤以测试被测试的对象,以及 Then 步骤以确认结果。

    这些边界不是固定的。例如,一个固定对象可能创建一个对象并采取行动,执行 Given 和 When 步骤。这允许多个测试函数应用多个独立的 Then 步骤。

    我们将为一个设计用来计算一些基本描述性统计的类创建一些测试。代码的主体已在使用 docstrings 进行测试的菜谱中展示。

    这是一个类的概要,提供作为方法名称的提醒:

    class Summary: 
    
        def __init__(self) -> None: ... 
    
        def __str__(self) -> str: ... 
    
        def add(self, value: int) -> None: ... 
    
        @property 
    
        def mean(self) -> float: ... 
    
        @property 
    
        def median(self) -> float: ... 
    
        @property 
    
        def count(self) -> int: ... 
    
        @property 
    
        def mode(self) -> list[tuple[int, int]]: ...
    

    我们希望复制使用 unittest 模块进行单元测试菜谱中展示的测试。我们将使用 pytest 功能来完成这个任务。

    15.6.2 如何做到...

    通常最好从一个单独的测试文件开始,甚至是一个单独的测试目录:

    1. 创建一个与被测试模块名称相似的测试文件。如果模块文件名为 summary.py,那么一个合适的测试模块名称将是 test_summary.py。使用 test_ 前缀可以使测试更容易找到。

    2. 我们将使用 pytest 模块来创建测试类。我们还将使用 random 模块来打乱输入数据。此外,我们需要导入被测试的模块:

      import random 
      
      import pytest 
      
      from recipe_01 import Summary 
      
    3. 将 Given 步骤实现为一个固定对象。这通过 @pytest.fixture 装饰器标记。它创建了一个可以返回有用对象、创建对象的必要数据或模拟对象的函数:

      @pytest.fixture() 
      
      def flat_data() -> list[int]: 
      
          data = list(range(1001)) 
      
          random.shuffle(data) 
      
          return data
      
    4. 将 When 和 Then 步骤实现为一个对 pytest 可见的测试函数。这意味着函数名称必须以 test_ 开头:

      def test_flat(flat_data: list[int]) -> None:
      

      当测试函数定义中的参数名称是固定函数的名称时,固定函数会自动评估。固定函数的结果在运行时提供。这意味着打乱的 1,000 个值的集合将作为 flat_data 参数的参数值提供。

    5. 实现一个 When 步骤以对一个对象执行操作:

       summary = Summary() 
      
          for sample in flat_data: 
      
              summary.add(sample)
      
    6. 实现 Then 步骤以验证结果:

       assert summary.mean == 500 
      
          assert summary.median == 500
      

    如果我们的测试模块名为 test_summary.py,我们通常可以使用如下命令来执行其中的测试:

    (cookbook3) % python -m pytest test_summary.py
    

    这将调用 pytest 包的一部分主应用程序。它将在给定的文件中搜索以 test_ 开头的函数并执行这些测试函数。

    15.6.3 它是如何工作的...

    我们正在使用 pytest 包的几个部分:

    • @fixture 装饰器可以用来创建具有已知状态的、可重用的测试固定对象,以便进行进一步处理。

    • pytest 应用程序可以执行多项任务:

      • 发现测试。默认情况下,它搜索名为 tests 的目录,查找以 test_ 开头的模块名称。在这些模块中,它查找以 test_ 开头的函数。它还找到 unittest.TestCase 类。

      • 运行所有测试,根据需要评估夹具。

      • 显示结果的摘要。

    当我们运行 pytest 命令时,我们将看到类似以下的输出:

    (cookbook3) % python -m pytest recipe_06.py 
    
    =========================== test session starts ============================ 
    
    platform darwin -- Python 3.12.0, pytest-7.4.3, pluggy-1.3.0 
    
    rootdir: /Users/slott/Documents/Writing/Python/Python Cookbook 3e 
    
    configfile: pytest.ini 
    
    plugins: anyio-4.0.0 
    
    collected 3 items 
    
    recipe_06.py ...                                                     [100%] 
    
    ============================ 3 passed in 0.02s =============================
    

    随着每个测试的通过,将显示一个 .。这表明测试套件正在取得进展。摘要显示了运行的测试数量和时间。如果有失败或异常,最后一行的计数将反映这一点。

    如果我们稍微改变一个测试以确保它失败,我们将看到以下输出:

    (cookbook3) % python -m pytest recipe_06.py 
    
    =========================== test session starts ============================ 
    
    platform darwin -- Python 3.12.0, pytest-7.4.3, pluggy-1.3.0 
    
    rootdir: /Users/slott/Documents/Writing/Python/Python Cookbook 3e 
    
    configfile: pytest.ini 
    
    plugins: anyio-4.0.0 
    
    collected 3 items 
    
    recipe_06.py F..                                                     [100%] 
    
    ================================= FAILURES ================================= 
    
    ________________________________ test_flat _________________________________ 
    
    flat_data = [883, 104, 898, 113, 519, 94, ...] 
    
        def test_flat(flat_data: list[int]) -> None: 
    
            summary = Summary() 
    
            for sample in flat_data: 
    
                summary.add(sample) 
    
    >       assert summary.mean == 501 
    
    E       assert 500.0 == 501 
    
    E        +  where 500.0 = <recipe_01.Summary object at 0x10fdcb350>.mean 
    
    recipe_06.py:57: AssertionError 
    
    ========================= short test summary info ========================== 
    
    FAILED recipe_06.py::test_flat - assert 500.0 == 501 
    
    ======================= 1 failed, 2 passed in 0.17s ========================
    

    这显示了通过和失败的测试的摘要以及每个失败的详细信息。

    15.6.4 更多...

    在这个例子中,我们在 test_flat() 函数内部有两个 Then 步骤。这些步骤被实现为两个 assert 语句。如果第一个失败,测试将作为失败停止,并且后续步骤将被跳过。这意味着我们可能看不到我们可能需要的所有诊断信息。

    更好的设计是使用多个测试函数。所有这些函数都可以共享一个共同的夹具。在这种情况下,我们可以创建一个依赖于 flat_data 夹具的第二个夹具,并构建一个 Summary 对象,供多个测试使用:

    @pytest.fixture() 
    
    def summary_object(flat_data: list[int]) -> Summary: 
    
        summary = Summary() 
    
        for sample in flat_data: 
    
            summary.add(sample) 
    
        return summary 
    
    def test_mean(summary_object: Summary) -> None: 
    
        assert summary_object.mean == 500 
    
    def test_median(summary_object: Summary) -> None: 
    
        assert summary_object.median == 500
    

    由于每个测试函数都是单独运行的,因此当计算平均值和中位数时,我们将看到单独的错误报告,或者可能在同时计算两者时。

    15.6.5 参见

    • 本章中的 使用 unittest 模块进行单元测试 菜单从 unittest 模块的角度覆盖了相同的测试案例。

    15.7 结合 pytest 和 doctest 测试

    在大多数情况下,我们将有 pytest 和 doctest 测试案例的组合。有关使用 doctest 工具的示例,请参阅 使用 docstrings 进行测试 菜谱。有关使用 pytest 工具的示例,请参阅 使用 pytest 模块进行单元测试 菜谱。

    经常,文档将包含 doctest。我们需要确保所有示例(在 docstrings 和文档中)都能正确工作。在这个菜谱中,我们将把这些 doctest 示例和 pytest 测试案例组合成一个整洁的包。

    15.7.1 准备工作

    我们将参考 使用 docstrings 进行测试 菜谱中的示例。这个菜谱为 Summary 类创建了一些测试,该类执行一些统计计算。在那个菜谱中,我们在 docstrings 中包含了示例。

    在 使用 pytest 模块进行单元测试 菜谱中,我们编写了一些测试函数来为这个类提供额外的测试。这些测试被放入了一个单独的模块中,模块名称以 test_ 开头,具体为 test_summary.py。

    按照结合 unittest 和 doctest 测试的配方,我们还将假设有一个项目文件夹结构,如下所示:

    project-name/ 
    
        src/ 
    
            summary.py 
    
        tests/ 
    
            test_summary.py 
    
        README 
    
        pyproject.toml 
    
        requirements.txt 
    
        tox.ini
    

    测试目录应包含所有包含测试的模块文件。我们选择了名为 tests 的目录和一个名为 test_*.py 的模块,这样它们就可以很好地与 pytest 工具的自动化测试发现功能相匹配。

    配方示例使用 recipe_07 而不是像 summary 这样的更酷的名字。作为一般实践,一个模块应该有一个易于记忆、有意义的名字。本书的内容相当庞大,名字是为了与整体章节和配方大纲相匹配。

    15.7.2 如何做...

    结果表明,我们不需要编写任何 Python 代码来组合测试。pytest 模块将定位测试函数。它也可以用来定位 doctest 用例:

    1. 创建一个 shell 命令来运行 recipe_07.py 文件中的测试套件,以及检查 recipe_01.py 模块中的额外 doctest 用例:

      % pytest recipe_07.py --doctest-modules recipe_01.py
      

    当我们从操作系统命令提示符运行这个程序时,我们会看到以下内容:

    (cookbook3) % pytest recipe_07.py --doctest-modules recipe_01.py 
    
    =========================== test session starts ============================ 
    
    platform darwin -- Python 3.12.0, pytest-7.4.3, pluggy-1.3.0 
    
    rootdir: /Users/slott/Documents/Writing/Python/Python Cookbook 3e 
    
    configfile: pytest.ini 
    
    plugins: anyio-4.0.0 
    
    collected 7 items 
    
    recipe_07.py ..                                                      [ 28%] 
    
    recipe_01.py .....                                                   [100%] 
    
    ============================ 7 passed in 0.06s =============================
    

    pytest 命令与这两个文件都兼容。recipe_07.py 后面的点表示在这个文件中找到了两个测试用例,这是测试套件的 28%。recipe_01.py 后面的点表示找到了更多的五个测试用例;这是套件剩余的 72%。

    这表明 pytest 测试用例以及 doctest 测试用例都被包含在内。有帮助的是,我们不需要在任一测试套件中调整任何内容来执行所有可用的测试用例。

    15.7.3 它是如何工作的...

    pytest 应用程序有多种方式来搜索测试用例。默认情况下,它会在给定模块中搜索所有以 test_ 开头的函数名称的给定路径。它也会搜索所有 TestCase 的子类。如果我们提供一个目录,它将搜索以 test_ 开头的所有模块。通常,我们会将测试文件收集在名为 tests 的目录中,因为这是默认将被搜索的目录。

    --doctest-modules 命令行选项用于标记包含 doctest 示例的模块。这些示例也被添加到测试套件中作为测试用例。

    在寻找和执行各种类型测试的复杂程度方面,pytest 是一个非常强大的工具。它使得创建各种形式的测试变得容易,从而增加了我们软件按预期工作的信心。

    15.7.4 更多...

    添加-v 选项提供了 pytest 工具找到的测试的更详细视图。以下是显示额外细节的方式:

    (cookbook3) % python -m pytest -v recipe_07.py --doctest-modules recipe_01.py 
    
    =========================== test session starts ============================ 
    
    platform darwin -- Python 3.12.0, pytest-7.4.3, pluggy-1.3.0 -- /Users/slott/miniconda3/envs/cookbook3/bin/python 
    
    cachedir: .pytest_cache 
    
    rootdir: /Users/slott/Documents/Writing/Python/Python Cookbook 3e 
    
    configfile: pytest.ini 
    
    plugins: anyio-4.0.0 
    
    collected 7 items 
    
    recipe_07.py::recipe_07.__test__.test_example_class PASSED           [ 14%] 
    
    recipe_07.py::test_flat PASSED                                       [ 28%] 
    
    recipe_01.py::recipe_01.Summary PASSED                               [ 42%] 
    
    recipe_01.py::recipe_01.__test__.GIVEN_binom_WHEN_0_0_THEN_1 PASSED  [ 57%] 
    
    recipe_01.py::recipe_01.__test__.GIVEN_binom_WHEN_52_52_THEN_1 PASSED [ 71%] 
    
    recipe_01.py::recipe_01.binom PASSED                                 [ 85%] 
    
    recipe_01.py::recipe_01.binom2 PASSED                                [100%] 
    
    ============================ 7 passed in 0.05s =============================
    

    每个单独的测试都会被标识,为我们提供了测试处理的详细解释。这有助于确认所有预期的 doctest 示例都已正确地定位在测试的模块中。

    15.7.5 参见

    • 关于 doctest 的示例,请参阅本章前面的使用 docstrings 进行测试配方。

    • 本章前面的使用 pytest 模块进行单元测试菜谱有用于此菜谱的 pytest 测试用例。

    • 关于这些测试的 unittest 版本示例,请参阅本章前面的使用 unittest 模块进行单元测试菜谱。

    15.8 测试涉及日期或时间的事物

    许多应用程序依赖于像 datetime.datetime.now()或 time.time()这样的函数来创建时间戳。当我们使用这些函数之一进行单元测试时,结果基本上是不可预测的。这是一个有趣的依赖注入问题:我们的应用程序依赖于一个我们希望在测试时才替换的类。

    一个选择是设计我们的应用程序以避免使用像 now()这样的函数。我们不是直接使用这种方法,而是可以创建一个工厂函数来输出时间戳。为了测试目的,这个函数可以被替换为产生已知结果的函数。

    另一种方法是猴子补丁 - 在测试时注入一个新的对象。这可以减少设计复杂性;它往往会增加测试复杂性。

    在这个菜谱中,我们将使用 datetime 对象编写测试。我们需要为 datetime 实例创建模拟对象以创建可重复的测试值。我们将使用 pytest 包的特性进行猴子补丁。

    15.8.1 准备工作

    我们将使用一个创建 CSV 文件的小函数进行工作。这个文件的名称将包括日期和时间,格式为 YYYYMMDDHHMMSS,作为一长串数字。这种文件命名约定可能被长期运行的服务器应用程序使用。这个名称有助于匹配文件和相关日志事件。它有助于追踪服务器正在执行的工作。

    应用程序使用此函数创建这些文件:

    import datetime 
    
    import json 
    
    from pathlib import Path 
    
    from typing import Any 
    
    def save_data(base: Path, some_payload: Any) -> None: 
    
        now_date = datetime.datetime.now(tz=datetime.timezone.utc) 
    
        now_text = now_date.strftime("extract_%Y%m%d%H%M%S") 
    
        file_path = (base / now_text).with_suffix(".json") 
    
        with file_path.open("w") as target_file: 
    
            json.dump(some_payload, target_file, indent=2) 
    

    这个函数使用了 now()函数,每次运行都会产生一个不同的值。由于这个值难以预测,因此编写测试断言变得困难。

    为了创建可重复的测试输出,我们可以创建 datetime 模块的模拟版本。然后我们可以猴子补丁测试上下文以使用这个模拟对象而不是实际的 datetime 模块。在模拟的模块中,我们可以创建一个带有模拟 now()方法的模拟类,以提供固定且易于测试的响应。

    对于这种情况,我们有一个类似的场景:

    Scenario: save_date function writes JSON data to a date-stamped file. 
    
    Given a base directory Path 
    
    And a payload object {"primes": [2, 3, 5, 7, 11, 13, 17, 19]} 
    
    And a known date and time of 2017-9-10 11:12:13 UTC 
    
    When save_data(base, payload) function is executed 
    
    Then the output file of "extract_20170910111213.json" is found in the base directory 
    
    And the output file has a properly serialized version of the payload 
    
    And the datetime.datetime.now() function was called once to get the date and time
    

    这可以通过使用 pytest 构造函数实现为一个测试用例。

    15.8.2 如何做...

    这个菜谱将创建和修补模拟对象以创建测试固件:

    1. 我们需要导入我们正在测试的模块所需的多个模块:

      import datetime 
      
      import json 
      
      from pathlib import Path
      
    2. 我们还需要创建模拟对象和测试固件的核心工具。此外,我们还需要我们打算测试的模块:

      from unittest.mock import Mock 
      
      import pytest 
      
      import recipe_08
      
    3. 我们必须创建一个对象,使其在测试场景中表现得像 datetime 模块。这个模拟模块必须包含一个看起来像类的名称,也命名为 datetime。这个类必须看起来包含一个方法,now(),它返回一个已知的对象,而不是每次测试运行时都变化的日期。我们将创建一个 fixture,并且该 fixture 将返回这个模拟对象,并定义一组小的属性和行为:

      @pytest.fixture() 
      
      def mock_datetime() -> Mock: 
      
          return Mock( 
      
              name="mock datetime", 
      
              datetime=Mock( 
      
                  name="mock datetime.datetime", 
      
                  now=Mock(return_value=datetime.datetime(2017, 9, 10, 11, 12, 13)), 
      
              ), 
      
              timezone=Mock(name="mock datetime.timezone", utc=Mock(name="UTC")), 
      
          )
      

      Mock 对象是一个命名空间,这是包、模块和类都共享的特性。在这个例子中,每个属性名都是另一个 Mock 对象。最深层嵌套的对象有一个 return_value 属性,使其表现得像一个函数。

    4. 我们还需要一种方法来隔离文件系统的行为到测试目录中。tmppath fixture 内置于 pytest 中,并提供临时目录,可以在其中安全地写入测试文件。

    5. 我们现在可以定义一个测试函数,该函数将使用 mock_datetime fixture 和 tmppath fixture。它将使用 monkeypatch fixture 来调整被测试模块的上下文:

      def test_save_data( 
      
          mock_datetime: Mock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 
      
      ) -> None:
      
    6. 我们可以使用 monkeypatch fixture 来替换 recipe_08 模块的一个属性。datetime 属性值将被 mock_datetime fixture 创建的 Mock 对象所替换:

       monkeypatch.setattr(recipe_08, "datetime", mock_datetime)
      

      在 fixture 定义和这个补丁之间,我们创建了一个 Given 步骤,用于定义测试安排。

    7. 我们现在可以在一个受控的测试环境中执行 save_data() 函数。这是 When 步骤,用于执行被测试的代码:

       data = {"primes": [2, 3, 5, 7, 11, 13, 17, 19]} 
      
          recipe_08.save_data(tmp_path, data)
      
    8. 由于日期和时间由 Mock 对象固定,输出文件有一个已知、可预测的名称。我们可以读取和验证文件中的预期数据。此外,我们可以查询 Mock 对象以确保它恰好被调用一次,并且没有参数值。这是一个 Then 步骤,用于确认预期结果:

       expected_path = tmp_path / "extract_20170910111213.json" 
      
          with expected_path.open() as result_file: 
      
              result_data = json.load(result_file) 
      
          assert data == result_data 
      
          mock_datetime.datetime.now.assert_called_once_with(tz=mock_datetime. 
      
                                                             timezone.utc) 
      

    这个测试确认应用程序的 save_data() 函数将创建预期的文件,并包含正确的内容。

    15.8.3 它是如何工作的...

    unittest.mock 模块有一个非常复杂的类定义,即 Mock 类。Mock 对象可以表现得像其他 Python 对象,同时提供有限的行为子集。在这个例子中,我们创建了三种不同类型的 Mock 对象。

    The Mock(wraps="datetime", ...) 对象模拟了一个完整的模块。它在测试场景所需的范围内将表现得像标准库的 datetime 模块。在这个对象内部,我们创建了一个模拟类定义,但没有将其分配给任何变量。

    Mock(now=...) 对象在 mock 模块内部表现得像一个模拟类定义。我们创建了一个单一的 now 属性值,它将表现得像一个静态函数。

    Mock(return_value=...) 对象表现得像一个普通函数或方法。我们提供这个测试所需的返回值。

    除了返回给定值外,Mock 对象还记录了调用历史。这意味着断言可以检查这些调用。Mock 模块中的 call()函数提供了一种描述函数调用中预期参数的方法。

    15.8.4 更多内容...

    在这个示例中,我们创建了一个针对 datetime 模块的模拟,该模块具有非常窄的功能集以供此测试使用。该模块包含一个名为 datetime 的模拟类。这个类有一个单一属性,一个模拟函数,now()。

    我们可以使用副作用属性而不是 return_value 属性来引发异常而不是返回值。我们可以使用这种方法来发现未正确使用 now()方法,而是使用已弃用的 utcnow()或 today()方法的代码。

    我们可以扩展这个模式并模拟多个属性以表现得像函数。以下是一个模拟几个函数的示例:

    @pytest.fixture() 
    
    def mock_datetime_now() -> Mock: 
    
        return Mock( 
    
            name="mock datetime", 
    
            datetime=Mock( 
    
                name="mock datetime.datetime", 
    
                utcnow=Mock(side_effect=AssertionError("Convert to now()")), 
    
                today=Mock(side_effect=AssertionError("Convert to now()")), 
    
                now=Mock(return_value=datetime.datetime(2017, 7, 4, 4, 2, 3)), 
    
            ),
    

    两个模拟方法,utcnow()和 today(),每个都定义了一个会引发异常的副作用。这允许我们确认旧代码已被转换为正确使用 now()方法。

    15.8.5 参见

    • 本章前面关于使用 unittest 模块进行单元测试的示例提供了有关 unittest 模块基本使用的信息。

    15.9 测试涉及随机性的内容

    许多应用程序依赖于随机模块来生成随机值或将值放入随机顺序。在许多统计测试中,重复的随机洗牌或随机选择是进行的。当我们想要测试这些算法之一时,任何中间结果或处理细节基本上是无法预测的。

    我们有两个选择来尝试使随机模块足够可预测,以便编写详细的单元测试:

    • 使用具有已知种子值的随机模块。

    • 使用 Mock 对象用 Mock 对象替换随机模块以产生可预测的值。

    在这个示例中,我们将探讨如何对涉及随机性的算法进行单元测试。

    15.9.1 准备工作

    给定一个样本数据集,我们可以计算一个统计量,例如平均值或中位数。下一步常见的操作是确定这些统计量对于某个总体人群的可能值。这可以通过一种称为自助法的技术来完成。

    理念是重复对初始数据集进行重采样。每个重采样都提供了对总体统计量的不同估计。

    为了确保重采样算法被正确实现,有助于消除处理中的随机性。我们可以使用非随机版本的 random.choice()函数对精心策划的数据集进行重采样。如果这能正常工作,那么我们有信心随机版本也会正常工作。

    这里是我们的候选重采样函数:

    from collections.abc import Iterator 
    
    import random 
    
    def resample(population: list[int], N: int) -> Iterator[int]: 
    
        for i in range(N): 
    
            sample = random.choice(population) 
    
            yield sample
    

    对于我们的示例,我们将根据重采样计算平均值的替代值。整体重采样过程看起来像这样:

    from collections import Counter 
    
    import statistics 
    
    def mean_distribution(population: list[int], N: int) -> Counter[float]: 
    
        means: Counter[float] = Counter() 
    
        for _ in range(1000): 
    
            subset = list(resample(population, N)) 
    
            measure = round(statistics.mean(subset), 1) 
    
            means[measure] += 1 
    
        return means 
    

    这评估 resample()函数以创建多个子集。每个子集的平均值填充了平均值集合。由 mean_distribution()函数创建的直方图将为人口方差提供一个有用的估计。

    这就是输出看起来像什么:

    >>> random.seed(42) 
    
    >>> population = [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68] 
    
    >>> mean_distribution(population, 4).most_common(5) 
    
    [(7.8, 51), (7.2, 45), (7.5, 44), (7.1, 41), (7.7, 40)]
    

    这表明,总体平均值的可能值可能在 7.1 到 7.8 之间。这种分析的内容远不止我们在这里展示的。我们的关注点仅限于测试 resample()函数的狭隘问题。

    重采样的测试涉及以下场景:

    Scenario: Resample example 
    
    Given a random number generator where choice() always return the sequence [23, 29, 31, 37, 41, 43, 47, 53] 
    
    When we evaluate the expression resample(any 8 values, 8) 
    
    Then the expected results are [23, 29, 31, 37, 41, 43, 47, 53] 
    
    And the choice() function was called 8 times
    

    15.9.2 如何做到...

    我们将定义一个可以替代 random.choice()函数的模拟对象。有了这个夹具,结果就是固定和可预测的:

    1. 我们需要创建模拟对象和测试夹具的核心工具。我们还需要我们打算测试的模块:

      from unittest.mock import Mock 
      
      import pytest 
      
      import recipe_09
      
    2. 我们需要一个类似于 choice()函数的对象。我们将创建一个基于另一个夹具的夹具:

      @pytest.fixture() 
      
      def expected_resample_data() -> list[int]: 
      
          return [23, 29, 31, 37, 41, 43, 47, 53] 
      
      @pytest.fixture() 
      
      def mock_random_choice(expected_resample_data: list[int]) -> Mock: 
      
          mock_choice = Mock(name="mock random.choice", side_effect=expected_resample_data) 
      
          return mock_choice
      

      expected_resample_data 测试夹具提供了一组特定的值,这些值将提供预期的结果。使用此夹具,mock_random_choice 选择夹具在 choice()函数的响应中返回预期值。

    3. 我们现在可以定义一个测试函数,该函数将使用 mock_random_choice 测试夹具,它创建一个模拟对象,以及 monkeypatch 测试夹具,它允许我们调整被测试模块的上下文:

      def test_resample( 
      
          mock_random_choice: Mock, 
      
          expected_resample_data: list[int], 
      
          monkeypatch: pytest.MonkeyPatch, 
      
      ) -> None:
      
    4. 我们可以使用 monkeypatch 夹具用 mock_random_choice 夹具创建的 Mock 对象替换 random 模块的 choice 属性:

       monkeypatch.setattr(recipe_09.random, "choice", mock_random_choice)  # type: ignore [attr-defined]
      

      在夹具定义和这个补丁之间,我们创建了一个 Given 步骤,该步骤定义了测试安排。

    5. 我们现在可以在一个受控的测试环境中练习 resample()函数。这是当步骤,用于测试被测试的代码:

       data = [2, 3, 5, 7, 11, 13, 17, 19] 
      
          resample_data = list(recipe_09.resample(data, 8))
      
    6. 由于随机选择被 Mock 对象固定,结果是固定的。我们可以确认由 mock_random_choice 夹具创建的数据被用于重采样。我们还可以确认模拟的选择函数是否正确地用输入数据调用了:

       assert resample_data == expected_resample_data 
      
          assert mock_random_choice.mock_calls == 8 * [call(data)]
      

    这个测试有助于确认我们的 resample()函数将根据给定的输入和 random.choice()函数创建输出。

    15.9.3 它是如何工作的...

    当我们创建一个 Mock 对象时,我们必须提供方法和属性来定义被模拟对象的行为。当我们创建一个提供 side_effect 参数值的 Mock 实例时,我们正在创建一个可调用对象。每次调用 Mock 对象时,可调用对象将返回 side_effect 序列中的下一个值。这为我们提供了一个方便的方式来模拟迭代器。

    如果 side_effect 中的任何值是异常,则会引发异常。

    我们还可以通过 Mock 对象的 mock_calls 属性查看调用历史。这使我们能够确认可调用对象提供了适当的参数值。

    15.9.4 更多...

    resample()函数有一个有趣的模式。当我们从细节中退一步时,我们看到的是这个:

    def resample_pattern(X: Any, Y: Any) -> Iterator[Any]: 
    
        for _ in range(Y): 
    
            yield another_function(X)
    

    X 参数值简单地传递给另一个函数,没有任何处理。对于测试目的,X 的值无关紧要。我们正在测试的是 resample()函数中的参数值是否被提供给另一个 _function()函数,且未被改变。

    模拟库提供了一个名为 sentinel 的对象,可以在这种情况下创建一个不透明的参数值。当我们引用哨兵对象的属性时,这个引用创建了一个不同的对象。我们可能会使用 sentinel.POPULATION 作为一组值的模拟。确切的集合无关紧要,因为它只是作为另一个函数(在实际情况中称为 random.choice())的参数传递。

    下面是如何使用哨兵对象来改变这个测试的示例:

    from unittest.mock import Mock, call, sentinel 
    
    @pytest.fixture() 
    
    def mock_choice_s() -> Mock: 
    
        mock_choice = Mock(name="mock random.choice()", return_value=sentinel.CHOICE) 
    
        return mock_choice 
    
    def test_resample_2( 
    
            mock_choice_s: Mock, monkeypatch: pytest.MonkeyPatch 
    
    ) -> None: 
    
        monkeypatch.setattr( 
    
            recipe_09.random, "choice", mock_choice_s # type: ignore [attr-defined] 
    
        ) 
    
        resample_data = list(recipe_09.resample(sentinel.POPULATION, 8)) 
    
        assert resample_data == [sentinel.CHOICE] * 8
    

    模拟的 choice()函数的输出是一个可识别的哨兵对象。同样,resample()函数的参数是另一个哨兵对象。我们预计这将被调用 8 次,因为测试用例中 N 参数被设置为 8。

    当一个对象应该通过一个函数而不被改变时,我们可以编写测试断言来确认这种预期的行为。如果我们正在测试的代码不正确地使用了总体对象,当结果不是未被改变的哨兵对象时,测试可能会失败。

    mypy 工具的 1.7.1 版本在 recipe_09 模块的导入上遇到了困难。我们使用了# type: ignore [attr-defined]注释来抑制一个令人困惑的 mypy 消息。

    这个测试让我们有信心,值集的总体是直接提供给 random.choice()函数的,而 N 参数值定义了从总体中返回的项目集合的大小。

    15.9.5 参见

    • 第四章中的构建集合 – 字面量、添加、列表推导和运算符配方,第五章中的创建字典 – 插入和更新配方,第六章中的使用 cmd 创建命令行应用程序配方展示了如何设置随机数生成器的种子以创建一个可预测的值序列。

    • 在第七章中,有其他几个配方展示了替代方法,例如,使用类封装数据和处理,设计具有大量处理的类,使用 slots 优化小对象,以及使用属性实现懒属性。

    • 此外,在第八章中,请参阅在继承和组合之间选择——“是”问题、通过多重继承分离关注点、利用 Python 的鸭子类型和创建具有可排序对象的类的配方。

    15.10 模拟外部资源

    在本章前面的配方中,即测试涉及日期或时间的事物和测试涉及随机性的事物,我们为具有可预测和模拟状态的资源编写了测试。在一个案例中,我们创建了一个具有固定当前时间响应的模拟 datetime 模块。在另一个案例中,我们创建了一个来自 random 模块的模拟函数。

    一个 Python 应用程序可以使用 os、subprocess 和 pathlib 模块对正在运行的计算机的内部状态进行重大更改。我们希望能够在安全的环境中测试这些外部请求,使用模拟对象,并避免因配置不当的测试而破坏工作系统的恐怖。另一个例子是数据库访问,它需要模拟对象来响应创建、检索、更新和删除请求。

    在这个配方中,我们将探讨创建更复杂的模拟对象的方法。这将允许安全地测试对宝贵的 OS 资源(如文件和目录)的更改。

    15.10.1 准备工作

    我们将重新访问一个进行多项 OS 更改的应用程序。在第十一章中,替换文件同时保留上一个版本配方展示了如何编写一个新文件,然后重命名它,以便始终保留上一个副本。

    一套详尽的测试用例将展示各种故障模式。对几种不同类型的错误进行测试可以帮助我们确信该函数的行为是正确的。

    核心设计是一个对象的类定义,名为 Quotient,以及一个 save_data()函数,用于将其中一个对象写入文件。以下是代码的概述:

    from pathlib import Path 
    
    import csv 
    
    from dataclasses import dataclass, asdict, fields 
    
    @dataclass 
    
    class Quotient: 
    
        numerator: int 
    
        denominator: int 
    
    def save_data(output_path: Path, data: Quotient) -> None:
    
        ... # Details omitted
    

    考虑到在 save_data()函数中间出现故障时会发生什么。结果包括部分重写的文件,对其他应用程序无用。为了防止这种情况,配方中提出了一个 safe_write()函数,该函数包括创建临时文件并将该文件重命名为所需输出文件的几个步骤。本质上,该函数看起来是这样的:

    def safe_write(output_path: Path, data: Quotient) -> None: 
    
        ... # Details omitted
    

    safe_write()函数在第十一章中有详细说明。它旨在处理多种场景:

    1. 一切都正常工作——有时被称为“幸福路径”——并且文件被正确创建。

    2. save_data()函数引发异常。损坏的文件被删除,原始文件保留在原位。

    3. 故障发生在 safe_write()处理过程中的其他地方。有三个场景中,Path 方法会引发异常。

    上述每个场景都可以翻译成 Gherkin 来帮助精确地说明其含义;例如:

    Scenario: save_data() function is broken. 
    
    Given some faulty set of data, "faulty_data", that causes a failure in the save_data() function 
    
    And an existing file, "important_data.csv" 
    
    When safe_write("important_data.csv", faulty_data) is processed 
    
    Then safe_write raises an exception 
    
    And the existing file, "important_data.csv" is untouched
    

    详细说明这五个场景有助于我们定义模拟对象,以提供所需的各类外部资源行为。每个场景都建议一个独特的固定装置来反映不同的故障模式。

    15.10.2 如何做到...

    我们将使用各种测试技术。pytest 包提供了 tmp_path 固定装置,可以用来创建隔离的文件和目录。除了隔离目录外,我们还想使用模拟来代替我们未测试的应用程序部分:

    1. 确定各种场景所需的全部固定装置。对于快乐的路径,即模拟最少的情况,我们只需要 tmp_path 固定装置。对于第二个场景,即 save_data()函数出现故障的情况,这个函数应该被模拟。对于剩余的三个场景,可以定义模拟对象来替换 Path 对象的方法。

    2. 这个测试将使用 pytest 和 unittest.mock 模块的多个功能。它将在 recipe_10 模块中创建 Path 对象和定义的测试函数:

      from pathlib import Path 
      
      from typing import Any 
      
      from unittest.mock import Mock, sentinel 
      
      import pytest 
      
      import recipe_10
      
    3. 编写一个测试固定装置来创建原始文件,除非一切正常,否则不应被干扰。我们将使用哨兵对象提供一些独特且可识别的文本,作为本测试场景的一部分:

      @pytest.fixture() 
      
      def original_file(tmp_path: Path) -> Path: 
      
          precious_file = tmp_path / "important_data.csv" 
      
          precious_file.write_text(hex(id(sentinel.ORIGINAL_DATA)), encoding="utf-8") 
      
          return precious_file
      
    4. 编写一个模拟来替换 save_data()函数。这将创建用于验证 safe_write()函数是否正常工作的模拟数据。在此,我们也将使用哨兵对象来创建一个在测试中可识别的唯一字符串:

      def save_data_good(path: Path, content: recipe_10.Quotient) -> None: 
      
          path.write_text(hex(id(sentinel.GOOD_DATA)), encoding="utf-8")
      
    5. 编写快乐的路径场景。可以将 save_data_good()函数作为模拟对象的副作用,并用来替代原始的 save_data()函数。使用模拟意味着将跟踪调用历史,这有助于确认正在测试的整体 safe_write()函数确实使用了 save_data()函数来创建预期的结果文件:

      def test_safe_write_happy(original_file: Path, monkeypatch: pytest.MonkeyPatch) -> None: 
      
          mock_save_data = Mock(side_effect=save_data_good) 
      
          monkeypatch.setattr(recipe_10, "save_data", mock_save_data) 
      
          data = recipe_10.Quotient(355, 113) 
      
          recipe_10.safe_write(Path(original_file), data) 
      
          actual = original_file.read_text(encoding="utf-8") 
      
          assert actual == hex(id(sentinel.GOOD_DATA))
      
    6. 编写第二个场景的模拟,其中 save_data()函数无法正确工作。模拟可以依赖于 save_data_failure()函数来写入可识别的损坏数据,然后也引发一个意外的异常:

      def save_data_failure(path: Path, content: recipe_10.Quotient) -> None: 
      
          path.write_text(hex(id(sentinel.CORRUPT_DATA)), encoding="utf-8") 
      
          raise RuntimeError("mock exception")
      
    7. 使用 save_data_failure()函数作为模拟对象的副作用,编写第二个场景的测试用例:

      def test_safe_write_scenario_2( 
      
          original_file: Path, monkeypatch: pytest.MonkeyPatch 
      
      ) -> None: 
      
          mock_save_data = Mock(side_effect=save_data_failure) 
      
          monkeypatch.setattr(recipe_10, "save_data", mock_save_data) 
      
          data = recipe_10.Quotient(355, 113) 
      
          with pytest.raises(RuntimeError) as ex: 
      
              recipe_10.safe_write(Path(original_file), data) 
      
          actual = original_file.read_text(encoding="utf-8") 
      
          assert actual == hex(id(sentinel.ORIGINAL_DATA))
      

      save_data_failure()函数写入了损坏的数据,但 safe_write()函数保留了原始文件。

    这个配方产生了两个测试场景,确认 safe_write()函数将正常工作。我们将在本配方中的“还有更多...”部分稍后讨论剩余的三个场景。

    15.10.3 它是如何工作的...

    当测试制作操作系统、网络或数据库请求的软件时,包括外部资源无法按预期操作的情况的测试案例是至关重要的。完成这项工作的主要工具是 Mock 对象和 monkeypatch 夹具。一个测试可以用引发异常而不是正确工作的 Mock 对象替换 Python 库函数。

    对于愉快的路径场景,我们用 Mock 对象替换了 save_data() 函数,并写入了一些可识别的数据。因为我们使用了 tmp_path 夹具,所以文件被写入到一个安全、临时的目录中,可以检查以确认新、良好的数据替换了原始数据。

    对于第一个故障场景,我们使用 monkeypatch 夹具替换了 save_data() 函数,用一个既写入损坏数据又引发异常(仿佛发生了操作系统问题)的函数。这是模拟涉及某种持久文件系统实体的广泛应用程序故障的一种方法。在更简单的情况下,如果没有实体,只需要一个具有异常类作为 side_effect 参数值的 Mock 对象来模拟故障。

    这些测试场景还使用了独特的哨兵对象。评估 hex(id(x)) 的值提供了一个难以预测的独特字符串值。

    15.10.4 更多内容...

    剩余的场景非常相似;它们都可以共享以下测试函数:

    def test_safe_write_scenarios( 
    
            original_file: Path, 
    
            mock_pathlib_path: Mock, 
    
            monkeypatch: pytest.MonkeyPatch 
    
    ) -> None: 
    
        mock_save_data = Mock(side_effect=save_data_good) 
    
        monkeypatch.setattr(recipe_10, "save_data", mock_save_data) 
    
        data = recipe_10.Quotient(355, 113) 
    
        with pytest.raises(RuntimeError) as exc_info: 
    
            recipe_10.safe_write(mock_pathlib_path, data) 
    
        actual = original_file.read_text(encoding="utf-8") 
    
        assert actual == hex(id(sentinel.ORIGINAL_DATA)) 
    
        mock_save_data.assert_called_once() 
    
        mock_pathlib_path.with_suffix.mock_calls == [ 
    
            call("suffix.new"), call("suffix.old") 
    
        ] 
    
        # Scenario-specific details...
    

    当调用模拟的 save_data() 函数时,此函数使用 save_data_good() 函数作为副作用。给定的 save_data_good() 函数将被执行,并将写入一个已知良好的测试文件。这些场景中的每一个都涉及在创建良好文件之后的路径操作异常。

    我们省略了展示任何特定场景的细节。这个测试的关键特性是即使在出现异常的情况下也能保留原始的良好数据。

    为了支持多个异常场景,我们希望使用三个不同版本的 mock_pathlib_path 模拟对象。

    我们可以使用参数化夹具来指定这些模拟对象的三个替代配置。首先,我们将选择打包为三个单独的字典,提供 side_effect 值:

    scenario_3 = { 
    
        "original": None, "new": None, "old": RuntimeError("3")} 
    
    scenario_4 = { 
    
        "original": RuntimeError("4"), "new": None, "old": None} 
    
    scenario_5 = { 
    
        "original": None, "new": RuntimeError("5"), "old": None}
    

    我们已使用 RuntimeError 作为引发异常,以触发替代执行路径。在某些情况下,可能需要使用 IOError 异常。在这种情况下,任何异常都行。

    给定这三个字典对象,我们可以通过 pytest 提供的 request.params 选项将值插入到夹具中:

    @pytest.fixture( 
    
        params=[scenario_3, scenario_4, scenario_5], 
    
    ) 
    
    def mock_pathlib_path(request: pytest.FixtureRequest) -> Mock: 
    
        mock_mapping = request.param 
    
        new_path = Mock(rename=Mock(side_effect=mock_mapping["new"])) 
    
        old_path = Mock(unlink=Mock(side_effect=mock_mapping["old"])) 
    
        output_path = Mock( 
    
            name="mock output_path", 
    
            suffix="suffix", 
    
            with_suffix=Mock(side_effect=[new_path, old_path]), 
    
            rename=Mock(side_effect=mock_mapping["original"]), 
    
        ) 
    
        return output_path
    

    由于这个夹具有三个参数值,任何使用这个夹具的测试都将运行三次,每次使用其中一个值。这使得我们可以重用 test_safe_write_scenarios() 测试用例,以确保它能够与各种系统故障一起工作。

    我们创建了许多 Mock 对象,以在复杂函数的处理过程中注入故障。使用参数化夹具有助于为这些测试定义一致的 Mock 对象。

    还有一种场景涉及对同一文件的成功操作随后是失败操作。这不符合上述模式,需要另一个稍微复杂一些的模拟对象测试用例。我们将这个作为你的练习。

    15.10.5 参见

    • 本章前面的测试涉及日期或时间的项目和测试涉及随机性的项目的食谱展示了处理不可预测数据的技术。

    • 可以使用 doctest 模块测试这些元素。参见本章前面的使用 docstrings 进行测试食谱中的示例。同时结合这些测试与任何 doctests 也很重要。参见本章前面的结合 pytest 和 doctest 测试食谱,了解更多如何进行这种结合的信息。

    加入我们的社区 Discord 空间

    加入我们的 Python Discord 工作空间,讨论并了解更多关于这本书的信息:packt.link/dHrHU

    PIC

    第十六章:16

    依赖关系和虚拟环境

    Python 在由操作系统定义的环境中运行。Windows、macOS 和大多数 Linux 环境之间有一些细微的差异。我们将把微控制器环境放在一边,因为定制这些环境的能力相当复杂。我们将尽量减少操作系统差异,以关注普遍可用的共同方面。

    运行时环境中有一些常见的方面。我们可以将这些分为两组:

    持久

    环境的各个方面变化缓慢。

    • 正在使用的 Python 运行时。这包括一个二进制应用程序,通常包括许多外部库。

    • 可用的标准库。这些库通过导入器访问,通常通过 import 语句访问。它们通常通过相对于 Python 二进制的路径找到。

    • 作为站点包安装的其他库。这些库也被导入器访问。这些库也可以通过它们相对于 Python 二进制的路径找到。

    • 通过站点包中其他机制找到的库。最值得注意的是,PYTHONPATH 环境变量。

    临时

    环境的各个方面每次启动 Python 运行时都可能发生变化。

    • 由当前使用的 shell 定义的环境变量。这些变量可以通过 os 模块访问。

    • 当前工作目录和用户信息,由操作系统定义。这可以通过 os、os.path 和 pathlib 模块访问。

    • 启动 Python 时使用的命令行。这可以通过 sys 模块的几个属性访问,包括 sys.argv、sys.stdout、sys.stdin 和 sys.stderr。

    持久环境通过操作系统级别的命令管理,在我们的应用程序程序之外。对环境持久方面的更改通常在 Python 启动时检查一次。这意味着我们编写的应用程序无法轻易安装一个包然后使用该包。

    持久环境有两个视角:

    实际环境
    单个站点由系统管理员处理并需要提升权限。例如,Python 运行时通常位于 root 用户拥有的路径中,并通过 PATH 环境变量的通用系统级值可见。
    虚拟环境
    任何数量的虚拟环境都可以由单个用户本地化,并且不需要特殊权限。多个 Python 运行时及其关联的站点包可以由单个用户拥有。

    从前有段时间——在很久以前,当计算机能力很小的日子里,一个单一的实际环境就是可以管理的全部。添加和更改已安装包的集合需要 Python 用户的合作。具有提升权限的管理员实施了任何更改。

    现在计算机的能力大大增强,每个用户都可以轻松地拥有多个虚拟环境。实际上,我们经常使用多个虚拟环境构建和测试模块,这些虚拟环境反映了 Python 运行时的不同版本。每个用户都能够管理自己的虚拟环境。

    在协作工作时,共享虚拟环境的详细信息变得很重要,这样多个用户就可以重新创建一个共同的虚拟环境。将单个实际环境的共享工作转移到每个用户需要准备和管理自己的虚拟环境。

    环境管理似乎与金斯伯格定理和热力学定律相平行:

    • 整体环境管理的工作量既不会被创造也不会被毁灭。

    • 对环境的任何更改都需要工作。

    • 没有什么是不变的(除非它完全与所有外部因素隔离)。

    尽管大多数 Linux 发行版都预装了 Python,但没有充分的理由要使用这个版本的 Python 来完成任何任务。通常,安装个人版本的 Python 并使用该版本管理虚拟环境要容易得多。拥有个人 Python 安装允许用户随时更新到新版本,而无需等待 Linux 发行版赶上最新技术。

    管理环境涉及两大类工具:

    • 安装 Python 二进制文件所需的特定于操作系统的工具。这因操作系统而异,对于新开发者来说可能具有挑战性。我们将避免涉及这些工具的复杂性,并将读者指引到 www.python.org/downloads/ 页面。

    • 用于安装 Python 库的基于 Python 的工具,如 PIP。由于这些工具依赖于 Python,因此命令对所有操作系统都是通用的。本章将专注于这些工具。

    在本章中,我们将探讨以下管理虚拟环境的配方:

    • 使用内置的 venv 创建环境

    • 使用 requirements.txt 文件安装包

    • 创建 pyproject.toml 文件

    • 使用 pip-tools 管理 requirements.txt 文件

    • 使用 Anaconda 和 conda 工具

    • 使用诗歌工具

    • 应对依赖关系的变化

    我们将从使用内置工具创建虚拟环境开始。

    16.1 使用内置的 venv 创建环境

    一旦安装了 Python,就可以使用内部 venv 模块为每个项目创建独特的虚拟环境。

    虚拟环境有两个主要用途:

    • 管理 Python 版本。我们可能需要为 Python 3.12 和 Python 3.13 创建不同的虚拟环境。在某些情况下,我们可能需要管理 Python 3.13 的多个小版本。

    • 管理项目所需的特定站点包的混合。我们不必尝试更新单个实际环境,当新版本的包可用时,我们可以创建新的虚拟环境。

    这两个用例有很大的重叠。Python 的每个版本都将有标准库包的不同版本,并且可能有外部特定站点的不同版本。

    使用虚拟环境最重要的部分是确保它已经被激活。许多场景会改变浏览器的内部状态,使虚拟环境失效。关闭终端窗口和重新启动计算机是使环境失效的两种最常见方式。

    切换终端窗口或打开新的终端窗口可能会启动一个 shell 环境,其中虚拟环境未激活。在开始使用之前激活环境可以轻松解决这个问题。

    16.1.1 准备工作

    重要的是要注意 Python 必须安装。Python 可能不存在,并且不应使用操作系统中的 Python 版本进行开发或实验。对于 macOS 和 Windows,通常安装预构建的二进制文件。这可能涉及下载磁盘映像并运行安装程序或下载安装程序应用程序并运行它。

    对于 Linux,通常从源代码构建特定分布的 Python。另一种选择是使用像 rpm、pkg、yum 或 aptitude 这样的管理工具安装特定分布的预构建 Python。

    大多数 Python 发布版将包括 pip 和 venv 包。微控制器 Python 和基于 WASM 的 Python 通常难以使用桌面工具更新;它们超出了本书的范围。

    16.1.2 如何操作...

    首先,我们将查看创建一个虚拟环境,该环境可以用于安装包和解决导入。一旦环境创建完成,我们将查看如何激活和停用它。环境必须处于活动状态才能正确安装和使用包。

    避免将虚拟环境置于配置控制之下是很重要的。相反,用于重新创建环境的配置细节被置于配置控制之下。

    当使用 Git 等工具时,可以使用.gitignore 文件来忽略项目的任何虚拟环境细节。另一种方法是将与特定项目目录分开的虚拟环境定义。

    创建虚拟环境

    1. 首先,创建项目目录。对于非常小的项目,不需要额外的文件。对于大多数项目,src、tests 和 docs 目录通常有助于组织项目代码、测试代码和文档。

    2. 在“隐藏”文件和可见文件之间进行选择。在 Linux 和 macOS 中,以.开头的文件通常不会被大多数命令显示。由于虚拟环境不是我们将要工作的目录,通常最简单的方法是使用名称.venv。

      在某些情况下,我们希望目录可见。那么,venv 名称将是最佳选择。

    3. 以下命令将创建虚拟环境:

      % python -m venv .venv
      

      虚拟环境将位于项目目录中的 .venv 目录内。

    完成此操作后,必须激活虚拟环境。每次打开新的终端窗口时,该窗口中的环境都需要被激活。

    激活和停用环境

    激活虚拟环境需要特定的操作系统命令。Python 标准库文档提供了所有变体命令。我们将展示两种最常见的变体:

    • 对于 Linux 和 macOS,使用 bash 或 zsh,输入以下命令以激活虚拟环境:

      % source .venv/bin/activate
      
    • 对于 Windows 系统,输入以下命令以激活虚拟环境:

      > .venv\Scripts\activate
      

    一旦激活了虚拟环境,许多环境变量将会改变。最值得注意的是,PATH 环境变量将包括虚拟环境的 bin 目录。例如,这将使 deactivate 命令可用。此外,提示将更改以包括虚拟环境的名称。它可能看起来像以下这样:

    C:\Users\Administrator>.venv\Scripts\activate 
    
    (.venv) C:\Users\Administrator>
    

    在第一行,默认提示显示目录。在第二行,提示以 (.venv) 作为前缀以显示虚拟环境现在已激活。

    一旦激活了虚拟环境,所有进一步使用 pip 命令安装包的操作都将指向活动环境。任何运行的 Python 应用程序将搜索活动环境以安装包。

    要停用环境,请使用以下命令:

    % deactivate
    

    activate 命令作为虚拟环境的一部分创建了此新命令,因此它对所有操作系统都是通用的。

    16.1.3 它是如何工作的...

    对于大多数操作系统,有几个关键环境变量定义了虚拟环境。PATH 环境变量通常提供查找 Python 可执行文件的位置。在 Windows 环境中,这也会使启动器、py 命令可用。

    剩余 Python 元素的位置都是相对于可执行文件的。特别是,标准库是一个相邻路径,这个库包含 sites 包,用于处理定位已安装包的所有其他细节。

    虚拟环境的详细信息由三个目录和一个配置文件定义。

    配置文件 pyvenv.cfg 提供了一些重要的设置。三个目录是 bin、include 和 lib。(在 Windows 上,这些名称分别是 Scripts、Include 和 Lib)。bin 目录包含执行虚拟环境激活的脚本文件。设置 PATH 环境变量使这些脚本可用。这包括 deactivate 命令。此外,bin 目录还包含一个 pip 可执行命令和一个指向正确 python 二进制的链接。

    16.1.4 更多内容...

    venv 命令有许多选项。其中,有两个似乎特别有用:

    • --without-pip 选项跳过了安装特定于 venv 的 PIP 副本。似乎使用 python -m pip 比依赖虚拟环境安装更好。

    • --prompt 选项可以设置比.venv 更友好的环境名称。

    我们通常会使用以下命令之一来激活环境:

    % python -m venv --prompt ch17 --without-pip .venv
    

    这将确保提示变为(ch17)而不是模糊且可能令人困惑的(.venv)。

    16.1.5 参见

    • 一旦创建了虚拟环境,我们就可以添加外部库。参见使用 requirements.txt 文件安装包以获取管理依赖项的建议。

    16.2 使用 requirements.txt 文件安装包

    Python 的一个显著优势是 Python 包索引(PyPI)等库中可用的庞大生态系统,pypi.org。使用 PIP 工具向环境中添加库很容易。

    在某些情况下,这可能——也许——过于简单。所有依赖项,从构建 Python 运行时所需的库开始,都处于不断变化的状态。每个都有其独特的更新节奏。在某些情况下,众多参与者之间的合作有限。

    为了管理不断的变化,对于开发应用程序的人来说,仔细跟踪依赖项非常重要。我们建议将依赖项分解为三个具体性级别:

    • 通用、仅名称的依赖项:例如,一个应用程序可能需要 Beautiful Soup。

    • 过滤:随着 Beautiful Soup 项目的演变,可能会有已知错误或缺少基本功能的版本。我们可能希望缩小依赖范围,省略或排除特定版本,或者要求版本为>=4.0。

    • 固定(或锁定):当需要构建(和测试)特定虚拟环境时,拥有用于测试的确切版本号的详细列表是至关重要的。

    当我们最初探索数据或问题领域或候选解决方案时,我们可能会将大量包下载到开发环境中。随着项目的成熟,虚拟环境的内容将发生变化。在某些情况下,我们会发现我们不需要某个包;未使用的包将被忽略并应该被删除。在其他情况下,随着新选项的探索,包的组合将扩大。在这个过程中,固定的版本号可能会改变,以跟踪项目所依赖的包的可接受版本。

    16.2.1 准备工作

    记录通用依赖项在 pyproject.toml 文件等地方效果很好。(我们将在创建 pyproject.toml 文件的配方中查看这一点。)

    特定的、固定的依赖项可以被分离成一系列的需求文件。有许多依赖项使用案例,导致一系列密切相关文件的产生。

    requirements 文件格式作为 PIP 文档的一部分定义。请参阅 Requirements File Format 页面,位于 packaging.python.org

    16.2.2 如何做...

    1. 收集一般需求。最好查看导入语句以区分直接依赖的包。我们可能会发现一个项目使用了 pydantic、beautifulsoup4、jupyterlab、matplotlib、pytest 和 memray。

    2. 在项目的顶层目录中打开名为 requirements.txt 的文件。

    3. 文件的每一行都将有一个包含四部分信息的 requirements 指定符:

      • 包名。请注意,打字错误是开源项目中的一个普遍问题;确保找到包的正确、当前存储库,而不是类似的名字。

      • 所需的任何额外内容。如果存在,这些内容被括号包围。例如,当使用 rich 包进行 Jupyter Lab 的文本样式时,可能会使用 rich[jupyter]。

      • 版本指定符。这有一个比较运算符(==、>= 等),以及一个由数字点序列组成的版本。例如,pillow>=10.2.0 选择任何 10.2.0 版本或更高版本的 pillow 包,避免与 10.1.0 版本的已知漏洞。

      • 如果需要,任何由分号分隔的进一步环境约束。例如,sys_platform == 'win32' 可能被用来提供特定平台的要求。

      虽然可以创建复杂的条件,但它们并不经常需要。最好避免编写版本信息,除非出现特定的错误修复、缺失功能或兼容性问题。

      该文件的完整规则集在 PEP 508 文档中。

      版本指定符在 Python 打包指南中定义。请参阅 版本指定符 页面,位于 packaging.python.org

      例如,以下是依赖项列表:

      pydantic 
      
      beautifulsoup4 
      
      types-beautifulsoup4 
      
      jupyterlab 
      
      matplotlib 
      
      pytest 
      
      memray
      
    4. 激活项目的虚拟环境(如果尚未激活):

      % source .venv/bin/activate
      
    5. 运行以下命令来安装指定包的最新版本:

      (ch17) % python -m pip install -r requirements.txt
      

      PIP 应用程序将找到各种包的匹配版本并将它们安装。由于一些这些包有复杂的依赖层,第一次尝试安装可能会比较耗时。

      这七个包的总数扩展到大约 111 个必须安装的包。

    对于许多项目,这已经足够构建一个有用的环境定义。在许多情况下,这个基本定义需要提供更具体的版本信息。这是一个单独的配方;请参阅 使用 pip-tools 管理 requirements.txt 文件。

    16.2.3 它是如何工作的...

    PIP 应用程序使用 -r 选项解析包含所需包的文件。在此文件中,我们可以有简单的包列表,以及用于定位包正确版本的复杂规则。我们甚至可以拥有其他 -r 选项以合并其他要求文件。使用多个文件可以帮助组织非常复杂的项目。

    当我们命名一个包为 PIP 时,它将检查目标元数据以定位它所依赖的包。这些传递依赖必须在目标包安装之前安装。这意味着必须构建一个显示所有依赖项的内部晶格结构。这可能涉及下载多个包的副本,因为版本约束被解析为单个、最终的包安装列表。

    虽然使用 PIP 手动安装单个包很容易,但这会导致对项目需要什么以及虚拟环境中当前安装了什么产生混淆。避免这种情况需要一种纪律性的方法,在探索新包时始终做以下两件事:

    • 将包添加到 requirements.txt 文件中。

    • 运行 python -m pip install -r requirements.txt 以向当前虚拟环境添加包。

    当从 requirements.txt 文件中删除包时,我们通常可以通过删除虚拟环境并创建一个全新的环境来继续操作。这会导致以下命令序列被使用:

    % (ch17) deactivate 
    
    % python -m venv --clear --prompt ch17 .venv 
    
    % source .venv/bin/activate 
    
    % (ch17) python -m pip install -r requirements.txt
    

    因为 PIP 维护了一个已下载文件的缓存,所以这个环境重建起来相对较快。使用 requirements.txt 确保环境以可重复的方式构建。

    16.2.4 更多...

    手动安装组件并发现冲突是非常常见的。例如,一个同事克隆了一个仓库,但无法运行单元测试套件,因为 requirements.txt 文件不完整。

    另一个案例是对开发环境的审计。随着新成员加入团队,他们可能会安装 requirements.txt 文件中命名的包的新版本。为了确保每个人都使用相同的版本,冻结虚拟环境中包的版本信息很有帮助。

    对于这两种用例,可以使用 python -m pip freeze 命令。这将报告所有已安装的包及其使用的版本。此输出的格式与 requirements 文件相同。

    我们可以使用以下命令:

    % source .venv/bin/activate 
    
    % (ch17) python -m pip freeze >audit_sfl.txt
    

    这些输出文件可以用来比较差异,并修复与预期不一致的环境。

    此外,pip freeze 子命令的输出可以用来替换一个通用的 requirements.txt 文件,以一个特别固定每个正在使用的包的文件。虽然这很简单,但它并不非常灵活,因为它提供了特定版本。使用 pip-tools 有更好的方法来构建 requirements.txt 文件。我们将在使用 pip-tools 管理 requirements.txt 文件中查看这一点。

    16.2.5 参见

    • 请参阅使用内置 venv 创建环境的配方,了解如何创建虚拟环境。

    • 请参阅使用 pip-tools 管理 requirements.txt 文件的配方,了解如何管理依赖项。

    16.3 创建 pyproject.toml 文件

    除了虚拟环境和清晰的依赖列表之外,项目还可以从整体总结中受益,这种总结以 pyproject.toml 文件的形式存在。

    pyproject.toml 文件是某些 Python 工具所必需的,并且通常很有帮助。它提供了项目技术细节的集中总结。

    随着PEP 621的采用,此文件已成为关于项目元数据的预期位置。它取代了旧的 setup.py 模块。

    本配方基于Sample Project项目,该项目位于github.com/pypa的打包权威 Git 仓库中。该配方还基于打包 Python 项目页面,这是打包权威教程之一。请参阅packaging.python.org

    16.3.1 准备工作

    我们假设项目不是一个简单的单文件模块,而是一个更大的项目。这意味着将会有一个类似以下结构的目录结构:

    YLRpdcisyimttoIEyoonronoeeuCApcndcuidssrEDrsfert.uttNMo.xpplsmPSEjp.ayeorE.eyrc.domcstkpujdtayle.gectoe.tmply

    图 16.1:项目文件

    我们已经展示了一个适用于许多项目的常见结构。顶级名称“Your Project”是一个适用于您项目集合的名称。

    src 目录内 your_package 的名称是当它被导入时包将被认识的名称。这不必与整体项目名称完全匹配,但它应该有一个清晰的关系。例如,Beautiful Soup 项目在 PYPI 上有名为 beautifulsoup4 的条目,但在您的 Python 本地 site packages 中导入的包被命名为 bs4。这种联系是清晰的。

    我们已经展示了 README.md 文件,其扩展名表明它使用 Markdown 标记编写。常见的替代方案是 README.rst 和 README。

    LICENSE 文件可能是一个困难的选择。请参阅spdx.org/licenses/以获取开源许可证的完整列表。请参阅GNU 许可证列表以获取有关各种开源许可证的建议。www.gnu.org

    docs 目录的内容通常使用 Sphinx 等工具构建。我们将在第十七章中讨论文档。

    16.3.2 如何操作...

    1. 确保 README.md 文件包含有关如何安装和使用项目的总结。随着项目的演变,这些内容可能会发生变化。

      有六个基本问题:“谁?”,“什么?”,“为什么?”,“何时?”,“何地?”,“如何?”这些问题可以帮助撰写简短的段落来描述项目。C4 模型提供了关于如何描述软件的额外帮助。请参阅C4 模型

    2. 确定将使用哪个构建系统。选择包括 setuptools、hatch 和 poetry。pyproject.toml 文件的内容中的一些部分将仅适用于构建系统。

      对于这个配方,我们将使用 setuptools 作为构建工具。

    3. 对于 pyproject.toml 文件,有大量的模板可供选择。PYPA 示例项目示例是全面的,也许有点令人望而生畏。TOML 中有两个表是必需的:[项目]和[构建系统]。在入门时可以忽略其余部分。

      这里是[项目]表的简短模板:

      [project] 
      
      name = "project_name" 
      
      version = "2024.1.0" 
      
      description = "A useful description." 
      
      requires-python = ">=3.12" 
      
      authors = [ 
      
        {email = "your.email@example.com", name = "Your Name"} 
      
      ] 
      
      dependencies = [ 
      
          your dependencies 
      
      ] 
      
      readme = "README.md" 
      
      license = {file = "LICENSE"}
      

      前六项需要用有关您项目的实际情况替换。最后两项,readme 和 license,通常不会改变,因为它们是项目目录中文件的引用。

      名称必须是项目的一个有效标识符。它们由PEP-508定义。它们是由字母、数字和特殊字符-、_ 和.组成的名称。有趣的是,它们不能包含空格或以标点符号结尾。ch17-recipe3 是可以接受的,但 ch17_ 是无效的。

      依赖项必须是一系列直接需求,必须安装这些需求才能使此项目工作。这些与 requirements.txt 文件中提供的依赖项规范相同。有关更多信息,请参阅使用 requirements.txt 文件安装包。

      这里是[构建系统]表的模板。这个模板使用了小型、广泛可用的 setuptools 工具:

      [build-system] 
      
      build-backend = "setuptools.build_meta" 
      
      requires = [ 
      
          "setuptools", 
      
      ]
      
    4. 使用 tomllib 打开此文件以确认其格式正确可能会有所帮助。这可以在 Python 控制台中交互式地完成,如下所示:

      
      >>> from pathlib import Path 
      
      >>> import tomllib 
      
      >>> doc = Path("pyproject.toml").read_text() 
      
      >>> tomllib.loads(doc)
      

      如果文件在某些方面无效,这将引发 tomllib.TOMLDecodeError 异常。异常将提供语法错误的行和列,或者当结构未正确终止时,它将说“在文档末尾”。

    16.3.3 它是如何工作的...

    许多工具都使用了 pyproject.toml 的内容。PIP 和 pyproject.toml 文件中命名的构建工具之间存在复杂的关系。对于这个配方,我们使用 setuptools。

    以下图表总结了下载和安装库所涉及的一些步骤:

    ysPPdBwtpPp1234r5r6oitYIouhakyip....ea.ea.uePPwierggP g c g bd cd ir-InleeIieaeusrsnpldltntctiescao TPsshrlatocaoateedtamkdocasqwellpalklluhssugaietegreeeseslr...hohookok

    图 16.2:PIP 和构建工具如何协作

    这个总结图表既不是对包安装的详尽审查,也不是最终审查。有关更多信息,请参阅PEP-517

    处理从 pip install 命令开始,如边界图标所示。PIP 操作通过编号步骤进行:

    1. PIP 首先从像 PYPI 这样的包索引获取压缩存档。

    2. 存档被缓存在本地的计算机上以供将来使用。

    3. PIP 使用 get_requires_for_build_wheel 构建工具钩子来收集需求。构建工具从 pyproject.toml 文件中获取依赖信息,并将其提供给 PIP。PIP 工具将下载这些额外的项目。这些项目有自己的需求。需求图被解析以确定所有必需的安装。

    4. 在某些情况下,需要一个新的 wheel 格式文件。在其他情况下,项目提供了一个 wheel 格式的文件。PIP 工具可以使用 build_wheel 构建工具钩子将下载的文件组合成可安装的形式。

      一些发行版包括源文件,可能还包括数据文件或脚本,这些文件不是简单地复制到 site-packages 目录的。

    5. 然后,PIP 将 wheel 文件安装到虚拟环境的适当 site-packages 目录中。

    可能用于构建包的构建工具包括 setuptools、build、hatch 和 poetry。所有这些构建工具都可以由 PIP 使用。它们都使用了 pyproject.toml。

    16.3.4 更多内容...

    除了项目运行所需的依赖项外,额外的依赖项通常基于我们可能对项目进行的其他操作。常见的额外用例包括运行测试、开发新功能和修复错误。

    这些额外用例的工具是可选依赖项。它们通常列在单独的表中,每个用例有自己的子表。例如,我们可能添加以下表格及其两个子表,以列出用于测试的工具,以及更多通用开发的其他工具:

    [project.optional-dependencies] 
    
    test = [ 
    
        "tox", 
    
        "ruff", 
    
        "mypy", 
    
        "pytest", 
    
        "pytest-cov" 
    
    ] 
    
    dev = [ 
    
        "pip-tools", 
    
        "sphinx" 
    
    ]
    

    这些额外的列表允许某人安装测试套件以确认下载的项目通过了所有测试用例。它们还允许某人下载维护文档和详细依赖列表的适当工具。

    注意,在这些示例中,没有一个依赖项使用特定的、固定的版本命名。这是因为我们将使用 pip-tools 从 pyproject.toml 文件中可用的信息构建一个 requirements.txt 文件。参见使用 pip-tools 管理 requirements.txt 文件。

    flit 和 twine 等工具常用于上传到像 PYPI 这样的存储库。对于企业开发者,可能有一个企业 Python 存储库。这些工具利用 pyproject.toml 文件中的额外表。

    例如,flit 工具使用额外的 [tool.flit.sdist][tool.flit.external-data] 表来提供执行上传所需的信息。

    16.3.5 参见

    • 请参阅python-semantic-release.readthedocs.io/en/latest/,了解可以根据 Git 提交消息修改版本名的 Python Semantic Release 工具。

    • 请参阅第十三章中的使用 TOML 配置文件,以获取有关 TOML 文件更多信息。

    • 请参阅使用 pip-tools 管理 requirements.txt 文件,了解我们将需求列表精炼成固定版本号的列表的方法。

    • Hypermodern Python 项目有一个模板,可以使用 Cookie-Cutter 工具构建目录结构。请参阅github.com/cjolowicz。此模板依赖于 Poetry 来管理依赖和虚拟环境。

    • 第十七章包含了最基本:一个 README.rst 文件的配方,以更深入地处理 README 文件。

    16.4 使用 pip-tools 管理 requirements.txt 文件

    如上所述,一个项目的依赖有三个级别的具体性:

    通用
    仅名称依赖
    过滤
    使用非常通用的约束如 >= 4.0
    固定
    使用特定版本如 == 4.12.2

    如何对这些级别进行对齐?一个简单的方法是使用 pip-tools 包。此包包括 pip-compile 工具,它将消化需求,解决依赖,并创建一个带有固定版本号的衍生 requirements.txt 文件。

    伴随工具 pip-sync 可以用来确保活动虚拟环境与 requirements.txt 文件匹配。这可以比删除和重新创建虚拟环境快得多。

    16.4.1 准备工作

    PIP-tools 必须下载并安装。通常,这是通过以下终端命令完成的:

    (ch17) % python -m pip install pip-tools
    

    这假设虚拟环境是激活的;在示例中,它被命名为 ch17。使用 python -m pip 命令确保我们将使用与当前激活虚拟环境关联的 pip 命令。

    pip-compile 工具将在 pyproject.toml 或 requirements.in 文件中定位需求。根据这些信息,它将构建一个详细的 requirements.txt 文件,该文件可用于 pip 或 pip-sync 来创建虚拟环境。

    16.4.2 如何操作...

    1. 确保依赖项在 pyproject.toml 文件中。在某些情况下,可能已经使用旧的 requirements.txt 文件开始。确认信息在 pyproject.toml 文件中是一个好主意,因为 requirements.txt 文件将被替换。

    2. 第一次这样做时,删除任何由 pip-compile 创建的旧 requirements.txt 文件可能会有所帮助。

    3. 要构建核心 requirements.txt 文件,请运行 pip-compile 命令:

      (ch17) % pip-compile
      

      这将在 pyproject.toml 文件中定位依赖项。然后,它将定位所有传递性依赖项,并构建一个已解决冲突的需求集。

      它将同时写入 requirements.txt 文件并在控制台上显示此文件。

    4. 要构建 requirements-test.txt 文件,请使用带有 --extra 选项的 pip-compile 命令:

      (ch17) % pip-compile --extra test -o requirements-test.txt
      

      这将创建一个文件,其中包含 [project.optional-dependencies] 表格中 test = [...] 部分的可选依赖项。

    5. 要构建一个包含所有额外内容的综合型 requirements-dev.txt 文件,请使用带有 --all-extras 选项的 pip-compile 命令:

      (ch17) % pip-compile --all-extras -o requirements-dev.txt
      

      这将创建一个包含 [project.optional-dependencies] 表格中所有可选依赖项的文件。

    6. 当需要时,使用 pip-sync 命令来重建当前虚拟环境,以匹配 requirements.txt 文件中的更改。

      通常,与 tox 工具一起使用。在一个测试环境描述的 commands_pre 部分,使用 pip-sync requirements.txt 确保测试虚拟环境与 requirements.txt 文件中的包版本同步。

    16.4.3 它是如何工作的...

    pip-compile 工具将在三个地方寻找信息:

    • pyproject.toml 文件。

    • 如果存在,则包含 requirements.in 文件。这不是必需的,因为相同的信息已经在 pyproject.toml 文件中。

    • 任何之前创建的 requirements.txt 文件。

    使用 pyproject.toml 和任何之前创建的 requirements.txt 文件可以让工具正确反映增量更改。这意味着它可以最小化分析未发生太大变化的项目的所需工作量。当开始一个新项目时,在做出重大更改后,有时删除整个 requirements.txt 文件是有帮助的。

    16.4.4 更多...

    在进行更改时,有两个选项可以帮助重建 requirements.txt 的详细信息:

    • --rebuild 选项将清除缓存并重新分析依赖项。

    • --upgrade some-package 选项将仅查找名为 some-package 的包的升级。这防止了对其他应该保持不变的包的分析。可以提供多个 --upgrade 选项以跟踪多个更改。

    这两个命令让我们可以管理增量更改,升级 requirements.txt,重建虚拟环境,并使用新版本的包进行测试。这确保了环境的描述与实际环境相匹配。我们可以有信心地分享项目。

    有时候,包会有冲突的需求。假设我们的项目依赖于项目 A 和项目 T。结果证明项目 A 也需要项目 T。当我们的项目需要 T >= 10.11(与 A 项目所需的版本不同,例如,T < 10.9)时,可能会出现问题。这可能很难解决。

    我们可以希望我们项目的限制 T >= 10.11 过于具体;我们可以放宽约束并找到一个兼容的版本。在其他情况下,项目 A 所声明的需求可能过于具体,我们需要考虑对其他项目的代码进行更改。理想情况下,这是一个适当的问题和拉取请求,但它可能需要分叉项目以提供不同的约束。最坏的情况需要重新设计我们的项目以改变依赖关系的性质,或者——也许——停止使用项目 A。

    在某些情况下,pyproject.toml 和 pip-compile 工具报告了令人沮丧的模糊错误信息:

    (ch17) % pip-compile 
    
    Backend subprocess exited when trying to invoke get_requires_for_build_wheel 
    
    Failed to parse /Users/slott/Documents/Writing/Python/Python Cookbook 3e/src/ch17/pyproject.toml
    

    这是 pyproject.toml 文件格式的问题。

    揭示问题的方法之一是在当前工作目录中尝试对项目进行“可编辑”安装。这将使用带有 -e . 选项的 pip install 命令,将当前目录用作要安装的项目。

    它看起来是这样的:

    (ch17) % pip install -e .
    

    这将报告在 pyproject.toml 文件中找到的具体错误。然后我们可以修复错误并再次运行 pip-compile。

    16.4.5 参见

    • 关于项目整体的更多背景信息,请参阅 PYPA 示例项目

    • 关于许可证的信息,请参阅 SPDX

    • 使用 requirements.txt 文件安装包 的配方描述了使用文件来驱动 PIP 安装。

    • 有关构建系统如何工作的更多信息,请参阅 PEP-517

    16.5 使用 Anaconda 和 conda 工具

    PIP 工具可以安装的包类型有一些限制。最明显的限制涉及涉及用编译语言(如 Rust 或 C)编写的扩展模块的项目。平台之间的差异——包括硬件和操作系统——可能会使得分发包的二进制文件的所需所有变体变得困难。

    在 Linux 环境中,由于 GNU CC 等编译器很容易获得,具有扩展模块的包可以包含源代码。PIP 工具可以使用编译器构建必要的二进制文件。

    对于 macOS 和 Windows,需要额外的工具来创建二进制文件。免费编译器不像在 Linux 环境中那样容易获得,这可能会带来潜在的问题。

    Conda 通过在其存储库中提供广泛的预构建二进制文件来解决二进制文件的问题。它还确保在预构建二进制文件不可用的情况下,目标平台上有一个可用的编译器。

    conda 工具是一个虚拟环境管理器和包安装程序。它满足与 PIP、venv 和 pip-tools 相同的使用案例。这包括包含二进制的包的构建,通常用于高性能数值应用。conda 的命令行界面在所有平台上都是相同的,允许更简单、更一致的文档。

    Anaconda 软件包索引由 Anaconda 公司维护。请访问他们的网站anaconda.com,了解价格和费用。提供的软件包已经集成并经过测试。这种测试需要时间,官方 Anaconda 发行版可能落后于 PYPI 上的可用内容。此外,它只是 PYPI 上可用内容的子集,因为它倾向于关注数据分析和数据科学。

    一个独立的软件包索引,conda-forge (conda-forge.org) 是基于社区的。这个频道包含的软件包更接近 PYPI 上的内容。在许多情况下,我们会从这个频道安装软件包,因为我们想要新的东西,或者我们想要 PYPI 上可用的精选子集之外的某些东西。

    16.5.1 准备工作

    获取 conda 工具有两种方式:

    • 下载并安装完整的 Anaconda 发行版。这是一个大文件下载,Windows 系统上从 900 MB 到更多 Linux 发行版超过 1,000 MB。

    • 下载并安装 miniconda,并使用它仅安装所需的软件包。这是一个较小的下载,通常大约 100 MB。

    对于完整的 Anaconda 安装,请参阅www.anaconda.com/download。安装程序有两种类型:

    图形界面
    这些安装程序使用操作系统交互式工具来支持一些配置。
    命令行
    这些安装程序是复杂的 shell 存档,在终端窗口中运行。它们提供与图形安装程序相同的安装选项。需要输入更多内容,点击和拖拽更少。

    对于 miniconda 安装,请参阅docs.conda.io/projects/miniconda/en/latest/index.html。每个操作系统都有略微不同的安装程序类型:

    Windows
    安装程序是一个可执行程序,它使用 Windows 安装程序。
    macOS
    有 PKG 图像可以下载并双击以使用 macOS UI。也有可以从终端窗口执行的命令行图像。
    Linux
    这些是从终端窗口启动的 shell-存档文件。

    虽然这里有很多选择,但我们推荐使用 Miniconda 的命令行安装程序。

    请参阅Miniconda页面,了解使用 curl 程序获取镜像并执行安装的推荐 shell 命令。

    一旦安装了 conda 工具,就可以用它来创建和填充虚拟环境。请注意,conda 工具创建了一个基本虚拟环境。当 conda 安装时,(base) 环境应显示为终端窗口提示的一部分。这作为一个视觉提示,表明没有其他环境已被激活。退出并重新启动所有终端窗口以确保 conda 正在运行可能会有所帮助。

    16.5.2 如何操作...

    确保安装了 conda 工具非常重要。请参阅本食谱的“准备就绪”部分,了解安装 conda 的建议。

    1. 使用 conda create 命令创建一个新的虚拟环境:

      % conda create -n cookbook3 python=3.12
      

      注意,命令在所有操作系统上都是相同的。

      虚拟环境的文件保存在项目目录之外。对于 macOS,将有一个 ~/miniconda3/envs 目录,其中包含所有虚拟环境文件。

    2. 使用 conda activate 命令激活这个新的虚拟环境:

      (base) % conda activate cookbook3
      
    3. 使用 conda install 命令在虚拟环境中安装一系列包。Conda 有自己的冲突解决器,与 PIP 工具或 pip-compile 使用的解决器是分开的。虽然我们可以使用 requirements.txt 文件,但我们实际上并不需要所有这些细节。通常,提供包名信息如该命令所示会更简单:

      (cookbook3) % conda install pydantic beautifulsoup4 jupyterlab matplotlib pytest memray
      
    4. 要创建当前虚拟环境的可共享定义,请使用 conda env export 命令:

      (cookbook3) % conda env export >environment.yml
      

      这使用 shell 重定向功能将导出的信息保存到一个 YAML 格式的文件中,该文件列出了所有需求。此文件可以被 conda env create 使用以重新创建此环境。

    16.5.3 它是如何工作的...

    conda 创建的虚拟环境已正确设置 PATH 环境变量,以指向特定的 Python 可执行文件。标准库包和特定于站点的包位于附近的目录中。

    这与内置的 venv 模块创建的虚拟环境相类似。它遵循 PEP-405 规则,该规则定义了虚拟环境的规则。

    为了保持一致性工作,conda 命令必须是可见的。这意味着基础 conda 安装也必须在系统 PATH 环境变量中命名。这是使用 conda 的一个关键步骤。Windows 安装程序有选项来更新系统路径,或者创建具有必要路径设置的专用命令窗口。同样,macOS 安装程序需要额外步骤来使 conda 命令可用于 zsh shell。

    Anaconda 存储库可能有预构建的二进制文件,这些二进制文件可以被 conda 工具下载和使用。在没有二进制文件的情况下,conda 工具将下载源代码并根据需要构建二进制文件。

    16.5.4 更多...

    最常见的用例之一是升级到软件包的最新版本。这是通过 conda update 命令完成的:

    (cookbook3) % conda update pydantic
    

    这将在搜索的各种渠道中查找软件包的版本。它将比较可用的版本与当前在活动虚拟环境中安装的版本。

    为了礼貌地与 tox 等测试工具协作,使用 pip freeze 命令创建 requirements.txt 文件很有帮助。默认情况下,tox 使用 pip 来构建虚拟环境。PIP 工具不会覆盖由 conda 安装的包,允许它们和平共存。

    另一个选择是使用 tox-conda 插件,允许 tox 工具使用 conda 创建和管理虚拟环境。请参阅 tox-conda 存储库 github.com/tox-dev/tox-conda

    并非所有库和包都是 Anaconda 支持和精选库的一部分。在许多情况下,我们需要超出 Anaconda 的范围,并使用社区 conda-forge 频道,除了 Anaconda 频道之外。

    我们经常需要使用以下类似命令来使用 conda-forge 频道:

    (cookbook3) % conda install --channel=conda-forge tox
    

    我们还可以使用 pip 向 conda 环境添加包。这很少需要,但它确实工作得很好。

    16.5.5 参考信息

    16.6 使用 poetry 工具

    venv、pip 和 pip-tools 包的组合使我们能够创建虚拟环境,并用来自 PYPI 包索引的包填充它们。

    Poetry 工具是一个将虚拟环境管理器和包安装程序结合成一个单一工具的工具。它满足了与 PIP、venv 和 pip-tools 相同的使用案例。它还满足了与 conda 相同的使用案例。CLI 在所有平台上都是相同的,允许为使用 Poetry 管理环境的开发者提供更简单、更一致的文档。

    Poetry 启用虚拟环境的方式有一些细微的差异。它不是调整当前 shell 的环境变量,而是启动一个子 shell。子 shell 具有所需的虚拟环境设置。

    16.6.1 准备工作

    注意,Poetry 工具必须安装在其自己的虚拟环境中,与任何由 Poetry 管理的项目分开。这最好通过使用 Poetry 安装程序来完成。这涉及到特定于操作系统的命令来下载和执行安装程序。安装程序是用 Python 编写的,这使得任务在操作系统之间的一致性有所提高。

    有关详细信息,请参阅 python-poetry.org/docs

    有两个步骤:

    推荐的命令在不同操作系统之间略有差异:

    • macOS、Linux 和 Windows Subsystem for Linux:curl 命令通常可用于下载。此命令可以用于:

      % curl -sSL https://install.python-poetry.org | python3 -
      

      之后,后续步骤将更新系统 PATH 环境变量。安装程序的输出将提供要使用的位置。这两个示例适用于 macOS,其中文件位于 ~/.local/bin。

      编辑 ~/.zshrc 文件,添加以下行:

      export PATH="~/.local/bin:$PATH"
      

      作为替代,可以定义 poetry 命令的位置别名。这通常是 ~/.local/bin/poetry。

    • Windows Powershell:Invoke-WebRequest Powershell 命令执行下载。Python 启动器 py 运行适当的 Python 版本:

      PS C:\> (Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py -
      

      poetry 脚本放置在 AppData\Roaming\Python\Scripts 子目录中。可以将此目录添加到 PATH 环境变量中,或者明确使用路径,例如:AppData\Roaming\Python\Scripts\poetry --version。

      使用 chdir 命令更改当前工作目录意味着明确地引用您的家目录的 AppData 子目录。

    一旦安装了 poetry 工具,就可以用它来创建和填充虚拟环境。

    16.6.2 如何做...

    1. 使用 poetry new 命令创建一个新的项目目录。这不仅会创建虚拟环境;还会创建目录结构和创建 pyproject.toml 文件:

      %  poetry new recipe_05
      

      虚拟环境的文件保存在项目目录之外。对于 macOS,将有一个 ~/Library/Caches/pypoetry 目录,其中包含所有虚拟环境文件。

      注意,poetry 尝试与其他虚拟环境工具协作。这意味着可以使用 venv activate 命令设置环境变量。

    2. 与在 shell 的环境中激活环境相比,通常更容易启动具有适当环境设置的子 shell。

      使用 poetry shell 命令启动一个已激活虚拟环境的 shell:

      % poetry shell
      

      使用 shell 的 exit 命令终止此子 shell 并返回到上一个环境。

    3. 使用 poetry add 命令将包添加到环境中。这将更新 pyproject.toml 文件并安装包:

      % (recipe-05-py3.11) poetry add pydantic beautifulsoup4 jupyterlab matplotlib pytest memray
      

      这还会创建一个 poetry.lock 文件,该文件定义了每个依赖项的确切版本。

    16.6.3 它是如何工作的...

    由 poetry 创建的虚拟环境已正确设置 PATH 环境变量,以指向特定的 Python 可执行文件。标准库包和特定于站点的包位于附近的目录中。Poetry 正确利用 pyproject.toml 文件中的信息,减少了定义工作环境所需的额外文件数量。

    为了保持一致性,poetry 命令必须是可见的。这意味着要么将 poetry 位置添加到系统 PATH 环境变量中,要么使用别名。这是使用 poetry 的关键步骤。

    别名的替代方案如配方中所示,使用 ~/.local/bin/poetry 明确指定。这并不理想,但它使当前工作虚拟环境与 poetry 命令之间的关系更加清晰。

    16.6.4 更多内容...

    环境和工具如 Poetry 的最常见用例之一是升级到软件包的最新版本。这是通过 poetry update 命令完成的。可以提供特定的包列表。如果没有参数,将检查所有包。

    这里有一个例子:

    % (recipe-05-py3.11) poetry update pydantic
    

    这将搜索正在搜索的各种渠道中可用的 pydantic 包版本,并将其与当前在活动虚拟环境中安装的版本进行比较。此操作还将更新 poetry.lock 文件,以安装更新。

    为了礼貌地与 tox 等测试工具协作,需要在 tox.ini 文件中添加一些额外的选项。一种易于使用的方法是跳过 tox 使用的默认安装程序,并使用 poetry 命令在 poetry 管理的环境中运行命令。

    这里有一个如何使用 Poetry 与 tox 一起使用的建议:

    [tox] 
    
    isolated_build = true 
    
    [testenv] 
    
    skip_install = true 
    
    allowlist_externals = poetry 
    
    commands_pre = 
    
        poetry install 
    
    commands = 
    
        poetry run pytest tests/ --import-mode importlib
    

    使用 poetry run 意味着命令将在虚拟环境中执行。这使得使用 tox 定义多个环境,并依赖 Poetry 为测试目的组装各种环境成为可能。

    16.6.5 参考信息

    16.7 应对依赖项的变化

    正如我们在使用 requirements.txt 文件安装包中提到的,构建应用程序的所有包都处于不断变化的状态。每个项目都有独特的更新节奏。为了管理这种持续的变化,对于开发应用程序的人来说,仔细跟踪依赖项非常重要。

    关于 Python 的一个常见抱怨有时可以概括为“依赖地狱”。这总结了跟踪和测试新依赖所需的工作,其中一些可能存在冲突。管理变化的工作是至关重要的;这是维持一个可行产品的最低要求。与其添加功能,它更是在不断变化的世界中保留了功能。

    在两种常见情况下,升级不仅仅是安装和测试升级后的包:

    • 任何以某种方式破坏我们应用程序的更改

    • 我们应用程序依赖的包之间的不兼容性

    在第一种情况下,我们的软件无法正常工作。在第二种情况下,我们甚至无法构建一个虚拟环境来测试。第二种情况通常是最令人沮丧的。

    16.7.1 准备工作

    我们将考虑一个假设的项目,即 applepie 应用程序。此应用程序有多个依赖项:

    • 来自 apple 项目包的单个模块,名为 apple.granny_smith

    • 来自 pie_filling 项目的几个类

    • 来自 pastry_crust 项目的几个类

    • 一个烤箱实现

    通用依赖项在 pyproject.toml 文件中以项目列表的形式命名。我们可以想象详细的 requirements.txt(或 poetry.lock)文件看起来如下:

    apple == 2.7.18 
    
    pie_filling = 3.1.4 
    
    pastry_crust >= 4.2 
    
    oven == 0.9.1a
    

    使用这个框架为应用程序,我们将查看当我们看到依赖关系发生变化时需要做出哪些更改。一个更改将移除所需的功能;另一个更改将是 pie_filling 和 pastry_crust 项目发布之间的不兼容性。我们需要根据这些更改在更大的 Python 生态系统中的发生,对我们的应用程序做出适当的更改。

    16.7.2 如何做到...

    我们将将其分解为两个子配方:一个用于导致测试失败的依赖关系,另一个用于不兼容的依赖关系。我们需要调整我们的项目,使其在持续变化的情况下继续工作。

    一个更改导致了测试失败

    继续我们的例子,烤箱工具对 API 进行了重大更改。新版本,烤箱 1.0 版本,没有与版本 0.9.1a 相同的接口。结果是我们的代码出现故障:

    1. 明确故障是什么以及是什么原因导致的。问“为什么?”足够多次以确定根本原因。故障的几个方面可能需要进一步探索。

      一定要理解问题的顶层:故障是如何表现出来的。理想情况下,是一个单元测试失败。另一个好的检测途径是使用像 mypy 或 ruff 这样的工具来识别潜在的失败。不太有用的是,即使单元测试都通过了,接受或系统测试也失败了。最糟糕的情况可能是部署后,在客户手中的失败。

      此外,务必理解发生了什么变化。一次性引入多个版本升级是很常见的。可能需要撤销这些更改,然后逐个升级每个所需的包,以确定故障的原因。

    2. 发布后的故障通常会导致问题跟踪工具中的问题报告。更新任何问题跟踪软件,包括根本原因分析。

      测试过程中的故障也应导致问题报告的内部报告。修复可能需要大量返工,并且通常有助于跟踪返工的原因。

    3. 在以下四种可能的修复方法中选择:

      1. 您的代码需要修复。烤箱版本 1.0 的更改是一个明显的改进。

      2. 烤箱的更改引入了一个错误,您需要向烤箱的维护者报告问题或修复他们的代码。

      3. 修改依赖关系,在 pyproject.toml 中将烤箱版本锁定为 0.9.1a 以防止升级。

      4. 仔细观察烤箱项目,可能很清楚它已经不再适合这个项目,需要被替换。

      这些不是互斥的选择。在某些情况下,可能会遵循多个路径。

      为了适应烤箱 1.0 的更改,我们可能需要对项目进行一些根本性的更改。这可能是一个机会,可以重构我们的代码,以更仔细地隔离这个依赖关系,从而简化未来的更改。

      当一个项目似乎有错误时,我们有两个选择:我们可以报告问题并希望它得到修复,或者我们可以克隆仓库,进行修复,并向下一个版本提交拉取请求以合并我们的更改。开源的好处是开始项目的成本降低。然而,持续维护是创新繁荣的景观的永恒特征。虽然我们从开源中受益于他人的工作,但我们也需要通过提出修复来做出贡献。

      在某些情况下,当我们决定如何处理依赖项时,我们会固定一个特定版本。我们可能在选择替代方案并重写我们的项目以用新的 convection_cooker 替换旧的烤箱时,固定旧版本。

    实际上,由于升级而损坏的代码是一个错误修复。这可能是我们所需项目的错误修复。更常见的是,修复在我们的项目中应用,以利用其他项目中发生的变化。管理我们应用程序的更改是我们为 Python 包广泛生态系统中的创新所付出的代价。

    改变的依赖项与另一个依赖项不兼容

    在这种情况下,pastry_crust 版本 4.3 使用糖版本 2.0。遗憾的是,pie_filling 版本 3.1.4 使用较旧的糖版本 1.8。

    1. 在尽可能的范围内确定冲突的根本原因。试图弄清楚为什么 pie_filling 项目团队没有升级到糖版本 2.0 可能非常困难。一个常见的观察结果是 pie_filling 项目缺乏活动;但如果不了解主要贡献者,很难询问他们为什么不做更改。

      在确定更改内容时,要非常清楚。一次性引入多个版本升级是很常见的。可能需要撤销这些更改,然后逐个升级所需的每个包,以确定失败的原因。这些冲突不是直接依赖项,而是在间接依赖项中。

      我们重视封装和抽象的想法,直到我们观察到由项目封装的冲突需求。当这些冲突出现时,封装的神秘性变成了负担。

    2. 在问题跟踪软件中将冲突记录为一个问题。务必提供指向冲突项目的链接及其问题跟踪器。解决方案可能涉及与其他项目进行扩展的侧边栏对话,以了解冲突需求的本性。记录这些对话的内容会有所帮助。

    3. 在可能的四种修复方法中选择:

      1. 很可能你代码中的任何小更改都无法解决该问题。所需更改是替换 pie_filling 需求,并做出全面更改。

      2. 有可能通过更改 pie_filling 项目来纠正问题。这可能涉及在其他人的项目上做大量工作。

      3. 修改依赖关系,在 pyproject.toml 中将 pastry_crust 版本 4.2 固定,以防止升级。

      4. 仔细观察 pie_filling 项目,可能很清楚它不再适合这个项目,需要被替换。这是对项目的重大改变。

      这些选择不是互斥的。在某些情况下,将遵循多个路径。可能最受欢迎的选择是锁定防止兼容性问题的版本。

      对 pie_filling 项目的重构可能涉及两个活动:我们可以报告问题并希望他们修复它,或者我们可以克隆他们的仓库,进行修复,并向他们提交一个拉取请求以将我们的更改合并到他们的下一个版本中。这种对他人创建的开源软件的持续维护是创新繁荣的景观的一个永恒特征。

    所需支持项目之间的不兼容性是一个架构问题。它很少能迅速解决。从这个类型的问题中学到的一个重要教训是,所有架构决策都需要可撤销:任何选择都需要有替代方案,软件需要编写得使得任一替代方案都可以实施。

    16.7.3 它是如何工作的...

    这里的关键步骤是进行根本原因分析:当尝试升级时,询问“为什么”某些内容未能通过测试。

    例如,我们的 applepie 项目的依赖项可能将 oven 版本锁定为 0.9.1a,因为 1.0 版本引入了故障。锁定版本可能适用于几个小时,直到 oven 项目修复了错误,或者它可能保持原样更长时间。我们的项目可能需要经过几个版本,直到 oven 1.0 的问题最终在 1.1 版本中修复。

    需要一定的自律来审查需求并确保任何锁定版本仍然需要被锁定。

    16.7.4 更多...

    依赖地狱的挫折来源之一是缺乏为寻找和修复依赖问题预留的时间预算。在企业环境中,这是一个严重的问题,因为项目发起人和经理通常只关注预算和实现新功能所需的时间。解决依赖问题的时间很少包含在预算中,因为这些问题是如此难以预测。

    可能会出现一种糟糕的情况,即修复依赖问题被计入团队的速度指标。当没有分配“故事点”用于升级依赖项和重新运行测试套件时,这种情况可能发生。在这种情况下,组织已经创造了一种扭曲的激励,即永远锁定版本,而不关注其他项目的进展。

    定期审查每个和每个需求是强制性的。这项任务包括查看自上次审查以来发生了哪些变化,以及可能已经弃用的内容。这可能导致修改项目版本约束。例如,我们可能能够将要求从严格的 oven==0.9.1a 放宽到更宽容的 oven!=1.0。

    定期审查所有需求是管理变革和创新的一个基本要素。

    寻找更新以及弃用信息。

    为运行新版本的测试分配时间,并报告发现的错误。

    16.7.5 参考以下内容

    • 请参阅使用 pip-tools 管理 requirements.txt 文件,了解一个非常好的依赖项解析器。

    • 请参阅使用 Anaconda 和 conda 工具,了解如何使用经过精心挑选、兼容的软件发布版本的 conda 仓库。

    16.8

    加入我们的社区 Discord 空间

    加入我们的 Python Discord 工作空间,讨论并了解更多关于本书的信息:packt.link/dHrHU

    图片

    第十七章:17

    文档和风格

    我们将两个主题合并到一个章节中。它们通常被视为项目生命周期中的“额外”内容。交付的代码通常被认为是最重要的事情。

    一些开发者可能会试图争辩说测试用例和文档并不是用户与之交互的代码,因此,这些额外的部分并不像代码那样重要。

    这是错误的。

    虽然用户确实不会直接与测试用例交互,但测试用例的存在是让人们有信心使用代码的原因。没有测试用例,就没有证据表明代码有任何有用的功能。

    文档提供了可以从代码中提取的基本信息。一个拥有突出文档文件夹的项目比缺乏文档的项目更值得信赖。

    代码“风格”是一个相对较小的问题。然而,它也是代码静态评估的一部分,包括类型提示分析、质量指标以及更具体的“lint”检查。有许多软件实现实践可能会令人困惑,或者依赖于文档不良的语言或库功能。这些都是软件的“模糊边缘”。lint 工具就像电烘干机中的 lint 捕集器,捕捉易燃的 lint,防止其堵塞通风口,从而预防火灾。移除软件模糊可以防止错误。在某些情况下,它可能只是减少了问题的可能性。

    我们将把代码的 linting 和格式化视为与测试用例和静态类型检查一样重要的质量保证步骤。

    在本章中,我们将探讨以下创建有用文档的食谱:

    • 最基本的要求:一个 README.rst 文件

    • 安装 Sphinx 和创建文档

    • 使用 Sphinx autodoc 创建 API 参考

    • 在 pyproject.toml 中识别其他 CI/CD 工具

    • 使用 tox 运行全面的质量检查

    17.1 最基本的要求:一个 README.rst 文件

    在第十六章中,创建 pyproject.toml 文件 的食谱描述了如何创建一个包含 README 文件引用的 pyproject.toml 文件。

    为了那个食谱的目的,我们建议该文件是对如何安装和使用项目的总结。

    我们还指出,有六个基本问题:“谁?”、“什么?”、“为什么?”、“何时?”、“何地?”和“如何?”这些问题可以帮助撰写简短的引言段落来描述项目。

    在编写 README 文件时,有两个常见的挑战:

    • 写得太多

    • 写得太少

    一个好的软件包将包括一个包含详细文档的单独文档文件夹。README 文件仅是一个介绍,以及项目各种文件和文件夹的路线图。在许多情况下,当需要具体示例时,非常重要地审慎地重复文档中其他地方提供的信息,以避免矛盾。

    没有 README 的项目在视觉上是有缺陷的。找到好的例子可以帮助提供指导,了解需要什么。一些开发者认为代码应该以某种方式为自己说话,并作为文档。不幸的是,代码只能有效地回答“如何?”这个问题。关于用户是谁以及软件应该如何部署的问题需要存在于软件之外的说法。

    在这个菜谱中,我们将深入了解一个有用的 README 文件的特点。

    17.1.1 准备工作

    首先的一个步骤是选择用于 README 文件的标记语言。这里有三种常见的选项:

    • 纯文本

    • Markdown

    • ReStructured Text (RST)

    纯文本的优势在于避免了额外的格式化元素。缺点是缺乏字体变化等印刷提示,以提供重要上下文。

    Markdown 标记语言的优势在于它有一组小的元素。这些元素与在文件中编写自然语言文本的许多常见实践重叠。例如,显示缩进文本时使用不同的字体,以及将带有标点符号和空格的段落视为项目符号列表项。

    使用 RST 提供了一套全面的元素,涵盖了广泛的印刷细节。这是 Python 内部文档项目的首选标记语言。在某些情况下,文档文件夹可能使用 RST 构建,但 README 文件可能是纯文本。

    选择没有长期后果,因为此文件本质上与项目其他文档隔离。如有疑问,扔一个三面硬币来做出选择可能会有所帮助。文件不大,修改相对容易。

    17.1.2 如何操作...

    1. 写一个介绍,或者说是引言,包含关于谁会使用这个包,为什么他们会使用它,以及它做什么的信息。在某些情况下,说明何时何地使用应用程序可能是有帮助的;这可能需要澄清客户端与服务器托管或管理员与用户角色的区别。保持简短;细节将在后面跟进。这有时被称为“电梯演讲”,因为你可以在一个办公楼的电梯里陈述它。

    2. 概述软件的重要特性。这通常是一个项目符号列表。如果用户界面是一个重要特性,它可能包括屏幕截图来展示。在这里总结而不是详述所有细节是很重要的。细节应该放在单独的文档文件夹中。

    3. 详细说明任何需求或依赖项。这可能包括硬件和操作系统,如果这很重要的话。必须包括任何 Python 版本限制。在库或包是另一个包或模块的插件或扩展的情况下,这可能会在 pyproject.toml 中重复依赖项。

    4. 提供安装说明。通常这是下载和安装包所需的 python -m pip 命令。如果有可选功能,这些也会在这里总结。

    5. 提供使用或操作的简介。这不是用户指南,但这是大多数人首先看到的内容,使用部分应提供一个整洁、清晰、可工作的示例。

      根据软件的性质,有两种不同的方法来编写这个:

      • 对于将要导入的模块和包,一个 doctest 示例是理想的。然后可以通过测试 README 来确认示例确实正确且按预期工作。

      • 对于应用程序,使用说明可能包括常见用例的逐步说明,可能还带有屏幕截图图像。

      对于一些简单的应用程序,这可能就是整个用户指南。通常情况下,它只会展示一个单一、简单的用例。

    6. 提供许可证类型和链接。

    7. 提供一个关于如何为项目做出贡献的部分。这可能是一个指向单独的贡献者指南文档的链接,或者可能是一个简短的说明,说明如何进行更改并提交拉取请求。

      在某些情况下,有关集成、测试和部署的信息可能在这里很有帮助。对于复杂的应用程序,构建过程可能涉及一些不明显步骤。

      这还应包括有关记录问题和提出功能请求的信息。

    8. 包含对其他贡献者工作的致谢或认可也是礼貌的。这可能包括关于赞助商和资助者的信息。

    17.1.3 它是如何工作的...

    README 文件的关键成分是实际工作的命令和功能的具体示例。它展示了软件是什么,如何安装它,如何使用它,以及如何维护它。

    检查流行存储库的 README 文件可以揭示一些共同特征。有一个Make a README网站,在需要额外指导的情况下可以帮助创建文件。

    虽然其他地方可能有额外的文档,但 README 是大多数人首先阅读的内容。在某些情况下,它也是他们最后阅读的内容。因此,必须清楚地说明软件是什么以及如何使用它。

    17.1.4 更多...

    README 文件的一个常见特点是显示项目总体健康状况的徽章。这些图形摘要有多个来源。

    shields.io网站提供了一系列静态和动态徽章。动态徽章可以查询像 PyPI 或 GitHub 这样的服务,以发布当前状态。

    在 Markdown 中,可能使用以下内容来构建徽章。

    ![release](https://img.shields.io/pypi/v/<project>.png)
    

    这将显示一个带有 pypi 的小型图形徽章在左侧,以及右侧的当前 PyPI 版本号。

    图片

    图 17.1:徽章示例

    徽章也可以是一个链接,并提供更详细的信息。

    [![release](https://img.shields.io/pypi/v/<name>.png)]  (https://pypi.org/project/<name>)
    

    17.1.5 参考信息

    17.2 安装 Sphinx 和创建文档

    README 文件是软件的摘要,涉及几个关键点。适当的文档通常与 README 的重要主题平行,但更深入。

    重要的辅助“如何做”指南包括两个重要主题:

    • 软件做什么。这通常是对可观察特征的详细描述。

    • 软件的工作原理,展示实现概念。

    C4 模型建议在描述中包含四个抽象层级。

    1. 应用程序使用的上下文。

    2. 软件运行的容器。

    3. 显示软件架构的组件图。

    4. 显示实现细节的代码图。

    这种组织方式为文档提供了必要的焦点。

    我们将使用 RST 或 Markdown 格式编写。然后,Sphinx 等工具在多种目标格式中构建输出文档。

    我们经常希望提供一个包含实现细节的 API 文档,这些细节是从我们模块、类、方法和函数中存在的 docstrings 中提取出来的。在第二章中,包含描述和文档食谱描述了如何向各种 Python 结构添加 docstrings。Sphinx 工具的 autodoc 扩展从 docstrings 中提取信息,以生成详细的 API 文档。

    此外,Sphinx 工具使得将文档源分解成更小的文件变得容易,这些文件更容易编辑和管理。

    17.2.1 准备工作

    我们需要下载并安装 Sphinx 工具。通常,这是通过以下终端命令完成的:

    (cookbook3) % python -m pip install sphinx
    

    使用 python -m pip 命令确保我们将使用与当前活动虚拟环境关联的 pip 命令。

    有几个内置主题,以及许多第三方主题。请参阅sphinx-themes.org以获取更多主题。

    17.2.2 如何操作...

    1. 确保项目目录至少有以下子目录:

      • 源。这可能使用包的名称,或者可能被称为 src。

      • 测试,通常称为 tests。

      • 通常称为文档的文档。

    2. 使用 cd 或 chdir 命令将工作目录更改为 docs 目录。从那里,运行 sphinx-quickstart 命令。

      (cookbook3) recipe_02 % cd docs 
      
      (cookbook3) docs % sphinx-quickstart 
      
      Welcome to the Sphinx 7.2.6 quickstart utility. 
      
      Please enter values for the following settings (just press Enter to 
      
      accept a default value, if one is given in brackets).
      

      这将开始一个交互式对话,以收集有关您的项目详细信息,并在您的文档文件夹中生成运行 Sphinx 所需的文件。

      结果将是用于文档的几个目录和文件:

      • conf.py 包含项目配置。

      • index.rst 是根文档。

      • Makefile 可用于所有其他环境来构建文档。

      • 用于 Windows 环境的 make.bat 也可以存在。

    3. 编辑 index.rst 文件以编写初始摘要。这可能会从 README 文件中复制过来。

    4. 运行 make html 命令来构建初始文档。这是一个在终端窗口中运行的 shell 命令。确保当前工作目录是 docs 目录。

    17.2.3 它是如何工作的...

    Sphinx 工具通过读取根文档开始处理。root_doc 配置参数命名为 index。source_suffix 配置参数设置此文件的后缀为 .rst。

    通常,此文件将命名文档中的其他文件。使用 .. toctree:: 指令指定文档中的其他文件。

    假设我们需要为安装、使用、维护、设计和 API 参考编写几个部分。index.rst 将包含这些作为主要内容。

    The recipe_02 project 
    
    ===================== 
    
    The **recipe_02** project is an example of Sphinx documentation. 
    
    ..  toctree:: 
    
        :maxdepth: 2 
    
        :caption: Contents: 
    
        installation 
    
        usage 
    
        maintenance 
    
        design 
    
        api
    

    由 sphinx-quickstart 工具创建的文件将在上述示例之前创建一个序言。我们已从示例中省略了它们,因为没有很好的理由去更改它们。

    toctree 指令有两个参数,:maxdepth: 2 和 :caption: Contents:。这些调整指令输出的行为。

    注意,指令内部的内容必须保持一致的缩进。通常初始文件将有一个三个空格的缩进。一些编辑器使用默认的四个空格缩进,因此对设置的一些更改可能会有所帮助。

    toctree 体中的每个名称都指的是一个具有配置后缀的文件,在我们的例子中是 .rst。

    installation.rst、usage.rst、maintenance.rst、design.rst 和 api.rst 文档必须以适当的 RST 标题行开始。初始内容可以来自笔记或 README。有关 RST 的更多信息,请参阅第二章中的使用 RST 标记编写更好的 docstrings。

    api.rst 文档的内容将使用 autodoc 扩展。我们将在使用 Sphinx autodoc 创建 API 参考中查看这一点。

    17.2.4 更多...

    对于 Sphinx 有许多有用的扩展。我们将查看包括待办事项列表。

    通过在 conf.py 配置文件中将 sphinx.ext.todo 添加到扩展列表中,启用一个扩展:

    extensions = [ 
    
        ’sphinx.ext.autodoc’, 
    
        ’sphinx.ext.todo’, 
    
    ]
    

    这将为可用的标记引入两个新的指令:

    • .. todo:: 指令创建一个待办事项。

    • ..  todolist:: 指令将被所有待办事项的内容所替换。

    待办事项不会产生其他输出。很容易找到它们;一些 IDE 会自动扫描文件中的 todo 字母,并将这些作为开发人员需要解决的问题列表。

    通过编辑 conf.py 配置文件并添加以下行,将启用.. todolist::指令以包含文档中的项目:

    todo_include_todos = True
    

    通过这种方式,待办事项从个人笔记提升为文档中的公共项目。

    Sphinx 附带了一些主题,这些主题定义了要使用的样式。默认主题被称为 alabaster。可以通过在 conf.py 配置文件中的 html_theme 设置中更改到其他内置主题。

    使用以下设置更改到 sphinxdoc 主题:

    html_theme = ’sphinxdoc’
    

    许多主题都有进一步的定制选项。提供 html_theme_options 字典可以定制主题。

    17.2.5 参见

    • 有关 Sphinx 项目的详细信息,请参阅 Sphinx www.sphinx-doc.org/en/master/

    • 有关一些额外的 Sphinx 主题,请参阅sphinx-themes.org

    • 查看如何从代码中构建 API 文档的使用 Sphinx autodoc 创建 API 参考。

    17.3 使用 Sphinx autodoc 创建 API 参考

    Sphinx 的一个巨大优势是能够使用 autodoc 扩展生成 API 文档。一系列命令可以从模块、类、函数和方法中提取文档字符串。有选项可以微调确切包含或排除的成员。

    我们将回溯到第七章,扩展内置集合 – 做统计的列表配方。其中有一个 Statslist 类:

    class StatsList(list[float]): 
    
        def sum(self) -> float: 
    
            return sum(v for v in self) 
    
        def size(self) -> float: 
    
            return sum(1 for v in self) 
    
        def mean(self) -> float: 
    
            return self.sum() / self.size() 
    
        # etc...
    

    因为这继承了列表类的方法,所以有大量的可用方法。默认情况下,只有那些带有文档字符串的方法(不包括私有方法,即以 _ 开头的方法)将被检查并包含在文档中。我们还有其他一些选择,可以选择包含在文档中的方法:

    • 我们可以指定特定的方法,并且只有这些方法将被记录。

    • 我们可以请求包含没有文档字符串的方法;将显示签名。

    • 我们可以请求私有成员(那些以 _ 开头的)。

    • 我们可以请求特殊成员(那些以两个 _ 开头的)。

    • 我们可以请求继承的成员以查看超类中的成员。

    我们将首先将文档字符串放入这个类定义中。一旦这项任务完成,我们就可以在文档目录中包含必要的配置和指令。

    17.3.1 准备工作

    第一步是为模块、类和方法添加文档字符串。在某些情况下,部分文档字符串已经存在,任务是将它们扩展得更完整。

    例如,我们可能已经添加了以下类型的注释:

    class StatsList(list[float]): 
    
        """ 
    
        A list of float (or int) values that computes some essential statistics. 
    
        >>> x = StatsList([1, 2, 3, 4]) 
    
        >>> x.mean() 
    
        2.5 
    
        """ 
    
        def sum(self) -> float: 
    
            """ 
    
            Sum of items in the list. 
    
            """ 
    
            return sum(v for v in self)
    

    这开始提供了一些有用的文档,在 API 参考中。类的文档字符串包含一个 doctest 示例,以展示其工作方式。该方法有一个文档字符串,总结了其功能。

    我们需要扩展它以添加有关参数、返回值和引发的异常的详细信息。这是通过称为“字段列表”的附加语法完成的。

    字段列表中的每个项都有一个名称和正文。一般语法如下:

    :name: body
    

    Sphinx 工具定义了大量的字段列表名称,用于格式化函数或方法的文档。以下是一些最有用的:

    • :param name: 参数的描述

    • :key name: 关键字参数的描述

    • :raises exception: 异常原因的描述

    • :var name: 类内部变量的详细信息,这些变量是公开的

    • :returns: 方法或函数的返回值

    这些允许编写方法的详细描述。

    17.3.2 如何做...

    1. 编辑文档字符串以包含详细信息。

      例如,我们可能想如下扩展方法定义:

              def sum(self) -> float: 
      
                  """ 
      
                  Computes the sum of items in the list. 
      
                  :returns: sum of the items. 
      
                  """ 
      
                  return sum(v for v in self)
      

      对于这样一个简单的函数,字段列表中的:returns:部分似乎有些冗余。

    2. 编辑 conf.py 文件,将’sphinx.ext.autodoc’字符串添加到扩展列表中:

      extensions = [ 
      
          ’sphinx.ext.autodoc’ 
      
      ]
      
    3. 在 conf.py 配置文件中将 src 目录添加到 sys.path:

      import sys 
      
      sys.path.append(’../src’)
      

      这之所以有效,是因为 conf.py 文件是一个 Python 模块,可以执行任何 Python 语句。将 src 目录添加到路径意味着 Sphinx 可以导入该模块。

    4. 在 api.rst 文档中放入以下指令。

      ..  automodule:: stats 
      
          :undoc-members:
      

      这将导入模块,提取文档字符串,然后尝试为所有成员创建文档,包括那些尚未有文档字符串的成员。

    Sphinx 快速入门创建了一个 Makefile,以帮助从源材料构建最终的 PDF 或 HTML 文件;有关更多信息,请参阅安装 Sphinx 和创建文档配方。在文档目录中运行 make html shell 命令,构建目录将包含项目的静态网站。

    17.3.3 它是如何工作的...

    从文档字符串中提取详细文档的检查始于 Python 语言的巧妙特性:文档字符串。RST 标记的规则继续走向优雅的文档。生成的 Sphinx 页面看起来像这样:

    PIC

    图 17.2:Sphinx 输出示例

    注意 variance()方法包含一个..  math::指令,其中包含有关计算方式的详细信息。这需要一些小心,因为 LaTeXmath 语法涉及相当多的\字符。

    在文档字符串中处理 LaTeXmath 有两种方式:

    • 使用“原始”字符串字面量和单个\:

      r""" 
      
      A docstring with :math:‘\alpha \times \beta‘ 
      
      """
      

      这意味着不能使用其他转义字符。这可能会阻止使用 Unicode 字符,例如。

    • 使用\来转义\的特殊含义:

      """ 
      
      A docstring with :math:‘\\alpha \\times \\beta‘ 
      
      """
      

      这允许在文档字符串中包含 Unicode 转义序列,如\N{黑桃}。

    在这两种情况下,请注意,RST 使用反引号 ` 包围具有角色的内容,如 :math:。

    17.3.4 更多...

    对另一个类、模块或方法的交叉引用使用 :role:‘value’ 语法。:role: 部分是特定类型的引用,有助于区分模块、类和函数。值是在文档中某处有定义指令的名称。

    交叉引用将生成适当格式化的文本,并带有指向名称定义的超文本链接。

    这里有一个例子:

    Uses the :py:class:‘~stats.StatsList‘ class in the :py:mod:‘stats‘ module.
    

    :py:class:‘~stats.StatsList’ 的作用是创建一个指向 StatsList 类定义的类引用。在名称中使用 ~ 的意思是只显示名称的最后一层。生成正确的类引用需要完整的路径。:py:mod:‘stats’ 引用是 :py:mod: 的作用,并命名了 stats 模块。

    17.3.5 参见

    • 参见 安装 Sphinx 和创建文档 以获取有关 Sphinx 的更多信息。

    • 参见第七章,扩展内置集合 – 一个用于统计的列表 的配方,了解这个例子所围绕的示例。

    • 参见第二章,包含描述和文档 的配方,以获取有关 docstrings 的更多信息。

    17.4 在 pyproject.toml 中识别其他 CI/CD 工具

    连续集成 (CI) 和连续部署 (CD) 这些术语通常用来描述发布 Python 包供他人使用的流程。对集成和部署进行一系列质量检查的想法是良好软件工程的核心。运行测试套件是确认软件适合预期用途的许多方法之一。

    可能包括额外的工具,如 memray,用于检查内存资源的使用。像 ruff 这样的工具也是一个有效的代码检查器。

    在第十六章,创建 pyproject.toml 文件 的配方中,以及在第十五章,结合 unittest 和 doctest 测试 的配方中,都讨论了在安装项目所需的依赖项之外定义测试工具。

    这表明存在多个要求层(也称为依赖项):

    • 首次安装应用程序所需的条件。在本书中,这包括 pydantic、beautifulsoup4、jupyterlab 和 matplotlib 等项目。

    • 适用于特殊功能、插件或扩展的可选要求。这些不是安装项目的必需条件。它们在配置文件中命名,并在软件使用时应用。例如,pydantic 包有一个可选的电子邮件地址验证器。如果您的应用程序需要这个功能,它需要作为依赖项的一部分命名。

    • 运行测试套件的要求。大部分情况下,这是 pytest 和 mypy。虽然这并没有被强调,但本书中示例的单元测试案例都使用了 tox 进行测试自动化。

    • 开发所需的软件包和工具。这包括像 memray 和 sphinx 这样的工具。像 ruff 或 black 这样的工具可能也是这些要求的一部分。

    依赖项信息用于正确安装软件。它还用于创建协作的开发环境。Python 的软件包生态系统处于不断变化的状态。

    记录软件包测试的版本是至关重要的。这个细节使得工具如 PIP 能够在虚拟环境中下载和安装所需的组件。

    17.4.1 准备工作

    第一步是创建基础 pyproject.toml 文件。参见第十六章中的 Creating a pyproject.toml file 菜单,其中包含与此密切相关的另一个菜谱。这应该在 [project] 表中有一个依赖项。它可能看起来是这样的:

    [project] 
    
        # details omitted 
    
    dependencies = [ 
    
        "pydantic", 
    
        "beautifulsoup4", 
    
        "types-beautifulsoup4", 
    
        "jupyterlab", 
    
        "matplotlib" 
    
    ]
    

    当使用 Poetry 时,这个信息以略微不同的格式。信息放在 [tool.poetry.dependencies] 表中。我们通常会使用 poetry add 命令行工具来构建这个信息。

    注意,Poetry 命令提供了一些额外的语法:

    % poetry add pydantic@².6.0
    

    ^ 前缀是一个复杂的规则,允许对次要版本号或补丁级别使用更大的版本号。它不允许对最左侧的主版本号,在这个例子中的 2,进行任何更改。这意味着任何 2.6.0 或更高版本的 Pydantic 都将被考虑。高于 3.x 的版本则不考虑。

    17.4.2 如何做...

    1. 在名为 [project.optional-dependencies] 的表中添加测试依赖项。这将是一个名为 test 的列表。它看起来是这样的:

      [project.optional-dependencies] 
      
      test = [ 
      
          "tox", 
      
          "pytest", 
      
          "mypy" 
      
      ]
      

      这个 test 名称可以被 pip-compile 用于构建详细的 requirements-test.txt,用于测试工具。

      当使用 Poetry 时,这个可选依赖项组位于不同的表中。我们使用 --group 选项来指定组。

      命令行看起来会是这样:

      % poetry add tox@⁴.0 --group test
      
    2. 在名为 [project.optional-dependencies] 的表中添加开发依赖项。通常使用 dev 这个名称。它看起来是这样的:

      [project.optional-dependencies] 
      
      dev = [ 
      
          "ruff", 
      
          "pip-tools", 
      
          "memray" 
      
      ]
      

      这个 dev 名称可以被 pip-compile 用于构建整个工具套件以及基础依赖项的详细 requirements-dev.txt。

      当使用 Poetry 时,--group 选项指定组。一个添加命令可能包括 --group dev 以将项目添加到 dev 组。

    17.4.3 它是如何工作的...

    目标是在 pyproject.toml 文件中提供范围和模式,以提供版本标识的灵活性。单独的 requirements*.txt 文件记录了当前发布版使用的特定版本号。这种通用-特定区分支持复杂软件包的集成和重用。

    17.4.4 更多...

    当使用像 tox 这样的工具时,我们可以创建多个虚拟环境,以使用依赖项的不同变体来测试我们的软件。

    软件包安装通常使用具有特定版本标识的 requirements.txt 文件。另一方面,开发工作可能涉及多个替代虚拟环境。

    我们可以使用 pip-compile 等工具创建包的混合,以允许在多个替代虚拟环境中进行测试。有关更多信息,请参阅分层需求工作流程

    我们通常会创建一个 base-requirements.in 文件来定义所有虚拟环境中的共同需求。有关更多信息,请参阅第十六章,依赖项和虚拟环境。这通常是一个所需软件包的简单列表:

    # requirements.in 
    
    this_package 
    
    sphinx
    

    这提供了项目独有的基本包集。

    然后,我们可以为各种测试环境创建 layered-requirements-dev_x.in 文件。这些文件中的每一个都将包括基础层 requirements.txt 和一组额外的约束。文件可能看起来像这样:

    # requirements_dev_x.in 
    
    # Anticipation of new release. See ... for details. 
    
    -c requirements.txt 
    
    some_package>2.6.1
    

    我们包含了一条注释,说明了为什么需要这个独特的发展虚拟环境。这些原因经常变化,留下关于为什么特定环境有帮助的提醒是有帮助的。

    在 tox.ini 文件中,pip-sync 命令将为测试构建一个独立的虚拟环境。我们将在使用 tox 运行全面质量检查的菜谱中查看这一点。

    17.4.5 参见

    • 在第十六章中,创建 pyproject.toml 文件的菜谱展示了如何开始一个 pyproject.toml 文件。

    • 有关使用 tox 工具运行测试套件的更多信息,请参阅使用 tox 运行全面质量检查。

    17.5 使用 tox 运行全面质量检查

    当我们开始使用多个 CI/CD 工具时,确保所有工具都一致使用至关重要。虚拟环境也必须一致构建。

    传统上,当源文件被修改时,使用像 make 这样的工具来重建目标文件。这需要极大的谨慎,因为 Python 并不适合 make 的编译器中心模型。

    如 tox 和 nox 这样的工具对于在 Python 代码上运行全面的测试和 CI/CD 工具序列非常有帮助。

    17.5.1 准备工作

    对于谨慎的软件开发,各种工具都可能很有用:

    单元测试:我们可以使用内置的 doctest 或 unittest 模块。我们还可以使用像 pytest 这样的工具来查找和运行测试套件。

    基准测试:也称为性能测试。pytest-benchmark 项目提供了一个方便的 fixture,以确保性能符合预期。

    接受测试:像 behave 或 pytest-bdd 插件这样的工具可以通过在 Gherkin 中声明接受测试用例来帮助,使产品所有者更容易理解。

    类型提示检查:这通常由 mypy、pyre、pyright 或 pytype 等工具处理。

    检查:虽然“linting”这个术语很常见,但这实际上更好的称呼是“lint blocking”。有众多工具,包括 ruff、pylint、flake8 和 pylama。

    样式和格式:为此,有两个流行的工具,即 ruff 和 black。

    文档:这通常使用 Sphinx 构建。

    这意味着我们需要安装所选的工具套件。一个额外的工具可以帮助找到所有工具并将它们绑定成可用的形式。tox 工具可以在多个虚拟环境中创建和运行测试。

    我们需要下载并安装 tox 工具。通常,这是通过以下终端命令完成的:

    (cookbook3) % python -m pip install tox
    

    使用 python -m pip 命令确保我们将使用与当前活动虚拟环境关联的 pip 命令。

    17.5.2 如何操作...

    1. 有两种方式为 tox 提供配置文件。我们可以将配置嵌入到 pyproject.toml 中。虽然这符合文件的理念,但 tox 工具不处理 TOML 选项。它依赖于在 TOML 文件中的具有 INI 格式选项的字符串。

      更好的替代方案是创建一个单独的 tox.ini 文件。在这个文件中,创建一个包含核心配置选项的初始 [tox] 表。以下对于许多项目都是合适的:

      [tox] 
      
      description = "Your project name goes here." 
      
      min_version = 4.0
      
    2. 对于不需要安装的应用或脚本,以下两行是合适的,以避免尝试准备和安装包:

      skip_sdist = true 
      
      no_package = true
      

      对于将要分发和安装的包,无需添加任何内容。

    3. 创建一个包含关于测试环境一般信息的 [testenv] 表。在某些情况下,一个环境就足够了。当需要多个不同的环境时,将会有多个 [testenv] 部分。

      [testenv]
      
    4. 在这个 [testenv] 表内,deps= 的值列出了将要使用的测试工具。它可能看起来像这样:

      deps = 
      
          pytest>=7 
      
          pip-tools 
      
          ruff>=0.1.4 
      
          mypy>=1.7
      

      tox 工具使用 pip 命令构建 deps 部分中列出的项目。当然,它可以用来安装所有需求。使用 -r requirements.txt 将这样做。

      使用 pip-sync 工具效率更高一些,因为它可以避免重新安装环境中已经存在的任何依赖。当使用 pip-sync 时,我们不在 deps= 列表中使用 -r requirements.txt。

    5. 如果使用 pip-sync 安装需求,这将以 commands_pre= 的值给出:

      commands_pre = pip-sync requirements.txt
      
    6. 如果需要任何独特的环境变量,它们将通过 setenv= 的值设置:

      setenv = 
      
          PYTHONPATH=src/ch03
      
    7. 最后,提供要执行的命令序列:

      commands = 
      
          pytest --doctest-glob=’*.txt’ src 
      
          ruff format src 
      
          ruff check src 
      
          mypy --strict src
      
    8. 关闭此文件后,使用以下命令运行测试套件:

      (cookbook3) % tox
      

    17.5.3 它是如何工作的...

    在像 tox 这样的工具中,内置了许多假设和默认值。这使我们免于编写复杂的 shell 脚本或调整 makefile 中存在的假设。相反,我们可以提供几行配置和一系列命令。

    理想情况下,使用 tox 总是看起来像这样:

    (cookbook3) % tox 
    
    ... details omitted 
    
      congratulations :) (4.09 seconds)
    

    最后的祝贺是一个恰当的总结。

    17.5.4 更多...

    在许多情况下,一个项目有多个虚拟环境。虚拟环境使用扩展名称来区分。这些将具有 [testenv:name] 的一般模式,其中 name 是某种描述性的内容。

    在配置的 [tox] 部分,env_list 列出了要自动处理的虚拟环境。未列出的环境可以通过在 tox 命令中使用 -e 选项手动执行。

    要测试其他版本的 Python,我们将在 tox.ini 文件中添加以下内容:

    [testenv:py311] 
    
        base_python = py311
    

    这将继承 master testenv 设置的详细信息。通过应用覆盖,将基本 Python 版本更改为 3.11。

    py311 名称是针对较长的指定(如 python>=3.11)的便捷 tox 简写。工具将在系统范围内的 PATH 中搜索候选 Python 实现。要测试多个 Python 版本,它们都需要安装在 PATH 中命名的目录中。

    17.5.5 参见

    • 查看 pyproject.toml 中识别其他 CI/CD 工具。

    • 查看 15 章节了解更多关于测试的信息。

    • 查看 16 章节了解有关虚拟环境的食谱。

    • 查看更多关于 tox 工具的信息,请访问 tox.wiki/en/latest/

    • 查看关于 nox 工具的信息,它提供了类似的功能,请访问 nox.thea.codes/en/stable/

    加入我们的社区 Discord 空间。

    加入我们的 Python Discord 工作空间,讨论并了解更多关于本书的信息:packt.link/dHrHU

    图片

    图片

    www.packt.com

    订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及帮助您规划个人发展和提升职业的业界领先工具。更多信息,请访问我们的网站。

    为什么订阅?

    [nosep]使用来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,多花时间编码。通过为您量身定制的技能计划提高学习效果。每月免费获得一本电子书或视频。内容可全文搜索,便于快速访问关键信息。可复制粘贴、打印和添加书签。

    您知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?您可以在 packt.com 升级到电子书版本,作为印刷书客户,您有权获得电子书副本的折扣。有关更多详情,请联系我们 customercare@packtpub.com。

    www.packt.com 上,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

    posted @ 2025-09-24 13:51  绝不原创的飞龙  阅读(35)  评论(0)    收藏  举报