现代-Python-标准库秘籍-全-

现代 Python 标准库秘籍(全)

原文:zh.annas-archive.org/md5/3fab99a8deba9438823e5414cd05b6e8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Python 是一种非常强大和广泛使用的语言,具有功能齐全的标准库。人们说它是“电池已包含”,这意味着您将需要做的大部分工作都可以在标准库中找到。

这样庞大的功能集可能会让开发人员感到迷失,而且并不总是清楚哪些可用工具最适合解决特定任务。对于这些任务中的许多,也将提供外部库,您可以安装以解决相同的问题。因此,您可能不仅会想知道从标准库提供的所有功能中选择哪个类或函数来使用,还会想知道何时最好切换到外部库来实现您的目标。

本书试图提供 Python 标准库中可用工具的概述,以解决许多常见任务,并提供利用这些工具实现特定结果的配方。对于基于标准库的解决方案可能变得过于复杂或有限的情况,它还将尝试建议标准库之外的工具,以帮助您迈出下一步。

本书的受众

本书非常适合希望在 Python 中编写富有表现力、高度响应、可管理、可扩展和具有弹性的代码的开发人员。预期具有 Python 的先前编程知识。

本书涵盖的内容

第一章,“容器和数据结构”,涵盖了标准库提供的不太明显的数据结构和容器的情况。虽然像listdict这样的基本容器被视为理所当然,但本章将深入探讨不太常见的容器和内置容器的更高级用法。

第二章,“文本管理”,涵盖了文本操作、字符串比较、匹配以及为基于文本的软件格式化输出时最常见的需求。

第三章,“命令行”,涵盖了如何编写基于终端/Shell 的软件,解析参数,编写交互式 Shell,并实现日志记录。

第四章,“文件系统和目录”,涵盖了如何处理目录和文件、遍历文件系统以及处理与文件系统和文件名相关的多种编码类型。

第五章,“日期和时间”,涵盖了如何解析日期和时间、格式化它们,并对日期进行数学运算以计算过去和未来的日期。

第六章,“读/写数据”,涵盖了如何读取和写入常见文件格式的数据,如 CSV、XML 和 ZIP,以及如何正确管理编码文本文件。

第七章,“算法”,涵盖了一些常见的排序、搜索和压缩算法,以及您可能需要在任何类型的数据集上应用的常见操作。

第八章,“加密”,涵盖了标准库提供的与安全相关的功能,或者可以使用标准库中可用的哈希函数来实现的功能。

第九章,“并发”,涵盖了标准库提供的各种并发模型,如线程、进程和协程,特别关注这些执行者的编排。

第十章,“网络”,涵盖了标准库提供的实现基于网络的应用程序的功能,以及如何从一些常见协议(如 FTP 和 IMAP)中读取数据,以及如何实现通用的 TCP/IP 应用程序。

第十一章,“Web 开发”,涵盖了如何实现基于 HTTP 的应用程序、简单的 HTTP 服务器和功能齐全的 Web 应用程序。它还将涵盖如何通过 HTTP 与第三方软件进行交互。

第十二章,多媒体,涵盖了检测文件类型、检查图像和生成声音的基本操作。

第十三章,图形用户界面,涵盖了 UI 应用程序的最常见构建块,可以组合在一起创建桌面环境的简单应用程序。

第十四章,开发工具,涵盖了标准库提供的工具,帮助开发人员进行日常工作,如编写测试和调试软件。

充分利用本书

读者预期已经具有 Python 和编程的先验知识。来自其他语言或对 Python 有中级了解的开发人员将从本书中获益。

本书假定读者已经安装了 Python 3.5+,并且大多数配方都展示了 Unix 系统(如 macOS 或 Linux)的示例,但也可以在 Windows 系统上运行。Windows 用户可以依赖于 Windows 子系统来完美地复制这些示例。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Modern-Python-Standard-Library-Cookbook。我们还有其他代码包来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"我们还可以通过将ChainMapdefaultdict结合来摆脱最后的.get调用。"

代码块设置如下:

for word in 'hello world this is a very nice day'.split():
    if word in counts:
        counts[word] += 1

当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体设置:

class Bunch(dict):
    def __init__(self, **kwds):
        super().__init__(**kwds)
        self.__dict__ = self

任何命令行输入或输出都以以下方式编写:

>>> print(population['japan'])
127

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中以这种方式出现。例如:"如果涉及持续集成系统"

警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。

各节

本书中,您会发现一些经常出现的标题(准备工作如何做它是如何工作的还有更多,和另请参阅)。

为了清晰地说明如何完成一个配方,使用以下各节:

准备工作

本节告诉您配方中可以期待什么,并描述了如何设置配方所需的任何软件或任何预备设置。

如何做…

本节包含遵循配方所需的步骤。

它是如何工作的…

本节通常包括对前一节中发生的事情的详细解释。

还有更多…

本节包含有关配方的其他信息,以使您对配方更加了解。

另请参阅

本节提供了与食谱相关的其他有用信息的链接。

第一章:容器和数据结构

在本章中,我们将涵盖以下食谱:

  • 计数频率-计算任何可散列值的出现次数

  • 带有回退的字典-为任何丢失的键设置回退值

  • 解包多个-关键字参数-如何多次使用**

  • 有序字典-保持字典中键的顺序

  • MultiDict-每个键具有多个值的字典

  • 优先处理条目-高效获取排序条目的顶部

  • Bunch-表现得像对象的字典

  • 枚举-处理已知状态集

介绍

Python 具有一组非常简单和灵活的内置容器。作为 Python 开发人员,您几乎可以用dictlist实现任何功能。Python 字典和列表的便利性是如此之大,以至于开发人员经常忘记它们的限制。与任何数据结构一样,它们都经过了优化,并且设计用于特定用例,可能在某些情况下效率低下,甚至无法处理它们。

曾经试图在字典中两次放入一个键吗?好吧,你不能,因为 Python 字典被设计为具有唯一键的哈希表,但MultiDict食谱将向您展示如何做到这一点。曾经试图在不遍历整个列表的情况下从列表中获取最低/最高值吗?列表本身不能,但在优先处理条目食谱中,我们将看到如何实现这一点。

标准 Python 容器的限制对 Python 专家来说是众所周知的。因此,多年来,标准库已经发展出了克服这些限制的方法,经常有一些模式是如此常见,以至于它们的名称被广泛认可,即使它们没有正式定义。

计数频率

在许多类型的程序中,一个非常常见的需求是计算值或事件的出现次数,这意味着计数频率。无论是需要计算文本中的单词,博客文章上的点赞次数,还是跟踪视频游戏玩家的得分,最终计数频率意味着计算特定值的数量。

对于这种需求,最明显的解决方案是保留我们需要计数的计数器。如果有两个、三个或四个,也许我们可以在一些专用变量中跟踪它们,但如果有数百个,保留这么多变量显然是不可行的,我们很快就会得到一个基于容器的解决方案来收集所有这些计数器。

如何做到...

以下是此食谱的步骤:

  1. 假设我们想要跟踪文本中单词的频率;标准库来拯救我们,并为我们提供了一种非常好的跟踪计数和频率的方法,即通过专用的collections.Counter对象。

  2. collections.Counter对象不仅跟踪频率,还提供了一些专用方法来检索最常见的条目,至少出现一次的条目,并快速计算任何可迭代对象。

  3. 您提供给Counter的任何可迭代对象都将被“计数”其值的频率:

>>> txt = "This is a vast world you can't traverse world in a day"
>>>
>>> from collections import Counter
>>> counts = Counter(txt.split())
  1. 结果将会正是我们所期望的,即我们短语中单词的频率字典:
Counter({'a': 2, 'world': 2, "can't": 1, 'day': 1, 'traverse': 1, 
         'is': 1, 'vast': 1, 'in': 1, 'you': 1, 'This': 1})
  1. 然后,我们可以轻松查询最常见的单词:
>>> counts.most_common(2)
[('world', 2), ('a', 2)]
  1. 获取特定单词的频率:
>>> counts['world']
2

或者,获取总出现次数:

>>> sum(counts.values())
12
  1. 我们甚至可以对计数器应用一些集合操作,例如合并它们,减去它们,或检查它们的交集:
>>> Counter(["hello", "world"]) + Counter(["hello", "you"])
Counter({'hello': 2, 'you': 1, 'world': 1})
>>> Counter(["hello", "world"]) & Counter(["hello", "you"])
Counter({'hello': 1})

它是如何工作的...

我们的计数代码依赖于Counter只是一种特殊类型的字典,字典可以通过提供一个可迭代对象来构建。可迭代对象中的每个条目都将添加到字典中。

在计数器的情况下,添加一个元素意味着增加其计数;对于我们列表中的每个“单词”,我们会多次添加该单词(每次它在列表中出现一次),因此它在Counter中的值每次遇到该单词时都会继续增加。

还有更多...

依赖Counter实际上并不是跟踪频率的唯一方法;我们已经知道Counter是一种特殊类型的字典,因此复制Counter的行为应该是非常简单的。

我们每个人可能都会得到这种形式的字典:

counts = dict(hello=0, world=0, nice=0, day=0)

每当我们遇到helloworldniceday的新出现时,我们就会增加字典中关联的值,并称之为一天:

for word in 'hello world this is a very nice day'.split():
    if word in counts:
        counts[word] += 1

通过依赖dict.get,我们也可以很容易地使其适应计算任何单词,而不仅仅是我们可以预见的那些:

for word in 'hello world this is a very nice day'.split():
    counts[word] = counts.get(word, 0) + 1

但标准库实际上提供了一个非常灵活的工具,我们可以使用它来进一步改进这段代码,那就是collections.defaultdict

defaultdict是一个普通的字典,对于任何缺失的值都不会抛出KeyError,而是调用我们可以提供的函数来生成缺失的值。

因此,诸如defaultdict(int)这样的东西将创建一个字典,为任何它没有的键提供0,这对我们的计数目的非常方便:

from collections import defaultdict

counts = defaultdict(int)
for word in 'hello world this is a very nice day'.split():
    counts[word] += 1

结果将会完全符合我们的期望:

defaultdict(<class 'int'>, {'day': 1, 'is': 1, 'a': 1, 'very': 1, 'world': 1, 'this': 1, 'nice': 1, 'hello': 1})

对于每个单词,第一次遇到它时,我们将调用int来获得起始值,然后加1。由于int在没有任何参数的情况下调用时会返回0,这就实现了我们想要的效果。

虽然这大致解决了我们的问题,但对于计数来说远非完整解决方案——我们跟踪频率,但在其他方面,我们是自己的。如果我们想知道我们的词袋中最常见的词是什么呢?

Counter的便利性基于其提供的一组专门用于计数的附加功能;它不仅仅是一个具有默认数值的字典,它是一个专门用于跟踪频率并提供方便的访问方式的类。

带有回退的字典

在处理配置值时,通常会在多个地方查找它们——也许我们从配置文件中加载它们——但我们可以用环境变量或命令行选项覆盖它们,如果没有提供选项,我们可以有一个默认值。

这很容易导致像这样的长链的if语句:

value = command_line_options.get('optname')
if value is None:
    value = os.environ.get('optname')
if value is None:
    value = config_file_options.get('optname')
if value is None:
    value = 'default-value'

这很烦人,而对于单个值来说可能只是烦人,但随着添加更多选项,它将变成一个庞大、令人困惑的条件列表。

命令行选项是一个非常常见的用例,但问题与链式作用域解析有关。在 Python 中,变量是通过查看locals()来解析的;如果找不到它们,解释器会查看globals(),如果还找不到,它会查找内置变量。

如何做到...

对于这一步,您需要按照以下步骤进行:

  1. 与使用多个if实例相比,dict.get的默认值链的替代方案可能并不会改进代码太多,如果我们想要添加一个额外的作用域,我们将不得不在每个查找值的地方都添加它。

  2. collections.ChainMap是这个问题的一个非常方便的解决方案;我们可以提供一个映射容器的列表,它将在它们所有中查找一个键。

  3. 我们之前的涉及多个不同if实例的示例可以转换为这样的形式:

import os
from collections import ChainMap

options = ChainMap(command_line_options, os.environ, config_file_options)
value = options.get('optname', 'default-value')
  1. 我们还可以通过将ChainMapdefaultdict结合来摆脱最后的.get调用。在这种情况下,我们可以使用defaultdict为每个键提供一个默认值:
import os
from collections import ChainMap, defaultdict

options = ChainMap(command_line_options, os.environ, config_file_options,
                   defaultdict(lambda: 'default-value'))
value = options['optname']
value2 = options['other-option']
  1. 打印valuevalue2将会得到以下结果:
optvalue
default-value

optname将从包含它的command_line_options中检索,而other-option最终将由defaultdict解析。

它是如何工作的...

ChainMap类接收多个字典作为参数;每当向ChainMap请求一个键时,它实际上会逐个查看提供的字典,以检查该键是否在其中任何一个中可用。一旦找到键,它就会返回,就好像它是ChainMap自己拥有的键一样。

未提供的选项的默认值是通过将defaultdict作为提供给ChainMap的最后一个字典来实现的。每当在之前的任何字典中找不到键时,它会在defaultdict中查找,defaultdict使用提供的工厂函数为所有键返回默认值。

还有更多...

ChainMap的另一个很棒的功能是它也允许更新,但是它总是更新第一个字典,而不是更新找到键的字典。结果是一样的,因为在下一次查找该键时,我们会发现第一个字典覆盖了该键的任何其他值(因为它是检查该键的第一个地方)。优点是,如果我们将空字典作为提供给ChainMap的第一个映射,我们可以更改这些值而不触及原始容器:

>>> population=dict(italy=60, japan=127, uk=65) >>> changes = dict()
>>> editablepop = ChainMap(changes, population)

>>> print(editablepop['japan'])
127
>>> editablepop['japan'] += 1
>>> print(editablepop['japan'])
128

但即使我们将日本的人口更改为 1.28 亿,原始人口也没有改变:

>>> print(population['japan'])
127

我们甚至可以使用changes来找出哪些值被更改了,哪些值没有被更改:

>>> print(changes.keys()) 
dict_keys(['japan']) 
>>> print(population.keys() - changes.keys()) 
{'italy', 'uk'}

顺便说一句,如果字典中包含的对象是可变的,并且我们直接对其进行改变,ChainMap无法避免改变原始对象。因此,如果我们在字典中存储的不是数字,而是列表,每当我们向字典追加值时,我们将改变原始字典:

>>> citizens = dict(torino=['Alessandro'], amsterdam=['Bert'], raleigh=['Joseph']) >>> changes = dict() 
>>> editablecits = ChainMap(changes, citizens) 
>>> editablecits['torino'].append('Simone') 
>>> print(editablecits['torino']) ['Alessandro', 'Simone']
>>> print(changes)
{}
>>> print(citizens)
{'amsterdam': ['Bert'], 
 'torino': ['Alessandro', 'Simone'], 
 'raleigh': ['Joseph']} 

解包多个关键字参数

经常情况下,你会发现自己需要从字典中向函数提供参数。如果你曾经面临过这种需求,你可能也会发现自己需要从多个字典中获取参数。

通常,Python 函数通过解包(**语法)从字典中接受参数,但到目前为止,在同一次调用中两次解包还不可能,也没有简单的方法来合并两个字典。

如何做...

这个食谱的步骤是:

  1. 给定一个函数f,我们希望按以下方式从两个字典d1d2传递参数:
>>> def f(a, b, c, d):
...     print (a, b, c, d)
...
>>> d1 = dict(a=5, b=6)
>>> d2 = dict(b=7, c=8, d=9)
  1. collections.ChainMap可以帮助我们实现我们想要的;它可以处理重复的条目,并且适用于任何 Python 版本:
>>> f(**ChainMap(d1, d2))
5 6 8 9
  1. 在 Python 3.5 及更新版本中,你还可以通过字面语法组合多个字典来创建一个新字典,然后将结果字典作为函数的参数传递:
>>> f(**{**d1, **d2})
5 7 8 9
  1. 在这种情况下,重复的条目也被接受,但按照ChainMap的优先级的相反顺序处理(从右到左)。请注意,b的值为7,而不是ChainMap中的6,这是由于优先级的反向顺序造成的。

由于涉及到大量的解包运算符,这种语法可能更难阅读,而使用ChainMap对于读者来说可能更加明确发生了什么。

它是如何工作的...

正如我们已经从之前的示例中知道的那样,ChainMap在所有提供的字典中查找键,因此它就像所有字典的总和。解包运算符(**)通过将所有键放入容器,然后为每个键提供一个参数来工作。

由于ChainMap具有所有提供的字典键的总和,它将提供包含在所有字典中的键给解包运算符,从而允许我们从多个字典中提供关键字参数。

还有更多...

自 Python 3.5 通过 PEP 448,现在可以解包多个映射以提供关键字参数:

>>> def f(a, b, c, d):
...     print (a, b, c, d)
...
>>> d1 = dict(a=5, b=6)
>>> d2 = dict(c=7, d=8)
>>> f(**d1, **d2)
5 6 7 8

这种解决方案非常方便,但有两个限制:

  • 仅适用于 Python 3.5+

  • 它无法处理重复的参数

如果你不知道你要解包的映射/字典来自哪里,很容易出现重复参数的问题:

>>> d1 = dict(a=5, b=6)
>>> d2 = dict(b=7, c=8, d=9)
>>> f(**d1, **d2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: f() got multiple values for keyword argument 'b'

在前面的示例中,b键在d1d2中都有声明,这导致函数抱怨它收到了重复的参数。

有序字典

对于新用户来说,Python 字典最令人惊讶的一个方面是,它们的顺序是不可预测的,而且在不同的环境中可能会发生变化。因此,您在自己的系统上期望的键的顺序可能在朋友的计算机上完全不同。

这经常会在测试期间导致意外的失败;如果涉及到持续集成系统,则运行测试的系统上的字典键的排序可能与您的系统上的排序不同,这可能导致随机失败。

假设您有一小段代码,它生成了一个带有一些属性的 HTML 标签:

>>> attrs = dict(style="background-color:red", id="header")
>>> '<span {}>'.format(' '.join('%s="%s"' % a for a in attrs.items()))
'<span id="header" style="background-color:red">'

也许会让你感到惊讶的是,在某些系统上,你最终会得到这样的结果:

'<span id="header" style="background-color:red">'

而在其他情况下,结果可能是这样的:

'<span style="background-color:red" id="header">'

因此,如果您期望能够比较生成的字符串,以检查您的函数在生成此标签时是否做对了,您可能会感到失望。

如何做到这一点...

键的排序是一个非常方便的功能,在某些情况下,它实际上是必需的,因此 Python 标准库提供了collections.OrderedDict容器。

collections.OrderedDict的情况下,键始终按插入的顺序排列:

>>> attrs = OrderedDict([('id', 'header'), ('style', 'background-color:red')])
>>> '<span {}>'.format(' '.join('%s="%s"' % a for a in attrs.items()))
'<span id="header" style="background-color:red">'

它是如何工作的...

OrderedDict同时存储键到值的映射和一个用于保留它们顺序的键列表。

因此,每当您查找键时,查找都会通过映射进行,但每当您想要列出键或对容器进行迭代时,您都会通过键列表来确保它们按照插入的顺序进行处理。

使用OrderedDict的主要问题是,Python 在 3.6 之前的版本中没有保证关键字参数的任何特定顺序:

>>> attrs = OrderedDict(id="header", style="background-color:red")

即使使用了OrderedDict,这将再次引入完全随机的键顺序。这不是因为OrderedDict没有保留这些键的顺序,而是因为它们可能以随机顺序接收到。

由于 PEP 468 的原因,现在在 Python 3.6 和更新版本中保证了参数的顺序(字典的顺序仍然不确定;请记住,它们是有序的只是偶然的)。因此,如果您使用的是 Python 3.6 或更新版本,我们之前的示例将按预期工作,但如果您使用的是较旧版本的 Python,您将得到一个随机的顺序。

幸运的是,这是一个很容易解决的问题。与标准字典一样,OrderedDict支持任何可迭代的内容作为其内容的来源。只要可迭代对象提供了一个键和一个值,就可以用它来构建OrderedDict

因此,通过在元组中提供键和值,我们可以在任何 Python 版本中在构建时提供它们并保留顺序:

>>> OrderedDict((('id', 'header'), ('style', 'background-color:red')))
OrderedDict([('id', 'header'), ('style', 'background-color:red')])

还有更多...

Python 3.6 引入了保留字典键顺序的保证,作为对字典的一些更改的副作用,但它被认为是一个内部实现细节,而不是语言保证。自 Python 3.7 以来,它成为语言的一个官方特性,因此如果您使用的是 Python 3.6 或更新版本,可以放心地依赖于字典的顺序。

MultiDict

如果您曾经需要提供一个反向映射,您可能已经发现 Python 缺乏一种方法来为字典中的每个键存储多个值。这是一个非常常见的需求,大多数语言都提供了某种形式的多映射容器。

Python 倾向于有一种单一的做事方式,因为为键存储多个值意味着只是为键存储一个值列表,所以它不提供专门的容器。

存储值列表的问题在于,为了能够将值附加到我们的字典中,列表必须已经存在。

如何做到这一点...

按照以下步骤进行此操作:

  1. 正如我们已经知道的,defaultdict将通过调用提供的可调用函数为每个缺失的键创建一个默认值。我们可以将list构造函数作为可调用函数提供:
>>> from collections import defaultdict
>>> rd = defaultdict(list)
  1. 因此,我们通过使用rd[k].append(v)而不是通常的rd[k] = v来将键插入到我们的多映射中:
>>> for name, num in [('ichi', 1), ('one', 1), ('uno', 1), ('un', 1)]:
...   rd[num].append(name)
...
>>> rd
defaultdict(<class 'list'>, {1: ['ichi', 'one', 'uno', 'un']})

它是如何工作的...

MultiDict通过为每个键存储一个列表来工作。每当访问一个键时,都会检索包含该键所有值的列表。

在缺少键的情况下,将提供一个空列表,以便为该键添加值。

这是因为每次defaultdict遇到缺少的键时,它将插入一个由调用list生成的值。调用list实际上会提供一个空列表。因此,执行rd[v]将始终提供一个列表,取决于v是否是已经存在的键。一旦我们有了列表,添加新值只是追加它的问题。

还有更多...

Python 中的字典是关联容器,其中键是唯一的。一个键只能出现一次,且只有一个值。

如果我们想要支持每个键多个值,实际上可以通过将list保存为键的值来满足需求。然后,该列表可以包含我们想要保留的所有值:

>>> rd = {1: ['one', 'uno', 'un', 'ichi'],
...       2: ['two', 'due', 'deux', 'ni'],
...       3: ['three', 'tre', 'trois', 'san']}
>>> rd[2]
['two', 'due', 'deux', 'ni']

如果我们想要为2(例如西班牙语)添加新的翻译,我们只需追加该条目:

>>> rd[2].append('dos')
>>> rd[2]
['two', 'due', 'deux', 'ni', 'dos']

当我们想要引入一个新的键时,问题就出现了:

>>> rd[4].append('four')
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
KeyError: 4

对于键4,没有列表存在,因此我们无法追加它。因此,我们的自动反向映射片段无法轻松适应处理多个值,因为它在尝试插入值时会出现键错误:

>>> rd = {}
>>> for k,v in d.items():
...     rd[v].append(k)
Traceback (most recent call last):
    File "<stdin>", line 2, in <module>
KeyError: 1

检查每个条目是否已经在字典中,然后根据情况采取行动并不是非常方便。虽然我们可以依赖字典的setdefault方法来隐藏该检查,但是通过使用collections.defaultdict可以获得更加优雅的解决方案。

优先处理条目

选择一组值的第一个/顶部条目是一个非常频繁的需求;这通常意味着定义一个优先于其他值的值,并涉及排序。

但是排序可能很昂贵,并且每次添加条目到您的值时重新排序肯定不是一种非常方便的方式来从一组具有某种优先级的值中选择第一个条目。

如何做...

堆是一切具有优先级的完美匹配,例如优先级队列:

import time
import heapq

class PriorityQueue:
    def __init__(self):
        self._q = []

    def add(self, value, priority=0):
        heapq.heappush(self._q, (priority, time.time(), value))

    def pop(self):
        return heapq.heappop(self._q)[-1]

然后,我们的PriorityQueue可以用于检索给定优先级的条目:

>>> def f1(): print('hello')
>>> def f2(): print('world')
>>>
>>> pq = PriorityQueue()
>>> pq.add(f2, priority=1)
>>> pq.add(f1, priority=0)
>>> pq.pop()()
hello
>>> pq.pop()()
world

它是如何工作的...

PriorityQueue通过在堆中存储所有内容来工作。堆在检索排序集的顶部/第一个元素时特别高效,而无需实际对整个集进行排序。

我们的优先级队列将所有值存储在一个三元组中:prioritytime.time()value

我们元组的第一个条目是priority(较低的优先级更好)。在示例中,我们记录了f1的优先级比f2更好,这确保了当我们使用heap.heappop获取要处理的任务时,我们首先得到f1,然后是f2,这样我们最终得到的是hello world消息而不是world hello

第二个条目timestamp用于确保具有相同优先级的任务按其插入顺序进行处理。最旧的任务将首先被处理,因为它将具有最小的时间戳。

然后,我们有值本身,这是我们要为任务调用的函数。

还有更多...

对于排序的一个非常常见的方法是将条目列表保存在一个元组中,其中第一个元素是我们正在排序的key,第二个元素是值本身。

对于记分牌,我们可以保留每个玩家的姓名和他们得到的分数:

scores = [(123, 'Alessandro'),
          (143, 'Chris'),
          (192, 'Mark']

将这些值存储在元组中有效,因为比较两个元组是通过将第一个元组的每个元素与另一个元组中相同索引位置的元素进行比较来执行的:

>>> (10, 'B') > (10, 'A')
True
>>> (11, 'A') > (10, 'B')
True

如果您考虑字符串,就可以很容易地理解发生了什么。'BB' > 'BB'('B', 'B') > ('B', 'A')相同;最终,字符串只是字符列表。

我们可以利用这个属性对我们的scores进行排序,并检索比赛的获胜者:

>>> scores = sorted(scores)
>>> scores[-1]
(192, 'Mark')

这种方法的主要问题是,每次我们向列表添加条目时,我们都必须重新对其进行排序,否则我们的计分板将变得毫无意义:

>>> scores.append((137, 'Rick'))
>>> scores[-1]
(137, 'Rick')
>>> scores = sorted(scores)
>>> scores[-1]
(192, 'Mark')

这很不方便,因为如果我们有多个地方向列表添加元素,很容易错过重新排序的地方,而且每次对整个列表进行排序可能会很昂贵。

Python 标准库提供了一种数据结构,当我们想要找出比赛的获胜者时,它是完美的匹配。

heapq模块中,我们有一个完全工作的堆数据结构的实现,这是一种特殊类型的树,其中每个父节点都小于其子节点。这为我们提供了一个具有非常有趣属性的树:根元素始终是最小的。

并且它是建立在列表之上的,这意味着l[0]始终是heap中最小的元素:

>>> import heapq
>>> l = []
>>> heapq.heappush(l, (192, 'Mark'))
>>> heapq.heappush(l, (123, 'Alessandro'))
>>> heapq.heappush(l, (137, 'Rick'))
>>> heapq.heappush(l, (143, 'Chris'))
>>> l[0]
(123, 'Alessandro')

顺便说一句,您可能已经注意到,堆找到了我们比赛的失败者,而不是获胜者,而我们对找到最好的玩家,即最高价值的玩家感兴趣。

这是一个我们可以通过将所有分数存储为负数来轻松解决的小问题。如果我们将每个分数存储为* -1,那么堆的头部将始终是获胜者:

>>> l = []
>>> heapq.heappush(l, (-143, 'Chris'))
>>> heapq.heappush(l, (-137, 'Rick'))
>>> heapq.heappush(l, (-123, 'Alessandro'))
>>> heapq.heappush(l, (-192, 'Mark'))
>>> l[0]
(-192, 'Mark')

Bunch

Python 非常擅长变形对象。每个实例都可以有自己的属性,并且在运行时添加/删除对象的属性是完全合法的。

偶尔,我们的代码需要处理未知形状的数据。例如,在用户提交的数据的情况下,我们可能不知道用户提供了哪些字段;也许我们的一些用户有名字,一些有姓氏,一些有一个或多个中间名字段。

如果我们不是自己处理这些数据,而只是将其提供给其他函数,我们实际上并不关心数据的形状;只要我们的对象具有这些属性,我们就没问题。

一个非常常见的情况是在处理协议时,如果您是一个 HTTP 服务器,您可能希望向您后面运行的应用程序提供一个request对象。这个对象有一些已知的属性,比如hostpath,还可能有一些可选的属性,比如query字符串或content类型。但是,它也可以有客户端提供的任何属性,因为 HTTP 在头部方面非常灵活,我们的客户端可能提供了一个x-totally-custom-header,我们可能需要将其暴露给我们的代码。

在表示这种类型的数据时,Python 开发人员通常倾向于查看字典。最终,Python 对象本身是建立在字典之上的,并且它们符合将任意值映射到名称的需求。

因此,我们可能最终会得到以下内容:

>>> request = dict(host='www.example.org', path='/index.html')

这种方法的一个副作用在于,一旦我们不得不将这个对象传递给其他代码,特别是第三方代码时,就变得非常明显。函数通常使用对象工作,虽然它们不需要特定类型的对象,因为鸭子类型是 Python 中的标准,但它们会期望某些属性存在。

另一个非常常见的例子是在编写测试时,Python 作为一种鸭子类型的语言,希望提供一个假对象而不是提供对象的真实实例是绝对合理的,特别是当我们需要模拟一些属性的值(如使用@property声明),因此我们不希望或无法创建对象的真实实例。

在这种情况下,使用字典是不可行的,因为它只能通过request['path']语法访问其值,而不能通过request.path访问,这可能是我们提供对象给函数时所期望的。

此外,我们访问这个值的次数越多,就越清楚使用点符号表示法传达了代码意图的实体协作的感觉,而字典传达了纯粹数据的感觉。

一旦我们记住 Python 对象可以随时改变形状,我们可能会尝试创建一个对象而不是字典。不幸的是,我们无法在初始化时提供属性:

>>> request = object(host='www.example.org', path='/index.html')
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
TypeError: object() takes no parameters

如果我们尝试在构建对象后分配这些属性,情况也不会有所改善:

>>> request = object()
>>> request.host = 'www.example.org'
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
AttributeError: 'object' object has no attribute 'host'

如何做...

通过一点努力,我们可以创建一个利用字典来包含我们想要的任何属性并允许通过属性和字典访问的类:

>>> class Bunch(dict):
...    def __getattribute__(self, key):
...        try: 
...            return self[key]
...        except KeyError:
...            raise AttributeError(key)
...    
...    def __setattr__(self, key, value): 
...        self[key] = value
...
>>> b = Bunch(a=5)
>>> b.a
5
>>> b['a']
5

它是如何工作的...

Bunch类继承自dict,主要是为了提供一个值可以被存储的上下文,然后大部分工作由__getattribute____setattr__完成。因此,对于在对象上检索或设置的任何属性,它们只会检索或设置self中的一个键(记住我们继承自dict,所以self实际上是一个字典)。

这使得Bunch类能够将任何值存储和检索为对象的属性。方便的特性是它在大多数情况下既可以作为对象又可以作为dict来使用。

例如,可以找出它包含的所有值,就像任何其他字典一样:

>>> b.items()
dict_items([('a', 5)])

它还能够将它们作为属性访问:

>>> b.c = 7
>>> b.c
7
>>> b.items()
dict_items([('a', 5), ('c', 7)])

还有更多...

我们的bunch实现还不完整,因为它将无法通过任何类名称测试(它总是被命名为Bunch),也无法通过任何继承测试,因此无法伪造其他对象。

第一步是使Bunch能够改变其属性,还能改变其名称。这可以通过每次创建Bunch时动态创建一个新类来实现。该类将继承自Bunch,除了提供一个新名称外不会做任何其他事情:

>>> class BunchBase(dict):
...    def __getattribute__(self, key):
...        try: 
...            return self[key]
...        except KeyError:
...            raise AttributeError(key)
...    
...    def __setattr__(self, key, value): 
...        self[key] = value
...
>>> def Bunch(_classname="Bunch", **attrs):
...     return type(_classname, (BunchBase, ), {})(**attrs)
>>>

Bunch函数从原来的类本身变成了一个工厂,将创建所有作为Bunch的对象,但可以有不同的类。每个Bunch将是BunchBase的子类,其中在创建Bunch时可以提供_classname名称:

>>> b = Bunch("Request", path="/index.html", host="www.example.org")
>>> print(b)
{'path': '/index.html', 'host': 'www.example.org'}
>>> print(b.path)
/index.html
>>> print(b.host)
www.example.org

这将允许我们创建任意类型的Bunch对象,并且每个对象都将有自己的自定义类型:

>>> print(b.__class__)
<class '__main__.Request'>

下一步是使我们的Bunch实际上看起来像它必须模仿的任何其他类型。这对于我们想要在另一个对象的位置使用Bunch的情况是必要的。由于Bunch可以具有任何类型的属性,因此它可以代替任何类型的对象,但为了能够这样做,它必须通过自定义类型的类型检查。

我们需要回到我们的Bunch工厂,并使Bunch对象不仅具有自定义类名,还要看起来是从自定义父类继承而来。

为了更好地理解发生了什么,我们将声明一个示例Person类型;这个类型将是我们的Bunch对象尝试伪造的类型:

class Person(object):
    def __init__(name, surname):
        self.name = name
        self.surname = surname

    @property
    def fullname(self):
        return '{} {}'.format(self.name, self.surname)

具体来说,我们将通过一个自定义的print函数打印Hello Your Name,该函数仅适用于Person

def hello(p):
    if not isinstance(p, Person):
        raise ValueError("Sorry, can only greet people")
    print("Hello {}".format(p.fullname))

我们希望改变我们的Bunch工厂,接受该类并创建一个新类型:

def Bunch(_classname="Bunch", _parent=None, **attrs):
    parents = (_parent, ) if parent else tuple()
    return type(_classname, (BunchBase, ) + parents, {})(**attrs)

现在,我们的Bunch对象将显示为我们想要的类的实例,并且始终显示为_parent的子类:

>>> p = Bunch("Person", Person, fullname='Alessandro Molina')
>>> hello(p)
Hello Alessandro Molina

Bunch可以是一种非常方便的模式;在其完整和简化版本中,它被广泛用于许多框架中,具有各种实现,但都可以实现几乎相同的结果。

展示的实现很有趣,因为它让我们清楚地知道发生了什么。有一些非常聪明的方法可以实现Bunch,但可能会让人难以猜测发生了什么并进行自定义。

实现Bunch模式的另一种可能的方法是通过修补包含类的所有属性的__dict__类:

class Bunch(dict):
    def __init__(self, **kwds):
        super().__init__(**kwds)
        self.__dict__ = self

在这种形式下,每当创建Bunch时,它将以dict的形式填充其值(通过调用super().__init__,这是dict的初始化),然后,一旦所有提供的属性都存储在dict中,它就会用self交换__dict__对象,这是包含所有对象属性的字典。这使得刚刚填充了所有值的dict也成为了包含对象所有属性的dict

我们之前的实现是通过替换我们查找属性的方式来工作的,而这个实现是替换我们查找属性的地方。

枚举

枚举是存储只能表示几种状态的值的常见方式。每个符号名称都绑定到一个特定的值,通常是数字,表示枚举可以具有的状态。

枚举在其他编程语言中非常常见,但直到最近,Python 才没有对枚举提供明确的支持。

如何做到...

通常,枚举是通过将符号名称映射到数值来实现的;在 Python 中,通过enum.IntEnum是允许的:

>>> from enum import IntEnum
>>> 
>>> class RequestType(IntEnum):
...     POST = 1
...     GET = 2
>>>
>>> request_type = RequestType.POST
>>> print(request_type)
RequestType.POST

它是如何工作的...

IntEnum是一个整数,除了在类定义时创建所有可能的值。IntEnum继承自int,因此它的值是真正的整数。

RequestType的定义过程中,所有enum的可能值都在类体内声明,并且这些值通过元类进行重复验证。

此外,enum提供了对特殊值auto的支持,它的意思是只是放一个值进去,我不在乎。通常你只关心它是POST还是GET,你通常不关心POST1还是2

最后但并非最不重要的是,如果枚举定义了至少一个可能的值,那么枚举就不能被子类化。

还有更多...

IntEnum的值在大多数情况下表现得像int,这通常很方便,但如果开发人员不注意类型,它们可能会引起问题。

例如,如果提供了另一个枚举或整数值,而不是正确的枚举值,函数可能会意外执行错误的操作:

>>> def do_request(kind):
...    if kind == RequestType.POST:
...        print('POST')
...    else:
...        print('OTHER')

例如,使用RequestType.POST1调用do_request将做完全相同的事情:

>>> do_request(RequestType.POST)
POST
>>> do_request(1)
POST

当我们不想将枚举视为数字时,可以使用enum.Enum,它提供了不被视为普通数字的枚举值:

>>> from enum import Enum
>>> 
>>> class RequestType(Enum):
...     POST = 1
...     GET = 2
>>>
>>> do_request(RequestType.POST)
POST
>>> do_request(1)
OTHER

因此,一般来说,如果你需要一个简单的枚举值集合或依赖于enum的可能状态,Enum更安全,但如果你需要依赖于enum的一组数值,IntEnum将确保它们表现得像数字。

第二章:文本管理

在本章中,我们将涵盖以下配方:

  • 模式匹配-正则表达式不是解析模式的唯一方法;Python 提供了更简单且同样强大的工具来解析模式

  • 文本相似性-检测两个相似字符串的性能可能很困难,但 Python 有一些易于使用的内置工具

  • 文本建议-Python 寻找最相似的一个建议给用户正确的拼写

  • 模板化-在生成文本时,模板化是定义规则的最简单方法

  • 保留空格拆分字符串-在空格上拆分可能很容易,但当您想保留一些空格时会变得更加困难

  • 清理文本-从文本中删除任何标点符号或奇怪的字符

  • 文本标准化-在处理国际文本时,通常方便避免处理特殊字符和单词拼写错误

  • 对齐文本-在输出文本时,正确对齐文本大大增加了可读性

介绍

Python 是为系统工程而生的,当与 shell 脚本和基于 shell 的软件一起工作时,经常需要创建和解析文本。这就是为什么 Python 有非常强大的工具来处理文本。

模式匹配

在文本中寻找模式时,正则表达式通常是解决这类问题的最常见方式。它们非常灵活和强大,尽管它们不能表达所有种类的语法,但它们通常可以处理大多数常见情况。

正则表达式的强大之处在于它们可以生成的广泛符号和表达式集。问题在于,对于不习惯正则表达式的开发人员来说,它们可能看起来就像纯噪音,即使有经验的人也经常需要花一点时间才能理解下面的表达式:

"^(*d{3})*( |-)*d{3}( |-)*d{4}$"

这个表达式实际上试图检测电话号码。

对于大多数常见情况,开发人员需要寻找非常简单的模式:例如,文件扩展名(它是否以.txt结尾?),分隔文本等等。

如何做...

fnmatch模块提供了一个简化的模式匹配语言,对于大多数开发人员来说,语法非常快速和易于理解。

很少有字符具有特殊含义:

  • *表示任何文本

  • ?表示任何字符

  • [...]表示方括号内包含的字符

  • [!...]表示除了方括号内包含的字符之外的所有内容

您可能会从系统 shell 中认出这个语法,所以很容易看出*.txt意味着每个具有.txt 扩展名的名称

>>> fnmatch.fnmatch('hello.txt', '*.txt')
True
>>> fnmatch.fnmatch('hello.zip', '*.txt')
False

还有更多...

实际上,fnmatch可以用于识别由某种常量值分隔的文本片段。

例如,如果我有一个模式,定义了变量的类型名称,通过:分隔,我们可以通过fnmatch识别它,然后声明所描述的变量:

>>> def declare(decl):
...   if not fnmatch.fnmatch(decl, '*:*:*'):
...     return False
...   t, n, v = decl.split(':', 2)
...   globals()[n] = getattr(__builtins__, t)(v)
...   return True
... 
>>> declare('int:somenum:3')
True
>>> somenum
3
>>> declare('bool:somebool:True')
True
>>> somebool
True
>>> declare('int:a')
False

显然,fnmatch在文件名方面表现出色。如果您有一个文件列表,很容易提取只匹配特定模式的文件:

>>> os.listdir()
['.git', '.gitignore', '.vscode', 'algorithms.rst', 'concurrency.rst', 
 'conf.py', 'crypto.rst', 'datastructures.rst', 'datetimes.rst', 
 'devtools.rst', 'filesdirs.rst', 'gui.rst', 'index.rst', 'io.rst', 
 'make.bat', 'Makefile', 'multimedia.rst', 'networking.rst', 
 'requirements.txt', 'terminal.rst', 'text.rst', 'venv', 'web.rst']
>>> fnmatch.filter(os.listdir(), '*.git*')
['.git', '.gitignore']

虽然非常方便,fnmatch显然是有限的,但当一个工具达到其极限时,最好的事情之一就是提供与可以克服这些限制的替代工具兼容的兼容性。

例如,如果我想找到所有包含单词gitvs的文件,我不能在一个fnmatch模式中做到这一点。我必须声明两种不同的模式,然后将结果连接起来。但是,如果我可以使用正则表达式,那是绝对可能的。

fnmatch.translatefnmatch模式和正则表达式之间建立桥梁,提供描述fnmatch模式的正则表达式,以便可以根据需要进行扩展。

例如,我们可以创建一个匹配这两种模式的正则表达式:

>>> reg = '({})|({})'.format(fnmatch.translate('*.git*'), 
                             fnmatch.translate('*vs*'))
>>> reg
'(.*\.git.*\Z(?ms))|(.*vs.*\Z(?ms))'
>>> import re
>>> [s for s in os.listdir() if re.match(reg, s)]
['.git', '.gitignore', '.vscode']

fnmatch的真正优势在于它是一种足够简单和安全的语言,可以向用户公开。假设您正在编写一个电子邮件客户端,并且希望提供搜索功能,如果您有来自 Jane Smith 和 Smith Lincoln 的电子邮件,您如何让用户搜索名为 Smith 或姓为 Smith 的人?

使用fnmatch很容易,因为您可以将其提供给用户,让他们编写*SmithSmith*,具体取决于他们是在寻找名为 Smith 的人还是姓氏为 Smith 的人:

>>> senders = ['Jane Smith', 'Smith Lincoln']
>>> fnmatch.filter(senders, 'Smith*')
['Smith Lincoln']
>>> fnmatch.filter(senders, '*Smith')
['Jane Smith']

文本相似性

在许多情况下,当处理文本时,我们可能需要识别与其他文本相似的文本,即使这两者并不相等。这在记录链接、查找重复条目或更正打字错误时非常常见。

查找文本相似性并不是一项简单的任务。如果您尝试自己去做,您很快就会意识到它很快变得复杂和缓慢。

Python 库提供了在difflib模块中检测两个序列之间差异的工具。由于文本本身是一个序列(字符序列),我们可以应用提供的函数来检测字符串的相似性。

如何做...

执行此食谱的以下步骤:

  1. 给定一个字符串,我们想要比较:
>>> s = 'Today the weather is nice'
  1. 此外,我们想将一组字符串与第一个字符串进行比较:
>>> s2 = 'Today the weater is nice'
>>> s3 = 'Yesterday the weather was nice'
>>> s4 = 'Today my dog ate steak'
  1. 我们可以使用difflib.SequenceMatcher来计算字符串之间的相似度(从 0 到 1)。
>>> import difflib
>>> difflib.SequenceMatcher(None, s, s2, False).ratio()
0.9795918367346939
>>> difflib.SequenceMatcher(None, s, s3, False).ratio()
0.8
>>> difflib.SequenceMatcher(None, s, s4, False).ratio()
0.46808510638297873

因此,SequenceMatcher能够检测到ss2非常相似(98%),除了weather中的拼写错误之外,它们实际上是完全相同的短语。然后它指出Today the weather is niceYesterday the weather was nice相似度为 80%,最后指出Today the weather is niceToday my dog ate steak几乎没有共同之处。

还有更多...

SequenceMatcher提供了对一些值标记为junk的支持。您可能期望这意味着这些值被忽略,但实际上并非如此。

使用和不使用垃圾计算比率在大多数情况下将返回相同的值:

>>> a = 'aaaaaaaaaaaaaXaaaaaaaaaa'
>>> b = 'X'
>>> difflib.SequenceMatcher(lambda c: c=='a', a, b, False).ratio()
0.08
>>> difflib.SequenceMatcher(None, a, b, False).ratio()
0.08    

即使我们提供了一个报告所有a结果为垃圾的isjunk函数(SequenceMatcher的第一个参数),a的结果也没有被忽略。

您可以通过使用.get_matching_blocks()来看到,在这两种情况下,字符串匹配的唯一部分是X在位置130处的ab

>>> difflib.SequenceMatcher(None, a, b, False).get_matching_blocks()
[Match(a=13, b=0, size=1), Match(a=24, b=1, size=0)]
>>> difflib.SequenceMatcher(lambda c: c=='a', a, b, False).get_matching_blocks()
[Match(a=13, b=0, size=1), Match(a=24, b=1, size=0)]

如果您想在计算差异时忽略一些字符,您将需要在运行SequenceMatcher之前剥离它们,也许使用一个丢弃它们的翻译映射:

>>> discardmap = str.maketrans({"a": None})
>>> difflib.SequenceMatcher(None, a.translate(discardmap), b.translate(discardmap), False).ratio()
1.0

文本建议

在我们之前的食谱中,我们看到difflib如何计算两个字符串之间的相似度。这意味着我们可以计算两个单词之间的相似度,并向我们的用户提供建议更正。

如果已知正确单词的集合(通常对于任何语言都是如此),我们可以首先检查单词是否在这个集合中,如果不在,我们可以寻找最相似的单词建议给用户正确的拼写。

如何做...

遵循此食谱的步骤是:

  1. 首先,我们需要一组有效的单词。为了避免引入整个英语词典,我们只会抽样一些单词:
dictionary = {'ability', 'able', 'about', 'above', 'accept',    
              'according', 
              'account', 'across', 'act', 'action', 'activity', 
              'actually', 
              'add', 'address', 'administration', 'admit', 'adult', 
              'affect', 
              'after', 'again', 'against', 'age', 'agency', 
              'agent', 'ago', 
              'agree', 'agreement', 'ahead', 'air', 'all', 'allow',  
              'almost', 
              'alone', 'along', 'already', 'also', 'although', 
              'always', 
              'American', 'among', 'amount', 'analysis', 'and', 
              'animal', 
              'another', 'answer', 'any', 'anyone', 'anything', 
              'appear', 
              'apply', 'approach', 'area', 'argue', 
              'arm', 'around', 'arrive', 
              'art', 'article', 'artist', 'as', 'ask', 'assume', 
              'at', 'attack', 
              'attention', 'attorney', 'audience', 'author',  
              'authority', 
              'available', 'avoid', 'away', 'baby', 'back', 'bad', 
              'bag', 
              'ball', 'bank', 'bar', 'base', 'be', 'beat', 
              'beautiful', 
              'because', 'become'}
  1. 然后我们可以编写一个函数,对于提供的任何短语,都会在我们的字典中查找单词,如果找不到,就通过difflib提供最相似的候选词:
import difflib

def suggest(phrase):
    changes = 0
    words = phrase.split()
    for idx, w in enumerate(words):
        if w not in dictionary:
            changes += 1
            matches = difflib.get_close_matches(w, dictionary)
            if matches:
                words[idx] = matches[0]
    return changes, ' '.join(words)
  1. 我们的suggest函数将能够检测拼写错误并建议更正的短语:
>>> suggest('assume ani answer')
(1, 'assume any answer')
>>> suggest('anoter agrement ahead')
(2, 'another agreement ahead')

第一个返回的参数是检测到的错误单词数,第二个是具有最合理更正的字符串。

  1. 如果我们的短语没有错误,我们将得到原始短语的0
>>> suggest('beautiful art')
(0, 'beautiful art')

模板

向用户显示文本时,经常需要根据软件状态动态生成文本。

通常,这会导致这样的代码:

name = 'Alessandro'
messages = ['Message 1', 'Message 2']

txt = 'Hello %s, You have %s message' % (name, len(messages))
if len(messages) > 1:
    txt += 's'
txt += ':n'
for msg in messages:
    txt += msg + 'n'
print(txt)

这使得很难预见消息的即将到来的结构,而且在长期内也很难维护。生成文本时,通常更方便的是反转这种方法,而不是将文本放入代码中,我们应该将代码放入文本中。这正是模板引擎所做的,虽然标准库提供了非常完整的格式化解决方案,但缺少一个开箱即用的模板引擎,但可以很容易地扩展为一个模板引擎。

如何做...

本教程的步骤如下:

  1. string.Formatter对象允许您扩展其语法,因此我们可以将其专门化以支持将代码注入到它将要接受的表达式中:
import string

class TemplateFormatter(string.Formatter):
    def get_field(self, field_name, args, kwargs):
        if field_name.startswith("$"):
            code = field_name[1:]
            val = eval(code, {}, dict(kwargs))
            return val, field_name
        else:
            return super(TemplateFormatter, self).get_field(field_name, args, kwargs)
  1. 然后,我们的TemplateFormatter可以用来以更简洁的方式生成类似于我们示例的文本:
messages = ['Message 1', 'Message 2']

tmpl = TemplateFormatter()
txt = tmpl.format("Hello {name}, "
                  "You have {$len(messages)} message{$len(messages) and 's'}:n{$'\n'.join(messages)}", 
                  name='Alessandro', messages=messages)
print(txt)

结果应该是:

Hello Alessandro, You have 2 messages:
Message 1
Message 2

它是如何工作的...

string.Formatter支持与str.format方法支持的相同语言。实际上,它根据 Python 称为格式化字符串语法的内容解析包含在{}中的表达式。{}之外的所有内容保持不变,而{}中的任何内容都会被解析为field_name!conversion:format_spec规范。因此,由于我们的field_name不包含!:,它可以是任何其他内容。

然后提取的field_name被提供给Formatter.get_field,以查找format方法提供的参数中该字段的值。

因此,例如,采用这样的表达式:

string.Formatter().format("Hello {name}", name='Alessandro')

这导致:

Hello Alessandro

因为{name}被识别为要解析的块,所以会在.format参数中查找名称,并保留其余部分不变。

这非常方便,可以解决大多数字符串格式化需求,但缺乏像循环和条件语句这样的真正模板引擎的功能。

我们所做的是扩展Formatter,不仅解析field_name中指定的变量,还评估 Python 表达式。

由于我们知道所有的field_name解析都要经过Formatter.get_field,在我们自己的自定义类中覆盖该方法将允许我们更改每当评估像{name}这样的field_name时发生的情况:

class TemplateFormatter(string.Formatter):
    def get_field(self, field_name, args, kwargs):

为了区分普通变量和表达式,我们使用了$符号。由于 Python 变量永远不会以$开头,因此我们不会与提供给格式化的参数发生冲突(因为str.format($something=5实际上是 Python 中的语法错误)。因此,像{$something}这样的field_name不意味着查找''$something的值,而是评估something表达式:

if field_name.startswith("$"):
    code = field_name[1:]
    val = eval(code, {}, dict(kwargs))

eval函数运行在字符串中编写的任何代码,并将执行限制为表达式(Python 中的表达式总是导致一个值,与不导致值的语句不同),因此我们还进行了语法检查,以防止模板用户编写if something: x='hi',这将不会提供任何值来显示在渲染模板后的文本中。

然后,由于我们希望用户能够查找到他们提供的表达式引用的任何变量(如{$len(messages)}),我们将kwargs提供给eval作为locals变量,以便任何引用变量的表达式都能正确解析。我们还提供一个空的全局上下文{},以便我们不会无意中触及软件的任何全局变量。

剩下的最后一部分就是将eval提供的表达式执行结果作为field_name解析的结果返回:

return val, field_name

真正有趣的部分是所有处理都发生在get_field阶段。转换和格式规范仍然受支持,因为它们是应用于get_field返回的值。

这使我们可以写出这样的东西:

{$3/2.0:.2f}

我们得到的输出是1.50,而不是1.5。这是因为我们在我们专门的TemplateFormatter.get_field方法中首先评估了3/2.0,然后解析器继续应用格式规范(.2f)到结果值。

还有更多...

我们的简单模板引擎很方便,但仅限于我们可以将生成文本的代码表示为一组表达式和静态文本的情况。

问题在于更高级的模板并不总是可以表示。我们受限于简单的表达式,因此实际上任何不能用lambda表示的东西都不能由我们的模板引擎执行。

虽然有人会认为通过组合多个lambda可以编写非常复杂的软件,但大多数人会认为语句会导致更可读的代码。

因此,如果你需要处理非常复杂的文本,你应该使用功能齐全的模板引擎,并寻找像 Jinja、Kajiki 或 Mako 这样的解决方案。特别是对于生成 HTML,像 Kajiki 这样的解决方案,它还能够验证你的 HTML,非常方便,可以比我们的TemplateFormatter做得更多。

拆分字符串并保留空格

通常在按空格拆分字符串时,开发人员倾向于依赖str.split,它能够很好地完成这个目的。但是当需要拆分一些空格并保留其他空格时,事情很快变得更加困难,实现一个自定义解决方案可能需要投入时间来进行适当的转义。

如何做...

只需依赖shlex.split而不是str.split

>>> import shlex
>>>
>>> text = 'I was sleeping at the "Windsdale Hotel"'
>>> print(shlex.split(text))
['I', 'was', 'sleeping', 'at', 'the', 'Windsdale Hotel']

工作原理...

shlex是最初用于解析 Unix shell 代码的模块。因此,它支持通过引号保留短语。通常在 Unix 命令行中,由空格分隔的单词被提供为调用命令的参数,但如果你想将多个单词作为单个参数提供,可以使用引号将它们分组。

这正是shlex所复制的,为我们提供了一个可靠的驱动拆分的方法。我们只需要用双引号或单引号包裹我们想要保留的所有内容。

清理文本

在分析用户提供的文本时,我们通常只对有意义的单词感兴趣;标点、空格和连词可能很容易妨碍我们。假设你想要统计一本书中单词的频率,你不希望最后得到"world"和"world"被计为两个不同的单词。

如何做...

你需要执行以下步骤:

  1. 提供要清理的文本:
txt = """And he looked over at the alarm clock,
ticking on the chest of drawers. "God in Heaven!" he thought.
It was half past six and the hands were quietly moving forwards,
it was even later than half past, more like quarter to seven.
Had the alarm clock not rung? He could see from the bed that it
had been set for four o'clock as it should have been; it certainly must have rung.
Yes, but was it possible to quietly sleep through that furniture-rattling noise?
True, he had not slept peacefully, but probably all the more deeply because of that."""
  1. 我们可以依赖string.punctuation来知道我们想要丢弃的字符,并制作一个转换表来丢弃它们全部:
>>> import string
>>> trans = str.maketrans('', '', string.punctuation)
>>> txt = txt.lower().translate(trans)

结果将是我们文本的清理版本:

"""and he looked over at the alarm clock
ticking on the chest of drawers god in heaven he thought
it was half past six and the hands were quietly moving forwards
it was even later than half past more like quarter to seven
had the alarm clock not rung he could see from the bed that it
had been set for four oclock as it should have been it certainly must have rung
yes but was it possible to quietly sleep through that furniturerattling noise
true he had not slept peacefully but probably all the more deeply because of that"""

工作原理...

这个示例的核心是使用转换表。转换表是将字符链接到其替换的映射。像{'c': 'A'}这样的转换表意味着任何'c'都必须替换为'A'

str.maketrans是用于构建转换表的函数。第一个参数中的每个字符将映射到第二个参数中相同位置的字符。然后最后一个参数中的所有字符将映射到None

>>> str.maketrans('a', 'b', 'c')
{97: 98, 99: None}

979899'a''b''c'的 Unicode 值:

>>> print(ord('a'), ord('b'), ord('c'))
97 98 99

然后我们的映射可以传递给str.translate来应用到目标字符串上。有趣的是,任何映射到None的字符都将被删除:

>>> 'ciao'.translate(str.maketrans('a', 'b', 'c'))
'ibo'

在我们之前的示例中,我们将string.punctuation作为str.maketrans的第三个参数。

string.punctuation是一个包含最常见标点字符的字符串:

>>> string.punctuation
'!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'

通过这样做,我们建立了一个事务映射,将每个标点字符映射到None,并没有指定任何其他映射:

>>> str.maketrans('', '', string.punctuation)
{64: None, 124: None, 125: None, 91: None, 92: None, 93: None,
 94: None, 95: None, 96: None, 33: None, 34: None, 35: None,
 36: None, 37: None, 38: None, 39: None, 40: None, 41: None,
 42: None, 43: None, 44: None, 45: None, 46: None, 47: None,
 123: None, 126: None, 58: None, 59: None, 60: None, 61: None,
 62: None, 63: None}

这样一来,一旦应用了str.translate,标点字符就都被丢弃了,保留了所有其他字符:

>>> 'This, is. A test!'.translate(str.maketrans('', '', string.punctuation))
'This is A test'

文本规范化

在许多情况下,一个单词可以用多种方式书写。例如,写"Über"和"Uber"的用户可能意思相同。如果你正在为博客实现标记等功能,你肯定不希望最后得到两个不同的标记。

因此,在保存标签之前,您可能希望将它们标准化为普通的 ASCII 字符,以便它们最终被视为相同的标签。

如何做...

我们需要的是一个翻译映射,将所有带重音的字符转换为它们的普通表示:

import unicodedata, sys

class unaccented_map(dict):
    def __missing__(self, key):
        ch = self.get(key)
        if ch is not None:
            return ch
        de = unicodedata.decomposition(chr(key))
        if de:
            try:
                ch = int(de.split(None, 1)[0], 16)
            except (IndexError, ValueError):
                ch = key
        else:
            ch = key
        self[key] = ch
        return ch

unaccented_map = unaccented_map()

然后我们可以将其应用于任何单词来进行规范化:

>>> 'Über'.translate(unaccented_map) Uber >>> 'garçon'.translate(unaccented_map) garcon

它是如何工作的...

我们已经知道如何解释清理文本食谱中解释的那样,str.translate是如何工作的:每个字符都在翻译表中查找,并且用表中指定的替换进行替换。

因此,我们需要的是一个翻译表,将"Ü"映射到"U",将"ç"映射到"c",依此类推。

但是我们如何知道所有这些映射呢?这些字符的一个有趣特性是它们可以被认为是带有附加符号的普通字符。就像à可以被认为是带有重音的a

Unicode 等价性知道这一点,并提供了多种写入被认为是相同字符的方法。我们真正感兴趣的是分解形式,这意味着将字符写成定义它的多个分隔符。例如,é将被分解为00650301,这是e和重音的代码点。

Python 提供了一种通过unicodedata.decompostion函数知道字符分解版本的方法:

>>> import unicodedata
>>> unicodedata.decomposition('é')
'0065 0301'

第一个代码点是基本字符的代码点,而第二个是添加的符号。因此,要规范化我们的è,我们将选择第一个代码点0065并丢弃符号:

>>> unicodedata.decomposition('é').split()[0]
'0065'

现在我们不能单独使用代码点,但我们想要它表示的字符。幸运的是,chr函数提供了一种从其代码点的整数表示中获取字符的方法。

unicodedata.decomposition函数提供的代码点是表示十六进制数字的字符串,因此首先我们需要将它们转换为整数:

>>> int('0065', 16)
101

然后我们可以应用chr来知道实际的字符:

>>> chr(101)
'e'

现在我们知道如何分解这些字符并获得我们想要将它们全部标准化为的基本字符,但是我们如何为它们构建一个翻译映射呢?

答案是我们不需要。事先为所有字符构建翻译映射并不是很方便,因此我们可以使用字典提供的功能,在需要时动态地为字符构建翻译。

翻译映射是字典,每当字典需要查找它不知道的键时,它可以依靠__missing__方法为该键生成一个值。因此,我们的__missing__方法必须做我们刚才做的事情,并使用unicodedata.decomposition来获取字符的规范化版本,每当str.translate尝试在我们的翻译映射中查找它时。

一旦我们计算出所请求字符的翻译,我们只需将其存储在字典本身中,这样下次再被请求时,我们就不必再计算它。

因此,我们的食谱的unaccented_map只是一个提供__missing__方法的字典,该方法依赖于unicodedata.decompostion来检索每个提供的字符的规范化版本。

如果它无法找到字符的非规范化版本,它将只返回原始版本一次,以免字符串被损坏。

对齐文本

在打印表格数据时,通常非常重要的是确保文本正确对齐到固定长度,既不长也不短于我们为表格单元保留的空间。

如果文本太短,下一列可能会开始得太早;如果太长,它可能会开始得太晚。这会导致像这样的结果:

col1 | col2-1
col1-2 | col2-2

或者这样:

col1-000001 | col2-1
col1-2 | col2-2

这两者都很难阅读,并且远非显示正确表格的样子。

给定固定的列宽(20 个字符),我们希望我们的文本始终具有确切的长度,以便它不会导致错位的表格。

如何做...

以下是此食谱的步骤:

  1. 一旦将textwrap模块与str对象的特性结合起来,就可以帮助我们实现预期的结果。首先,我们需要打印的列的内容:
cols = ['hello world', 
        'this is a long text, maybe longer than expected, surely long enough', 
        'one more column']
  1. 然后我们需要修复列的大小:
COLSIZE = 20
  1. 一旦这些准备好了,我们就可以实际实现我们的缩进函数:
import textwrap, itertools

def maketable(cols):
    return 'n'.join(map(' | '.join, itertools.zip_longest(*[
        [s.ljust(COLSIZE) for s in textwrap.wrap(col, COLSIZE)] for col in cols
    ], fillvalue=' '*COLSIZE)))
  1. 然后我们可以正确地打印任何表格:
>>> print(maketable(cols))
hello world          | this is a long text, | one more column     
                     | maybe longer than    |                     
                     | expected, surely     |                     
                     | long enough          |                     

它是如何工作的...

我们必须解决三个问题来实现我们的maketable函数:

  • 长度小于 20 个字符的文本

  • 将长度超过 20 个字符的文本拆分为多行

  • 填充列中缺少的行

如果我们分解我们的maketable函数,它的第一件事就是将长度超过 20 个字符的文本拆分为多行:

[textwrap.wrap(col, COLSIZE) for col in cols]

将其应用于每一列,我们得到了一个包含列的列表,每个列包含一列行:

[['hello world'], 
 ['this is a long text,', 'maybe longer than', 'expected, surely', 'long enough'],
 ['one more column']]

然后我们需要确保每行长度小于 20 个字符的文本都扩展到恰好 20 个字符,以便我们的表保持形状,这是通过对每行应用ljust方法来实现的:

[[s.ljust(COLSIZE) for s in textwrap.wrap(col, COLSIZE)] for col in cols]

ljusttextwrap结合起来,就得到了我们想要的结果:包含每个 20 个字符的行的列的列表:

[['hello world         '], 
 ['this is a long text,', 'maybe longer than   ', 'expected, surely    ', 'long enough         '],
 ['one more column     ']]

现在我们需要找到一种方法来翻转行和列,因为在打印时,由于print函数一次打印一行,我们需要按行打印。此外,我们需要确保每列具有相同数量的行,因为按行打印时需要打印所有行。

这两个需求都可以通过itertools.zip_longest函数解决,它将生成一个新列表,通过交错提供的每个列表中包含的值,直到最长的列表用尽。由于zip_longest会一直进行,直到最长的可迭代对象用尽,它支持一个fillvalue参数,该参数可用于指定用于填充较短列表的值:

list(itertools.zip_longest(*[
    [s.ljust(COLSIZE) for s in textwrap.wrap(col, COLSIZE)] for col in cols
], fillvalue=' '*COLSIZE))

结果将是一列包含一列的行的列表,对于没有值的行,将有空列:

[('hello world         ', 'this is a long text,', 'one more column     '), 
 ('                    ', 'maybe longer than   ', '                    '), 
 ('                    ', 'expected, surely    ', '                    '), 
 ('                    ', 'long enough         ', '                    ')]

文本的表格形式现在清晰可见。我们函数中的最后两个步骤涉及在列之间添加|分隔符,并通过' | '.join将列合并成单个字符串:

map(' | '.join, itertools.zip_longest(*[
    [s.ljust(COLSIZE) for s in textwrap.wrap(col, COLSIZE)] for col in cols
], fillvalue=' '*COLSIZE))

这将导致一个包含所有三列文本的字符串列表:

['hello world          | this is a long text, | one more column     ', 
 '                     | maybe longer than    |                     ', 
 '                     | expected, surely     |                     ', 
 '                     | long enough          |                     ']

最后,行可以被打印。为了返回单个字符串,我们的函数应用了最后一步,并通过应用最终的'n'.join()将所有行连接成一个由换行符分隔的单个字符串,从而返回一个包含整个文本的单个字符串,准备打印:

'''hello world          | this is a long text, | one more column     
                        | maybe longer than    |                     
                        | expected, surely     |                     
                        | long enough          |                     '''

第三章:命令行

在本章中,我们将涵盖以下配方:

  • 基本日志记录-日志记录允许您跟踪软件正在做什么,通常与其输出无关

  • 记录到文件-当记录频繁时,有必要将日志存储在磁盘上

  • 记录到 Syslog-如果您的系统有 Syslog 守护程序,则可能希望登录到 Syslog 而不是使用独立文件

  • 解析参数-在使用命令行工具编写时,您需要为几乎任何工具解析选项

  • 交互式 shell-有时选项不足,您需要一种交互式的 REPL 来驱动您的工具

  • 调整终端文本大小-为了正确对齐显示的输出,我们需要知道终端窗口的大小

  • 运行系统命令-如何将其他第三方命令集成到您的软件中

  • 进度条-如何在文本工具中显示进度条

  • 消息框-如何在文本工具中显示 OK/取消消息框

  • 输入框-如何在文本工具中请求输入

介绍

编写新工具时,首先出现的需求之一是使其能够与周围环境进行交互-显示结果,跟踪错误并接收输入。

用户习惯于命令行工具与他们和系统交互的某些标准方式,如果从头开始遵循这个标准可能是耗时且困难的。

这就是为什么 Python 标准库提供了工具来实现能够通过 shell 和文本进行交互的软件的最常见需求。

在本章中,我们将看到如何实现某些形式的日志记录,以便我们的程序可以保留日志文件;我们将看到如何实现基于选项和交互式软件,然后我们将看到如何基于文本实现更高级的图形输出。

基本日志记录

控制台软件的首要要求之一是记录其所做的事情,即发生了什么以及任何警告或错误。特别是当我们谈论长期运行的软件或在后台运行的守护程序时。

遗憾的是,如果您曾经尝试使用 Python 的logging模块,您可能已经注意到除了错误之外,您无法获得任何输出。

这是因为默认启用级别是“警告”,因此只有警告和更严重的情况才会被跟踪。需要进行一些小的调整,使日志通常可用。

如何做...

对于这个配方,步骤如下:

  1. logging模块允许我们通过basicConfig方法轻松设置日志记录配置:
>>> import logging, sys
>>> 
>>> logging.basicConfig(level=logging.INFO, stream=sys.stderr,
...                     format='%(asctime)s %(name)s %(levelname)s: %(message)s')
>>> log = logging.getLogger(__name__)
  1. 现在我们的logger已经正确配置,我们可以尝试使用它:
>>> def dosum(a, b, count=1):
...     log.info('Starting sum')
...     if a == b == 0:
...         log.warning('Will be just 0 for any count')
...     res = (a + b) * count
...     log.info('(%s + %s) * %s = %s' % (a, b, count, res))
...     print(res)
... 
>>> dosum(5, 3)
2018-02-11 22:07:59,870 __main__ INFO: Starting sum
2018-02-11 22:07:59,870 __main__ INFO: (5 + 3) * 1 = 8
8
>>> dosum(5, 3, count=2)
2018-02-11 22:07:59,870 __main__ INFO: Starting sum
2018-02-11 22:07:59,870 __main__ INFO: (5 + 3) * 2 = 16
16
>>> dosum(0, 1, count=5)
2018-02-11 22:07:59,870 __main__ INFO: Starting sum
2018-02-11 22:07:59,870 __main__ INFO: (0 + 1) * 5 = 5
5
>>> dosum(0, 0)
2018-02-11 22:08:00,621 __main__ INFO: Starting sum
2018-02-11 22:08:00,621 __main__ WARNING: Will be just 0 for any count
2018-02-11 22:08:00,621 __main__ INFO: (0 + 0) * 1 = 0
0

它是如何工作的...

logging.basicConfig配置root记录器(主记录器,如果找不到用于使用的记录器的特定配置,则 Python 将使用它)以在INFO级别或更高级别写入任何内容。这将允许我们显示除调试消息之外的所有内容。format参数指定了我们的日志消息应该如何格式化;在这种情况下,我们添加了日期和时间,记录器的名称,我们正在记录的级别以及消息本身。最后,stream参数告诉记录器将其输出写入标准错误。

一旦我们配置了root记录器,任何我们选择的日志记录,如果没有特定的配置,都将使用root记录器。

因此,下一行logging.getLogger(__name__)会获得一个与执行的 Python 模块类似命名的记录器。如果您将代码保存到文件中,则记录器的名称将类似于dosum(假设您的文件名为dosum.py);如果没有,则记录器的名称将为__main__,就像前面的示例中一样。

Python 记录器在使用logging.getLogger检索时首次创建,并且对getLogger的任何后续调用只会返回已经存在的记录器。对于非常简单的程序,名称可能并不重要,但在更大的软件中,通常最好抓取多个记录器,这样您可以区分消息来自软件的哪个子系统。

还有更多...

也许你会想知道为什么我们配置logging将其输出发送到stderr,而不是标准输出。这样可以将我们软件的输出(通过打印语句写入stdout)与日志信息分开。这通常是一个好的做法,因为您的工具的用户可能需要调用您的工具的输出,而不带有日志消息生成的所有噪音,这样做可以让我们以以下方式调用我们的脚本:

$ python dosum.py 2>/dev/null
8
16
5
0

我们只会得到结果,而不会有所有的噪音,因为我们将stderr重定向到/dev/null,这在 Unix 系统上会导致丢弃所有写入stderr的内容。

记录到文件

对于长时间运行的程序,将日志记录到屏幕并不是一个非常可行的选择。在运行代码数小时后,最旧的日志消息将丢失,即使它们仍然可用,也不容易阅读所有日志或搜索其中的内容。

将日志保存到文件允许无限长度(只要我们的磁盘允许)并且可以使用grep等工具进行搜索。

默认情况下,Python 日志配置为写入屏幕,但在配置日志时很容易提供一种方式来写入任何文件。

如何做到...

为了测试将日志记录到文件,我们将创建一个简短的工具,根据当前时间计算最多n个斐波那契数。如果是下午 3:01,我们只想计算 1 个数字,而如果是下午 3:59,我们想计算 59 个数字。

软件将提供计算出的数字作为输出,但我们还想记录计算到哪个数字以及何时运行:

import logging, sys

if __name__ == '__main__':
    if len(sys.argv) < 2:
        print('Please provide logging file name as argument')
        sys.exit(1)

    logging_file = sys.argv[1]
    logging.basicConfig(level=logging.INFO, filename=logging_file,
                        format='%(asctime)s %(name)s %(levelname)s: %(message)s')

log = logging.getLogger(__name__)

def fibo(num):
    log.info('Computing up to %sth fibonacci number', num)
    a, b = 0, 1
    for n in range(num):
        a, b = b, a+b
        print(b, '', end='')
    print(b)

if __name__ == '__main__':
    import datetime
    fibo(datetime.datetime.now().second)

工作原理...

代码分为三个部分:初始化日志记录、fibo函数和我们工具的main函数。我们明确地以这种方式划分代码,因为fibo函数可能会在其他模块中使用,在这种情况下,我们不希望重新配置logging;我们只想使用程序提供的日志配置。因此,logging.basicConfig调用被包装在__name__ == '__main__'中,以便只有在模块被直接调用为工具时才配置logging,而不是在被其他模块导入时。

当调用多个logging.basicConfig实例时,只有第一个会被考虑。如果我们在其他模块中导入时没有将日志配置包装在if中,它可能最终会驱动整个软件的日志配置,这取决于模块导入的顺序,这显然是我们不想要的。

与之前的方法不同,basicConfig是使用filename参数而不是stream参数进行配置的。这意味着将创建logging.FileHandler来处理日志消息,并且消息将被追加到该文件中。

代码的核心部分是fibo函数本身,最后一部分是检查代码是作为 Python 脚本调用还是作为模块导入。当作为模块导入时,我们只想提供fibo函数并避免运行它,但当作为脚本执行时,我们想计算斐波那契数。

也许你会想知道为什么我使用了两个if __name__ == '__main__'部分;如果将两者合并成一个,脚本将继续工作。但通常最好确保在尝试使用日志之前配置logging,否则结果将是我们最终会使用logging.lastResort处理程序,它只会写入stderr直到日志被配置。

记录到 Syslog

类 Unix 系统通常提供一种通过syslog协议收集日志消息的方法,这使我们能够将存储日志的系统与生成日志的系统分开。

特别是在跨多个服务器分布的应用程序的情况下,这非常方便;您肯定不想登录到 20 个不同的服务器上收集您的 Python 应用程序的所有日志,因为它在多个节点上运行。特别是对于 Web 应用程序来说,这在云服务提供商中现在非常常见,因此能够在一个地方收集所有 Python 日志非常方便。

这正是使用syslog允许我们做的事情;我们将看到如何将日志消息发送到运行在我们系统上的守护程序,但也可以将它们发送到任何系统。

准备工作

虽然这个方法不需要syslog守护程序才能工作,但您需要一个来检查它是否正常工作,否则消息将无法被读取。在 Linux 或 macOS 系统的情况下,这通常是开箱即用的,但在 Windows 系统的情况下,您需要安装一个 Syslog 服务器或使用云解决方案。有许多选择,只需在 Google 上快速搜索,就可以找到一些便宜甚至免费的替代方案。

如何做...

当使用一个定制程度很高的日志记录解决方案时,就不再能依赖于logging.basicConfig,因此我们将不得不手动设置日志记录环境:

import logging
import logging.config

# OSX logs through /var/run/syslog this should be /dev/log 
# on Linux system or a tuple ('ADDRESS', PORT) to log to a remote server
SYSLOG_ADDRESS = '/var/run/syslog'

logging.config.dictConfig({
    'version': 1,
    'formatters': {
        'default': {
            'format': '%(asctime)s %(name)s: %(levelname)s %(message)s'
        },
    },
    'handlers': {
        'syslog': {
            'class': 'logging.handlers.SysLogHandler',
            'formatter': 'default',
            'address': SYSLOG_ADDRESS
        }
    },
    'root': {
        'handlers': ['syslog'],
        'level': 'INFO'
    }
})

log = logging.getLogger()
log.info('Hello Syslog!')

如果这样操作正常,您的消息应该被 Syslog 记录,并且在 macOS 上运行syslog命令或在 Linux 上作为/var/log/syslogtail命令时可见:

$ syslog | tail -n 2
Feb 18 17:52:43 Pulsar Google Chrome[294] <Error>: ... SOME CHROME ERROR MESSAGE ...
Feb 18 17:53:48 Pulsar 2018-02-18 17[4294967295] <Info>: 53:48,610 INFO root Hello Syslog!

syslog文件路径可能因发行版而异;如果/var/log/syslog不起作用,请尝试/var/log/messages或参考您的发行版文档。

还有更多...

由于我们依赖于dictConfig,您会注意到我们的配置比以前的方法更复杂。这是因为我们自己配置了日志基础设施的部分。

每当您配置日志记录时,都要使用记录器写入您的消息。默认情况下,系统只有一个记录器:root记录器(如果您调用logging.getLogger而不提供任何特定名称,则会得到该记录器)。

记录器本身不处理消息,因为写入或打印日志消息是处理程序的职责。因此,如果您想要读取您发送的日志消息,您需要配置一个处理程序。在我们的情况下,我们使用SysLogHandler,它写入到 Syslog。

处理程序负责写入消息,但实际上并不涉及消息应该如何构建/格式化。您会注意到,除了您自己的消息之外,当您记录某些内容时,还会得到日志级别、记录器名称、时间戳以及由日志系统为您添加的一些细节。将这些细节添加到消息中通常是格式化程序的工作。格式化程序获取记录器提供的所有信息,并将它们打包成应该由处理程序写入的消息。

最后但并非最不重要的是,您的日志配置可能非常复杂。您可以设置一些消息发送到本地文件,一些消息发送到 Syslog,还有一些应该打印在屏幕上。这将涉及多个处理程序,它们应该知道哪些消息应该处理,哪些消息应该忽略。允许这种知识是过滤器的工作。一旦将过滤器附加到处理程序,就可以控制哪些消息应该由该处理程序保存,哪些应该被忽略。

Python 日志系统现在可能看起来非常直观,这是因为它是一个非常强大的解决方案,可以以多种方式进行配置,但一旦您了解了可用的构建模块,就可以以非常灵活的方式将它们组合起来。

解析参数

当编写命令行工具时,通常会根据提供给可执行文件的选项来改变其行为。这些选项通常与可执行文件名称一起在sys.argv中可用,但解析它们并不像看起来那么容易,特别是当必须支持多个参数时。此外,当选项格式不正确时,通常最好提供一个使用消息,以便通知用户正确使用工具的方法。

如何做...

执行此食谱的以下步骤:

  1. argparse.ArgumentParser对象是负责解析命令行选项的主要对象:
import argparse
import operator
import logging
import functools

parser = argparse.ArgumentParser(
    description='Applies an operation to one or more numbers'
)
parser.add_argument("number", 
                    help="One or more numbers to perform an operation on.",
                    nargs='+', type=int)
parser.add_argument('-o', '--operation', 
                    help="The operation to perform on numbers.",
                    choices=['add', 'sub', 'mul', 'div'], default='add')
parser.add_argument("-v", "--verbose", action="store_true",
                    help="increase output verbosity")

opts = parser.parse_args()

logging.basicConfig(level=logging.INFO if opts.verbose else logging.WARNING)
log = logging.getLogger()

operation = getattr(operator, opts.operation)
log.info('Applying %s to %s', opts.operation, opts.number)
print(functools.reduce(operation, opts.number))
  1. 一旦我们的命令没有任何参数被调用,它将提供一个简短的使用文本:
$ python /tmp/doop.py
usage: doop.py [-h] [-o {add,sub,mul,div}] [-v] number [number ...]
doop.py: error: the following arguments are required: number
  1. 如果我们提供了-h选项,argparse将为我们生成一个完整的使用指南:
$ python /tmp/doop.py -h
usage: doop.py [-h] [-o {add,sub,mul,div}] [-v] number [number ...]

Applies an operation to one or more numbers

positional arguments:
number                One or more numbers to perform an operation on.

optional arguments:
-h, --help            show this help message and exit
-o {add,sub,mul,div}, --operation {add,sub,mul,div}
                        The operation to perform on numbers.
-v, --verbose         increase output verbosity
  1. 使用该命令将会得到预期的结果:
$ python /tmp/dosum.py 1 2 3 4 -o mul
24

工作原理...

我们使用了ArgumentParser.add_argument方法来填充可用选项的列表。对于每个参数,还可以提供一个help选项,它将为该参数声明help字符串。

位置参数只需提供参数的名称:

parser.add_argument("number", 
                    help="One or more numbers to perform an operation on.",
                    nargs='+', type=int)

nargs选项告诉ArgumentParser我们期望该参数被指定的次数,+值表示至少一次或多次。然后type=int告诉我们参数应该被转换为整数。

一旦我们有了要应用操作的数字,我们需要知道操作本身:

parser.add_argument('-o', '--operation', 
                    help="The operation to perform on numbers.",
                    choices=['add', 'sub', 'mul', 'div'], default='add')

在这种情况下,我们指定了一个选项(以破折号-开头),可以提供-o--operation。我们声明唯一可能的值是'add''sub''mul''div'(提供不同的值将导致argparse抱怨),如果用户没有指定默认值,则为add

作为最佳实践,我们的命令只打印结果;能够询问一些关于它将要做什么的日志是很方便的。因此,我们提供了verbose选项,它驱动了我们为命令启用的日志级别:

parser.add_argument("-v", "--verbose", action="store_true",
                    help="increase output verbosity")

如果提供了该选项,我们将只存储verbose模式已启用(action="store_true"使得True被存储在opts.verbose中),并且我们将相应地配置logging模块,这样我们的log.info只有在verbose被启用时才可见。

最后,我们可以实际解析命令行选项并将结果返回到opts对象中:

opts = parser.parse_args()

一旦我们有了可用的选项,我们配置日志,以便我们可以读取verbose选项并相应地配置它:

logging.basicConfig(level=logging.INFO if opts.verbose else logging.WARNING)

一旦选项被解析并且logging被配置,剩下的就是在提供的数字集上执行预期的操作并打印结果:

operation = getattr(operator, opts.operation)
log.info('Applying %s to %s', opts.operation, opts.number)
print(functools.reduce(operation, opts.number))

还有更多...

如果你将命令行选项与第一章容器和数据结构中的带回退的字典食谱相结合,你可以扩展工具的行为,不仅可以从命令行读取选项,还可以从环境变量中读取,当你无法完全控制命令的调用方式但可以设置环境变量时,这通常非常方便。

交互式 shell

有时,编写命令行工具是不够的,你需要能够提供某种交互。假设你想要编写一个邮件客户端。在这种情况下,必须要调用mymail list来查看你的邮件,或者从你的 shell 中读取特定的邮件,等等,这是不太方便的。此外,如果你想要实现有状态的行为,比如一个mymail reply实例,它应该回复你正在查看的当前邮件,这甚至可能是不可能的。

在这些情况下,交互式程序更好,Python 标准库通过cmd模块提供了编写这样一个程序所需的所有工具。

我们可以尝试为我们的mymail程序编写一个交互式 shell;它不会读取真实的电子邮件,但我们将伪造足够的行为来展示一个功能齐全的 shell。

如何做...

此示例的步骤如下:

  1. cmd.Cmd类允许我们启动交互式 shell 并基于它们实现命令:
EMAILS = [
    {'sender': 'author1@domain.com', 'subject': 'First email', 
     'body': 'This is my first email'},
    {'sender': 'author2@domain.com', 'subject': 'Second email', 
     'body': 'This is my second email'},
]

import cmd
import shlex

class MyMail(cmd.Cmd):
    intro = 'Simple interactive email client.'
    prompt = 'mymail> '

    def __init__(self, *args, **kwargs):
        super(MyMail, self).__init__(*args, **kwargs)
        self.selected_email = None

    def do_list(self, line):
        """list

        List emails currently in the Inbox"""
        for idx, email in enumerate(EMAILS):
            print('[{idx}] From: {e[sender]} - 
                    {e[subject]}'.format(
                    idx=idx, e=email
            ))

    def do_read(self, emailnum):
        """read [emailnum]

        Reads emailnum nth email from those listed in the Inbox"""
        try:
            idx = int(emailnum.strip())
        except:
            print('Invalid email index {}'.format(emailnum))
            return

        try:
            email = EMAILS[idx]
        except IndexError:
            print('Email {} not found'.format(idx))
            return

        print('From: {e[sender]}\n'
              'Subject: {e[subject]}\n'
              '\n{e[body]}'.format(e=email))
        # Track the last read email as the selected one for reply.
        self.selected_email = idx

    def do_reply(self, message):
        """reply [message]

        Sends back an email to the author of the received email"""
        if self.selected_email is None:
            print('No email selected for reply.')
            return

        email = EMAILS[self.selected_email]
        print('Replied to {e[sender]} with: {message}'.format(
            e=email, message=message
        ))

    def do_send(self, arguments):
        """send [recipient] [subject] [message]

        Send a new email with [subject] to [recipient]"""
        # Split the arguments with shlex 
        # so that we allow subject or message with spaces. 
        args = shlex.split(arguments)
        if len(args) < 3:
            print('A recipient, a subject and a message are 
                  required.')
            return

        recipient, subject, message = args[:3]
        if len(args) >= 4:
            message += ' '.join(args[3:])

        print('Sending email {} to {}: "{}"'.format(
            subject, recipient, message
        ))

    def complete_send(self, text, line, begidx, endidx):
        # Provide autocompletion of recipients for send command.
        return [e['sender'] for e in EMAILS if e['sender'].startswith(text)]

    def do_EOF(self, line):
        return True

if __name__ == '__main__':
    MyMail().cmdloop()
  1. 启动我们的脚本应该提供一个很好的交互提示:
$ python /tmp/mymail.py 
Simple interactive email client.
mymail> help

Documented commands (type help <topic>):
========================================
help  list  read  reply  send

Undocumented commands:
======================
EOF
  1. 如文档所述,我们应该能够读取邮件列表,阅读特定的邮件,并回复当前打开的邮件:
mymail> list
[0] From: author1@domain.com - First email
[1] From: author2@domain.com - Second email
mymail> read 0
From: author1@domain.com
Subject: First email

This is my first email
mymail> reply Thanks for your message!
Replied to author1@domain.com with: Thanks for your message!
  1. 然后,我们可以依赖更高级的发送命令,这些命令还为我们的新邮件提供了收件人的自动完成:
mymail> help send
send [recipient] [subject] [message]

Send a new email with [subject] to [recipient]
mymail> send author
author1@domain.com  author2@domain.com  
mymail> send author2@domain.com "Saw your email" "I saw your message, thanks for sending it!"
Sending email Saw your email to author2@domain.com: "I saw your message, thanks for sending it!"
mymail> 

工作原理...

cmd.Cmd循环通过prompt类属性打印我们提供的prompt并等待命令。在prompt之后写的任何东西都会被分割,然后第一部分会被查找我们自己的子类提供的方法列表。

每当提供一个命令时,cmd.Cmd.cmdloop调用相关的方法,然后重新开始。

任何以do_*开头的方法都是一个命令,do_之后的部分是命令名称。如果在交互提示中使用help命令,则实现命令的方法的 docstring 将被报告在我们工具的文档中。

Cmd类不提供解析命令参数的功能,因此,如果您的命令有多个参数,您必须自己拆分它们。在我们的情况下,我们依赖于shlex,以便用户可以控制参数的拆分方式。这使我们能够解析主题和消息,同时提供了一种包含空格的方法。否则,我们将无法知道主题在哪里结束,消息从哪里开始。

send命令还支持自动完成收件人,通过complete_send方法。如果提供了complete_*方法,当按下Tab自动完成命令参数时,Cmd会调用它。该方法接收需要完成的文本以及有关整行文本和光标当前位置的一些详细信息。由于没有对参数进行解析,光标的位置和整行文本可以帮助提供不同的自动完成行为。在我们的情况下,我们只能自动完成收件人,因此无需区分各个参数。

最后但并非最不重要的是,do_EOF命令允许在按下Ctrl + D时退出命令行。否则,我们将无法退出交互式 shell。这是Cmd提供的一个约定,如果do_EOF命令返回True,则表示 shell 可以退出。

调整终端文本大小

我们在第二章的文本管理中看到了对齐文本的示例,其中展示了在固定空间内对齐文本的可能解决方案。可用空间的大小在COLSIZE常量中定义,选择适合大多数终端的三列(大多数终端适合 80 列)。

但是,如果用户的终端窗口小于 60 列会发生什么?我们的对齐会被严重破坏。此外,在非常大的窗口上,虽然文本不会被破坏,但与窗口相比会显得太小。

因此,每当显示应保持正确对齐属性的文本时,通常最好考虑用户终端窗口的大小。

如何做...

步骤如下:

  1. shutil.get_terminal_size函数可以指导终端窗口的大小,并为无法获得大小的情况提供后备。我们将调整maketable函数,以适应终端大小。
import shutil
import textwrap, itertools

def maketable(cols):
    term_size = shutil.get_terminal_size(fallback=(80, 24))
    colsize = (term_size.columns // len(cols)) - 3
    if colsize < 1:
        raise ValueError('Column too small')
    return '\n'.join(map(' | '.join, itertools.zip_longest(*[
        [s.ljust(colsize) for s in textwrap.wrap(col, colsize)] for col in cols
    ], fillvalue=' '*colsize)))
  1. 现在可以在多列中打印任何文本,并看到它适应您的终端窗口的大小:
COLUMNS = 5
TEXT = ['Lorem ipsum dolor sit amet, consectetuer adipiscing elit. '
        'Aenean commodo ligula eget dolor. Aenean massa. '
        'Cum sociis natoque penatibus et magnis dis parturient montes, '
        'nascetur ridiculus mus'] * COLUMNS

print(maketable(TEXT))

如果尝试调整终端窗口大小并重新运行脚本,您会注意到文本现在总是以不同的方式对齐,以确保它适合可用的空间。

工作原理...

我们的maketable函数现在通过获取终端宽度(term_size.columns)并将其除以要显示的列数来计算列的大小,而不是依赖于列的大小的常量。

始终减去三个字符,因为我们要考虑|分隔符占用的空间。

终端的大小(term_size)通过shutil.get_terminal_size获取,它将查看stdout以检查连接终端的大小。

如果无法检索大小或连接的输出不是终端,则使用回退值。您可以通过将脚本的输出重定向到文件来检查回退值是否按预期工作:

$ python myscript.py > output.txt

如果您打开output.txt,您应该会看到 80 个字符的回退值被用作文件没有指定宽度。

运行系统命令

在某些情况下,特别是在编写系统工具时,可能有一些工作需要转移到另一个命令。例如,如果你需要解压文件,在许多情况下,将工作转移到gunzip/zip命令可能更合理,而不是尝试在 Python 中复制相同的行为。

在 Python 中有许多处理这项工作的方法,它们都有微妙的差异,可能会让任何开发人员的生活变得困难,因此最好有一个通常有效的解决方案来解决最常见的问题。

如何做...

执行以下步骤:

  1. 结合subprocessshlex模块使我们能够构建一个在大多数情况下都可靠的解决方案:
import shlex
import subprocess

def run(command):
    try:
        result = subprocess.check_output(shlex.split(command), 
                                         stderr=subprocess.STDOUT)
        return 0, result
    except subprocess.CalledProcessError as e:
        return e.returncode, e.output
  1. 很容易检查它是否按预期工作,无论是成功还是失败的命令:
for path in ('/', '/should_not_exist'):
    status, out = run('ls "{}"'.format(path))
    if status == 0:
        print('<Success>')
    else:
        print('<Error: {}>'.format(status))
    print(out)
  1. 在我的系统上,这样可以正确列出文件系统的根目录,并对不存在的路径进行抱怨:
<Success>
Applications
Developer
Library
LibraryPreferences
Network
...

<Error: 2>
ls: cannot access /should_not_exist: No such file or directory

工作原理...

调用命令本身是由subprocess.check_output函数执行的,但在调用之前,我们需要正确地将命令拆分为包含命令本身及其参数的列表。依赖于shlex使我们能够驱动和区分参数应如何拆分。要查看其效果,可以尝试在任何类 Unix 系统上比较run('ls / var')run('ls "/ var"')。第一个将打印很多文件,而第二个将抱怨路径不存在。这是因为在第一种情况下,我们实际上向ls发送了两个不同的参数(/var),而在第二种情况下,我们发送了一个单一的参数("/ var")。如果我们没有使用shlex,就无法区分这两种情况。

传递stderr=subprocess.STDOUT选项,然后处理命令失败的情况(我们可以检测到,因为run函数将返回一个非零的状态),允许我们接收失败的描述。

调用我们的命令的繁重工作由subprocess.check_output执行,实际上,它是subprocess.Popen的包装器,将执行两件事:

  1. 使用subprocess.Popen生成所需的命令,配置为将输出写入管道,以便父进程(我们自己的程序)可以从该管道中读取并获取输出。

  2. 生成线程以持续从打开的管道中消耗内容,以与子进程通信。这确保它们永远不会填满,因为如果它们填满了,我们调用的命令将会被阻塞,因为它将无法再写入任何输出。

还有更多...

需要注意的一点是,我们的run函数将寻找一个可满足请求命令的可执行文件,但不会运行任何 shell 表达式。因此,无法将 shell 脚本发送给它。如果需要,可以将shell=True选项传递给subprocess.check_output,但这是极不鼓励的,因为它允许将 shell 代码注入到我们的程序中。

假设您想编写一个命令,打印用户选择的目录的内容;一个非常简单的解决方案可能是以下内容:

import sys
if len(sys.argv) < 2:
    print('Please provide a directory')
    sys.exit(1)
_, out = run('ls {}'.format(sys.argv[1]))
print(out)

现在,如果我们在run中允许shell=True,并且用户提供了诸如/var; rm -rf /这样的路径,会发生什么?用户可能最终会删除整个系统磁盘,尽管我们仍然依赖于shlex来分割参数,但通过 shell 运行命令仍然不安全。

进度条

当进行需要大量时间的工作时(通常是需要 I/O 到较慢的端点,如磁盘或网络的任何工作),让用户知道您正在前进以及还有多少工作要做是一个好主意。进度条虽然不精确,但是是给我们的用户一个关于我们已经完成了多少工作以及还有多少工作要做的概览的很好的方法。

如何做...

配方步骤如下:

  1. 进度条本身将由装饰器显示,这样我们就可以将其应用到任何我们想要以最小的努力报告进度的函数上。
import shutil, sys

def withprogressbar(func):
    """Decorates ``func`` to display a progress bar while running.

    The decorated function can yield values from 0 to 100 to
    display the progress.
    """
    def _func_with_progress(*args, **kwargs):
        max_width, _ = shutil.get_terminal_size()

        gen = func(*args, **kwargs)
        while True:
            try:
                progress = next(gen)
            except StopIteration as exc:
                sys.stdout.write('\n')
                return exc.value
            else:
                # Build the displayed message so we can compute
                # how much space is left for the progress bar 
                  itself.
                message = '[%s] {}%%'.format(progress)
                # Add 3 characters to cope for the %s and %%
                bar_width = max_width - len(message) + 3  

                filled = int(round(bar_width / 100.0 * progress))
                spaceleft = bar_width - filled
                bar = '=' * filled + ' ' * spaceleft
                sys.stdout.write((message+'\r') % bar)
                sys.stdout.flush()

    return _func_with_progress
  1. 然后我们需要一个实际执行某些操作并且可能想要报告进度的函数。在这个例子中,它将是一个简单的等待指定时间的函数。
import time

@withprogressbar
def wait(seconds):
    """Waits ``seconds`` seconds and returns how long it waited."""
    start = time.time()
    step = seconds / 100.0
    for i in range(1, 101):
        time.sleep(step)
        yield i  # Send % of progress to withprogressbar

    # Return how much time passed since we started, 
    # which is in fact how long we waited for real.
    return time.time() - start
  1. 现在调用被装饰的函数应该告诉我们它等待了多长时间,并在等待时显示一个进度条。
print('WAITED', wait(5))
  1. 当脚本运行时,您应该看到您的进度条和最终结果,看起来像这样:
$ python /tmp/progress.py 
[=====================================] 100%
WAITED 5.308781862258911

工作原理...

所有的工作都由withprogressbar函数完成。它充当装饰器,因此我们可以使用@withprogressbar语法将其应用到任何函数上。

这非常方便,因为报告进度的代码与实际执行工作的代码是隔离的,这使我们能够在许多不同的情况下重用它。

为了创建一个装饰器,它在函数本身运行时与被装饰的函数交互,我们依赖于 Python 生成器。

gen = func(*args, **kwargs)
while True:
    try:
        progress = next(gen)
    except StopIteration as exc:
        sys.stdout.write('\n')
        return exc.value
    else:
        # display the progressbar

当我们调用被装饰的函数(在我们的例子中是wait函数)时,实际上我们将调用装饰器中的_func_with_progress。该函数将要做的第一件事就是调用被装饰的函数。

gen = func(*args, **kwargs)

由于被装饰的函数包含一个yield progress语句,每当它想显示一些进度(在wait中的for循环中的yield i),函数将返回generator

每当生成器遇到yield progress语句时,我们将其作为应用于生成器的下一个函数的返回值收到。

progress = next(gen)

然后我们可以显示我们的进度并再次调用next(gen),这样被装饰的函数就可以继续前进并返回新的进度(被装饰的函数当前在yield处暂停,直到我们在其上调用next,这就是为什么我们的整个代码都包裹在while True:中的原因,让函数永远继续,直到它完成它要做的工作)。

当被装饰的函数完成了所有它要做的工作时,它将引发一个StopIteration异常,该异常将包含被装饰函数在.value属性中返回的值。

由于我们希望将任何返回值传播给调用者,我们只需自己返回该值。如果被装饰的函数应该返回其完成的工作的某些结果,比如一个download(url)函数应该返回对下载文件的引用,这一点尤为重要。

在返回之前,我们打印一个新行。

sys.stdout.write('\n')

这确保了进度条后面的任何内容不会与进度条本身重叠,而是会打印在新的一行上。

然后我们只需显示进度条本身。配方中进度条部分的核心基于只有两行代码:

sys.stdout.write((message+'\r') % bar)
sys.stdout.flush()

这两行将确保我们的消息在屏幕上打印,而不像print通常做的那样换行。相反,这将回到同一行的开头。尝试用'\n'替换'\r',你会立即看到区别。使用'\r',你会看到一个进度条从 0 到 100%移动,而使用'\n',你会看到许多进度条被打印。

然后需要调用sys.stdout.flush()来确保进度条实际上被显示出来,因为通常只有在新的一行上才会刷新输出,而我们只是一遍又一遍地打印同一行,除非我们明确地刷新它,否则它不会被刷新。

现在我们知道如何绘制进度条并更新它,函数的其余部分涉及计算要显示的进度条:

message = '[%s] {}%%'.format(progress)
bar_width = max_width - len(message) + 3  # Add 3 characters to cope for the %s and %%

filled = int(round(bar_width / 100.0 * progress))
spaceleft = bar_width - filled
bar = '=' * filled + ' ' * spaceleft

首先,我们计算message,这是我们想要显示在屏幕上的内容。消息是在没有进度条本身的情况下计算的,对于进度条,我们留下了一个%s占位符,以便稍后填充它。

我们这样做是为了知道在我们显示周围的括号和百分比后,进度条本身还有多少空间。这个值是bar_width,它是通过从屏幕宽度的最大值(在我们的函数开始时使用shutil.get_terminal_size()检索)中减去我们的消息的大小来计算的。我们必须添加的三个额外字符将解决在我们的消息中%s%%消耗的空间,一旦消息显示到屏幕上,%s将被进度条本身替换,%%将解析为一个单独的%

一旦我们知道了进度条本身有多少空间可用,我们就计算出应该用'='(已完成的部分)填充多少空间,以及应该用空格' '(尚未完成的部分)填充多少空间。这是通过计算要填充和匹配我们的进度的百分比的屏幕大小来实现的:

filled = int(round(bar_width / 100.0 * progress))

一旦我们知道要用'='填充多少,剩下的就只是空格:

spaceleft = bar_width - filled

因此,我们可以用填充的等号和spaceleft空格来构建我们的进度条:

bar = '=' * filled + ' ' * spaceleft

一旦进度条准备好了,它将通过%字符串格式化操作符注入到在屏幕上显示的消息中:

sys.stdout.write((message+'\r') % bar)

如果你注意到了,我混合了两种字符串格式化(str.format%)。我这样做是因为我认为这样做可以更清楚地说明格式化的过程,而不是在每个格式化步骤上都要正确地进行转义。

消息框

尽管现在不太常见,但能够创建交互式基于字符的用户界面仍然具有很大的价值,特别是当只需要一个带有“确定”按钮的简单消息对话框或一个带有“确定/取消”对话框时;通过一个漂亮的文本对话框,可以更好地引导用户的注意力。

准备工作

curses库只包括在 Unix 系统的 Python 中,因此 Windows 用户可能需要一个解决方案,比如 CygWin 或 Linux 子系统,以便能够拥有包括curses支持的 Python 设置。

如何做到这一点...

对于这个配方,执行以下步骤:

  1. 我们将制作一个MessageBox.show方法,我们可以在需要时用它来显示消息框。MessageBox类将能够显示只有确定或确定/取消按钮的消息框。
import curses
import textwrap
import itertools

class MessageBox(object):
    @classmethod
    def show(cls, message, cancel=False, width=40):
        """Show a message with an Ok/Cancel dialog.

        Provide ``cancel=True`` argument to show a cancel button 
        too.
        Returns the user selected choice:

            - 0 = Ok
            - 1 = Cancel
        """
        dialog = MessageBox(message, width, cancel)
        return curses.wrapper(dialog._show)

    def __init__(self, message, width, cancel):
        self._message = self._build_message(width, message)
        self._width = width
        self._height = max(self._message.count('\n')+1, 3) + 6
        self._selected = 0
        self._buttons = ['Ok']
        if cancel:
            self._buttons.append('Cancel')

    def _build_message(self, width, message):
        lines = []
        for line in message.split('\n'):
            if line.strip():
                lines.extend(textwrap.wrap(line, width-4,                                             
                             replace_whitespace=False))
            else:
                lines.append('')
        return '\n'.join(lines)

    def _show(self, stdscr):
        win = curses.newwin(self._height, self._width, 
                            (curses.LINES - self._height) // 2, 
                            (curses.COLS - self._width) // 2)
        win.keypad(1)
        win.border()
        textbox = win.derwin(self._height - 1, self._width - 3, 
                             1, 2)
        textbox.addstr(0, 0, self._message)
        return self._loop(win)

    def _loop(self, win):
        while True:
            for idx, btntext in enumerate(self._buttons):
                allowedspace = self._width // len(self._buttons)
                btn = win.derwin(
                    3, 10, 
                    self._height - 4, 
                    (((allowedspace-10)//2*idx) + allowedspace*idx 
                       + 2)
                )
                btn.border()
                flag = 0
                if idx == self._selected:
                    flag = curses.A_BOLD
                btn.addstr(1, (10-len(btntext))//2, btntext, flag)
            win.refresh()

            key = win.getch()
            if key == curses.KEY_RIGHT:
                self._selected = 1
            elif key == curses.KEY_LEFT:
                self._selected = 0
            elif key == ord('\n'):
                return self._selected
  1. 然后我们可以通过MessageBox.show方法来使用它:
MessageBox.show('Hello World,\n\npress enter to continue')
  1. 我们甚至可以用它来检查用户的选择:
if MessageBox.show('Are you sure?\n\npress enter to confirm',
                   cancel=True) == 0:
    print("Yeah! Let's continue")
else:
    print("That's sad, hope to see you soon")

它是如何工作的...

消息框基于curses库,它允许我们在屏幕上绘制基于文本的图形。当我们使用对话框时,我们将进入全屏文本图形模式,一旦退出,我们将恢复先前的终端状态。

这使我们能够在更复杂的程序中交错使用MessageBox类,而不必用curses编写整个程序。这是由curses.wrapper函数允许的,该函数在MessageBox.show类方法中用于包装实际显示框的MessageBox._show方法。

消息显示是在MessageBox初始化程序中准备的,通过MessageBox._build_message方法,以确保当消息太长时自动换行,并正确处理多行文本。消息框的高度取决于消息的长度和结果行数,再加上我们始终包括的六行,用于添加边框(占用两行)和按钮(占用四行)。

然后,MessageBox._show方法创建实际的框窗口,为其添加边框,并在其中显示消息。消息显示后,我们进入MessageBox._loop,等待用户在 OK 和取消之间做出选择。

MessageBox._loop方法通过win.derwin函数绘制所有必需的按钮及其边框。每个按钮宽 10 个字符,高 3 个字符,并根据allowedspace的值显示自身,该值为每个按钮保留了相等的框空间。然后,一旦绘制了按钮框,它将检查当前显示的按钮是否为所选按钮;如果是,则使用粗体文本显示按钮的标签。这使用户可以知道当前选择的选项。

绘制了两个按钮后,我们调用win.refresh()来实际在屏幕上显示我们刚刚绘制的内容。

然后我们等待用户按任意键以相应地更新屏幕;左/右箭头键将在 OK/取消选项之间切换,Enter将确认当前选择。

如果用户更改了所选按钮(通过按左或右键),我们将再次循环并重新绘制按钮。我们只需要重新绘制按钮,因为屏幕的其余部分没有改变;窗口边框和消息仍然是相同的,因此无需覆盖它们。屏幕的内容始终保留,除非调用了win.erase()方法,因此我们永远不需要重新绘制不需要更新的屏幕部分。

通过这种方式,我们还可以避免重新绘制按钮本身。这是因为只有取消/确定文本在从粗体到普通体和反之时需要重新绘制。

用户按下Enter键后,我们退出循环,并返回当前选择的 OK 和取消之间的选择。这允许调用者根据用户的选择采取行动。

输入框

在编写基于控制台的软件时,有时需要要求用户提供无法通过命令选项轻松提供的长文本输入。

在 Unix 世界中有一些这样的例子,比如编辑crontab或一次调整多个配置选项。其中大多数依赖于启动一个完整的第三方编辑器,比如nanovim,但是可以很容易地使用 Python 标准库滚动一个解决方案,这在许多情况下将足够满足我们的工具需要长或复杂的用户输入。

准备就绪

curses库仅包含在 Unix 系统的 Python 中,因此 Windows 用户可能需要一个解决方案,例如 CygWin 或 Linux 子系统,以便能够拥有包括curses支持的 Python 设置。

如何做...

对于这个示例,执行以下步骤:

  1. Python 标准库提供了一个curses.textpad模块,其中包含一个带有emacs的多行文本编辑器的基础,例如键绑定。我们只需要稍微扩展它以添加一些所需的行为和修复:
import curses
from curses.textpad import Textbox, rectangle

class TextInput(object):
    @classmethod
    def show(cls, message, content=None):
        return curses.wrapper(cls(message, content)._show)

    def __init__(self, message, content):
        self._message = message
        self._content = content

    def _show(self, stdscr):
        # Set a reasonable size for our input box.
        lines, cols = curses.LINES - 10, curses.COLS - 40

        y_begin, x_begin = (curses.LINES - lines) // 2, 
                           (curses.COLS - cols) // 2
        editwin = curses.newwin(lines, cols, y_begin, x_begin)
        editwin.addstr(0, 1, "{}: (hit Ctrl-G to submit)"
         .format(self._message))
        rectangle(editwin, 1, 0, lines-2, cols-1)
        editwin.refresh()

        inputwin = curses.newwin(lines-4, cols-2, y_begin+2, 
        x_begin+1)
        box = Textbox(inputwin)
        self._load(box, self._content)
        return self._edit(box)

    def _load(self, box, text):
        if not text:
            return
        for c in text:
            box._insert_printable_char(c)

    def _edit(self, box):
        while True:
            ch = box.win.getch()
            if not ch:
                continue
            if ch == 127:
                ch = curses.KEY_BACKSPACE
            if not box.do_command(ch):
                break
            box.win.refresh()
        return box.gather()
  1. 然后我们可以从用户那里读取输入:
result = TextInput.show('Insert your name:')
print('Your name:', result)
  1. 我们甚至可以要求它编辑现有文本:
result = TextInput.show('Insert your name:', 
                        content='Some Text\nTo be edited')
print('Your name:', result)

工作原理...

一切都始于TextInput._show方法,该方法准备了两个窗口;第一个绘制帮助文本(在我们的示例中为'插入您的姓名:'),以及文本区域的边框框。

一旦绘制完成,它会创建一个专门用于Textbox的新窗口,因为文本框将自由地插入、删除和编辑该窗口的内容。

如果我们有现有的内容(content=参数),TextInput._load函数会负责在继续编辑之前将其插入到文本框中。提供的内容中的每个字符都通过Textbox._insert_printable_char函数注入到文本框窗口中。

然后我们最终可以进入编辑循环(TextInput._edit方法),在那里我们监听按键并做出相应反应。实际上,Textbox.do_command已经为我们完成了大部分工作,因此我们只需要将按下的键转发给它,以将字符插入到我们的文本中或对特殊命令做出反应。这个方法的特殊部分是我们检查字符 127,它是Backspace,并将其替换为curses.KEY_BACKSPACE,因为并非所有终端在按下Backspace键时发送相同的代码。一旦字符被do_command处理,我们就可以刷新窗口,以便任何新文本出现并再次循环。

当用户按下Ctrl + G时,编辑器将认为文本已完成并退出编辑循环。在这之前,我们调用Textbox.gather来获取文本编辑器的全部内容并将其发送回调用者。

需要注意的是,内容实际上是从curses窗口的内容中获取的。因此,它实际上包括您屏幕上看到的所有空白空间。因此,Textbox.gather方法将剥离空白空间,以避免将大部分空白空间包围您的文本发送回给您。如果您尝试编写包含多个空行的内容,这一点就非常明显;它们将与其余空白空间一起被剥离。

第四章:文件系统和目录

在本章中,我们将涵盖以下食谱:

  • 遍历文件夹-递归遍历文件系统中的路径并检查其内容

  • 处理路径-以系统独立的方式构建路径

  • 扩展文件名-查找与特定模式匹配的所有文件

  • 获取文件信息-检测文件或目录的属性

  • 命名临时文件-使用需要从其他进程访问的临时文件

  • 内存和磁盘缓冲区-如果临时缓冲区大于阈值,则将其暂存到磁盘上

  • 管理文件名编码-处理文件名的编码

  • 复制目录-复制整个目录的内容

  • 安全地替换文件内容-在发生故障时如何安全地替换文件的内容

介绍

使用文件和目录是大多数软件自然而然的,也是我们作为用户每天都在做的事情,但作为开发人员,您很快会发现它可能比预期的要复杂得多,特别是当需要支持多个平台或涉及编码时。

Python 标准库有许多强大的工具可用于处理文件和目录。起初,可能很难在osshutilstatglob函数中找到这些工具,但一旦您了解了所有这些工具,就会清楚地知道标准库提供了一套很好的工具来处理文件和目录。

遍历文件夹

在文件系统中使用路径时,通常需要查找直接或子文件夹中包含的所有文件。想想复制一个目录或计算其大小;在这两种情况下,您都需要获取要复制的目录中包含的所有文件的完整列表,或者要计算大小的目录中包含的所有文件的完整列表。

如何做...

这个食谱的步骤如下:

  1. os模块中的os.walk函数用于递归遍历目录,其使用方法并不直接,但稍加努力,我们可以将其包装成一个方便的生成器,列出所有包含的文件:
import os

def traverse(path):
    for basepath, directories, files in os.walk(path):
        for f in files:
            yield os.path.join(basepath, f)
  1. 然后,我们可以遍历traverse并对其进行任何操作:
for f in traverse('.'):
    print(f)

它是如何工作的...

os.walk函数遍历目录及其所有子文件夹。对于它找到的每个目录,它返回三个值:目录本身、它包含的子目录和它包含的文件。然后,它将进入所提供的目录的子目录,并为子目录返回相同的三个值。

这意味着在我们的食谱中,basepath始终是正在检查的当前目录,directories是其子目录,files是它包含的文件。

通过迭代当前目录中包含的文件列表,并将它们的名称与目录路径本身连接起来,我们可以获取目录中包含的所有文件的路径。由于os.walk将进入所有子目录,因此我们将能够返回直接或间接位于所需路径内的所有文件。

处理路径

Python 最初是作为系统管理语言创建的。最初是为 Unix 系统编写脚本,因此在语言的核心部分之一始终是浏览磁盘,但在 Python 的最新版本中,这进一步扩展到了pathlib模块,它使得非常方便和容易地构建引用文件或目录的路径,而无需关心我们正在运行的系统。

由于编写多平台软件可能很麻烦,因此非常重要的是有中间层来抽象底层系统的约定,并允许我们编写可以在任何地方运行的代码。

特别是在处理路径时,Unix 和 Windows 系统处理路径的方式之间的差异可能会有问题。一个系统使用/,另一个使用\来分隔路径的部分本身就很麻烦,但 Windows 还有驱动器的概念,而 Unix 系统没有,因此我们需要一些东西来抽象这些差异并轻松管理路径。

如何做...

执行此食谱的以下步骤:

  1. pathlib库允许我们根据构成它的部分构建路径,根据您所在的系统正确地执行正确的操作:
>>> import pathlib
>>> 
>>> path = pathlib.Path('somefile.txt')
>>> path.write_text('Hello World')  # Write some text into file.
11
>>> print(path.resolve())  # Print absolute path
/Users/amol/wrk/pythonstlcookbook/somefile.txt
>>> path.read_text()  # Check the file content
'Hello World'
>>> path.unlink()  # Destroy the file
  1. 有趣的是,即使在 Windows 上进行相同的操作,也会得到完全相同的结果,即使path.resolve()会打印出稍微不同的结果:
>>> print(path.resolve())  # Print absolute path
C:\\wrk\\pythonstlcookbook\\somefile.txt
  1. 一旦我们有了pathlib.Path实例,甚至可以使用/运算符在文件系统中移动:
>>> path = pathlib.Path('.')
>>> path = path.resolve()
>>> path
PosixPath('/Users/amol/wrk/pythonstlcookbook')
>>> path = path / '..'
>>> path.resolve()
PosixPath('/Users/amol/wrk')

即使我是在类 Unix 系统上编写的,上述代码在 Windows 和 Linux/macOS 上都能正常工作并产生预期的结果。

还有更多...

pathlib.Path实际上会根据我们所在的系统构建不同的对象。在 POSIX 系统上,它将导致一个pathlib.PosixPath对象,而在 Windows 系统上,它将导致一个pathlib.WindowsPath对象。

在 POSIX 系统上无法构建pathlib.WindowsPath,因为它是基于 Windows 系统调用实现的,而这些调用在 Unix 系统上不可用。如果您需要在 POSIX 系统上使用 Windows 路径(或在 Windows 系统上使用 POSIX 路径),可以依赖于pathlib.PureWindowsPathpathlib.PurePosixPath

这两个对象不会实现实际访问文件的功能(读取、写入、链接、解析绝对路径等),但它们将允许您执行与操作路径本身相关的简单操作。

扩展文件名

在我们系统的日常使用中,我们习惯于提供路径,例如*.py,以识别所有的 Python 文件,因此当我们的用户提供一个或多个文件给我们的软件时,他们能够做同样的事情并不奇怪。

通常,通配符是由 shell 本身扩展的,但假设您从配置文件中读取它们,或者您想编写一个工具来清除当前项目中的.pyc文件(编译的 Python 字节码缓存),那么 Python 标准库中有您需要的内容。

如何做...

此食谱的步骤是:

  1. pathlib能够对您提供的路径执行许多操作。其中之一是解析通配符:
>>> list(pathlib.Path('.').glob('*.py'))
[PosixPath('conf.py')]
  1. 它还支持递归解析通配符:
>>> list(pathlib.Path('.').glob('**/*.py'))
[PosixPath('conf.py'), PosixPath('venv/bin/cmark.py'), 
 PosixPath('venv/bin/rst2html.py'), ...]

获取文件信息

当用户提供路径时,您真的不知道路径指的是什么。它是一个文件吗?是一个目录吗?它甚至存在吗?

检索文件信息允许我们获取有关提供的路径的详细信息,例如它是否指向文件以及该文件的大小。

如何做...

执行此食谱的以下步骤:

  1. 对任何pathlib.Path使用.stat()将提供有关路径的大部分详细信息:
>>> pathlib.Path('conf.py').stat()
os.stat_result(st_mode=33188, 
               st_ino=116956459, 
               st_dev=16777220, 
               st_nlink=1, 
               st_uid=501, 
               st_gid=20, 
               st_size=9306, 
               st_atime=1519162544, 
               st_mtime=1510786258, 
               st_ctime=1510786258)

返回的详细信息是指:

    • st_mode: 文件类型、标志和权限
  • st_ino: 存储文件的文件系统节点

  • st_dev: 存储文件的设备

  • st_nlink: 对此文件的引用(超链接)的数量

  • st_uid: 拥有文件的用户

  • st_gid: 拥有文件的组

  • st_size: 文件的大小(以字节为单位)

  • st_atime: 文件上次访问的时间

  • st_mtime: 文件上次修改的时间

  • st_ctime: 文件在 Windows 上创建的时间,Unix 上修改元数据的时间

  1. 如果我们想要查看其他详细信息,例如路径是否存在或者它是否是一个目录,我们可以依赖于这些特定的方法:
>>> pathlib.Path('conf.py').exists()
True
>>> pathlib.Path('conf.py').is_dir()
False
>>> pathlib.Path('_build').is_dir()
True

命名临时文件

通常在处理临时文件时,我们不关心它们存储在哪里。我们需要创建它们,在那里存储一些内容,并在完成后摆脱它们。大多数情况下,我们在想要存储一些太大而无法放入内存的东西时使用临时文件,但有时你需要能够提供一个文件给另一个工具或软件,临时文件是避免需要知道在哪里存储这样的文件的好方法。

在这种情况下,我们需要知道通往临时文件的路径,以便我们可以将其提供给其他工具。

这就是tempfile.NamedTemporaryFile可以帮助的地方。与所有其他tempfile形式的临时文件一样,它将为我们创建,并且在我们完成工作后会自动删除,但与其他类型的临时文件不同,它将有一个已知的路径,我们可以提供给其他程序,这些程序将能够从该文件中读取和写入。

如何做...

tempfile.NamedTemporaryFile将创建临时文件:

>>> from tempfile import NamedTemporaryFile
>>>
>>> with tempfile.NamedTemporaryFile() as f:
...   print(f.name)
... 
/var/folders/js/ykgc_8hj10n1fmh3pzdkw2w40000gn/T/tmponbsaf34

.name属性导致完整的文件路径在磁盘上,这使我们能够将其提供给其他外部程序:

>>> with tempfile.NamedTemporaryFile() as f:
...   os.system('echo "Hello World" > %s' % f.name)
...   f.seek(0)
...   print(f.read())
... 
0
0
b'Hello World\n'

内存和磁盘缓冲

有时,我们需要将某些数据保留在缓冲区中,比如我们从互联网上下载的文件,或者我们动态生成的一些数据。

由于这种数据的大小通常是不可预测的,通常不明智将其全部保存在内存中。

如果你从互联网上下载一个 32GB 的大文件,需要处理它(如解压缩或解析),如果你在处理之前尝试将其存储到字符串中,它可能会耗尽你所有的内存。

这就是为什么通常依赖tempfile.SpooledTemporaryFile通常是一个好主意,它将保留内容在内存中,直到达到最大大小,然后如果它比允许的最大大小更大,就将其移动到临时文件中。

这样,我们可以享受保留数据的内存缓冲区的好处,而不会因为内容太大而耗尽所有内存,因为一旦内容太大,它将被移动到磁盘上。

如何做...

像其他tempfile对象一样,创建SpooledTemporaryFile就足以使临时文件可用。唯一的额外部分是提供允许的最大大小,max_size=,在此之后内容将被移动到磁盘上:

>>> with tempfile.SpooledTemporaryFile(max_size=30) as temp:
...     for i in range(3):
...         temp.write(b'Line of text\n')
...     
...     temp.seek(0)
...     print(temp.read())
... 
b'Line of text\nLine of text\nLine of text\n'

它是如何工作的...

tempfile.SpooledTemporaryFile有一个内部 _file属性,它将真实数据存储在BytesIO存储中,直到它可以适应内存,然后一旦它比max_size更大,就将其移动到真实文件中。

在写入数据时,你可以通过打印_file的值来轻松看到这种行为:

>>> with tempfile.SpooledTemporaryFile(max_size=30) as temp:
...     for i in range(3):
...         temp.write(b'Line of text\n')
...         print(temp._file)
... 
<_io.BytesIO object at 0x10d539ca8>
<_io.BytesIO object at 0x10d539ca8>
<_io.BufferedRandom name=4>

管理文件名编码

以可靠的方式使用文件系统并不像看起来那么容易。我们的系统必须有特定的编码来表示文本,通常这意味着我们创建的所有内容都是以该编码处理的,包括文件名。

问题在于文件名的编码没有强有力的保证。假设你连接了一个外部硬盘,那个硬盘上的文件名的编码是什么?嗯,这将取决于文件创建时系统的编码。

通常,为了解决这个问题,软件会尝试系统编码,如果失败,它会打印一些占位符(你是否曾经看到过一个充满?的文件名,只是因为你的系统无法理解文件的名称?),这通常允许我们看到有一个文件,并且在许多情况下甚至打开它,尽管我们可能不知道它的实际名称。

为了使一切更加复杂,Windows 和 Unix 系统在处理文件名时存在很大的差异。在 Unix 系统上,路径基本上只是字节;你不需要真正关心它们的编码,因为你只是读取和写入一堆字节。而在 Windows 上,文件名实际上是文本。

在 Python 中,文件名通常存储为str。它们是需要以某种方式进行编码/解码的文本。

如何做...

每当我们处理文件名时,我们应该根据预期的文件系统编码对其进行解码。如果失败(因为它不是以预期的编码存储的),我们仍然必须能够将其放入str而不使其损坏,以便我们可以打开该文件,即使我们无法读取其名称:

def decode_filename(fname):
    fse = sys.getfilesystemencoding()
    return fname.decode(fse, "surrogateescape")

它是如何工作的...

decode_filename试图做两件事:首先,它询问 Python 根据操作系统预期的文件系统编码是什么。一旦知道了这一点,它就会尝试使用该编码解码提供的文件名。如果失败,它将使用surrogateescape进行解码。

这实际上意味着如果你无法解码它,就将其解码为假字符,我们将使用它来表示文本

这真的很方便,因为这样我们能够将文件名作为文本进行管理,即使我们不知道它的编码,当它使用surrogateescape编码回字节时,它将导致回到其原始字节序列。

当文件名以与我们的系统相同的编码进行编码时,很容易看出我们如何能够将其解码为str并打印它以读取其内容:

>>> utf8_filename_bytes = 'ùtf8.txt'.encode('utf8')
>>> utf8_filename = decode_filename(utf8_filename_bytes)
>>> type(utf8_filename)
<class 'str'>
>>> print(utf8_filename)
ùtf8.txt

如果编码实际上不是我们的系统编码(也就是说,文件来自一个非常古老的外部驱动器),我们实际上无法读取里面写的内容,但我们仍然能够将其解码为字符串,以便我们可以将其保存在一个变量中,并将其提供给任何可能需要处理该文件的函数:

>>> latin1_filename_bytes = 'làtìn1.txt'.encode('latin1')
>>> latin1_filename = decode_filename(latin1_filename_bytes)
>>> type(latin1_filename)
<class 'str'>
>>> latin1_filename
'l\udce0t\udcecn1.txt'

surrogateescape意味着能够告诉 Python我不在乎数据是否是垃圾,只需原样传递未知的字节

复制目录

复制目录的内容是我们可以轻松做到的事情,但是如果我告诉你,像cp(在 GNU 系统上复制文件的命令)这样的工具大约有 1200 行代码呢?

显然,cp的实现不是基于 Python 的,它已经发展了几十年,它照顾的远远超出了你可能需要的,但是自己编写递归复制目录的代码所需的工作远远超出你的预期。

幸运的是,Python 标准库提供了实用程序,可以直接执行最常见的操作之一。

如何做...

此处的步骤如下:

  1. copydir函数可以依赖于shutil.copytree来完成大部分工作:
import shutil

def copydir(source, dest, ignore=None):
    """Copy source to dest and ignore any file matching ignore 
       pattern."""
    shutil.copytree(source, dest, ignore_dangling_symlinks=True,
                    ignore=shutil.ignore_patterns(*ignore) if 
                    ignore else None)
  1. 然后,我们可以轻松地使用它来复制任何目录的内容,甚至将其限制为只复制相关部分。我们将复制一个包含三个文件的目录,其中我们实际上只想复制.pdf文件:
>>> import glob
>>> print(glob.glob('_build/pdf/*'))
['_build/pdf/PySTLCookbook.pdf', '_build/pdf/PySTLCookbook.rtc', '_build/pdf/PySTLCookbook.stylelog']
  1. 我们的目标目录目前不存在,因此它不包含任何内容:
>>> print(glob.glob('/tmp/buildcopy/*'))
[]
  1. 一旦我们执行copydir,它将被创建并包含我们期望的内容:
>>> copydir('_build/pdf', '/tmp/buildcopy', ignore=('*.rtc', '*.stylelog'))
  1. 现在,目标目录存在并包含我们期望的内容:
>>> print(glob.glob('/tmp/buildcopy/*'))
['/tmp/buildcopy/PySTLCookbook.pdf']

它是如何工作的...

shutil.copytree将通过os.listdir检索提供的目录的内容。对于listdir返回的每个条目,它将检查它是文件还是目录。

如果是文件,它将通过shutil.copy2函数进行复制(实际上可以通过提供copy_function参数来替换使用的函数),如果是目录,copytree本身将被递归调用。

然后使用ignore参数构建一个函数,一旦调用,将返回所有需要忽略的文件,给定一个提供的模式:

>>> f = shutil.ignore_patterns('*.rtc', '*.stylelog')
>>> f('_build', ['_build/pdf/PySTLCookbook.pdf', 
                 '_build/pdf/PySTLCookbook.rtc', 
                 '_build/pdf/PySTLCookbook.stylelog'])
{'_build/pdf/PySTLCookbook.stylelog', '_build/pdf/PySTLCookbook.rtc'}

因此,shutil.copytree将复制除ignore_patterns之外的所有文件,这将使其跳过。

最后的ignore_dangling_symlinks=True参数确保在symlinks损坏的情况下,我们只是跳过文件而不是崩溃。

安全地替换文件内容

替换文件的内容是一个非常缓慢的操作。与替换变量的内容相比,通常慢几倍;当我们将某些东西写入磁盘时,需要一些时间才能真正刷新,以及在内容实际写入磁盘之前需要一些时间。这不是一个原子操作,因此如果我们的软件在保存文件时遇到任何问题,文件可能会被写入一半,我们的用户无法恢复其数据的一致状态。

通常有一种常用模式来解决这种问题,该模式基于写入文件是一个缓慢、昂贵、易出错的操作,但重命名文件是一个原子、快速、廉价的操作。

如何做...

您需要执行以下操作:

  1. 就像open可以用作上下文管理器一样,我们可以轻松地推出一个safe_open函数,以安全的方式打开文件进行写入:
import tempfile, os

class safe_open:
    def __init__(self, path, mode='w+b'):
        self._target = path
        self._mode = mode

    def __enter__(self):
        self._file = tempfile.NamedTemporaryFile(self._mode, delete=False)
        return self._file

    def __exit__(self, exc_type, exc_value, traceback):
        self._file.close()
        if exc_type is None:
            os.rename(self._file.name, self._target)
        else:
            os.unlink(self._file.name)
  1. 使用safe_open作为上下文管理器允许我们写入文件,就像我们通常会做的那样。
with safe_open('/tmp/myfile') as f:
    f.write(b'Hello World')
  1. 内容将在退出上下文时正确保存:
>>> print(open('/tmp/myfile').read())
Hello World
  1. 主要区别在于,如果我们的软件崩溃或在写入时发生系统故障,我们不会得到一个写入一半的文件,而是会保留文件的任何先前状态。在这个例子中,我们在尝试写入替换 hello world,期望写入更多时中途崩溃:
with open('/tmp/myfile', 'wb+') as f:
    f.write(b'Replace the hello world, ')
    raise Exception('but crash meanwhile!')
    f.write(b'expect to write some more')
  1. 使用普通的open,结果将只是"替换 hello world,"
>>> print(open('/tmp/myfile').read())
Replace the hello world,
  1. 在使用safe_open时,只有在整个写入过程成功时,文件才会包含新数据:
with safe_open('/tmp/myfile') as f:
    f.write(b'Replace the hello world, ')
    raise Exception('but crash meanwhile!')
    f.write(b'expect to write some more')
  1. 在所有其他情况下,文件仍将保留其先前的状态:
>>> print(open('/tmp/myfile').read())
Hello World

工作原理...

safe_open依赖于tempfile来创建一个新文件,其中实际发生写操作。每当我们在上下文中写入f时,实际上是在临时文件中写入。

然后,只有当上下文存在时(safe_open.__exit__中的exc_type为 none),我们才会使用os.rename将旧文件与我们刚刚写入的新文件进行交换。

如果一切如预期般运行,我们应该有新文件,并且所有内容都已更新。

如果任何步骤失败,我们只需向临时文件写入一些或没有数据,并通过os.unlink将其丢弃。

在这种情况下,我们以前的文件从未被触及,因此仍保留其先前的状态。

第五章:日期和时间

在本章中,我们将涵盖以下技巧:

  • 时区感知的 datetime-检索当前 datetime 的可靠值

  • 解析日期-如何根据 ISO 8601 格式解析日期

  • 保存日期-如何存储 datetime

  • 从时间戳到 datetime-转换为时间戳和从时间戳转换为 datetime

  • 以用户格式显示日期-根据用户语言格式化日期

  • 去明天-如何计算指向明天的 datetime

  • 去下个月-如何计算指向下个月的 datetime

  • 工作日-如何构建一个指向本月第n个星期一/星期五的日期

  • 工作日-如何在时间范围内获取工作日

  • 组合日期和时间-从日期和时间创建一个 datetime

介绍

日期是我们生活的一部分,我们习惯于处理时间和日期作为一个基本的过程。即使是一个小孩也知道现在是什么时间或者明天是什么意思。但是,试着和世界另一端的人交谈,突然之间明天午夜等概念开始变得非常复杂。

当你说明天时,你是在说你的明天还是我的明天?如果你安排一个应该在午夜运行的进程,那么是哪一个午夜?

为了让一切变得更加困难,我们有闰秒,奇怪的时区,夏令时等等。当你尝试在软件中处理日期时,特别是在可能被世界各地的人使用的软件中,突然之间就会明白日期是一个复杂的事务。

本章包括一些短小的技巧,可以在处理用户提供的日期时避免头痛和错误。

时区感知的 datetime

Python datetimes 通常是naive,这意味着它们不知道它们所指的时区。这可能是一个主要问题,因为给定一个 datetime,我们无法知道它实际指的是什么时候。

在 Python 中处理日期最常见的错误是尝试通过datetime.datetime.now()获取当前 datetime,因为所有datetime方法都使用 naive 日期,所以无法知道该值代表的时间。

如何做到这一点...

执行以下步骤来完成这个技巧:

  1. 检索当前 datetime 的唯一可靠方法是使用datetime.datetime.utcnow()。无论用户在哪里,系统如何配置,它都将始终返回 UTC 时间。因此,我们需要使其具有时区感知能力,以便能够将其拒绝到世界上的任何时区:
import datetime

def now():
    return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
  1. 一旦我们有了一个具有时区感知能力的当前时间,就可以将其转换为任何其他时区,这样我们就可以向我们的用户显示他们自己时区的值:
def astimezone(d, offset):
    return d.astimezone(datetime.timezone(datetime.timedelta(hours=offset)))
  1. 现在,假设我目前在 UTC+01:00 时区,我可以获取 UTC 的具有时区感知能力的当前时间,然后在我的时区中显示它:
>>> d = now()
>>> print(d)
2018-03-19 21:35:43.251685+00:00

>>> d = astimezone(d, 1)
>>> print(d)
2018-03-19 22:35:43.251685+01:00

它是如何工作的...

所有 Python datetimes,默认情况下都没有指定任何时区,但通过设置tzinfo,我们可以使它们意识到它们所指的时区。

如果我们只是获取当前时间(datetime.datetime.now()),我们无法轻松地从软件内部知道我们正在获取时间的时区。因此,我们唯一可以依赖的时区是 UTC。每当检索当前时间时,最好始终依赖于datetime.datetime.utcnow()

一旦我们有了 UTC 的日期,因为我们知道它实际上是 UTC 时区的日期,我们可以轻松地附加datetime.timezone.utc时区(Python 提供的唯一时区)并使其具有时区感知能力。

now函数可以做到这一点:它获取 datetime 并使其具有时区感知能力。

由于我们的 datetime 现在具有时区感知能力,从那一刻起,我们可以依赖于datetime.datetime.astimezone方法将其转换为任何我们想要的时区。因此,如果我们知道我们的用户在 UTC+01:00,我们可以显示 datetime 的用户本地值,而不是显示 UTC 值。

这正是astimezone函数所做的。一旦提供了日期时间和与 UTC 的偏移量,它将返回一个日期,该日期是基于该偏移量的本地时区。

还有更多...

您可能已经注意到,虽然这个解决方案有效,但缺乏更高级的功能。例如,我目前在 UTC+01:00,但根据我的国家的夏令时政策,我可能在 UTC+02:00。此外,我们只支持基于整数小时的偏移量,虽然这是最常见的情况,但有一些时区,如印度或伊朗,有半小时的偏移量。

虽然我们可以扩展我们对时区的支持以包括这些奇怪的情况,但对于更复杂的情况,您可能应该依赖于pytz软件包,该软件包为完整的 IANA 时区数据库提供了时区。

解析日期

从另一个软件或用户那里接收日期时间时,它可能是以字符串格式。例如 JSON 等格式甚至不定义日期应该如何表示,但通常最好的做法是以 ISO 8601 格式提供这些日期。

ISO 8601 格式通常定义为[YYYY]-[MM]-[DD]T[hh]:[mm]:[ss]+-[TZ],例如2018-03-19T22:00+0100将指的是 UTC+01:00 时区的 3 月 19 日晚上 10 点。

ISO 8601 传达了表示日期和时间所需的所有信息,因此这是一种将日期时间编组并通过网络发送的好方法。

遗憾的是,它有许多奇怪之处(例如,+00时区也可以写为Z,或者您可以省略小时、分钟和秒之间的:),因此解析它有时可能会引起麻烦。

如何做...

以下是要遵循的步骤:

  1. 由于 ISO 8601 允许所有这些变体,没有简单的方法将其传递给datetime.datetime.strptime,并为所有情况返回一个日期时间;我们必须将所有可能的格式合并为一个格式,然后解析该格式:
import datetime

def parse_iso8601(strdate):
    date, time = strdate.split('T', 1)
    if '-' in time:
        time, tz = time.split('-')
        tz = '-' + tz
    elif '+' in time:
        time, tz = time.split('+')
        tz = '+' + tz
    elif 'Z' in time:
        time = time[:-1]
        tz = '+0000'
    date = date.replace('-', '')
    time = time.replace(':', '')
    tz = tz.replace(':', '')
    return datetime.datetime.strptime('{}T{}{}'.format(date, time, tz), 
                                      "%Y%m%dT%H%M%S%z")
  1. parse_iso8601的先前实现处理了大多数可能的 ISO 8601 表示:
>>> parse_iso8601('2018-03-19T22:00Z')
datetime.datetime(2018, 3, 19, 22, 0, tzinfo=datetime.timezone.utc)
>>> parse_iso8601('2018-03-19T2200Z')
datetime.datetime(2018, 3, 19, 22, 0, tzinfo=datetime.timezone.utc)
>>> parse_iso8601('2018-03-19T22:00:03Z')
datetime.datetime(2018, 3, 19, 22, 0, 3, tzinfo=datetime.timezone.utc)
>>> parse_iso8601('20180319T22:00:03Z')
datetime.datetime(2018, 3, 19, 22, 0, 3, tzinfo=datetime.timezone.utc)
>>> parse_iso8601('20180319T22:00:03+05:00')
datetime.datetime(2018, 3, 19, 22, 0, 3, tzinfo=datetime.timezone(datetime.timedelta(0, 18000)))
>>> parse_iso8601('20180319T22:00:03+0500')
datetime.datetime(2018, 3, 19, 22, 0, 3, tzinfo=datetime.timezone(datetime.timedelta(0, 18000)))

它是如何工作的...

parse_iso8601的基本思想是,无论在解析之前收到 ISO 8601 的方言是什么,我们都将其转换为[YYYY][MM][DD]T[hh][mm][ss]+-[TZ]的形式。

最难的部分是检测时区,因为它可以由+-分隔,甚至可以是Z。一旦提取了时区,我们可以摆脱日期中所有-的示例和时间中所有:的实例。

请注意,在提取时区之前,我们将时间与日期分开,因为日期和时区都可能包含-字符,我们不希望我们的解析器感到困惑。

还有更多...

解析日期可能变得非常复杂。虽然我们的parse_iso8601在与大多数以字符串格式提供日期的系统(如 JSON)交互时可以工作,但您很快就会面临它因日期时间可以表示的所有方式而不足的情况。

例如,我们可能会收到一个值,例如2 周前2013 年 7 月 4 日 PST。尝试解析所有这些情况并不是很方便,而且可能很快变得复杂。如果您必须处理这些特殊情况,您可能应该依赖于外部软件包,如dateparserdateutilmoment

保存日期

迟早,我们都必须在某个地方保存一个日期,将其发送到数据库或将其保存到文件中。也许我们将其转换为 JSON 以将其发送到另一个软件。

许多数据库系统不跟踪时区。其中一些具有配置选项,指定它们应该使用的时区,但在大多数情况下,您提供的日期将按原样保存。

这会导致许多情况下出现意外的错误或行为。假设您是一个好童子军,并且正确地完成了接收保留其时区的日期时间所需的所有工作。现在您有一个2018-01-15 15:30:00 UTC+01:00的日期时间,一旦将其存储在数据库中,UTC+01:00将很容易丢失,即使您自己将其存储在文件中,存储和恢复时区通常是一项麻烦的工作。

因此,您应该始终确保在将日期时间存储在某个地方之前将其转换为 UTC,这将始终保证,无论日期时间来自哪个时区,当您将其加载回来时,它将始终表示正确的时间。

如何做到...

此食谱的步骤如下:

  1. 要保存日期时间,我们希望有一个函数,确保日期时间在实际存储之前始终指的是 UTC:
import datetime

def asutc(d):
    return d.astimezone(datetime.timezone.utc)
  1. asutc函数可用于任何日期时间,以确保在实际存储之前将其移至 UTC:
>>> now = datetime.datetime.now().replace(
...    tzinfo=datetime.timezone(datetime.timedelta(hours=1))
... )
>>> now
datetime.datetime(2018, 3, 22, 0, 49, 45, 198483, 
                  tzinfo=datetime.timezone(datetime.timedelta(0, 3600)))
>>> asutc(now)
datetime.datetime(2018, 3, 21, 23, 49, 49, 742126, tzinfo=datetime.timezone.utc)

它是如何工作的...

此食谱的功能非常简单,通过datetime.datetime.astimezone方法,日期始终转换为其 UTC 表示。

这确保它将适用于存储跟踪时区的地方(因为日期仍将是时区感知的,但时区将是 UTC),以及当存储不保留时区时(因为没有时区的 UTC 日期仍然表示与零增量相同的 UTC 日期)。

从时间戳到日期时间

时间戳是从特定时刻开始的秒数的表示。通常,由于计算机可以表示的值在大小上是有限的,通常从 1970 年 1 月 1 日开始。

如果您收到一个值,例如1521588268作为日期时间表示,您可能想知道如何将其转换为实际的日期时间。

如何做到...

最近的 Python 版本引入了一种快速将日期时间与时间戳相互转换的方法:

>>> import datetime
>>> ts = 1521588268

>>> d = datetime.datetime.utcfromtimestamp(ts)
>>> print(repr(d))
datetime.datetime(2018, 3, 20, 23, 24, 28)

>>> newts = d.timestamp()
>>> print(newts)
1521584668.0

还有更多...

正如食谱介绍中指出的,计算机可以表示的数字有一个限制。因此,重要的是要注意,虽然datetime.datetime可以表示几乎任何日期,但时间戳却不能。

例如,尝试表示来自1300的日期时间将成功,但将无法将其转换为时间戳:

>>> datetime.datetime(1300, 1, 1)
datetime.datetime(1300, 1, 1, 0, 0)
>>> datetime.datetime(1300, 1, 1).timestamp()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OverflowError: timestamp out of range

时间戳只能表示从 1970 年 1 月 1 日开始的日期。

对于遥远的日期,反向方向也是如此,而253402214400表示 9999 年 12 月 31 日的时间戳,尝试从该值之后的日期创建日期时间将失败:

>>> datetime.datetime.utcfromtimestamp(253402214400)
datetime.datetime(9999, 12, 31, 0, 0)
>>> datetime.datetime.utcfromtimestamp(253402214400+(3600*24))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: year is out of range

日期时间只能表示从公元 1 年到 9999 年的日期。

以用户格式显示日期

在软件中显示日期时,如果用户不知道您将依赖的格式,很容易使用户感到困惑。

我们已经知道时区起着重要作用,并且在显示时间时我们总是希望将其显示为时区感知,但是日期也可能存在歧义。如果您写 3/4/2018,它是 4 月 3 日还是 3 月 4 日?

因此,通常有两种选择:

  • 采用国际格式(2018-04-03)

  • 本地化日期(2018 年 4 月 3 日)

可能的话,最好能够本地化日期格式,这样我们的用户将看到一个他们可以轻松识别的值。

如何做到...

此食谱需要以下步骤:

  1. Python 标准库中的locale模块提供了一种获取系统支持的本地化格式的方法。通过使用它,我们可以以目标系统允许的任何方式格式化日期:
import locale
import contextlib

@contextlib.contextmanager
def switchlocale(name):
    prev = locale.getlocale()
    locale.setlocale(locale.LC_ALL, name)
    yield
    locale.setlocale(locale.LC_ALL, prev)

def format_date(loc, d):
    with switchlocale(loc):
        fmt = locale.nl_langinfo(locale.D_T_FMT)
        return d.strftime(fmt)
  1. 调用format_date将正确地给出预期locale模块中日期的字符串表示:
>>> format_date('de_DE', datetime.datetime.utcnow())
'Mi 21 Mär 00:08:59 2018'
>>> format_date('en_GB', datetime.datetime.utcnow())
'Wed 21 Mar 00:09:11 2018'

它是如何工作的...

format_date函数分为两个主要部分。

第一个由switchlocale上下文管理器提供,它负责启用请求的locale(locale 是整个进程范围的),并在包装的代码块中返回控制,然后恢复原始locale。这样,我们可以仅在上下文管理器中使用请求的locale,而不影响软件的任何其他部分。

第二个是上下文管理器内部发生的事情。使用locale.nl_langinfo,请求当前启用的locale的日期时间格式字符串(locale.D_T_FMT)。这会返回一个字符串,告诉我们如何在当前活动的locale中格式化日期时间。返回的字符串将类似于'%a %e %b %X %Y'

然后日期本身根据通过datetime.strftime检索到的格式字符串进行格式化。

请注意,返回的字符串通常会包含%a%b格式化符号,它们代表当前星期当前月份的名称。由于星期几或月份的名称对每种语言都是不同的,Python 解释器将以当前启用的locale发出星期几或月份的名称。

因此,我们不仅按照用户的期望格式化了日期,而且结果输出也将是用户的语言。

还有更多...

虽然这个解决方案看起来非常方便,但重要的是要注意它依赖于动态切换locale

切换locale是一个非常昂贵的操作,所以如果你有很多值需要格式化(比如for循环或成千上万的日期),这可能会太慢。

另外,切换locale也不是线程安全的,所以除非所有的locale切换发生在其他线程启动之前,否则你将无法在多线程软件中应用这个食谱。

如果你想以一种健壮且线程安全的方式处理本地化,你可能想要检查 babel 包。Babel 支持日期和数字的本地化,并且以一种不需要设置全局状态的方式工作,因此即使在多线程环境中也能正确运行。

前往明天

当你有一个日期时,通常需要对该日期进行数学运算。例如,也许你想要移动到明天或昨天。

日期时间支持数学运算,比如对它们进行加减,但涉及时间时,很难得到你需要添加或减去的确切秒数,以便移动到下一天或前一天。

因此,这个食谱将展示一种从任意给定日期轻松移动到下一个或上一个日期的方法。

如何做...

对于这个食谱,以下是步骤:

  1. shiftdate函数将允许我们按任意天数移动到一个日期:
import datetime

def shiftdate(d, days):
    return (
        d.replace(hour=0, minute=0, second=0, microsecond=0) + 
        datetime.timedelta(days=days)
    )
  1. 使用它就像简单地提供你想要添加或移除的天数一样简单:
>>> now = datetime.datetime.utcnow()
>>> now
datetime.datetime(2018, 3, 21, 21, 55, 5, 699400)
  1. 我们可以用它去到明天:
>>> shiftdate(now, 1)
datetime.datetime(2018, 3, 22, 0, 0)
  1. 或者前往昨天:
>>> shiftdate(now, -1)
datetime.datetime(2018, 3, 20, 0, 0)
  1. 甚至前往下个月:
>>> shiftdate(now, 11)
datetime.datetime(2018, 4, 1, 0, 0)

它是如何工作的...

通常在移动日期时间时,我们想要去到一天的开始。假设你想要在事件列表中找到明天发生的所有事件,你真的想要搜索day_after_tomorrow > event_time >= tomorrow,因为你想要找到从明天午夜开始到后天午夜结束的所有事件。

因此,简单地改变日期本身是行不通的,因为我们的日期时间也与时间相关联。如果我们只是在日期上加一天,实际上我们会在明天包含的小时范围内结束。

这就是为什么shiftdate函数总是用午夜替换提供的日期时间的原因。

一旦日期被移动到午夜,我们只需添加一个等于指定天数的timedelta。如果这个数字是负数,我们将会向后移动时间,因为D + -1 == D -1

前往下个月

在移动日期时,另一个经常需要的需求是能够将日期移动到下个月或上个月。

如果你阅读了前往明天的食谱,你会发现与这个食谱有很多相似之处,尽管在处理月份时需要一些额外的变化,而在处理天数时是不需要的,因为月份的持续时间是可变的。

如何做...

按照这个食谱执行以下步骤:

  1. shiftmonth函数将允许我们按任意月数前后移动我们的日期:
import datetime

def shiftmonth(d, months):
    for _ in range(abs(months)):
        if months > 0:
            d = d.replace(day=5) + datetime.timedelta(days=28)
        else:
            d = d.replace(day=1) - datetime.timedelta(days=1)
    d = d.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
    return d
  1. 使用它就像简单地提供你想要添加或移除的月份一样简单:
>>> now = datetime.datetime.utcnow()
>>> now
datetime.datetime(2018, 3, 21, 21, 55, 5, 699400)
  1. 我们可以用它去到下个月:
>>> shiftmonth(now, 1)
datetime.datetime(2018, 4, 1, 0, 0)
  1. 或者回到上个月:
>>> shiftmonth(now, -1)
datetime.datetime(2018, 2, 1, 0, 0)
  1. 甚至可以按月份移动:
>>> shiftmonth(now, 10)
datetime.datetime(2019, 1, 1, 0, 0)

它是如何工作的...

如果您尝试将此配方与前往明天进行比较,您会注意到,尽管其目的非常相似,但这个配方要复杂得多。

就像在移动天数时,我们有兴趣在一天中的特定时间点移动一样(通常是开始时),当移动月份时,我们不希望最终处于新月份的随机日期和时间。

这解释了我们配方的最后一部分,对于我们数学表达式产生的任何日期时间,我们将时间重置为该月的第一天的午夜:

d = d.replace(day=1, hour=0, minute=0, second=0, microsecond=0)

就像对于天数配方一样,这使我们能够检查条件,例如two_month_from_now > event_date >= next_month,因为我们将捕捉从该月的第一天午夜到上个月的最后一天 23:59 的所有事件。

您可能想知道的部分是for循环。

与我们必须按天数移动(所有天数的持续时间相等为 24 小时)不同,当按月份移动时,我们需要考虑到每个月的持续时间不同的事实。

这就是为什么在向前移动时,我们将当前日期设置为月份的第 5 天,然后添加 28 天。仅仅添加 28 天是不够的,因为它只适用于 2 月,如果您在想,添加 31 天也不起作用,因为在 2 月的情况下,您将移动两个月而不是一个月。

这就是为什么我们将当前日期设置为月份的第 5 天,因为我们想要选择一个日期,我们确切地知道向其添加 28 天将使我们进入下一个月。

例如,选择月份的第一天将有效,因为 3 月 1 日+28 天=3 月 29 日,所以我们仍然在 3 月。而 3 月 5 日+28 天=4 月 2 日,4 月 5 日+28 天=5 月 3 日,2 月 5 日+28 天=3 月 5 日。因此,对于任何给定的月份,我们在将 5 日加 28 天时总是进入下一个月。

我们总是移动到不同的日期并不重要,因为该日期总是会被替换为该月的第一天。

由于我们无法移动确保我们总是准确地移动到下一个月的固定天数,所以我们不能仅通过添加天数*月份来移动,因此我们必须在for循环中执行此操作,并连续移动月份次数。

当向后移动时,事情变得容易得多。由于所有月份都从月份的第一天开始,我们只需移动到那里,然后减去一天。我们总是会在上个月的最后一天。

工作日

为月份的第 20 天或第 3 周构建日期非常简单,但如果您必须为月份的第 3 个星期一构建日期呢?

如何做...

按照以下步骤进行:

  1. 为了解决这个问题,我们将实际生成所有与请求的工作日匹配的月份日期:
import datetime

def monthweekdays(month, weekday):
    now = datetime.datetime.utcnow()
    d = now.replace(day=1, month=month, hour=0, minute=0, second=0, 
                    microsecond=0)
    days = []
    while d.month == month:
        if d.isoweekday() == weekday:
            days.append(d)
        d += datetime.timedelta(days=1)
    return days
  1. 然后,一旦我们有了这些列表,抓取第 n 个日期只是简单地索引结果列表。例如,要抓取 3 月的星期一:
>>> monthweekdays(3, 1)
[datetime.datetime(2018, 3, 5, 0, 0), 
 datetime.datetime(2018, 3, 12, 0, 0), 
 datetime.datetime(2018, 3, 19, 0, 0), 
 datetime.datetime(2018, 3, 26, 0, 0)]
  1. 所以抓取三月的第三个星期一将是:
>>> monthweekdays(3, 1)[2]
datetime.datetime(2018, 3, 19, 0, 0)

它是如何工作的...

在配方的开始,我们为所请求的月份的第一天创建一个日期。然后我们每次向前移动一天,直到月份结束,并将所有与请求的工作日匹配的日期放在一边。

星期从星期一到星期日分别为 1 到 7。

一旦我们有了所有星期一、星期五或者月份的其他日期,我们只需索引结果列表,抓取我们真正感兴趣的日期。

工作日

在许多管理应用程序中,您只需要考虑工作日,星期六和星期日并不重要。在这些日子里,您不工作,所以从工作的角度来看,它们不存在。

因此,在计算项目管理或与工作相关的应用程序的给定时间跨度内包含的日期时,您可以忽略这些日期。

如何做...

我们想要获取两个日期之间的工作日列表:

def workdays(d, end, excluded=(6, 7)):
    days = []
    while d.date() < end.date():
        if d.isoweekday() not in excluded:
            days.append(d)
        d += datetime.timedelta(days=1)
    return days

例如,如果是 2018 年 3 月 22 日,这是一个星期四,我想知道工作日直到下一个星期一(即 3 月 26 日),我可以轻松地要求workdays

>>> workdays(datetime.datetime(2018, 3, 22), datetime.datetime(2018, 3, 26))
[datetime.datetime(2018, 3, 22, 0, 0), 
 datetime.datetime(2018, 3, 23, 0, 0)]

因此我们知道还剩下两天:星期四本身和星期五。

如果您在世界的某个地方工作日是星期日,可能不是星期五,excluded参数可以用来指示哪些日期应该从工作日中排除。

它是如何工作的...

这个方法非常简单,我们只是从提供的日期(d)开始,每次加一天,直到达到end

我们认为提供的参数是日期时间,因此我们循环比较只有日期,因为我们不希望根据dend中提供的时间随机包括和排除最后一天。

这允许datetime.datetime.utcnow()为我们提供第一个参数,而不必关心函数何时被调用。只有日期本身将被比较,而不包括它们的时间。

组合日期和时间

有时您会有分开的日期和时间。当它们由用户输入时,这种情况特别频繁。从交互的角度来看,通常更容易选择一个日期然后选择一个时间,而不是一起选择日期和时间。或者您可能正在组合来自两个不同来源的输入。

在所有这些情况下,您最终会得到一个日期和时间,您希望将它们组合成一个单独的datetime.datetime实例。

如何做到...

Python 标准库提供了对这些操作的支持,因此拥有其中的任何两个:

>>> t = datetime.time(13, 30)
>>> d = datetime.date(2018, 1, 11)

我们可以轻松地将它们组合成一个单一的实体:

>>> datetime.datetime.combine(d, t)
datetime.datetime(2018, 1, 11, 13, 30)

还有更多...

如果您的time实例有一个时区(tzinfo),将日期与时间组合也会保留它:

>>> t = datetime.time(13, 30, tzinfo=datetime.timezone.utc)
>>> datetime.datetime.combine(d, t)
datetime.datetime(2018, 1, 11, 13, 30, tzinfo=datetime.timezone.utc)

如果您的时间没有时区,您仍然可以在组合这两个值时指定一个时区:

>>> t = datetime.time(13, 30)
>>> datetime.datetime.combine(d, t, tzinfo=datetime.timezone.utc)

在组合时提供时区仅支持 Python 3.6+。如果您使用之前的 Python 版本,您将不得不将时区设置为时间值。

第六章:读/写数据

在本章中,我们将涵盖以下配方:

  • 读取和写入文本数据——从文件中读取任何编码的文本

  • 读取文本行——逐行读取文本文件

  • 读取和写入二进制数据——从文件中读取二进制结构化数据

  • 压缩目录——读取和写入压缩的 ZIP 存档

  • Pickling 和 shelving——如何将 Python 对象保存在磁盘上

  • 读取配置文件——如何读取.ini格式的配置文件

  • 写入 XML/HTML 内容——生成 XML/HTML 内容

  • 读取 XML/HTML 内容——从文件或字符串解析 XML/HTML 内容

  • 读取和写入 CSV——读取和写入类似电子表格的 CSV 文件

  • 读取和写入关系数据库——将数据加载到SQLite数据库中

介绍

您的软件的输入将来自各种来源:命令行选项,标准输入,网络,以及经常是文件。从输入中读取本身很少是处理外部数据源时的问题;一些输入可能需要更多的设置,有些更直接,但通常只是打开它然后从中读取。

问题出在我们读取的数据该如何处理。有成千上万种格式,每种格式都有其自己的复杂性,有些是基于文本的,有些是二进制的。在本章中,我们将设置处理您作为开发人员在生活中可能会遇到的最常见格式的配方。

读取和写入文本数据

当读取文本文件时,我们已经知道应该以文本模式打开它,这是 Python 的默认模式。在这种模式下,Python 将尝试根据locale.getpreferredencoding返回的作为我们系统首选编码的编码来解码文件的内容。

遗憾的是,任何类型的编码都是我们系统的首选编码与文件内容保存时使用的编码无关。因为它可能是别人写的文件,甚至是我们自己写的,编辑器可能以任何编码保存它。

因此,唯一的解决方案是指定应该用于解码文件的编码。

如何做...

Python 提供的open函数接受一个encoding参数,可以用于正确地编码/解码文件的内容:

# Write a file with latin-1 encoding
with open('/tmp/somefile.txt', mode='w', encoding='latin-1') as f:
    f.write('This is some latin1 text: "è già ora"')

# Read back file with latin-1 encoding.
with open('/tmp/somefile.txt', encoding='latin-1') as f:
    txt = f.read()
    print(txt)

它是如何工作的...

一旦encoding选项传递给open,生成的文件对象将知道任何提供给file.write的字符串必须在将实际字节存储到文件之前编码为指定的编码。对于file.read()也是如此,它将从文件中获取字节,并在将它们返回给您之前使用指定的编码对其进行解码。

这允许您独立于系统声明的首选编码,读/写文件中的内容。

还有更多...

如果您想知道如何可能读取编码未知的文件,那么这是一个更加复杂的问题。

事实是,除非文件在头部提供一些指导,或者等效物,可以告诉您内容的编码类型,否则没有可靠的方法可以知道文件可能被编码的方式。

您可以尝试多种不同类型的编码,并检查哪种编码能够解码内容(不会抛出UnicodeDecodeError),但是一组字节解码为一种编码并不保证它解码为正确的结果。例如,编码为utf-8'ì'字符在latin-1中完美解码,但结果完全不同:

>>> 'ì'.encode('utf-8').decode('latin-1')
'ì'

如果您真的想尝试猜测内容的类型编码,您可能想尝试一个库,比如chardet,它能够检测到大多数常见类型的编码。如果要解码的数据长度足够长且足够多样化,它通常会成功地检测到正确的编码。

读取文本行

在处理文本文件时,通常最容易的方法是按行处理;每行文本是一个单独的实体,我们可以通过'\n''\r\n'连接所有行,因此在列表中有文本文件的所有行将非常方便。

有一种非常方便的方法可以立即从文本文件中提取行,Python 可以立即使用。

如何做...

由于file对象本身是可迭代的,我们可以直接构建一个列表:

with open('/var/log/install.log') as f:
    lines = list(f)

工作原理...

open充当上下文管理器,返回结果对象file。依赖上下文管理器非常方便,因为当我们完成文件操作时,我们需要关闭它,使用open作为上下文管理器将在我们退出with的主体时为我们关闭文件。

有趣的是file实际上是一个可迭代对象。当你迭代一个文件时,你会得到其中包含的行。因此,将list应用于它将构建所有行的列表,然后我们可以按照我们的意愿导航到结果列表。

字符

读取文本数据已经相当复杂,因为它需要解码文件的内容,但读取二进制数据可能会更加复杂,因为它需要解析字节及其内容以重建保存在文件中的原始数据。

在某些情况下,甚至可能需要处理字节顺序,因为当将数字保存到文本文件时,字节的写入顺序实际上取决于写入该文件的系统。

假设我们想要读取 TCP 头的开头,特定的源和目标端口、序列号和确认号,表示如下:

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Source Port          |       Destination Port        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        Sequence Number                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Acknowledgment Number                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

如何做...

此食谱的步骤如下:

  1. 假设有一个包含 TCP 数据包转储的文件(在我的计算机上,我将其保存为/tmp/packet.dump),我们可以尝试将其读取为二进制数据并解析其内容。

Python 的struct模块是读取二进制结构化数据的完美工具,我们可以使用它来解析我们的 TCP 数据包,因为我们知道每个部分的大小:

>>> import struct
>>> with open('/tmp/packet.dump', 'rb') as f:
...     data = struct.unpack_from('>HHLL', f.read())
>>> data
(50291, 80, 2778997212, 644363807)

作为 HTTP 连接,结果是我们所期望的:源端口:50291,目标端口:80,序列号:2778997212确认号:644363807

  1. 可以使用struct.pack将二进制数据写回:
>>> with open('/tmp/packet.dump', 'wb') as f:
...     data = struct.pack('>HHLL', 50291, 80, 2778997212, 644363807)
...     f.write(data)
>>> data
b'\xc4s\x00P\xa5\xa4!\xdc&h6\x1f'

工作原理...

首先,我们以二进制模式rb参数)打开文件。这告诉 Python 避免尝试解码文件的内容,就像它是文本一样;内容以bytes对象的形式返回。

然后,我们使用f.read()读取的数据传递给struct.unpack_from,它能够解码二进制数据作为一组数字、字符串等。在我们的例子中,我们使用>指定我们正在读取的数据是大端排序的(就像所有与网络相关的数据一样),然后使用HHLL来说明我们要读取两个无符号 16 位数字和两个无符号 32 位数字(端口和序列/确认号)。

由于我们使用了unpack_from,在消耗了指定的四个数字后,任何其他剩余的数据都会被忽略。

写入二进制数据也是一样的。我们以二进制模式打开文件,通过struct.pack将四个数字打包成一个字节对象,并将它们写入文件。

还有更多...

struct.packstruct.unpack函数支持许多选项和格式化程序,以定义应该写入/读取的数据以及应该如何写入/读取。

字节顺序的最常见格式化程序如下:

字节顺序
读取和写入二进制数据
本地
小端
大端

如果没有指定这些选项中的任何一个,数据将以您系统的本机字节顺序进行编码,并且将按照在系统内存中的自然对齐方式进行对齐。强烈不建议以这种方式保存数据,因为能够读取它的唯一系统是保存它的系统。

对于数据本身,每种数据类型由一个单个字符表示,每个字符定义数据的类型(整数、浮点数、字符串)和其大小:

格式 C 类型 Python 类型 大小(字节)
x 填充字节 无值
c char 长度为 1 的字节 1
b 有符号char 整数 1
B 无符号char 整数 1
? _Bool 布尔值 1
h short 整数 2
H 无符号short 整数 2
i int 整数 4
I 无符号int 整数 4
l long 整数 4
L 无符号long 整数 4
q long long 整数 8
Q 无符号long long 整数 8
n ssize_t 整数
N size_t 整数
e 半精度float 浮点数 2
f float 浮点数 4
d double 浮点数 8
s char[] 字节
p char[] 字节
P void * 整数

压缩目录

存档文件是以一种好的方式来分发整个目录,就好像它们是单个文件,并且可以减小分发文件的大小。

Python 内置支持创建 ZIP 存档文件,可以利用它来压缩整个目录。

如何实现...

这个食谱的步骤如下:

  1. zipfile模块允许我们创建由多个文件组成的压缩 ZIP 存档:
import zipfile
import os

def zipdir(archive_name, directory):
    with zipfile.ZipFile(
        archive_name, 'w', compression=zipfile.ZIP_DEFLATED
    ) as archive:
        for root, dirs, files in os.walk(directory):
            for filename in files:
                abspath = os.path.join(root, filename)
                relpath = os.path.relpath(abspath, directory)
                archive.write(abspath, relpath)        
  1. 使用zipdir就像提供应该创建的.zip文件的名称和应该存档的目录的路径一样简单:
zipdir('/tmp/test.zip', '_build/doctrees')
  1. 在这种情况下,我压缩了包含本书文档树的目录。存档准备好后,我们可以通过再次使用zipfile打开它并列出包含的条目来验证其内容:
>>> with zipfile.ZipFile('/tmp/test.zip') as archive:
...     for n in archive.namelist():
...         print(n)
algorithms.doctree
concurrency.doctree
crypto.doctree
datastructures.doctree
datetimes.doctree
devtools.doctree
environment.pickle
filesdirs.doctree
gui.doctree
index.doctree
io.doctree
multimedia.doctree

如何实现...

zipfile.ZipFile首先以ZIP_DEFLATED压缩(这意味着用标准 ZIP 格式压缩数据)的写模式打开。这允许我们对存档进行更改,然后在退出上下文管理器的主体时自动刷新并关闭存档。

在上下文中,我们依靠os.walk来遍历整个目录及其所有子目录,并找到所有包含的文件。

对于在每个目录中找到的每个文件,我们构建两个路径:绝对路径和相对路径。

绝对路径是必需的,以告诉ZipFile从哪里读取需要添加到存档中的数据,相对路径用于为写入存档的数据提供适当的名称。这样,我们写入存档的每个文件都将以磁盘上的名称命名,但是不会存储其完整路径(/home/amol/pystlcookbook/_build/doctrees/io.doctree),而是以相对路径(_build/doctrees/io.doctree)存储,因此,如果存档被解压缩,文件将相对于我们正在解压缩的目录创建,而不是以长而无意义的路径结束,这个路径类似于文件在我的计算机上的路径。

一旦文件的路径和应该用来存储它的名称准备好,它们就被提供给ZipFile.write来实际将文件写入存档。

一旦所有文件都被写入,我们退出上下文管理器,存档最终被刷新。

Pickling and shelving

如果您的软件需要大量信息,或者如果您希望在不同运行之间保留历史记录,除了将其保存在某个地方并在下次运行时加载它之外,几乎没有其他选择。

手动保存和加载数据可能会很繁琐且容易出错,特别是如果数据结构很复杂。

因此,Python 提供了一个非常方便的模块shelve,允许我们保存和恢复任何类型的 Python 对象,只要可以对它们进行pickle

如何实现...

执行以下步骤以完成此食谱:

  1. shelve,由shelve实现,可以像 Python 中的任何其他文件一样打开。一旦打开,就可以像字典一样将键读入其中:
>>> with shelve.open('/tmp/shelf.db') as shelf:
...   shelf['value'] = 5
... 
  1. 存储到shelf中的值也可以作为字典读回:
>>> with shelve.open('/tmp/shelf.db') as shelf:
...   print(shelf['value'])
... 
5
  1. 复杂的值,甚至自定义类,都可以存储在shelve中:
>>> class MyClass(object):
...   def __init__(self, value):
...     self.value = value
... 
>>> with shelve.open('/tmp/shelf.db') as shelf:
...   shelf['value'] = MyClass(5)
... 
>>> with shelve.open('/tmp/shelf.db') as shelf:
...   print(shelf['value'])
... 
<__main__.MyClass object at 0x101e90d30>
>>> with shelve.open('/tmp/shelf.db') as shelf:
...   print(shelf['value'].value)
... 
5

它的工作原理...

shelve 模块被实现为管理dbm数据库的上下文管理器。

当上下文进入时,数据库被打开,并且因为shelf是一个字典,所以包含的对象变得可访问。

每个对象都作为一个 pickled 对象存储在数据库中。这意味着在存储之前,每个对象都使用pickle进行编码,并产生一个序列化字符串:

>>> import pickle
>>> pickle.dumps(MyClass(5))
b'\x80\x03c__main__\nMyClass\nq\x00)\x81q\x01}'
b'q\x02X\x05\x00\x00\x00valueq\x03K\x05sb.'

这允许shelve存储任何类型的 Python 对象,甚至自定义类,只要它们在读取对象时再次可用。

然后,当上下文退出时,所有已更改的shelf键都将通过在关闭shelf时调用shelf.sync写回磁盘。

还有更多...

在使用shelve时需要注意一些事项。

首先,shelve不跟踪突变。如果您将可变对象(如dictlist)存储在shelf中,则对其进行的任何更改都不会被保存。只有对shelf本身的根键的更改才会被跟踪:

>>> with shelve.open('/tmp/shelf.db') as shelf:
...   shelf['value'].value = 10
... 
>>> with shelve.open('/tmp/shelf.db') as shelf:
...   print(shelf['value'].value)
... 
5

这只是意味着您需要重新分配您想要改变的任何值:

>>> with shelve.open('/tmp/shelf.db') as shelf:
...   myvalue = shelf['value']
...   myvalue.value = 10
...   shelf['value'] = myvalue
... 
>>> with shelve.open('/tmp/shelf.db') as shelf:
...   print(shelf['value'].value)
... 
10

shelve 不允许多个进程或线程同时进行并发读/写。如果要从多个进程访问相同的shelf,则必须使用锁(例如使用fcntl.flock)来包装shelf访问。

读取配置文件

当您的软件有太多的选项无法通过命令行简单地传递它们,或者当您希望确保用户不必每次启动应用程序时手动提供它们时,从配置文件加载这些选项是最常见的解决方案之一。

配置文件应该易于人类阅读和编写,因为他们经常会与它们一起工作,而最常见的要求之一是允许注释,以便用户可以在配置中写下为什么设置某些选项或如何计算某些值的原因。这样,当用户在六个月后回到配置文件时,他们仍然会知道这些选项的原因。

因此,通常依赖于 JSON 或机器-机器格式来配置选项并不是很好,因此最好使用特定于配置的格式。

最长寿的配置格式之一是.ini文件,它允许我们使用[section]语法声明多个部分,并使用name = value语法设置选项。

生成的配置文件将如下所示:

[main]
debug = true
path = /tmp
frequency = 30

另一个很大的优势是我们可以轻松地从 Python 中读取.ini文件。

如何做到这一点...

本教程的步骤是:

  1. 大多数加载和解析.ini的工作可以由configparser模块本身完成,但我们将扩展它以实现每个部分的默认值和转换器:
import configparser

def read_config(config_text, schema=None):
    """Read options from ``config_text`` applying given ``schema``"""
    schema = schema or {}

    cfg = configparser.ConfigParser(
        interpolation=configparser.ExtendedInterpolation()
    )
    try:
        cfg.read_string(config_text)
    except configparser.MissingSectionHeaderError:
        config_text = '[main]\n' + config_text
        cfg.read_string(config_text)

    config = {}
    for section in schema:
        options = config.setdefault(section, {})
        for option, option_schema in schema[section].items():
            options[option] = option_schema.get('default')
    for section in cfg.sections():
        options = config.setdefault(section, {})
        section_schema = schema.get(section, {})
        for option in cfg.options(section):
            option_schema = section_schema.get(option, {})
            getter = 'get' + option_schema.get('type', '')
            options[option] = getattr(cfg, getter)(section, option)
    return config
  1. 使用提供的函数就像提供一个应该用于解析它的配置和模式一样容易:
config_text = '''
debug = true

[registry]
name = Alessandro
surname = Molina

[extra]
likes = spicy food
countrycode = 39
'''

config = read_config(config_text, {
    'main': {
        'debug': {'type': 'boolean'}
    },
    'registry': {
        'name': {'default': 'unknown'},
        'surname': {'default': 'unknown'},
        'middlename': {'default': ''},
    },
    'extra': {
        'countrycode': {'type': 'int'},
        'age': {'type': 'int', 'default': 0}
    },
    'more': {
        'verbose': {'type': 'int', 'default': 0}
    }
})

生成的配置字典config将包含配置中提供的所有选项或在模式中声明的选项,转换为模式中指定的类型:

>>> import pprint
>>> pprint.pprint(config)
{'extra': {'age': 0, 'countrycode': 39, 'likes': 'spicy food'},
 'main': {'debug': True},
 'more': {'verbose': 0},
 'registry': {'middlename': 'unknown',
              'name': 'Alessandro',
              'surname': 'Molina'}}

它的工作原理...

read_config函数执行三件主要事情:

  • 允许我们解析没有部分的简单config文件的纯列表选项:
option1 = value1
option2 = value2
  • 为配置的default模式中声明的所有选项应用默认值。

  • 将所有值转换为模式中提供的type

第一个特性是通过捕获解析过程中引发的任何MissingSectionHeaderError异常来提供的,并在缺少时自动添加[main]部分。所有未在任何部分中提供的选项都将记录在main部分下。

提供默认值是通过首先遍历模式中声明的所有部分和选项,并将它们设置为其default中提供的值或者如果没有提供默认值,则设置为None来完成的。

在第二次遍历中,所有默认值都将被实际存储在配置中的值所覆盖。

在第二次遍历期间,对于每个被设置的值,该选项的type在模式中被查找。通过在类型前加上get单词来构建诸如getbooleangetint的字符串。这导致成为需要用于将配置选项解析为请求的类型的configparser方法的名称。

如果没有提供type,则使用空字符串。这导致使用普通的.get方法,该方法将值读取为文本。因此,不提供type意味着将选项视为普通字符串。

然后,所有获取和转换的选项都存储在字典中,这样就可以通过config[section][name]的表示法更容易地访问转换后的值,而无需总是调用访问器,例如.getboolean

还有更多...

提供给ConfigParser对象的interpolation=configparser.ExtendedInterpolation()参数还启用了一种插值模式,允许我们引用配置文件中其他部分的值。

这很方便,可以避免一遍又一遍地重复相同的值,例如,当提供应该都从同一个根开始的多个路径时:

[paths]
root = /tmp/test01
images = ${root}/images
sounds = ${root}/sounds

此外,该语法允许我们引用其他部分中的选项:

[main]
root = /tmp/test01

[paths]
images = ${main:root}/images
sounds = ${main:root}/sounds

ConfigParser的另一个便利功能是,如果要使一个选项在所有部分中都可用,只需在特殊的[DEFAULT]部分中指定它。

这将使该选项在所有其他部分中都可用,除非在该部分本身中明确覆盖它:

>>> config = read_config('''
... [DEFAULT]
... option = 1
... 
... [section1]
... 
... [section2]
... option = 5
... ''')
>>> config
{'section1': {'option': '1'}, 
 'section2': {'option': '5'}}

编写 XML/HTML 内容

编写基于 SGML 的语言通常并不是很困难,大多数语言都提供了用于处理它们的实用程序,但是如果文档变得太大,那么在尝试以编程方式构建元素树时很容易迷失。

最终会有数百个.addChild或类似的调用,这些调用都是连续的,这样很难理解我们在文档中的位置以及我们当前正在编辑的部分是什么。

幸运的是,通过将 Python 的ElementTree模块与上下文管理器结合起来,我们可以拥有一个解决方案,使我们的代码结构能够与我们试图生成的 XML/HTML 的结构相匹配。

如何做...

对于这个配方,执行以下步骤:

  1. 我们可以创建一个代表 XML/HTML 文档树的XMLDocument类,并且通过允许我们插入标签和文本的XMLDocumentBuilder来辅助实际构建文档。
import xml.etree.ElementTree as ET
from contextlib import contextmanager

class XMLDocument:
    def __init__(self, root='document', mode='xml'):
        self._root = ET.Element(root)
        self._mode = mode

    def __str__(self):
        return ET.tostring(self._root, encoding='unicode', method=self._mode)

    def write(self, fobj):
        ET.ElementTree(self._root).write(fobj)

    def __enter__(self):
        return XMLDocumentBuilder(self._root)

    def __exit__(self, exc_type, value, traceback):
        return

class XMLDocumentBuilder:
    def __init__(self, root):
        self._current = [root]

    def tag(self, *args, **kwargs):
        el = ET.Element(*args, **kwargs)
        self._current[-1].append(el)
        @contextmanager
        def _context():
            self._current.append(el)
            try:
                yield el
            finally:
                self._current.pop()
        return _context()

    def text(self, text):
        if self._current[-1].text is None:
            self._current[-1].text = ''
        self._current[-1].text += text
  1. 然后,我们可以使用我们的XMLDocument来构建我们想要的文档。例如,我们可以在 HTML 模式下构建网页:
doc = XMLDocument('html', mode='html')

with doc as _:
    with _.tag('head'):
        with _.tag('title'): _.text('This is the title')
    with _.tag('body'):
        with _.tag('div', id='main-div'):
            with _.tag('h1'): _.text('My Document')
            with _.tag('strong'): _.text('Hello World')
            _.tag('img', src='http://via.placeholder.com/150x150')
  1. XMLDocument支持转换为字符串,因此要查看生成的 XML,我们只需打印它:
>>> print(doc)
<html>
    <head>
        <title>This is the title</title>
    </head>
    <body>
        <div id="main-div">
            <h1>My Document</h1>
            <strong>Hello World</strong>
            <img src="http://via.placeholder.com/150x150">
        </div>
    </body>
</html>

正如您所看到的,我们的代码结构与实际 XML 文档的嵌套相匹配,因此很容易看到_.tag('body')中的任何内容都是我们 body 标签的内容。

将生成的文档写入实际文件可以依赖于XMLDocument.write方法来完成:

doc.write('/tmp/test.html')

它是如何工作的...

实际的文档生成是由xml.etree.ElementTree执行的,但是如果我们必须使用普通的xml.etree.ElementTree生成相同的文档,那么将会导致一堆el.append调用:

root = ET.Element('html')
head = ET.Element('head')
root.append(head)
title = ET.Element('title')
title.text = 'This is the title'
head.append(title)

这使得我们很难理解我们所在的位置。在这个例子中,我们只是构建一个结构,<html><head><title>This is the title</title></head></html>,但是已经很难跟踪title在 head 中,依此类推。对于更复杂的文档,这将变得不可能。

因此,虽然我们的XMLDocument保留了文档树的root并支持将其转换为字符串并将其写入文件,但实际工作是由XMLDocumentBuilder完成的。

XMLDocumentBuilder保持节点堆栈以跟踪我们在树中的位置(XMLDocumentBuilder._current)。该列表的尾部将始终告诉我们当前在哪个标签内。

调用XMLDocumentBuilder.text将向当前活动标签添加文本:

doc = XMLDocument('html', mode='html')
with doc as _:
    _.text('Some text, ')
    _.text('and even more')

上述代码将生成<html>Some text, and even more</html>

XMLDocumentBuilder.tag方法将在当前活动标签中添加一个新标签:

doc = XMLDocument('html', mode='html')
with doc as _:
    _.tag('input', type='text', placeholder='Name?')
    _.tag('input', type='text', placeholder='Surname?')

这导致以下结果:

<html>
    <input placeholder="Name?" type="text">
    <input placeholder="Surname?" type="text">
</html>

有趣的是,XMLDocumentBuilder.tag方法还返回一个上下文管理器。进入时,它将设置输入的标签为当前活动标签,退出时,它将恢复先前的活动节点。

这使我们能够嵌套XMLDocumentBuilder.tag调用并生成标签树:

doc = XMLDocument('html', mode='html')
with doc as _:
    with _.tag('head'):
        with _.tag('title') as title: title.text = 'This is a title'

这导致以下结果:

<html>
    <head>
        <title>This is a title</title>
    </head>
</html>

实际文档节点可以通过as获取,因此在先前的示例中,我们能够获取刚刚创建的title节点并为其设置文本,但XMLDocumentBuilder.text也可以工作,因为title节点现在是活动元素,一旦我们进入其上下文。

还有更多...

在使用此方法时,我经常应用一个技巧。这使得在 Python 端更难理解发生了什么,这就是我在解释配方本身时避免这样做的原因,但通过消除大部分 Python 噪音,它使 HTML/XML 结构更加可读。

如果您将XMLDocumentBuilder.tagXMLDocumentBuilder.text方法分配给一些简短的名称,您几乎可以忽略调用 Python 函数的事实,并使 XML 结构更相关:

doc = XMLDocument('html', mode='html')
with doc as builder:
    _ = builder.tag
    _t = builder.text

    with _('head'):
        with _('title'): _t('This is the title')
    with _('body'):
        with _('div', id='main-div'):
            with _('h1'): _t('My Document')
            with _('strong'): _t('Hello World')
            _('img', src='http://via.placeholder.com/150x150')

以这种方式编写,您实际上只能看到 HTML 标签及其内容,这使得文档结构更加明显。

阅读 XML/HTML 内容

阅读 HTML 或 XML 文件使我们能够解析网页内容,并阅读 XML 中描述的文档或配置。

Python 有一个内置的 XML 解析器,ElementTree模块非常适合解析 XML 文件,但涉及 HTML 时,由于 HTML 的各种怪癖,它很快就会出现问题。

考虑尝试解析以下 HTML:

<html>
    <body class="main-body">
        <p>hi</p>
        <img><br>
        <input type="text" />
    </body>
</html>

您将很快遇到错误:

xml.etree.ElementTree.ParseError: mismatched tag: line 7, column 6

幸运的是,调整解析器以处理至少最常见的 HTML 文件并不太难,例如自闭合/空标签。

如何做...

对于此配方,您需要执行以下步骤:

  1. ElementTree默认使用expat解析文档,然后依赖于xml.etree.ElementTree.TreeBuilder构建文档的 DOM。

我们可以用基于HTMLParser的自己的解析器替换基于expatXMLParser,并让TreeBuilder依赖于它:

import xml.etree.ElementTree as ET
from html.parser import HTMLParser

class ETHTMLParser(HTMLParser):
    SELF_CLOSING = {'br', 'img', 'area', 'base', 'col', 'command',    
                    'embed', 'hr', 'input', 'keygen', 'link', 
                    'menuitem', 'meta', 'param',
                    'source', 'track', 'wbr'}

    def __init__(self, *args, **kwargs):
        super(ETHTMLParser, self).__init__(*args, **kwargs)
        self._builder = ET.TreeBuilder()
        self._stack = []

    @property
    def _last_tag(self):
        return self._stack[-1] if self._stack else None

    def _handle_selfclosing(self):
        last_tag = self._last_tag
        if last_tag in self.SELF_CLOSING:
            self.handle_endtag(last_tag)

    def handle_starttag(self, tag, attrs):
        self._handle_selfclosing()
        self._stack.append(tag)
        self._builder.start(tag, dict(attrs))

    def handle_endtag(self, tag):
        if tag != self._last_tag:
            self._handle_selfclosing()
        self._stack.pop()
        self._builder.end(tag)

    def handle_data(self, data):
        self._handle_selfclosing()
        self._builder.data(data)

    def close(self):
        return self._builder.close()
  1. 使用此解析器,我们最终可以成功处理我们的 HTML 文档:
text = '''
<html>
    <body class="main-body">
        <p>hi</p>
        <img><br>
        <input type="text" />
    </body>
</html>
'''

parser = ETHTMLParser()
parser.feed(text)
root = parser.close()
  1. 我们可以验证我们的root节点实际上包含我们原始的 HTML 文档,通过将其打印回来:
>>> print(ET.tostring(root, encoding='unicode'))
<html>
    <body class="main-body">
        <p>hi</p>
        <img /><br />
        <input type="text" />
    </body>
</html>
  1. 然后,生成的root文档可以像任何其他ElementTree.Element树一样进行导航:
def print_node(el, depth=0):
    print(' '*depth, el)
    for child in el:
        print_node(child, depth + 1)

>>> print_node(root)
 <Element 'html' at 0x102799a48>
  <Element 'body' at 0x102799ae8>
   <Element 'p' at 0x102799a98>
   <Element 'img' at 0x102799b38>
   <Element 'br' at 0x102799b88>
   <Element 'input' at 0x102799bd8>

它是如何工作的...

为了构建表示 HTML 文档的ElementTree.Element对象树,我们一起使用了两个类:HTMLParser读取 HTML 文本,TreeBuilder构建ElementTree.Element对象树。

每次HTMLParser遇到打开或关闭标签时,它将调用handle_starttaghandle_endtag。当我们遇到这些时,我们通知TreeBuilder必须启动一个新元素,然后关闭该元素。

同时,我们在self._stack中跟踪上次启动的标签(因此我们当前所在的标签)。这样,我们可以知道当前打开的标签尚未关闭。每次遇到新的打开标签或关闭标签时,我们都会检查上次打开的标签是否是自闭合标签;如果是,我们会在打开或关闭新标签之前关闭它。

这将自动转换代码。考虑以下内容:

<br><p></p>

它将被转换为以下内容:

In::
<br></br><p></p>

在遇到一个新的开放标签后,当遇到一个自关闭标签(<br>)时,<br>标签会自动关闭。

它还处理以下代码:

<body><br></body>

前面的代码转换为以下内容:

<body><br></br></body>

当面对<br>自关闭标签后,遇到不同的关闭标签(</body>),<br>会自动关闭。

即使在处理标签内文本时调用handle_data,如果最后一个开放标签是自关闭标签,自关闭标签也会自动关闭:

<p><br>Hello World</p>

Hello World文本被认为是<p>的内容,而不是<br>的内容,因为代码被转换为以下内容:

<p><br></br>Hello World</p>

最后,一旦完整的文档被解析,调用ETHTMLParser.close()将终止TreeBuilder构建的树,并返回生成的根Element

还有更多...

提出的食谱展示了如何使用HTMLParser来适应 XML 解析工具以处理 HTML,与 XML 相比,HTML 的规则更加灵活。

虽然这个解决方案主要处理常见的 HTML 写法,但它不会涵盖所有可能的情况。HTML 支持一些奇怪的情况,有时会使用一些没有值的属性:

<input disabled>

或者没有引号的属性:

<input type=text>

甚至一些带内容但没有任何关闭标签的属性:

<li>Item 1
<li>Item 2

尽管大多数这些格式都得到支持,但它们很少被使用(也许除了没有任何值的属性,我们的解析器会报告其值为None之外),所以在大多数情况下,它们不会引起麻烦。但是,如果您真的需要解析支持所有可能的奇怪情况的 HTML,那么最好使用外部库,比如lxmlhtml5lib,它们在面对奇怪情况时会尽可能地像浏览器一样行为。

读写 CSV

CSV 被认为是表格数据的最佳交换格式之一;几乎所有的电子表格工具都支持读写 CSV,并且可以使用任何纯文本编辑器轻松编辑,因为它对人类来说很容易理解。

只需拆分并用逗号设置值,您几乎已经写了一个 CSV 文档。

Python 对于读取 CSV 文件有非常好的内置支持,我们可以通过csv模块轻松地写入或读取 CSV 数据。

我们将看到如何读写表格:

"ID","Name","Surname","Language"
1,"Alessandro","Molina","Italian"
2,"Mika","Häkkinen","Suomi"
3,"Sebastian","Vettel","Deutsch"

如何做...

让我们看看这个食谱的步骤:

  1. 首先,我们将看到如何写指定的表:
import csv

with open('/tmp/table.csv', 'w', encoding='utf-8') as f:
    writer = csv.writer(f, quoting=csv.QUOTE_NONNUMERIC)
    writer.writerow(("ID","Name","Surname","Language"))
    writer.writerow((1,"Alessandro","Molina","Italian"))
    writer.writerow((2,"Mika","Häkkinen","Suomi"))
    writer.writerow((3,"Sebastian","Vettel","Deutsch"))
  1. table.csv文件将包含我们之前看到的相同的表,我们可以使用任何csv读取器将其读回。当您的 CSV 文件有标题时,最方便的是DictReader,它将使用标题作为键读取每一行到一个字典中:
with open('/tmp/table.csv', 'r', encoding='utf-8', newline='') as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(row)
  1. 迭代DictReader将消耗行,应该打印我们写的相同数据:
{'Surname': 'Molina', 'Language': 'Italian', 'ID': '1', 'Name': 'Alessandro'}
{'Surname': 'Häkkinen', 'Language': 'Suomi', 'ID': '2', 'Name': 'Mika'}
{'Surname': 'Vettel', 'Language': 'Deutsch', 'ID': '3', 'Name': 'Sebastian'}

还有更多...

CSV 文件是纯文本文件,有一些限制。例如,没有任何东西告诉我们如何编码换行符(\r\n\n),也没有告诉我们应该使用哪种编码,utf-8还是ucs-2。理论上,CSV 甚至没有规定必须是逗号分隔的;很多软件会用:;来分隔。

这就是为什么在读取 CSV 文件时,您应该注意提供给open函数的encoding。在我们的例子中,我们确定使用了utf8,因为我们自己写了文件,但在其他情况下,不能保证使用了任何特定的编码。

如果您不确定 CSV 文件的格式,可以尝试使用csv.Sniffer对象,当应用于 CSV 文件中包含的文本时,它将尝试检测使用的方言。

一旦方言被确定,您可以将其传递给csv.reader,告诉读取器使用该方言解析文件。

读写数据库

Python 通常被称为一个内置电池的语言,这要归功于它非常完整的标准库,它提供的最好的功能之一就是从一个功能齐全的关系型数据库中读取和写入。

Python 内置了SQLite库,这意味着我们可以保存和读取由SQLite存储的数据库文件。

使用起来非常简单,实际上大部分只涉及发送 SQL 进行执行。

如何做到...

对于这些食谱,步骤如下:

  1. 使用sqlite3模块,可以创建一个新的数据库文件,创建一个表,并向其中插入条目:
import sqlite3

with sqlite3.connect('/tmp/test.db') as db:
    try:
        db.execute('''CREATE TABLE people (
            id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 
            name TEXT, 
            surname TEXT, 
            language TEXT
        )''')
    except sqlite3.OperationalError:
        # Table already exists
        pass

    sql = 'INSERT INTO people (name, surname, language) VALUES (?, ?, ?)'
    db.execute(sql, ("Alessandro", "Molina", "Italian"))
    db.execute(sql, ("Mika", "Häkkinen", "Suomi"))
    db.execute(sql, ("Sebastian", "Vettel", "Deutsch"))
  1. sqlite3模块还提供了对cursors的支持,它允许我们将查询的结果从数据库流式传输到你自己的代码:
with sqlite3.connect('/tmp/test.db') as db:
    db.row_factory = sqlite3.Row
    cursor = db.cursor()
    for row in cursor.execute('SELECT * FROM people WHERE language 
                              != :language', 
                              {'language': 'Italian'}):
        print(dict(row))
  1. 前面的片段将打印存储在我们的数据库中的所有行作为dict,键与列名匹配,值与行中每个列的值匹配。
{'name': 'Mika', 'language': 'Suomi', 'surname': 'Häkkinen', 'id': 2}
{'name': 'Sebastian', 'language': 'Deutsch', 'surname': 'Vettel', 'id': 3}

它是如何工作的...

sqlite3.connect用于打开数据库文件;返回的对象可以用于对其执行任何查询,无论是插入还是选择。

然后使用.execute方法来运行任何 SQL 代码。要运行的 SQL 以纯字符串的形式提供。

在执行查询时,通常不应直接在 SQL 中提供值,特别是如果这些值是由用户提供的。

想象我们写了以下内容:

cursor.execute('SELECT * FROM people WHERE language != %s' % ('Italian',)):

如果用户提供的字符串是Italian" OR 1=1 OR ",而不是Italian,会发生什么?用户不会过滤结果,而是可以访问表的全部内容。很容易看出,如果查询是通过用户 ID 进行过滤,而表中包含来自多个用户的数据,这可能会成为安全问题。

此外,在executescript命令的情况下,用户将能够依赖相同的行为来实际执行任何 SQL 代码,从而将代码注入到我们自己的应用程序中。

因此,sqlite3提供了一种方法来传递参数到 SQL 查询并转义它们的内容,这样即使用户提供了恶意输入,也不会发生任何不好的事情。

我们的INSERT语句中的?占位符和我们的SELECT语句中的:language占位符正是为了这个目的:依赖于sqlite的转义行为。

这两者是等价的,你可以选择使用哪一个。一个适用于元组,而另一个适用于字典。

在从数据库中获取结果时,它们是通过Cursor提供的。你可以将光标视为从数据库流式传输数据的东西。每当你需要访问它时,才会读取每一行,从而避免将所有行加载到内存中并一次性传输它们的需要。

虽然这对于常见情况不是一个主要问题,但当读取大量数据时可能会出现问题,直到系统可能会因为消耗太多内存而终止你的 Python 脚本。

默认情况下,从光标读取行会返回元组,其中值的顺序与列的声明顺序相同。通过使用db.row_factory = sqlite3.Row,我们确保光标返回sqlite3.Row对象作为行。

它们比元组更方便,因为它们可以像元组一样进行索引(你仍然可以写row[0]),而且还支持通过列名进行访问(row['name'])。我们的片段依赖于sqlite3.Row对象可以转换为字典,以打印所有带有列名的行值。

还有更多...

sqlite3模块支持许多其他功能,例如事务、自定义类型和内存数据库。

自定义类型允许我们将结构化数据读取为 Python 对象,但我最喜欢的功能是支持内存数据库。

在编写软件的测试套件时,使用内存数据库非常方便。如果你编写依赖于sqlite3模块的软件,请确保编写连接到":memory:"数据库的测试。这将使你的测试更快,并且将避免在每次运行测试时在磁盘上堆积测试数据库文件。

第七章:算法

在本章中,我们将涵盖以下配方:

  • 搜索、排序、过滤-在排序的容器中进行高性能搜索

  • 获取任何可迭代对象的第 n 个元素-抓取任何可迭代对象的第 n 个元素,包括生成器

  • 分组相似项目-将可迭代对象分成相似项目的组

  • 合并-将来自多个可迭代对象的数据合并成单个可迭代对象

  • 展平列表的列表-将列表的列表转换为平面列表

  • 生成排列和-计算一组元素的所有可能排列

  • 累积和减少-将二进制函数应用于可迭代对象

  • 记忆-通过缓存函数加速计算

  • 从运算符到函数-如何保留对 Python 运算符的可调用引用

  • 部分-通过预应用一些函数来减少函数的参数数量

  • 通用函数-能够根据提供的参数类型改变行为的函数

  • 适当的装饰-适当地装饰函数以避免丢失其签名和文档字符串

  • 上下文管理器-在进入和退出代码块时自动运行代码

  • 应用可变上下文管理器-如何应用可变数量的上下文管理器

介绍

在编写软件时,有很多事情你会发现自己一遍又一遍地做,与你正在编写的应用程序类型无关。

除了您可能需要在不同应用程序中重用的整个功能(例如登录、日志记录和授权)之外,还有一堆可以在任何类型的软件中重用的小构建块。

本章将尝试收集一堆可以用作可重用片段的配方,以实现您可能需要独立于软件目的执行的非常常见的操作。

搜索、排序、过滤

在编程中查找元素是一个非常常见的需求。在容器中查找项目基本上是您的代码可能会执行的最频繁的操作,因此它非常重要,它既快速又可靠。

排序经常与搜索相关联,因为当你知道你的集合是排序的时,往往可以使用更智能的查找解决方案,并且排序意味着不断搜索和移动项目,直到它们按排序顺序排列。所以它们经常一起出现。

Python 具有内置函数,可以对任何类型的容器进行排序并在其中查找项目,甚至可以利用排序序列的函数。

如何做...

对于这个配方,需要执行以下步骤:

  1. 取以下一组元素:
>>> values = [ 5, 3, 1, 7 ]
  1. 通过in运算符可以在序列中查找元素:
>>> 5 in values
True
  1. 排序可以通过sorted函数完成:
>>> sorted_value = sorted(values)
>>> sorted_values
[ 1, 3, 5, 7 ]
  1. 一旦我们有了一个排序的容器,我们实际上可以使用bisect模块更快地找到包含的条目:
def bisect_search(container, value):
    index = bisect.bisect_left(container, value)
    return index < len(container) and container[index] == value
  1. bisect_search可以用来知道一个条目是否在列表中,就像in运算符一样:
>>> bisect_search(sorted_values, 5)
True
  1. 但是,优点是对于许多排序的条目来说可能会更快:
>>> import timeit
>>> values = list(range(1000))
>>> 900 in values
True
>>> bisect_search(values, 900)
True
>>> timeit.timeit(lambda: 900 in values)
timeit.timeit(lambda: bisect_search(values, 900))
13.61617108999053
>>> timeit.timeit(lambda: bisect_search(values, 900))
0.872136551013682

因此,在我们的示例中,bisect_search函数比普通查找快 17 倍。

它是如何工作的...

bisect模块使用二分搜索来查找已排序容器中元素的插入点。

如果元素存在于数组中,它的插入位置正是元素所在的位置(因为它应该正好在它所在的位置):

>>> values = [ 1, 3, 5, 7 ]
>>> bisect.bisect_left(values, 5)
2

如果元素缺失,它将返回下一个立即更大的元素的位置:

>>> bisect.bisect_left(values, 4)
2

这意味着我们将获得一个位置,即使对于不存在于我们的容器中的元素。这就是为什么我们将返回的位置处的元素与我们正在寻找的元素进行比较。如果两者不同,这意味着返回了最近的元素,因此元素本身没有找到。

出于同样的原因,如果未找到元素并且它大于容器中包含的最大值,则返回容器本身的长度(因为元素应该放在最后),因此我们还需要确保index < len(container)来检查不在容器中的元素。

还有更多...

到目前为止,我们只对条目本身进行了排序和查找,但在许多情况下,您将拥有复杂的对象,您有兴趣对对象的特定属性进行排序和搜索。

例如,您可能有一个人员列表,您想按其姓名排序:

class Person:
    def __init__(self, name, surname):
        self.name = name
        self.surname = surname
    def __repr__(self):
        return '<Person: %s %s>' % (self.name, self.surname)

people = [Person('Derek', 'Zoolander'),
          Person('Alex', 'Zanardi'),
          Person('Vito', 'Corleone')
          Person('Mario', 'Rossi')]

通过依赖sorted函数的key参数,可以对这些人按姓名进行排序,该参数指定应返回应对条目进行排序的值的可调用对象:

>>> sorted_people = sorted(people, key=lambda v: v.name)
[<Person: Alex Zanardi>, <Person: Derek Zoolander>, 
 <Person: Mario Rossi>, <Person: Vito Corleone>]

通过key函数进行排序比通过比较函数进行排序要快得多。因为key函数只需要对每个项目调用一次(然后结果被保留),而comparison函数需要在每次需要比较两个项目时一遍又一遍地调用。因此,如果计算我们应该排序的值很昂贵,key函数方法可以实现显着的性能改进。

现在的问题是,bisect不允许我们提供一个键,因此为了能够在 people 列表上使用bisect,我们首先需要构建一个keys列表,然后我们可以应用bisect

>>> keys = [p.name for p in people]
>>> bisect_search(keys, 'Alex')
True

这需要通过列表进行一次额外的传递来构建keys列表,因此只有在您必须查找多个条目(或多次查找相同的条目)时才方便,否则在列表上进行线性搜索将更快。

请注意,即使要使用in运算符,您也必须构建keys列表。因此,如果要搜索一个属性而不构建一个特定的列表,您将不得不依赖于filter或列表推导。

获取任何可迭代对象的第 n 个元素

随机访问容器是我们经常做的事情,而且没有太多问题。对于大多数容器类型来说,这甚至是一个非常便宜的操作。另一方面,当使用通用可迭代对象和生成器时,情况并不像我们期望的那样简单,通常最终会导致我们将它们转换为列表或丑陋的for循环。

Python 标准库实际上有办法使这变得非常简单。

如何做...

itertools模块是一个宝库,当处理可迭代对象时具有非常有价值的功能,并且只需很少的努力就可以获得任何可迭代对象的第 n 个项目:

import itertools

def iter_nth(iterable, nth):
    return next(itertools.islice(iterable, nth, nth+1))

给定一个随机的可迭代对象,我们可以使用它来获取我们想要的元素:

>>> values = (x for x in range(10))
>>> iter_nth(values, 4)
4

它是如何工作的...

itertools.islice函数能够获取任何可迭代对象的切片。在我们的特定情况下,我们需要的是从我们要查找的元素到下一个元素的切片。

一旦我们有了包含我们要查找的元素的切片,我们就需要从切片本身中提取该项。

由于islice作用于可迭代对象,它本身返回一个可迭代对象。这意味着我们可以使用next来消耗它,由于我们要查找的项实际上是切片的第一个项,因此使用next将正确返回我们要查找的项。

如果元素超出范围(例如,我们在仅有三个元素的情况下寻找第四个元素),则会引发StopIteration错误,我们可以像在普通列表中一样捕获它,就像对IndexError一样。

分组相似的项目

有时,您可能会面对一个具有多个重复条目的条目列表,并且您可能希望根据某种属性对相似的条目进行分组。

例如,这里是一个名字列表:

names = [('Alex', 'Zanardi'),
         ('Julius', 'Caesar'),
         ('Anakin', 'Skywalker'),
         ('Joseph', 'Joestar')]

我们可能希望构建一个所有名字以相同字符开头的人的组,这样我们就可以按字母顺序保留我们的电话簿,而不是让名字随机散落在这里和那里。

如何做...

itertools模块再次是一个非常强大的工具,它为我们提供了处理可迭代对象所需的基础:

import itertools

def group_by_key(iterable, key):
    iterable = sorted(iterable, key=key)
    return {k: list(g) for k,g in itertools.groupby(iterable, key)}

给定我们的姓名列表,我们可以应用一个键函数,该函数获取名称的第一个字符,以便所有条目都将按其分组:

>>> group_by_key(names, lambda v: v[0][0])
{'A': [('Alex', 'Zanardi'), ('Anakin', 'Skywalker')], 
 'J': [('Julius', 'Caesar'), ('Joseph', 'Joestar')]}

它是如何工作的...

这里的函数核心由itertools.groupby提供。

此函数将迭代器向前移动,抓取项目,并将其添加到当前组中。当面对具有不同键的项目时,将创建一个新组。

因此,实际上,它只会将共享相同键的附近条目分组:

>>> sample = [1, 2, 1, 1]
>>> [(k, list(g)) for k,g in itertools.groupby(sample)]
[(1, [1]), (2, [2]), (1, [1, 1])]

正如您所看到的,这里有三个组,而不是预期的两个,因为数字1的第一组立即被数字2中断,因此我们最终得到了两个不同的1组。

我们在对它们进行分组之前对元素进行排序,原因是排序可以确保相等的元素都靠在一起:

>>> sorted(sample)
[1, 1, 1, 2]

在那一点上,分组函数将创建正确数量的组,因为每个等效元素都有一个单独的块:

>>> sorted_sample = sorted(sample)
>>> [(k, list(g)) for k,g in itertools.groupby(sorted_sample)]
[(1, [1, 1, 1]), (2, [2])]

我们在现实生活中经常使用复杂的对象,因此group_by_key函数还接受key函数。这将说明应该根据哪个键对元素进行分组。

由于排序在排序时接受一个键函数,因此我们知道在分组之前所有元素都将根据该键进行排序,因此我们将返回正确数量的组。

最后,由于groupby返回一个迭代器或迭代器(顶级可迭代对象中的每个组也是一个迭代器),我们将每个组转换为列表,并构建一个字典,以便可以通过key轻松访问这些组。

压缩

Zipping 意味着附加两个不同的可迭代对象,以创建一个包含两者值的新对象。

当您有多个值轨道应该同时进行时,这是非常方便的。想象一下,您有名字和姓氏,您只想得到一个人的列表:

names = [ 'Sam', 'Axel', 'Aerith' ]
surnames = [ 'Fisher', 'Foley', 'Gainsborough' ]

如何做到这一点...

我们想要将名称和姓氏一起压缩:

>>> people = zip(names, surnames)
>>> list(people)
[('Sam', 'Fisher'), ('Axel', 'Foley'), ('Aerith', 'Gainsborough')]

它是如何工作的...

Zip 将创建一个新的可迭代对象,其中新创建的可迭代对象中的每个项目都是通过从所提供的可迭代对象中选择一个项目而生成的集合。

因此,result[0] = (i[0], j[0])result[1] = (i[1], j[1]),依此类推。如果ij的长度不同,它将在两者之一耗尽时立即停止。

如果要继续直到耗尽所提供的可迭代对象中最长的一个,而不是在最短的一个上停止,可以依靠itertools.zip_longest。已经耗尽的可迭代对象的值将填充默认值。

展平列表的列表

当您有多个嵌套列表时,通常需要遍历所有列表中包含的项目,而不太关心它们实际存储的深度。

假设您有这个列表:

values = [['a', 'b', 'c'],
          [1, 2, 3],
          ['X', 'Y', 'Z']]

如果您只想抓取其中的所有项目,那么您真的不想遍历列表中的列表,然后再遍历其中每一个项目。我们只想要叶子项目,我们根本不在乎它们在列表中的列表中。

如何做到这一点...

我们想要做的就是将所有列表连接成一个可迭代对象,该对象将产生项目本身,因为我们正在谈论迭代器,itertools模块具有正确的函数,可以让我们像单个迭代器一样链接所有列表:

>>> import itertools
>>> chained = itertools.chain.from_iterable(values)

生成的chained迭代器将在消耗时逐个产生底层项目:

>>> list(chained)
['a', 'b', 'c', 1, 2, 3, 'X', 'Y', 'Z']

它是如何工作的...

itertools.chain函数在您需要依次消耗多个可迭代对象时非常方便。

默认情况下,它接受这些可迭代对象作为参数,因此我们将不得不执行:

itertools.chain(values[0], values[1], values[2])

但是,为了方便起见,itertools.chain.from_iterable将链接提供的参数中包含的条目,而不必逐个显式传递它们。

还有更多...

如果您知道原始列表包含多少项,并且它们的大小相同,那么很容易应用反向操作。

我们已经知道可以使用zip从多个来源合并条目,所以我们实际上想要做的是将原始列表的元素一起压缩,这样我们就可以从chained返回到原始的列表列表:

>>> list(zip(chained, chained, chained))
[('a', 'b', 'c'), (1, 2, 3), ('X', 'Y', 'Z')]

在这种情况下,我们有三个项目列表,所以我们必须提供chained三次。

这是因为zip将顺序地从每个提供的参数中消耗一个条目。 因此,由于我们提供了相同的参数三次,实际上我们正在消耗前三个条目,然后是接下来的三个,然后是最后的三个。

如果chained是一个列表而不是一个迭代器,我们将不得不从列表中创建一个迭代器:

>>> chained = list(chained) 
>>> chained ['a', 'b', 'c', 1, 2, 3, 'X', 'Y', 'Z'] 
>>> ichained = iter(chained) 
>>> list(zip(ichained, ichained, ichained)) [('a', 'b', 'c'), (1, 2, 3), ('X', 'Y', 'Z')]

如果我们没有使用ichained而是使用原始的chained,结果将与我们想要的相去甚远:

>>> chained = list(chained)
>>> chained
['a', 'b', 'c', 1, 2, 3, 'X', 'Y', 'Z']
>>> list(zip(chained, chained, chained))
[('a', 'a', 'a'), ('b', 'b', 'b'), ('c', 'c', 'c'), 
 (1, 1, 1), (2, 2, 2), (3, 3, 3), 
 ('X', 'X', 'X'), ('Y', 'Y', 'Y'), ('Z', 'Z', 'Z')]

生成排列和组合

给定一组元素,如果您曾经感到有必要对这些元素的每个可能的排列执行某些操作,您可能会想知道生成所有这些排列的最佳方法是什么。

Python 在itertools模块中有各种函数,可帮助进行排列和组合,这些之间的区别并不总是容易理解,但一旦您调查它们的功能,它们就会变得清晰。

如何做...

笛卡尔积通常是在谈论组合和排列时人们所考虑的。

  1. 给定一组元素ABC,我们想要提取所有可能的两个元素的组合,AAABAC等等:
>>> import itertools
>>> c = itertools.product(('A', 'B', 'C'), repeat=2)
>>> list(c)
[('A', 'A'), ('A', 'B'), ('A', 'C'),
 ('B', 'A'), ('B', 'B'), ('B', 'C'), 
 ('C', 'A'), ('C', 'B'), ('C', 'C')]
  1. 如果您想要省略重复的条目(AABBCC),您可以只使用排列:
>>> c = itertools.permutations(('A', 'B', 'C'), 2)
>>> list(c)
[('A', 'B'), ('A', 'C'), 
 ('B', 'A'), ('B', 'C'), 
 ('C', 'A'), ('C', 'B')]
  1. 您甚至可能希望确保相同的夫妇不会发生两次(例如ABBA),在这种情况下,itertools.combinations可能是您要寻找的。
>>> c = itertools.combinations(('A', 'B', 'C'), 2)
>>> list(c)
[('A', 'B'), ('A', 'C'), ('B', 'C')]

因此,大多数需要组合值的需求都可以通过itertools模块提供的函数轻松解决。

累积和减少

列表推导和map是非常方便的工具,当您需要将函数应用于可迭代对象的所有元素并返回结果值时。 但这些工具大多用于应用一元函数并保留转换值的集合(例如将所有数字加1),但是如果您想要应用应该一次接收多个元素的函数,它们就不太合适。

减少和累积函数实际上是为了从可迭代对象中接收多个值并返回单个值(在减少的情况下)或多个值(在累积的情况下)。

如何做...

这个食谱的步骤如下:

  1. 减少的最简单的例子是对可迭代对象中的所有项目求和:
>>> values = [ 1, 2, 3, 4, 5 ]
  1. 这是可以通过sum轻松完成的事情,但是为了这个例子,我们将使用reduce
>>> import functools, operator
>>> functools.reduce(operator.add, values)
15
  1. 如果您不是要获得单个最终结果,而是要保留中间步骤的结果,您可以使用accumulate
>>> import itertools
>>> list(itertools.accumulate(values, operator.add))
[1, 3, 6, 10, 15]

还有更多...

accumulatereduce不仅限于数学用途。 虽然这些是最明显的例子,但它们是非常灵活的函数,它们的目的取决于它们将应用的函数。

例如,如果您有多行文本,您也可以使用reduce来计算所有文本的总和:

>>> lines = ['this is the first line',
...          'then there is one more',
...          'and finally the last one.']
>>> functools.reduce(lambda x, y: x + len(y), [0] + lines)
69

或者,如果您有多个需要折叠的字典:

>>> dicts = [dict(name='Alessandro'), dict(surname='Molina'),
...          dict(country='Italy')]
>>> functools.reduce(lambda d1, d2: {**d1, **d2}, dicts)
{'name': 'Alessandro', 'surname': 'Molina', 'country': 'Italy'}

这甚至是访问深度嵌套的字典的一种非常方便的方法:

>>> import operator
>>> nesty = {'a': {'b': {'c': {'d': {'e': {'f': 'OK'}}}}}}
>>> functools.reduce(operator.getitem, 'abcdef', nesty)
'OK'

记忆化

一遍又一遍地运行函数,避免调用该函数的成本可以大大加快生成的代码。

想象一下for循环或递归函数,也许必须调用该函数数十次。 如果它能够保留对函数的先前调用的已知结果,而不是调用它,那么它可以大大加快代码。

最常见的例子是斐波那契数列。 该序列是通过添加前两个数字来计算的,然后将第二个数字添加到结果中,依此类推。

这意味着在序列11235中,计算5需要我们计算3 + 2,这又需要我们计算2 + 1,这又需要我们计算1 + 1

以递归方式进行斐波那契数列是最明显的方法,因为它导致5 = fib(n3) + fib(n2),其中3 = fib(n2) + fib(n1),所以你可以很容易地看到我们必须计算fib(n2)两次。记忆fib(n2)的结果将允许我们只执行这样的计算一次,然后在下一次调用时重用结果。

如何做...

这是这个食谱的步骤:

  1. Python 提供了内置的 LRU 缓存,我们可以用它来进行记忆化:
import functools

@functools.lru_cache(maxsize=None)
def fibonacci(n):
    '''inefficient recursive version of Fibonacci number'''
    if n > 1:
        return fibonacci(n-1) + fibonacci(n-2)
    return n
  1. 然后我们可以使用该函数来计算整个序列:
fibonacci_seq = [fibonacci(n) for n in range(100)]
  1. 结果将是一个包含所有斐波那契数的列表,直到第 100 个:
>>> print(fibonacci_seq)
[0, 1, 1, 2, 3, 5, 8, 13, 21 ...

性能上的差异是巨大的。如果我们使用timeit模块来计时我们的函数,我们可以很容易地看到记忆化对性能有多大帮助。

  1. 当使用fibonacci函数的记忆化版本时,计算在不到一毫秒内结束:
>>> import timeit
>>> timeit.timeit(lambda: [fibonacci(n) for n in range(40)], number=1)
0.000033469987101
  1. 然后,如果我们移除@functools.lru_cache(),实现记忆化的时间会发生根本性的变化:
>>> timeit.timeit(lambda: [fibonacci(n) for n in range(40)], number=1)
89.14927123498637

所以很容易看出记忆化如何将性能从 89 秒提高到几分之一秒。

它是如何工作的...

每当调用函数时,functools.lru_cache都会保存返回的值以及提供的参数。

下一次调用函数时,参数将在保存的参数中搜索,如果找到,将提供先前返回的值,而不是调用函数。

实际上,这改变了调用我们的函数的成本,只是在字典中查找的成本。

所以第一次调用fibonacci(5)时,它被计算,然后下一次调用时它将什么都不做,之前存储的5的值将被返回。由于fibonacci(6)必须调用fibonacci(5)才能计算,很容易看出我们为任何fibonacci(n)提供了主要的性能优势,其中n>5

同样,由于我们想要整个序列,所以节省不仅仅是单个调用,而是在第一个需要记忆值的列表推导式之后的每次调用。

lru_cache函数诞生于最近最少使用LRU)缓存,因此默认情况下,它只保留最近的128个,但通过传递maxsize=None,我们可以将其用作标准缓存,并丢弃其中的 LRU 部分。所有调用将永远被缓存,没有限制。

纯粹针对斐波那契情况,你会注意到将maxsize设置为大于3的任何值都不会改变,因为每个斐波那契数只需要前两个调用就能计算。

函数到运算符

假设你想创建一个简单的计算器。第一步是解析用户将要写的公式以便执行它。基本公式由一个运算符和两个操作数组成,所以你实际上有一个函数和它的参数。

但是,考虑到+-等等,我们的解析器如何返回相关的函数呢?通常,为了对两个数字求和,我们只需写n1 + n2,但我们不能传递+本身来调用任何n1n2

这是因为+是一个运算符而不是一个函数,但在 CPython 中它仍然只是一个函数被执行。

如何做...

我们可以使用operator模块来获取一个可调用的对象,表示我们可以存储或传递的任何 Python 运算符:

import operator

operators = {
    '+': operator.add,
    '-': operator.sub,
    '*': operator.mul,
    '/': operator.truediv
}

def calculate(expression):
    parts = expression.split()

    try:
        result = int(parts[0])
    except:
        raise ValueError('First argument of expression must be numberic')

    operator = None
    for part in parts[1:]:
        try:
            num = int(part)
            if operator is None:
                raise ValueError('No operator proviede for the numbers')
        except ValueError:
            if operator:
                raise ValueError('operator already provided')
            operator = operators[part]
        else:
            result = operator(result, num)
            operator = None

    return result

我们的calculate函数充当一个非常基本的计算器(没有运算符优先级,实数,负数等):

>>> print(calculate('5 + 3'))
8
>>> print(calculate('1 + 2 + 3'))
6
>>> print(calculate('3 * 2 + 4'))
10

它是如何工作的...

因此,我们能够在operators字典中存储四个数学运算符的函数,并根据表达式中遇到的文本查找它们。

calculate中,表达式被空格分隔,因此5 + 3变成了['5','+','3']。一旦我们有了表达式的三个元素(两个操作数和运算符),我们只需遍历部分,当我们遇到+时,在operators字典中查找以获取应该调用的关联函数,即operator.add

operator模块包含了最常见的 Python 运算符的函数,从比较(operator.gt)到基于点的属性访问(operator.attrgetter)。

大多数提供的函数都是为了与mapsortedfilter等配对使用。

部分

我们已经知道可以使用map将一元函数应用于多个元素,并使用reduce将二元函数应用于多个元素。

有一整套函数接受 Python 中的可调用函数,并将其应用于一组项目。

主要问题是,我们想要应用的可调用函数可能具有稍有不同的签名,虽然我们可以通过将可调用函数包装到另一个适应签名的可调用函数中来解决问题,但如果你只想将函数应用到一组项目中,这并不是很方便。

例如,如果你想将列表中的所有数字乘以 3,没有一个函数可以将给定的参数乘以 3。

如何做...

我们可以很容易地将operator.mul调整为一元函数,然后将其传递给map以将其应用于整个列表:

>>> import functools, operator
>>>
>>> values = range(10)
>>> mul3 = functools.partial(operator.mul, 3)
>>> list(map(mul3, values))
[0, 3, 6, 9, 12, 15, 18, 21, 24, 27]

正如你所看到的,operator.mul被调用时带有3和项目作为其参数,因此返回item*3

它是如何工作的...

我们通过functools.partial创建了一个新的mul3可调用函数。这个可调用函数只是调用operator.mul,将3作为第一个参数传递,然后将提供给可调用函数的任何参数作为第二、第三等参数传递给operator.mul

因此,最终执行mul3(5)意味着operator.mul(3, 5)

这是因为functools.partial通过提供的函数硬编码提供的参数创建一个新函数。

当然,也可以传递关键字参数,这样我们就可以设置任何参数,而不是硬编码第一个参数。

然后,将生成的函数应用于所有数字通过map,这将导致创建一个新列表,其中包含所有从 0 到 10 的数字乘以 3。

通用函数

通用函数是标准库中我最喜欢的功能之一。Python 是一种非常动态的语言,通过鸭子类型,你经常能够编写适用于许多不同条件的代码(无论你收到的是列表还是元组),但在某些情况下,你确实需要根据接收到的输入有两个完全不同的代码库。

例如,我们可能希望有一个函数,以人类可读的格式打印所提供的字典内容,但我们也希望它在元组列表上正常工作,并报告不支持的类型的错误。

如何做...

functools.singledispatch装饰器允许我们基于参数类型实现通用分派:

from functools import singledispatch

@singledispatch
def human_readable(d):
    raise ValueError('Unsupported argument type %s' % type(d))

@human_readable.register(dict)
def human_readable_dict(d):
    for key, value in d.items():
        print('{}: {}'.format(key, value))

@human_readable.register(list)
@human_readable.register(tuple)
def human_readable_list(d):
    for key, value in d:
        print('{}: {}'.format(key, value))

调用这三个函数将正确地将请求分派到正确的函数:

>>> human_readable({'name': 'Tifa', 'surname': 'Lockhart'})
name: Tifa
surname: Lockhart

>>> human_readable([('name', 'Nobuo'), ('surname', 'Uematsu')])
name: Nobuo
surname: Uematsu

>>> human_readable(5)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    File "<stdin>", line 2, in human_readable
ValueError: Unsupported argument type <class 'int'>

它是如何工作的...

使用@singledispatch装饰的函数实际上被一个对参数类型的检查所取代。

每次调用human_readable.register都会记录到一个注册表中,指定每种参数类型应该使用哪个可调用函数:

>>> human_readable.registry
mappingproxy({
    <class 'list'>: <function human_readable_list at 0x10464da60>, 
    <class 'object'>: <function human_readable at 0x10464d6a8>, 
    <class 'dict'>: <function human_readable_dict at 0x10464d950>, 
    <class 'tuple'>: <function human_readable_list at 0x10464da60>
})

每当调用装饰的函数时,它将在注册表中查找参数的类型,并将调用转发到关联的函数以执行。

使用@singledispatch装饰的函数应该始终是通用实现,即在参数没有明确支持时应该使用的实现。

在我们的示例中,这只是抛出一个错误,但通常情况下,它将尝试提供在大多数情况下有效的实现。

然后,可以使用 @function.register 注册特定的实现,以覆盖主要函数无法覆盖的情况,或者实际实现行为,如果主要函数只是抛出错误。

适当的装饰

对于第一次面对装饰器的任何人来说,装饰器通常并不直接,但一旦你习惯了它们,它们就成为扩展函数行为或实现轻量级面向方面的编程的非常方便的工具。

但即使装饰器变得自然并成为日常开发的一部分,它们也有细微之处,直到您第一次面对它们时才会变得不明显。

当您应用 decorator 时,可能并不立即明显,但通过使用它们,您正在改变 decorated 函数的签名,直到函数本身的名称和文档都丢失:

def decorator(f):
    def _f(*args, **kwargs):
        return f(*args, **kwargs)
    return _f

@decorator
def sumtwo(a, b):
    """Sums a and b"""
    return a + back

sumtwo 函数被 decorator 装饰,但现在,如果我们尝试访问函数文档或名称,它们将不再可访问:

>>> print(sumtwo.__name__)
'_f'
>>> print(sumtwo.__doc__)
None

即使我们为 sumtwo 提供了文档字符串,并且我们确切知道它的名称是 sumtwo,我们仍需要确保我们的装饰被正确应用并保留原始函数的属性。

如何做...

对于这个配方,需要执行以下步骤:

  1. Python 标准库提供了一个 functools.wraps 装饰器,可以应用于装饰器,以使它们保留装饰函数的属性:
from functools import wraps

def decorator(f):
    @wraps(f)
    def _f(*args, **kwargs):
        return f(*args, **kwargs)
    return _f
  1. 在这里,我们将装饰器应用于一个函数:
@decorator
def sumthree(a, b):
    """Sums a and b"""
    return a + back
  1. 正如您所看到的,它将正确保留函数的名称和文档字符串:
>>> print(sumthree.__name__)
'sumthree'
>>> print(sumthree.__doc__)
'Sums a and b'

如果装饰的函数有自定义属性,这些属性也将被复制到新函数中。

还有更多...

functools.wraps 是一个非常方便的工具,尽最大努力确保装饰函数看起来与原始函数完全一样。

但是,虽然函数的属性可以很容易地被复制,但函数本身的签名并不容易复制。

因此,检查我们装饰的函数参数不会返回原始参数:

>>> import inspect
>>> inspect.getfullargspec(sumthree)
FullArgSpec(args=[], varargs='args', varkw='kwargs', defaults=None, 
            kwonlyargs=[], kwonlydefaults=None, annotations={})

因此,报告的参数只是 *args**kwargs 而不是 ab。要访问真正的参数,我们必须通过 __wrapped__ 属性深入到底层函数中:

>>> inspect.getfullargspec(sumthree.__wrapped__)
FullArgSpec(args=['a', 'b'], varargs=None, varkw=None, defaults=None, 
            kwonlyargs=[], kwonlydefaults=None, annotations={})

幸运的是,标准库为我们提供了一个 inspect.signature 函数来做到这一点:

>>> inspect.signature(sumthree)
(a, b)

因此,最好在想要检查函数的参数时依赖于 inspect.signature,以便支持装饰和未装饰的函数。

应用装饰也可能与其他装饰器冲突。最常见的例子是 classmethod

class MyClass(object):
    @decorator
    @classmethod
    def dosum(cls, a, b):
        return a+b

尝试装饰 classmethod 通常不起作用:

>>> MyClass.dosum(3, 3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    return f(*args, **kwargs)
TypeError: 'classmethod' object is not callable

您需要确保 @classmethod 始终是最后应用的装饰器,以确保它将按预期工作:

class MyClass(object):
    @classmethod
    @decorator
    def dosum(cls, a, b):
        return a+b

在那时,classmethod 将按预期工作:

>>> MyClass.dosum(3, 3)
6

Python 环境中有许多与装饰器相关的怪癖,因此有一些库试图为日常使用正确实现装饰。如果您不想考虑如何处理它们,您可能想尝试 wrapt 库,它将为您处理大多数装饰怪癖。

上下文管理器

装饰器可用于确保在进入和退出函数时执行某些操作,但在某些情况下,您可能希望确保在代码块的开头和结尾始终执行某些操作,而无需将其移动到自己的函数中或重写应该每次执行的部分。

上下文管理器存在是为了解决这个需求,将您必须一遍又一遍地重写的代码因 try:except:finally: 子句而被分解出来。

上下文管理器最常见的用法可能是关闭上下文管理器,它确保文件在开发人员完成使用它们后关闭,但标准库使编写新的上下文管理器变得很容易。

如何做...

对于这个配方,需要执行以下步骤:

  1. contextlib提供了与上下文管理器相关的功能,contextlib.contextmanager可以使编写上下文管理器变得非常容易:
@contextlib.contextmanager
def logentrance():
    print('Enter')
    yield
    print('Exit')
  1. 然后创建的上下文管理器可以像任何其他上下文管理器一样使用:
>>> with logentrance():
>>>    print('This is inside')
Enter
This is inside
Exit
  1. 在包装块内引发的异常将传播到上下文管理器,因此可以使用标准的try:except:finally:子句来处理它们并进行适当的清理:
@contextlib.contextmanager
def logentrance():
    print('Enter')
    try:
        yield
    except:
        print('Exception')
        raise
    finally:
        print('Exit')
  1. 更改后的上下文管理器将能够记录异常,而不会干扰异常的传播。
>>> with logentrance():
        raise Exception('This is an error')
Enter
Exception
Exit
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
        raise Exception('This is an error')
Exception: This is an error

应用可变上下文管理器

在使用上下文管理器时,必须依赖with语句来应用它们。虽然可以通过用逗号分隔它们来在一个语句中应用多个上下文管理器,但是要应用可变数量的上下文管理器并不那么容易:

@contextlib.contextmanager
def first():
    print('First')
    yield

@contextlib.contextmanager
def second():
    print('Second')
    yield

在编写代码时必须知道要应用的上下文管理器:

>>> with first(), second():
>>>     print('Inside')
First
Second
Inside

但是如果有时我们只想应用first上下文管理器,有时又想同时应用两个呢?

如何做...

contextlib.ExitStack有各种用途,其中之一是允许我们对一个块应用可变数量的上下文管理器。

例如,我们可能只想在循环中打印偶数时同时应用两个上下文管理器:

from contextlib import ExitStack

for n in range(5):
    with ExitStack() as stack:
        stack.enter_context(first())
        if n % 2 == 0:
            stack.enter_context(second())
        print('NUMBER: {}'.format(n))

结果将是second只被添加到上下文中,因此仅对偶数调用:

First
Second
NUMBER: 0
First
NUMBER: 1
First
Second
NUMBER: 2
First
NUMBER: 3
First
Second
NUMBER: 4

正如你所看到的,对于13,只有First被打印出来。

当通过ExitStack上下文管理器声明的上下文退出时,ExitStack中注册的所有上下文管理器也将被退出。

第八章:密码学

本章中,我们将涵盖以下食谱:

  • 要求密码-在终端软件中要求密码时,请确保不要泄漏它。

  • 哈希密码-如何存储密码而不会泄漏风险?

  • 验证文件的完整性-如何检查通过网络传输的文件是否已损坏。

  • 验证消息的完整性-如何检查您发送给另一个软件的消息是否已被更改。

介绍

虽然加密通常被认为是一个复杂的领域,但它是我们作为软件开发人员日常生活的一部分,或者至少应该是,以确保我们的代码库具有最低的安全级别。

本章试图覆盖大多数您每天都必须面对的常见任务的食谱,这些任务可以帮助使您的软件对攻击具有抵抗力。

虽然用 Python 编写的软件很难受到利用,比如缓冲区溢出(除非解释器或您依赖的编译库中存在错误),但仍然有很多情况可能会泄露必须保密的信息。

要求密码

在基于终端的程序中,通常会向用户询问密码。通常不建议从命令选项中这样做,因为在类 Unix 系统上,可以通过运行ps命令获取进程列表的任何人都可以看到它们,并且可以通过运行history命令获取最近执行的命令列表。

虽然有方法可以调整命令参数以将其隐藏在进程列表中,但最好还是交互式地要求密码,以便不留下任何痕迹。

但是,仅仅交互地要求它们是不够的,除非您还确保在输入时不显示它们,否则任何看着您屏幕的人都可以获取您的所有密码。

如何做...

幸运的是,Python 标准库提供了一种从提示中输入密码而不显示它们的简单方法:

>>> import getpass
>>> pwd = getpass.getpass()
Password: 
>>> print(pwd)
'HelloWorld'

它是如何工作的...

getpass.getpass函数将在大多数系统上使用termios库来禁用用户输入的字符的回显。为了避免干扰其他应用程序输入,它将在终端的新文件描述符中完成。

在不支持此功能的系统上,它将使用更基本的调用直接从sys.stdin读取字符而不回显它们。

哈希密码

避免以明文存储密码是一种已知的最佳实践,因为软件通常只需要检查用户提供的密码是否正确,并且可以存储密码的哈希值并与提供的密码的哈希值进行比较。如果两个哈希值匹配,则密码相等;如果不匹配,则提供的密码是错误的。

存储密码是一个非常标准的做法,通常它们被存储为哈希加一些盐。盐是一个随机生成的字符串,它在哈希之前与密码连接在一起。由于是随机生成的,它确保即使相同密码的哈希也会得到不同的结果。

Python 标准库提供了一套相当完整的哈希函数,其中一些非常适合存储密码。

如何做...

Python 3 引入了密钥派生函数,特别适用于存储密码。提供了pbkdf2scrypt。虽然scrypt更加抗攻击,因为它既消耗内存又消耗 CPU,但它只能在提供 OpenSSL 1.1+的系统上运行。而pbkdf2可以在任何系统上运行,在最坏的情况下会使用 Python 提供的后备。

因此,从安全性的角度来看,scrypt更受青睐,但由于其更广泛的可用性以及自 Python 3.4 以来就可用的事实,我们将依赖于pbkdf2scrypt仅在 Python 3.6+上可用):

import hashlib, binascii, os

def hash_password(password):
    """Hash a password for storing."""
    salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii')
    pwdhash = hashlib.pbkdf2_hmac('sha512', password.encode('utf-8'), 
                                salt, 100000)
    pwdhash = binascii.hexlify(pwdhash)
    return (salt + pwdhash).decode('ascii')

def verify_password(stored_password, provided_password):
    """Verify a stored password against one provided by user"""
    salt = stored_password[:64]
    stored_password = stored_password[64:]
    pwdhash = hashlib.pbkdf2_hmac('sha512', 
                                  provided_password.encode('utf-8'), 
                                  salt.encode('ascii'), 
                                  100000)
    pwdhash = binascii.hexlify(pwdhash).decode('ascii')
    return pwdhash == stored_password

这两个函数可以用来对用户提供的密码进行哈希处理,以便存储在磁盘或数据库中(hash_password),并在用户尝试重新登录时验证密码是否与存储的密码匹配(verify_password):

>>> stored_password = hash_password('ThisIsAPassWord')
>>> print(stored_password)
cdd5492b89b64f030e8ac2b96b680c650468aad4b24e485f587d7f3e031ce8b63cc7139b18
aba02e1f98edbb531e8a0c8ecf971a61560b17071db5eaa8064a87bcb2304d89812e1d07fe
bfea7c73bda8fbc2204e0407766197bc2be85eada6a5
>>> verify_password(stored_password, 'ThisIsAPassWord')
True
>>> verify_password(stored_password, 'WrongPassword')
False

工作原理...

这里涉及两个函数:

  • hash_password:以安全的方式对提供的密码进行编码,以便存储在数据库或文件中

  • verify_password:给定一个编码的密码和用户提供的明文密码,它验证提供的密码是否与编码的(因此已保存的)密码匹配。

hash_password实际上做了多件事情;它不仅仅是对密码进行哈希处理。

它的第一件事是生成一些随机盐,应该添加到密码中。这只是从os.urandom读取的一些随机字节的sha256哈希。然后提取哈希盐的字符串表示形式作为一组十六进制数字(hexdigest)。

然后将盐提供给pbkdf2_hmac,与密码本身一起进行哈希处理,以随机化的方式哈希密码。由于pbkdf2_hmac需要字节作为输入,因此两个字符串(密码和盐)先前被编码为纯字节。盐被编码为纯 ASCII,因为哈希的十六进制表示只包含 0-9 和 A-F 字符。而密码被编码为utf-8,它可能包含任何字符。(有人的密码里有表情符号吗?)

生成的pbkdf2是一堆字节,因为我们想要将其存储到数据库中;我们使用binascii.hexlify将一堆字节转换为它们的十六进制表示形式的字符串格式。hexlify是一种方便的方法,可以将字节转换为字符串而不丢失数据。它只是将所有字节打印为两个十六进制数字,因此生成的数据将比原始数据大一倍,但除此之外,它与转换后的数据完全相同。

最后,该函数将哈希与其盐连接在一起。因为我们知道sha256哈希的hexdigest始终是 64 个字符长。通过将它们连接在一起,我们可以通过读取结果字符串的前 64 个字符来重新获取盐。

这将允许verify_password验证密码,并验证是否需要使用用于编码的盐。

一旦我们有了密码,verify_password就可以用来验证提供的密码是否正确。因此,它需要两个参数:哈希密码和应该被验证的新密码。

verify_password的第一件事是从哈希密码中提取盐(记住,我们将它放在hash_password结果字符串的前 64 个字符中)。

然后将提取的盐和密码候选者提供给pbkdf2_hmac,计算它们的哈希,然后将其转换为一个字符串,使用binascii.hexlify。如果生成的哈希与先前存储的密码的哈希部分匹配(盐后的字符),这意味着这两个密码匹配。

如果结果哈希不匹配,这意味着提供的密码是错误的。正如你所看到的,我们非常重要的是将盐和密码一起提供,因为我们需要它来验证密码,不同的盐会导致不同的哈希,因此我们永远无法验证密码。

验证文件的完整性

如果你曾经从公共网络下载过文件,你可能会注意到它们的 URL 经常是这种形式:http://files.host.com/somefile.tar.gz#md5=3b3f5b2327421800ef00c38ab5ad81a6

这是因为下载可能出错,你得到的数据可能部分损坏。因此 URL 包含了一个 MD5 哈希,你可以使用md5sum工具来验证下载的文件是否正确。

当你从 Python 脚本下载文件时也是一样。如果提供的文件有一个 MD5 哈希用于验证,你可能想要检查检索到的文件是否有效,如果不是,那么你可以重新尝试下载它。

如何做到...

hashlib中,有多种受支持的哈希算法,而且可能最常见的是md5,因此我们可以依靠hashlib来验证我们下载的文件:

import hashlib

def verify_file(filepath, expectedhash, hashtype='md5'):
    with open(filepath, 'rb') as f:
        try:
            filehash = getattr(hashlib, hashtype)()
        except AttributeError:
            raise ValueError(
                'Unsupported hashing type %s' % hashtype
            ) from None

        while True:
            data = f.read(4096)
            if not data:
                break
            filehash.update(data)

    return filehash.hexdigest() == expectedhash

然后我们可以使用verify_file下载并验证我们的文件。

例如,我可能从Python Package Index (PyPI)下载wrapt分发包,并且我可能想要验证它是否已正确下载。

文件名将是wrapt-1.10.11.tar.gz#sha256=d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6,我可以运行我的verify_file函数:

>>> verify_file(
...     'wrapt-1.10.11.tar.gz', 
...     'd4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6',
...     'sha256
... )
True

工作原理...

该函数的第一步是以二进制模式打开文件。由于所有哈希函数都需要字节,而且我们甚至不知道文件的内容,因此以二进制模式读取文件是最方便的解决方案。

然后,它检查所请求的哈希算法是否在hashlib中可用。通过getattr通过尝试抓取hashlib.md5hashlib.sha256等来完成。如果不支持该算法,它将不是有效的hashlib属性(因为它不会存在于模块中),并且将抛出AttributeError。为了使这些更容易理解,它们被捕获并引发了一个新的ValueError,清楚地说明该算法不受支持。

文件打开并验证算法后,将创建一个空哈希(请注意,在getattr之后,括号将导致返回的哈希的创建)。

我们从一个空的开始,因为文件可能非常大,我们不想一次性读取完整的文件并将其一次性传递给哈希函数。

相反,我们从一个空哈希开始,并且以 4 KB 的块读取文件,然后将每个块馈送到哈希算法以更新哈希。

最后,一旦我们计算出哈希,我们就会获取其十六进制数表示,并将其与函数提供的哈希进行比较。

如果两者匹配,那么文件就是正确下载的。

验证消息的完整性

在通过公共网络或对其他用户和系统可访问的存储发送消息时,我们需要知道消息是否包含原始内容,或者是否被任何人拦截和修改。

这是一种典型的中间人攻击形式,它可以修改我们内容中的任何内容,这些内容存储在其他人也可以阅读的地方,例如未加密的网络或共享系统上的磁盘。

HMAC 算法可用于保证消息未从其原始状态更改,并且经常用于签署数字文档以确保其完整性。

HMAC 的一个很好的应用场景可能是密码重置链接;这些链接通常包括有关应该重置密码的用户的参数:myapp.com/reset-password?user=myuser@email.net

但是,任何人都可以替换用户参数并重置其他人的密码。因此,我们希望确保我们提供的链接实际上没有被修改,因为它是通过附加 HMAC 发送的。

这将导致类似于以下内容:myapp.com/reset-password?user=myuser@email.net&signature=8efc6e7161004cfb09d05af69cc0af86bb5edb5e88bd477ba545a9929821f582

此外,任何尝试修改用户都将使签名无效,从而使其无法重置其他人的密码。

另一个用例是部署 REST API 以验证和验证请求。亚马逊网络服务使用 HMAC 作为其网络服务的身份验证系统。注册时,会为您提供访问密钥和密钥。您发出的任何请求都必须使用 HMAC 进行哈希处理,使用密钥来确保您实际上是请求中所述的用户(因为您拥有其密钥),并且请求本身没有以任何方式更改,因为它的详细信息也使用 HMAC 进行了哈希处理。

HMAC 签名经常涉及到软件必须向自身发送消息或从拥有密钥的验证合作伙伴接收消息的情况。

如何做...

对于这个示例,需要执行以下步骤:

  1. 标准库提供了一个 hmac 模块,结合 hashlib 提供的哈希函数,可以用于计算任何提供的消息的身份验证代码:
import hashlib, hmac, time

def compute_signature(message, secret):
    message = message.encode('utf-8')
    timestamp = str(int(time.time()*100)).encode('ascii')

    hashdata = message + timestamp
    signature = hmac.new(secret.encode('ascii'), 
                         hashdata, 
                         hashlib.sha256).hexdigest()
    return {
        'message': message,
        'signature': signature,
        'timestamp': timestamp
    }

def verify_signature(signed_message, secret):
    timestamp = signed_message['timestamp']
    expected_signature = signed_message['signature']
    message = signed_message['message']

    hashdata = message + timestamp
    signature = hmac.new(secret.encode('ascii'), 
                         hashdata, 
                         hashlib.sha256).hexdigest()
    return signature == expected_signature
  1. 然后,我们的函数可以用来计算签名消息,并且我们可以检查签名消息是否被以任何方式更改:
>>> signed_msg = compute_signature('Hello World', 'very_secret')
>>> verify_signature(signed_msg, 'very_secret')
True
  1. 如果尝试更改签名消息的消息字段,它将不再有效,只有真实的消息才能匹配签名:
>>> signed_msg['message'] = b'Hello Boat'
>>> verify_signature(signed_msg, 'very_secret')
False

工作原理...

我们的目的是确保任何给定的消息都不能以任何方式更改,否则将使附加到消息的签名无效。

因此,compute_signature 函数在给定消息和私有密钥的情况下,返回发送到接收方时签名消息应包括的所有数据。发送的数据包括消息本身、签名和时间戳。时间戳包括在内,因为在许多情况下,确保消息是最近的消息是一个好主意。如果您收到使用 HMAC 签名的 API 请求或刚刚设置的 cookie,您可能希望确保您处理的是最近的消息,而不是一个小时前发送的消息。时间戳无法被篡改,因为它与消息一起包括在签名中,其存在使得攻击者更难猜测密钥,因为两个相同的消息将导致有两个不同的签名,这要归功于时间戳。

一旦消息和时间戳已知,compute_signature 函数将它们与密钥一起传递给 hmac.new,以计算签名本身。为了方便起见,签名被表示为组成十六进制数字的字符,这些数字表示签名由哪些字节组成。这确保它可以作为纯文本在 HTTP 标头或类似方式中传输。

一旦我们得到了由 compute_signature 返回的签名消息,可以将其存储在某个地方,并在加载时使用 verify_signature 来检查它是否被篡改。

verify_signature 函数执行与 compute_signature 相同的步骤。签名的消息包括消息本身、时间戳和签名。因此,verify_signature 获取消息和时间戳,并与密钥结合计算签名。如果计算得到的签名与签名消息中提供的签名匹配,这意味着消息没有被以任何方式更改。否则,即使对消息或时间戳进行微小更改,签名也将无效。

第九章:并发

在本章中,我们将介绍以下食谱:

  • 线程池-通过线程池并发运行任务

  • 协程-通过协程交错执行代码

  • 进程-将工作分派给多个子进程

  • 期货-期货代表将来会完成的任务

  • 计划任务-设置在特定时间运行的任务,或每隔几秒运行一次

  • 在进程之间共享数据-管理可在多个进程中访问的变量

介绍

并发是在相同的时间段内运行两个或多个任务的能力,无论它们是并行的还是不并行的。Python 提供了许多工具来实现并发和异步行为:线程、协程和进程。虽然其中一些由于设计(协程)或全局解释器锁(线程)的原因不允许真正的并行,但它们非常易于使用,并且可以用于执行并行 I/O 操作或以最小的工作量交错函数。当需要真正的并行时,Python 中的多进程足够容易,可以成为任何类型软件的可行解决方案。

本章将介绍在 Python 中实现并发的最常见方法,将向您展示如何执行异步任务,这些任务将在后台等待特定条件,并且如何在进程之间共享数据。

线程池

线程在软件中实现并发的历史上一直是最常见的方式。

理论上,当系统允许时,这些线程可以实现真正的并行,但在 Python 中,全局解释器锁(GIL)不允许线程实际上利用多核系统,因为锁将允许单个 Python 操作在任何给定时间进行。

因此,线程在 Python 中经常被低估,但实际上,即使涉及 GIL,它们也可以是运行 I/O 操作的非常方便的解决方案。

在使用协程时,我们需要一个run循环和一些自定义代码来确保 I/O 操作可以并行进行。使用线程,我们可以在线程中运行任何类型的函数,如果该函数进行某种 I/O 操作,例如从套接字或磁盘中读取,其他线程将同时进行。

线程的一个主要缺点是产生它们的成本。这经常被认为是协程可能是更好的解决方案的原因之一,但是有一种方法可以避免在需要线程时支付成本:ThreadPool

ThreadPool是一组线程,通常在应用程序启动时启动,并且一直保持空闲,直到您实际上有一些工作要分派。这样,当我们有一个任务想要在单独的线程中运行时,我们只需将其发送到ThreadPoolThreadPool将把它分配给它拥有的所有线程中的第一个可用线程。由于这些线程已经在那里运行,我们不必每次有工作要做时都支付产生线程的成本。

如何做...

此食谱的步骤如下:

  1. 为了展示ThreadPool的工作原理,我们需要两个我们想要同时运行的操作。一个将从网络中获取一个 URL,这可能需要一些时间:
def fetch_url(url):
    """Fetch content of a given url from the web"""
    import urllib.request
    response = urllib.request.urlopen(url)
    return response.read()
  1. 另一个将只是等待给定条件为真,一遍又一遍地循环,直到完成:
def wait_until(predicate):
    """Waits until the given predicate returns True"""
    import time
    seconds = 0
    while not predicate():
        print('Waiting...')
        time.sleep(1.0)
        seconds += 1
    print('Done!')
    return seconds
  1. 然后我们将只下载https://httpbin.org/delay/3,这将需要 3 秒,并且同时等待下载完成。

  2. 为此,我们将在一个ThreadPool(四个线程)中运行这两个任务,并等待它们都完成:

>>> from multiprocessing.pool import ThreadPool
>>> pool = ThreadPool(4)
>>> t1 = pool.apply_async(fetch_url, args=('https://httpbin.org/delay/3',))
>>> t2 = pool.apply_async(wait_until, args=(t1.ready, ))
Waiting...
>>> pool.close()
>>> pool.join()
Waiting...
Waiting...
Waiting...
Done!
>>> print('Total Time:', t2.get())
Total Time: 4
>>> print('Content:', t1.get())
Content: b'{"args":{},"data":"","files":{},"form":{},
            "headers":{"Accept-Encoding":"identity",
            "Connection":"close","Host":"httpbin.org",
            "User-Agent":"Python-urllib/3.5"},
            "origin":"99.199.99.199",
            "url":"https://httpbin.org/delay/3"}\n'

它是如何工作的...

ThreadPool由两个主要组件组成:一堆线程和一堆队列。在创建池时,一些协调线程与您在池初始化时指定的工作线程一起启动。

工作线程将负责实际运行分派给它们的任务,而编排线程将负责管理工作线程,例如在池关闭时告诉它们退出,或在它们崩溃时重新启动它们。

如果没有提供工作线程的数量,TaskPool将会启动与系统核心数量相同的线程,由os.cpu_count()返回。

一旦线程启动,它们将等待从包含要完成的工作的队列中获取内容。一旦队列有条目,工作线程将唤醒并消耗它,开始工作。

工作完成后,工作及其结果将放回结果队列,以便等待它们的人可以获取它们。

因此,当我们创建TaskPool时,实际上启动了四个工作线程,这些线程开始等待从任务队列中获取工作:

>>> pool = ThreadPool(4)

然后,一旦我们为TaskPool提供了工作,实际上我们将两个函数排入任务队列,一旦有工作线程可用,它就会获取其中一个并开始运行:

>>> t1 = pool.apply_async(fetch_url, args=('https://httpbin.org/delay/3',))

与此同时,TaskPool返回一个AsyncResult对象,该对象有两个有趣的方法:AsyncResult.ready()告诉我们结果是否准备好(任务完成),AsyncResult.get()在结果可用时返回结果。

我们排队的第二个函数是等待特定谓词为True的函数,在这种情况下,我们提供了 t1.ready,这是先前AsyncResult的就绪方法:

>>> t2 = pool.apply_async(wait_until, args=(t1.ready, ))

这意味着第二个任务将在第一个任务完成后完成,因为它将等待直到t1.ready() == True

一旦这两个任务都在运行,我们告诉pool我们没有更多事情要做,这样它就可以在完成任务后退出:

>>> pool.close()

然后我们等待pool退出:

>>> pool.join()

这样,我们将等待两个任务都完成,然后退出pool启动的所有线程。

一旦我们知道所有任务都已完成(因为pool.join()返回),我们可以获取结果并打印它们:

>>> print('Total Time:', t2.get())
Total Time: 4
>>> print('Content:', t1.get())
Content: b'{"args":{},"data":"","files":{},"form":{},
            "headers":{"Accept-Encoding":"identity",
            "Connection":"close","Host":"httpbin.org",
            "User-Agent":"Python-urllib/3.5"},
            "origin":"99.199.99.199",
            "url":"https://httpbin.org/delay/3"}\n'

如果我们有更多工作要做,我们将避免运行pool.close()pool.join()方法,这样我们就可以将更多工作发送给TaskPool,一旦有空闲线程,工作就会完成。

还有更多...

当您有多个条目需要反复应用相同操作时,ThreadPool特别方便。假设您有一个包含四个 URL 的列表需要下载:

urls = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/2",
    "https://httpbin.org/delay/3",
    "https://httpbin.org/delay/4"
]

在单个线程中获取它们将需要很长时间:

def fetch_all_urls():
    contents = []
    for url in urls:
        contents.append(fetch_url(url))
    return contents

我们可以通过timeit模块运行函数来测试时间:

>>> import timeit
>>> timeit.timeit(fetch_all_urls, number=1)
12.116707602981478

如果我们可以使用单独的线程来执行每个函数,那么获取所有提供的 URL 只需要最慢的一个的时间,因为下载将同时进行。

ThreadPool实际上为我们提供了map方法,该方法正是这样做的:它将一个函数应用于一系列参数:

def fetch_all_urls_theraded():
    pool = ThreadPool(4)
    return pool.map(fetch_url, urls)

结果将是一个包含每次调用返回结果的列表,我们可以轻松测试这将比我们原始示例快得多:

>>> timeit.timeit(fetch_all_urls_theraded, number=1)
4.660976745188236

协程

线程是大多数语言和用例中实现并发的最常见方式,但它们在成本方面很昂贵,而且虽然ThreadPool在涉及数千个线程的情况下可能是一个很好的解决方案,但通常不合理涉及数千个线程。特别是在涉及长期 I/O 时,您可能会轻松地达到数千个并发运行的操作(考虑一下 HTTP 服务器可能需要处理的并发 HTTP 请求数量),其中大多数任务将无所事事,只是大部分时间等待来自网络或磁盘的数据。

在这些情况下,异步 I/O 是首选的方法。与同步阻塞 I/O 相比,你的代码坐在那里等待读取或写入操作完成,异步 I/O 允许需要数据的任务启动读取操作,切换到做其他事情,一旦数据可用,就返回到原来的工作。

在某些情况下,可用数据的通知可能以信号的形式到来,这将中断并发运行的代码,但更常见的是,异步 I/O 是通过使用选择器(如selectpollepoll)和一个事件循环来实现的,该事件循环将在选择器通知数据可用时立即恢复等待数据的函数。

这实际上导致了交错运行的功能,能够运行一段时间,达到需要一些 I/O 的时候,将控制权传递给另一个函数,只要它需要执行一些 I/O,就会立即返回。通过暂停和恢复它们的执行来交错执行的函数称为协程,因为它们是协作运行的。

如何做...

在 Python 中,协程是通过async def语法实现的,并通过asyncio事件循环执行。

例如,我们可以编写一个函数,运行两个协程,从给定的秒数开始倒计时,并打印它们的进度。这将很容易让我们看到这两个协程是同时运行的,因为我们会看到一个协程的输出与另一个协程的输出交错出现:

import asyncio

async def countdown(identifier, n):
    while n > 0:
        print('left:', n, '({})'.format(identifier))
        await asyncio.sleep(1)
        n -= 1

async def main():
    await asyncio.wait([
        countdown("A", 2),
        countdown("B", 3)
    ])

一旦创建了一个事件循环,并在其中运行main,我们将看到这两个函数在运行:

>>> loop = asyncio.get_event_loop()
>>> loop.run_until_complete(main())
left: 2 (A)
left: 3 (B)
left: 1 (A)
left: 2 (B)
left: 1 (B)

一旦执行完成,我们可以关闭事件循环,因为我们不再需要它:

>>> loop.close()

它是如何工作的...

我们协程世界的核心是事件循环。没有事件循环,就不可能运行协程(或者说,会变得非常复杂),所以我们代码的第一件事就是创建一个事件循环:

>>> loop = asyncio.get_event_loop()

然后我们要求事件循环等待直到提供的协程完成:

loop.run_until_complete(main())

main协程只启动两个countdown协程并等待它们完成。这是通过使用await来完成的,而asyncio.wait函数负责等待一堆协程:

await asyncio.wait([
    countdown("A", 2),
    countdown("B", 3)
])

await在这里很重要,因为我们在谈论协程,所以除非它们被明确等待,否则我们的代码会立即向前移动,因此,即使我们调用了asyncio.wait,我们也不会等待。

在这种情况下,我们正在等待两个倒计时完成。第一个倒计时将从2开始,并由字符A标识,而第二个倒计时将从3开始,并由B标识。

countdown函数本身非常简单。它只是一个永远循环并打印剩下多少时间要等待的函数。

在每个循环之间等待一秒钟,这样就等待了预期的秒数:

await asyncio.sleep(1)

你可能会想知道为什么我们使用asyncio.sleep而不是time.sleep,原因是,当使用协程时,你必须确保每个其他会阻塞的函数也是一个协程。这样,你就知道在你的函数被阻塞时,你会让其他协程继续向前移动。

通过使用asyncio.sleep,我们让事件循环在第一个协程等待时推进另一个countdown函数,因此,我们正确地交错执行了这两个函数。

这可以通过检查输出来验证。当使用asyncio.sleep时,输出将在两个函数之间交错出现:

left 2 (A)
left 3 (B)
left 1 (A)
left 2 (B)
left 1 (B)

当使用time.sleep时,第一个协程必须完全完成,然后第二个协程才能继续向前移动:

left 2 (A)
left 1 (A)
left 3 (B)
left 2 (B)
left 1 (B)

因此,使用协程时的一个一般规则是,每当要调用会阻塞的东西时,确保它也是一个协程,否则你将失去协程的并发属性。

还有更多...

我们已经知道协程最重要的好处是事件循环能够在它们等待 I/O 操作时暂停它们的执行,以便让其他协程继续。虽然目前没有支持协程的 HTTP 协议的内置实现,但很容易推出一个后备版本来重现我们同时下载网站的示例以跟踪它花费了多长时间。

至于ThreadPool示例,我们将需要wait_until函数,它将等待任何给定的谓词为真:

async def wait_until(predicate):
    """Waits until the given predicate returns True"""
    import time
    seconds = 0
    while not predicate():
        print('Waiting...')
        await asyncio.sleep(1)
        seconds += 1
    print('Done!')
    return seconds

我们还需要一个fetch_url函数来下载 URL 的内容。由于我们希望这个函数作为协程运行,所以我们不能依赖urllib,否则它会永远阻塞而不是将控制权传递回事件循环。因此,我们将不得不使用asyncio.open_connection来读取数据,这将在纯 TCP 级别工作,因此需要我们自己实现 HTTP 支持:

async def fetch_url(url):
    """Fetch content of a given url from the web"""
    url = urllib.parse.urlsplit(url)
    reader, writer = await asyncio.open_connection(url.hostname, 80)
    req = ('GET {path} HTTP/1.0\r\n'
           'Host: {hostname}\r\n'
           '\r\n').format(path=url.path or '/', hostname=url.hostname)
    writer.write(req.encode('latin-1'))
    while True:
        line = await reader.readline()
        if not line.strip():
            # Read until the headers, from here on is the actualy response.
            break
    return await reader.read()

在这一点上,可以交错两个协程,看到下载与等待同时进行,并且在预期时间内完成:

>>> loop = asyncio.get_event_loop()
>>> t1 = asyncio.ensure_future(fetch_url('http://httpbin.org/delay/3'))
>>> t2 = asyncio.ensure_future(wait_until(t1.done))
>>> loop.run_until_complete(t2)
Waiting...
Waiting...
Waiting...
Waiting...
Done!
>>> loop.close()
>>> print('Total Time:', t2.result())
Total Time: 4
>>> print('Content:', t1.result())
Content: b'{"args":{},"data":"","files":{},"form":{},
            "headers":{"Connection":"close","Host":"httpbin.org"},
            "origin":"93.147.95.71",
            "url":"http://httpbin.org/delay/3"}\n'

进程

线程和协程是与 Python GIL 并存的并发模型,并利用 I/O 操作留下的执行时间来允许其他任务继续。在现代多核系统中,能够利用系统提供的全部性能并涉及真正的并行性并将工作分配到所有可用的核心上是非常好的。

Python 标准库提供了非常精细的工具来处理多进程,这是在 Python 上利用并行性的一个很好的解决方案。由于多进程将导致多个独立的解释器,因此 GIL 不会成为障碍,并且与线程和协程相比,甚至可能更容易理解它们作为完全隔离的进程,需要合作,而不是考虑在同一系统中共享底层内存状态的多个线程/协程。

管理进程的主要成本通常是生成成本和确保您不会在任何奇怪的情况下分叉子进程的复杂性,从而导致在内存中复制不需要的数据或重用文件描述符。

multiprocessing.ProcessPool可以是解决所有这些问题的一个很好的解决方案,因为在软件开始时启动它将确保当我们有任务要提交给子进程时不必支付任何特定的成本。此外,通过在开始时仅创建进程,我们可以保证软件的状态可预测(并且大部分为空),被复制以创建子进程。

如何做...

就像在ThreadPool示例中一样,我们将需要两个函数,它们将作为我们在进程中并行运行的任务。

在进程的情况下,我们实际上不需要执行 I/O 来实现并发运行,因此我们的任务可以做任何事情。我将使用计算斐波那契数列并打印出进度,以便我们可以看到两个进程的输出是如何交错的:

import os

def fib(n, seen):
    if n not in seen and n % 5 == 0:
        # Print out only numbers we didn't yet compute
        print(os.getpid(), '->', n)
        seen.add(n)

    if n < 2:
        return n
    return fib(n-2, seen) + fib(n-1, seen)

因此,现在我们需要创建运行fib函数并生成计算的多进程Pool

>>> from multiprocessing import Pool
>>> pool = Pool()
>>> t1 = pool.apply_async(fib, args=(20, set()))
>>> t2 = pool.apply_async(fib, args=(22, set()))
>>> pool.close()
>>> pool.join()
42588 -> 20
42588 -> 10
42588 -> 0
42589 -> 20
42588 -> 5
42589 -> 10
42589 -> 0
42589 -> 5
42588 -> 15
42589 -> 15
>>> t1.get()
6765
>>> t2.get()
17711

您可以看到两个进程的进程 ID 是如何交错的,一旦作业完成,就可以获得它们两者的结果。

它是如何工作的...

创建multiprocessing.Pool时,将通过os.fork或生成一个新的 Python 解释器创建与系统上的核心数量相等的进程(由os.cpu_count()指定),具体取决于底层系统支持的情况:

>>> pool = Pool()

一旦启动了新进程,它们将都执行相同的操作:执行worker函数,该函数循环消耗发送到Pool的作业队列,并逐个运行它们。

这意味着如果我们创建了两个进程的Pool,我们将有两个工作进程。一旦我们要求Pool执行某些操作(通过Pool.apply_asyncPool.map或任何其他方法),作业(函数及其参数)将被放置在multiprocessing.SimpleQueue中,工作进程将从中获取。

一旦worker从队列中获取任务,它将运行它。如果有多个worker实例在运行,每个实例都会从队列中选择一个任务并运行它。

任务完成后,执行的函数结果将被推送回结果队列(与任务本身一起,以标识结果所属的任务),Pool将能够消耗结果并将其提供给最初启动任务的代码。

所有这些通信都发生在多个进程之间,因此它不能在内存中发生。相反,multiprocessing.SimpleQueue使用pipe,每个生产者将写入pipe,每个消费者将从pipe中读取。

由于pipe只能读取和写入字节,我们提交给pool的参数以及由pool执行的函数的结果通过pickle协议转换为字节。只要发送方和接收方都有相同的模块可用,它就能够在 Python 对象之间进行编组/解组。

因此,我们向Pool提交我们的请求:

>>> t1 = pool.apply_async(fib, args=(20, set()))

fib函数,20和空集都被 pickled 并发送到队列中,供Pool的一个工作进程消耗。

与此同时,当工作进程正在获取数据并运行斐波那契函数时,我们加入池,以便我们的主进程将阻塞,直到池中的所有进程都完成:

>>> pool.close()
>>> pool.join()

理论上,池中的进程永远不会完成(它将永远运行,不断地查找队列中的任务)。在调用join之前,我们关闭池。关闭池告诉池一旦它们完成当前正在做的事情,就退出所有进程

然后,在close之后立即加入,我们等待直到池完成它现在正在做的事情,即为我们提供服务的两个请求。

与线程一样,multiprocessing.Pool返回AsyncResult对象,这意味着我们可以通过AsyncResult.ready()方法检查它们的完成情况,并且一旦准备就绪,我们可以通过AsyncResult.get()获取返回的值:

>>> t1.get()
6765
>>> t2.get()
17711

还有更多...

multiprocessing.Pool的工作方式与multiprocessing.pool.ThreadPool几乎相同。实际上,它们共享很多实现,因为其中一个是另一个的子类。

但由于使用的底层技术不同,这会导致一些主要差异。一个基于线程,另一个基于子进程。

使用进程的主要好处是 Python 解释器锁不会限制它们的并行性,它们将能够实际并行运行。

另一方面,这是有成本的。使用进程在启动时间上更昂贵(fork 一个进程通常比生成一个线程慢),而且在内存使用方面更昂贵,因为每个进程都需要有自己的内存状态。虽然大部分系统通过写时复制等技术大大降低了这些成本,但线程通常比进程便宜得多。

因此,通常最好在应用程序开始时只启动进程pool,这样生成进程的额外成本只需支付一次。

进程不仅更昂贵,而且与线程相比,它们不共享程序的状态;每个进程都有自己的状态和内存。因此,无法在Pool和执行任务的工作进程之间共享数据。所有数据都需要通过pickle编码并通过pipe发送到另一端进行消耗。与可以依赖共享队列的线程相比,这将产生巨大的成本,特别是当需要发送的数据很大时。

因此,通常最好避免在参数或返回值中涉及大文件或数据时涉及进程,因为该数据将不得不多次复制才能到达最终目的地。在这种情况下,最好将数据保存在磁盘上,并传递文件的路径。

未来

当启动后台任务时,它可能会与您的主流程并发运行,永远不会完成自己的工作(例如ThreadPool的工作线程),或者它可能是某种迟早会向您返回结果并且您可能正在等待该结果的东西(例如在后台下载 URL 内容的线程)。

这些第二种类型的任务都共享一个共同的行为:它们的结果将在_future_中可用。因此,通常将将来可用的结果称为Future。编程语言并不都具有完全相同的futures定义,而在 Python 中,Future是指将来会完成的任何函数,通常返回一个结果。

Future是可调用本身,因此与实际用于运行可调用的技术无关。您需要一种让可调用的执行继续进行的方法,在 Python 中,这由Executor提供。

有一些执行器可以将 futures 运行到线程、进程或协程中(在协程的情况下,循环本身就是执行器)。

如何做...

要运行一个 future,我们将需要一个执行器(ThreadPoolExecutorProcessPoolExecutor)和我们实际想要运行的 futures。为了举例说明,我们将使用一个返回加载网页所需时间的函数,以便对多个网站进行基准测试,以查看哪个网站速度最快:

import concurrent.futures
import urllib.request
import time

def benchmark_url(url):
    begin = time.time()
    with urllib.request.urlopen(url) as conn:
        conn.read()
    return (time.time() - begin, url)

class UrlsBenchmarker:
    def __init__(self, urls):
        self._urls = urls

    def run(self, executor):
        futures = self._benchmark_urls(executor)
        fastest = min([
            future.result() for future in 
                concurrent.futures.as_completed(futures)
        ])
        print('Fastest Url: {1}, in {0}'.format(*fastest))

    def _benchmark_urls(self, executor):
        futures = []
        for url in self._urls:
            future = executor.submit(benchmark_url, url)
            future.add_done_callback(self._print_timing)
            futures.append(future)
        return futures

    def _print_timing(self, future):
        print('Url {1} downloaded in {0}'.format(
            *future.result()
        ))

然后我们可以创建任何类型的执行器,并让UrlsBenchmarker在其中运行其futures

>>> import concurrent.futures
>>> with concurrent.futures.ThreadPoolExecutor() as executor:
...     UrlsBenchmarker([
...             'http://time.com/',
...             'http://www.cnn.com/',
...             'http://www.facebook.com/',
...             'http://www.apple.com/',
...     ]).run(executor)
...
Url http://time.com/ downloaded in 1.0580978393554688
Url http://www.apple.com/ downloaded in 1.0482590198516846
Url http://www.facebook.com/ downloaded in 1.6707532405853271
Url http://www.cnn.com/ downloaded in 7.4976489543914795
Fastest Url: http://www.apple.com/, in 1.0482590198516846

它是如何工作的...

UrlsBenchmarker将通过UrlsBenchmarker._benchmark_urls为每个 URL 触发一个 future:

for url in self._urls:
    future = executor.submit(benchmark_url, url)

每个 future 将执行benchmark_url,该函数下载给定 URL 的内容并返回下载所用的时间,以及 URL 本身:

def benchmark_url(url):
    begin = time.time()
    # download url here...
    return (time.time() - begin, url)

返回 URL 本身是必要的,因为future可以知道其返回值,但无法知道其参数。因此,一旦我们submit函数,我们就失去了它与哪个 URL 相关,并通过将其与时间一起返回,每当时间存在时我们将始终有 URL 可用。

然后对于每个future,通过future.add_done_callback添加一个回调:

future.add_done_callback(self._print_timing)

一旦 future 完成,它将调用UrlsBenchmarker._print_timing,该函数打印运行 URL 所用的时间。这通知用户基准测试正在进行,并且已完成其中一个 URL。

UrlsBenchmarker._benchmark_urls 然后会返回一个包含所有需要在列表中进行基准测试的 URL 的futures

然后将该列表传递给concurrent.futures.as_completed。这将创建一个迭代器,按照完成的顺序返回所有futures,并且只有在它们完成时才返回。因此,我们知道通过迭代它,我们只会获取已经完成的futures,并且一旦消耗了所有已完成的futures,我们将阻塞等待新的 future 完成:

[
    future.result() for future in 
        concurrent.futures.as_completed(futures)
]

因此,只有当所有futures都完成时,循环才会结束。

已完成futures的列表被list推导式所消耗,它将创建一个包含这些futures结果的列表。

由于结果都是以(时间,URL)形式存在,我们可以使用min来获取具有最短时间的结果,即下载时间最短的 URL。

这是因为比较两个元组会按顺序比较元素:

>>> (1, 5) < (2, 0)
True
>>> (2, 1) < (0, 5)
False

因此,在元组列表上调用min将抓取元组中第一个元素的最小值的条目:

>>> min([(1, 2), (2, 0), (0, 7)])
(0, 7)

当有两个第一个元素具有相同值时,才会查看第二个元素:

>>> min([(0, 7), (1, 2), (0, 3)])
(0, 3)

因此,我们获取具有最短时间的 URL(因为时间是由未来返回的元组中的第一个条目)并将其打印为最快的:

fastest = min([
    future.result() for future in 
        concurrent.futures.as_completed(futures)
])
print('Fastest Url: {1}, in {0}'.format(*fastest))

还有更多...

未来执行器与 multiprocessing.pool 提供的工作进程池非常相似,但它们有一些差异可能会推动您朝一个方向或另一个方向。

主要区别可能是工作进程的启动方式。池会启动固定数量的工作进程,在创建池时同时创建和启动它们。因此,早期创建池会将生成工作进程的成本移到应用程序的开始。这意味着应用程序可能启动相当慢,因为它可能需要根据您请求的工作进程数量或系统核心数量来分叉许多进程。相反,执行器仅在需要时创建工作进程,并且它旨在在将来避免在有可用工作进程时创建新的工作进程。

因此,执行器通常更快地启动,但第一次将未来发送到执行器时会有更多的延迟,而池则将大部分成本集中在启动时间上。因此,如果您经常需要创建和销毁一组工作进程池的情况下,使用 futures 执行器可能更有效。

调度的任务

一种常见的后台任务是应该在任何给定时间自行在后台运行的操作。通常,这些通过 cron 守护程序或类似的系统工具进行管理,通过配置守护程序在提供的时间运行给定的 Python 脚本。

当您有一个主要应用程序需要周期性执行任务(例如过期缓存、重置密码链接、刷新待发送的电子邮件队列或类似任务)时,通过 cron 作业进行操作并不是可行的,因为您需要将数据转储到其他进程可以访问的地方:磁盘上、数据库上,或者任何类似的共享存储。

幸运的是,Python 标准库有一种简单的方法来安排在任何给定时间执行并与线程一起加入的任务。这可以是一个非常简单和有效的定时后台任务的解决方案。

如何做...

sched 模块提供了一个完全功能的调度任务执行器,我们可以将其与线程混合使用,创建一个后台调度器:

import threading
import sched
import functools

class BackgroundScheduler(threading.Thread):
    def __init__(self, start=True):
        self._scheduler = sched.scheduler()
        self._running = True
        super().__init__(daemon=True)
        if start:
            self.start()

    def run_at(self, time, action, args=None, kwargs=None):
        self._scheduler.enterabs(time, 0, action, 
                                argument=args or tuple(), 
                                kwargs=kwargs or {})

    def run_after(self, delay, action, args=None, kwargs=None):
        self._scheduler.enter(delay, 0, action, 
                            argument=args or tuple(), 
                            kwargs=kwargs or {})

    def run_every(self, seconds, action, args=None, kwargs=None):
        @functools.wraps(action)
        def _f(*args, **kwargs):
            try:
                action(*args, **kwargs)
            finally:
                self.run_after(seconds, _f, args=args, kwargs=kwargs)
        self.run_after(seconds, _f, args=args, kwargs=kwargs)

    def run(self):
        while self._running:
            delta = self._scheduler.run(blocking=False)
            if delta is None:
                delta = 0.5
            self._scheduler.delayfunc(min(delta, 0.5))

    def stop(self):
        self._running = False

BackgroundScheduler 可以启动,并且可以向其中添加作业,以便在固定时间开始执行它们:

>>> import time
>>> s = BackgroundScheduler()
>>> s.run_every(2, lambda: print('Hello World'))
>>> time.sleep(5)
Hello World
Hello World
>>> s.stop()
>>> s.join()

工作原理...

BackgroundSchedulerthreading.Thread 的子类,因此它在我们的应用程序在做其他事情时在后台运行。注册的任务将在辅助线程中触发和执行,而不会妨碍主要代码:

class BackgroundScheduler(threading.Thread):
        def __init__(self):
            self._scheduler = sched.scheduler()
            self._running = True
            super().__init__(daemon=True)
            self.start()

每当创建 BackgroundScheduler 时,它的线程也会启动,因此它立即可用。该线程将以 daemon 模式运行,这意味着如果程序在结束时仍在运行,它不会阻止程序退出。

通常 Python 在退出应用程序时会等待所有线程,因此将线程设置为 daemon 可以使其在无需等待它们的情况下退出。

threading.Thread 作为线程代码执行 run 方法。在我们的情况下,这是一个重复运行调度器中注册的任务的方法:

def run(self):
    while self._running:
        delta = self._scheduler.run(blocking=False)
        if delta is None:
            delta = 0.5
        self._scheduler.delayfunc(min(delta, 0.5))

_scheduler.run(blocking=False) 表示从计划的任务中选择一个任务并运行它。然后,它返回在运行下一个任务之前仍需等待的时间。如果没有返回时间,这意味着没有要运行的任务。

通过 _scheduler.delayfunc(min(delta, 0.5)),我们等待下一个任务需要运行的时间,最多为半秒钟。

我们最多等待半秒钟,因为当我们等待时,调度的任务可能会发生变化。可能会注册一个新任务,我们希望确保它不必等待超过半秒钟才能被调度器捕捉到。

如果我们等待的时间正好是下一个任务挂起的时间,我们可能会运行,得到下一个任务在 60 秒内,然后开始等待 60 秒。但是,如果我们在等待时,用户注册了一个必须在 5 秒内运行的新任务,我们无论如何都会在 60 秒内运行它,因为我们已经在等待。通过等待最多 0.5 秒,我们知道需要半秒钟才能接收下一个任务,并且它将在 5 秒内正确运行。

等待少于下一个任务挂起的时间不会使任务运行得更快,因为调度程序不会运行任何已经超过其计划时间的任务。因此,如果没有要运行的任务,调度程序将不断告诉我们你必须等待,我们将等待半秒钟,直到达到下一个计划任务的计划时间为止。

run_atrun_afterrun_every方法实际上是注册在特定时间执行函数的方法。

run_atrun_after只是包装调度程序的enterabsenter方法,这些方法允许我们在特定时间或n秒后注册任务运行。

最有趣的函数可能是run_every,它每n秒运行一次任务:

def run_every(self, seconds, action, args=None, kwargs=None):
    @functools.wraps(action)
    def _f(*args, **kwargs):
        try:
            action(*args, **kwargs)
        finally:
            self.run_after(seconds, _f, args=args, kwargs=kwargs)
    self.run_after(seconds, _f, args=args, kwargs=kwargs)

该方法接受必须运行的可调用对象,并将其包装成实际运行该函数的装饰器,但是一旦完成,它会将函数重新安排为再次执行。这样,它将一直运行,直到调度程序停止,并且每当它完成时,它都会再次安排。

在进程之间共享数据

在使用线程或协程时,数据是通过它们共享相同的内存空间而共享的。因此,只要注意避免竞争条件并提供适当的锁定,您就可以从任何线程访问任何对象。

相反,使用进程时,情况变得更加复杂,数据不会在它们之间共享。因此,在使用ProcessPoolProcessPoolExecutor时,我们需要找到一种方法来在进程之间传递数据,并使它们能够共享一个公共状态。

Python 标准库提供了许多工具来创建进程之间的通信渠道:multiprocessing.Queuesmultiprocessing.Pipemultiprocessing.Valuemultiprocessing.Array可用于创建一个进程可以提供并且另一个进程可以消费的队列,或者在共享内存中共享的多个进程之间的值。

虽然所有这些都是可行的解决方案,但它们有一些限制:您必须在创建任何进程之前创建所有共享值,因此如果共享值的数量是可变的并且在存储类型方面受到限制,则它们就不可行。

相反,multiprocessing.Manager允许我们通过共享的Namespace存储任意数量的共享值。

如何做到...

以下是此配方的步骤:

  1. 管理器应该在应用程序开始时创建,然后所有进程都能够从中设置和读取值:
import multiprocessing

manager = multiprocessing.Manager()
namespace = manager.Namespace()
  1. 一旦我们有了我们的namespace,任何进程都能够向其设置值:
def set_first_variable():
    namespace.first = 42
p = multiprocessing.Process(target=set_first_variable)
p.start()
p.join()

def set_second_variable():
    namespace.second = dict(value=42)
p = multiprocessing.Process(target=set_second_variable)
p.start()
p.join()

import datetime
def set_custom_variable():
    namespace.last = datetime.datetime.utcnow()
p = multiprocessing.Process(target=set_custom_variable)
p.start()
p.join()
  1. 任何进程都能够访问它们:
>>> def print_variables():
...    print(namespace.first, namespace.second, namespace.last)
...
>>> p = multiprocessing.Process(target=print_variables)
>>> p.start()
>>> p.join()
42 {'value': 42} 2018-05-26 21:39:17.433112

无需提前创建变量或从主进程创建,只要进程能够访问Namespace,所有进程都能够读取或设置任何变量。

它是如何工作的...

multiprocessing.Manager类充当服务器,能够存储任何进程都能够访问的值,只要它具有对Manager和它想要访问的值的引用。

通过知道它正在侦听的套接字或管道的地址,可以访问Manager本身,每个具有对Manager实例的引用的进程都知道这些:

>>> manager = multiprocessing.Manager()
>>> print(manager.address)
/tmp/pymp-4l33rgjq/listener-34vkfba3

然后,一旦您知道如何联系管理器本身,您需要能够告诉管理器要访问的对象。

可以通过拥有代表并确定该对象的Token来完成:

>>> namespace = manager.Namespace()
>>> print(namespace._token)
Token(typeid='Namespace', 
      address='/tmp/pymp-092482xr/listener-yreenkqo', 
      id='7f78c7fd9630')

特别地,Namespace是一种允许我们在其中存储任何变量的对象。因此,通过仅使用namespace令牌就可以访问Namespace中存储的任何内容。

所有进程,因为它们是从同一个原始进程复制出来的,都具有namespace的令牌和管理器的地址,因此能够访问namespace,并因此设置或读取其中的值。

还有更多...

multiprocessing.Manager 不受限于与源自同一进程的进程一起工作。

可以创建一个在网络上监听的Manager,以便任何能够连接到它的进程可能能够访问其内容:

>>> import multiprocessing.managers
>>> manager = multiprocessing.managers.SyncManager(
...     address=('localhost', 50000), 
...     authkey=b'secret'
... )
>>> print(manager.address)
('localhost', 50000)

然后,一旦服务器启动:

>>> manager.get_server().serve_forever()

其他进程将能够通过使用与他们想要连接的管理器完全相同的参数创建一个manager2实例,然后显式连接:

>>> manager2 = multiprocessing.managers.SyncManager(
...     address=('localhost', 50000), 
...     authkey=b'secret'
... )
>>> manager2.connect()

让我们在管理器中创建一个namespace并将一个值设置到其中:

>>> namespace = manager.Namespace()
>>> namespace.value = 5

知道namespace的令牌值后,可以创建一个代理对象通过网络从manager2访问namespace

>>> from multiprocessing.managers import NamespaceProxy
>>> ns2 = NamespaceProxy(token, 'pickle', 
...                      manager=manager2, 
...                      authkey=b'secret')
>>> print(ns2.value)
5

第十章:网络

在本章中,我们将涵盖以下内容:

  • 发送电子邮件-从您的应用程序发送电子邮件

  • 获取电子邮件-检查并阅读新收到的邮件

  • FTP-从 FTP 上传、列出和下载文件

  • 套接字-基于 TCP/IP 编写聊天系统

  • AsyncIO-基于协程的异步 HTTP 服务器,用于静态文件

  • 远程过程调用-通过 XMLRPC 实现 RPC

介绍

现代应用程序经常需要通过网络与用户或其他软件进行交互。我们的社会越向连接的世界发展,用户就越希望软件能够与远程服务或网络进行交互。

基于网络的应用程序依赖于几十年来稳定且经过广泛测试的工具和范例,Python 标准库提供了对从传输到应用程序协议的最常见技术的支持。

除了提供对通信通道本身(如套接字)的支持外,标准库还提供了实现基于事件的应用程序模型,这些模型是网络使用案例的典型,因为在大多数情况下,应用程序将不得不对来自网络的输入做出反应并相应地处理它。

在本章中,我们将看到如何处理一些最常见的应用程序协议,如 SMTP、IMAP 和 FTP。但我们还将看到如何通过套接字直接处理网络,并如何实现我们自己的 RPC 通信协议。

发送电子邮件

电子邮件是当今最广泛使用的通信工具,如果您在互联网上,几乎可以肯定您有一个电子邮件地址,它们现在也高度集成在智能手机中,因此可以随时随地访问。

出于所有这些原因,电子邮件是向用户发送通知、完成报告和长时间运行进程结果的首选工具。

发送电子邮件需要一些机制,如果您想自己支持 SMTP 和 MIME 协议,这两种协议都相当复杂。

幸运的是,Python 标准库内置支持这两种情况,我们可以依赖smtplib模块与 SMTP 服务器交互以发送我们的电子邮件,并且可以依赖email包来实际创建电子邮件的内容并处理所需的所有特殊格式和编码。

如何做...

发送电子邮件是一个三步过程:

  1. 联系 SMTP 服务器并对其进行身份验证

  2. 准备电子邮件本身

  3. 向 SMTP 服务器提供电子邮件

Python 标准库中涵盖了所有三个阶段,我们只需要将它们包装起来,以便在更简单的接口中方便使用:

from email.header import Header
from email.mime.text import MIMEText
from email.utils import parseaddr, formataddr
from smtplib import SMTP

class EmailSender:
    def __init__(self, host="localhost", port=25, login="", password=""):
        self._host = host
        self._port = int(port)
        self._login = login
        self._password = password

    def send(self, sender, recipient, subject, body):
        header_charset = 'UTF-8'
        body_charset = 'UTF-8'

        sender_name, sender_addr = parseaddr(sender)
        recipient_name, recipient_addr = parseaddr(recipient)

        sender_name = str(Header(sender_name, header_charset))
        recipient_name = str(Header(recipient_name, header_charset))

        msg = MIMEText(body.encode(body_charset), 'plain', body_charset)
        msg['From'] = formataddr((sender_name, sender_addr))
        msg['To'] = formataddr((recipient_name, recipient_addr))
        msg['Subject'] = Header(subject, header_charset)

        smtp = SMTP(self._host, self._port)
        try:
            smtp.starttls()
        except:
            pass
        smtp.login(self._login, self._password)
        smtp.sendmail(sender, recipient, msg.as_string())
        smtp.quit()

我们的EmailSender类可用于轻松通过我们的电子邮件提供商发送电子邮件。

es = EmailSender('mail.myserver.it', 
                 login='amol@myserver.it', 
                 password='mymailpassword')
es.send(sender='Sender <no-reply@senders.net>', 
        recipient='amol@myserver.it',
        subject='Hello my friend!',
        body='''Here is a little email for you''')

它是如何工作的...

发送电子邮件需要连接到 SMTP 服务器,这需要数据,如服务器运行的主机、暴露的端口以及用于对其进行身份验证的用户名和密码。

每次我们想要发送电子邮件时,都需要所有这些细节,因为每封电子邮件都需要单独的连接。因此,这些都是我们负责发送电子邮件的类始终需要可用的所有细节,并且在创建实例时请求:

class EmailSender:
    def __init__(self, host="localhost", port=25, login="", password=""):
        self._host = host
        self._port = int(port)
        self._login = login
        self._password = password

一旦知道连接到 SMTP 服务器所需的所有细节,我们类的唯一公开方法就是实际发送电子邮件的方法:

def send(self, sender, recipient, subject, body):

这需要组成电子邮件所需的细节:发件人地址、接收电子邮件的地址、主题和电子邮件内容本身。

我们的方法必须解析提供的发件人和收件人。包含发件人和收件人名称的部分与包含地址的部分是分开的:

sender_name, sender_addr = parseaddr(sender)
recipient_name, recipient_addr = parseaddr(recipient)

如果sender类似于"Alessandro Molina <amol@myserver.it>"sender_name将是"Alessandro Molina"sender_addr将是"amol@myserver.it"

这是必需的,因为名称部分通常包含不受限于纯 ASCII 的名称,邮件可能会发送到中国、韩国或任何其他需要正确支持 Unicode 以处理收件人名称的地方。

因此,我们必须以一种邮件客户端在接收电子邮件时能够理解的方式正确编码这些字符,这是通过使用提供的字符集编码的Header类来完成的,在我们的情况下是"UTF-8"

sender_name = str(Header(sender_name, header_charset))
recipient_name = str(Header(recipient_name, header_charset))

一旦发件人和收件人的名称以电子邮件标题所期望的格式进行编码,我们就可以将它们与地址部分结合起来,以构建回一个完整的收件人和发件人,形式为"Name <address>"

msg['From'] = formataddr((sender_name, sender_addr))
msg['To'] = formataddr((recipient_name, recipient_addr))

相同的情况也适用于“主题”,作为邮件的一个标题字段,也需要进行编码:

msg['Subject'] = Header(subject, header_charset)

相反,消息的正文不必作为标题进行编码,并且可以以任何编码的纯字节表示形式提供,只要指定了编码。

在我们的情况下,消息的正文也被编码为UTF-8

msg = MIMEText(body.encode(body_charset), 'plain', body_charset)

然后,一旦消息本身准备就绪,正文和标题都被正确编码,唯一剩下的部分就是实际与 SMTP 服务器取得联系并发送电子邮件。

这是通过创建一个已知地址和端口的SMTP对象来完成的:

smtp = SMTP(self._host, self._port)

然后,如果 SMTP 服务器支持 TLS 加密,我们就启动它。如果不支持,我们就忽略错误并继续:

try:
    smtp.starttls()
except:
    pass

一旦启用了加密(如果可用),我们最终可以对 SMTP 服务器进行身份验证,并将邮件本身发送给相关的收件人:

smtp.login(self._login, self._password)
smtp.sendmail(sender, recipient, msg.as_string())
smtp.quit()

为了测试编码是否按预期工作,您可以尝试发送一封包含标准 ASCII 字符之外字符的电子邮件,以查看您的客户端是否正确理解了电子邮件:

es.send(sender='Sender <no-reply@senders.net>', 
        recipient='amol@myserver.it',
        subject='Have some japanese here: ã“ã‚“ã«ã¡ã¯',
        body='''And some chinese here! ä½ å¥½''')

如果一切都按预期进行,您应该能够对 SMTP 提供程序进行身份验证,发送电子邮件,并在收件箱中看到具有适当内容的电子邮件。

获取电子邮件

经常情况下,应用程序需要对某种事件做出反应,它们接收来自用户或软件的消息,然后需要相应地采取行动。基于网络的应用程序的整体性质在于对接收到的消息做出反应,但这类应用程序的一个非常特定和常见的情况是需要对接收到的电子邮件做出反应。

典型情况是,当用户需要向您的应用程序发送某种文档(通常是身份证或签署的合同)时,您希望对该事件做出反应,例如在用户发送签署的合同后启用服务。

这要求我们能够访问收到的电子邮件并扫描它们以检测发件人和内容。

如何做...

这个食谱的步骤如下:

  1. 使用imaplibemail模块,可以构建一个工作的 IMAP 客户端,从支持的 IMAP 服务器中获取最近的消息:
import imaplib
import re
from email.parser import BytesParser

class IMAPReader:
    ENCODING = 'utf-8'
    LIST_PATTERN = re.compile(
        r'\((?P<flags>.*?)\) "(?P<delimiter>.*)" (?P<name>.*)'
    )

    def __init__(self, host, username, password, ssl=True):
        if ssl:
            self._imap = imaplib.IMAP4_SSL(host)
        else:
            self._imap = imaplib.IMAP4(host)
        self._imap.login(username, password)

    def folders(self):
        """Retrieve list of IMAP folders"""
        resp, lines = self._imap.list()
        if resp != 'OK':
            raise Exception(resp)

        entries = []
        for line in lines:
            flags, _, name = self.LIST_PATTERN.match(
                line.decode(self.ENCODING)
            ).groups()
            entries.append(dict(
                flags=flags,
                name=name.strip('"')
            ))
        return entries

    def messages(self, folder, limit=10, peek=True):
        """Return ``limit`` messages from ``folder``

        peek=False will also fetch message body
        """
        resp, count = self._imap.select('"%s"' % folder, readonly=True)
        if resp != 'OK':
            raise Exception(resp)

        last_message_id = int(count[0])
        msg_ids = range(last_message_id, last_message_id-limit, -1)

        mode = '(BODY.PEEK[HEADER])' if peek else '(RFC822)'

        messages = []
        for msg_id in msg_ids:
            resp, msg = self._imap.fetch(str(msg_id), mode)
            msg = msg[0][-1]

            messages.append(BytesParser().parsebytes(msg))
            if len(messages) >= limit:
                break
        return messages

    def get_message_body(self, message):
        """Given a message for which the body was fetched, returns it"""
        body = []
        if message.is_multipart():
            for payload in message.get_payload():
                body.append(payload.get_payload())
        else:
            body.append(message.get_payload())
        return body

    def close(self):
        """Close connection to IMAP server"""
        self._imap.close()
  1. 然后可以使用IMAPReader访问兼容的邮件服务器以阅读最近的电子邮件:
mails = IMAPReader('imap.gmail.com', 
                   YOUR_EMAIL, YOUR_PASSWORD,
                   ssl=True)

folders = mails.folders()
for msg in mails.messages('INBOX', limit=2, peek=True):
    print(msg['Date'], msg['Subject'])
  1. 这返回了最近两封收到的电子邮件的标题和时间戳:
Fri, 8 Jun 2018 00:07:16 +0200 Hello Python CookBook!
Thu, 7 Jun 2018 08:21:11 -0400 SSL and turbogears.org

如果我们需要实际的电子邮件内容和附件,我们可以通过使用peek=False来检索它们,然后在检索到的消息上调用IMAPReader.get_message_body

它的工作原理是...

我们的类充当了imaplibemail模块的包装器,为从文件夹中获取邮件的需求提供了一个更易于使用的接口。

实际上,可以从imaplib创建两种不同的对象来连接到 IMAP 服务器,一种使用 SSL,一种不使用。根据服务器的要求,您可能需要打开或关闭它(例如,Gmail 需要 SSL),这在__init__中进行了抽象处理:

def __init__(self, host, username, password, ssl=True):
    if ssl:
        self._imap = imaplib.IMAP4_SSL(host)
    else:
        self._imap = imaplib.IMAP4(host)
    self._imap.login(username, password)

__init__方法还负责登录到 IMAP 服务器,因此一旦创建了阅读器,它就可以立即使用。

然后我们的阅读器提供了列出文件夹的方法,因此,如果您想要从所有文件夹中读取消息,或者您想要允许用户选择文件夹,这是可能的:

def folders(self):
    """Retrieve list of IMAP folders"""

我们的folders方法的第一件事是从服务器获取文件夹列表。imaplib方法已经在出现错误时报告异常,但作为安全措施,我们还检查响应是否为OK

resp, lines = self._imap.list()
if resp != 'OK':
    raise Exception(resp)

IMAP 是一种基于文本的协议,服务器应该始终响应OK <response>,如果它能够理解您的请求并提供响应。否则,可能会返回一堆替代响应代码,例如NOBAD。如果返回了其中任何一个,我们认为我们的请求失败了。

一旦我们确保实际上有文件夹列表,我们需要解析它。列表由多行文本组成。每行包含有关一个文件夹的详细信息,这些详细信息:标志和文件夹名称。它们由一个分隔符分隔,这不是标准的。在某些服务器上,它是一个点,而在其他服务器上,它是一个斜杠,因此我们在解析时需要非常灵活。这就是为什么我们使用允许标志和名称由任何分隔符分隔的正则表达式来解析它:

LIST_PATTERN = re.compile(
    r'\((?P<flags>.*?)\) "(?P<delimiter>.*)" (?P<name>.*)'
)

一旦我们知道如何解析响应中的这些行,我们就可以根据它们构建一个包含名称和这些文件夹的标志的字典列表:

entries = []
for line in lines:
    flags, _, name = self.LIST_PATTERN.match(
        line.decode(self.ENCODING)
    ).groups()
    entries.append(dict(
        flags=flags,
        name=name.strip('"')
    ))
return entries

然后可以使用imaplib.ParseFlags类进一步解析这些标志。

一旦我们知道要获取消息的文件夹的名称,我们就可以通过messages方法检索消息:

def messages(self, folder, limit=10, peek=True):
    """Return ``limit`` messages from ``folder``

    peek=False will also fetch message body
    """

由于 IMAP 是一种有状态的协议,我们需要做的第一件事是选择我们想要运行后续命令的文件夹:

resp, count = self._imap.select('"%s"' % folder, readonly=True)
if resp != 'OK':
    raise Exception(resp)

我们提供一个readonly选项,这样我们就不会无意中销毁我们的电子邮件,并像往常一样验证响应代码。

然后select方法的响应内容实际上是上传到该文件夹的最后一条消息的 ID。

由于这些 ID 是递增的数字,我们可以使用它来生成要获取的最近消息的最后limit条消息的 ID:

last_message_id = int(count[0])
msg_ids = range(last_message_id, last_message_id-limit, -1)

然后,根据调用者的选择,我们选择要下载的消息的内容。如果只有标题或整个内容:

mode = '(BODY.PEEK[HEADER])' if peek else '(RFC822)'

模式将被提供给fetch方法,告诉它我们要下载什么数据:

resp, msg = self._imap.fetch(str(msg_id), mode)

然后,消息本身被组合成一个包含两个元素的元组列表。第一个元素包含消息返回的大小和模式(由于我们自己提供了模式,所以我们并不真的在乎),元组的最后一个元素包含消息本身,所以我们只需抓取它:

msg = msg[0][-1]

一旦我们有了可用的消息,我们将其提供给BytesParser,以便我们可以得到一个Message实例:

BytesParser().parsebytes(msg)

我们循环遍历所有消息,解析它们,并添加到我们将返回的消息列表中。一旦达到所需数量的消息,我们就停止:

messages = []
for msg_id in msg_ids:
    resp, msg = self._imap.fetch(str(msg_id), mode)
    msg = msg[0][-1]

    messages.append(BytesParser().parsebytes(msg))
    if len(messages) >= limit:
        break
return messages

messages方法中,我们得到一个Message对象的列表,我们可以轻松访问除消息正文之外的所有数据。因为正文实际上可能由多个项目组成(想象一条带附件的消息 - 它包含文本、图像、PDF 文件或任何附件)。

因此,读取器提供了一个get_message_body方法,用于检索消息正文的所有部分(如果是多部分消息),并将它们返回:

def get_message_body(self, message):
    """Given a message for which the body was fetched, returns it"""
    body = []
    if message.is_multipart():
        for payload in message.get_payload():
            body.append(payload.get_payload())
    else:
        body.append(message.get_payload())
    return body

通过结合messagesget_message_body方法,我们能够从邮箱中抓取消息及其内容,然后根据需要对其进行处理。

还有更多...

编写一个功能完备且完全运行的 IMAP 客户端是一个独立的项目,超出了本书的范围。

IMAP 是一个复杂的协议,包括对标志、搜索和许多其他功能的支持。大多数这些命令都由imaplib提供,还可以上传消息到服务器或创建工具来执行备份或将消息从一个邮件帐户复制到另一个邮件帐户。

此外,当解析复杂的电子邮件时,email模块将处理电子邮件相关的 RFCs 指定的各种数据表示,例如,我们的示例将日期返回为字符串,但email.utils.parsedate可以将其解析为 Python 对象。

FTP

FTP 是保存和从远程服务器检索文件的最广泛使用的解决方案。它已经存在了几十年,是一个相当容易使用的协议,可以提供良好的性能,因为它在传输内容上提供了最小的开销,同时支持强大的功能,如传输恢复。

通常,软件需要接收由其他软件自动上传的文件;多年来,FTP 一直被频繁地用作这些场景中的强大解决方案。无论您的软件是需要上传内容的软件,还是需要接收内容的软件,Python 标准库都内置了对 FTP 的支持,因此我们可以依靠ftplib来使用 FTP 协议。

如何做到这一点...

ftplib是一个强大的基础,我们可以在其上提供一个更简单的 API 来与 FTP 服务器进行交互,用于存储和检索文件:

import ftplib

class FTPCLient:
    def __init__(self, host, username='', password=''):
        self._client = ftplib.FTP_TLS(timeout=10)
        self._client.connect(host)

        # enable TLS
        try:
            self._client.auth()
        except ftplib.error_perm:
            # TLS authentication not supported
            # fallback to a plain FTP client
            self._client.close()
            self._client = ftplib.FTP(timeout=10)
            self._client.connect(host)

        self._client.login(username, password)

        if hasattr(self._client, 'prot_p'):
            self._client.prot_p()

    def cwd(self, directory):
        """Enter directory"""
        self._client.cwd(directory)

    def dir(self):
        """Returns list of files in current directory.

        Each entry is returned as a tuple of two elements,
        first element is the filename, the second are the
        properties of that file.
        """
        entries = []
        for idx, f in enumerate(self._client.mlsd()):
            if idx == 0:
                # First entry is current path
                continue
            if f[0] in ('..', '.'):
                continue
            entries.append(f)
        return entries

    def download(self, remotefile, localfile):
        """Download remotefile into localfile"""
        with open(localfile, 'wb') as f:
            self._client.retrbinary('RETR %s' % remotefile, f.write)

    def upload(self, localfile, remotefile):
        """Upload localfile to remotefile"""
        with open(localfile, 'rb') as f:
            self._client.storbinary('STOR %s' % remotefile, f)

    def close(self):
        self._client.close()

然后,我们可以通过上传和获取一个简单的文件来测试我们的类:

with open('/tmp/hello.txt', 'w+') as f:
    f.write('Hello World!')

cli = FTPCLient('localhost', username=USERNAME, password=PASSWORD)
cli.upload('/tmp/hello.txt', 'hellofile.txt')    
cli.download('hellofile.txt', '/tmp/hello2.txt')

with open('/tmp/hello2.txt') as f:
    print(f.read())

如果一切按预期工作,输出应该是Hello World!

工作原理...

FTPClient类提供了一个初始化程序,负责设置与服务器的正确连接以及一堆方法来实际对连接的服务器进行操作。

__init__做了很多工作,尝试建立与远程服务器的正确连接:

def __init__(self, host, username='', password=''):
    self._client = ftplib.FTP_TLS(timeout=10)
    self._client.connect(host)

    # enable TLS
    try:
        self._client.auth()
    except ftplib.error_perm:
        # TLS authentication not supported
        # fallback to a plain FTP client
        self._client.close()
        self._client = ftplib.FTP(timeout=10)
        self._client.connect(host)

    self._client.login(username, password)

    if hasattr(self._client, 'prot_p'):
        self._client.prot_p()

首先它尝试建立 TLS 连接,这可以保证加密,否则 FTP 是一种明文协议,会以明文方式发送所有数据。

如果我们的远程服务器支持 TLS,可以通过调用.auth()在控制连接上启用它,然后通过调用prot_p()在数据传输连接上启用它。

FTP 基于两种连接,控制连接用于发送和接收服务器的命令及其结果,数据连接用于发送上传和下载的数据。

如果可能的话,它们两者都应该加密。如果我们的服务器不支持它们,我们将退回到普通的 FTP 连接,并继续通过对其进行身份验证来进行操作。

如果您的服务器不需要任何身份验证,提供anonymous作为用户名,空密码通常足以登录。

一旦我们连接上了,我们就可以自由地在服务器上移动,可以使用cwd命令来实现:

def cwd(self, directory):
    """Enter directory"""
    self._client.cwd(directory)

这个方法只是内部客户端方法的代理,因为内部方法已经很容易使用并且功能齐全。

但一旦我们进入一个目录,我们需要获取它的内容,这就是dir()方法发挥作用的地方:

def dir(self):
    """Returns list of files in current directory.

    Each entry is returned as a tuple of two elements,
    first element is the filename, the second are the
    properties of that file.
    """
    entries = []
    for idx, f in enumerate(self._client.mlsd()):
        if idx == 0:
            # First entry is current path
            continue
        if f[0] in ('..', '.'):
            continue
        entries.append(f)
    return entries

dir()方法调用内部客户端的mlsd方法,负责返回当前目录中文件的列表。

这个列表被返回为一个包含两个元素的元组:

('Desktop', {'perm': 'ceflmp', 
             'unique': 'BAAAAT79CAAAAAAA', 
             'modify': '20180522213143', 
             'type': 'dir'})

元组的第一个条目包含文件名,而第二个条目包含其属性。

我们自己的方法只做了两个额外的步骤,它跳过了第一个返回的条目——因为那总是当前目录(我们用cwd()选择的目录)——然后跳过了任何特殊的父目录或当前目录的条目。我们对它们并不感兴趣。

一旦我们能够在目录结构中移动,我们最终可以将文件uploaddownload到这些目录中:

def download(self, remotefile, localfile):
    """Download remotefile into localfile"""
    with open(localfile, 'wb') as f:
        self._client.retrbinary('RETR %s' % remotefile, f.write)

def upload(self, localfile, remotefile):
    """Upload localfile to remotefile"""
    with open(localfile, 'rb') as f:
        self._client.storbinary('STOR %s' % remotefile, f)

这两种方法非常简单,当我们上传文件时,它们只是打开本地文件进行读取,当我们下载文件时,它们只是打开本地文件进行写入,并发送 FTP 命令来检索或存储文件。

当上传一个新的remotefile时,将创建一个具有与localfile相同内容的文件。当下载时,将打开localfile以在其中写入remotefile的内容。

还有更多...

并非所有的 FTP 服务器都支持相同的命令。多年来,该协议进行了许多扩展,因此一些命令可能缺失或具有不同的语义。

例如,mlsd函数可能会缺失,但您可能有LISTnlst,它们可以执行类似的工作。

您可以参考 RFC 959 了解 FTP 协议应该如何工作,但经常通过明确与您要连接的 FTP 服务器进行实验是评估它将接受哪些命令和签名的最佳方法。

经常,FTP 服务器实现了一个HELP命令,您可以使用它来获取支持的功能列表。

套接字

套接字是您可以用来编写网络应用程序的最低级别概念之一。这意味着我们通常要自己管理整个连接,当直接依赖套接字时,您需要处理连接请求,接受它们,然后启动一个线程或循环来处理通过新创建的连接通道发送的后续命令或数据。

这几乎所有依赖网络的应用程序都必须实现的流程,通常您调用服务器时都有一个基础在上述循环中。

Python 标准库提供了一个很好的基础,避免每次必须处理基于网络的应用程序时手动重写该流程。我们可以使用socketserver模块,让它为我们处理连接循环,而我们只需专注于实现应用程序层协议和处理消息。

如何做...

对于这个配方,您需要执行以下步骤:

  1. 通过混合TCPServerThreadingMixIn类,我们可以轻松构建一个通过 TCP 处理并发连接的多线程服务器:
import socket
import threading
import socketserver

class EchoServer:
    def __init__(self, host='0.0.0.0', port=9800):
        self._host = host
        self._port = port
        self._server = ThreadedTCPServer((host, port), EchoRequestHandler)
        self._thread = threading.Thread(target=self._server.serve_forever)
        self._thread.daemon = True

    def start(self):
        if self._thread.is_alive():
            # Already serving
            return

        print('Serving on %s:%s' % (self._host, self._port))
        self._thread.start()

    def stop(self):
        self._server.shutdown()
        self._server.server_close()

class ThreadedTCPServer(socketserver.ThreadingMixIn, 
                        socketserver.TCPServer):
    allow_reuse_address = True

class EchoRequestHandler(socketserver.BaseRequestHandler):
    MAX_MESSAGE_SIZE = 2**16  # 65k
    MESSAGE_HEADER_LEN = len(str(MAX_MESSAGE_SIZE))

    @classmethod
    def recv_message(cls, socket):
        data_size = int(socket.recv(cls.MESSAGE_HEADER_LEN))
        data = socket.recv(data_size)
        return data

    @classmethod
    def prepare_message(cls, message):
        if len(message) > cls.MAX_MESSAGE_SIZE:
            raise ValueError('Message too big'

        message_size = str(len(message)).encode('ascii')
        message_size = message_size.zfill(cls.MESSAGE_HEADER_LEN)
        return message_size + message

    def handle(self):
        message = self.recv_message(self.request)
        self.request.sendall(self.prepare_message(b'ECHO: %s' % message))
  1. 一旦我们有一个工作的服务器,为了测试它,我们需要一个客户端向其发送消息。为了方便起见,我们将保持客户端简单,只需连接,发送消息,然后等待一个简短的回复:
def send_message_to_server(ip, port, message):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((ip, port))
    try:
        message = EchoRequestHandler.prepare_message(message)
        sock.sendall(message)
        response = EchoRequestHandler.recv_message(sock)
        print("ANSWER: {}".format(response))
    finally:
        sock.close()
  1. 现在我们既有服务器又有客户端,我们可以测试我们的服务器是否按预期工作:
server = EchoServer()
server.start()

send_message_to_server('localhost', server._port, b"Hello World 1")
send_message_to_server('localhost', server._port, b"Hello World 2")
send_message_to_server('localhost', server._port, b"Hello World 3")

server.stop()
  1. 如果一切正常,您应该看到:
Serving on 0.0.0.0:9800
ANSWER: b'ECHO: Hello World 1'
ANSWER: b'ECHO: Hello World 2'
ANSWER: b'ECHO: Hello World 3'

它是如何工作的...

服务器部分由三个不同的类组成。

EchoServer,它编排服务器并提供我们可以使用的高级 API。EchoRequestHandler,它管理传入的消息并提供服务。ThreadedTCPServer,它负责整个网络部分,打开套接字,监听它们,并生成线程来处理连接。

EchoServer允许启动和停止我们的服务器:

class EchoServer:
    def __init__(self, host='0.0.0.0', port=9800):
        self._host = host
        self._port = port
        self._server = ThreadedTCPServer((host, port), EchoRequestHandler)
        self._thread = threading.Thread(target=self._server.serve_forever)
        self._thread.daemon = True

    def start(self):
        if self._thread.is_alive():
            # Already serving
            return

        print('Serving on %s:%s' % (self._host, self._port))
        self._thread.start()

    def stop(self):
        self._server.shutdown()
        self._server.server_close()

它创建一个新的线程,服务器将在其中运行并启动它(如果尚未运行)。该线程将只运行ThreadedTCPServer.serve_forever方法,该方法循环运行,依次为每个请求提供服务。

当我们完成服务器时,我们可以调用stop()方法,它将关闭服务器并等待其完成(一旦完成所有当前运行的请求,它将退出)。

ThreadedTCPServer基本上是标准库提供的标准服务器,如果不是因为我们也继承自ThreadingMixInMixin是一组附加功能,您可以通过继承它来注入类中,在这种特定情况下,它为套接字服务器提供了线程功能。因此,我们可以同时处理多个请求,而不是一次只能处理一个请求。

我们还设置了服务器的allow_reuse_address = True属性,以便在发生崩溃或超时的情况下,套接字可以立即重用,而不必等待系统关闭它们。

最后,EchoRequestHandler提供了整个消息处理和解析。每当ThreadedTCPServer接收到新连接时,它将在处理程序上调用handle方法,由处理程序来执行正确的操作。

在我们的情况下,我们只是实现了一个简单的服务器,它会回复发送给它的内容,因此处理程序必须执行两件事:

  • 解析传入的消息以了解其内容

  • 发送一个具有相同内容的消息

在使用套接字时的一个主要复杂性是它们实际上并不是基于消息的。它们是一连串的数据(好吧,UDP 是基于消息的,但就我们而言,接口并没有太大变化)。这意味着不可能知道新消息何时开始以及消息何时结束。

handle方法只告诉我们有一个新连接,但在该连接上,可能会连续发送多条消息,除非我们知道消息何时结束,否则我们会将它们读取为一条大消息。

为了解决这个问题,我们使用了一个非常简单但有效的方法,即给所有消息加上它们自己的大小前缀。因此,当接收到新消息时,我们总是知道我们只需要读取消息的大小,然后一旦知道大小,我们将读取由大小指定的剩余字节。

要读取这些消息,我们依赖于一个实用方法recv_message,它将能够从任何提供的套接字中读取以这种方式制作的消息:

@classmethod
def recv_message(cls, socket):
    data_size = int(socket.recv(cls.MESSAGE_HEADER_LEN))
    data = socket.recv(data_size)
    return data

该函数的第一件事是从套接字中精确读取MESSAGE_HEADER_LEN个字节。这些字节将包含消息的大小。所有大小必须相同。因此,诸如10之类的大小将必须表示为00010。然后前缀的零将被忽略。然后,该大小使用int进行转换,我们将得到正确的数字。大小必须全部相同,否则我们将不知道需要读取多少字节来获取大小。

我们决定将消息大小限制为 65,000,这导致MESSAGE_HEADER_LEN为五,因为需要五位数字来表示最多 65,536 的数字:

MAX_MESSAGE_SIZE = 2**16  # 65k
MESSAGE_HEADER_LEN = len(str(MAX_MESSAGE_SIZE))

大小并不重要,我们只选择了一个相当大的值。允许的消息越大,就需要更多的字节来表示它们的大小。

然后recv_message方法由handle()使用来读取发送的消息:

def handle(self):
    message = self.recv_message(self.request)
    self.request.sendall(self.prepare_message(b'ECHO: %s' % message))

一旦消息知道,handle()方法还会以相同的方式准备发送回一条新消息,并且为了准备响应,它依赖于prepare_message,这也是客户端用来发送消息的方法:

@classmethod
def prepare_message(cls, message):
    if len(message) > cls.MAX_MESSAGE_SIZE:
        raise ValueError('Message too big'

    message_size = str(len(message)).encode('ascii')
    message_size = message_size.zfill(cls.MESSAGE_HEADER_LEN)
    return message_size + message

该函数的作用是,给定一条消息,它确保消息不会超过允许的最大大小,然后在消息前面加上它的大小。

该大小是通过将消息的长度作为文本获取,然后使用ascii编码将其编码为字节来计算的。由于大小只包含数字,因此ascii编码已经足够表示它们了:

message_size = str(len(message)).encode('ascii')

由于生成的字符串可以有任何大小(从一到五个字节),我们总是用零填充它,直到达到预期的大小:

message_size = message_size.zfill(cls.MESSAGE_HEADER_LEN)

然后将生成的字节添加到消息前面,并返回准备好的消息。

有了这两个函数,服务器就能够接收和发送任意大小的消息。

客户端函数的工作方式几乎相同,因为它必须发送一条消息,然后接收答案:

def send_message_to_server(ip, port, message):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((ip, port))
    try:
        message = EchoRequestHandler.prepare_message(message)
        sock.sendall(message)
        response = EchoRequestHandler.recv_message(sock)
        print("ANSWER: {}".format(response))
    finally:
        sock.close()

它仍然使用EchoRequestHandler.prepare_message来准备发送到服务器的消息,以及EchoRequestHandler.recv_message来读取服务器的响应。

唯一的额外部分与连接到服务器有关。为此,我们实际上创建了一个类型为AF_INETSOCK_STREAM的套接字,这实际上意味着我们要使用 TCP/IP。

然后我们连接到服务器运行的ipport,一旦连接成功,我们就通过生成的套接字sock发送消息并在同一个套接字上读取答案。

完成后,我们必须记得关闭套接字,否则它们将一直泄漏,直到操作系统决定杀死它们,因为它们长时间不活动。

AsyncIO

虽然异步解决方案已经存在多年,但这些天它们变得越来越普遍。主要原因是,拥有一个没有数千个并发用户的应用程序不再是一个不寻常的场景;对于一个小型/中型应用程序来说,这实际上是一个常态,而且我们可以通过全球范围内使用的主要服务扩展到数百万用户。

能够提供这样的服务量,使用基于线程或进程的方法并不适合。特别是当用户触发的许多连接大部分时间可能都在那里无所事事。想想 Facebook Messenger 或 WhatsApp 这样的服务。无论你使用哪一个,你可能偶尔发送一条消息,大部分时间你与服务器的连接都在那里无所事事。也许你是一个热络的聊天者,每秒收到一条消息,但这仍然意味着在你的计算机每秒钟可以做的数百万次操作中,大部分时间都在无所事事。这种应用程序中的大部分繁重工作是由网络部分完成的,因此有很多资源可以通过在单个进程中进行多个连接来共享。

异步技术正好允许这样做,编写一个网络应用程序,而不是需要多个单独的线程(这将浪费内存和内核资源),我们可以有一个由多个协程组成的单个进程和线程,直到实际有事情要做时才会执行。

只要协程需要做的事情非常快速(比如获取一条消息并将其转发给你的另一个联系人),大部分工作将在网络层进行,因此可以并行进行。

如何做...

这个配方的步骤如下:

  1. 我们将复制我们的回显服务器,但不再使用线程,而是使用 AsyncIO 和协程来提供请求:
import asyncio

class EchoServer:
    MAX_MESSAGE_SIZE = 2**16  # 65k
    MESSAGE_HEADER_LEN = len(str(MAX_MESSAGE_SIZE))

    def __init__(self, host='0.0.0.0', port=9800):
        self._host = host
        self._port = port
        self._server = None

    def serve(self, loop):
        coro = asyncio.start_server(self.handle, self._host, self._port,
                                    loop=loop)
        self._server = loop.run_until_complete(coro)
        print('Serving on %s:%s' % (self._host, self._port))
        loop.run_until_complete(self._server.wait_closed())
        print('Done')

    @property
    def started(self):
        return self._server is not None and self._server.sockets

    def stop(self):
        print('Stopping...')
        self._server.close()

    async def handle(self, reader, writer):
        data = await self.recv_message(reader)
        await self.send_message(writer, b'ECHO: %s' % data)
        # Signal we finished handling this request
        # or the server will hang.
        writer.close()

    @classmethod
    async def recv_message(cls, socket):
        data_size = int(await socket.read(cls.MESSAGE_HEADER_LEN))
        data = await socket.read(data_size)
        return data

    @classmethod
    async def send_message(cls, socket, message):
        if len(message) > cls.MAX_MESSAGE_SIZE:
            raise ValueError('Message too big')

        message_size = str(len(message)).encode('ascii')
        message_size = message_size.zfill(cls.MESSAGE_HEADER_LEN)
        data = message_size + message

        socket.write(data)
        await socket.drain()
  1. 现在我们有了服务器实现,我们需要一个客户端来测试它。由于实际上客户端做的与我们之前的配方相同,我们只是要重用相同的客户端实现。因此,客户端不会是基于 AsyncIO 和协程的,而是一个使用socket的普通函数:
import socket

def send_message_to_server(ip, port, message):
    def _recv_message(socket):
        data_size = int(socket.recv(EchoServer.MESSAGE_HEADER_LEN))
        data = socket.recv(data_size)
        return data

    def _prepare_message(message):
        if len(message) > EchoServer.MAX_MESSAGE_SIZE:
            raise ValueError('Message too big')

        message_size = str(len(message)).encode('ascii')
        message_size = message_size.zfill(EchoServer.MESSAGE_HEADER_LEN)
        return message_size + message

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((ip, port))
    try:
        sock.sendall(_prepare_message(message))
        response = _recv_message(sock)
        print("ANSWER: {}".format(response))
    finally:
        sock.close()
  1. 现在我们可以把这些部分放在一起。为了在同一个进程中运行客户端和服务器,我们将在一个单独的线程中运行asyncio循环。因此,我们可以同时启动客户端。这并不是为了服务多个客户端而必须的,只是为了方便,避免不得不启动两个不同的 Python 脚本来玩服务器和客户端。

  2. 首先,我们为服务器创建一个将持续3秒的线程。3 秒后,我们将明确停止我们的服务器:

server = EchoServer()
def serve_for_3_seconds():
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    loop.call_later(3, server.stop)
    server.serve(loop)
    loop.close()

import threading
server_thread = threading.Thread(target=serve_for_3_seconds)
server_thread.start()
  1. 然后,一旦服务器启动,我们就创建三个客户端并发送三条消息:
while not server.started:
    pass

send_message_to_server('localhost', server._port, b"Hello World 1")
send_message_to_server('localhost', server._port, b"Hello World 2")
send_message_to_server('localhost', server._port, b"Hello World 3")
  1. 完成后,我们等待服务器退出,因为 3 秒后它应该停止并退出:
server_thread.join()
  1. 如果一切按预期进行,你应该看到服务器启动,为三个客户端提供服务,然后退出:
Serving on 0.0.0.0:9800
ANSWER: b'ECHO: Hello World 1'
ANSWER: b'ECHO: Hello World 2'
ANSWER: b'ECHO: Hello World 3'
Stopping...
Done 

工作原理...

这个配方的客户端大部分是直接从套接字服务配方中取出来的。区别在于服务器端不再是多线程的,而是基于协程的。

给定一个asyncio事件循环(我们在serve_for_3_seconds线程中使用asyncio.new_event_loop()创建的),EchoServer.serve方法创建一个基于协程的新服务器,并告诉循环永远提供请求,直到服务器本身关闭为止:

def serve(self, loop):
    coro = asyncio.start_server(self.handle, self._host, self._port,
                                loop=loop)
    self._server = loop.run_until_complete(coro)
    print('Serving on %s:%s' % (self._host, self._port))
    loop.run_until_complete(self._server.wait_closed())
    print('Done')

loop.run_until_complete将阻塞,直到指定的协程退出,而self._server.wait_closed()只有在服务器本身停止时才会退出。

为了确保服务器在短时间内停止,当我们创建循环时,我们发出了loop.call_later(3, server.stop)的调用。这意味着 3 秒后,服务器将停止,整个循环将退出。

同时,直到服务器真正停止,它将继续提供服务。每个请求都会生成一个运行handle函数的协程:

async def handle(self, reader, writer):
    data = await self.recv_message(reader)
    await self.send_message(writer, b'ECHO: %s' % data)
    # Signal we finished handling this request
    # or the server will hang.
    writer.close()

处理程序将接收两个流作为参数。一个用于传入数据,另一个用于传出数据。

就像我们在使用线程套接字服务器的情况下所做的那样,我们从reader流中读取传入的消息。为此,我们将recv_message重新实现为一个协程,这样我们就可以同时读取数据和处理其他请求:

@classmethod
async def recv_message(cls, socket):
    data_size = int(await socket.read(cls.MESSAGE_HEADER_LEN))
    data = await socket.read(data_size)
    return data

当消息的大小和消息本身都可用时,我们只需返回消息,以便send_message函数可以将其回显到客户端。

在这种情况下,与socketserver的唯一特殊更改是我们要写入流写入器,但然后我们必须将其排空:

socket.write(data)
await socket.drain()

这是因为在我们写入套接字后,我们需要将控制权发送回asyncio循环,以便它有机会实际刷新这些数据。

三秒后,调用server.stop方法,这将停止服务器,唤醒wait_closed()函数,从而使EchoServer.serve方法退出,因为它已经完成。

远程过程调用

有数百种系统可以在 Python 中执行 RPC,但由于它具有强大的网络工具并且是一种动态语言,我们需要的一切都已经内置在标准库中。

如何做到...

您需要执行以下步骤来完成此操作:

  1. 使用xmlrpc.server,我们可以轻松创建一个基于 XMLRPC 的服务器,该服务器公开多个服务:
import xmlrpc.server

class XMLRPCServices:
    class ExposedServices:
        pass

    def __init__(self, **services):
        self.services = self.ExposedServices()
        for name, service in services.items():
            setattr(self.services, name, service)

    def serve(self, host='localhost', port=8000):
        print('Serving XML-RPC on {}:{}'.format(host, port))
        self.server = xmlrpc.server.SimpleXMLRPCServer((host, port))
        self.server.register_introspection_functions()
        self.server.register_instance(self.services, 
                                      allow_dotted_names=True)
        self.server.serve_forever()

    def stop(self):
        self.server.shutdown()
        self.server.server_close()
  1. 特别是,我们将公开两项服务:一个用于获取当前时间,另一个用于将数字乘以2
class MathServices:
    def double(self, v):
        return v**2

class TimeServices:
    def currentTime(self):
        import datetime
        return datetime.datetime.utcnow()
  1. 一旦我们有了我们的服务,我们可以使用xmlrpc.client.ServerProxy来消费它们,它提供了一个简单的调用接口来对 XMLRPC 服务器进行操作。

  2. 通常情况下,为了在同一进程中启动客户端和服务器,我们可以使用一个线程来启动服务器,并让服务器在该线程中运行,而客户端驱动主线程:

xmlrpcserver = XMLRPCServices(math=MathServices(),
                              time=TimeServices())

import threading
server_thread = threading.Thread(target=xmlrpcserver.serve)
server_thread.start()

from xmlrpc.client import ServerProxy
client = ServerProxy("http://localhost:8000")
print(
    client.time.currentTime()
)

xmlrpcserver.stop()
server_thread.join()
  1. 如果一切正常,您应该在终端上看到当前时间的打印:
Serving XML-RPC on localhost:8000
127.0.0.1 - - [10/Jun/2018 23:41:25] "POST /RPC2 HTTP/1.1" 200 -
20180610T21:41:25

它是如何工作的...

XMLRPCServices类接受我们要公开的所有服务作为初始化参数并将它们公开:

xmlrpcserver = XMLRPCServices(math=MathServices(),
                              time=TimeServices())

这是因为我们公开了一个本地对象(ExposedServices),默认情况下为空,但我们将提供的所有服务作为属性附加到其实例上:

def __init__(self, **services):
    self.services = self.ExposedServices()
    for name, service in services.items():
        setattr(self.services, name, service)

因此,我们最终暴露了一个self.services对象,它有两个属性:mathtime,它们分别指向MathServicesTimeServices类。

实际上是由XMLRPCServices.serve方法来提供它们的:

def serve(self, host='localhost', port=8000):
    print('Serving XML-RPC on {}:{}'.format(host, port))
    self.server = xmlrpc.server.SimpleXMLRPCServer((host, port))
    self.server.register_introspection_functions()
    self.server.register_instance(self.services, 
                                  allow_dotted_names=True)
    self.server.serve_forever()

这创建了一个SimpleXMLRPCServer实例,它是负责响应 XMLRPC 请求的 HTTP 服务器。

然后,我们将self.services对象附加到该实例,并允许它访问子属性,以便嵌套的mathtime属性可以作为服务公开:

self.server.register_instance(self.services, 
                              allow_dotted_names=True)

在实际启动服务器之前,我们还启用了内省功能。这些都是允许我们访问公开服务列表并请求其帮助和签名的所有功能:

self.server.register_introspection_functions()

然后我们实际上启动了服务器:

self.server.serve_forever()

这将阻止serve方法并循环提供请求,直到调用stop方法为止。

这就是为什么在示例中,我们在单独的线程中启动服务器的原因;也就是说,这样就不会阻塞我们可以用于客户端的主线程。

stop方法负责停止服务器,以便serve方法可以退出。该方法要求服务器在完成当前请求后立即终止,然后关闭关联的网络连接:

def stop(self):
    self.server.shutdown()
    self.server.server_close()

因此,只需创建XMLRPCServices并提供它就足以使我们的 RPC 服务器正常运行:

xmlrpcserver = XMLRPCServices(math=MathServices(),
                              time=TimeServices())
xmlrpcserver.serve()

在客户端,代码基础要简单得多;只需创建一个针对服务器公开的 URL 的ServerProxy即可:

client = ServerProxy("http://localhost:8000")

然后,服务器公开的服务的所有方法都可以通过点表示法访问:

client.time.currentTime()

还有更多...

XMLRPCServices具有很大的安全性影响,因此您不应该在开放网络上使用SimpleXMLRPCServer

最明显的问题是,您允许任何人执行远程代码,因为 XMLRPC 服务器未经身份验证。因此,服务器应仅在您可以确保只有受信任的客户端能够访问服务的私人网络上运行。

但即使您在服务前提供适当的身份验证(通过在其前面使用任何 HTTP 代理来实现),您仍希望确保信任客户端将要发送的数据,因为XMLRPCServices存在一些安全限制。

所提供的数据是以明文交换的,因此任何能够嗅探您网络的人都能够看到它。

可以通过一些努力绕过这个问题,通过对SimpleXMLRPCServer进行子类化,并用 SSL 包装的socket实例替换它(客户端也需要这样做才能连接)。

但是,即使涉及到通信渠道的加固,您仍需要信任将要发送的数据,因为解析器是天真的,可以通过发送大量递归数据来使其失效。想象一下,您有一个实体,它扩展到数十个实体,每个实体又扩展到数十个实体,依此类推,达到 10-20 个级别。这将迅速需要大量的 RAM 来解码,但只需要几千字节来构建并通过网络发送。

此外,我们暴露子属性意味着我们暴露了比我们预期的要多得多。

您肯定希望暴露time服务的currentTime方法:

client.time.currentTime()

请注意,您正在暴露TimeServices中声明的每个不以_开头的属性或方法。

在旧版本的 Python(如 2.7)中,这实际上意味着也暴露了内部代码,因为您可以通过诸如以下方式访问所有公共变量:

client.time.currentTime.im_func.func_globals.keys()

然后,您可以通过以下方式检索它们的值:

client.time.currentTime.im_func.func_globals.get('varname')

这是一个重大的安全问题。

幸运的是,函数的im_func属性已更名为__func__,因此不再可访问。但是,对于您自己声明的任何属性,仍然存在这个问题。

第十一章:Web 开发

在本章中,我们将介绍以下配方:

  • 处理 JSON - 如何解析和编写 JSON 对象

  • 解析 URL - 如何解析 URL 的路径、查询和其他部分

  • 消费 HTTP - 如何从 HTTP 端点读取数据

  • 提交表单到 HTTP - 如何将 HTML 表单提交到 HTTP 端点

  • 构建 HTML - 如何生成带有适当转义的 HTML

  • 提供 HTTP - 在 HTTP 上提供动态内容

  • 提供静态文件 - 如何通过 HTTP 提供静态文件

  • Web 应用程序中的错误 - 如何报告 Web 应用程序中的错误

  • 处理表单和文件 - 解析从 HTML 表单和上传的文件接收到的数据

  • REST API - 提供基本的 REST/JSON API

  • 处理 cookies - 如何处理 cookies 以识别返回用户

介绍

HTTP 协议,更一般地说,Web 技术集,被认为是创建分布式系统的一种有效和健壮的方式,可以利用一种广泛和可靠的方式来实现进程间通信,具有可用的技术和缓存、错误传播、可重复请求的范例,以及在服务可能失败而不影响整体系统状态的情况下的最佳实践。

Python 有许多非常好的和可靠的 Web 框架,从全栈解决方案,如 Django 和 TurboGears,到更精细调整的框架,如 Pyramid 和 Flask。然而,对于许多情况来说,标准库可能已经提供了您需要实现基于 HTTP 的软件的工具,而无需依赖外部库和框架。

在本章中,我们将介绍标准库提供的一些常见配方和工具,这些工具在 HTTP 和基于 Web 的应用程序的上下文中非常方便。

处理 JSON

在使用基于 Web 的解决方案时,最常见的需求之一是解析和处理 JSON。Python 内置支持 XML 和 HTML,还支持 JSON 编码和解码。

JSON 编码器也可以被专门化以处理非标准类型,如日期。

如何做...

对于这个配方,需要执行以下步骤:

  1. JSONEncoderJSONDecoder类可以被专门化以实现自定义的编码和解码行为:
import json
import datetime
import decimal
import types

class CustomJSONEncoder(json.JSONEncoder):
    """JSON Encoder with support for additional types.

    Supports dates, times, decimals, generators and
    any custom class that implements __json__ method.
    """
    def default(self, obj):
        if hasattr(obj, '__json__') and callable(obj.__json__):
            return obj.__json__()
        elif isinstance(obj, (datetime.datetime, datetime.time)):
            return obj.replace(microsecond=0).isoformat()
        elif isinstance(obj, datetime.date):
            return obj.isoformat()
        elif isinstance(obj, decimal.Decimal):
            return float(obj)
        elif isinstance(obj, types.GeneratorType):
            return list(obj)
        else:
            return super().default(obj)
  1. 然后,我们可以将我们的自定义编码器传递给json.dumps,以根据我们的规则对 JSON 输出进行编码:
jsonstr = json.dumps({'s': 'Hello World',
                    'dt': datetime.datetime.utcnow(),
                    't': datetime.datetime.utcnow().time(),
                    'g': (i for i in range(5)),
                    'd': datetime.date.today(),
                    'dct': {
                        's': 'SubDict',
                        'dt': datetime.datetime.utcnow()
                    }}, 
                    cls=CustomJSONEncoder)

>>> print(jsonstr)
{"t": "10:53:53", 
 "s": "Hello World", 
 "d": "2018-06-29", 
 "dt": "2018-06-29T10:53:53", 
 "dct": {"dt": "2018-06-29T10:53:53", "s": "SubDict"}, 
 "g": [0, 1, 2, 3, 4]}
  1. 只要提供了__json__方法,我们也可以对任何自定义类进行编码:
class Person:
    def __init__(self, name, surname):
        self.name = name
        self.surname = surname

    def __json__(self):
        return {
            'name': self.name,
            'surname': self.surname
        }
  1. 结果将是一个包含提供数据的 JSON 对象:
>>> print(json.dumps({'person': Person('Simone', 'Marzola')}, 
                     cls=CustomJSONEncoder))
{"person": {"name": "Simone", "surname": "Marzola"}}
  1. 加载回编码值将导致纯字符串被解码,因为它们不是 JSON 类型:
>>> print(json.loads(jsonstr))
{'g': [0, 1, 2, 3, 4], 
 'd': '2018-06-29', 
 's': 'Hello World', 
 'dct': {'s': 'SubDict', 'dt': '2018-06-29T10:56:30'}, 
 't': '10:56:30', 
 'dt': '2018-06-29T10:56:30'}
  1. 如果我们还想解析回日期,我们可以尝试专门化JSONDecoder来猜测字符串是否包含 ISO 8601 格式的日期,并尝试解析它:
class CustomJSONDecoder(json.JSONDecoder):
    """Custom JSON Decoder that tries to decode additional types.

    Decoder tries to guess dates, times and datetimes in ISO format.
    """
    def __init__(self, *args, **kwargs):
        super().__init__(
            *args, **kwargs, object_hook=self.parse_object
        )

    def parse_object(self, values):
        for k, v in values.items():
            if not isinstance(v, str):
                continue

            if len(v) == 10 and v.count('-') == 2:
                # Probably contains a date
                try:
                    values[k] = datetime.datetime.strptime(v, '%Y-
                    %m-%d').date()
                except:
                    pass
            elif len(v) == 8 and v.count(':') == 2:
                # Probably contains a time
                try:
                    values[k] = datetime.datetime.strptime(v, 
                    '%H:%M:%S').time()
                except:
                    pass
            elif (len(v) == 19 and v.count('-') == 2 and 
                v.count('T') == 1 and v.count(':') == 2):
                # Probably contains a datetime
                try:
                    values[k] = datetime.datetime.strptime(v, '%Y-
                    %m-%dT%H:%M:%S')
                except:
                    pass
        return values
  1. 回到以前的数据应该导致预期的类型:
>>> jsondoc = json.loads(jsonstr, cls=CustomJSONDecoder)
>>> print(jsondoc)
{'g': [0, 1, 2, 3, 4], 
 'd': datetime.date(2018, 6, 29), 
 's': 'Hello World', 
 'dct': {'s': 'SubDict', 'dt': datetime.datetime(2018, 6, 29, 10, 56, 30)},
 't': datetime.time(10, 56, 30), 
 'dt': datetime.datetime(2018, 6, 29, 10, 56, 30)}

它是如何工作的...

要生成 Python 对象的 JSON 表示,使用json.dumps方法。该方法接受一个额外的参数cls,可以提供自定义编码器类:

json.dumps({'key': 'value', cls=CustomJSONEncoder)

每当需要编码编码器不知道如何编码的对象时,提供的类的default方法将被调用。

我们的CustomJSONEncoder类提供了一个default方法,用于处理编码日期、时间、生成器、小数和任何提供__json__方法的自定义类:

class CustomJSONEncoder(json.JSONEncoder):
    def default(self, obj):
        if hasattr(obj, '__json__') and callable(obj.__json__):
            return obj.__json__()
        elif isinstance(obj, (datetime.datetime, datetime.time)):
            return obj.replace(microsecond=0).isoformat()
        elif isinstance(obj, datetime.date):
            return obj.isoformat()
        elif isinstance(obj, decimal.Decimal):
            return float(obj)
        elif isinstance(obj, types.GeneratorType):
            return list(obj)
        else:
            return super().default(obj)

这是通过依次检查编码对象的属性来完成的。请记住,编码器知道如何编码的对象不会被提供给default方法;只有编码器不知道如何处理的对象才会传递给default方法。

因此,我们只需要检查我们想要支持的对象,而不是标准对象。

我们的第一个检查是验证提供的对象是否有__json__方法:

if hasattr(obj, '__json__') and callable(obj.__json__):
    return obj.__json__()

对于具有__json__属性的任何对象,该属性是可调用的,我们将依赖调用它来检索对象的 JSON 表示。__json__方法所需做的就是返回任何 JSON 编码器知道如何编码的对象,通常是一个dict,其中对象的属性将被存储。

对于日期的情况,我们将使用简化的 ISO 8601 格式对其进行编码:

elif isinstance(obj, (datetime.datetime, datetime.time)):
    return obj.replace(microsecond=0).isoformat()
elif isinstance(obj, datetime.date):
    return obj.isoformat()

这通常允许来自客户端的轻松解析,例如 JavaScript 解释器可能需要从提供的数据中构建date对象。

Decimal只是为了方便转换为浮点数。这在大多数情况下足够了,并且与任何 JSON 解码器完全兼容,无需任何额外的机制。当然,我们可以返回更复杂的对象,例如字典,以保留固定的精度:

elif isinstance(obj, decimal.Decimal):
    return float(obj)

最后,生成器被消耗,并从中返回包含的值的列表。这通常是您所期望的,表示生成器逻辑本身将需要不合理的努力来保证跨语言的兼容性:

elif isinstance(obj, types.GeneratorType):
    return list(obj)

对于我们不知道如何处理的任何对象,我们只需让父对象实现default方法并继续:

else:
    return super().default(obj)

这将只是抱怨对象不可 JSON 序列化,并通知开发人员我们不知道如何处理它。

自定义解码器支持的工作方式略有不同。

虽然编码器将接收它知道的对象和它不知道的对象(因为 Python 对象比 JSON 对象更丰富),但很容易看出它只能请求对它不知道的对象进行额外的指导,并对它知道如何处理的对象以标准方式进行处理。

解码器只接收有效的 JSON 对象;否则,提供的字符串根本就不是有效的 JSON。

它如何知道提供的字符串必须解码为普通字符串,还是应该要求额外的指导?

它不能,因此它要求对任何单个解码的对象进行指导。

这就是为什么解码器基于一个object_hook可调用,它将接收每个单独解码的 JSON 对象,并可以检查它以执行其他转换,或者如果正常解码是正确的,它可以让它继续。

在我们的实现中,我们对解码器进行了子类化,并提供了一个基于本地类方法parse_object的默认object_hook参数:

class CustomJSONDecoder(json.JSONDecoder):
    def __init__(self, *args, **kwargs):
        super().__init__(
            *args, **kwargs, object_hook=self.parse_object
        )

然后,parse_object方法将接收到解码 JSON(顶级或嵌套的)中找到的任何 JSON 对象;因此,它将接收到一堆字典,可以以任何需要的方式检查它们,并编辑它们的内容以执行 JSON 解码器本身执行的其他转换:

def parse_object(self, values):
    for k, v in values.items():
        if not isinstance(v, str):
            continue

        if len(v) == 10 and v.count('-') == 2:
            # Probably contains a date
            try:
                values[k] = datetime.datetime.strptime(v, '%Y-%m-
                %d').date()
            except:
                pass
        elif len(v) == 8 and v.count(':') == 2:
            # Probably contains a time
            try:
                values[k] = datetime.datetime.strptime(v, 
                '%H:%M:%S').time()
            except:
                pass
        elif (len(v) == 19 and v.count('-') == 2 and 
            v.count('T') == 1 and v.count(':') == 2):
            # Probably contains a datetime
            try:
                values[k] = datetime.datetime.strptime(v, '%Y-%m-
                %dT%H:%M:%S')
            except:
                pass
    return values

接收到的参数实际上是一个完整的 JSON 对象,因此它永远不会是单个字段;它总是一个对象(因此,一个完整的 Python 字典,具有多个键值)。

看看以下对象:

{'g': [0, 1, 2, 3, 4], 
 'd': '2018-06-29', 
 's': 'Hello World', 

您不会收到一个g键,但您将收到整个 Python 字典。这意味着如果您的 JSON 文档没有嵌套的 JSON 对象,您的object_hook将被调用一次,并且不会再有其他调用。

因此,我们的parse_object方法提供的自定义object_hook会迭代解码后的 JSON 对象的所有属性:

for k, v in values.items():
    if not isinstance(v, str):
        continue

由于 JSON 中的日期和时间通常以 ISO 8601 格式的字符串表示,因此它会忽略一切不是字符串的内容。

我们对数字、列表和字典的转换非常满意(如果您期望日期被放在列表中,可能需要转到列表),因此如果值不是字符串,我们就跳过它。

当值是字符串时,我们检查其属性,如果我们猜测它可能是日期,我们尝试将其解析为日期。

我们可以考虑日期的正确定义:由两个破折号分隔的三个值,后跟由两个冒号分隔的三个值,中间有一个"T"来分隔两个值:

elif (len(v) == 19 and v.count('-') == 2 and 
      v.count('T') == 1 and v.count(':') == 2):
    # Probably contains a datetime

如果匹配该定义,我们实际上会尝试将其解码为 Python 的datetime对象,并在解码后的 JSON 对象中替换该值:

# Probably contains a datetime
try:
    values[k] = datetime.datetime.strptime(v, '%Y-%m-%dT%H:%M:%S')
except:
    pass

还有更多...

您可能已经注意到,将 Python 编码为 JSON 是相当合理和健壮的,但返回的过程中充满了问题。

JSON 不是一种非常表达性的语言;它不提供任何用于自定义类型的机制,因此您有一种标准方法可以向解码器提供关于您期望将某些内容解码为的类型的提示。

虽然我们可以猜测2017-01-01T13:21:17这样的东西是一个日期,但我们根本没有任何保证。也许最初它实际上是一些文本,碰巧包含可以解码为日期的内容,但从未打算成为 Python 中的datetime对象。

因此,通常只在受限环境中实现自定义解码是安全的。如果您知道并控制将接收数据的源,通常可以安全地提供自定义解码。您可能希望通过使用自定义属性来扩展 JSON,这些属性可能会指导解码器(例如具有告诉您它是日期还是字符串的__type__键),但在开放的网络世界中,通常不明智地尝试猜测人们发送给您的内容,因为网络非常多样化。

有一些扩展的标准 JSON 版本试图解决解码数据中的这种歧义,例如 JSON-LD 和 JSON Schema,它们允许您在 JSON 中表示更复杂的实体。

如果有必要,您应该依赖这些标准,以避免重新发明轮子的风险,并面对您的解决方案已经由现有标准解决的限制。

解析 URL

在处理基于 Web 的软件时,经常需要了解链接、协议和路径。

您可能会倾向于依赖正则表达式或字符串拆分来解析 URL,但是如果考虑到 URL 可能包含的所有奇特之处(例如凭据或特定协议等),它可能并不像您期望的那样容易。

Python 提供了urllibcgi模块中的实用工具,当您想要考虑 URL 可能具有的所有可能不同的格式时,这些工具可以使生活更轻松。

依靠它们可以使生活更轻松,使您的软件更健壮。

如何做...

urllib.parse模块具有多种工具可用于解析 URL。最常用的解决方案是依赖于urllib.parse.urlparse,它可以处理最常见的 URL 类型:

import urllib.parse

def parse_url(url):
    """Parses an URL of the most widespread format.

    This takes for granted there is a single set of parameters
    for the whole path.
    """
    parts = urllib.parse.urlparse(url)
    parsed = vars(parts)
    parsed['query'] = urllib.parse.parse_qs(parts.query)
    return parsed

可以在命令行上调用前面的代码片段,如下所示:

>>> url = 'http://user:pwd@host.com:80/path/subpath?arg1=val1&arg2=val2#fragment'
>>> result = parse_url(url)
>>> print(result)
OrderedDict([('scheme', 'http'),
             ('netloc', 'user:pwd@host.com:80'),
             ('path', '/path/subpath'),
             ('params', ''),
             ('query', {'arg1': ['val1'], 'arg2': ['val2']}),
             ('fragment', 'fragment')])

返回的OrderedDict包含组成我们的 URL 的所有部分,并且对于查询参数,它们已经被解析。

还有更多...

如今,URI 还支持在每个路径段中提供参数。这在实践中很少使用,但如果您的代码预期接收此类 URI,则不应依赖于urllib.parse.urlparse,因为它尝试从 URL 中解析参数,而这对于这些 URI 来说并不受支持:

>>> url = 'http://user:pwd@host.com:80/root;para1/subpath;para2?arg1=val1#fragment'
>>> result = urllib.parse.urlparse(url)
>>> print(result)
ParseResult(scheme='http', netloc='user:pwd@host.com:80', 
            path='/root;para1/subpath', 
            params='para2', 
            query='arg1=val1', 
            fragment='fragment')

您可能已经注意到,路径的最后一部分的参数在params中被正确解析,但是第一部分的参数保留在path中。

在这种情况下,您可能希望依赖于urllib.parse.urlsplit,它不会解析参数,而会将它们保留下来供您解析。因此,您可以自行拆分 URL 段和参数:

>>> parsed = urllib.parse.urlsplit(url)
>>> print(parsed)
SplitResult(scheme='http', netloc='user:pwd@host.com:80', 
            path='/root;para1/subpath;para2', 
            query='arg1=val1', 
            fragment='fragment')

请注意,在这种情况下,所有参数都保留在“路径”中,然后您可以自行拆分它们。

HTTP 消费

您可能正在与基于 HTTP REST API 的第三方服务进行交互,或者可能正在从第三方获取内容或仅下载软件需要的文件。这并不重要。如今,几乎不可能编写一个应用程序并忽略 HTTP;您迟早都会面对它。人们期望各种应用程序都支持 HTTP。如果您正在编写图像查看器,他们可能希望能够将指向图像的 URL 传递给它并看到图像出现。

虽然它们从来没有真正用户友好和明显,但 Python 标准库一直有与 HTTP 交互的方式,并且这些方式可以直接使用。

如何做到这一点...

此处的步骤如下:

  1. urllib.request模块提供了提交 HTTP 请求所需的机制。它的轻量级包装可以解决大多数 HTTP 使用需求:
import urllib.request
import urllib.parse
import json

def http_request(url, query=None, method=None, headers={}, data=None):
    """Perform an HTTP request and return the associated response."""
    parts = vars(urllib.parse.urlparse(url))
    if query:
        parts['query'] = urllib.parse.urlencode(query)

    url = urllib.parse.ParseResult(**parts).geturl()
    r = urllib.request.Request(url=url, method=method, 
                            headers=headers,
                            data=data)
    with urllib.request.urlopen(r) as resp:
        msg, resp = resp.info(), resp.read()

    if msg.get_content_type() == 'application/json':
        resp = json.loads(resp.decode('utf-8'))

    return msg, resp
  1. 我们可以使用我们的http_request函数执行请求以获取文件:
>>> msg, resp = http_request('https://httpbin.org/bytes/16')
>>> print(msg.get_content_type(), resp)
application/octet-stream b'k\xe3\x05\x06=\x17\x1a9%#\xd0\xae\xd8\xdc\xf9>'
  1. 我们还可以使用它与基于 JSON 的 API 进行交互:
>>> msg, resp = http_request('https://httpbin.org/get', query={
...     'a': 'Hello',
...     'b': 'World'
... })
>>> print(msg.get_content_type(), resp)
application/json
{'url': 'https://httpbin.org/get?a=Hello&b=World', 
 'headers': {'Accept-Encoding': 'identity', 
             'User-Agent': 'Python-urllib/3.5', 
             'Connection': 'close', 
             'Host': 'httpbin.org'}, 
 'args': {'a': 'Hello', 'b': 'World'}, 
 'origin': '127.19.102.123'}
  1. 它还可以用于提交或上传数据到端点:
>>> msg, resp = http_request('https://httpbin.org/post', method='POST',
...                          data='This is my posted data!'.encode('ascii'),
...                          headers={'Content-Type': 'text/plain'})
>>> print(msg.get_content_type(), resp)
application/json 
{'data': 'This is my posted data!', 
 'json': None, 
 'form': {}, 
 'args': {}, 
 'files': {}, 
 'headers': {'User-Agent': 'Python-urllib/3.5', 
             'Connection': 'close', 
             'Content-Type': 'text/plain', 
             'Host': 'httpbin.org', 
             'Accept-Encoding': 'identity', 
             'Content-Length': '23'}, 
 'url': 'https://httpbin.org/post', 
 'origin': '127.19.102.123'}

它是如何工作的...

http_request方法负责创建urllib.request.Request实例,通过网络发送它并获取响应。

向指定的 URL 发送请求,其中附加了查询参数。

函数的第一件事是解析 URL,以便能够替换其中的部分。这样做是为了能够用提供的部分替换/追加查询参数:

parts = vars(urllib.parse.urlparse(url))
if query:
    parts['query'] = urllib.parse.urlencode(query)

urllib.parse.urlencode将接受一个参数字典,例如{'a': 5, 'b': 7},并将返回带有urlencode参数的字符串:'b=7&a=5'

然后,将生成的查询字符串放入url的解析部分中,以替换当前存在的查询参数。

然后,从现在包括正确查询参数的所有部分构建url

url = urllib.parse.ParseResult(**parts).geturl()

一旦准备好带有编码查询的url,它就会构建一个请求,代理指定的方法、标头和请求的主体:

r = urllib.request.Request(url=url, method=method, headers=headers,
                           data=data)

在进行普通的GET请求时,这些将是默认的,但能够指定它们允许我们执行更高级的请求,例如POST,或者在我们的请求中提供特殊的标头。

然后打开请求并读取响应:

with urllib.request.urlopen(r) as resp:
    msg, resp = resp.info(), resp.read()

响应以urllib.response.addinfourl对象的形式返回,其中包括两个相关部分:响应的主体和一个http.client.HTTPMessage,我们可以从中获取所有响应信息,如标头、URL 等。

通过像读取文件一样读取响应来检索主体,而通过info()方法检索HTTPMessage

通过检索的信息,我们可以检查响应是否为 JSON 响应,在这种情况下,我们将其解码回字典,以便我们可以浏览响应而不仅仅是接收纯字节:

if msg.get_content_type() == 'application/json':
    resp = json.loads(resp.decode('utf-8'))

对于所有响应,我们返回消息和主体。如果不需要,调用者可以忽略消息:

return msg, resp

还有更多...

对于简单的情况来说,进行 HTTP 请求可能非常简单,但对于更复杂的情况来说可能非常复杂。完美地处理 HTTP 协议可能是一项漫长而复杂的工作,特别是因为协议规范本身并不总是清楚地规定事物应该如何工作,很多都来自于对现有的网络服务器和客户端工作方式的经验。

因此,如果您的需求超出了仅仅获取简单端点的范围,您可能希望依赖于第三方库来执行 HTTP 请求,例如几乎适用于所有 Python 环境的 requests 库。

向 HTTP 提交表单

有时您必须与 HTML 表单交互或上传文件。这通常需要处理multipart/form-data编码。

表单可以混合文件和文本数据,并且表单中可以有多个不同的字段。因此,它需要一种方式来在同一个请求中表示多个字段,其中一些字段可以是二进制文件。

这就是为什么在多部分中编码数据可能会变得棘手,但是可以使用标准库工具来制定一个基本的食谱,以便在大多数情况下都能正常工作。

如何做到这一点...

以下是此食谱的步骤:

  1. multipart本身需要跟踪我们想要编码的所有字段和文件,然后执行编码本身。

  2. 我们将依赖io.BytesIO来存储所有生成的字节:

import io
import mimetypes
import uuid

class MultiPartForm:
    def __init__(self):
        self.fields = {}
        self.files = []

    def __setitem__(self, name, value):
        self.fields[name] = value

    def add_file(self, field, filename, data, mimetype=None):
        if mimetype is None:
            mimetype = (mimetypes.guess_type(filename)[0] or
                        'application/octet-stream')
        self.files.append((field, filename, mimetype, data))

    def _generate_bytes(self, boundary):
        buffer = io.BytesIO()
        for field, value in self.fields.items():
            buffer.write(b'--' + boundary + b'\r\n')
            buffer.write('Content-Disposition: form-data; '
                        'name="{}"\r\n'.format(field).encode('utf-8'))
            buffer.write(b'\r\n')
            buffer.write(value.encode('utf-8'))
            buffer.write(b'\r\n')
        for field, filename, f_content_type, body in self.files:
            buffer.write(b'--' + boundary + b'\r\n')
            buffer.write('Content-Disposition: file; '
                        'name="{}"; filename="{}"\r\n'.format(
                            field, filename
                        ).encode('utf-8'))
            buffer.write('Content-Type: {}\r\n'.format(
                f_content_type
            ).encode('utf-8'))
            buffer.write(b'\r\n')
            buffer.write(body)
            buffer.write(b'\r\n')
        buffer.write(b'--' + boundary + b'--\r\n')
        return buffer.getvalue()

    def encode(self):
        boundary = uuid.uuid4().hex.encode('ascii')
        while boundary in self._generate_bytes(boundary=b'NOBOUNDARY'):
            boundary = uuid.uuid4().hex.encode('ascii')

        content_type = 'multipart/form-data; boundary={}'.format(
            boundary.decode('ascii')
        )
        return content_type, self._generate_bytes(boundary)
  1. 然后我们可以提供并编码我们的form数据:
>>> form = MultiPartForm()
>>> form['name'] = 'value'
>>> form.add_file('file1', 'somefile.txt', b'Some Content', 'text/plain')
>>> content_type, form_body = form.encode()
>>> print(content_type, '\n\n', form_body.decode('ascii'))
multipart/form-data; boundary=6c5109dfa19a450695013d4eecac2b0b 

--6c5109dfa19a450695013d4eecac2b0b
Content-Disposition: form-data; name="name"

value
--6c5109dfa19a450695013d4eecac2b0b
Content-Disposition: file; name="file1"; filename="somefile.txt"
Content-Type: text/plain

Some Content
--6c5109dfa19a450695013d4eecac2b0b--
  1. 使用我们先前食谱中的http_request方法,我们可以通过 HTTP 提交任何form
>>> _, resp = http_request('https://httpbin.org/post', method='POST',
                           data=form_body, 
                           headers={'Content-Type': content_type})
>>> print(resp)
{'headers': {
    'Accept-Encoding': 'identity', 
    'Content-Type': 'multipart/form-data; boundary=6c5109dfa19a450695013d4eecac2b0b', 
    'User-Agent': 'Python-urllib/3.5', 
    'Content-Length': '272', 
    'Connection': 'close', 
    'Host': 'httpbin.org'
 }, 
 'json': None,
 'url': 'https://httpbin.org/post', 
 'data': '', 
 'args': {}, 
 'form': {'name': 'value'}, 
 'origin': '127.69.102.121', 
 'files': {'file1': 'Some Content'}}

正如你所看到的,httpbin正确接收了我们的file1和我们的name字段,并对两者进行了处理。

工作原理...

multipart实际上是基于在单个主体内编码多个请求。每个部分都由一个boundary分隔,而在边界内则是该部分的数据。

每个部分都可以提供数据和元数据,例如所提供数据的内容类型。

这样接收者就可以知道所包含的数据是二进制、文本还是其他类型。例如,指定formsurname字段值的部分将如下所示:

Content-Disposition: form-data; name="surname"

MySurname

提供上传文件数据的部分将如下所示:

Content-Disposition: file; name="file1"; filename="somefile.txt"
Content-Type: text/plain

Some Content

我们的MultiPartForm允许我们通过字典语法存储纯form字段:

def __setitem__(self, name, value):
    self.fields[name] = value

我们可以在命令行上调用它,如下所示:

>>> form['name'] = 'value'

并通过add_file方法提供文件:

def add_file(self, field, filename, data, mimetype=None):
    if mimetype is None:
        mimetype = (mimetypes.guess_type(filename)[0] or
                    'application/octet-stream')
    self.files.append((field, filename, mimetype, data))

我们可以在命令行上调用这个方法,如下所示:

>>> form.add_file('file1', 'somefile.txt', b'Some Content', 'text/plain')

这些只是在稍后调用_generate_bytes时才会使用的字典和列表,用于记录想要的字段和文件。

所有的辛苦工作都是由_generate_bytes完成的,它会遍历所有这些字段和文件,并为每一个创建一个部分:

for field, value in self.fields.items():
    buffer.write(b'--' + boundary + b'\r\n')
    buffer.write('Content-Disposition: form-data; '
                'name="{}"\r\n'.format(field).encode('utf-8'))
    buffer.write(b'\r\n')
    buffer.write(value.encode('utf-8'))
    buffer.write(b'\r\n')

由于边界必须分隔每个部分,非常重要的是要验证边界是否不包含在数据本身中,否则接收者可能会在遇到它时错误地认为部分已经结束。

这就是为什么我们的MultiPartForm类会生成一个boundary,检查它是否包含在多部分响应中,如果是,则生成一个新的,直到找到一个不包含在数据中的boundary

boundary = uuid.uuid4().hex.encode('ascii')
while boundary in self._generate_bytes(boundary=b'NOBOUNDARY'):
    boundary = uuid.uuid4().hex.encode('ascii')

一旦我们找到了一个有效的boundary,我们就可以使用它来生成多部分内容,并将其返回给调用者,同时提供必须使用的内容类型(因为内容类型为接收者提供了关于要检查的boundary的提示):

content_type = 'multipart/form-data; boundary={}'.format(
    boundary.decode('ascii')
)
return content_type, self._generate_bytes(boundary)

还有更多...

多部分编码并不是一个简单的主题;例如,在多部分主体中对名称的编码并不是一个简单的话题。

多年来,关于在多部分内容中对字段名称和文件名称进行正确编码的方式已经多次更改和讨论。

从历史上看,在这些字段中只依赖于纯 ASCII 名称是安全的,因此,如果您想确保您提交的数据的服务器能够正确接收您的数据,您可能希望坚持使用简单的文件名和字段,不涉及 Unicode 字符。

多年来,提出了多种其他编码这些字段和文件名的方法。UTF-8 是 HTML5 的官方支持的后备之一。建议的食谱依赖于 UTF-8 来编码文件名和字段,以便与使用纯 ASCII 名称的情况兼容,但仍然可以在服务器支持它们时依赖于 Unicode 字符。

构建 HTML

每当您构建网页、电子邮件或报告时,您可能会依赖用实际值替换 HTML 模板中的占位符,以便向用户显示所需的内容。

我们已经在第二章中看到了文本管理,如何实现一个最小的简单模板引擎,但它并不特定于 HTML。

在处理 HTML 时,特别重要的是要注意对用户提供的值进行转义,因为这可能导致页面损坏甚至 XSS 攻击。

显然,您不希望您的用户因为您在网站上注册时使用姓氏"<script>alert('You are hacked!')</script>"而对您生气。

出于这个原因,Python 标准库提供了可以用于正确准备内容以插入 HTML 的转义工具。

如何做...

结合string.Formattercgi模块,可以创建一个负责为我们进行转义的格式化程序:

import string
import cgi

class HTMLFormatter(string.Formatter):
    def get_field(self, field_name, args, kwargs):
        val, key = super().get_field(field_name, args, kwargs)
        if hasattr(val, '__html__'):
            val = val.__html__()
        elif isinstance(val, str):
            val = cgi.escape(val)
        return val, key

class Markup:
    def __init__(self, v):
        self.v = v
    def __str__(self):
        return self.v
    def __html__(self):
        return str(self)

然后我们可以在需要时使用HTMLFormatterMarkup类,同时保留注入原始html的能力:

>>> html = HTMLFormatter().format('Hello {name}, you are {title}', 
                                  name='<strong>Name</strong>',
                                  title=Markup('<em>a developer</em>'))
>>> print(html)
Hello &lt;strong&gt;Name&lt;/strong&gt;, you are <em>a developer</em>

我们还可以轻松地将此配方与有关文本模板引擎的配方相结合,以实现一个具有转义功能的极简 HTML 模板引擎。

它是如何工作的...

每当HTMLFormatter需要替换格式字符串中的值时,它将检查检索到的值是否具有__html__方法:

if hasattr(val, '__html__'):
    val = val.__html__()

如果存在该方法,则预计返回值的 HTML 表示。并且预计是一个完全有效和转义的 HTML。

否则,预计值将是需要转义的字符串:

elif isinstance(val, str):
    val = cgi.escape(val)

这样,我们提供给HTMLFormatter的任何值都会默认进行转义:

>>> html = HTMLFormatter().format('Hello {name}', 
                                  name='<strong>Name</strong>')
>>> print(html)
Hello &lt;strong&gt;Name&lt;/strong&gt;

如果我们想要避免转义,我们可以依赖Markup对象,它可以包装一个字符串,使其原样传递而不进行任何转义:

>>> html = HTMLFormatter().format('Hello {name}', 
                                  name=Markup('<strong>Name</strong>'))
>>> print(html)
Hello <strong>Name</strong>

这是因为我们的Markup对象实现了一个__html__方法,该方法返回原样的字符串。由于我们的HTMLFormatter忽略了任何具有__html__方法的值,因此我们的字符串将无需任何形式的转义而通过。

虽然Markup允许我们根据需要禁用转义,但是当我们知道实际上需要 HTML 时,我们可以将 HTML 方法应用于任何其他对象。需要在网页中表示的任何对象都可以提供一个__html__方法,并将根据它自动转换为 HTML。

例如,您可以向您的User类添加__html__,并且每当您想要将用户放在网页中时,您只需要提供User实例本身。

提供 HTTP

通过 HTTP 进行交互是分布式应用程序或完全分离的软件之间最常见的通信手段之一,也是所有现有 Web 应用程序和基于 Web 的工具的基础。

虽然 Python 有数十个出色的 Web 框架可以满足大多数不同的需求,但标准库本身具有您可能需要实现基本 Web 应用程序的所有基础。

如何做...

Python 有一个方便的协议名为 WSGI 来实现基于 HTTP 的应用程序。对于更高级的需求,可能需要一个 Web 框架;对于非常简单的需求,Python 本身内置的wsgiref实现可以满足我们的需求:

import re
import inspect
from wsgiref.headers import Headers
from wsgiref.simple_server import make_server
from wsgiref.util import request_uri
from urllib.parse import parse_qs

class WSGIApplication:
    def __init__(self):
        self.routes = []

    def route(self, path):
        def _route_decorator(f):
            self.routes.append((re.compile(path), f))
            return f
        return _route_decorator

    def serve(self):
        httpd = make_server('', 8000, self)
        print("Serving on port 8000...")
        httpd.serve_forever()

    def _not_found(self, environ, resp):
        resp.status = '404 Not Found'
        return b"""<h1>Not Found</h1>"""

    def __call__(self, environ, start_response):
        request = Request(environ)

        routed_action = self._not_found
        for regex, action in self.routes:
            match = regex.fullmatch(request.path)
            if match:
                routed_action = action
                request.urlargs = match.groupdict()
                break

        resp = Response()

        if inspect.isclass(routed_action):
            routed_action = routed_action()
        body = routed_action(request, resp)

        resp.send(start_response)
        return [body]

class Response:
    def __init__(self):
        self.status = '200 OK'
        self.headers = Headers([
            ('Content-Type', 'text/html; charset=utf-8')
        ])

    def send(self, start_response):
        start_response(self.status, self.headers.items())

class Request:
    def __init__(self, environ):
        self.environ = environ
        self.urlargs = {}

    @property
    def path(self):
        return self.environ['PATH_INFO']

    @property
    def query(self):
        return parse_qs(self.environ['QUERY_STRING'])

然后我们可以创建一个WSGIApplication并向其注册任意数量的路由:

app = WSGIApplication()

@app.route('/')
def index(request, resp):
    return b'Hello World, <a href="/link">Click here</a>'

@app.route('/link')
def link(request, resp):
    return (b'You clicked the link! '
            b'Try <a href="/args?a=1&b=2">Some arguments</a>')

@app.route('/args')
def args(request, resp):
    return (b'You provided %b<br/>'
            b'Try <a href="/name/HelloWorld">URL Arguments</a>' % 
            repr(request.query).encode('utf-8'))

@app.route('/name/(?P<first_name>\\w+)')
def name(request, resp):
    return (b'Your name: %b' % request.urlargs['first_name'].encode('utf-8'))

一旦准备就绪,我们只需要提供应用程序:

app.serve()

如果一切正常,通过将浏览器指向http://localhost:8000,您应该会看到一个 Hello World 文本和一个链接,引导您到进一步提供查询参数,URL 参数并在各种 URL 上提供服务的页面。

它是如何工作的...

WSGIApplication创建一个负责提供 Web 应用程序本身(self)的 WSGI 服务器:

def serve(self):
    httpd = make_server('', 8000, self)
    print("Serving on port 8000...")
    httpd.serve_forever()

在每个请求上,服务器都会调用WSGIApplication.__call__来检索该请求的响应。

WSGIApplication.__call__扫描所有注册的路由(每个路由可以使用app.route(path)注册,其中path是正则表达式)。当正则表达式与当前 URL 路径匹配时,将调用注册的函数以生成该路由的响应:

def __call__(self, environ, start_response):
    request = Request(environ)

    routed_action = self._not_found
    for regex, action in self.routes:
        match = regex.fullmatch(request.path)
        if match:
            routed_action = action
            request.urlargs = match.groupdict()
            break

一旦找到与路径匹配的函数,就会调用该函数以获取响应主体,然后将生成的主体返回给服务器:

resp = Response()
body = routed_action(request, resp)

resp.send(start_response)
return [body]

在返回主体之前,将调用Response.send通过start_response可调用发送响应 HTTP 标头和状态。

ResponseRequest对象用于保留当前请求的环境(以及从 URL 解析的任何附加参数)、响应的标头和状态。这样,处理请求的操作可以接收它们并检查请求或在发送之前添加/删除响应的标头。

还有更多...

虽然基本的基于 HTTP 的应用程序可以使用提供的WSGIApplication实现,但完整功能的应用程序还有很多缺失或不完整的地方。

在涉及更复杂的 Web 应用程序时,通常需要缓存、会话、身份验证、授权、管理数据库连接、事务和管理等部分,并且大多数 Python Web 框架都可以轻松为您提供这些部分。

实现完整的 Web 框架不在本书的范围之内,当 Python 环境中有许多出色的 Web 框架可用时,您可能应该尽量避免重复造轮子。

Python 拥有广泛的 Web 框架,涵盖了从用于快速开发的全栈框架(如 Django)到面向 API 的微框架(如 Flask)以及灵活的解决方案(如 Pyramid 和 TurboGears),其中所需的部分可以根据需要启用、禁用或替换,从全栈解决方案到微框架。

提供静态文件

有时在处理基于 JavaScript 的应用程序或静态网站时,有必要能够直接从磁盘上提供目录的内容。

Python 标准库提供了一个现成的 HTTP 服务器,用于处理请求,并将它们映射到目录中的文件,因此我们可以快速地编写自己的 HTTP 服务器来编写网站,而无需安装任何其他工具。

如何做...

http.server模块提供了实现负责提供目录内容的 HTTP 服务器所需的大部分内容:

import os.path
import socketserver
from http.server import SimpleHTTPRequestHandler, HTTPServer

def serve_directory(path, port=8000):
    class ConfiguredHandler(HTTPDirectoryRequestHandler):
        SERVED_DIRECTORY = path
    httpd = ThreadingHTTPServer(("", port), ConfiguredHandler)
    print("serving on port", port)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        httpd.server_close()

class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
    pass

class HTTPDirectoryRequestHandler(SimpleHTTPRequestHandler):
    SERVED_DIRECTORY = '.'

    def translate_path(self, path):
        path = super().translate_path(path)
        relpath = os.path.relpath(path)
        return os.path.join(self.SERVED_DIRECTORY, relpath)

然后serve_directory可以针对任何路径启动,以在http://localhost:8000上提供该路径的内容:

serve_directory('/tmp')

将浏览器指向http://localhost:8000应该列出/tmp目录的内容,并允许您浏览它并查看任何文件的内容。

工作原理...

ThreadingHTTPServerHTTPServerThreadingMixin结合在一起,这允许您一次提供多个请求。

这在提供静态网站时尤其重要,因为浏览器经常保持连接时间比需要的更长,当一次只提供一个请求时,您可能无法获取您的 CSS 或 JavaScript 文件,直到浏览器关闭前一个连接。

对于每个请求,HTTPServer将其转发到指定的处理程序进行处理。SimpleHTTPRequestHandler能够提供请求,将其映射到磁盘上的本地文件,但在大多数 Python 版本中,它只能从当前目录提供服务。

为了能够从任何目录提供请求,我们提供了一个自定义的translate_path方法,它替换了相对于SERVED_DIRECTORY类变量的标准实现产生的路径。

然后serve_directory将所有内容放在一起,并将HTTPServer与定制的请求处理程序结合在一起,以创建一个能够处理提供路径的请求的服务器。

还有更多...

在较新的 Python 版本中,关于http.server模块已经发生了很多变化。最新版本 Python 3.7 已经提供了ThreadingHTTPServer类,并且现在可以配置特定目录由SimpleHTTPRequestHandler提供服务,因此无需自定义translate_path方法来提供特定目录的服务。

Web 应用程序中的错误

通常,当 Python WSGI Web 应用程序崩溃时,您会在终端中获得一个回溯,浏览器中的路径为空。

这并不是很容易调试发生了什么,除非您明确检查终端,否则很容易错过页面没有显示出来的情况,因为它实际上崩溃了。

幸运的是,Python 标准库为 Web 应用程序提供了一些基本的调试工具,使得可以将崩溃报告到浏览器中,这样您就可以在不离开浏览器的情况下查看并修复它们。

如何做...

cgitb模块提供了将异常及其回溯格式化为 HTML 的工具,因此我们可以利用它来实现一个 WSGI 中间件,该中间件可以包装任何 Web 应用程序,以在浏览器中提供更好的错误报告:

import cgitb
import sys

class ErrorMiddleware:
    """Wrap a WSGI application to display errors in the browser"""
    def __init__(self, app):
        self.app = app

    def __call__(self, environ, start_response):
        app_iter = None
        try:
            app_iter = self.app(environ, start_response)
            for item in app_iter:
                yield item
        except:
            try:
                start_response('500 INTERNAL SERVER ERROR', [
                    ('Content-Type', 'text/html; charset=utf-8'),
                    ('X-XSS-Protection', '0'),
                ])
            except Exception:
                # There has been output but an error occurred later on. 
                # In that situation we can do nothing fancy anymore, 
                # better log something into the error log and fallback.
                environ['wsgi.errors'].write(
                    'Debugging middleware caught exception in streamed '
                    'response after response headers were already sent.\n'
                )
            else:
                yield cgitb.html(sys.exc_info()).encode('utf-8')
        finally:
            if hasattr(app_iter, 'close'):
                app_iter.close()

ErrorMiddleware可以用于包装任何 WSGI 应用程序,以便在出现错误时将错误显示在 Web 浏览器中。

例如,我们可以从上一个示例中重新获取我们的WSGIApplication,添加一个将导致崩溃的路由,并提供包装后的应用程序以查看错误如何报告到 Web 浏览器中:

from web_06 import WSGIApplication
from wsgiref.simple_server import make_server

app = WSGIApplication()

@app.route('/crash')
def crash(req, resp):
    raise RuntimeError('This is a crash!')

app = ErrorMiddleware(app)

httpd = make_server('', 8000, app)
print("Serving on port 8000...")
httpd.serve_forever()

一旦将浏览器指向http://localhost:8000/crash,您应该看到触发异常的精美格式的回溯。

工作原理...

ErrorMiddleware接收原始应用程序并替换请求处理。

所有 HTTP 请求都将被ErrorMiddleware接收,然后将其代理到应用程序,返回应用程序提供的结果响应。

如果在消耗应用程序响应时出现异常,它将停止标准流程,而不是进一步消耗应用程序的响应,它将格式化异常并将其作为响应发送回浏览器。

这是因为ErrorMiddleware.__call__实际上调用了包装的应用程序并迭代了任何提供的结果:

def __call__(self, environ, start_response):
    app_iter = None
    try:
        app_iter = self.app(environ, start_response)
        for item in app_iter:
            yield item
    ...

这种方法适用于返回正常响应的应用程序和返回生成器作为响应的应用程序。

如果在调用应用程序或消耗响应时出现错误,则会捕获错误并尝试使用新的start_response来通知服务器错误到浏览器:

except:
    try:
        start_response('500 INTERNAL SERVER ERROR', [
            ('Content-Type', 'text/html; charset=utf-8'),
            ('X-XSS-Protection', '0'),
        ])

如果start_response失败,这意味着被包装的应用程序已经调用了start_response,因此不可能再更改响应状态码或标头。

在这种情况下,由于我们无法再提供精美格式的响应,我们只能退回到在终端上提供错误:

except Exception:
    # There has been output but an error occurred later on. 
    # In that situation we can do nothing fancy anymore, 
    # better log something into the error log and fallback.
    environ['wsgi.errors'].write(
        'Debugging middleware caught exception in streamed '
        'response after response headers were already sent.\n'
    )

如果start_response成功,我们将停止返回应用程序响应的内容,而是返回错误和回溯,由cgitb精美格式化:

else:
    yield cgitb.html(sys.exc_info()).encode('utf-8')

在这两种情况下,如果它提供了close方法,我们将关闭应用程序响应。这样,如果它是一个需要关闭的文件或任何源,我们就可以避免泄漏它:

finally:
    if hasattr(app_iter, 'close'):
        app_iter.close()

还有更多...

Python 标准库之外还提供了更完整的 Web 应用程序错误报告解决方案。如果您有进一步的需求或希望通过电子邮件或通过 Sentry 等云错误报告解决方案通知错误,您可能需要提供一个错误报告 WSGI 库。

来自 Flask 的Werkzeug调试器,来自 Pylons 项目的WebError库,以及来自 TurboGears 项目的Backlash库可能是这个目的最常见的解决方案。

您可能还想检查您的 Web 框架是否提供了一些高级的错误报告配置,因为其中许多提供了这些功能,依赖于这些库或其他工具。

处理表单和文件

在提交表单和上传文件时,它们通常以multipart/form-data编码发送。

我们已经看到如何创建以multipart/form-data编码的数据,并将其提交到端点,但是如何处理以这种格式接收的数据呢?

如何做...

标准库中的cgi.FieldStorage类已经提供了解析多部分数据并以易于处理的方式发送回数据所需的所有机制。

我们将创建一个简单的 Web 应用程序(基于WSGIApplication),以展示如何使用cgi.FieldStorage来解析上传的文件并将其显示给用户:

import cgi

from web_06 import WSGIApplication
import base64

app = WSGIApplication()

@app.route('/')
def index(req, resp):
    return (
        b'<form action="/upload" method="post" enctype="multipart/form-
           data">'
        b'  <input type="file" name="uploadedfile"/>'
        b'  <input type="submit" value="Upload">'
        b'</form>'
    )

@app.route('/upload')
def upload(req, resp):
    form = cgi.FieldStorage(fp=req.environ['wsgi.input'], 
                            environ=req.environ)
    if 'uploadedfile' not in form:
        return b'Nothing uploaded'

    uploadedfile = form['uploadedfile']
    if uploadedfile.type.startswith('image'):
        # User uploaded an image, show it
        return b'<img src="data:%b;base64,%b"/>' % (
            uploadedfile.type.encode('ascii'),
            base64.b64encode(uploadedfile.file.read())
        )
    elif uploadedfile.type.startswith('text'):
        return uploadedfile.file.read()
    else:
        return b'You uploaded %b' % uploadedfile.filename.encode('utf-8')

app.serve()

工作原理...

该应用程序公开了两个网页。一个位于网站的根目录(通过index函数),只显示一个带有上传字段的简单表单。

另一个upload函数,接收上传的文件,如果是图片或文本文件,则显示出来。在其他情况下,它将只显示上传文件的名称。

处理多部分格式上传的唯一要求是创建一个cgi.FieldStorage

form = cgi.FieldStorage(fp=req.environ['wsgi.input'], 
                        environ=req.environ)

POST请求的整个主体始终在environ请求中可用,使用wsgi.input键。

这提供了一个类似文件的对象,可以读取以消耗已发布的数据。确保在创建FieldStorage后将其保存,如果需要多次使用它,因为一旦从wsgi.input中消耗了数据,它就变得不可访问。

cgi.FieldStorage提供了类似字典的接口,因此我们可以通过检查uploadedfile条目是否存在来检查是否上传了文件:

if 'uploadedfile' not in form:
    return b'Nothing uploaded'

这是因为在我们的表单中,我们提供了uploadedfile作为字段的名称:

b'  <input type="file" name="uploadedfile"/>'

该特定字段将可以通过form['uploadedfile']访问。

因为它是一个文件,它将返回一个对象,通过该对象我们可以检查上传文件的 MIME 类型,以确定它是否是一张图片:

if uploadedfile.type.startswith('image'):

如果它是一张图片,我们可以读取它的内容,将其编码为base64,这样它就可以被img标签显示出来:

base64.b64encode(uploadedfile.file.read())

filename属性仅在上传文件是无法识别的格式时使用,这样我们至少可以打印出上传文件的名称:

return b'You uploaded %b' % uploadedfile.filename.encode('utf-8')

REST API

REST 与 JSON 已成为基于 Web 的应用程序之间的跨应用程序通信技术的事实标准。

这是一个非常有效的协议,而且它的定义可以被每个人理解,这使得它很快就变得流行起来。

与其他更复杂的通信协议相比,快速的 REST 实现可以相对快速地推出。

由于 Python 标准库提供了我们构建基于 WSGI 的应用程序所需的基础,因此很容易扩展我们现有的配方以支持基于 REST 的请求分发。

如何做...

我们将使用我们之前的配方中的WSGIApplication,但是不是为根注册一个函数,而是注册一个能够根据请求方法进行分发的特定类。

  1. 我们想要实现的所有 REST 类都必须继承自单个RestController实现:
class RestController:
    def __call__(self, req, resp):
        method = req.environ['REQUEST_METHOD']
        action = getattr(self, method, self._not_found)
        return action(req, resp)

    def _not_found(self, environ, resp):
        resp.status = '404 Not Found'
        return b'{}'  # Provide an empty JSON document
  1. 然后我们可以子类化RestController来实现所有特定的GETPOSTDELETEPUT方法,并在特定路由上注册资源:
import json
from web_06 import WSGIApplication

app = WSGIApplication()

@app.route('/resources/?(?P<id>\\w*)')
class ResourcesRestController(RestController):
    RESOURCES = {}

    def GET(self, req, resp):
        resource_id = req.urlargs['id']
        if not resource_id:
            # Whole catalog requested
            return json.dumps(self.RESOURCES).encode('utf-8')

        if resource_id not in self.RESOURCES:
            return self._not_found(req, resp)

        return json.dumps(self.RESOURCES[resource_id]).encode('utf-8')

    def POST(self, req, resp):
        content_length = int(req.environ['CONTENT_LENGTH'])
        data = req.environ['wsgi.input'].read(content_length).decode('utf-8')

        resource = json.loads(data)
        resource['id'] = str(len(self.RESOURCES)+1)
        self.RESOURCES[resource['id']] = resource
        return json.dumps(resource).encode('utf-8')

    def DELETE(self, req, resp):
        resource_id = req.urlargs['id']
        if not resource_id:
            return self._not_found(req, resp)
        self.RESOURCES.pop(resource_id, None)

        req.status = '204 No Content'
        return b''

这已经提供了基本功能,允许我们从内存目录中添加、删除和列出资源。

  1. 为了测试这一点,我们可以在后台线程中启动服务器,并使用我们之前的配方中的http_request函数:
import threading
threading.Thread(target=app.serve, daemon=True).start()

from web_03 import http_request
  1. 然后我们可以创建一个新的资源:
>>> _, resp = http_request('http://localhost:8000/resources', method='POST', 
                           data=json.dumps({'name': 'Mario',
                                            'surname': 'Mario'}).encode('utf-8'))
>>> print('NEW RESOURCE: ', resp)
NEW RESOURCE:  b'{"surname": "Mario", "id": "1", "name": "Mario"}'
  1. 这里我们列出它们全部:
>>> _, resp = http_request('http://localhost:8000/resources')
>>> print('ALL RESOURCES: ', resp)
ALL RESOURCES:  b'{"1": {"surname": "Mario", "id": "1", "name": "Mario"}}'
  1. 添加第二个:
>>> http_request('http://localhost:8000/resources', method='POST', 
                 data=json.dumps({'name': 'Luigi',
                                  'surname': 'Mario'}).encode('utf-8'))
  1. 接下来,我们看到现在列出了两个资源:
>>> _, resp = http_request('http://localhost:8000/resources')
>>> print('ALL RESOURCES: ', resp)
ALL RESOURCES:  b'{"1": {"surname": "Mario", "id": "1", "name": "Mario"}, 
                   "2": {"surname": "Mario", "id": "2", "name": "Luigi"}}'
  1. 然后我们可以从目录中请求特定的资源:
>>> _, resp = http_request('http://localhost:8000/resources/1')
>>> print('RESOURCES #1: ', resp)
RESOURCES #1:  b'{"surname": "Mario", "id": "1", "name": "Mario"}'
  1. 我们还可以删除特定的资源:
>>> http_request('http://localhost:8000/resources/2', method='DELETE')
  1. 然后查看它是否已被删除:
>>> _, resp = http_request('http://localhost:8000/resources')
>>> print('ALL RESOURCES', resp)
ALL RESOURCES b'{"1": {"surname": "Mario", "id": "1", "name": "Mario"}}'

这应该允许我们为大多数简单情况提供 REST 接口,依赖于 Python 标准库中已经可用的内容。

工作原理...

大部分工作由RestController.__call__完成:

class RestController:
    def __call__(self, req, resp):
        method = req.environ['REQUEST_METHOD']
        action = getattr(self, method, self._not_found)
        return action(req, resp)

每当调用RestController的子类时,它将查看 HTTP 请求方法,并查找一个命名类似于 HTTP 方法的实例方法。

如果有的话,将调用该方法,并返回方法本身提供的响应。如果没有,则调用self._not_found,它将只响应 404 错误。

这依赖于WSGIApplication.__call__对类而不是函数的支持。

WSGIApplication.__call__通过app.route找到与路由关联的对象是一个类时,它将始终创建它的一个实例,然后它将调用该实例:

if inspect.isclass(routed_action):
    routed_action = routed_action()
body = routed_action(request, resp)

如果routed_actionRestController的子类,那么将会发生的是routed_action = routed_action()将用其实例替换类,然后routed_action(request, resp)将调用RestController.__call__方法来实际处理请求。

然后,RestController.__call__方法可以根据 HTTP 方法将请求转发到正确的实例方法。

请注意,由于 REST 资源是通过在 URL 中提供资源标识符来识别的,因此分配给RestController的路由必须具有一个id参数和一个可选的/

@app.route('/resources/?(?P<id>\\w*)')

否则,您将无法区分对整个GET资源目录/resources的请求和对特定GET资源/resources/3的请求。

缺少id参数正是我们的GET方法决定何时返回整个目录的内容或不返回的方式:

def GET(self, req, resp):
    resource_id = req.urlargs['id']
    if not resource_id:
        # Whole catalog requested
        return json.dumps(self.RESOURCES).encode('utf-8')

对于接收请求体中的数据的方法,例如POSTPUTPATCH,您将不得不从req.environ['wsgi.input']读取请求体。

在这种情况下,重要的是提供要读取的字节数,因为连接可能永远不会关闭,否则读取可能会永远阻塞。

Content-Length头部可用于知道输入的长度:

def POST(self, req, resp):
    content_length = int(req.environ['CONTENT_LENGTH'])
    data = req.environ['wsgi.input'].read(content_length).decode('utf-8')

处理 cookie

在 Web 应用程序中,cookie 经常用于在浏览器中存储数据。最常见的用例是用户识别。

我们将实现一个非常简单且不安全的基于 cookie 的身份识别系统,以展示如何使用它们。

如何做...

http.cookies.SimpleCookie类提供了解析和生成 cookie 所需的所有设施。

  1. 我们可以依赖它来创建一个将设置 cookie 的 Web 应用程序端点:
from web_06 import WSGIApplication

app = WSGIApplication()

import time
from http.cookies import SimpleCookie

@app.route('/identity')
def identity(req, resp):
    identity = int(time.time())

    cookie = SimpleCookie()
    cookie['identity'] = 'USER: {}'.format(identity)

    for set_cookie in cookie.values():
        resp.headers.add_header('Set-Cookie', set_cookie.OutputString())
    return b'Go back to <a href="/">index</a> to check your identity'
  1. 我们可以使用它来创建一个解析 cookie 并告诉我们当前用户是谁的 cookie:
@app.route('/')
def index(req, resp):
    if 'HTTP_COOKIE' in req.environ:
        cookies = SimpleCookie(req.environ['HTTP_COOKIE'])
        if 'identity' in cookies:
            return b'Welcome back, %b' % cookies['identity'].value.encode('utf-8')
    return b'Visit <a href="/identity">/identity</a> to get an identity'
  1. 一旦启动应用程序,您可以将浏览器指向http://localhost:8000,然后您应该看到 Web 应用程序抱怨您缺少身份:
app.serve()

点击建议的链接后,您应该得到一个,返回到索引页面,它应该通过 cookie 识别您。

它是如何工作的...

SimpleCookie类表示一个或多个值的 cookie。

每个值都可以像字典一样设置到 cookie 中:

cookie = SimpleCookie()
cookie['identity'] = 'USER: {}'.format(identity)

如果 cookiemorsel必须接受更多选项,那么可以使用字典语法进行设置:

cookie['identity']['Path'] = '/'

每个 cookie 可以包含多个值,每个值都应该使用Set-Cookie HTTP 头进行设置。

迭代 cookie 将检索构成 cookie 的所有键/值对,然后在它们上调用OutputString()将返回编码为Set-Cookie头部所期望的 cookie 值,以及所有其他属性:

for set_cookie in cookie.values():
    resp.headers.add_header('Set-Cookie', set_cookie.OutputString())

实际上,一旦设置了 cookie,调用OutputString()将会将您发送回浏览器的字符串:

>>> cookie = SimpleCookie()
>>> cookie['somevalue'] = 42
>>> cookie['somevalue']['Path'] = '/'
>>> cookie['somevalue'].OutputString()
'somevalue=42; Path=/'

读取 cookie 与从environ['HTTP_COOKIE']值构建 cookie 一样简单,如果它可用的话:

cookies = SimpleCookie(req.environ['HTTP_COOKIE'])

一旦 cookie 被解析,其中存储的值可以通过字典语法访问:

cookies['identity']

还有更多...

在处理 cookie 时,您应该注意的一个特定条件是它们的生命周期。

Cookie 可以有一个Expires属性,它将说明它们应该在哪个日期死亡(浏览器将丢弃它们),实际上,这就是您删除 cookie 的方式。使用过去日期的Expires日期再次设置 cookie 将删除它。

但是 cookie 也可以有一个Max-Age属性,它规定它们应该保留多长时间,或者可以创建为会话 cookie,当浏览器窗口关闭时它们将消失。

因此,如果您遇到 cookie 随机消失或未正确加载回来的问题,请始终检查这些属性,因为 cookie 可能刚刚被浏览器删除。

第十二章:多媒体

在本章中,我们将涵盖以下配方:

  • 确定文件类型——如何猜测文件类型

  • 检测图像类型——检查图像以了解其类型

  • 检测图像大小——检查图像以检索其大小

  • 播放音频/视频/图像——在桌面系统上播放音频、视频或显示图像

介绍

多媒体应用程序,如视频、声音和游戏通常需要依赖非常特定的库来管理用于存储数据和播放内容所需的硬件。

由于数据存储格式的多样性,视频和音频存储领域的不断改进导致新格式的出现,以及与本地操作系统功能和特定硬件编程语言的深度集成,多媒体相关功能很少集成在标准库中。

当每隔几个月就会创建一个新的图像格式时,需要维护对所有已知图像格式的支持,这需要全职的工作,而专门的库可以比维护编程语言本身的团队更好地处理这个问题。

因此,Python 几乎没有与多媒体相关的函数,但一些核心函数是可用的,它们可以在多媒体不是主要关注点的应用程序中非常有帮助,但也许它们需要处理多媒体文件以正确工作;例如,一个可能需要检查用户上传的文件是否是浏览器支持的有效格式的 Web 应用程序。

确定文件类型

当我们从用户那里收到文件时,通常需要检测其类型。通过文件名而无需实际读取数据就可以实现这一点,这可以通过mimetypes模块来实现。

如何做...

对于这个配方,需要执行以下步骤:

  1. 虽然mimetypes模块并不是绝对可靠的,因为它依赖于文件名来检测预期的类型,但它通常足以处理大多数常见情况。

  2. 用户通常会为了自己的利益(特别是 Windows 用户,其中扩展名对文件的正确工作至关重要)为其文件分配适当的名称,使用mimetypes.guess_type猜测类型通常就足够了:

import mimetypes

def guess_file_type(filename):
    if not getattr(guess_file_type, 'initialised', False):
        mimetypes.init()
        guess_file_type.initialised = True
    file_type, encoding = mimetypes.guess_type(filename)
    return file_type
  1. 我们可以对任何文件调用guess_file_type来获取其类型:
>>> print(guess_file_type('~/Pictures/5565_1680x1050.jpg'))
'image/jpeg'
>>> print(guess_file_type('~/Pictures/5565_1680x1050.jpeg'))
'image/jpeg'
>>> print(guess_file_type('~/Pictures/avatar.png'))
'image/png' 
  1. 如果类型未知,则返回None
>>> print(guess_file_type('/tmp/unable_to_guess.blob'))
None
  1. 另外,请注意文件本身并不一定真的存在。您关心的只是它的文件名:
>>> print(guess_file_type('/this/does/not/exists.txt'))
'text/plain'

它是如何工作的...

mimetypes模块保留了与每个文件扩展名关联的 MIME 类型列表。

提供文件名时,只分析扩展名。

如果扩展名在已知 MIME 类型列表中,则返回关联的类型。否则返回None

调用mimetypes.init()还会加载系统配置中注册的任何 MIME 类型,通常是从 Linux 系统的/etc/mime.types和 Windows 系统的注册表中加载。

这使我们能够涵盖更多可能不为 Python 所知的扩展名,并且还可以轻松支持自定义扩展名,如果您的系统配置支持它们的话。

检测图像类型

当您知道正在处理图像文件时,通常需要验证它们的类型,以确保它们是您的软件能够处理的格式。

一个可能的用例是确保它们是浏览器可能能够在网站上上传时显示的格式的图像。

通常可以通过检查文件头部来检测多媒体文件的类型,文件头部是文件的初始部分,存储有关文件内容的详细信息。

标头通常包含有关文件类型、包含图像的大小、每种颜色的位数等的详细信息。所有这些细节都是重现文件内存储的内容所必需的。

通过检查头部,可以确认存储数据的格式。这需要支持特定的头部格式,Python 标准库支持大多数常见的图像格式。

如何做...

imghdr 模块可以帮助我们了解我们面对的是什么类型的图像文件:

import imghdr

def detect_image_format(filename):
    return imghdr.what(filename)

这使我们能够检测磁盘上任何图像的格式或提供的字节流的格式:

>>> print(detect_image_format('~/Pictures/avatar.jpg'))
'jpeg'
>>> with open('~/Pictures/avatar.png', 'rb') as f:
...     print(detect_image_format(f))
'png'

它是如何工作的...

当提供的文件名是包含文件路径的字符串时,直接在其上调用 imghdr.what

这只是返回文件的类型,如果不支持则返回 None

相反,如果提供了类似文件的对象(例如文件本身或 io.BytesIO),则它将查看其前 32 个字节并根据这些字节检测头部。

鉴于大多数图像类型的头部大小在 10 多个字节左右,读取 32 个字节可以确保我们应该有足够的内容来检测任何图像。

读取字节后,它将返回到文件的开头,以便任何后续调用仍能读取文件(否则,前 32 个字节将被消耗并永远丢失)。

还有更多...

Python 标准库还提供了一个 sndhdr 模块,它的行为很像音频文件的 imghdr

sndhdr 识别的格式通常是非常基本的格式,因此当涉及到 waveaiff 文件时,它通常是非常有帮助的。

检测图像大小

如果我们知道我们面对的是什么类型的图像,检测分辨率通常只是从图像头部读取它。

对于大多数图像类型,这相对简单,因为我们可以使用 imghdr 来猜测正确的图像类型,然后根据检测到的类型读取头部的正确部分,以提取大小部分。

如何做...

一旦 imghdr 检测到图像类型,我们就可以使用 struct 模块读取头部的内容:

import imghdr
import struct
import os
from pathlib import Path

class ImageReader:
    @classmethod
    def get_size(cls, f):    
        requires_close = False
        if isinstance(f, (str, getattr(os, 'PathLike', str))):
            f = open(f, 'rb')
            requires_close = True
        elif isinstance(f, Path):
            f = f.expanduser().open('rb')
            requires_close = True

        try:
            image_type = imghdr.what(f)
            if image_type not in ('jpeg', 'png', 'gif'):
                raise ValueError('Unsupported image format')

            f.seek(0)
            size_reader = getattr(cls, '_size_{}'.format(image_type))
            return size_reader(f)
        finally:
            if requires_close: f.close()

    @classmethod
    def _size_gif(cls, f):
        f.read(6)  # Skip the Magick Numbers
        w, h = struct.unpack('<HH', f.read(4))
        return w, h

    @classmethod
    def _size_png(cls, f):
        f.read(8)  # Skip Magic Number
        clen, ctype = struct.unpack('>I4s', f.read(8))
        if ctype != b'IHDR':
            raise ValueError('Unsupported PNG format')
        w, h = struct.unpack('>II', f.read(8))
        return w, h

    @classmethod
    def _size_jpeg(cls, f):
        start_of_image = f.read(2)
        if start_of_image != b'\xff\xd8':
            raise ValueError('Unsupported JPEG format')
        while True:
            marker, segment_size = struct.unpack('>2sH', f.read(4))
            if marker[0] != 0xff:
                raise ValueError('Unsupported JPEG format')
            data = f.read(segment_size - 2)
            if not 0xc0 <= marker[1] <= 0xcf:
                continue
            _, h, w = struct.unpack('>cHH', data[:5])
            break
        return w, h

然后我们可以使用 ImageReader.get_size 类方法来检测任何支持的图像的大小:

>>> print(ImageReader.get_size('~/Pictures/avatar.png'))
(300, 300)
>>> print(ImageReader.get_size('~/Pictures/avatar.jpg'))
(300, 300)

它是如何工作的...

ImageReader 类的四个核心部分共同工作,以提供对读取图像大小的支持。

首先,ImageReader.get_size 方法本身负责打开图像文件并检测图像类型。

第一部分与打开文件有关,如果它以字符串形式提供为路径,作为 Path 对象,或者如果它已经是文件对象:

requires_close = False
if isinstance(f, (str, getattr(os, 'PathLike', str))):
    f = open(f, 'rb')
    requires_close = True
elif isinstance(f, Path):
    f = f.expanduser().open('rb')
    requires_close = True

如果它是一个字符串或路径对象(os.PathLike 仅支持 Python 3.6+),则打开文件并将 requires_close 变量设置为 True,这样一旦完成,我们将关闭文件。

如果它是一个 Path 对象,并且我们使用的 Python 版本不支持 os.PathLike,那么文件将通过路径本身打开。

如果提供的对象已经是一个打开的文件,则我们什么也不做,requires_close 保持 False,这样我们就不会关闭提供的文件。

一旦文件被打开,它被传递给 imghdr.what 来猜测文件类型,如果它不是受支持的类型之一,它就会被拒绝:

image_type = imghdr.what(f)
if image_type not in ('jpeg', 'png', 'gif'):
    raise ValueError('Unsupported image format')

最后,我们回到文件的开头,这样我们就可以读取头部,并调用相关的 cls._size_pngcls._size_jpegcls._size_gif 方法:

f.seek(0)
size_reader = getattr(cls, '_size_{}'.format(image_type))
return size_reader(f)

每种方法都专门用于了解特定文件格式的大小,从最简单的(GIF)到最复杂的(JPEG)。

对于 GIF 本身,我们所要做的就是跳过魔术数字(只有 imghdr.what 关心;我们已经知道它是 GIF),并将随后的四个字节读取为无符号短整数(16 位数字),采用小端字节顺序:

@classmethod
def _size_gif(cls, f):
    f.read(6)  # Skip the Magick Numbers
    w, h = struct.unpack('<HH', f.read(4))
    return w, h

png 几乎和 GIF 一样复杂。我们跳过魔术数字,并将随后的字节作为大端顺序的 unsigned int(32 位数字)读取,然后是四字节字符串:

@classmethod
def _size_png(cls, f):
    f.read(8)  # Skip Magic Number
    clen, ctype = struct.unpack('>I4s', f.read(8))

这给我们返回了图像头部的大小,后面跟着图像部分的名称,必须是 IHDR,以确认我们正在读取图像头部:

if ctype != b'IHDR':
    raise ValueError('Unsupported PNG format')

一旦我们知道我们在图像头部内,我们只需读取前两个unsigned int数字(仍然是大端)来提取图像的宽度和高度:

w, h = struct.unpack('>II', f.read(8))
return w, h

最后一种方法是最复杂的,因为 JPEG 的结构比 GIF 或 PNG 复杂得多。JPEG 头由多个部分组成。每个部分由0xff标识,后跟部分标识符和部分长度。

一开始,我们只读取前两个字节并确认我们面对图像的开始SOI)部分:

@classmethod
def _size_jpeg(cls, f):
    start_of_image = f.read(2)
    if start_of_image != b'\xff\xd8':
        raise ValueError('Unsupported JPEG format')

然后我们寻找一个声明 JPEG 为基线 DCT、渐进 DCT 或无损帧的部分。

这是通过读取每个部分的前两个字节及其大小来完成的:

while True:
    marker, segment_size = struct.unpack('>2sH', f.read(4))

由于我们知道每个部分都以0xff开头,如果我们遇到以不同字节开头的部分,这意味着图像无效:

if marker[0] != 0xff:
    raise ValueError('Unsupported JPEG format')

如果部分有效,我们可以读取它的内容。我们知道大小,因为它是在两个字节的无符号短整数中以大端记法指定的:

data = f.read(segment_size - 2)

现在,在能够从我们刚刚读取的数据中读取宽度和高度之前,我们需要检查我们正在查看的部分是否实际上是基线、渐进或无损的帧的开始。这意味着它必须是从0xc00xcf的部分之一。

否则,我们只是跳过这个部分并移动到下一个:

if not 0xc0 <= marker[1] <= 0xcf:
    continue

一旦我们找到一个有效的部分(取决于图像的编码方式),我们可以通过查看前五个字节来读取大小。

第一个字节是样本精度。我们真的不关心它,所以我们可以忽略它。然后,剩下的四个字节是图像的高度和宽度,以大端记法的两个无符号短整数:

_, h, w = struct.unpack('>cHH', data[:5])

播放音频/视频/图像

Python 标准库没有提供打开图像的实用程序,并且对播放音频文件的支持有限。

虽然可以通过结合waveossaudiodevwinsound模块以某种格式在一些格式中播放音频文件,但是 OSS 音频系统在 Linux 系统上已经被弃用,而且这两者都不适用于 Mac 系统。

对于图像,可以使用tkinter模块显示图像,但我们将受到非常简单的图像格式的限制,因为解码图像将由我们自己完成。

但是有一个小技巧,我们可以用来实际显示大多数图像文件和播放大多数音频文件。

在大多数系统上,尝试使用默认的网络浏览器打开文件将播放文件,我们可以依靠这个技巧和webbrowser模块通过 Python 播放大多数文件类型。

如何做...

此食谱的步骤如下:

  1. 给定一个指向支持的文件的路径,我们可以构建一个file:// url,然后使用webbrowser模块打开它:
import pathlib
import webbrowser

def playfile(fpath):
    fpath = pathlib.Path(fpath).expanduser().resolve()
    webbrowser.open('file://{}'.format(fpath))
  1. 打开图像应该会显示它:
>>> playfile('~/Pictures/avatar.jpg')
  1. 此外,打开音频文件应该会播放它:
>>> playfile('~/Music/FLY_ME_TO_THE_MOON.mp3')

因此,我们可以在大多数系统上使用这种方法来向用户显示文件的内容。

它是如何工作的...

webbrowser.open函数实际上在 Linux 系统上启动浏览器,但在 macOS 和 Windows 系统上,它的工作方式有所不同。

在 Windows 和 macOS 系统上,它将要求系统使用最合适的应用程序打开指定的路径。

如果路径是 HTTP URL,则最合适的应用程序当然是webbrowser,但如果路径是本地file:// URL,则系统将寻找能够处理该文件类型并将文件打开的软件。

这是通过在 Windows 系统上使用os.startfile,并通过osascript命令在 macOS 上运行一个小的 Apple 脚本片段来实现的。

这使我们能够打开图像和音频文件,由于大多数图像和音频文件格式也受到浏览器支持,因此它也可以在 Linux 系统上运行。

第十三章:图形用户界面

在本章中,我们将涵盖以下配方:

  • 警报-在图形系统上显示警报对话框

  • 对话框-如何使用对话框询问简单问题

  • ProgressBar 对话框-如何提供图形进度对话框

  • 列表-如何实现可滚动的元素列表以供选择

  • 菜单-如何在 GUI 应用程序中创建菜单以允许多个操作

介绍

Python 带有一个编程语言很少提供的功能:内置的图形用户界面GUI)库。

Python 附带了一个可通过标准库提供的tkinter模块控制的Tk小部件工具包的工作版本。

Tk工具包实际上是通过一种称为Tcl的简单语言使用的。所有Tk小部件都可以通过Tcl命令进行控制。

大多数这些命令都非常简单,采用以下形式:

classname widgetid options

例如,以下内容会导致一个按钮(标识为mybutton)上有红色的“点击这里”文本:

button .mybutton -fg red  -text "click here"

由于这些命令通常相对简单,Python 附带了一个内置的Tcl解释器,并使用它来驱动Tk小部件。

如今,几乎每个人,甚至更加专注的计算机用户,都习惯于依赖 GUI 来完成他们的许多任务,特别是对于需要基本交互的简单应用程序,例如选择选项,确认输入或显示一些进度。因此,使用 GUI 可能非常方便。

对于图形应用程序,用户通常无需查看应用程序的帮助页面,阅读文档并浏览应用程序提供的选项以了解其特定的语法。 GUI 已经提供了几十年的一致交互语言,如果正确使用,是保持软件入门门槛低的好方法。

由于 Python 提供了创建强大的控制台应用程序和良好的 GUI 所需的一切,因此下次您需要创建新工具时,如果您选择图形应用程序,也许停下来考虑一下您的用户会发现什么更方便,前往tkinter可能是一个不错的选择。

虽然tkinter与强大的工具包(如 Qt 或 GTK)相比可能有限,但它确实是一个完全独立于平台的解决方案,对于大多数应用程序来说已经足够好了。

警报

最简单的 GUI 类型是警报。只需在图形框中打印一些内容以通知用户结果或事件:

如何做...

tkinter中的警报由messagebox对象管理,我们可以通过要求messagebox为我们显示一个来创建一个:

from tkinter import messagebox

def alert(title, message, kind='info', hidemain=True):
    if kind not in ('error', 'warning', 'info'):
        raise ValueError('Unsupported alert kind.')

    show_method = getattr(messagebox, 'show{}'.format(kind))
    show_method(title, message)

一旦我们有了alert助手,我们可以初始化Tk解释器并显示我们想要的多个警报:

from tkinter import Tk

Tk().withdraw()
alert('Hello', 'Hello World')
alert('Hello Again', 'Hello World 2', kind='warning')

如果一切按预期工作,我们应该看到一个弹出对话框,一旦解除,新的对话框应该出现“再见”。

工作原理...

alert函数本身只是tkinter.messagebox提供的一个薄包装。

我们可以显示三种类型的消息框:errorwarninginfo。如果请求了不支持的对话框类型,我们会拒绝它:

if kind not in ('error', 'warning', 'info'):
    raise ValueError('Unsupported alert kind.')

每种对话框都是通过依赖messagebox的不同方法来显示的。信息框使用messagebox.showinfo显示,而错误使用messagebox.showerror显示,依此类推。

因此,我们获取messagebox的相关方法:

show_method = getattr(messagebox, 'show{}'.format(kind))

然后,我们调用它来显示我们的框:

show_method(title, message)

alert函数非常简单,但还有一件事情我们需要记住。

tkinter库通过与Tk的解释器和环境交互来工作,必须创建和启动它。

如果我们自己不开始,tkinter需要在需要发送一些命令时立即为我们启动一个。但是,这会导致始终创建一个空的主窗口。

因此,如果您像这样使用alert,您将收到警报,但您也会在屏幕角落看到空窗口。

为了避免这种情况,我们需要自己初始化Tk环境并禁用主窗口,因为我们对它没有任何用处:

from tkinter import Tk
Tk().withdraw()

然后我们可以显示任意数量的警报,而不会出现在屏幕周围泄漏空的不需要的窗口的风险。

对话框

对话框是用户界面可以提供的最简单和最常见的交互。询问一个简单的输入,比如数字、文本或是是/否,可以满足简单应用程序与用户交互的许多需求。

tkinter提供了大多数情况下的对话框,但如果你不知道这个库,可能很难找到它们。作为一个指针,tkinter提供的所有对话框都有非常相似的签名,因此很容易创建一个dialog函数来显示它们:

对话框将如下所示:

打开文件的窗口如下截图所示:

如何做...

我们可以创建一个dialog函数来隐藏对话框类型之间的细微差异,并根据请求的类型调用适当的对话框:

from tkinter import messagebox
from tkinter import simpledialog
from tkinter import filedialog

def dialog(ask, title, message=None, **kwargs):
    for widget in (messagebox, simpledialog, filedialog):
        show = getattr(widget, 'ask{}'.format(ask), None)
        if show:
            break
    else:
        raise ValueError('Unsupported type of dialog: {}'.format(ask))

    options = dict(kwargs, title=title)
    for arg, replacement in dialog._argsmap.get(widget, {}).items():
        options[replacement] = locals()[arg]
    return show(**options)
dialog._argsmap = {
    messagebox: {'message': 'message'},
    simpledialog: {'message': 'prompt'}
}

然后我们可以测试我们的dialog方法来显示所有可能的对话框类型,并显示用户的选择:

>>> from tkinter import Tk

>>> Tk().withdraw()
>>> for ask in ('okcancel', 'retrycancel', 'yesno', 'yesnocancel',
...             'string', 'integer', 'float', 'directory', 'openfilename'):
...     choice = dialog(ask, 'This is title', 'What?')
...     print('{}: {}'.format(ask, choice))
okcancel: True
retrycancel: False
yesno: True
yesnocancel: None
string: Hello World
integer: 5
float: 1.3
directory: /Users/amol/Documents
openfilename: /Users/amol/Documents/FileZilla_3.27.1_macosx-x86.app.tar.bz2

它是如何工作的...

tkinter提供的对话框类型分为messageboxsimpledialogfiledialog模块(你可能也考虑colorchooser,但它很少需要)。

因此,根据用户想要的对话框类型,我们需要选择正确的模块并调用所需的函数来显示它:

from tkinter import messagebox
from tkinter import simpledialog
from tkinter import filedialog

def dialog(ask, title, message=None, **kwargs):
    for widget in (messagebox, simpledialog, filedialog):
        show = getattr(widget, 'ask{}'.format(ask), None)
        if show:
            break
    else:
        raise ValueError('Unsupported type of dialog: {}'.format(ask))

如果没有模块公开函数来显示请求的对话框类型(所有函数都以ask*命名),循环将在没有打破的情况下结束,因此将进入else子句,引发异常以通知调用者请求的类型不可用。

如果循环以break退出,widget变量将指向能够显示请求的对话框的模块,而show变量将导致实际能够显示它的函数。

一旦我们有了正确的函数,我们需要考虑各种对话框函数之间的细微差异。

主要的问题与messagebox对话框有一个message参数有关,而simpledialog对话框有一个提示参数来显示用户的消息。filedialog根本不需要任何消息。

这是通过创建一个基本的选项字典和自定义提供的选项以及title选项来完成的,因为在所有类型的对话框中始终可用:

options = dict(kwargs, title=title)

然后,通过查找dialog._argsmap字典中从dialog参数的名称到预期参数的映射,将message选项替换为正确的名称(或跳过)。

例如,在simpledialog的情况下,使用{'message': 'prompt'}映射。message变量在函数局部变量中查找(locals()[arg]),然后将其分配给选项字典,prompt名称由replacement指定。然后,最终调用分配给show的函数来显示对话框:

for arg, replacement in dialog._argsmap.get(widget, {}).items():
    options[replacement] = locals()[arg]
return show(**options)

dialog._argsmap = {
    messagebox: {'message': 'message'}, 
    simpledialog: {'message': 'prompt'}
}

进度条对话框

在进行长时间运行的操作时,向用户显示进度的最常见方式是通过进度条。

在线程中运行操作时,我们可以更新进度条以显示操作正在向前推进,并向用户提示可能需要完成工作的时间:

如何做...

simpledialog.SimpleDialog小部件用于创建带有一些文本和按钮的简单对话框。我们将利用它来显示进度条而不是按钮:

import tkinter
from tkinter import simpledialog
from tkinter import ttk

from queue import Queue

class ProgressDialog(simpledialog.SimpleDialog):
    def __init__(self, master, text='', title=None, class_=None):
        super().__init__(master=master, text=text, title=title, 
                         class_=class_)
        self.default = None
        self.cancel = None

        self._bar = ttk.Progressbar(self.root, orient="horizontal", 
                                    length=200, mode="determinate")
        self._bar.pack(expand=True, fill=tkinter.X, side=tkinter.BOTTOM)
        self.root.attributes("-topmost", True)

        self._queue = Queue()
        self.root.after(200, self._update)

    def set_progress(self, value):
        self._queue.put(value)

    def _update(self):
        while self._queue.qsize():
            try:
                self._bar['value'] = self._queue.get(0)
            except Queue.Empty:
                pass
        self.root.after(200, self._update)

然后可以创建ProgressDialog,并使用后台线程让操作进展(比如下载),然后在我们的操作向前推进时更新进度条:

if __name__ == '__main__':
    root = tkinter.Tk()
    root.withdraw()

    # Prepare the progress dialog
    p = ProgressDialog(master=root, text='Downloading Something...',
                    title='Download')

    # Simulate a download running for 5 seconds in background
    import threading
    def _do_progress():
        import time
        for i in range(1, 11):
            time.sleep(0.5)
            p.set_progress(i*10)
        p.done(0)
    t = threading.Thread(target=_do_progress)
    t.start()

    # Display the dialog and wait for the download to finish.
    p.go()
    print('Download Completed!')

它是如何工作的...

我们的对话框本身主要基于simpledialog.SimpleDialog小部件。我们创建它,然后设置self.default = None以防止用户能够通过按<Return>键关闭对话框,并且我们还设置self.default = None以防止用户通过按窗口上的按钮关闭对话框。我们希望对话框保持打开状态,直到完成为止:

class ProgressDialog(simpledialog.SimpleDialog):
    def __init__(self, master, text='', title=None, class_=None):
        super().__init__(master=master, text=text, title=title, class_=class_)
        self.default = None
        self.cancel = None

然后我们实际上需要进度条本身,它将显示在文本消息下方,并且我们还将对话框移到前面,因为我们希望用户意识到正在发生某事:

self._bar = ttk.Progressbar(self.root, orient="horizontal", 
                            length=200, mode="determinate")
self._bar.pack(expand=True, fill=tkinter.X, side=tkinter.BOTTOM)
self.root.attributes("-topmost", True)

在最后一部分,我们需要安排self._update,它将继续循环,直到对话框停止更新进度条,如果self._queue中有新的进度值可用。进度值可以通过self._queue提供,我们将在通过set_progress方法提供新的进度值时插入新的进度值:

self._queue = Queue()
self.root.after(200, self._update)

我们需要通过Queue进行,因为具有进度条更新的对话框会阻塞整个程序。

Tkinter mainloop函数运行时(由simpledialog.SimpleDialog.go()调用),没有其他东西可以继续进行。

因此,UI 和下载必须在两个不同的线程中进行,并且由于我们无法从不同的线程更新 UI,因此必须从生成它们的线程将进度值发送到将其消耗以更新进度条的 UI 线程。

执行操作并生成进度更新的线程可以通过set_progress方法将这些进度更新发送到 UI 线程:

def set_progress(self, value):
    self._queue.put(value)

另一方面,UI 线程将不断调用self._update方法(每 200 毫秒一次),以检查self._queue中是否有更新请求,然后应用它:

def _update(self):
    while self._queue.qsize():
        try:
            self._bar['value'] = self._queue.get(0)
        except Queue.Empty:
            pass
    self.root.after(200, self._update)

在更新结束时,该方法将重新安排自己:

self.root.after(200, self._update)

这样,我们将永远继续每 200 毫秒检查进度条是否有更新,直到self.root mainloop退出。

为了使用ProgressDialog,我们模拟了一个需要 5 秒钟的下载。这是通过创建对话框本身完成的:

if __name__ == '__main__':
    root = tkinter.Tk()
    root.withdraw()

    # Prepare the progress dialog
    p = ProgressDialog(master=root, text='Downloading Something...',
                    title='Download')

然后我们启动了一个后台线程,持续 5 秒,每隔半秒更新一次进度:

# Simulate a download running for 5 seconds in background
import threading

def _do_progress():
    import time
    for i in range(1, 11):
        time.sleep(0.5)
        p.set_progress(i*10)
    p.done(0)

t = threading.Thread(target=_do_progress)
t.start()

更新发生是因为线程调用p.set_progress,它将在队列中设置一个新的进度值,向 UI 线程发出新的进度值设置信号。

一旦下载完成,进度对话框将通过p.done(0)退出。

一旦我们的下载线程就位,我们就可以显示进度对话框并等待其退出:

# Display the dialog and wait for the download to finish.
p.go()
print('Download Completed!')

列表

当用户有两个以上的选择时,最好的列出它们的方式是通过列表。tkinter模块提供了一个ListBox,允许我们在可滚动的小部件中显示一组条目供用户选择。

我们可以使用它来实现一个对话框,用户可以从中选择许多选项并抓取所选项:

如何做...

simpledialog.Dialog类可用于实现简单的确定/取消对话框,并允许我们提供具有自定义内容的对话框主体。

我们可以使用它向对话框添加消息和列表,并让用户进行选择:

import tkinter
from tkinter import simpledialog

class ChoiceDialog(simpledialog.Dialog):
    def __init__(self, parent, title, text, items):
        self.selection = None
        self._items = items
        self._text = text
        super().__init__(parent, title=title)

    def body(self, parent):
        self._message = tkinter.Message(parent, text=self._text, aspect=400)
        self._message.pack(expand=1, fill=tkinter.BOTH)
        self._list = tkinter.Listbox(parent)
        self._list.pack(expand=1, fill=tkinter.BOTH, side=tkinter.TOP)
        for item in self._items:
            self._list.insert(tkinter.END, item)
        return self._list

    def validate(self):
        if not self._list.curselection():
            return 0
        return 1

    def apply(self):
        self.selection = self._items[self._list.curselection()[0]]

一旦有了ChoiceDialog,我们可以显示它并提供一个项目列表,让用户选择一个或取消对话框:

if __name__ == '__main__':
    tk = tkinter.Tk()
    tk.withdraw()

    dialog = ChoiceDialog(tk, 'Pick one',
                        text='Please, pick a choice?',
                        items=['first', 'second', 'third'])
    print('Selected "{}"'.format(dialog.selection))

ChoiceDialog.selection属性将始终包含所选项目,如果对话框被取消,则为None

它是如何工作的...

simpledialog.Dialog默认创建一个带有确定取消按钮的对话框,并且只提供一个标题。

在我们的情况下,除了创建对话框本身之外,我们还希望保留对话框的消息和可供选择的项目,以便我们可以向用户显示它们。此外,默认情况下,我们希望设置尚未选择任何项目。最后,我们可以调用simpledialog.Dialog.__init__,一旦调用它,主线程将阻塞,直到对话框被解除:

import tkinter
from tkinter import simpledialog

class ChoiceDialog(simpledialog.Dialog):
    def __init__(self, parent, title, text, items):
        self.selection = None
        self._items = items
        self._text = text
        super().__init__(parent, title=title)

我们可以通过重写simpledialog.Dialog.body方法来添加任何其他内容。这个方法可以将更多的小部件添加为对话框主体的子级,并且可以返回应该具有焦点的特定小部件:

def body(self, parent):
    self._message = tkinter.Message(parent, text=self._text, aspect=400)
    self._message.pack(expand=1, fill=tkinter.BOTH)
    self._list = tkinter.Listbox(parent)
    self._list.pack(expand=1, fill=tkinter.BOTH, side=tkinter.TOP)
    for item in self._items:
        self._list.insert(tkinter.END, item)
    return self._list

body方法是在simpledialog.Dialog.__init__中创建的,因此在阻塞主线程之前调用它。

对话框的内容放置好后,对话框将阻塞等待用户点击按钮。

如果点击cancel按钮,则对话框将自动关闭,ChoiceDialog.selection将保持为None

如果点击Ok,则调用ChoiceDialog.validate方法来检查选择是否有效。我们的validate实现将检查用户在点击Ok之前是否实际选择了条目,并且只有在有选定项目时才允许用户关闭对话框:

def validate(self):
    if not self._list.curselection():
        return 0
    return 1

如果验证通过,将调用ChoiceDialog.apply方法来确认选择,然后我们只需在self.selection中设置所选项目的名称,这样一旦对话框不再可见,调用者就可以访问它了:

def apply(self):
    self.selection = self._items[self._list.curselection()[0]]

这使得可以显示对话框并在其关闭后从selection属性中读取所选值成为可能:

dialog = ChoiceDialog(tk, 'Pick one',
                    text='Please, pick a choice?',
                    items=['first', 'second', 'third'])
print('Selected "{}"'.format(dialog.selection))

菜单

当应用程序允许执行多个操作时,菜单通常是允许访问这些操作的最常见方式:

如何做...

tkinter.Menu类允许我们创建菜单、子菜单、操作和分隔符。因此,它提供了我们在基于 GUI 的应用程序中创建基本菜单所需的一切:

import tkinter

def set_menu(window, choices):
    menubar = tkinter.Menu(root)
    window.config(menu=menubar)

    def _set_choices(menu, choices):
        for label, command in choices.items():
            if isinstance(command, dict):
                # Submenu
                submenu = tkinter.Menu(menu)
                menu.add_cascade(label=label, menu=submenu)
                _set_choices(submenu, command)
            elif label == '-' and command == '-':
                # Separator
                menu.add_separator()
            else:
                # Simple choice
                menu.add_command(label=label, command=command)

    _set_choices(menubar, choices)

set_menu函数允许我们轻松地从嵌套的操作和子菜单的字典中创建整个菜单层次结构:

import sys
root = tkinter.Tk()

from collections import OrderedDict
set_menu(root, {
    'File': OrderedDict([
        ('Open', lambda: print('Open!')),
        ('Save', lambda: print('Save')),
        ('-', '-'),
        ('Quit', lambda: sys.exit(0))
    ])
})
root.mainloop()

如果您使用的是 Python 3.6+,还可以避免使用OrderedDict,而是使用普通字典,因为字典已经是有序的。

它是如何工作的...

提供一个窗口,set_menu函数创建一个Menu对象并将其设置为窗口菜单:

def set_menu(window, choices):
    menubar = tkinter.Menu(root)
    window.config(menu=menubar)

然后,它使用通过choices参数提供的选择填充菜单。这个参数预期是一个字典,其中键是菜单条目的名称,值是在选择时应调用的可调用对象,或者如果选择应导致子菜单,则是另一个字典。最后,当标签和选择都设置为-时,它支持分隔符。

菜单通过递归函数遍历选项树来填充,该函数调用Menu.add_commandMenu.add_cascadeMenu.add_separator,具体取决于遇到的条目:

def _set_choices(menu, choices):
    for label, command in choices.items():
        if isinstance(command, dict):
            # Submenu
            submenu = tkinter.Menu(menu)
            menu.add_cascade(label=label, menu=submenu)
            _set_choices(submenu, command)
        elif label == '-' and command == '-':
            # Separator
            menu.add_separator()
        else:
            # Simple choice
            menu.add_command(label=label, command=command)

_set_choices(menubar, choices)

第十四章:开发工具

在本章中,我们将介绍以下内容:

  • 调试-如何利用 Python 内置调试器

  • 测试-使用 Python 标准库测试框架编写测试套件

  • 模拟-在测试中修补对象以模拟虚假行为

  • 在生产中报告错误-通过电子邮件报告崩溃

  • 基准测试-如何使用标准库对函数进行基准测试

  • 检查-检查对象提供的类型、属性和方法

  • 代码评估-在 Python 代码中运行 Python 代码

  • 跟踪-如何跟踪执行了哪些代码行

  • 性能分析-如何跟踪代码中的瓶颈

介绍

在编写软件时,您需要工具来更轻松地实现目标,以及帮助您管理代码库的复杂性,代码库可能包含数百万行代码,并且可能涉及您不熟悉的其他人的代码。

即使是对于小型项目,如果涉及第三方库、框架和工具,实际上是将其他人的代码引入到自己的代码中,您将需要一套工具来理解在依赖于此代码时发生了什么,并且保持自己的代码受控并且没有错误。

在这里,诸如测试、调试、性能分析和跟踪等技术可以派上用场,以验证代码库,了解发生了什么,发现瓶颈,并查看执行了什么以及何时执行。

Python 标准库提供了许多您在日常开发中需要实现大多数最佳实践和软件开发技术的工具。

调试

在开发过程中,您可能会遇到代码的意外行为或崩溃,并且希望深入了解,查看变量的状态,并检查发生了什么,以了解如何处理意外情况,以便软件能够正常运行。

这通常是调试的一部分,通常需要专用工具、调试器,以使您的生活更轻松(是否曾经发现自己在代码中到处添加print语句,只是为了查看某个变量的值?)。

Python 标准库提供了一个非常强大的调试器,虽然存在其他第三方解决方案,但内部的pdb调试器非常强大,并且能够在几乎所有情况下帮助您。

如何做...

如果您想在特定点停止代码执行,并在交互式地向前移动,同时检查变量如何变化以及执行的流程,您只需设置一个跟踪点,然后您将进入一个交互式会话,在那里您的代码正在运行:

def divide(x, y):
    print('Going to divide {} / {}'.format(x, y))

    # Stop execution here and enter the debugger
    import pdb; pdb.set_trace()

    return x / y

现在,如果我们调用divide函数,我们将进入一个交互式调试器,让我们看到xy的值,并继续执行:

>>> print(divide(3, 2))
Going to divide 3 / 2
> ../sources/devtools/devtools_01.py(4)divide()
-> return x / y
(Pdb) x
3
(Pdb) y
2
(Pdb) continue
1.5

它是如何工作的...

pdb模块公开了一个set_trace函数,当调用时,会停止执行并进入交互式调试器。

从这里开始,您的提示将更改(为Pdb),您可以向调试器发送命令,或者只需写出变量名称即可打印变量值。

pdb调试器有许多命令;最有用的命令如下:

  • next:逐行执行代码

  • continue:继续执行代码,直到达到下一个断点

  • list:打印当前正在执行的代码

要查看完整的命令列表,您可以使用help命令,它将列出所有可用的命令。您还可以使用help命令获取有关特定命令的帮助。

还有更多...

自 Python 3.7 版本以来,不再需要进行奇怪的import pdbpdb.set_trace()操作。您只需编写breakpoint(),就会进入pdb

更好的是,如果您的系统配置了更高级的调试器,您将依赖于这些调试器,因为breakpoint()使用当前配置的调试器,而不仅仅依赖于pdb

测试

为了确保您的代码正确,并且不会在将来的更改中出现问题,编写测试通常是您可以做的最好的事情之一。

在 Python 中,有一些框架可以实现自动验证代码可靠性的测试套件,实现不同的模式,比如行为驱动开发BDD),甚至可以自动为您找到边界情况。

但是,只需依赖标准库本身就可以编写简单的自动测试,因此只有在需要特定插件或模式时才需要第三方测试框架。

标准库有unittest模块,它允许我们为我们的软件编写测试,运行它们,并报告测试套件的状态。

如何做...

对于这个配方,需要执行以下步骤:

  1. 假设我们有一个divide函数,我们想为它编写测试:
def divide(x, y):
    return x / y
  1. 我们需要创建一个名为test_divide.py的文件(包含测试的文件必须命名为test_*.py,否则测试将无法运行)。在test_divide.py文件中,我们可以放置所有的测试:
from divide import divide
import unittest

class TestDivision(unittest.TestCase):
    def setUp(self):
        self.num = 6

    def test_int_division(self):
        res = divide(self.num, 3)
        self.assertEqual(res, 2)

    def test_float_division(self):
        res = divide(self.num, 4)
        self.assertEqual(res, 1.5)

    def test_divide_zero(self):
        with self.assertRaises(ZeroDivisionError) as err:
            res = divide(self.num, 0)
        self.assertEqual(str(err.exception), 'division by zero')
  1. 然后,假设test_divide.py模块在同一个目录中,我们可以用python -m unittest来运行我们的测试:
$ python -m unittest
...
------------------------------------------------------------------
Ran 3 tests in 0.000s

OK
  1. 如果我们还想看到哪些测试正在运行,我们也可以提供-v选项:
$ python -m unittest -v
test_divide_zero (test_devtools_02.TestDivision) ... ok
test_float_division (test_devtools_02.TestDivision) ... ok
test_int_division (test_devtools_02.TestDivision) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

它是如何工作的...

unittest模块提供了两个主要功能:

  • unittest.TestCase类提供了编写测试和固定的基础

  • unittest.TestLoader类提供了从多个来源找到并运行多个测试的基础,一次运行;然后可以将结果提供给运行器来运行它们所有并报告它们的进度。

通过创建一个unittest.TestCase类,我们可以在相同的固定集下收集多个测试,这些固定集由类作为setUpsetUpClass方法提供。setUpClass方法对整个类执行一次,而setUp方法对每个测试执行一次。测试是所有名称以test*开头的类方法。

一旦测试完成,tearDowntearDownClass方法可以用来清理状态。

因此,我们的TestDivision类将为其中声明的每个测试提供一个self.num属性:

class TestDivision(unittest.TestCase):
    def setUp(self):
        self.num = 6

然后将有三个测试,其中两个(test_int_divisiontest_float_division)断言除法的结果是预期的(通过self.assertEqual):

def test_int_division(self):
    res = divide(self.num, 3)
    self.assertEqual(res, 2)

def test_float_division(self):
    res = divide(self.num, 4)
    self.assertEqual(res, 1.5)

然后,第三个测试(test_divide_zero)检查我们的divide函数在提供0作为除数时是否实际引发了预期的异常:

def test_divide_zero(self):
    with self.assertRaises(ZeroDivisionError) as err:
        res = divide(self.num, 0)
    self.assertEqual(str(err.exception), 'division by zero')

然后检查异常消息是否也是预期的。

然后将这些测试保存在一个名为test_divide.py的文件中,以便TestLoader能够找到它们。

当执行python -m unittest时,实际发生的是调用了TestLoader.discover。这将查找本地目录中命名为test*的所有模块和包,并运行这些模块中声明的所有测试。

还有更多...

标准库unittest模块几乎提供了您为库或应用程序编写测试所需的一切。

但是,如果您发现需要更多功能,比如重试不稳定的测试、以更多格式报告和支持驱动浏览器,您可能想尝试像pytest这样的测试框架。这些通常提供了一个插件基础架构,允许您通过附加功能扩展它们的行为。

Mocking

在测试代码时,您可能会面临替换现有函数或类的行为并跟踪函数是否被调用以及是否使用了正确的参数的需求。

例如,假设你有一个如下的函数:

def print_division(x, y):
    print(x / y)

为了测试它,我们不想去屏幕上检查输出,但我们仍然想知道打印的值是否是预期的。

因此,一个可能的方法是用不打印任何东西的东西来替换print,但允许我们跟踪提供的参数(这是将要打印的值)。

这正是 mocking 的意思:用一个什么都不做但允许我们检查调用的对象或函数替换代码库中的对象或函数。

它是如何工作的...

您需要执行以下步骤来完成此操作:

  1. unittest包提供了一个mock模块,允许我们创建Mock对象和patch现有对象,因此我们可以依赖它来替换print的行为:
from unittest import mock

with mock.patch('builtins.print') as mprint:
    print_division(4, 2)

mprint.assert_called_with(2)
  1. 一旦我们知道模拟的print实际上是用2调用的,这是我们预期的值,我们甚至可以进一步打印它接收到的所有参数:
mock_args, mock_kwargs = mprint.call_args
>>> print(mock_args)
(2, )

在这种情况下,这并不是很有帮助,因为只有一个参数,但在只想检查部分参数而不是整个调用的情况下,能够访问其中一些参数可能会很方便。

工作原理...

mock.patch在上下文中用Mock实例替换指定的对象或类。

Mock在被调用时不会执行任何操作,但会跟踪它们的参数,并允许您检查它们是否按预期被调用。

因此,通过mock.patch,我们用Mock替换print,并将Mock的引用保留为mprint

with mock.patch('builtins.print') as mprint:
    print_division(4, 2)

这使我们能够检查print是否通过Mock以预期的参数被调用:

mprint.assert_called_with(2)

还有更多...

Mock对象实际上并不受限于什么都不做。

通过为mock.patch提供side_effect参数,您可以在调用时引发异常。这对于模拟代码中的故障非常有帮助。

或者,您甚至可以通过为mock.patch提供new来将它们的行为替换为完全不同的对象,这对于在实现的位置注入伪造对象非常有用。

因此,通常情况下,unittest.mock可以用来替换现有类和对象的行为,从模拟对象到伪造对象,再到不同的实现,都可以。

但是在使用它们时要注意,因为如果调用者保存了对原始对象的引用,mock.patch可能无法为其替换函数,因为它仍然受到 Python 是基于引用的语言这一事实的限制,如果您有一个对象的引用,第三方代码就无法轻松地劫持该引用。

因此,请务必在使用要打补丁的对象之前应用mock.patch,以减少对原始对象的引用风险。

在生产中报告错误

生产软件中最重要的一个方面是在发生错误时得到通知。由于我们不是软件本身的用户,所以只有在软件通知我们时(或者当为时已晚并且用户在抱怨时)才能知道出了什么问题。

基于 Python 标准库,我们可以轻松构建一个解决方案,以便在发生崩溃时通过电子邮件通知开发人员。

如何做...

logging模块有一种通过电子邮件报告异常的方法,因此我们可以设置一个记录器,并捕获异常以通过电子邮件记录它们:

import logging
import logging.handlers
import functools

crashlogger = logging.getLogger('__crashes__')

def configure_crashreport(mailhost, fromaddr, toaddrs, subject, 
                        credentials, tls=False):
    if configure_crashreport._configured:
        return

    crashlogger.addHandler(
        logging.handlers.SMTPHandler(
            mailhost=mailhost,
            fromaddr=fromaddr,
            toaddrs=toaddrs,
            subject=subject,
            credentials=credentials,
            secure=tuple() if tls else None
        )
    )
    configure_crashreport._configured = True
configure_crashreport._configured = False

def crashreport(f):
    @functools.wraps(f)
    def _crashreport(*args, **kwargs):
        try:
            return f(*args, **kwargs)
        except Exception as e:
            crashlogger.exception(
                '{} crashed\n'.format(f.__name__)
            )
            raise
    return _crashreport

一旦这两个函数就位,我们可以配置logging,然后装饰我们的主代码入口点,以便代码库中的所有异常都通过电子邮件报告:

@crashreport
def main():
    3 / 0

configure_crashreport(
    'your-smtp-host.com',
    'no-reply@your-smtp-host.com',
    'crashes_receiver@another-smtp-host.com',
    'Automatic Crash Report from TestApp',
    ('smtpserver_username', 'smtpserver_password'),
    tls=True
)

main()

工作原理...

logging模块能够向附加到记录器的任何处理程序发送消息,并且具有通过.exception显式记录崩溃的功能。

因此,我们解决方案的根本是用装饰器包装代码库的主函数,以捕获所有异常并调用记录器:

def crashreport(f):
    @functools.wraps(f)
    def _crashreport(*args, **kwargs):
        try:
            return f(*args, **kwargs)
        except Exception as e:
            crashlogger.exception(
                '{} crashed\n'.format(f.__name__)
            )
            raise
    return _crashreport

crashlogger.exception方法将构建一个包含我们自定义文本的消息(报告装饰函数的名称)以及崩溃的回溯,并将其发送到关联的处理程序。

通过configure_crashreport方法,我们为crashlogger提供了自定义处理程序。然后处理程序通过电子邮件发送消息:

def configure_crashreport(mailhost, fromaddr, toaddrs, subject, 
                        credentials, tls=False):
    if configure_crashreport._configured:
        return

    crashlogger.addHandler(
        logging.handlers.SMTPHandler(
            mailhost=mailhost,
            fromaddr=fromaddr,
            toaddrs=toaddrs,
            subject=subject,
            credentials=credentials,
            secure=tuple() if tls else None
        )
    )
    configure_crashreport._configured = True
configure_crashreport._configured = False

额外的_configured标志用作保护,以防止处理程序被添加两次。

然后我们只需调用configure_crashreport来提供电子邮件服务的凭据:

configure_crashreport(
    'your-smtp-host.com',
    'no-reply@your-smtp-host.com',
    'crashes_receiver@another-smtp-host.com',
    'Automatic Crash Report from TestApp',
    ('smtpserver_username', 'smtpserver_password'),
    tls=True
)

并且函数中的所有异常都将在crashlogger中记录,并通过关联的处理程序发送电子邮件。

基准测试

在编写软件时,通常需要确保某些性能约束得到保证。标准库中有大部分我们编写的函数的时间和资源消耗的工具。

假设我们有两个函数,我们想知道哪一个更快:

def function1():
    l = []
    for i in range(100):
        l.append(i)
    return l

def function2():
    return [i for i in range(100)]

如何做...

timeit模块提供了一堆实用程序来计时函数或整个脚本:

>>> import timeit

>>> print(
...     timeit.timeit(function1)
... )
10.132873182068579

>>> print(
...     timeit.timeit(function2)
... )
5.13165780401323

从报告的时间中,我们知道function2function1快两倍。

还有更多...

通常,这样的函数会在几毫秒内运行,但报告的时间是以秒为单位的。

这是因为,默认情况下,timeit.timeit将运行被基准测试的代码 100 万次,以提供一个结果,其中执行速度的任何临时变化都不会对最终结果产生太大影响。

检查

作为一种强大的动态语言,Python 允许我们根据它正在处理的对象的状态来改变其运行时行为。

检查对象的状态是每种动态语言的基础,标准库inspect模块具有大部分这种情况所需的功能。

如何做...

对于这个示例,需要执行以下步骤:

  1. 基于inspect模块,我们可以快速创建一个辅助函数,它将告诉我们大多数对象的主要属性和类型:
import inspect

def inspect_object(o):
    if inspect.isfunction(o) or inspect.ismethod(o):
        print('FUNCTION, arguments:', inspect.signature(o))
    elif inspect.isclass(o):
        print('CLASS, methods:', 
              inspect.getmembers(o, inspect.isfunction))
    else:
        print('OBJECT ({}): {}'.format(
            o.__class__, 
            [(n, v) for n, v in inspect.getmembers(o) 
                if not n.startswith('__')]
        ))
  1. 然后,如果我们将其应用于任何对象,我们将获得有关其类型、属性、方法的详细信息,如果它是一个函数,还有关其参数。我们甚至可以创建一个自定义类型:
class MyClass:
    def __init__(self):
        self.value = 5

    def sum_to_value(self, other):
        return self.value + other
  1. 我们检查它的方法:
>>> inspect_object(MyClass.sum_to_value)
FUNCTION, arguments: (self, other)

该类型的一个实例:

>>> o = MyClass()
>>> inspect_object(o)
OBJECT (<class '__main__.MyClass'>): [
    ('sum_to_value', <bound method MyClass.sum_to_value of ...>), 
    ('value', 5)
]

或者类本身:

>>> inspect_object(MyClass)
CLASS, methods: [
    ('__init__', <function MyClass.__init__ at 0x107bd0400>), 
    ('sum_to_value', <function MyClass.sum_to_value at 0x107bd0488>)
]

它是如何工作的...

inspect_object依赖于inspect.isfunctioninspect.ismethodinspect.isclass来决定提供的参数的类型。

一旦清楚提供的对象适合其中一种类型,它就会为该类型的对象提供更合理的信息。

对于函数和方法,它查看函数的签名:

if inspect.isfunction(o) or inspect.ismethod(o):
    print('FUNCTION, arguments:', inspect.signature(o))

inspect.signature函数返回一个包含给定方法接受的所有参数详细信息的Signature对象。

当打印时,这些参数会显示在屏幕上,这正是我们所期望的:

FUNCTION, arguments: (self, other)

对于类,我们主要关注类公开的方法。因此,我们将使用inspect.getmembers来获取类的所有属性,然后使用inspect.isfunction来仅过滤函数:

elif inspect.isclass(o):
    print('CLASS, methods:', inspect.getmembers(o, inspect.isfunction))

inspect.getmembers的第二个参数可以是任何谓词,用于过滤成员。

对于对象,我们想要显示对象的属性和方法。

对象通常有数十种方法,这些方法在 Python 中默认提供,以支持标准操作符和行为。这些就是所谓的魔术方法,我们通常不关心。因此,我们只需要列出公共方法和属性:

else:
    print('OBJECT ({}): {}'.format(
        o.__class__, 
        [(n, v) for n, v in inspect.getmembers(o) 
            if not n.startswith('__')]
    ))

正如我们所知,inspect.getmembers接受一个谓词来过滤要返回的成员。但是谓词只能作用于成员本身;它无法知道它的名称。因此,我们必须使用列表推导来过滤inspect.getmembers的结果,删除任何名称以dunder(__)开头的属性。

结果是提供的对象的公共属性和方法:

OBJECT (<class '__main__.MyClass'>): [
    ('sum_to_value', <bound method MyClass.sum_to_value of ...>), 
    ('value', 5)
]

我们还打印了对象本身的__class__,以提供关于我们正在查看的对象类型的提示。

还有更多...

inspect模块有数十个函数,可以用来深入了解 Python 对象。

在调查第三方代码或实现必须处理未知形状和类型的对象的高度动态代码时,它可以是一个非常强大的工具。

代码评估

Python 是一种解释性语言,解释器的功能也暴露在标准库中。

这意味着我们可以评估来自文件或文本源的表达式和语句,并让它们作为 Python 代码在 Python 代码本身中运行。

还可以以相当安全的方式评估代码,允许我们从表达式中创建对象,但阻止执行任何函数。

如何做...

本教程的步骤如下:

  1. evalexecast 函数和模块提供了执行字符串代码所需的大部分机制:
import ast

def run_python(code, mode='evalsafe'):
    if mode == 'evalsafe':
        return ast.literal_eval(code)
    elif mode == 'eval':
        return eval(compile(code, '', mode='eval'))
    elif mode == 'exec':
        return exec(compile(code, '', mode='exec'))
    else:
        raise ValueError('Unsupported execution model 
                         {}'.format(mode))
  1. evalsafe 模式中的 run_python 函数允许我们以安全的方式运行基本的 Python 表达式。这意味着我们可以根据它们的文字表示创建 Python 对象:
>>> print(run_python('[1, 2, 3]'))
[1, 2, 3]
  1. 我们不能运行函数或执行更高级的命令,比如索引:
>>> print(run_python('[1, 2, 3][0]'))
[ ... ]
malformed node or string: <_ast.Subscript object at 0x10ee57ba8>
  1. 如果我们想要运行这些,我们需要以不安全的方式 eval
>>> print(run_python('[1, 2, 3][0]', 'eval'))
1
  1. 这是不鼓励的,因为它允许在当前解释器会话中执行恶意代码。但即使它允许更广泛的执行,它仍然不允许更复杂的语句,比如函数的定义:
>>> print(run_python('''
... def x(): 
...     print("printing hello")
... x()
... ''', 'eval'))
[ ... ]
invalid syntax (, line 2)
  1. 为了允许完整的 Python 支持,我们需要使用 exec 模式,这将允许执行所有 Python 代码,但不再返回表达式的结果(因为提供的代码可能根本不是表达式):
>>> print(run_python('''
... def x(): 
...     print("printing hello")
... x()
... ''', 'exec'))
printing hello
None

跟踪代码

trace 模块提供了一个强大且易于使用的工具,可以跟踪运行过程中执行了哪些代码行。

跟踪可以用于确保测试覆盖率,并查看我们的软件或第三方函数的行为。

如何做...

您需要执行以下步骤来完成此教程:

  1. 我们可以实现一个函数,跟踪提供的函数的执行并返回执行的模块以及每个模块的行:
import trace
import collections

def report_tracing(func, *args, **kwargs):
    outputs = collections.defaultdict(list)

    tracing = trace.Trace(trace=False)
    tracing.runfunc(func, *args, **kwargs)

    traced = collections.defaultdict(set)
    for filename, line in tracing.results().counts:
        traced[filename].add(line)

    for filename, tracedlines in traced.items():
        with open(filename) as f:
            for idx, fileline in enumerate(f, start=1):
                outputs[filename].append(
                  (idx, idx in tracedlines, fileline))
                )  
    return outputs
  1. 然后,一旦我们有了跟踪,我们需要实际打印它,以便人类能够阅读。为此,我们将阅读每个被跟踪模块的源代码,并使用 + 标记打印它,该标记将指示哪些行被执行或未执行:
def print_traced_execution(tracings):
    for filename, tracing in tracings.items():
        print(filename)
        for idx, executed, content in tracing:
            print('{:04d}{}  {}'.format(idx, 
                                        '+' if executed else ' ', 
                                        content),
                end='')
        print()
  1. 给定任何函数,我们都可以看到在各种条件下执行的代码行:
def function(should_print=False):
    a = 1
    b = 2
    if should_print:
        print('Usually does not execute!')
    return a + b
  1. 首先,我们可以使用 should_print=False 打印函数的跟踪:
>>> print_traced_execution(
...     report_tracing(function)
... )
devtools_08.py
0001   def function(should_print=False):
0002+      a = 1
0003+      b = 2
0004+      if should_print:
0005           print('Usually does not execute!')
0006+      return a + b
  1. 然后我们可以检查 should_print=True 时会发生什么:
>>> print_traced_execution(
...     report_tracing(function, True)
... )
Usually does not execute!
devtools_08.py
0001   def function(should_print=False):
0002+      a = 1
0003+      b = 2
0004+      if should_print:
0005+          print('Usually does not execute!')
0006+      return a + b

您可以看到行 0005 现在标记为 +,因为它被执行了。

工作原理...

report_tracing 函数实际上负责跟踪另一个函数的执行。

首先,由于执行是按模块进行的,它创建了 defaultdict,用于存储跟踪。键将是模块,值将是包含该模块每行信息的列表:

def report_tracing(func, *args, **kwargs):
    outputs = collections.defaultdict(list)

然后,它创建了实际的跟踪机制。trace=False 选项特别重要,以避免在屏幕上打印跟踪。现在,我们希望将其保存在一边,而不是打印出来。

tracing = trace.Trace(trace=False)

一旦跟踪器可用,我们就可以使用它来运行提供的函数并提供任何给定的参数:

tracing.runfunc(func, *args, **kwargs)

跟踪的结果保存在跟踪器本身中,因此我们可以使用 tracing.results() 访问它。我们感兴趣的是代码行是否至少执行了一次,因此我们将寻找计数,并将每个执行的代码行添加到给定模块的执行代码行集合中:

traced = collections.defaultdict(set)
for filename, line in tracing.results().counts:
    traced[filename].add(line)

traced 字典包含了给定模块实际执行的所有代码行。顺便说一句,它不包含任何关于未执行的代码行的详细信息。

到目前为止,我们只有行号,没有关于执行的代码行的其他细节。当然,我们也希望有代码行本身,并且希望有所有代码行,而不仅仅是执行的代码行,这样我们就可以打印出没有间隙的源代码。

这就是为什么 report_tracing 打开每个执行模块的源代码并读取其内容。对于每一行,它检查它是否在该模块的执行集合中,并存储一对元组,其中包含行号、一个布尔值,指示它是否被执行,以及行内容本身:

for filename, tracedlines in traced.items():
    with open(filename) as f:
        for idx, fileline in enumerate(f, start=1):
            outputs[filename].append((idx, idx in tracedlines, fileline))

最后,结果字典包含了所有被执行的模块,以及它们的源代码,注释了关于行号和是否执行的详细信息:

return outputs

print_traced_execution则更容易:它的唯一目的是获取我们收集的数据并将其打印到屏幕上,以便人类可以看到源代码和执行的内容。

该函数会迭代每个被跟踪的模块并打印filename模块:

def print_traced_execution(tracings):
    for filename, tracing in tracings.items():
        print(filename)

然后,对于每个模块,它会迭代跟踪详细信息并打印行号(作为四位数,以便对任何行号最多到 9999 进行正确缩进),如果执行了该行,则打印一个+号,以及行内容本身:

for idx, executed, content in tracing:
    print('{:04d}{}  {}'.format(idx, 
                                '+' if executed else ' ', 
                                content),
        end='')
print()

还有更多...

使用跟踪,您可以轻松地检查您编写的代码是否被测试执行。您只需将跟踪限制在您编写并感兴趣的模块上即可。

有一些第三方模块专门用于测试覆盖率报告;最广泛使用的可能是coverage模块,它支持最常见的测试框架,如pytestnose

性能分析

当您需要加快代码速度或了解瓶颈所在时,性能分析是最有效的技术之一。

Python 标准库提供了一个内置的分析器,用于跟踪每个函数的执行和时间,并允许您找出更昂贵或运行次数过多的函数,消耗了大部分执行时间。

如何做...

对于这个示例,需要执行以下步骤:

  1. 我们可以选择任何要进行性能分析的函数(甚至可以是程序的主入口点):
import time

def slowfunc(goslow=False):
    l = []
    for i in range(100):
        l.append(i)
        if goslow:
            time.sleep(0.01)
    return l
  1. 我们可以使用cProfile模块对其进行性能分析。
from cProfile import Profile

profiler = Profile()
profiler.runcall(slowfunc, True)
profiler.print_stats()
  1. 这将打印函数的时间以及分析函数调用的最慢函数:
202 function calls in 1.183 seconds

Ordered by: standard name

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    1    0.002    0.002    1.183    1.183 devtools_09.py:3(slowfunc)
  100    1.181    0.012    1.181    0.012 {built-in method time.sleep}
  100    0.000    0.000    0.000    0.000 {method 'append' of 'list' objects}

它是如何工作的...

cProfile.Profile对象能够使用少量负载运行任何函数并收集执行统计信息。

runcall函数是实际运行函数并提供传递的参数的函数(在本例中,True作为第一个函数参数提供,这意味着goslow=True):

profiler = Profile()
profiler.runcall(slowfunc, True)

一旦收集到了性能分析数据,我们可以将其打印到屏幕上,以提供关于执行的详细信息:

profiler.print_stats()

打印输出包括在调用期间执行的函数列表,每个函数所花费的总时间,每个调用中每个函数所花费的时间,以及调用的总次数:

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    1    0.002    0.002    1.183    1.183 devtools_09.py:3(slowfunc)
  100    1.181    0.012    1.181    0.012 {built-in method time.sleep}
  ...

我们可以看到,slowfunc的主要瓶颈是time.sleep调用:它占用了总共1.183时间中的1.181

我们可以尝试使用goslow=False调用slowfunc,并查看时间的变化:

profiler.runcall(slowfunc, False)
profiler.print_stats()

而且,在这种情况下,我们看到整个函数运行时间为0.000而不是1.183,并且不再提到time.sleep

102 function calls in 0.000 seconds

Ordered by: standard name

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    1    0.000    0.000    0.000    0.000 devtools_09.py:3(slowfunc)
  100    0.000    0.000    0.000    0.000 {method 'append' of 'list' objects}
posted @ 2025-09-24 13:51  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报