Python-开发高级教程-一-

98 阅读1小时+

Python 开发高级教程(一)

原文:Advanced Python Development

协议:CC BY-NC-SA 4.0

一、原型和环境

在这一章中,我们将探索不同的方法来试验不同的 Python 函数做什么,以及什么时候是使用这些不同选项的合适时机。使用其中一种方法,我们将构建一些简单的函数来提取我们将要聚集的第一批数据,并看看如何将它们构建到一个简单的命令行工具中。

Python 中的原型

在任何 Python 项目中,从花几个小时开发的东西到运行几年的项目,都需要原型化函数。这可能是你做的第一件事,或者它可能在项目中期悄悄出现,但迟早你会发现自己在 Python shell 中尝试代码。

有两种广泛的方法来实现原型:要么运行一段代码并查看结果,要么一次执行一条语句并查看中间结果。一般来说,一个接一个地执行语句更有效率,但是有时如果有你已经确信的代码块,恢复运行代码块似乎更容易。

Python shell(也称为 REPL,表示 R ead、 E val、 P rint、 L oop)是大多数人对使用 Python 的第一次介绍。能够启动解释器并实时输入命令是直接进入编码的一种强有力的方式。它允许您运行命令并立即看到它们的结果,然后调整您的输入而不删除任何变量的值。与编译语言相比,编译语言的开发流程是围绕编译文件然后运行可执行文件来构建的。对于像 Python 这样的解释型语言中的简单程序来说,等待时间要短得多。

用 REPL 制作原型

REPL 的优势很大程度上在于尝试简单的代码并获得对函数如何工作的直观理解。它不太适合有大量流控制的情况,因为它不太容忍错误。如果您在键入循环体的一部分时出错,您将不得不重新开始,而不仅仅是编辑错误的行。用一行 Python 代码修改一个变量并查看输出,这非常适合将 REPL 用于原型制作。

例如,我经常发现很难记住内置函数filter(...)是如何工作的。有几种方法可以提醒自己:我可以在 Python 网站上或者使用我的代码编辑器/IDE 查看这个函数的文档。或者,我可以尝试在我的代码中使用它,然后检查我得到的值是否是我所期望的,或者我可以使用 REPL 来查找对文档的引用或者只是尝试该函数。

在实践中,我通常发现自己在尝试一些东西。下面是一个典型的例子,我第一次尝试反转了参数,第二次尝试提醒我 filter 返回一个自定义对象,而不是一个元组或列表,第三次尝试提醒我 filter 只包含匹配条件的元素,而不是排除匹配条件的元素。

>>> filter(range(10), lambda x: x == 5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'function' object is not iterable
>>> filter(lambda x: x == 5, range(10))
<filter object at 0x033854F0>
>>> tuple(filter(lambda x: x == 5, range(10)))
(5,)

Note

内置函数帮助(...)在试图理解函数如何工作时是非常宝贵的。由于 filter 有一个清晰的 docstring,调用help(filter)并读取信息可能会更简单。然而,当将多个函数调用链接在一起时,尤其是当试图理解现有代码时,能够试验样本数据并查看交互是如何进行的是非常有帮助的。

如果我们尝试将 REPL 用于涉及更多流量控制的任务,比如著名的面试编码测试问题 FizzBuzz(清单 1-1 ),我们可以看到它不可饶恕的本质。

for num in range(1, 101):
    val = ''
    if num % 3 == 0:
        val += 'Fizz'
    if num % 5 == 0:
        val += 'Buzz'
    if not val:
        val = str(num)
    print(val)

Listing 1-1fizzbuzz.py – a typical implementation

如果我们要一步一步地构建它,我们可以从创建一个输出不变数字的循环开始:

>>> for num in range(1, 101):
...     print(num)
...
1
.
.
.
98
99
100

此时,我们将在新行中看到数字 1 到 100,因此我们将开始添加逻辑:

>>> for num in range(1, 101):
...     if num % 3 == 0:
...         print('Fizz')
...     else:
...         print(num)
...
1
.
.
.
98
Fizz
100

每次我们这样做的时候,我们都不得不重新输入我们之前输入的代码,有时有小的改动,有时一字不差。这些行一旦输入就不可编辑,所以任何打字错误都意味着整个循环需要重新输入。

您可以决定原型化循环体而不是整个循环,以便更容易地跟踪条件的动作。在这个例子中,从 1 到 14 的 n 值是用三路 if 语句正确生成的,其中n=15是第一个被错误渲染的。虽然这是在循环体的中间,但是很难检查条件交互的方式。

在这里你会发现 REPL 和剧本对缩进的解释之间的第一个区别。Python 解释器对在 REPL 模式下如何缩进的解释比执行脚本时更严格,要求在任何返回到缩进级别 0 的取消缩进后面有一个空行。

>>> num = 15
>>> if num % 3 == 0:
...     print('Fizz')
... if num % 5 == 0:
  File "<stdin>", line 3
    if num % 5 == 0:
     ^
SyntaxError: invalid syntax

此外,当返回到缩进级别 0 时,REPL 只允许一个空行,而在 Python 文件中,它被视为最后一个缩进级别的隐式延续。清单 1-2 (与清单 1-1 的区别仅在于增加了空行)作为python fizzbuzz_blank_lines.py调用时有效。

for num in range(1, 101):
    val = ''
    if num % 3 == 0:
        val += 'Fizz'
    if num % 5 == 0:
        val += 'Buzz'

    if not val:
        val = str(num)

    print(val)

Listing 1-2fizzbuzz_blank_lines.py

然而,由于缩进解析规则的差异,将清单 1-2 的内容输入 Python 解释器会导致以下错误:

>>> for num in range(1, 101):
...     val = ''
...     if num % 3 == 0:
...         val += 'Fizz'
...     if num % 5 == 0:
...         val += 'Buzz'
...
>>>     if not val:
  File "<stdin>", line 1
    if not val:
    ^
IndentationError: unexpected indent
>>>         val = str(num)
  File "<stdin>", line 1
    val = str(num)
    ^
IndentationError: unexpected indent
>>>
>>>     print(val)
  File "<stdin>", line 1
    print(val)
    ^
IndentationError: unexpected indent

当您习惯于在文件中编写 Python 时,使用 REPL 来构建循环或条件的原型很容易出错。犯错误和不得不重新输入代码的挫败感足以抵消使用这种方法比使用简单脚本节省的时间。虽然可以使用箭头键滚动回先前输入的行,但多行构造(如循环)不会组合在一起,因此很难重新运行循环体。在整个会话中使用>>>...提示也使得复制和粘贴先前的行变得困难,无论是重新运行它们还是将它们集成到一个文件中。

使用 Python 脚本构建原型

很有可能通过编写一个简单的 Python 脚本并运行它直到它返回正确的结果来构建代码原型。与使用 REPL 不同,这可以确保在出错时很容易重新运行代码,并且代码存储在文件中,而不是存储在终端的回滚缓冲区中。不幸的是,这确实意味着不能在代码运行时与代码进行交互,这导致它被戏称为“printf 调试”,以 C 打印变量的功能命名。

顾名思义,从脚本执行中获取信息的唯一可行的方法是使用print(...)函数将数据记录到控制台窗口。在我们的示例中,通常会在循环体中添加一个打印,以查看每次迭代发生了什么:

Tip

f-string 对于 printf 调试很有用,因为它们允许您将变量插入到一个字符串中,而不需要额外的字符串格式化操作。

for num in range(1,101):
    print(f"n: {num} n%3: {num%3} n%5: {num%5}")

以下是结果:

n: 1 n%3: 1 n%5: 1
.
.
.
n: 98 n%3: 2 n%5: 3
n: 99 n%3: 0 n%5: 4
n: 100 n%3: 1 n%5: 0

这提供了一个容易理解的视图,显示脚本正在做什么,但是它确实需要重复一些逻辑。这种重复使错误更容易被遗漏,从而导致大量的时间损失。与 REPL 相比,代码被永久存储是其最大的优势,但是它为程序员提供了较差的用户体验。打字错误和简单的错误会变得令人沮丧,因为从编辑文件到在终端中运行它需要进行上下文切换。看一眼你需要的信息也可能更困难,这取决于你如何组织你的打印报表。尽管有这些缺陷,但它的简单性使得向现有系统添加调试语句非常容易,因此这是最常用的调试方法之一,尤其是在试图对问题有一个广泛的理解时。

使用脚本和 pdb 构建原型

内置的 Python 调试器 pdb 是任何 Python 开发人员的武器库中最有用的工具。这是调试复杂代码的最有效方法,也是检查 Python 脚本在多级表达式(如列表理解)中做什么的唯一方法。 3

在许多方面,原型代码是调试的一种特殊形式。我们知道我们写的代码是不完整的,并且包含错误,但是我们没有试图找到一个缺陷,而是试图分阶段建立复杂性。pdb 的许多有助于调试的特性使这变得更加容易。

当您启动 pdb 会话时,您会看到一个(Pdb)提示,允许您控制调试器。在我看来,最重要的命令是sn extbc ontinuep retty p rintd ebug4

stepnext都执行当前语句并移动到下一条语句。他们对“下一个”语句的看法不同。Step 移动到下一条语句,不管它在哪里,所以如果当前行包含一个函数调用,下一行就是该函数的第一行。Next 不会将执行移到该函数中;它认为下一条语句是当前函数中的下一条语句。如果你想检查一个函数调用在做什么,那么就进入它。如果您相信函数正在做正确的事情,使用 next 来掩饰它的实现并获得结果。

breakcontinue允许代码的更长部分在没有直接检查的情况下运行。break用于指定要返回到 pdb 提示符的行号,并带有在该范围内评估的可选条件,例如break 20 x==1continue命令返回到正常的执行流程;除非遇到另一个断点,否则不会返回到 pdb 提示符。

Tip

如果您发现可视化状态显示更加自然,您可能会发现很难跟踪您在调试会话中的位置。我建议您安装 pdb++调试器,它会显示一个代码清单,并突出显示当前行。ide,比如 PyCharm,更进一步,它允许你在一个正在运行的程序中设置断点,并直接从你的编辑器窗口控制步进。

最后,debug允许您指定任意 python 表达式来单步执行。这让您可以从 pdb 提示符中调用包含任何数据的任何函数,如果您在意识到错误所在之前已经使用了nextcontinue来传递一个点,这将非常有用。它作为debug somefunction()被调用,并修改(Pdb)提示,通过添加额外的一对括号让您知道您在嵌套的 pdb 会话中,使提示成为((Pdb))5

事后调试

有两种调用 pdb 的常见方法,要么在代码中显式调用,要么直接调用所谓的“事后调试”事后调试在 pdb 中启动一个脚本,如果出现异常,将触发 pdb。它是通过使用python -m pdb yourscript.py而不是python yourscript.py运行的。脚本不会自动启动;您将看到一个 pdb 提示,允许您设置断点。要开始执行脚本,您应该使用continue命令。当您设置的断点被触发或程序终止时,您将返回到 pdb 提示符。如果程序因错误而终止,它允许您查看错误发生时设置的变量。

或者,您可以使用 step 命令逐个运行文件中的语句;然而,对于除了最简单的脚本之外的所有脚本,最好在您希望开始调试的地方设置一个断点,并从那里开始调试。

以下是在 pdb 中运行清单 1-1 并设置条件断点(输出略)的结果:

> python -m pdb fizzbuzz.py
> c:\fizzbuzz_pdb.py(1)<module>()
-> def fizzbuzz(num):
(Pdb) break 2, num==15
Breakpoint 1 at c:\fizzbuzz.py:2
(Pdb) continue
1
.
.
.
13
14
> c:\fizzbuzz.py(2)fizzbuzz()
-> val = ''
(Pdb) p num
15

当与前面的基于脚本的方法结合使用时,这种风格效果很好。它允许您在代码执行的各个阶段设置任意断点,并在您的代码触发异常时自动提供 pdb 提示,而无需您事先知道发生了什么错误以及错误发生在哪里。

断点功能

内置的breakpoint()6允许你精确地指定 pdb 在程序中控制的位置。当调用这个函数时,执行立即停止,并显示一个 pdb 提示符。它的行为就像先前已经在当前位置设置了 pdb 断点一样。通常在 if 语句或异常处理程序中使用breakpoint(),以模拟调用 pdb 提示的条件断点和事后调试风格。尽管这意味着要修改源代码(因此不适合调试生产问题),但它消除了每次运行程序时设置断点的需要。

在计算值 15 时调试 fizzbuzz 脚本可以通过添加一个新条件来查找num == 15并将breakpoint()放入主体中来完成,如清单 1-3 所示。

for num in range(1, 101):
    val = ''
    if num == 15:
        breakpoint()
    if num % 3 == 0:
        val += 'Fizz'
    if num % 5 == 0:
        val += 'Buzz'
    if not val:
        val = str(num)
    print(val)

Listing 1-3fizzbuzz_with_breakpoint.py

要在构建原型时使用这种风格,创建一个简单的 Python 文件,其中包含您认为可能需要的导入和您知道自己拥有的任何测试数据。然后,在文件底部添加一个breakpoint()调用。无论何时执行该文件,您都会发现自己处于一个交互式环境中,拥有您需要的所有功能和数据。

Tip

我强烈推荐使用库remote-pdb来调试复杂的多线程应用。要使用它,安装remote-pdb包并用环境变量PYTHONBREAKPOINT =remote_pdb.set_trace python yourscript.py启动你的应用。当您在代码中调用breakpoint()时,连接信息被记录到控制台。更多选项参见remote-pdb文档。

用 Jupyter 制作原型

Jupyter 是一套工具,用于以更加用户友好的方式与支持 REPL 的语言进行交互。它提供了广泛的支持,使得与代码的交互更加容易,比如显示绑定到函数输入或输出的小部件,这使得非技术人员与复杂函数的交互更加容易。在这个阶段对我们有用的功能是,它允许将代码分解成逻辑块并独立运行它们,还能够保存这些块并在以后返回。

Jupyter 是用 Python 编写的,但它是 Julia、Python 和 R 编程语言的通用前端。它旨在作为一种工具,用于共享提供简单用户界面的自包含程序,例如,用于数据分析。许多 Python 程序员创建 Jupyter 笔记本而不是控制台脚本,尤其是那些从事科学工作的人。在这一章中,我们没有以那种方式使用 Jupyter 我们使用它是因为它的特性恰好与原型任务相吻合。

支持多种语言的设计目标意味着它也支持 Haskell、Lua、Perl、PHP、Rust、Node.js 以及许多其他语言。每种语言都有 ide、REPLs、文档网站等等。使用 Jupyter 进行这种类型的原型开发的一个最显著的优点是,它允许您开发一个工作流,该工作流也可以在不熟悉的环境和语言中工作。例如,全栈 web 程序员经常必须同时处理 Python 和 JavaScript 代码。相比之下,科学家可能需要方便地访问 Python 和 r。拥有单一接口意味着语言之间的一些差异被消除了。

由于 Jupyter 不是特定于 Python 的,并且有内置的支持来选择使用什么样的后端来运行当前代码,所以我建议以这样一种方式安装它,以便在整个系统中都可以方便地使用它。如果您通常将 Python 实用程序安装到虚拟环境中,这没问题。 7 然而,我已经将 Jupyter 安装到我的用户环境中:

> python -m pip install --user jupyter

Note

由于 Jupyter 是以用户模式安装的,您需要确保二进制文件目录包含在您的系统路径中。安装到全局 python 环境中或通过您的包管理器安装是一个可接受的替代方案;与使用多种方法相比,保持工具安装方式的一致性更为重要。

当使用 Jupyter 进行原型开发时,您可以将我们的代码分成逻辑块,这些逻辑块既可以单独运行,也可以顺序运行。这些块是可编辑的和持久的,就像我们在使用脚本一样,但是我们可以控制哪些块运行和编写新代码,而不会丢弃变量的内容。在这种情况下,这类似于使用 REPL,因为我们可以在不中断代码流的情况下尝试运行脚本。

访问 Jupyter 工具有两种主要方式,要么通过 Web 使用 Jupyter 的笔记本服务器,要么作为标准 Python REPL 的替代品。每一个都基于单元的思想,单元是独立的执行单元,可以随时重新运行。笔记本和 REPL 都使用相同的 Python 底层接口,称为 IPython。IPython 在理解标准 REPL 的缩进方面没有任何困难,并且支持在会话早期轻松地重新运行代码。

该笔记本比 shell 更加用户友好,但缺点是只能通过 web 浏览器访问,而不能通过通常的文本编辑器或 IDE 访问。 8 我强烈推荐使用笔记本界面,因为在能够重新运行单元格和编辑多行单元格时,它通过更直观的界面大大提高了您的工作效率。

笔记本电脑

要开始原型制作,启动 Jupyter 笔记本服务器,然后使用 web 界面创建一个新的笔记本。

> jupyter notebook

笔记本加载后,在第一个单元格中输入代码,然后单击 run 按钮。代码编辑器中有许多常见的键盘快捷键,当一个新的块开始时会自动缩进(图 1-1 )。

img/481001_1_En_1_Fig1_HTML.jpg

图 1-1

Jupyter 笔记本里的 fizzbuzz

Pdb 通过 web 界面与 Jupyter 笔记本协同工作,中断执行并显示一个新的输入提示(图 1-2 ),其方式与在命令行中相同。所有标准的 pdb 功能都通过这个接口公开,所以本章 pdb 部分的提示也可以在 Jupyter 环境中使用。

img/481001_1_En_1_Fig2_HTML.jpg

图 1-2

Jupyter 笔记本中的 pdb

本章中的原型制作

我们探索的所有方法都有优点和缺点,但每种方法都有其独特之处。对于非常简单的一行程序,比如列表理解,我经常使用 REPL,因为它启动速度最快,而且没有难以调试的复杂控制流。

对于更复杂的任务,比如将外部库的函数放在一起,用它们做多种事情,一个更有特色的方法通常更有效。我鼓励你在设计原型时尝试不同的方法,以了解在便利性和个人偏好方面的最佳点在哪里。

不同方法的各种特性应该有助于明确哪种方法最适合您的特定用例。作为一般规则,我建议使用表 1-1 中最左边的条目,该条目满足您对想要的可用特性的要求。使用更靠右的东西可能不太方便;使用太偏左的工具可能意味着你在尝试执行其他工具更容易完成的任务时会感到沮丧。

表 1-1

原型开发环境的比较

|

特征

|

取代

|

脚本

|

脚本+ pdb

|

朱皮特

|

Jupyter + pdb

| | --- | --- | --- | --- | --- | --- | | 缩进代码 | 严格的规则 | 正常规则 | 正常规则 | 正常规则 | 正常规则 | | 重新运行以前的命令 | 单一类型行 | 仅整个脚本 | 整个脚本或跳到上一行 | 逻辑块 | 逻辑块 | | 步进 | 缩进的块作为一个整体运行 | 整个脚本作为一个整体运行 | 逐句通过语句 | 逻辑块作为一个整体运行 | 逐句通过语句 | | 反省 | 可以在逻辑块之间自省 | 没有反省 | 可以在语句之间自省 | 可以在逻辑块之间自省 | 可以在语句之间自省 | | 坚持 | 什么都没有保存 | 命令被保存 | 命令被保存,但 pdb 提示符下的交互没有被保存 | 命令和输出被保存 | 命令和输出被保存 | | 编辑 | 必须重新输入命令 | 任何命令都可以编辑,但是整个脚本必须重新运行 | 任何命令都可以编辑,但是整个脚本必须重新运行 | 任何命令都可以编辑,但逻辑块必须重新运行 | 任何命令都可以编辑,但逻辑块必须重新运行 |

在这一章中,我们将原型化几个不同的函数,这些函数返回关于它们正在运行的系统的数据。它们将依赖于一些外部库,我们可能需要使用一些简单的循环,但不是广泛地使用。

由于我们不太可能有复杂的控制结构,缩进代码特性不是一个问题。当我们处理多个不同的数据源时,重新运行前面的命令会很有用。这些数据源中的一些可能会很慢,所以我们不希望在处理其中一个数据源时被迫总是重新运行每个数据源命令。这降低了 REPL,并且比基于脚本的过程更适合 Jupyter。

我们希望能够内省每个数据源的结果,但是我们不太可能需要内省单个数据源的内部变量,这表明基于 pdb 的方法是不必要的(并且,如果情况发生变化,我们总是可以添加一个breakpoint()调用)。我们会希望存储我们正在编写的代码,但这只会对已经贴现的 REPL 进行贴现。最后,我们希望能够编辑代码,并看到它带来的不同。

如果我们将这些需求与表 1-1 进行比较,我们可以创建表 1-2 ,这表明 Jupyter 方法很好地覆盖了我们需要的所有特性,而脚本方法足够好,但在重新运行之前的命令的能力方面不是很理想。

因此,在本章中,我们将使用 Jupyter 笔记本来进行原型制作。在本章的其余部分,我们将介绍 Jupyter 给我们带来的一些其他优势,以及一些在 Python 开发过程中有效使用它的技术,而不是创建作为笔记本分发的独立软件。

表 1-2

各种方法的特征是否符合我们要求的矩阵 9

|

特征

|

取代

|

脚本

|

脚本+ pdb

|

朱皮特

|

Jupyter + pdb

| | --- | --- | --- | --- | --- | --- | | 缩进代码 | ✔ | ✔ | ✔ | ✔ | ✔ | | 重新运行以前的命令 | -好的 | ⚠ | ⚠ | ✔ | ✔ | | 步进 | -好的 | -好的 | ⚠ | ✔ | ⚠ | | 反省 | ✔ | ✔ | ✔ | ✔ | ✔ | | 坚持 | -好的 | ✔ | ✔ | ✔ | ✔ | | 编辑 | -好的 | ✔ | ✔ | ✔ | ✔ |

环境设置

也就是说,我们需要为这个项目安装库和管理依赖项,这意味着我们需要一个虚拟环境。我们使用 pipenv 来指定我们的依赖关系,pipenv 是一个工具,既可以创建隔离的虚拟环境,又可以进行出色的依赖关系管理。

> python -m pip install --user pipenv

Why Pipenv

用 Python 创建隔离环境的系统已经有很长的历史了。你之前最有可能用过的一个叫做 virtualenv。您也可能使用过 venv、conda、buildout、virtualenvwrapper 或 pyenv。您甚至可以通过在 Python 的内部目录中操作sys.path或创建lnk文件来创建自己的文件。

每种方法都有优点和缺点(除了手动方法,我只能想到缺点),但是 pipenv 对管理直接依赖关系有很好的支持,同时跟踪已知可以正确工作的一整套依赖关系版本,并确保您的环境保持最新。这使得它非常适合现代纯 Python 项目。如果您的工作流涉及构建二进制文件或使用过时的包,那么坚持使用现有的工作流可能比将其迁移到 pipenv 更适合您。特别是,如果您因为做科学计算而使用 Anaconda,就没有必要切换到 pipenv。如果您愿意,您可以使用pipenv --site-packages让 pipenv 包含通过 conda 管理的包以及它自己的包。

与其他 Python 工具相比,Pipenv 的开发周期相当长。它几个月或几年没有发布并不罕见。总的来说,我发现 pipenv 稳定可靠,这就是我推荐它的原因。发布频率更高的包管理器有时会不受欢迎,迫使您定期响应重大变更。

为了让 pipenv 有效地工作,它确实需要您声明依赖的包的维护者正确地声明它们的依赖。有些包没有很好地做到这一点,例如,当存在限制时,只指定一个依赖包,而没有任何版本限制。这个问题可能发生,例如,因为最近发布了一个新的子依赖关系的主要版本。在这些情况下,您可以添加自己的限制,限制您接受的版本(称为版本 pin )。

如果您发现自己的软件包缺少一个必需的版本 pin,请考虑联系软件包维护者来提醒他们。开源维护者通常非常忙,可能还没有注意到这个问题——不要因为他们有经验就认为他们不需要你的帮助。大多数 Python 包在 GitHub 上都有带有问题跟踪器的存储库。您可以从问题跟踪器中看到是否有其他人已经报告了这个问题,如果没有,这是一个简单的方法来为减轻您的开发任务的包做贡献。

设置新项目

首先,为这个项目创建一个新目录,并切换到它。我们希望将ipykernel声明为开发依赖项。这个包包含管理 Python 和 Jupyter 之间的接口的代码,我们希望确保它和它的库代码在我们新的、隔离的环境中可用。

> mkdir advancedpython
> cd advancedpython
> pipenv install ipykernel --dev
> pipenv run ipython kernel install --user --name=advancedpython

这里的最后一行指令隔离环境中的 IPython 副本将自身安装为当前用户帐户的可用内核,名称为 advancedpython。这允许我们选择内核,而不必每次都手动激活这个隔离的环境。安装的内核可以用jupyter kernelspec list列出,用jupyter kernelspec remove删除。

现在我们可以启动 Jupyter,查看针对我们的系统 Python 或我们的隔离环境运行代码的选项。我建议为此打开一个新的命令行窗口,因为 Jupyter 在前台运行,我们不久将需要再次使用命令行。如果你在本章前面已经打开了一个 Jupyter 服务器,我建议你在打开新的服务器之前先关闭它。我们想使用我们之前创建的工作目录,所以如果新窗口不在那里,就切换到那个目录。

> cd advancedpython
> jupyter notebook

web 浏览器会自动打开并显示 Jupyter 界面,其中列出了我们创建的目录。这将类似于图 1-3 。随着项目的建立,是时候开始构建原型了。选择“新建”,然后选择“高级 python”。

我们现在看到了笔记本的主编辑界面。我们有一个“细胞”不包含任何东西,还没有被执行。我们在单元格中键入的任何代码都可以通过单击上面的“运行”按钮来运行。Jupyter 显示下面单元格的输出,以及一个新的空单元格用于进一步的代码。你应该把一个细胞想象成一个功能体。它们通常包含您希望作为一个逻辑组运行的多个相关语句。

img/481001_1_En_1_Fig3_HTML.jpg

图 1-3

新 pipenv 目录中的 jupiter 主屏幕

原型化我们的脚本

合乎逻辑的第一步是创建一个 Python 程序,该程序返回关于运行它的系统的各种信息。稍后,这些信息将成为汇总数据的一部分,但目前一些简单的数据是合适的首要目标。

本着从小处着手的精神,我们将使用第一个单元来查找我们正在运行的 Python 版本,如图 1-4 所示。因为这是由 Python 标准库公开的,并且可以在所有平台上工作,所以它是更有趣的东西的一个很好的占位符。

img/481001_1_En_1_Fig4_HTML.jpg

图 1-4

显示 sys.version_info 的简单 Jupyter 笔记本

Jupyter 显示单元格最后一行的值,以及任何显式打印的内容。由于我们单元格的最后一行是sys.version_info,这就是输出中显示的内容。 10

另一个有用的信息是当前机器的 IP 地址。这不是在单个变量中公开的;这是一些 API 调用和信息处理的结果。因为这需要的不仅仅是简单的导入,所以在新的单元格中逐步构建变量是有意义的。当这样做时,您可以一眼看到您从上一个调用中获得了什么,并且您在下一个单元格中有那些可用的变量。这个循序渐进的过程允许你专注于你正在编写的代码的新部分,忽略你已经完成的部分。

在这个过程结束时,您将得到类似于图 1-5 中的代码,显示与当前计算机相关的各种 IP 地址。在第二阶段,很明显 IPv4 和 IPv6 地址都可用。这使得第三阶段稍微复杂一些,因为我决定提取地址类型和实际值。通过单独执行这些步骤,我们可以在编写下一个步骤时适应我们在一个步骤中学到的东西。能够在不改变窗口的情况下单独重新运行循环体是 Jupyter 在原型开发方面优势所在的一个很好的例子。

img/481001_1_En_1_Fig5_HTML.jpg

图 1-5

在多个单元格中原型化一个复杂的函数 11

此时,我们有三个单元来查找 IP 地址,这意味着单元和逻辑组件之间没有一对一的映射。要整理它,请选择顶部的单元格,然后从编辑菜单中选择“合并下面的单元格”。这样做两次,合并两个附加单元,完整的实现现在存储为一个逻辑块(图 1-6 )。该操作现在可以作为一个整体运行,而不是需要运行所有三个单元来产生输出。整理这个单元格的内容也是一个好主意:因为我们不再想打印中间值,我们可以删除重复的addresses行。

img/481001_1_En_1_Fig6_HTML.jpg

图 1-6

图 1-5 中单元格合并的结果

安装依赖项

更有用的是了解系统正在经历多少负载。在 Linux 中,这可以通过读取存储在/proc/loadavg中的值来找到。在 macOS 中这是sysctl -n vm.loadavg。这两个系统也将它包含在其他程序的输出中,比如 uptime,但这是一个如此常见的任务,毫无疑问有一个库可以帮助我们。如果可以避免,我们不想增加任何复杂性。

我们将安装我们的第一个依赖项,psutil。由于这是我们代码的一个实际依赖项,而不是我们碰巧希望可用的开发工具,我们应该省略前面安装依赖项时使用的--dev标志:

> pipenv install psutil

Note

我们对需要哪个版本的psutil没有偏好,所以我们没有指定版本。install 命令将依赖项添加到Pipfile中,并将特定版本添加到Pipfile.lock中。扩展名为.lock的文件通常被添加到版本控制中的忽略集中。您应该为Pipfile.lock破例,因为它有助于重建旧环境和执行可重复的部署。

当我们返回笔记本时,我们需要重启内核以确保新的依赖项可用。单击内核菜单,然后重新启动。如果你喜欢键盘快捷键,你可以按<ESCAPE>退出编辑模式(当前单元格的绿色高亮显示将变成蓝色以示确认)并按 0(零)两次。

完成后,我们可以开始探索 psutils 模块了。在第二个单元格中,导入 psutil:

import psutil

并点击运行(或从键盘点击<SHIFT+ENTER>运行单元格)。在新的单元格中,键入psutil.cpu<TAB>。您将看到 psutil 的成员,jupyter 可以为您自动完成。在这种情况下,cpu_stats似乎是一个很好的选择,所以输入它。此时,您可以按下<SHIFT+TAB>来查看关于cpu_stats的最小文档,这告诉我们它不需要任何参数。

完成该行,因此单元格现在显示为:

import psutil

psutil.cpu_stats()

当我们运行第二个单元时,我们看到cpu_stats给出了操作系统内部使用 CPU 的不透明信息。我们试试cpu_percent吧。在这个函数上使用<SHIFT+TAB>,我们看到它有两个可选参数。interval 参数决定函数返回前需要多长时间,如果非零,效果最好。因此,我们将修改代码如下,并获得一个介于 0 和 100 之间的简单浮点数:

import psutil
psutil.cpu_percent(interval=0.1)

Exercise 1-1: Explore the Library

psutil 库中的许多其他函数是很好的数据源,所以让我们为每个看起来有趣的函数创建一个单元格。在不同的操作系统上有不同的函数可用,所以请注意,如果您在 Windows 上学习本教程,您的函数选择会稍微有限一些。

尝试 Jupyter 的自动完成和帮助功能,感受一下哪些信息对您有用,并创建至少一个返回数据的单元格。

在每个单元格中包含psutil的导入会是重复的,对于 Python 文件来说不是好的做法,但是我们确实想确保单独运行一个函数是容易的。为了解决这个问题,我们将把导入移动到一个新的顶部单元格,这相当于标准 Python 文件中的模块范围。

一旦你为你的数据源创建了额外的单元格,你的笔记本将看起来如图 1-7 所示。

img/481001_1_En_1_Fig7_HTML.jpg

图 1-7

练习后完整笔记本的示例

当你这样做的时候,单元格旁边的方括号中的数字一直在增加。这个数字是已经运行的操作序列。第一个单元格旁边的数字保持不变,这意味着在我们用较低的单元格进行实验时,这个单元格没有运行过。

在单元格菜单中,有一个运行全部的选项,它将像标准 Python 文件一样按顺序运行每个单元格。虽然能够运行所有单元来测试整个笔记本很有用,但是能够单独运行每个单元可以让您从正在处理的内容中分离出复杂而缓慢的逻辑,而不必每次都重新运行它。

为了演示这是如何有用的,我们将修改对cpu_percent函数的使用。我们选择 0.1 的间隔,因为它足以获得准确的数据。更大的间隔虽然不太现实,但有助于我们了解 Jupyter 如何允许我们编写昂贵的设置代码,同时仍然允许我们重新运行更快的部分,而不必等待速度慢的部分。

import psutil
psutil.cpu_percent(interval=5)

导出到. py 文件

尽管 Jupyter 作为原型工具为我们提供了很好的服务,但它并不适合我们项目的主体。我们想要一个传统的 Python 应用,而 Jupyter 强大的表示功能现在并没有用。Jupyter 内置了从幻灯片到 HTML 等多种格式的笔记本导出支持,但我们感兴趣的是 Python 脚本。

执行转换的脚本是 Jupyter 命令的一部分,使用 nbconvert(笔记本转换)子命令。 十三

> jupyter nbconvert --to script Untitled.ipynb

我们创建的无标题笔记本保持不变,生成了一个新的Untitled.py文件(列表 1-4 )。如果您重命名了笔记本,则这些名称与您指定的名称相匹配。如果您没有,并且想现在重命名它,因为您没有注意到它以前只被称为Untitled.ipynb,请单击笔记本视图顶部的“无标题”并输入新标题。

#!/usr/bin/env python
# coding: utf-8

# In[1]:

import sys
sys.version_info

# In[4]:

import socket
hostname = socket.gethostname()

addresses = socket.getaddrinfo(hostname, None)

for address in addresses:
    print(address[0].name, address[4][0])

# In[5]:

import psutil

# In[6]:

psutil.cpu_percent()

# In[7]:

psutil.virtual_memory().available

# In[8]:

psutil.sensors_battery().power_plugged

# In[ ]:

Listing 1-4Untitled.py, generated from the preceding notebook

正如您所看到的,每个单元格都用注释与其他单元格分隔开,围绕文本编码和 shebang 的标准样板文件出现在文件的顶部。在 Jupyter 中而不是直接在 Python 脚本或 REPR 中开始原型制作,在灵活性或时间方面我们没有任何损失;相反,在我们探索的时候,它给了我们更多的控制权来控制我们如何执行单独的代码块。

我们现在可以把它整理成一个实用程序脚本,而不是简单的语句,方法是把导入移到文件的顶部,然后把每个单元格转换成一个命名函数。显示单元格开始位置的# In注释是关于函数应该从哪里开始的有用提示。我们还必须转换代码以返回值,而不仅仅是把它留在函数的末尾(或者在 IP 地址的情况下打印出来)。结果是清单 1-5 。

# coding: utf-8
import sys
import socket

import psutil

def python_version():
    return sys.version_info

def ip_addresses():
    hostname = socket.gethostname()

    addresses = socket.getaddrinfo(hostname, None)
    address_info = []
    for address in addresses:
        address_info.append(address[0].name, address[4][0])
    return address_info

def cpu_load():
    return psutil.cpu_percent()

def ram_available():
    return psutil.virtual_memory().available

def ac_connected():
    return psutil.sensors_battery().power_plugged

Listing 1-5serverstatus.py

建筑物

这些函数本身并不是特别有用,大多数只是包装了一个现有的 Python 函数。显而易见,我们想做的事情是打印他们的数据,所以你可能想知道为什么我们要不厌其烦地创建单行包装函数。随着我们创建更复杂的数据源和消费它们的多种方式,这一点将变得更加明显,因为我们将受益于不将最简单的数据源作为特例。现在,为了让这些有用,我们可以给用户一个简单的命令行应用来显示这些数据。

因为我们使用的是一个裸 Python 脚本,而不是可安装的东西,所以我们使用一个通常称为“ifmain”的习惯用法。这是内置在许多编码文本编辑器和 ide 中的一个片段,因为它很难记住并且非常不直观。看起来是这样的:

def do_something():
    print("Do something")

if __name__ == '__main__':
    do_something()

这真的很可怕。__name__ 14 变量是对一个模块的全限定名的引用。如果您导入一个模块,__name__属性将是它可以被导入的位置。

>>> from json import encoder
>>> type(encoder)
<class 'module'>
>>> encoder.__name__
'json.encoder'

但是,如果您通过交互式会话或者通过提供脚本运行路径来加载代码,那么它就不一定会被导入。因此,这种模块有一个特殊的名字"__main__"。ifmain 技巧用于检测情况是否如此。也就是说,如果模块已经在命令行上被指定为要运行的文件,那么块的内容将会执行。当模块被其他代码导入时,这个块中的代码将而不是执行,因为变量__name__将被设置为模块的名称。如果没有这种保护,命令行处理程序将在该模块被导入时执行,从而接管任何使用这些实用函数的程序。

Caution

因为 ifmain 块的内容只有在模块是应用的入口点时才能运行,所以应该注意使它尽可能短。一般来说,将它限制在调用一个实用函数的单个语句是一个好主意。这使得函数调用是可测试的,并且对于我们在下一章中将要看到的一些技术是必需的。

sys 模块和 argv

大多数编程语言都公开了一个名为argv,的变量,它代表程序名和用户在调用时传递的参数。在 Python 中,这是一个字符串列表,其中第一个条目是 Python 脚本的名称(但不是 Python 解释器的位置)以及其后列出的任何参数。

不检查argv变量,我们只能产生非常基本的脚本。用户期望命令行标志提供关于该工具的帮助信息。此外,除了最简单的程序之外,所有程序都需要允许用户从命令行传递配置变量。

最简单的方法是检查出现在sys.argv中的值,并在条件中处理它们。实现一个帮助标志可能看起来像清单 1-6 。

#!/usr/bin/env python
# coding: utf-8

import socket
import sys

import psutil

HELP_TEXT = """usage: python {program_name:s}

Displays the values of the sensors

Options and arguments:
--help:    Display this message"""

def python_version():
    return sys.version_info

def ip_addresses():
    hostname = socket.gethostname()
    addresses = socket.getaddrinfo(socket.gethostname(), None)

    address_info = []
    for address in addresses:
        address_info.append((address[0].name, address[4][0]))
    return address_info

def cpu_load():
    return psutil.cpu_percent(interval=0.1)

def ram_available():
    return psutil.virtual_memory().available

def ac_connected():
    return psutil.sensors_battery().power_plugged

def show_sensors():
    print("Python version: {0.major}.{0.minor}".format(python_version()))
    for address in ip_addresses():
        print("IP addresses: {0[1]} ({0[0]})".format(address))
    print("CPU Load: {:.1f}".format(cpu_load()))
    print("RAM Available: {} MiB".format(ram_available() / 1024**2))
    print("AC Connected: {}".format(ac_connected()))

def command_line(argv):
    program_name, *arguments = argv
    if not arguments:
        show_sensors()
    elif arguments and arguments[0] == '--help':
        print(HELP_TEXT.format(program_name=program_name))
        return
    else:
        raise ValueError("Unknown arguments {}".format(arguments))

if __name__ == '__main__':
    command_line(sys.argv)

Listing 1-6sensors_argv.py – cli using manual checking of argv

command_line(...)函数并不复杂,但这是一个非常简单的程序。您可以很容易地想象这样的情况,其中允许以任何顺序使用多个标志,并且可配置的变量要复杂得多。这实际上是可行的,因为不涉及值的排序或解析。标准库中提供了一些助手功能,使得创建更复杂的命令行实用程序变得更加容易。

抱怨吗

argparse 模块是解析命令行参数的标准方法,不依赖于外部库。它使得处理前面提到的复杂情况变得不那么复杂;然而,与许多为开发人员提供选择的库一样,它的接口很难记住。除非您经常编写命令行实用程序,否则每次需要使用它时,您都需要阅读文档。

argparse 遵循的模型是,程序员通过用程序的一些基本信息实例化argparse.ArgumentParser来创建一个显式解析器,然后调用该解析器上的函数来添加新选项。这些函数指定了选项的名称、帮助文本、默认值以及解析器应该如何处理它。例如,一些参数是简单的标志,如--dry-run;其他的是加性的,像-v-vv-vvv;还有一些采用显式值,如--config config.ini

我们还没有在程序中使用任何参数,所以我们跳过添加这些选项,让解析器解析来自sys.argv的参数。该函数调用的结果是它从用户那里收集的信息。一些基本的处理也在这个阶段完成,比如处理--help,它根据添加的选项显示一个自动生成的帮助屏幕。

当使用 argparse 编写时,我们的命令行程序看起来如清单 1-7 所示。

#!/usr/bin/env python
# coding: utf-8

import argparse
import socket
import sys

import psutil

def python_version():
    return sys.version_info

def ip_addresses():
    hostname = socket.gethostname()
    addresses = socket.getaddrinfo(socket.gethostname(), None)

    address_info = []
    for address in addresses:
        address_info.append((address[0].name, address[4][0]))
    return address_info

def cpu_load():
    return psutil.cpu_percent(interval=0.1)

def ram_available():
    return psutil.virtual_memory().available

def ac_connected():
    return psutil.sensors_battery().power_plugged

def show_sensors():
    print("Python version: {0.major}.{0.minor}".format(python_version()))
    for address in ip_addresses():
        print("IP addresses: {0[1]} ({0[0]})".format(address))
    print("CPU Load: {:.1f}".format(cpu_load()))
    print("RAM Available: {} MiB".format(ram_available() / 1024**2))
    print("AC Connected: {}".format(ac_connected()))

def command_line(argv):
    parser = argparse.ArgumentParser(
        description='Displays the values of the sensors',
        add_help=True,
    )
    arguments = parser.parse_args()

    show_sensors()

if __name__ == '__main__':
    command_line(sys.argv)

Listing 1-7sensors_argparse.py – cli using the standard library module argparse

点击

Click 是一个附加模块,它简化了创建命令行界面的过程,假设您的界面与人们期望的标准大体相似。当创建命令行界面时,它有助于更加自然的流程,并鼓励您使用直观的界面。

argparse 要求程序员在构造解析器时指定可用的选项,而 click 在方法上使用 decorators 来推断参数应该是什么。这种方法不太灵活,但是可以轻松处理 80%的典型用例。如果你正在编写一个命令行界面,你通常希望跟随其他工具的引导,这样对最终用户来说是直观的。

由于 click 不在标准库中,我们需要将它安装到我们的环境中。像 psutil 一样,click 是一个代码依赖,而不是一个开发工具,所以我们安装如下:

> pipenv install click

由于我们只有一个主要命令,没有选项,click 只需要添加两行代码,一个导入和@click.command(...)装饰。print(...)调用应该全部替换为click.echo(...),但这不是严格要求的。结果如清单 1-8 所示。click.echo是一个助手函数,其行为类似于 print,但也处理字符编码不匹配的情况,并根据调用程序的终端的功能以及输出是否被传送到其他地方,智能地去除或保留颜色和格式标记。

#!/usr/bin/env python
# coding: utf-8
import socket
import sys

import click
import psutil

def python_version():
    return sys.version_info

def ip_addresses():
    hostname = socket.gethostname()
    addresses = socket.getaddrinfo(socket.gethostname(), None)

    address_info = []
    for address in addresses:
        address_info.append((address[0].name, address[4][0]))
    return address_info

def cpu_load():
    return psutil.cpu_percent(interval=0.1)

def ram_available():
    return psutil.virtual_memory().available

def ac_connected():
    return psutil.sensors_battery().power_plugged

@click.command(help="Displays the values of the sensors")
def show_sensors():
    click.echo("Python version: {0.major}.{0.minor}".format(python_version()))
    for address in ip_addresses():
        click.echo("IP addresses: {0[1]} ({0[0]})".format(address))
    click.echo("CPU Load: {:.1f}".format(cpu_load()))
    click.echo("RAM Available: {} MiB".format(ram_available() / 1024**2))
    click.echo("AC Connected: {}".format(ac_connected()))

if __name__ == '__main__':
    show_sensors()

Listing 1-8sensors_click.py – cli using the contributed library click

它还有许多实用功能,使创建更复杂的界面变得更容易,并补偿了最终用户系统上的非标准终端环境。例如,如果我们决定在show_sensors命令中将标题加粗,在 click 中我们可以使用secho (...)命令,将样式信息回显到终端。一个样式化标题的版本如清单 1-9 所示。

@click.command(help="Displays the values of the sensors")
def show_sensors():
    click.secho("Python version: ", bold=True, nl=False)
    click.echo("{0.major}.{0.minor}".format(python_version()))
    for address in ip_addresses():
        click.secho("IP addresses: ", bold=True, nl=False)
        click.echo("{0[1]} ({0[0]})".format(address))
    click.secho("CPU Load: ", bold=True, nl=False)
    click.echo("{:.1f}".format(cpu_load()))
    click.secho("RAM Available: ", bold=True, nl=False)
    click.echo("{} MiB".format(ram_available() / 1024**2))
    click.secho("AC Connected: ", bold=True, nl=False)
    click.echo("{}".format(ac_connected()))

Listing 1-9Extract from sensors_click_bold.py

secho( ... )功能 n 以指定的格式 将一些信息打印到屏幕上。The??l=参数允许 u s 指定 i *fy 是否应该打印新的一行。*如果您不使用 click,最简单的方法是

BOLD = '\033[1m'
END = '\033[0m'
def show_sensors():
    print(BOLD + "Python version:" + END + " ({0.major}.{0.minor})".format(python_version()))
    for address in ip_addresses():
        print(BOLD  + "IP addresses: " + END + "{0[1]} ({0[0]})".format(address))
    print(BOLD + "CPU Load:" + END + " {:.1f}".format(cpu_load()))
    print(BOLD + "RAM Available:" + END + "{} MiB".format(ram_available() / 1024**2))
    print(BOLD + "AC Connected:" + END + " {}".format(ac_connected()))

Click 还为终端中的自动完成和许多其他有用的功能提供了透明的支持。我们将在本书后面扩展这个接口时重新讨论这些。

挑战极限

我们已经研究了使用 Jupyter 和 IPython 进行原型开发,但是有时我们需要在特定的计算机上运行原型代码,而不是我们用于日常开发工作的计算机。例如,这可能是因为计算机有我们需要的外围设备或某些软件。

这主要是舒适的问题;在远程机器上编辑和运行代码可能有些不方便,也可能非常困难,尤其是当操作系统存在差异时。

在前面的例子中,我们已经在本地运行了所有代码。然而,我们计划在 Raspberry Pi 上运行最终代码,因为那是我们附加专用传感器的地方。作为一个嵌入式系统,无论是性能还是外设,它都有显著的硬件差异。

远程内核

测试这段代码需要在 Raspberry Pi 上运行 Jupyter 环境,并通过 HTTP 连接到该环境,或者通过 SSH 连接并手动与 Python 解释器交互。这是次优的,因为它需要确保 Raspberry Pi 有开放的端口供 Jupyter 绑定,并且需要使用 scp 之类的工具在本地和远程主机之间手动同步笔记本的内容。这对于真实世界的例子来说更是一个问题。很难想象在服务器上打开一个端口,在那里连接 Jupyter 来测试日志分析代码。

相反,可以使用 Jupyter 和 IPython 的可插拔内核基础设施将本地运行的 Jupyter 笔记本连接到许多远程计算机中的一台。这允许在多台机器上透明地测试相同的代码,并且只需要最少的手工工作。

当 Jupyter 显示其潜在执行目标列表时,它列出了其已知的内核规范。当选择了一个内核规范后,该内核的一个实例被创建并链接到笔记本。可以连接到远程机器,并为本地 Jupyter 实例手动启动一个单独的内核。然而,这很少是对时间的有效利用。当我们在本章开始时运行pipenv run ipython kernel install时,我们正在为当前环境创建一个新的内核规范,并将其安装到已知内核规范的列表中。

要添加使用远程主机的内核规范,我们可以使用助手实用程序remote_ikernel。我们应该将它安装到与 Jupyter 相同的位置,因为它是 Jupyter 的助手,而不是该环境的特定开发工具。

> pip install --user remote_ikernel

然后,我们需要在远程主机上设置环境和内核助手程序。连接到 Raspberry Pi(或另一台我们希望向其发送代码的机器),并像我们前面所做的那样在该计算机上创建一个 pipenv:

rpi> python -m pip install --user pipenv
rpi> mkdir development-testing
rpi> cd development-testing
rpi> pipenv install ipykernel

Tip

一些低性能主机,如 Raspberry Pis,可能会使安装 ipython_kernel 慢得令人沮丧。在这种情况下,您可以考虑使用软件包管理器的 ipython_kernel 版本。ipython 内核确实需要许多支持库,这些库可能需要一些时间才能安装到低性能计算机上。在这种情况下,您可以将环境设置为

rpi> sudo apt install python3-ipykernel
rpi> pipenv --three --site-packages

或者,如果您使用的是 Raspberry Pi,在 https://www.piwheels.org 有一个预编译轮子的存储库,除了现有的之外,还可以通过向您的 Pipfile 添加以下新的源来启用它:

[[source]]
url = "https://www.piwheels.org/simple"
name = "piwheels"
verify_ssl = true

然后使用pipenv install像平常一样安装 ipython_kernel 包。如果您使用的是运行 raspbuin 的 Raspberry Pi,您应该总是将 piwheels 添加到您的 Pipfile 中,因为 raspbuin 预先配置为全局使用 PiWheels。不在 Pipfile 中列出它会导致安装失败。

这将在 Raspberry Pi 机器上安装 IPython 内核程序;然而,我们仍然需要在我们的主机上安装它。首先,我们将安装一个指向我们创建的 pipenv 环境的内核。在此之后,Raspberry Pi 将有两个可用的内核,一个用于系统 Python 安装,另一个用于我们环境的开发测试。安装内核后,我们可以查看规范的配置文件:

rpi> pipenv run ipython kernel install --user --name=development-testing
Installed kernelspec development-testing in /home/pi/.local/share/jupyter/kernels/development-testing
> cat /home/pi/.local/share/jupyter/kernels/development-testing/kernel.json
 {
 "argv": [
  "/home/pi/.local/share/virtualenvs/development-testing-nbi70cWI/bin/python",
  "-m",
  "ipykernel_launcher",
  "-f",
  "{connection_file}"
 ],
 "display_name": "development-testing",
 "language": "python"
}

这个输出向我们展示了如果 Jupyter 安装在那台计算机上,它将如何运行内核。我们可以使用这个规范中的信息在我们的开发机器上创建一个新的 remote_ikernel 规范,它指向与 Raspberry Pi 上的开发测试内核相同的环境。

前面的内核规范列出了如何在 Raspberry Pi 上启动内核。我们可以通过测试 SSH 到 Raspberry Pi 的命令来验证这一点,例如,将-f {connection_file}改为--help来显示帮助文本。

rpi> /home/pi/.local/share/virtualenvs/development-testing-nbi70cWI/bin/python -m ipykernel –help

我们现在可以返回开发计算机,创建远程内核规范,如下所示:

> remote_ikernel manage --add --kernel_cmd="/home/pi/.local/share/virtualenvs/development-testing-nbi70cWI/bin/python
-m ipykernel_launcher -f {connection_file}"
--name="development-testing" --interface=ssh --host=pi@raspberrypi --workdir="/home/pi/developmenttesting" --language=python

它看起来有点吓人,跨越五行文本,但它可以被分解:

  • --kernel_cmd参数是内核规范文件中argv部分的内容。每行都用空格分隔,没有单独的引号。这是启动内核本身的命令。

  • --name参数相当于原始内核规范中的display_name。当您选择这个内核时,这将显示在 Jupyter 中,旁边是 SSH 信息。它不必与您复制的远程内核的名称相匹配,这只是供您参考。

  • --interface--host参数定义了如何连接到远程机器。你应该确保无密码的 15 SSH 可以连接到这台机器,以便 Jupyter 可以建立连接。

  • --workdir参数是环境应该使用的默认工作目录。我建议将这个目录设置为包含您的远程 Pipfile 的目录。

  • --language参数是来自原始内核规范的语言值,用于区分不同的编程语言。

Tip

如果连接远程内核有困难,可以尝试在命令行上使用 Jupyter 打开一个 shell。这通常会显示有用的错误消息。使用jupyter kernelspec list找到内核的名称,然后使用jupyter console:

> jupyter kernelspec list
Available kernels:
  advancedpython       C:\Users\micro\AppData\Roaming\jupyter\kernels\advancedpython

  rik_ssh_pi_raspberrypi_developmenttesting        C:\Users\micro\AppData\Roaming\jupyter\kernels\rik_ssh_pi_raspberrypi_developmenttesting

> jupyter console --kernel= rik_ssh_pi_raspberrypi_developmenttesting
In [1]:

此时,当我们重新进入 Jupyter 环境时,我们看到一个新的内核与我们提供的连接信息相匹配。然后,我们可以选择该内核并执行需要该环境的命令, 16 ,Jupyter 内核系统负责连接到 Raspberry Pi 并激活~/development-testing中的环境。

开发不能在本地运行的代码

树莓 Pi 上有一些有用的传感器;这些提供了我们有兴趣收集的实际数据。在其他用例中,这可能是通过调用自定义命令行实用程序、自省数据库或进行本地 API 调用收集的信息。

这不是一本关于如何充分利用 Raspberry Pi 的书,所以我们将忽略它是如何工作的许多细节,但可以说有大量的文档和支持使用 Python 做令人兴奋的事情。在这种情况下,我们希望使用一个库,该库提供从可以添加到电路板上的传感器中检索温度和相对湿度的功能。像许多其他任务一样,这相对较慢(可能需要一秒钟的时间来测量),并且需要特定的环境(安装外部传感器)来执行。这样,它类似于通过管理端口进行通信来监控 web 服务器上的活动进程。

首先,我们将 Adafruit DHT 17 库添加到我们的环境中。我们目前在 Raspberry Pi 和本地都有 pipfile 的副本。远程副本只包含对 ipykernel 的依赖,而 ipykernel 已经在本地副本中,所以用我们在本地创建的文件覆盖远程文件是安全的。正如我们所知,DHT 库只在 Raspberry Pis 上有用,我们可以使用条件依赖语法: 18 对它进行限制,使它只安装在带有 ARM 处理器的 Linux 机器上

> pipenv install "Adafruit-CircuitPython-DHT ; 'arm' in platform_machine"

这导致PipfilePipfile.lock文件被更新以包含该依赖关系。我们希望在远程主机上利用这些依赖关系,因此我们必须使用 Pipenv 复制这些文件并安装它们。在两种环境下运行这个命令都是可能的,但是这样会有出错的风险。Pipenv 假设您使用相同版本的 Python 进行开发和部署,这符合其避免部署期间出现问题的理念。因此,如果您计划部署一套 Python 版本,您应该在本地使用它进行开发。

但是,如果您不想在本地环境中安装不寻常的 Python 版本,或者如果您的目标是多台不同的机器,则可以取消此检查。为此,从 Pipfile 的末尾删除python_version行。这允许您的环境部署到任何 Python 版本。然而,您应该确保您知道您需要支持什么版本,并相应地进行测试。

使用scp(或者您选择的工具)将PipfilePipfile.lock文件复制到远程主机,然后在远程机器上运行带有--deploy标志的pipenv install--deploy指示 pipenv 仅在版本完全匹配的情况下继续,这对于将已知良好的环境从一台机器部署到另一台机器非常有用。

rpi> cd /home/pi/development-testing
rpi> pipenv install --deploy

但是,请注意,如果您已经在不同的操作系统或不同的 CPU 架构上创建了您的Pipfile(例如在标准笔记本电脑上创建并安装在 Raspberry Pi 上的文件),那么在将它们部署到另一台机器上时,固定的包可能不适合。在这种情况下,可以通过运行pipenv lock --keep-outdated在不触发版本升级的情况下重新锁定依赖关系。

现在,您在远程环境中拥有了指定的依赖项。如果您已经重新锁定了文件,您应该将已更改的锁定文件转移回来并存储它,这样您就可以在将来重新部署而不必重新生成该文件。在这一阶段,您可以通过 Jupyter 客户机连接到远程服务器,并开始构建原型。我们希望添加湿度传感器,所以我们将使用我们刚刚添加的库,现在可以接收有效的湿度百分比。

我复制这些文件的树莓 Pi 有一个 DHT22 传感器连接到引脚 D4,如图 1-8 所示。这种传感器很容易从 Raspberry Pi 或通用电子供应商处获得。如果您手头没有这样的命令,那么尝试一个替代命令来演示代码正在 Pi 上运行,比如platform.uname()

img/481001_1_En_1_Fig8_HTML.jpg

图 1-8

Jupyter 连接到一个远程树莓码头

该笔记本存储在您的开发机器上,而不是远程服务器上。可以使用nbconvert将它移植到 Python 脚本中,方法和以前一样。然而,在我们这样做之前,我们也可以将内核改回我们的本地实例,以检查代码在那里的行为是否正确。目标是创建在两种环境下都能工作的代码,返回湿度或占位符值。

img/481001_1_En_1_Fig9_HTML.jpg

图 1-9

演示在本地机器上运行的相同代码

图 1-9 表明该规范并不适用于所有环境。我们非常希望至少能够在本地运行一些代码,这样我们就可以调整我们的代码来考虑其他平台的限制。当它被转换成更一般的函数形式时,看起来会像这样

def get_relative_humidity():
    try:
        # Connect to a DHT22 sensor on GPIO pin 4
        from adafruit_dht import DHT22
        from board import D4
    except (ImportError, NotImplementedError):
        # No DHT library results in an ImportError.
        # Running on an unknown platform results in a NotImplementedError
        # when getting the pin
        return None
    return DHT22(D4).humidity

这允许在任何机器上调用该函数,除非它有一个温度和湿度传感器连接到引脚D4并在任何其他地方返回一个None

完整的脚本

清单 1-10 显示了完整的脚本。要确保这是一个有用的库,仍然有一些障碍需要克服,最值得注意的是,show_sensors函数正在对值进行格式化。此时,我们不想将格式集成到数据源中,因为我们想确保原始值对其他接口可用。这是我们将在下一章中讨论的内容。

#!/usr/bin/env python
# coding: utf-8
import socket
import sys

import click
import psutil

def python_version():
    return sys.version_info

def ip_addresses():
    hostname = socket.gethostname()
    addresses = socket.getaddrinfo(socket.gethostname(), None)

    address_info = []
    for address in addresses:
        address_info.append((address[0].name, address[4][0]))
    return address_info

def cpu_load():
    return psutil.cpu_percent(interval=0.1) / 100.0

def ram_available():
    return psutil.virtual_memory().available

def ac_connected():
    return psutil.sensors_battery().power_plugged

def get_relative_humidity():
    try:
        # Connect to a DHT22 sensor on GPIO pin 4
        from adafruit_dht import DHT22
        from board import D4
    except (ImportError, NotImplementedError):
        # No DHT library results in an ImportError.
        # Running on an unknown platform results in a NotImplementedError
        # when getting the pin
        return None
    return DHT22(D4).humidity

@click.command(help="Displays the values of the sensors")

def show_sensors():
    click.echo("Python version: {0.major}.{0.minor}".format(python_version()))
    for address in ip_addresses():
        click.echo("IP addresses: {0[1]} ({0[0]})".format(address))
    click.echo("CPU Load: {:.1%}".format(cpu_load()))
    click.echo("RAM Available: {:.0f} MiB".format(ram_available() / 1024**2))
    click.echo("AC Connected: {!r}".format(ac_connected()))
    click.echo("Humidity: {!r}".format(get_relative_humidity()))

if __name__ == '__main__':
    show_sensors()

Listing 1-10The final version of our script from this chapter

摘要

关于原型的章节到此结束;在接下来的章节中,我们将基于我们在这里创建的数据提取函数来创建遵循 Python 最佳实践的库和工具。我们遵循的道路是从摆弄一个库到拥有一个真正有用的工作 shell 脚本。随着我们的继续,它将发展到更好地适应我们的分布式数据聚合的最终目标。

我们在这里讨论的技巧在软件开发生命周期的很多时候都是有用的,但是重要的是不要僵化,只遵循一个过程。虽然这些方法很有效,但有时打开 REPL 或使用 pdb(甚至简单的print(...)调用)会比设置远程内核更直接。除非你知道有哪些选择,否则不可能找到解决问题的最佳方法。

概括一下:

  1. Jupyter 是一个很好的工具,可以用来探索库并对它们的使用进行初步的原型设计。

  2. Python 有一些特殊用途的调试器,可以使用breakpoint()函数和环境变量轻松集成到您的工作流中。

  3. Pipenv 帮助您定义保持最新的版本需求,包含最少的规范,并促进可重复的构建。

  4. 库点击允许以惯用的 Python 风格实现简单的命令行界面。

  5. Jupyter 的内核系统允许将本地和其他计算机上运行的多种编程语言无缝集成到一个开发流程中。

额外资源

我们在这一章中使用的每一个工具都有很深的内涵,而我们只是浏览了表面来达到我们的目的。

  • https://pipenv.pypa.io/en/latest/ 的 pipenv 文档中有很多关于定制 Pipenv 以使其按照您的意愿工作的有用解释,特别是关于定制虚拟环境创建和集成到现有流程中。如果您是 pipenv 的新手,但是已经使用了很多虚拟环境,那么这里有很好的文档来帮助您弥合差距。

  • 如果你对用 Jupyter 开发其他编程语言的原型感兴趣,我建议你通读位于 https://jupyter.readthedocs.io/en/latest/ 的 Jupyter 文档——尤其是内核部分。

  • 关于树莓 Pi 和兼容传感器的信息,我推荐 CircuitPython 项目关于树莓 Pi 的文档: https://learn.adafruit.com/circuitpython-on-raspberrypi-linux

Footnotes 1

当你第一次不小心关闭了窗口,丢失了正在编写的代码时,你会很高兴的。

  2

一些文本编辑器精确地集成了一个终端来减少这种上下文切换。

  3

Pdb 允许你遍历列表理解的每一次迭代,就像你处理循环一样。当您有试图诊断问题的现有代码时,这是有用的,但是当列表理解是调试的附带任务时,这是令人沮丧的。

  4

这些都可以缩写,如粗体所示。step变成了s , prettyprint变成了pp等等。

  5

我曾经严重误解了一个 bug,以至于过度使用了debug,直到 pdb 提示符看起来像((((((Pdb))))))。这是一个反模式,因为很容易不小心丢失位置;如果您发现自己处于类似的情况,请尝试使用条件断点。

  6

您可能会找到推荐import pdb; pdb.set_trace()的文档。这是一种较老的风格,仍然很常见,但做同样的事情,尽管没有一些可配置性和可读性。

  7

事实上,许多人更喜欢为 Jupyter 创建一个虚拟环境,并将其添加到系统路径中,以避免在他们的全局名称空间中出现版本冲突的风险。

  8

一些编辑器,比如专业版的 PyCharm IDE 和微软的 VSCode editor,已经开始在 IDE 中提供部分等同于笔记本的界面。他们没有所有可用的功能,但它出奇的好。

  9

✔表示满足了我们的要求,❌表示没有满足,⚠表示满足了我们的要求,但是用户体验很差。

  10

这意味着如果您的单元格以赋值结束,它将不会显示被赋值的值。这是因为 Python 中的赋值不计算变量。用version = sys.version_info明确显示这一点是很常见的

version

虽然您也可以使用 Python 3.8 的“walrus”操作符(version := sys.version_info),因为它确实会计算被赋值的值,但它看起来相当奇怪,所以我建议不要将它用于独立的赋值。这个操作符最适合用在循环和 if 语句中,因为在这种情况下不需要括号,所以看起来更自然。

  11

在这些截图中,全球可路由 IPv6 地址的一部分已经被删除。

  12

只有当变量对内核可用时,这个快捷方式才起作用,所以您可能会发现在使用自动完成功能之前,您必须运行定义它的单元格。如果你用不同的数据覆盖同一个变量名,那么你可能会看到错误的信息,但是我建议尽可能不要这样做,因为这可能会造成混乱。

  13

提供笔记本兼容性的 ide 和编辑器通常也有一个在编辑器窗口中完成这项工作的特性。

  14

“双下划线”通常读作“dunder main ”,因为说“下划线”四次会增加 12 个音节,感觉很傻。

  15

使用ssh-copy-id user@host自动设置,而不是手动编辑authorized_hosts文件。

  16

如果您更喜欢控制台环境而不是 Jupyter 笔记本的 web 环境,您可以使用jupyter kernelspec list查看可用内核的列表,并使用jupyter console --kernel kernelname打开一个连接到您选择的规范的 IPython shell。

  17

这是 Adafruit 优秀的 CircuitPython 生态系统的一部分。在 https://learn.adafruit.com/dht ,他们有更多关于这些传感器以及如何在各种项目中使用它们的信息

  18

这是由 PEP508 在 www.python.org/dev/peps/pep-0508/ 定义的。该页面上有一个表格列出了有效的过滤器,尽管将来可能会添加更多过滤器。

 

二、测试、检查、Lint

Python 以“鸭子”类型而闻名, 1 也就是说,你应该在没有显式类型检查的情况下编写代码。如果你写了一个在数字类型上实现某种算法的函数,那么当使用intfloatdecimal.Decimalfractions.Fractionnumpy.uint64时,它应该同样工作良好。只要对象提供了正确的功能,并且这些功能具有正确的含义,它们就能正常工作。

Python 通过后期绑定动态分派的相关特性来实现这一点。我们稍后将更深入地回到这个主题,但是可以说动态调度是能够运行

some_int + other_int
some_float + other_float

而不得不用 2

int.__add__(some_int, other_int)
float.__add__(some_float, other_float)

也就是说,通过对象来解析函数,以找到该类型的正确实现。后期绑定意味着这种查找发生在调用函数的时候,而不是在编写程序的时候。这两者的结合形成了我们所说的鸭子类型,并允许编写信任底层对象实现的函数,而无需事先知道它们是什么。然而,这也意味着 Python 程序无法从使用早期绑定 3 的语言提供的相同级别的自动检查中获益。

到目前为止,我们一直在编写操作 Python 内置数据类型的简单函数,比如 float。这对于琐碎的函数很有效,但是随着程序变得越来越复杂,编写与代码的其他部分没有正式关系的代码变得越来越困难。

在前一章中,我们向数据收集中添加了一个湿度值,但它来自一个也收集环境温度的传感器。传感器以摄氏度为单位返回这一信息。我们可以添加一个匹配的温度传感器,如清单 2-1 所示。

def get_temperature():
    # Connect to a DHT22 sensor on GPIO pin 4
    try:
        from adafruit_dht import DHT22
        from board import D4
    except (ImportError, NotImplementedError):
        # No DHT library results in an ImportError.
        # Running on an unknown platform results in a NotImplementedError
        # when getting the pin
        return None
    return DHT22(D4).temperature

Listing 2-1A simple temperature sensor function

然而,我们可能想让用户以不同的格式看到它。当我们编写一个转换函数时,从我们对程序功能的理解和我们给函数起的名字中我们知道,它对数字进行运算,从一个温度系统转换到另一个温度系统,但这种关系纯粹是开发人员的理解,并没有隐含在任何代码中。我们为此编写的代码如清单 2-2 所示。

to Kelvin

Listing 2-2Conversion functions for Celsius to Fahrenheit and Celsius

img/481001_1_En_2_Figa_HTML.jpg

正如你从截图中看到的,这些对于整数参数是正确的。如果为它们提供了Fraction、 4 、Decimalfloat参数,它们也会返回正确的值。我们的函数实际上会返回任何数值类型的值。如果我们调用celsius_to_fahrenheit("21"),Python 的类型系统会抛出TypeError,因为除法运算不是在字符串上指定的,但是我们的函数只对实数有意义,而不仅仅是实现除法方法的对象。我们在任何地方都没有捕捉到这个需求,所以如果有人传递一些我们不期望的数值,这些函数仍然会产生一个输出(清单 2-3 )。

to Fahrenheit

Listing 2-3The result of converting a complex number or a matrix from Celsius

img/481001_1_En_2_Figb_HTML.jpg

本章介绍的前两个概念在这些示例中有所体现。测试是确定功能是否正常工作的过程。检查,或者更确切地说是静态类型检查,是在编写函数时而不是运行时识别函数所操作的类型的过程。当编写库时,为代码编写测试是正常的。你可能是唯一运行这些测试的人;它们可以增强你对准则的信心,并有助于你做出贡献。

另一方面,您添加的任何类型检查对任何使用您的代码作为库函数来源的人都有直接的好处。您可能会从这些检查中对自己的代码缺乏信心(尽管它们确实有助于您发现错误),但是它们真正的作用是使您的代码更容易被那些不像作者那样熟悉它的人使用。这并不是说类型检查对你没有什么好处;他们提供的暗示对于澄清微妙的误解是非常宝贵的。许多 ide 甚至使用它们提供的额外信息来提供更加用户友好的编程体验。

测试

未经测试的代码就是断码。

Python 内置了对测试的支持,其形式是标准库中的 unittest 模块。这提供了一个TestCase类,它用安装和拆卸代码包装了单独的测试,并提供了用于断言值之间关系的帮助函数。虽然可以单独使用这个模块编写测试,但是我强烈建议使用附加模块 pytest。

Pytest 消除了在建立测试系统时对样板文件的需求。比较以下用 unittest 风格(清单 2-5 )和 pytest 风格(清单 2-6 )编写的测试。这些是测试我们之前原型化的温度转换函数,如清单 2-4 所示。

import pytest
from temperature import celsius_to_fahrenheit

def test_celsius_to_fahrenheit():
    assert celsius_to_fahrenheit(21) == 69.8

def test_celsius_to_fahrenheit_equivlance_point():
    assert celsius_to_fahrenheit(-40) == -40

def test_celsius_to_fahrenheit_float():
    assert celsius_to_fahrenheit(21.2) == 70.16

def test_celsius_to_fahrenheit_string():
    with pytest.raises(TypeError):
        f = celsius_to_fahrenheit("21")

Listing 2-6Pytest style of testing the conversion function

import unittest
from temperature import celsius_to_fahrenheit

class TestTemperatureConversion(unittest.TestCase):

    def test_celsius_to_fahrenheit(self):
        self.assertEqual(celsius_to_fahrenheit(21), 69.8)

    def test_celsius_to_fahrenheit_equivlance_point(self):
        self.assertEqual(celsius_to_fahrenheit(-40), -40)

    def test_celsius_to_fahrenheit_float(self):
        self.assertEqual(celsius_to_fahrenheit(21.2), 70.16)

    def test_celsius_to_fahrenheit_string(self):
        with self.assertRaises(TypeError):
            f = celsius_to_fahrenheit("21")

if __name__ == '__main__':
    unittest.main()

Listing 2-5Unittest style of testing the conversion function

def celsius_to_fahrenheit(celsius):
    return celsius * 9 / 5 + 32

def celsius_to_kelvin(celsius):
    return 273.15 + celsius

Listing 2-4temperature.py being tested

最明显的区别就是self.assertEqual(x, y)assert x == y之间的区别。这两者做同样的事情,但是 pytest 风格允许更自然的代码。unittest 风格将大多数操作包装在助手函数中,助手函数既执行比较,又在断言失败时生成适当的错误消息。例如,如果 x 和 y 是不同的列表,assertEqual调用assertListEqual来比较列表并生成缺失和附加元素的 diff,并将当前测试标记为失败。表 2-1 展示了 pytest 断言风格比 unittest 断言风格更清晰的方式。

表 2-1

unittest 和 pytest 风格中的一些常见断言格式

|

比较

|

单元测试

|

Pytest(测试)

| | --- | --- | --- | | 价值观是平等的 | self.assertEqual(x, y) | assert x == y | | 价值观是不平等的 | self.assertNotEqual(x, y) | assert x != y | | 值为无 | self.assertIsNone(x) | assert x is None | | 列表包含 | self.assertIn(x, y) | assert x in y | | 浮点数相差不到 0.000001 | self.assertAlmostEqual(x, y) | assert x == pytest.approx(y) | | 引发了异常 | with self.assertRaises(TypeError):``doSomething() | with pytest.raises(TypeError):``doSomething() |

此外,unittest 有一个TestCase类,它被用作所有测试组的基类。这些测试用例可以具有通用的设置和拆卸功能,以确保通用的变量和数据就位。ifmain 块中调用的unittest.main()函数是测试系统的入口点。那个函数收集当前模块中的所有测试类并执行它们。对于较大的项目,通常会有多个包含测试的文件,这些文件由测试加载器发现,并且它们的内容被收集和运行。

Pytest 的行为有些不同;运行可执行文件开始测试发现,而不是依赖 Python 源文件来整理测试。一旦发现了测试,就应用作为命令行参数传递的任何过滤器,并运行剩余的测试。

定义测试的代码与执行设置和发现的独立可执行文件之间的分离允许对执行测试的 Python 环境进行更多的控制,例如,允许使用裸assert语句,而不需要断言的包装函数。

何时编写测试

在软件工程中有很多关于什么时候是写测试的合适时间,应该在代码写之前还是之后写的强烈意见。首先编写测试通常被称为测试驱动开发(TDD ),它有很多支持者。这是有原因的。在测试驱动的环境中工作是非常令人满意的,因为在开发一个特性的最后阶段,它会让你有一种胜利的感觉。如果您计划以后编写测试,它们可能会让人觉得是不必要的杂务。

在软件工程的许多情况下,对于任何给定的问题都有一个最佳选择,但是我相信 TDD 与稍后编写测试是一个更个人化的选择。我坚信无论哪种方式,开发人员都可以是高效的,但是有些人自然会被首先编写测试所吸引,而另一些人则觉得这会导致缓慢的开始,这是他们想要避免的。也很有可能你更喜欢哪一个取决于你的心情或者你对你正在工作的代码库的熟悉程度。

我通常更喜欢首先编写测试,因为我发现它有助于我在深入实现细节之前思考代码的含义,但我经常发现自己希望快速完成一些工作,然后再完善它。这两种方法都完全有效;在编写代码之前编写测试并不比之后编写更正确或合适。两种都试试,看看哪一种对你来说更自然。

在某些情况下,您甚至可能决定测试不值得编写,或者您可能有一个客户或经理为了节省时间而强迫您不要编写测试。我不会告诉你这是一个好主意,但同样,有时这也是一种有效的方法。如果您正在编写一个只运行一次的程序,或者正在使用一个未经测试的复杂的现有代码库,那么编写测试的成本/收益比就会偏离它通常所在的位置。在这些情况下,决定测试不是时间投资的优先事项是完全可以接受的。但是,如果发生这种情况,你要记住,决定不是不可逆的。如果你发现自己反复手动测试同样的东西并感到沮丧,这通常是你应该编写测试的信号。如果你认为增加测试可以节省时间,不要让你花费在手工测试上的时间的沉没成本阻止你花时间增加测试。

Exercise 2-1: Try Test-Driven Development

在这一章中,我们在编写完代码后,正在编写测试。这没有什么特别的原因。这一选择是为了使这一章的内容更加自然。如果您想先尝试编写测试,这是一个很好的机会。如果你更喜欢在这之后写测试,按照这一章的流程,那么可以跳过这个练习。

选择一个我们在前一章看到的传感器,并为其编写一些测试。在本章的支持代码中,您会发现一个使用上一章中的代码设置的环境。它还包含如何运行测试的文档。

如果你真的完成了这个练习,请注意你最终得到的代码结构可能与本章所建议的有很大的不同。请记住,未来的章节将建立在这一点上,你还不知道所有的要求。有许多方法可以解决这个问题;这个练习的目的是帮助你了解在 TDD 过程中,作为测试写作的一部分,你需要做什么样的决策;这里没有正确的答案。

创建格式化函数以提高可测试性

在前一章中,我们创建了一个简单的脚本,以简单的命令行脚本的形式打印各个传感器的值。这包括从预先编写的main()函数中手动调用多个函数,并独立处理它们的格式。尽管这是一个概念验证,但这不是构建大型系统的可持续方式。对于每个传感器值,我们需要一种方法来提取用于定量分析的原始值以及用于向最终用户显示的格式化值。

进行这种分离的另一个重要原因是确保功能有严格的关注点分离。我们希望能够测试是否提取了正确的值,以及值的格式是否正确,而不必同时进行这两项工作。如果我们有一个紧密耦合的数据提取和格式化函数,我们将无法检查一系列不同值的格式是否正确。我们将只能检查当前运行测试的机器的值,这些值可能会随着运行的不同而变化很大。

为了实现这一点,我们将把函数扩展到一个 Python 类中,该类既提供传感器检索到的原始值,又提供一个帮助函数来适当地格式化它(清单 2-7 )。这种方法使得在面向用户的环境(如命令行脚本)中显示传感器的当前值更加容易,因为在周围的脚本中没有单独传感器值的特殊大小写。

例如,确定有多少 RAM 可用的传感器应该显示格式化为适当单位的字节数。之前,我们假设兆字节 5 是一个合适的单位,并使用"{:.0f} MiB".format(ram_available() / 1024**2)静态缩放该数字。这既太复杂,不适合一行程序,又太简单,没有普遍意义。

class Temperature(Sensor[Optional[float]]):
    title = "Ambient Temperature"

    def value(self) -> Optional[float]:
        try:
            # Connect to a DHT22 sensor on GPIO pin 4
            from adafruit_dht import DHT22
            from board import D4
        except (ImportError, NotImplementedError):
            # No DHT library results in an ImportError.
            # Running on an unknown platform results in a
            # NotImplementedError when getting the pin
            return None
        try:
            return DHT22(D4).temperature
        except RuntimeError:
            return None

    @staticmethod
    def celsius_to_fahrenheit(value: float) -> float:
        return value * 9 / 5 + 32

    @classmethod
    def format(cls, value: Optional[float]) -> str:
        if value is None:
            return "Unknown"
        else:
            return "{:.1f}C ({:.1f}F)".format(value, cls.celsius_to_fahrenheit(value))

    def __str__(self) -> str:
        return self.format(self.value())

Listing 2-7New temperature sensor implementation from sensors.py

这个版本和原始版本之间最显著的区别是从函数到类的转换。这是一个简单的类,它不从基类继承,所以在包含基类的类名后面没有括号。最直接的方法, 6 value(),是原始ram_available()函数的直接模拟,因为它提取的数据没有任何格式。

format(...)方法相当于之前直接在命令行程序的显示逻辑中进行的格式化。通过使它成为 sensor 类上的一个方法,我们隐式地将格式化函数与它们使用的数据检索函数相关联。与几十个函数都在全局范围内相比,这使得理解相关的代码变得更容易,并且减少了理解模块整体的认知负荷。

Instance, Class, and Static Methods On Classes

函数celsius_to_fahrenheit(...)被定义为上面有装饰器的staticmethod,方法format(...)被定义为classmethod,第一个参数是cls而不是self

这些方法的行为与标准实例方法略有不同。当你在一个类上定义一个函数时,它把 self 作为第一个参数。这使它成为一个实例方法;它只能在类的实例上调用,并且可以访问在该实例上设置的属性以及其他方法。Temperature().value()会返回一个结果,但是Temperature.value()会引出一个TypeError

在典型情况下,当在 Python 中对一个对象定义一个函数时,它在第一个位置有一个参数self。这被绑定到该类的一个实例,因此每个函数都可以访问存储在该类中的数据,并且可以调用具有相同访问权限的函数。当用Temperature()调用类对象时,返回该类的一个实例,当调用该实例上的一个方法时,它会自动将该实例作为第一个参数传递。这意味着Temperature().value()是检索该值所需的全部内容。只要您通过实例访问方法来调用它,您就永远不需要显式传递self参数。

一个类方法以 cls 7 作为第一个参数,指向类而不是实例。该函数仍然可以访问该类上的其他函数以及存储在该类上的任何属性,但是它不能调用实例方法,因为它没有可用的类实例。类方法可以像平常一样在实例上调用,也可以直接在类上调用。它们对于编写自定义构造函数(比如from_json(...))或者使用类的其他函数或属性的实用函数非常有用。可以在类(Temperature.format(21))或实例(Temperature().format(21))上调用类方法;在这两种情况下,它都将接收类作为第一个参数。

最后,静态方法是没有隐式第一个参数的方法。与类方法相比,静态方法没有明显的优势,但是隐式参数的缺失让代码的读者清楚地看到,它是一个完全独立的方法,只是为了方便起见才与类组合在一起。它也可以在类或实例上调用,如Temperature.celsius_to_fahrenheit(21)Temperature().celsius_to_fahrenheit(21)

前面的传感器代码旨在检索和格式化传感器数据。一些传感器的__init__()方法可能会执行一些昂贵的 8 设置,这是使value()工作所需的。我们将format(...)方法标记为类方法的原因是为了确保我们仍然可以在不实例化类的情况下格式化数据。这允许我们在没有相关传感器实例的情况下格式化数据,只需要它的类。

__str__()方法是 Python 的内部约定;它确定如何将对象转换为字符串表示形式。 9 因为这只在类的实例中使用过,我们可以把它简化为“获取当前值并格式化它”因此,显示所有传感器值的代码大大缩短,更容易理解:

@click.command(help="Displays the values of the sensors")
def show_sensors():
    for sensor in [PythonVersion(), IPAddresses(), CPULoad(), RAMAvailable(),
                  ACStatus(), RelativeHumidity()]:
        click.secho(sensor.title, bold=True)
        click.echo(sensor)
        click.echo("")

显示传感器值的工作几乎完全委托给了传感器本身。只需要传感器有一个返回其当前值的格式化版本的__str__()方法,并且有一个包含显示标题的title属性。

现在我们已经重新组织了代码,使其具有独立的格式化和值提取功能,我们可以编写测试来确保值按照我们期望的格式进行格式化。和往常一样,你可以在这本书的网站上找到本章的支持文件中重新组织的代码。

pytest(测试)

为了能够运行我们的测试,首先要做的是我们需要安装 pytest 本身。我们认为这是一个开发包,因为它不是系统必须使用的,只是为了让开发人员确信它的行为符合预期。

pipenv install --dev pytest

这为我们的项目创建了一个新的 pytest 脚本。此时,我们可以运行pipenv run pytest并查看我们测试运行的结果,即运行了 0 个测试。为了测试我们有一个工作环境,我们可以创建一个样本测试。这通常由代码框架生成器来完成,其中的测试类似于assert 1 == 1。我们将断言包含 cli 脚本的文件中有一个我们期望看到的传感器。

为此,我们创建一个新的tests/目录,并添加一个空的__init__.py和一个test_sensors.py,如下所示:

import sensors

def test_sensors():
    assert hasattr(sensors, 'PythonVersion')

单元、集成和功能测试

编写测试最困难的部分是知道要编写哪些测试。编写运行整个应用并检查输出的测试很有诱惑力,可以像最终用户一样有效地与代码交互。这被称为功能测试。功能测试在 web 框架中尤其流行,其中可能有许多不同的代码层进行交互,以提供诸如身份验证、会话和模板呈现等服务。虽然这确实有效地测试了是否生成了正确的输出,但是编写比确认常见情况更进一步的测试可能会很困难。

如果我们在命令行脚本中采用这种方法,我们会看到脚本在运行时返回我们期望的值。我们将面临的直接问题是,知道我们期望的正确价值观是什么是具有挑战性的。我们的传感器中最容易预测的是 Python 版本,因为只有少数几个可能的值,但即使这样,也不可能预先知道使用的是哪个版本的 Python。

例如,下面的测试使用 click 中的CliRunner辅助工具来模拟运行命令行工具并捕获输出:

def test_python_version_is_first_two_lines_of_cli_output ():
    runner = CliRunner()
    result = runner.invoke(sensors.show_sensors)
    assert ["Python Version", "3.8"] == result.stdout.split("\n")[:2]

这看起来很好,直到有人第一次在 Python 3.7 上运行它并看到失败:

__________ test_python_version_is_first_two_lines_of_cli_output ___________

    def test_python_version_is_first_two_lines_of_cli_output():
        runner = CliRunner()
        result = runner.invoke(sensors.show_sensors)
>       assert ["Python Version", "3.8"] == result.stdout.split("\n")[:2]
E       AssertionError: assert ['Python Version', '3.8'] == ['Python Version', '3.7']
E         At index 1 diff: '3.8' != '3.7'
E         Use -v to get the full diff

tests\test_sensors.py:11: AssertionError

对于许多人来说,此时自然要做的事情是更改测试,以检测系统正在运行的 Python 版本,并使用它来确定预期的结果,类似于

def test_python_version_is_first_two_lines_of_cli_output():
    runner = CliRunner()
    result = runner.invoke(sensors.show_sensors)
    python_version = "{}.{}".format(sys.version_info.major, sys.version_info.minor)
    assert ["Python Version", python_version] == (result.stdout.split("\n")[:2])

它可以在任何 Python 版本上成功运行。这是一个完全合理的改变,但是重要的是要意识到你不再测试同样的东西。记住PythonVersion传感器的实现是

class PythonVersion:
    def value(self):
        return sys.version_info

    @classmethod
    def format(cls, value):
        return "{0.major}.{0.minor}".format(value)

因此,如果我们去掉传感器脚本中涉及的所有间接函数调用,我们的测试将有效地测试以下内容:

assert "{}.{}".format(sys.version_info.major, sys.version_info.minor) == "{0.major}.{0.minor}".format(sys.version_info)

编写一个测试,其中断言的结果是计算出来的,而不是事先知道的,这通常会导致一个同义反复的测试。它可能不那么明显,但在所有情况下它都是次优的。这不是错了,测试仍然在检查报头、传感器的顺序,并且显示的值是基于sys.version_info的,但是它看起来像是在测试版本检测,而不像是在检查传感器顺序。

这个测试现在只是测试 Python 版本“sensor”在列表中排在第一位,并且显示了适当的标题。它不再测试 Python 版本传感器的任何行为。

为了确保传感器正常工作,我们将测试分成更小的单元。我们想知道的关于PythonVersion传感器的事情有

  1. 传感器的值等于sys.version_info

  2. 传感器的格式化程序返回类似“3.8”的版本字符串,即 major.minor。

  3. 传感器的字符串表示是当前值的格式化版本。

  4. CLI 输出包含标题“Python version ”,然后是格式化输出前两行值的结果。这是我们开始的测试。

这些都应该是独立的测试,因为它们都是潜在的故障模式。如果我们只有检查脚本输出的功能测试,并且发现了一个错误,那么如果不调试失败的测试并了解工具的整个上下文,就不可能知道值、格式化程序或脚本集成是否不正确。

对于其中的一些测试,我们可以完全隔离地调用函数,并查看它们的输入和输出。例如,格式化程序接受一个输入并返回一个没有副作用的输出。 10 这种测试被称为单元测试,因为我们一次测试的是源代码的单个逻辑单元

单元测试是最难在复杂代码上编写的一种测试。如果代码的结构不利于测试,就不可能写出有用的单元测试。如果我们回想一下上一章末尾的脚本版本,逻辑单元并不像它们在基于类的实现中那样定义良好。

我们编写的每个函数都涉及到调用一些其他函数来获取它们的数据,格式化逻辑与命令行处理逻辑紧密结合在一起。单元测试也是一些最有用的测试类型,因为一个失败的单元测试可以非常准确地将损坏的代码缩小到一个位置。单元测试通常执行得非常快,并且需要最少的测试设置,这使得开发人员的体验更加令人满意。

其他函数,比如__str__()方法,更加复杂,需要调用其他函数来查找结果。查找字符串值包括获取值,该值委托给库方法,然后格式化该值。这种类型的函数需要一些设置来有效地测试,因为我们需要编写测试来覆盖代码调用的库函数的行为,以便它们返回已知的值。这些类型的测试最好被描述为集成测试,但是准确的含义很难定义。集成测试通常将少量相关的功能作为一个整体进行测试,但是不同的开发人员认为集成测试有一定的灵活性。

集成测试是介于单元测试和功能测试之间的一个很好的媒介。通过编写覆盖一组相关功能的测试,他们确保代码库的逻辑组件在给定的输入和输出上正确工作。使用集成测试很难真正检查边缘情况,但是对于正确处理已知好的或已知坏的数据的测试来说,这是一个很好的选择。

前面提到的四种计划的测试类型大致属于这三类测试。这些测试中的第一个测试是非常简单的函数行为是否正确。对于更复杂的传感器,这些可能更像集成测试,但区别是为了帮助我们对测试进行推理,这不是我们应该关心的事情。

第三个测试是集成测试的一个例子。字符串表示函数调用上一步中测试的两个函数,并确保它们一起正确工作。这些测试应该相互补充;对于一个集成测试来说,顺便测试多个东西是正常的,其中一些可能与已经编写的显式单元测试重叠。

最后,我们进行了功能测试,以确保在 CLI 程序的输出中使用了传感器。像集成测试一样,这不可避免地测试了在其他地方更适合测试的东西;你不应该试图把这件事最小化。重要的是,从功能测试的名称和注释中可以清楚地看出它要测试什么。通常,功能测试是故意广泛的,而不解释它们的逻辑,当它们由于后来的改变而失败时,这是适得其反的。如果不清楚测试在做什么,那么当它开始失败时,就不清楚错误是从哪里引入的。许多不同的问题都可能导致功能测试失败,其中一些乍看起来可能并不相关。

Tip

当集成或功能测试由于部分代码库的更改而失败时,编写一个更具体的测试来涵盖这种情况是一个好主意。也就是说,如果功能测试失败,尝试添加一个单元或集成测试来隔离问题。一个测试证明一个已被修复的 bug 比一张过时的 JIRA 票更有用,尤其是当那个 bug 被重新引入的时候。

Pytest 夹具

对于除了最基本的功能之外的所有功能,很可能会有一些不同的情况被测试,所有的情况都应该有它们自己的测试功能。很常见的情况是需要设置代码,例如,如果函数是类成员而不是可导入的函数,则需要实例化类。一种方法是将测试组织成包含所有相关测试和所有这些测试共享的设置代码的类。

所有的测试框架都有一些方法来提供通用的安装和拆卸代码来支持测试。在 pytest 中,这些被称为“fixtures ”,允许在不同的支持代码之间进行非常灵活的选择。Pytest fixtures 被自动调用来匹配测试函数的参数。

组织测试的一个好方法是定义一个类来包含相关的测试和任何特定于这些测试的固定装置,并保留更多的通用固定装置供其他测试使用。这允许使用一种通常被称为“被测试主题”的风格,或 SUT。你可能会看到 FUT(被测函数)、MUT(被测方法)、OUT(被测对象)等等。

在这个测试布局中,每个类都有一个类似于MUT()method()subject()的 fixture,它返回要测试的函数。11FUT 的 fixture 可能只是导入函数并返回它,而 MUT 作为一个类方法,可能涉及创建一个类的实例并从该实例返回特定的方法。这使得单个函数可以测试一个可调用函数,而不必担心该可调用函数是如何获得的,这在测试采用许多参数进行构造的类的方法时特别有用。

首先,我们将创建一个测试类,用于测试 Python 版本号传感器的格式化程序,并为其提供一系列要测试的值。这包括一个版本传感器的测试文件(列表 2-8 ),它提供了一个代表被测传感器的sensor fixture 和一个 TestPythonVersionFormatter 类,该类使用 subject fixture 将 MUT 定义为该传感器的格式方法。

from collections import namedtuple

import pytest

from sensors import PythonVersion

@pytest.fixture
def version():
    return namedtuple(
        "sys_versioninfo", ("major", "minor", "micro", "releaselevel", "serial")
    )

@pytest.fixture
def sensor():
    return PythonVersion()

class TestPythonVersionFormatter:
    @pytest.fixture
    def subject(self, sensor):
        return sensor.format

    def test_format_py38(self, subject, version):
        py38 = version(3, 8, 0, "final", 0)
        assert subject(py38) == "3.8"

    def test_format_large_version(self, subject, version):
        large = version(255, 128, 0, "final", 0)
        assert subject(large) == "255.128"

    def test_alpha_of_minor_is_marked(self, subject, version):
        py39 = version(3, 9, 0, "alpha", 1)
        assert subject(py39) == "3.9.0a1"

    def test_alpha_of_micro_is_unmarked(self, subject, version):
        py39 = version(3, 9, 1, "alpha", 1)
        assert subject(py39) == "3.9"

Listing 2-8Initial version of test_pythonversion.py

version fixture 提供了一个看起来类似于sys.version_info结果的结构,因为 Python 在内部使用的特定对象类型不能用新值实例化。这确保了我们可以创造与sys.version_info行为相同的价值观,但我们可以控制他们的价值观。

这些测试可以用pipenv run pytest tests来运行,并且它们通过了,但是任何使用过其他单元测试框架的读者可能会担心我们在夹具上移动了太多,并且可能很难调试问题。具体来说,看一眼代码就不清楚subject指的是什么。为了证明这没有问题,我们将添加一个新的失败测试来覆盖我们想要添加的特性。

我们的格式化程序在这里只显示了发行版的主要和次要组件,假设微版本不包含任何显著的变化来突出显示。然而,在我写这篇文章的时候,有一个新的 Python 版本处于 alpha 阶段,alpha 版本之间的差异在新特性的增加方面非常显著。为此,在新的次要行中的第一个微发布的特殊情况预发布版本可能是有用的。我将添加一对新的测试来证明我们期望 3.9.0a1 有不同的输出(但是要回到 3.9.1a1 的缺省值)。

    def test_prerelease_of_minor_is_marked(self, subject, version):
        py39 = version(3, 9, 0, "alpha", 1)
        assert subject(py39) == "3.9.0a1"

    def test_prerelease_of_micro_is_unmarked(self, subject):
        py39 = (3, 9, 1, "alpha", 1)
        assert subject(py39) == "3.9"

这两项测试中的一项失败,另一项通过。这里添加两个测试的原因是为了清楚地表明,alpha 标记只对 micro 版本为 0 的情况有影响。如果没有第二个测试,如果所有预发布版本都显示完整的版本字符串,我们就会有一个通过测试的套件,这不是我们要找的特性。

如果我们现在重新运行测试,我们将会看到test_prerelease_of_minor_is_marked测试的失败以及 pytest 自动包含的大量上下文信息:

________ TestPythonVersionFormatter.test_alpha_of_minor_is_marked ________

self = <tests.test_pythonversion.TestPythonVersionFormatter object at 0x03BA4670>
subject = <bound method PythonVersion.format of <class 'sensors.PythonVersion'>>
version = <class 'tests.test_pythonversion.sys_versioninfo'>

    def test_alpha_of_minor_is_marked(self, subject, version):
        py39 = version(3, 9, 0, "alpha", 1)
>       assert subject(py39) == "3.9.0a1"
E       AssertionError: assert '3.9' == '3.9.0a1'
E         - 3.9
E         + 3.9.0a1

tests\test_pythonversion.py:28: AssertionError
=============== 1 failed, 3 passed in 0.11 seconds ========================

报告的第一件事是失败的测试的名称,随后是正在使用的夹具的表示。这些都显示在失败信息的顶部,所以我们一眼就能看出subject fixture 是PythonVersion类的一个实例 12format方法。

接下来要显示的是测试方法的主体,直到导致错误的那一行,然后是格式化错误。在这种情况下,这是一个断言错误,因为断言行失败了。我们看到了扩展版本的断言,所以我们可以看到subject(py39)的计算结果,然后在下面我们看到了两个字符串的差异。在这种情况下,diff 不是特别有用,但是对于较长的字符串,逐行进行 diff 是很方便的。

如果我们要将格式化程序方法改为

    @classmethod
    def format(cls, value):
        if value.micro == 0 and value.releaselevel == "alpha":
            return "{0.major}.{0.minor}.{0.micro}a{0.serial}".format(value)
        return "{0.major}.{0.minor}".format(value)

并重新运行测试,我们将会看到一个确认,确认test_pythonversion.py中的所有测试都已通过。

分类测试功能

我们已经决定为我们的代码编写多种不同类型的测试,涵盖从单元测试到全栈功能测试的所有内容。由于功能测试比单元测试慢得多,我们可能希望不时地将它们排除在测试运行之外,只快速运行测试的快速子集。如果我们期望看到测试失败,这可以节省大量的时间,所以我们可以避免运行更长的验证测试,直到我们知道在快速的单元测试中没有失败。

这可以使用@pytest.mark装饰器来完成。我们将使用“功能”标记将我们的test_python_version_is_first_two_lines_of_cli_output标记为功能测试。

@pytest.mark.functional
def test_python_version_is_first_two_lines_of_cli_output():
    runner = CliRunner()
    result = runner.invoke(sensors.show_sensors)
    python_version = str(sensors.PythonVersion())
    assert ["Python Version", python_version] == result.stdout.split("\n")[:2]

这允许我们用pytest -m functional调用测试,只运行功能测试

============ 1 passed, 5 deselected, 1 warnings in 3.17 seconds ============

或者使用pytest -m "not functional"运行除功能测试之外的所有测试:

============ 5 passed, 1 deselected, 1 warnings in 0.11 seconds ============

运行功能测试的开销是巨大的,运行一个功能测试的时间是运行五个单元测试的 30 倍。3 秒钟的测试运行并没有慢到让你不敢运行测试,但是我们才刚刚开始编写测试套件。当它大 10 倍时,它将是 30 秒的测试和 1 秒之间的差异。如果你的测试对你来说太麻烦而不想运行,那么它们就不会那么有用。

仅仅通过使用@ pytest.mark.something作为修饰符就可以创建任意的标记,但是会生成一个警告,告诉您它还没有被显式声明。这些警告对于发现标记名中的印刷错误很有用,所以我们应该创建一个pytest.ini文件,声明我们使用了一个functional标记。

[pytest]
markers = functional: these tests are significantly slower

新闻报道

代码覆盖率是衡量测试套件范围的一个标准。它表示在测试运行期间执行的应用基本代码的比例。有些人强烈认为高水平的测试覆盖是必要的,甚至经常说所有软件都应该 100%覆盖。

我鼓励你采取更务实的观点。一个测试套件能做的最重要的事情是给你信心,让你相信软件正在按预期运行。高覆盖率通常与自信相关,我鼓励你以高覆盖率为目标,但这不应该给你一种错误的安全感。特别是,当您接近 100%的测试覆盖率时,确保覆盖最后几行变得越来越困难,但是好处是不变的。覆盖面较低、易于理解的测试套件比过于复杂、达到 100%的测试套件要好。

为了实现代码覆盖率,我们需要一个 pytest 插件来收集数据。最简单的方法是使用pipenv install --dev pytest-cov安装 pytest-cov 插件。一旦完成,pytest 可执行文件中的--cov参数就可用了。此参数将部分代码库的路径作为可选参数。如果提供了这个,那么覆盖率报告只显示这个子路径的覆盖率数据。要查看所有代码的覆盖范围,请使用 just - cov,如下所示:

> pipenv run pytest tests --cov

我们还应该创建一个.coveragerc文件来配置我们想要看到的覆盖报告。最重要的事情是排除测试目录,因为在运行测试时被执行的测试文件的比例不是一个有用的度量,并且扭曲了我们的平均值。

[run]
branch = True
omit = tests/*

我们还添加了分支配置参数,该参数更改了覆盖率的计算,以便在同时满足TrueFalse条件时,只考虑覆盖 if 语句。如果我们运行带有--cov标志的测试,我们可以看到我们的项目到目前为止的覆盖率:

----------- coverage: platform win32, python 3.8.0-alpha-1 -----------
Name         Stmts   Miss Branch BrPart  Cover
----------------------------------------------
sensors.py     121     17     22      7    83%

========================= 8 passed in 3.23 seconds =========================

这表明,我们的测试运行检测到 83%的代码已经被测试套件覆盖,这恰恰证明了为什么我们应该怀疑覆盖率数字作为测试质量的度量。请记住,我们只为脚本中的七个传感器中的一个编写了测试,所以 83%的代码都以任何有意义的方式进行了测试的说法显然是错误的。这是由运行脚本并查看输出的功能测试导致的,因为它会导致所有代码被执行。如果我们重新运行测试,排除功能测试,我们得到

----------- coverage: platform win32, python 3.8.0-alpha-1 -----------
Name         Stmts   Miss Branch BrPart  Cover
----------------------------------------------
sensors.py     121     62     22      1    43%

================== 7 passed, 1 deselected in 0.38 seconds ==================

考虑到我们所写的测试数量,43%似乎还是一个过高的估计,但是覆盖率选项可以让我们看到哪些行被覆盖了,哪些行被遗漏了。有几种不同的显示方式,但它们都由--cov-report标志控制。包括了许多机器可读的格式,比如 XML 格式,如果您使用持续集成,这是很有用的,但是对于直接的人类消费,两个最有用的是--cov-report html--cov-report annotate

HTML 报告格式创建了一个名为htmlcov的目录,其中包含一个 index.html 文件,该文件列出了总体覆盖率和每个文件的覆盖率。通过点击您感兴趣的文件名,您会看到一个文件内容列表,在覆盖率报告中,这些文件内容的线条用它们的状态进行了颜色编码,如图 2-1 所示。 13

img/481001_1_En_2_Fig1_HTML.jpg

图 2-1

不运行功能测试时,覆盖线和未覆盖线的可视化表示

带有绿色边框且没有阴影的线条是被覆盖的线条。测试套件已经执行了这些行。带有红色边框和红色阴影的线是未覆盖的线。这些没有被执行。假设启用了分支覆盖,一些线可能会有黄色边框,并带有黄色阴影。这些是被部分覆盖的行,例如文件底部的if __name__== "__main__"构造。因为 if 语句的主体是红色的,所以很明显包含了条件评估为False的情况,但是没有包含条件评估为True的情况。

或者,annotate报告类型在与 sensors.py 相同的目录下创建一个sensors.py,cover文件。前缀为>的行被覆盖或部分覆盖;前缀为!的线未被覆盖。与前面的 HTML 截图匹配的sensors.py,cover部分如清单 2-9 所示。

> class PythonVersion:
>     title = "Python Version"

>     def value(self):
>         return sys.version_info

>     @classmethod
>     def format(cls, value):
>         if value.micro == 0 and value.releaselevel == "alpha":
>             return "{0.major}.{0.minor}.{0.micro}a{0.serial}".format(value)
>         return "{0.major}.{0.minor}".format(value)

>     def __str__(self):
>         return self.format(self.value())

> class IPAddresses:
>     title = "IP Addresses"

>     def value(self):
!         hostname = socket.gethostname()
!         addresses = socket.getaddrinfo(socket.gethostname(), None)

!         address_info = []
!         for address in addresses:
!             value = (address[0].name, address[4][0])
!             if value not in address_info:
!                 address_info.append(value)
!         return address_info

Listing 2-9sensors.py,cover representing coverage when not running functional tests

我发现 HTML 报告更容易使用,但您的偏好可能会有所不同。无论哪种方式,我们都可以看到除了PythonVersion之外的各种传感器的函数体都没有覆盖,但是类和函数定义都覆盖了。这是有意义的,因为 Python 解释器必须执行声明行来知道哪些函数、类和类属性是可用的。由于我们的函数体相对较短,测试过的函数体加上类和函数声明几乎占了语句行的一半。

Exercise 2-2: Expanding The Test Suite

我们已经为一个最简单的传感器编写了测试,但是还有其他几个传感器没有经过测试。通过向其他传感器添加测试来练习编写一些测试。

大多数传感器都遵循相同的模式,只有温度和湿度传感器例外,这种传感器要编写覆盖 value 方法的测试有些困难。

如果您可以编写一个测试套件,在使用-m "not functional"运行时覆盖 75%的 sensors.py,那么您就有了一个测试套件,它应该会让您对整个程序充满信心。

类型检查

我们在测试套件上所做的工作给了我们很大的信心,让我们相信我们所写的代码会如我们所愿的那样运行,但它对我们正确使用它的信心没有太大的帮助。我们在许多传感器中大量使用了 psutil 库,但没有为此编写任何直接测试。一些程序员陷入了编写测试的陷阱,这些测试更多的是测试他们的依赖库,而不是他们自己的代码。

如果您觉得自己需要编写测试来涵盖您的代码所依赖的库的工作方式,那么您应该后退一步,考虑最佳的行动方案是什么。作为测试套件的一部分,为库编写测试要比在消费者应用的测试中容易得多。

当人们使用第三方库时,他们通常需要的是正确使用它的信心:一致地传递参数,处理异常和不寻常的返回值,以及理解函数打算做什么。没有自动的方法来检查我们的理解,但是类型检查确实允许我们检查一些其他的情况。

如果您使用过像 Java 这样的编程语言,您就会熟悉彻底的类型检查器对代码的影响:不可能忽略异常或调用带有无效值的函数,但对其他人来说,这可能会有很大的限制。

Python 最近获得了可选地用类型注释变量的语法,以允许在基本 Python 语言之上构建类型检查。Python 本身并不为您做任何类型检查,但是 mypy 项目提供了一个对您的 Python 代码运行静态类型检查的程序。

安装 mypy

Mypy 是作为 Python 模块分发的,因此它的安装方式与我们的其他开发依赖项一样,使用

>  pipenv install --dev mypy

这将 mypy 可执行文件添加到我们的环境中,并安装 mypy 类型检查库和类型定义的 typeshed 集合。Python 标准库不包含类型检查提示,在撰写本文时,大多数第三方库也不包含。类型注释本来就是一个可选的特性,所以许多开发人员选择不使用它们也就不足为奇了。Typeshed 是 Python 软件基金会的一个项目,为标准库和各种常用的第三方库维护一组类型声明。

也就是说,许多库既不提供类型注释,也没有类型化的条目,所以当我们对使用它们的代码运行类型检查时,它会生成类型警告。如果我们在代码中调用 mypy,我们会看到关于psutil的错误,以及可选的依赖项adafruit_dhtboard

> pipenv run mypy sensors.py
sensors.py:9: error: No library stub file for module 'psutil'
sensors.py:9: note: (Stub files are from https://github.com/python/typeshed)
sensors.py:116: error: Cannot find module named 'adafruit_dht'
sensors.py:116: note: See https://mypy.readthedocs.io/en/latest/running_mypy.html#missing-imports
sensors.py:117: error: Cannot find module named 'board'

解决这个问题有两种方法:忽略它和修复它。几乎在所有情况下,将 mypy 配置为忽略这些问题比向代码使用的所有依赖项添加类型提示更能有效地利用时间。为此,我们需要添加一个 mypy 配置文件,或者作为mypy.ini或者作为setup.cfg文件的一部分,它可以包含多个不同工具的配置。添加以下内容作为setup.cfg,并重新运行 mypy,查看它是否完整,没有任何警告:

[mypy]
ignore_missing_imports = True

添加类型提示

由于我们的代码目前相对简单,所以检查传感器和添加类型提示并不困难。Python 使用的格式是

def function_name(argument: type, other: type) -> type:

所以,我们的CPULoad传感器看起来会像

class CPULoad:
    title = "CPU Usage"

    def value(self) -> float:
        return psutil.cpu_percent(interval=3) / 100.0

    @classmethod
    def format(cls, value: float) -> str:
        return "{:.1%}".format(value)

    def __str__(self) -> str:
        return self.format(self.value())

value函数的返回值总是与format函数的value参数相同。一旦添加了这个,我们就可以直接用 mypy 做实验了。例如,我们可以创建一个误用传感器的新文件,如清单 2-10 所示。

import sensors

sensor = sensors.CPULoad()
print("The CPU load is " + sensor.value())

Listing 2-10incorrect.py

Mypy 可以通过分析包含错误代码的文件和 sensors.py 文件来发现此错误,从而导致以下错误:

> pipenv run mypy incorrect.py
incorrect.py:4: error: Unsupported operand types for + ("str" and "float")

然而,有些传感器更复杂。ACStatusTemperatureRelativeHumidity传感器都有一个值,如果由于某种原因无法确定该值,则该值可以是None。对于这些,我们需要声明不同的返回类型。Python 的类型允许将类型包装到容器中,与其他语言中的泛型类似。typing.Union类型定义了一个类型,它是许多不同选项中的一个。在我们的例子中,ACStatus.value返回typing.Union[bool, None],温度传感器返回typing.Union[float, None]

我们可以通过使用Optional类型来进一步简化。OptionalUnion的特例,它接受一个类型参数,而Union不接受任何类型参数。它的行为并没有什么不同;只是更容易阅读。因此,我们的ACStatus.value()功能就变成了

    def value(self) -> typing.Optional[bool]:
        battery = psutil.sensors_battery()
        if battery is not None:
            return battery.power_plugged
        else:
            return None

最后,IPAddresses传感器的值是一个更复杂的对象。每个 IP 地址由一个二元元组表示,包含地址族和地址本身的字符串表示。传感器返回这些二元组的列表。我们可以将此声明为

    def value(self) -> typing.List:
        ...

但是如果我们这样做了,返回值[None, None, None]将被认为是有效的。我们可以声明更多的列表内部结构,以确保 mypy 能够严格执行检查。声明List内部的语法与Union相同。对于(str, str)二元组的列表,我们将使用

    def value(self) -> typing.List[typing.Tuple[str, str]]:
        ...

这并不能防止数据结构与预期相匹配的任何错误,因为我们仍然不能自动检查语义,但它确实防止了几类打字错误和疏忽。例如,我们无法防止在元组中混合两个值,但是我们无法假设元组是直接返回的,或者返回类型是包含 IP 地址的字符串列表,而没有地址族信息。

对于这个传感器,我们可能希望在格式上放松返回值的返回类型和参数类型之间的对称性。在所有其他传感器中,这些都是完全相同的,因为我们只想能够格式化我们收到的数据。在某些情况下,在格式化程序中更加灵活可能会有用。格式化程序的类型定义应该表示可能被格式化的数据,而不是我们期望的数据。我们可以格式化任何包含至少两个元素的可索引序列的 iterable,这两个元素都是字符串。如果我们传入一个列表元组,我们的格式化程序代码就像传入一个元组列表一样有效。

以下类型都是有效的选择:

  • List[Tuple[str, str]]

  • List[Sequence[str]]

  • Sequence[Tuple[str, str]]

  • Sequence[Sequence[str]]

  • Iterable[Tuple[str, str]]

  • Iterable[Sequence[str]]

语义略有不同。使用Sequence而不是List允许外部变量类型是列表或元组,使用Iterable作为外部类型允许它是列表、元组、集合或生成器。如果我们对内部类型使用Sequence[str]而不是Tuple[str, str],我们获得了内部类型成为列表的灵活性,但是我们失去了关于序列内部结构的断言。其中,我认为最好的选择是

def format(cls, value: Iterable[Tuple[str, str]]) -> str:

因为它是不允许无效数据的限制性最小的类型提示。

Tip

您可能希望先使用import typing as t,然后使用t.Union[...]t.Sequence[...]等等,而不是单独导入所有这些标记类型。这使得这些类型是类型提示的一部分这一事实对于偶尔阅读源代码的人来说更加清楚,并且避免了在添加具有新类型签名的函数时必须管理导入。

子类和继承

对于不习惯在编写由 mypy 检查的代码时使用类型提示的 Python 开发人员来说,最令人困惑的事情可能是,与他们习惯的相比,使用了更严格的类型继承视图。到目前为止,在我们的传感器文件中,我们有许多共享相同的__str__()方法实现的类。想要将这些移动到一个超类是很自然的。您可能很自然地认为这对类型提示有很大的好处,因为它允许编写显式操作Sensor的子类的代码。

这样做的问题是我们没有一个用于Sensor的公共接口。我们有几个行为相似的子类,但是它们不能互换。如果你知道你有一个 Sensor 的实例,那么你知道你有一个 value 函数,但是你没有更具体的保证关于这个函数的输出是什么。

如果我们要向超类添加一个__str__()方法,那么这个方法需要在超类本身上进行类型检查。如果value()format(...)方法缺失,那么类型检查将会失败,不管这些方法是否在子类上实现。类型检查肯定会失败,因为基类本身不会孤立地工作。同样,如果我们在超类上定义了 stub value()format(...)方法,那么这些定义将用于确定__str__()方法是否正确,而不是单个子类上的定义。

这就是静态类型化和动态类型化区别的症结所在。在动态类型的语言中,你可以相信事情发生为真,而在静态类型的环境中,你的断言必须必然为真。

想象一下我们在这里定义的超类。我们编写的非类型化 Python 的基本代码是

class Sensor:
    def __str__(self):
        return self.format(self.value())

__str__(self) -> str在一个类型化的上下文中写这个,会导致函数被类型检查,从而产生错误"Sensor" has no attribute "format"。所以,我们需要添加占位符format(...)value()方法。问题是,value 方法应该返回什么类型?我们有返回floatOptional[bool]Optional[float]List[Tuple[str, str]]的传感器。存根方法不能使用这些返回类型中的任何一种,因为它们与其他选项不兼容。如果我们使用特殊的typing.Any类型,它有效地禁用了这个方法的类型检查。如果我们对value()方法使用极其冗长的Union[float, Optional[bool], Optional[float], List[Tuple[str, str]]],那么我们说所有这些类型作为任何给定传感器的输出都是同样有效的。

如果我们试图使用相同的Union作为format(...)方法的参数类型,那么我们会遇到一个更微妙的错误。所有的子类都受到它们的超类的类型限制,但是这以不同的方式表现出来。当指定一个函数的输出时,子类必须返回一个和超类定义一样具体或者比超类定义更具体的值。因此

class Sensor:
    ...
    def value(self) -> Union[float, Optional[bool], Optional[float], List[Tuple[str, str]]]:
        raise NotImplementedError

class ToySensor(Sensor):
    ...
    def value(self) -> Optional[bool]:
        return True

是完全有效的,因为任何期望一个Sensor并被给予一个ToySensor的消费者总是会找到一个值方法,当它期望几个可能的值之一时,返回Optional[bool],包括Optional[bool]

这与处理函数参数的方式相反。在format(...)函数的情况下,超类的类型定义向该类的用户保证传递的任何值类型都是可接受的;子类不能对此进行限制,因为这意味着调用代码必须明确知道正在使用哪个传感器。因此,下面的代码会失败:

class Sensor:
    ...
    def format(self, value:Union[float, Optional[bool], Optional[float], List[Tuple[str, str]]]) -> str:
        raise NotImplementedError

class ToySensor(Sensor):
    ...
    def format(self, value: Optional[bool]) -> str:
        return "Yes"

有错误

Argument 1 of "format" incompatible with supertype "Sensor".

这里有两种方法可以采用,选择哪一种将在很大程度上取决于您从类型检查中获得多少好处。最简单的方法是让一些函数保持非类型化,无论是隐式的还是显式的。保持这种非类型化将意味着我们在处理一般的传感器时,只有在处理特定的单个传感器时,才能获得类型检查的显著好处。对于许多应用来说,这可能就足够了,而且肯定会更简单。为此,我们将创建一个传感器超类,如下所示:

class Sensor:

    def value(self) -> Any:
        raise NotImplementedError

    @classmethod
    def format(cls, value: Any) -> str:
        raise NotImplementedError

    def __str__(self) -> str:
        return self.format(self.value())

我们未来的所有代码都将限制类型检查,因为__str__()format(...)方法总是返回字符串。不会对值类型进行任何检查。

泛型类型

另一种方法是全力进行类型检查。我们已经看到,typing.List类型可以接受参数来指定列表的内容。同样,我们可以告诉类型系统,Sensor基类接受一个类型参数,该参数表示传感器操作的类型。

增加指定包含类型的能力被称为使类型通用化。我们需要将 Sensor 转换为具有单一类型变量的泛型类型,该类型变量既用作值函数的返回类型,也用作超类的参数类型。

T_value = TypeVar("T_value")

class Sensor(Generic[T_value]):

    def value(self) -> T_value:
        raise NotImplementedError

    @classmethod
    def format(cls, value: T_value) -> str:
        raise NotImplementedError

    def __str__(self) -> str:
        return self.format(self.value())

在这里,T_value不是一个类型;它是一个占位符,代表使用方括号语法在Sensor上指定的值的类型。如果有一个类型为Sensor[str]的变量,那么 mypy 将该变量的T_valuestr相关联,因此value()format(...)方法都与str相关联。重要的是,与T_value相关的类型因传感器而异;它不局限于单个类型,而是与任何特定代码段声明的Sensor的子类型动态关联。

传感器本身使用Sensor[type]作为它们的基类,但是仍然需要在函数上声明它们自己的类型提示。当 mypy 分析父类的类型提示时,它要求所有子类都必须定义类型提示才能参与类型检查。这看起来像是浪费时间,但这让任何阅读代码的人都清楚地知道所需的类型,而不必阅读超类。它还允许检查代码在子类中的内部一致性,以及它与超类的断言的一致性。结果是真实的传感器实现看起来像清单 2-11 。

class CPULoad(Sensor[float]):
    title = "CPU Usage"

    def value(self) -> float:
        return psutil.cpu_percent(interval=3) / 100.0

    @classmethod
    def format(cls, value: float) -> str:
        return "{:.1%}".format(value)

Listing 2-11Typed version of sensors

Caution

在前面的CPULoad传感器示例中,我们有value(self) -> float,但是我们可以将其更改为value(self) -> int甚至value(self) -> bool,而不会看到任何错误。这是一个支持更简单的 duck 类型的不幸的设计决定。论点是,任何接受浮点数的函数都可以接受整数,虽然不完全正确,但对于大多数目的来说已经足够接近了。此外,在 Python 中boolint的子类,所以接受浮点数的函数也可以接受布尔值而不会引发错误。因此,一个预期返回一个float但返回一个bool的函数被视为返回兼容的东西。我希望这可能会在未来产生警告。现在,你应该记住这个限制。

T_value绑定到指定的子类型这一事实的一个令人惊讶的结果就是Sensor[Any]的含义。这似乎意味着任何有效的Sensor,但实际上它意味着一个其值未经类型检查的Sensor。使用Sensor[Any]仍然比完全不使用类型检查有好处。虽然类型检查器无法检查在处理 value 参数时运行在循环Iterable[Sensor[Any]]上的代码的类型安全性,但是关于存在title属性和公共__str__()方法的断言在所有传感器类型中都是通用的,因此仍然可以检查。

调试和过度使用打字

使用 mypy 时,查看调试信息有时会很有用。Mypy 没有交互式调试器,所以如果我们在理解错误发生的原因方面有问题,我们必须通过 reveal_type 函数求助于 printf 风格的调试。

例如,让我们创建一个测试脚本,它以不正确的方式使用 sensors.py 中的一些代码:

from sensors import CPULoad

sensor = CPULoad()
print(sensor.format("3.2"))

如果我们调用 pipenv run mypy broken.py,我们将得到以下预期的错误:

broken.py:4: error: Argument 1 to "format" of "CPULoad" has incompatible type "str"; expected "float"

但是如果我们把 broken.py 更新得更复杂一点

from sensors import CPULoad, ACStatus

two_sensors = [CPULoad(), ACStatus()]
print(two_sensors[0].format("3.2"))

然后重新运行 mypy,我们看到的错误更基本:

broken.py:4: error: "object" has no attribute "format"

在这种情况下,mypy 似乎推断出了two_sensors列表的错误类型。我们可以将reveal_type(two_sensors)添加到定义好的源文件中,看看 mypy 发现了什么。请注意,reveal_type不是一个真正的函数。它不需要导入,因为它是 mypy 解析器的构造,而不是 Python 代码。如果您将它留在代码中,那么当代码运行时,它将导致错误。仅在运行 mypy 时将其添加为临时调试辅助工具。添加了reveal_type(two_sensors)后,我们会在 mypy 输出中看到以下附加行:

broken.py:4: error: Revealed type is 'builtins.list[builtins.object*]'

显示 mypy 已经将变量解释为对象列表,而不是传感器列表。如果我们从键入模块导入适当的名称,并向two_sensors行添加一个显式类型,比如

two_sensors: List[Sensor[Any]] = [CPULoad(), ACStatus()]

那么 mypy 的输出变成了

broken.py:6: error: Revealed type is 'builtins.list[sensors.Sensor[Any]]'

如前所述,typing.Any的使用是鱼龙混杂。这个定义意味着从这个列表中检索到的任何传感器都属于类型Sensor[Any],因此two_sensors[0].format("3.2")将不再被 mypy 检测为错误。

在当前的例子中,我们有两个传感器,一个返回一个浮点数,另一个返回一个Optional[bool],所以我们可以将列表声明为

two_sensors: List[Union[Sensor[float], Sensor[Optional[bool]]]] = [CPULoad(), ACStatus()]

这意味着two_sensors将只包含那些类型的传感器,但这仍然不是特别有用。我们现在得到一对错误线:

broken.py:7: error: Argument 1 to "format" of "Sensor" has incompatible type "str"; expected "float"
broken.py:7: error: Argument 1 to "format" of "Sensor" has incompatible type "str"; expected "Optional[bool]"

显示 mypy 确实已经确定调用不正确,但是基于信息,它无法知道floatOptional[bool]是否是正确的选择。我们可以通过返回的reveal_type(two_sensors[0].format)看到更多关于它所抱怨的格式方法的信息

broken.py:6: error: Revealed type is 'Union[def (value: builtins.float*) -> builtins.str, def (value: Union[builtins.bool, None]) -> builtins.str]'

也就是说,mypy 知道它是两个函数签名中的一个,一个接受称为 value 的 float,另一个接受称为 bool 或 None 的 value,两者都返回 str。根据输入提示,这两种情况都同样有效。我们无法让 mypy 检测到正确的函数,除非我们将类型声明为

two_sensors: Tuple[Sensor[float], Sensor[Optional[bool]]] = (CPULoad(), ACStatus())

这些都是荒谬的长度去。这说明了如果你过于教条地对待输入,你的代码会变得多么不可维护。在这种情况下,您可以选择接受较低级别的类型检查,或者完全重新构建您的程序,以通过避免混合类型的情况来允许更容易的类型检查。就个人而言,我会选择少检查。

何时使用打字,何时避免打字

一般来说,类型提示在 Python 中是一个非常可选的特性。有些人喜欢静态类型鼓励的更严谨的风格,但是如果这种风格对你来说不自然,那么我不建议仅仅因为它使工具更容易就进行转换。

将类型检查作为一种帮助自己的方式,而不是检测所有可能错误的方式。你必须在你的编码中判断每一个正确输入的好处,权衡增加代码复杂性的坏处。通常有一个明确的中间地带,任何额外的输入都很难正确表示,任何更少的输入都不会显著简化代码。

例如,在这个项目的后期,我们将需要绘制一些传感器的输出随时间变化的图表。返回 float 或 int 的传感器很容易绘制图表,因为它们是一个定量值。返回字符串列表或sys.version_info列表的传感器没有被转换成图表的自然方法。

对于这些,我们可以想象编写使用一系列传感器的代码,这些传感器都使用数字类型(或者可选的数字类型)作为它们的输入。这将允许我们限制从 value 函数返回的预期类型,并确保该函数的其余部分是类型安全的,而无需我们确保在整个代码库中的所有变量中维护每个传感器的确切类型。

更一般地说,并不是所有的项目都从静态类型中获益匪浅。如果一个项目有一组相对简单的返回已知类型的函数,这将是一个真正的好处。一旦你开始需要大量使用Union或定制的泛型类型,支持类型化的论点就变得越来越弱。

在我看来,最重要的考虑是开发软件的人是否想使用静态类型。如果您和您的同事喜欢这种工作方式带来的严格性,那么使用静态类型可能是个好主意。如果你在代码审查和测试上花费了大量的时间和精力,那么增加测试的好处可能会少得多。

如果您正在编写一个供他人使用的库,那么最好至少有外部接口类型提示,因为它允许您的用户使用类型提示,而不会将您的库标记为排除在类型检查过程之外。

在本书中,我们将包含代码的类型提示。因为代码是由一个不反对类型提示的人编写的,所以没有特别的理由去避免它。好处是双重的。首先,如果在代码示例中发现一个小的 bug,书籍很难更新。使用类型提示使得第一次获得正确的代码更加容易。其次,如果你以前使用过这个特性,你会更容易凭直觉判断它是否会在你的项目中有用。当我们在章节中构建这个例子时,你可能会发现自己不同意我选择的类型提示。不要忽视这些想法,知道什么对你来说是自然的,是设计测试套件和静态检查器的成功的一半。

将类型提示与代码分开

在代码中使用类型提示的另一种方法是在 pyi 文件中与代码一起定义它们。这些行为就像。对于熟悉 C 编程的人来说。代码的结构保持不变,但是没有实现。如果从事软件工作的大多数开发人员不使用类型提示(例如,如果它们是为代码的外部消费者设计的),或者如果您的类型结构非常复杂,以至于代码看起来很混乱,那么这可能是有益的。它的部分实现如清单 2-12 所示。

#!/usr/bin/env python
# coding: utf-8
import math
import socket
import sys

import click
import psutil

class Sensor:

    def value(self):
        raise NotImplementedError

    @classmethod
    def format(cls, value):
        raise NotImplementedError

    def __str__(self):
        return self.format(self.value())

class PythonVersion(Sensor):
    title = "Python Version"

    def value(self):
        return sys.version_info

    @classmethod
    def format(cls, value):
        if value.micro == 0 and value.releaselevel == "alpha":
            return "{0.major}.{0.minor}.{0.micro}a{0.serial}".format(value)
        return "{0.major}.{0.minor}".format(value)

Listing 2-12Partial sensors.py file without inline type definitions

为前面的部分文件匹配 sensors.pyi

from typing import Any, Iterable, List, Optional, Tuple, TypeVar, Generic

T_value = TypeVar('T_value')

class Sensor(Generic[T_value]):
    title: str
    def value(self) -> T_value: ...
    @classmethod
    def format(cls: Any, value: T_value) -> str: ...

class PythonVersion(Sensor[Any]):
    title: str = ...
    def value(self) -> Any: ...
    @classmethod
    def format(cls: Any, value: Any) -> str: ...

这些存根文件可以由 mypy 从标准 Python 文件中生成。这些生成的文件在使用之前必须进行编辑,因为它们不包含除typing.Any之外的任何类型声明。这些文件是使用 stubgen 工具生成的,如下所示:

> pipenv run stubgen sensors.py
> cp out/sensors.pyi ./sensors.pyi

在我看来,除非有充分的理由,否则应该避免使用这种格式。这更难维护,因为新的函数需要添加到pyi文件和py文件中,并且在某些情况下使用类型注释会稍微困难一些。比如在组合语法中,Sensor[float]是有效的 Python,但是在这种拆分形式中,Sensor基类没有从Generic继承的__getitem__方法,所以Sensor[float]只在 pyi 文件中有效,在 py 文件中无效。如果我们想在py文件中使用Sensor[float],而不仅仅是在pyi文件中,我们必须使用遗留注释语法来定义类型:

sensor = [CPULoad(), ]  # type: List[Sensor[float]]

Exercise 2-3: Expanding The Typing Coverage

我们已经有了一个传感器的基类,并研究了如何将它应用到一个传感器上。

浏览 sensors.py 文件中的其余传感器,并更新它们以使用带有适当类型提示的传感器基类。

您可能希望尝试对 mypy 使用- strict 命令行标志,以查看默认情况下不会引发的其他警告,例如,因为我们忽略了外部模块。

您必须做出一些选择,特别是关于如何处理来自psutil的非类型化变量和一个难以类型化的特定传感器。

Lint

Lint 是许多不同类型的代码静态分析的通称。在某种程度上,mypy 在上一节中所做的静态分析是一种非常技术性的、计算机科学驱动的 Lint。与类型检查相比,我们将在本节中讨论的 Lint 涉及的内容要少得多,并且更容易引入到现有的项目中。

我选择的 linter 是 flake8,这是对 Python 增强建议(PEP8)的参考,它定义了 Python 代码的风格指南。 14 Flake8 和其他 linters 比这个风格指南走得更远,以产生符合最佳实践和一些备受尊敬的 Python 开发人员的意见的代码。您可能会发现不同的 linter 与您选择的代码编辑器集成得特别好,在这种情况下,我会推荐使用那个。

您会不可避免地发现,一些 linters 执行您认为不重要的检查,或者可能会遗漏一些您认为应该在代码中强制执行的检查。由于这个原因,flake8 是非常可定制的,允许一个软件的作者定义如何检查它的代码。作为一个软件的作者或维护者,您可以随意设置这些值,这样您就可以从 linter 中获得最大的好处。如果您发现自己为他人维护的代码做贡献,那么他们对 flake8 配置的选择可以帮助您在提交补丁之前就知道您是否编写了一些他们可能不喜欢的代码。不得不调整代码来传递过分热心的 linter 可能会令人沮丧,但这比不得不在 pull 请求上一个接一个地检查注释要令人沮丧得多,因为维护人员会注意到它们。

由于 linters 的许多抱怨都是基于格式的,因此有一种趋势是 linters 自己解决格式的一致性问题。Python 社区这个领域的暴走领袖是黑人。 15 Black 自动以一致的方式重新格式化您的代码。使用黑色棉绒比使用其他棉绒有许多优点。最主要的一点是,从情感上来说,接受对代码格式没有控制权比处理大量看似无关紧要的变更要容易得多。使用黑色的一个很大的优势是不用去安抚空白的短绒。

Caution

如果你正在为一个不使用 black 的代码库做贡献,确保你只贡献你想要的改变。git 命令git add --patch是一个很好的工具,可以准确地选择提交哪些变更。如果您提交了一个项目,该项目重新格式化了与您的更改无关的代码,那么提交很可能会被恢复,人们会感到不安。

安装 flake8 和黑色

我们将安装并设置 flake8 和 black 来运行我们的代码。这两者都是开发依赖项,而不是核心依赖项,因此安装时带有--dev标志。 16

> pipenv install --dev flake8 black

修复现有代码

然后,我们可以对我们的代码(或测试)运行 flake8

> pipenv run flake8 sensors.py
> pipenv run flake8 tests

如果您运行其中任何一个,您将看到几个必需的更改。其中许多是空格的改变,但其他的是与代码格式有关的。我们不想手动进行所有这些更改,所以让我们使用 black 来重新格式化我们的代码。 17

> pipenv run black sensors.py tests

现在这些文件已经被重新格式化,我们希望 flake8 只报告不是由格式化引起的错误。然而,我们还需要做几件事情。首先,black 的默认行长度是 88 个字符,但是 flake8 的是 80 个字符。我们需要更新薄片配置,以使用与黑色相同的值。这是通过在现有的 mypy 配置旁边向setup.cfg添加一个[flake8]部分来实现的。

[mypy]
ignore_missing_imports = True

[flake8]
max-line-length = 88

当我们运行pipenv run flake8 sensors.py时,我们仍然会看到一些错误。这是因为我们有过长的注释,并且注释是为人类而不是 Python 解释器准备的,black 没有为我们拆分它们。使 sensors.py 通过 flake8 测试所需的更改是最小的,但是当我们运行测试时,我们看到了几个需要修复的真正错误。 18

> pipenv run flake8 tests
tests\test_acstatus.py:2:1: F401 'socket' imported but unused
tests\test_acstatus.py:41:26: E712 comparison to True should be 'if cond is True:' or 'if cond:'
tests\test_acstatus.py:46:26: E711 comparison to None should be 'if cond is None:'
tests\test_acstatus.py:51:26: E711 comparison to None should be 'if cond is None:'
tests\test_cpuusage.py:2:1: F401 'socket' imported but unused
tests\test_dht.py:2:1: F401 'socket' imported but unused
tests\test_dht.py:57:13: F841 local variable 'f' is assigned to but never used
tests\test_ramusage.py:2:1: F401 'socket' imported but unused
tests\test_sensors.py:1:1: F401 'sys' imported but unused

在这个输出中,我们得到了文件名,后面是行号,再后面是该行中的列号(如果不适用,则为 1)。最后,我们给出了样式错误的 flake8 代码和一个易读的解释。通过将检查添加到setup.cfg中的ignore=行,代码号用于排除正在运行的检查。

这些抱怨中的每一个都很清楚;一行一行地检查并做出建议的更改是一项相对机械的任务。我建议从错误列表的底部开始,向上努力。如果从顶部开始,一路向下,那么行号可能不正确,因为删除了不需要的导入行来修复F401错误。

自动运行

手动运行 linters 当然是可能的,但是我们现在有四种不同的检查要记住运行,以确保代码是可接受的。很容易错过其中的一个,并意外地犯下不符合标准的事情。一旦它被提交,修复就变得更加困难;要么需要编辑提交以包括修复,要么需要仅包含那些修复的新提交。在使用 linters 的项目中,看到类似“PEP8”、“Fixes”或“Flake8”这样的提交消息是很常见的,但是并没有一致地使用它们。

使用 linter 的一个主要原因是第一次就把事情做好,所以为了获得最大的好处,它应该在每次提交时运行,而不仅仅是每次推送或者当作者喜欢时运行。如果代码库接受外部贡献或来自不止一个开发人员的工作,就好像有些开发人员没有运行 linter 一样,那么这一点就特别重要,因为它所发现的错误不能保证与您正在进行的更改有关。

因此,我在本章推荐的最后一个工具叫做预提交。这是一个管理 Git 提供的钩子的工具,用于确定是否应该允许提交。它是用 Python 编写的,所以可以很容易地用和我们所有其他开发工具一样的方法安装。

> pipenv install --dev pre-commit

我们需要配置预提交,通过将它们输入到.pre-commit-config.yaml配置文件中来了解我们想要运行的三件事。预提交广泛支持通过 GitHub 使用社区编写的配置,这是官方推荐的配置钩子的方式。然而,我发现对许多人来说,直接在存储库中编写一个手动挂钩更快,如清单 2-13 所示。如果您愿意,有许多外部维护的钩子可供选择,但是这种显式的方法通常就足够了。

repos:

- repo: local
  hooks:
  - id: black
    name: black
    entry: pipenv run black
    args: [--quiet]
    language: system
    types: [python]

  - id: mypy
    name: mypy
    entry: pipenv run mypy
    args: ["--follow-imports=skip"]
    language: system
    types: [python]

  - id: flake8
    name: flake8
    entry: pipenv run flake8
    language: system
    types: [python]

Listing 2-13.pre-commit-config.yaml

我们没有将 pytest 作为该套件的一部分自动运行,因为我们预计 pytest 会随着项目的进展而变慢。随着代码库的增长,静态分析工具不应该变得太慢,但是测试可能会做得很好。

一旦该文件就位,就配置了预提交。每个用户都需要在签出时启用预提交,这是通过

> pipenv run pre-commit install

从这一点开始,所有的提交都由这三个检查器保护。可以跳过检查(例如,如果进行一个快速的正在进行的工作提交,并且您打算在以后进行更改)。跳过检查是通过 git commit 调用中的--no-verify参数或者通过将SKIP环境变量设置为要跳过的检查器的名称来完成的。 19

Tip

我经常使用git add --patch来交互地放置我作品的“大块头” 20 ,而不是一次添加整个文件。如果您也这样工作,您可能会对 linters 和 formatters 有所顾虑,因为当您提交时,可能已经为您打算提交的下一个提交准备好了代码。

预提交程序很好地处理了这个问题。任何未暂存的更改都将存储在由预提交管理的独立存储中(它不会干扰您现有的存储),因此验证器和重新格式化器只对您已暂存的代码起作用。在我看来,这就是预提交的“杀手锏”特性。

根据拉取请求运行

像 GitHub 和 GitLab 这样的版本控制软件的现代前端支持持续集成挂钩。这些允许外部服务对您的提交、分支和拉请求运行验证,并在用户界面中用结果对它们进行注释。许多不同的产品都提供这种功能,所有产品都有不同的功能集和定价结构。

Github 提供了一个简单的基于 docker 的 CI 运行程序,以及许多商业产品。GitLab 方法反映了 GitLab 本身,因为它们都是开源的,可以根据您的要求进行配置。这里有许多不同的方法,我不可能给出一个对每个人都有用的建议,所以本节只讨论一般的方法。我个人一般用 Github 的动作。

持续集成软件提供的信息有两个目标用户。显而易见的是包的维护者。如果您有一些其他人可以访问的代码,无论是公开的补丁还是仅仅来自您的同事,您都会想知道建议的补丁中是否有任何明显的错误。维护一个软件可能是非常辛苦的工作;如果您必须检查一个分支,并在您的本地计算机上构建它,然后发现提交的内容有一个打字错误,这使它无法工作,那么它会变得更加困难。持续集成通过执行您通常会做的常见检查来减少您的工作量,并让您专注于审查代码。

信息的不太明显的用户是变更的作者。每当你第一次为某个软件做贡献的时候,确保你没有犯一个小错误是很伤脑筋的。没有人喜欢犯错,尤其是在公共场合或在你的同伴面前。持续集成有助于在没有其他人积极参与的情况下,警告您是否出现了问题。当你提交一个拉取请求时,你可以看着一个个检查通过,并确保你的贡献不会因为一个简单的错误而被视为浪费某人的时间。

这对于拥有非常慢的测试套件或依赖于特定操作系统或依赖版本的测试套件的项目尤其有用。可以设置持续集成来在 Linux、Windows 和 macOS 上运行您的软件。Django 测试套件可以在每一个支持的数据库架构上运行,包括 Oracle 这样的非免费数据库。要求所有提交补丁的人针对所有这些不同的配置运行测试是不可行的,所以 CI 服务器会处理它。

摘要

在这一章中,我们已经将我们的工作示例从一些基本函数扩展到了实现这些功能的类,使得构建即将到来的特性变得更加容易。我们已经实现了自动化测试,因此我们可以确信我们所做的更改不会破坏整个过程,以及类型检查和 Lint 来捕捉潜在的基本错误。

我们已经看了三大类软件(测试、类型检查和 Lint),它们帮助作为软件工程师的你写出你有信心的代码。您可能经常看到有人提倡这三种方法,以及应该应用于它们的使用的特定哲学,例如 100%测试覆盖率。这些方法的价值在于它节省了你和为你的软件做出贡献的人的时间,这应该是你判断如何使用它们的标准。

一般来说,最努力的方法会有最大的回报。因此,测试您的代码具有最高的潜在回报,并且被广泛认为是一个好主意。测试驱动开发和在主要开发之后编写测试的相对优势,以及 100%测试覆盖率的相对优势,以及不同类型测试的好处就不那么显著了。对于任何比玩具项目更复杂的事情,我强烈建议至少编写一些测试。它们不一定是很棒的测试,但是随着时间的推移,某种程度的测试通常会对你有所帮助。

静态类型检查有很大的好处,尤其是在编写大型复杂代码时。它还需要一些关于如何处理过程的决策,并且有一个重要的学习曲线。不精通测试的开发人员不会每次都面对测试套件的细节;静态类型就不是这样了。在整个代码库中都有键入工作的证据,编写一个新函数需要考虑静态类型。出于这个原因,如果你有充分的理由,我建议只使用静态类型。在我看来,最好的原因是开发团队认为它是有帮助的。其他因素,比如对高代码复杂性的预期,或者未来用户可能希望对他们的代码使用类型检查,也很有说服力。

最后,Lint 很容易实现,但好处相对较少。这当然会节省你一些时间(也许还会节省一些时间),但它只会发现相对较浅的错误和风格上的改进。这值得去做,但不值得为此而紧张。我强烈建议所有 Python 项目使用某种 linter,并且我鼓励任何多人参与的项目使用代码格式化程序。也就是说,如果你觉得某些警告没有帮助,不要害怕忽视它们。

在下一章中,我们将以可安装的方式打包这个软件,并提供一种通过插件架构将额外的传感器添加到可用集合的方式。

额外资源

以下资源提供了本章所涵盖主题的附加信息:

Footnotes 1

源自一句话“如果它走路像鸭子,叫起来像鸭子,那么它很可能就是一只鸭子。”在这个上下文中,这意味着 Python 不检查变量的类型是否与现有的声明匹配;相反,只要执行代码所需的所有方法和属性都存在,它将代替任何其他对象接受任何对象。

  2

严格来说就是some_int.__add__(other_int)int.__add__(some_int, other_int)的区别。Python 会自动将x + y转换为x.__add__(y),但我不想暗示这是一种合适的整数相加方式。

  3

早期绑定要求在编写程序时就知道将要使用的确切函数。

  4

这个fractions.Fraction(...)类用得不多,这真是一个遗憾。它允许在不损失精度的情况下操作分数。浮点数的精度对于大多数计算来说已经足够了,但是如果您的值代表有意义的分数,那么它们可能是有用的。想象你把一块蛋糕切成四份,然后吃掉三分之二。以下哪一项更清楚你吃了多少?

>>> from fractions import Fraction
>>> 1/4 * 2/3
0.16666666666666666
>>> Fraction(“1/4”) * Fraction(“2/3”)
Fraction(1, 6)

  5

技术上, mebibytes : 1024 × 1024 字节而不是 1000 × 1000 字节。虽然术语兆字节(和相应的缩写 MB)经常用于这两种定义,但兆字节(缩写 MiB)专门指较大的二进制定义。

  6

定义在对象上而不是全局范围内的函数传统上称为方法。

  7

或者klassclass是保留字,因此不能用于变量名。clsself参数名称都只是约定,但是我强烈建议您遵循它们。

  8

在时间或者记忆方面是昂贵的。虽然有些 API 会导致真正的金钱支出,但我建议不要编写仅仅通过实例化一个类就隐含地发生这种情况的代码。

  9

当对象被转换成面向用户的字符串时,使用__str__()。当一个对象被打印或用于字符串操作方法如”{}”.format(obj)时,就会发生这种情况。__repr__()由 Python 内部使用,用于面向程序员的字符串表示,例如在回溯中,以及在 REPL 提示符下键入其名称。你可以通过使用内置的str(obj)repr(obj)函数来明确选择你想看的。

  10

这被称为“纯函数”:它的输出只由它的输入决定。行为不一致的函数,像random.random(),不是纯函数,更难测试。

  11

这是个人风格问题。你可能会发现给设备起一个更清晰的名字,让它的功能更清晰。

  12

方法的表示称为“绑定方法”,这意味着它是一个附加到实例的方法。

  13

对于那些黑白阅读这篇文章的人来说,红线在绿线下面。

  14

从技术上讲,flake8 这个名字实际上是基于 pyflakes 和 pep8 库,这两个库都是静态分析工具。pep8 库以 PEP8 命名,因为它试图检查 PEP8 的符合性。

  15

黑色的配置选项很少;它做它认为最好的事。它的名字参考了亨利·福特的名言:“任何顾客都可以把车漆成他想要的任何颜色,只要是黑色的。”

  16

在撰写本文时,black 的作者已经承诺在过去的 18 个月中立即取消 black 的预发布标志。如果在您阅读本文时这还没有完成,您可能需要给 pipenv 添加--pre标志来允许它被安装。尽管它声称还没有准备好生产,但我认为它已经准备好了。

  17

当转换项目以使用 black 时,您应该在一次 git 提交中将 black 添加到环境中,并在第二次提交中进行所有的自动更改。如果稍后发生合并冲突,则更容易放弃第二次提交并重新运行 black。包含重新格式化代码的提交应该按如下方式完成

git commit -m “Apply initial black formatting” --author=”Black Formatter <black@example.com>”

以确保这是一个自动重新格式化的事实在将来对开发者是清楚的。如果您不指定作者,那么您将永远被认为是最后一个接触代码库各个部分的人。

  18

其中一些我一直假装没有注意到,所以在这个部分会有一些东西可以找到,其他的我没有注意到。重构代码时很容易遗漏一些东西,即使你很专心。

  19

对于除了 Windows cmd.exe 之外的大多数 shells,这可以作为SKIP=”mypy” git commit来完成。

  20

我不知道 GNU diff 的作者为什么选择将一组更改的行称为 hunk,但它已经成为标准术语。

  21

“自行车脱落”关注的是设计的一个微不足道的方面,而不是重要的部分。这个名字是指这样一种想法,即当面对一个核电站的详细计划时,人们更有可能对一些琐碎但普遍的东西进行评论,比如自行车棚的颜色,而不是复杂的东西,比如电厂的设计。