Python-开发高级教程-五-

144 阅读1小时+

Python 开发高级教程(五)

原文:Advanced Python Development

协议:CC BY-NC-SA 4.0

十、加快速度

提高代码速度有两种主要方法:优化我们编写的代码和优化程序的控制流以运行更少的代码。人们通常专注于优化代码而不是控制流,因为更容易进行自包含的更改,但是最显著的好处通常是在更改流程时。

优化功能

优化一个函数的第一步是在做任何改变之前对它的性能有一个很好的理解。Python 标准库有一个 profile 模块来帮助实现这一点。Profile 会在代码运行时对代码进行内省,以了解每个函数调用所花费的时间。探查器可以检测对同一函数的多次调用,并监视任何间接调用的函数。然后,您可以生成一个报告,显示整个运行的函数调用图。

我们可以使用profile.run(...)函数来分析一条语句。这使用参考分析器,它总是可用的,但是大多数人在cProfile.run(...) 1 使用优化的分析器。分析器将对作为第一个参数传递的字符串进行exec,生成分析信息,然后自动将分析结果格式化成报告。

>>> from apd.aggregation.analysis import interactable_plot_multiple_charts
>>> import cProfile
>>> cProfile.run("interactable_plot_multiple_charts()()", sort="cumulative")
         164 function calls in 2.608 seconds

   Ordered by: cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1   0.001   0.001   2.608   2.608 {built-in method builtins.exec}
        1   0.001   0.001   2.606   2.606 <string>:1(<module>)
        1   0.004   0.004   2.597   2.597 analysis.py:327(run_in_thread)
        9   2.558   0.284   2.558   0.284 {method 'acquire' of '_thread.lock' objects}
        1   0.000   0.000   2.531   2.531 _base.py:635(__exit__)
...

这里显示的表格显示了一个函数被调用的次数( ncalls )、执行该函数所花费的时间( tottime )以及总时间除以调用次数( percall )。它还显示了执行该函数和所有被间接调用的函数所花费的累计时间,包括总计时间和除以调用次数的时间(累计时间和第二个 percall )。具有高累计时间和低总时间的函数意味着函数本身不能从优化中受益,但是涉及该函数的控制流可以。

Tip

一些 ide 和代码编辑器内置了对运行分析器和查看其输出的支持。如果您使用的是 IDE,那么这可能是一个更自然的界面。然而,分析器的行为仍然是相同的。

在 Jupyter 笔记本中运行代码时,您也可以使用“单元格魔术”功能生成相同的报告(图 10-1 )。单元魔术是在单元上的注释,用于在执行期间使用命名的插件,在本例中是一个分析器。如果您创建了单元格的第一行%%prun -s cumulative,那么一旦单元格执行完毕,笔记本就会显示一个包含整个单元格的概要报告的弹出窗口。

Caution

“cell magic”方法目前与 IPython 中的顶级 await 支持不兼容。如果你使用%%prun 细胞魔法,那么该细胞不能等待协程。

img/481001_1_En_10_Fig1_HTML.jpg

图 10-1

分析 jupiter 笔记本电脑单元的示例

分析和线程

前面的例子生成的报告列出了许多线程内部函数,而不是我们的实质性函数。这是因为我们的interactable_plot_multiple_charts(...)(...)函数 2 启动了一个新线程来处理底层协程的运行。分析器不会进入已启动的线程来启动分析器,所以我们只能看到主线程在等待工作线程完成。

我们可以通过改变代码将协程封装到线程中的方式来解决这个问题,让我们有机会在子线程中插入一个分析器。例如,我们可以添加一个debug=标志,然后如果debug=True被传递,就向线程池提交一个不同的函数,如清单 10-1 所示。

_Coroutine_Result = t.TypeVar("_Coroutine_Result")

def wrap_coroutine(
    f: t.Callable[..., t.Coroutine[t.Any, t.Any, _Coroutine_Result]], debug: bool=False,
) -> t.Callable[..., _Coroutine_Result]:
    """Given a coroutine, return a function that runs that coroutine
    in a new event loop in an isolated thread"""

    @functools.wraps(f)
    def run_in_thread(*args: t.Any, **kwargs: t.Any) -> _Coroutine_Result:
        loop = asyncio.new_event_loop()
        wrapped = f(*args, **kwargs)

        if debug:
            # Create a new function that runs the loop inside a cProfile
            # session, so it can be profiled transparently

            def fn():
                import cProfile

                return cProfile.runctx(
                    "loop.run_until_complete(wrapped)",
                    {},
                    {"loop": loop, "wrapped": wrapped},
                    sort="cumulative",
                )

            task_callable = fn
        else:
            # If not debugging just submit the loop run function with the
            # desired coroutine
            task_callable = functools.partial(loop.run_until_complete, wrapped)
        with ThreadPoolExecutor(max_workers=1) as pool:
            task = pool.submit(task_callable)
        # Mypy can get confused when nesting generic functions, like we do # here
        # The fact that Task is generic means we lose the association with
        # _CoroutineResult. Adding an explicit cast restores this.
        return t.cast(_Coroutine_Result, task.result())

    return run_in_thread

def interactable_plot_multiple_charts(
    *args: t.Any, debug: bool=False, **kwargs: t.Any
) -> t.Callable[..., Figure]:
    with_config = functools.partial(plot_multiple_charts, *args, **kwargs)
    return wrap_coroutine(with_config, debug=debug)

Listing 10-1Example of wrap_coroutine to optionally include profiling

在清单 10-1 中,我们使用分析器中的runctx(...)函数,而不是run(...)函数。runctx(...)允许将全局和局部变量传递给我们正在分析的表达式。 3 解释器不会自省代表要运行的代码的字符串来确定需要什么变量。您必须显式地传递它们。

有了这一改变,我们用来绘制所有带有交互元素的图表的代码也可以请求收集分析信息,因此 Jupyter 笔记本中的用户可以轻松地调试他们正在添加的新图表类型,如图 10-2 所示。

img/481001_1_En_10_Fig2_HTML.jpg

图 10-2

从 jupiter 使用集成分析选项

运行在子线程中的分析器在顶部仍然包括一些开销函数,但是我们现在可以看到我们想要分析的函数,而不仅仅是线程管理函数。如果我们只看与我们的代码相关的函数,输出如下:

ncalls   tottime  percall  cumtime  percall  filename:lineno(function)
    20   0.011    0.001    2.607    0.130    analysis.py:282(plot_sensor)
    12   0.028    0.002    2.108    0.176    analysis.py:304(<listcomp>)
  3491   0.061    0.000    1.697    0.000    analysis.py:146(clean_watthours_to_watts)
 33607   0.078    0.000    0.351    0.000 query.py:114(subiterator)
    12   0.000    0.000    0.300    0.025 analysis.py:60(draw_date)
 33603   0.033    0.000    0.255    0.000 query.py:39(get_data)
     3   0.001    0.000    0.254    0.085 analysis.py:361(plot_multiple_charts)
 16772   0.023    0.000    0.214    0.000 analysis.py:223(clean_passthrough)
 33595   0.089    0.000    0.207    0.000 database.py:77(from_sql_result)
  8459   0.039    0.000    0.170    0.000 analysis.py:175(clean_temperature_fluctuations)
    24   0.000    0.000    0.140    0.006 query.py:74(get_deployment_by_id)
     2   0.000    0.000    0.080    0.040 query.py:24(with_database)

看起来plot_sensor(...)函数被调用了 20 次,列表理解points = [dp async for dp in config.clean(query_results)]被调用了 12 次,clean_watthours_to_watts(...)函数被调用了 3491 次。大量报告的对 clean 函数的调用是由于分析器与生成器函数的交互方式。每次从生成器请求一个新项时,它都被归类为该函数的一个新调用。同样,每次一个项目被放弃,它被归类为调用返回。这种方法看起来比测量从第一次调用到生成器耗尽的时间更复杂,但是这意味着 tottime 和 cumtime 总计不包括迭代器空闲并等待其他代码请求下一项的时间。但是,这也意味着 percall 数字表示检索单个项目所花费的时间,而不是每次调用该函数所花费的时间。

Caution

分析器需要一个函数来确定当前时间。默认情况下,profile 使用time.process_time(),cProfile 使用time.perf_counter()。这些测量非常不同的东西。process_time()函数测量 CPU 忙碌的时间,但是perf_counter()测量真实世界的时间。现实世界的时间通常被称为“墙上时间”,意思是由墙上的时钟测量的时间。

解释配置文件报告

clean_watthours_to_watts(...)函数应该立即吸引你的注意力,因为它是一个相对低级的函数,具有非常高的累计时间。这个函数被用作绘制四个图表之一的支持函数,但是它占了plot_sensor(...)总执行时间的 65%。这个函数是我们开始优化的地方,但是如果我们比较 tottime 和 cumtime,我们可以看到它只花了这个函数总时间的 2%。

这种差异告诉我们,并不是我们在这个函数中直接编写的代码导致了速度下降,而是我们在实现clean_watthours_to_watts(...)的过程中间接调用了其他函数。现在,我们着眼于优化功能,而不是优化执行流程。因为优化这个函数需要优化调用我们无法控制的函数的模式,所以我们暂时忽略它。本章的后半部分将介绍通过改变控制流来提高性能的策略,我们将在那里返回来修正这个函数。

相反,让我们把注意力集中在那些 tottime 比 cumtime 高的项目上,这表示花费的时间是在执行我们编写的代码上,而不是在执行我们正在使用的代码上。这些数字明显低于我们之前观察的次数;它们是相对简单的功能,代表着较小的潜在利益,但情况可能并不总是如此。

       12   0.103   0.009   2.448   0.204 analysis.py:304(<listcomp>)
    33595   0.082   0.000   0.273   0.000 database.py:77(from_sql_result)
    33607   0.067   0.000   0.404   0.000 query.py:114(subiterator)

我们看到与数据库接口相关的两个函数是潜在的候选函数。这些都运行了 33,000 次以上,每次运行的总时间不到十分之一秒,因此它们不是特别诱人的优化目标。尽管如此,就我们代码的总时间而言,它们是最高的,因此它们代表了我们必须进行简单、自包含类型的优化的最佳机会。

要做的第一件事是尝试改变一些关于实现的东西,并测量任何差异。现有的实现非常短,只包含一行代码。我们根本不可能优化,但让我们试试。

@classmethod
def from_sql_result(cls, result) -> DataPoint:
    return cls(**result._asdict())

在前面的实现中,有一点可能会导致速度变慢,那就是生成了一个值字典,并动态地映射到关键字参数。 4 一个要测试的想法将是显式地传递论点,因为我们知道它们是一致的。

@classmethod
def from_sql_result(cls, result) -> DataPoint:
    if result.id is None:
        return cls(data=result.data, deployment_id=result.deployment_id,
                    sensor_name=result.sensor_name, collected_at=result.collected_at)
    else:
        return cls(id=result.id, data=result.data, deployment_id=result.deployment_id, sensor_name=result.sensor_name,
        collected_at=result.collected_at)

这个过程中最重要的部分是检验我们的假设。我们需要重新运行代码并比较结果。我们还需要意识到这样一个事实,即代码的执行时间可能会因外部因素(如计算机上的负载)而有所不同,因此尝试运行几次代码以查看结果是否稳定是一个好主意。我们在这里寻求显著的加速,因为我们的改变会引入可维护性问题,所以微不足道的速度提升是不值得的。

    33595   0.109   0.000   0.147   0.000 database.py:77(from_sql_result)

这里的结果显示,与之前的实现相比,在from_sql_result()函数上花费了更多的时间,但是累积时间减少了。这个结果告诉我们,我们对from_sql_result()所做的更改直接导致该函数花费更长的时间,但是这样做的时候,我们更改了控制流以消除对_asdict()的调用,并直接传递值,这大大弥补了我们引入的减速。

换句话说,除了通过改变控制流来避免_asdict()中的代码之外,这个函数的实现对性能没有明显的改善。它还要求我们列出在多个地方使用的字段,从而降低了代码的可维护性。因此,我们将坚持我们的原始实现,而不是“优化”版本。

Tip

对于类的创建还有另一个潜在的优化,在类上设置一个 slots 属性,比如__slots__ = {"sensor_name", "data", "deployment_id", "id", "collected_at"}。这使得开发人员可以保证在一个实例上只设置专门命名的属性,这使得解释器可以添加许多优化。在撰写本文时,数据类和__slots__之间存在一些不兼容,这使得它不太容易使用,但是如果您想优化对象的实例化,那么我建议您看一看。

其他两个也是如此:subiterator()和列表理解功能非常少;对它们的更改会降低可读性,并且不会带来实质性的性能改进。

一个小的、容易理解的函数成为显著性能改进的候选者的情况相对较少,因为糟糕的性能通常与复杂性相关。如果系统的复杂性是由简单函数的组合造成的,那么性能的提高来自于控制流的优化。如果你有很长的函数来做复杂的事情,那么更有可能的是通过孤立地优化函数来获得显著的改进。

其他分析器

Python 自带的分析器足以在大多数情况下获得有用的信息。尽管如此,由于代码性能是一个如此重要的主题,还有其他可用的分析器,它们都有独特的优点和缺点。

时间到了

要提到的最重要的可选分析器也来自 Python 标准库,名为 timeit。时间它对于分析快速、独立的函数很有用。它不是在正常运行中监控程序,而是重复运行给定的代码,并返回所用的累计时间。

>>> import timeit
>>> from apd.aggregation.utils import merc_y
>>> timeit.timeit("merc_y(52.2)", globals={"merc_y": merc_y})
1.8951617999996415

当使用默认参数调用时,如前所示,输出是执行第一个参数一百万次所需的秒数,使用最精确的时钟测量。只有第一个参数(stmt=)是必需的,它是每次要执行的代码的字符串表示。第二个字符串参数(setup=)表示测试开始前必须执行的设置代码,一个globals=字典允许将任意项传递到被分析代码的名称空间中。这对于在测试中传递函数特别有用,而不是在setup=代码中导入它。可选的number=参数允许我们指定代码应该运行多少次,因为一百万次执行对于执行时间超过 50 微秒的函数来说是不合适的。 5

表示要测试的代码的字符串和setup=字符串都可以是包含一系列 Python 语句的多行字符串。但是,请注意,第一个字符串中的任何定义或导入每次都要运行,所以所有设置代码都应该在第二个字符串中完成,或者直接作为全局变量传递。

线条轮廓图

一个普遍推荐的替代分析器是 Robert Kern 的 line_profiler。 6 它逐行记录信息,而不是逐函数记录,这对于精确定位函数性能问题的来源非常有用。

不幸的是,line_profiler 的权衡相当重要。它需要修改您的 Python 程序来注释您希望分析的每个函数,并且当这些注释就位时,代码不能运行,除非通过 line _ profilers 自定义环境。此外,在撰写本文时,大约有两年的时间无法安装带有 pip 的 line_profiler。虽然你会发现很多人在网上推荐这个分析器,但部分原因是它比其他工具更早上市。我建议避免使用这个分析器,除非对调试复杂的函数绝对必要;你可能会发现安装花费的时间比安装后节省的时间还多。

雅皮

另一个可选的分析器是 yappi, 7 ,它提供了跨多线程和异步事件循环运行的 Python 代码的透明分析。迭代器的调用计数之类的数字表示迭代器被调用的次数,而不是检索的项数,并且不需要修改代码来支持多线程分析。

yappi 的缺点是,它是一个相对较小的项目,正在大量开发中,因此您可能会发现它不如许多其他 Python 库那么完美。对于内置分析器不足的情况,我会推荐 yappi。在撰写本文时,我仍然推荐内置的分析工具作为我的首选,但是 yappi 紧随其后。

yappi 的接口与我们到目前为止使用的内置分析器有些不同,因为它没有提供与run(...)函数调用等价的接口。yappi 分析器必须在被分析的代码周围启用和禁用。默认分析器有一个等效的 API,如表 10-1 所示。

表 10-1

profile 和 yappi 分析的比较

| *使用启用/禁用 API 的 c profile*`import cProfile``profiler = cProfile.Profile()``profiler.enable()``method_to_profile()``profiler.disable()``profiler.print_stats()` | *基于 Yappi 的剖析*`import yappi``yappi.start()``method_to_profile()``yappi.stop()``yappi.get_func_stats().print_all()` |

在 Jupyter 单元中使用 yappi 使我们能够调用底层代码中的函数,而不需要解决线程和异步问题。我们本可以使用 yappi 来分析我们的代码,而不需要提前修改debug=参数。在前面的例子中,如果method_to_profile()调用interactable_plot_multiple_charts(...)widgets.interactive(...),产生的概要文件输出如下:

Clock type: CPU
Ordered by: totaltime, desc

name                                  ncall  tsub      ttot      tavg
..futures\thread.py:52 _WorkItem.run  17     0.000000  9.765625  0.574449
..rrent\futures\thread.py:66 _worker  5/1    0.000000  6.734375  1.346875
..38\Lib\threading.py:859 Thread.run  5/1    0.000000  6.734375  1.346875
..ndowsSelectorEventLoop.run_forever  1      0.000000  6.734375  6.734375
..b\asyncio\events.py:79 Handle._run  101    0.000000  6.734375  0.066677
..lectorEventLoop.run_until_complete  1      0.000000  6.734375  6.734375
..WindowsSelectorEventLoop._run_once  56     0.000000  6.734375  0.120257
..gation\analysis.py:282 plot_sensor  4      0.093750  6.500000  1.625000
..egation\analysis.py:304 <listcomp>  12     0.031250  5.515625  0.459635
...

在这个例子中,yappi 显示的总时间明显高于 cProfile 显示的总时间。您应该只比较性能分析器产生的时间和使用相同工具在相同硬件上产生的结果,因为当启用性能分析器时,性能会有很大差异 8

Yappi Helper Functions

Yappi 支持按函数和模块过滤统计数据。还有一个提供定制过滤功能的选项,以准确地确定应该在性能报告中显示哪些代码。还有一些其他的选择;您应该查看 yappi 的文档,找到推荐的方法来过滤输出,只显示您感兴趣的代码。

本章附带的代码有一些帮助器函数,可以让 yappi 在 Jupyter 上下文中更容易进行分析。这些是profile_with_yappi,一个上下文管理器,处理激活和停用分析器;jupyter_page_file,一个上下文管理器,帮助以与%%prun单元魔术相同的方式显示剖析数据,不与单元输出合并;和yappi_package_matches,一个助手,它使用filter_callback=选项来限制显示的统计信息,只显示给定 Python 包中的模块。清单 10-2 显示了使用这些辅助函数的一个例子。

img/481001_1_En_10_Figa_HTML.jpg

from apd.aggregation.analysis import (interactable_plot_multiple_charts, configs)
from apd.aggregation.utils import (jupyter_page_file, profile_with_yappi, yappi_package_matches)
import yappi

with profile_with_yappi():
    plot = interactable_plot_multiple_charts()
    plot()

with jupyter_page_file() as output:
    yappi.get_func_stats(filter_callback=lambda stat:
        yappi_package_matches(stat, ["apd.aggregation"])
    ).print_all(output)

Listing 10-2.Jupyter cell for yappi profiling, with part of the Jupyter output shown

这三个助手都不是必需的,但是它们提供了一个更加用户友好的界面。

Tracemalloc

到目前为止,我们看到的分析器都是测量运行一段代码所需的 CPU 资源。我们可用的另一个主要资源是内存。一个运行速度很快但需要大量内存的程序在可用内存较少的系统上运行速度会慢得多。

Python 有一个内置的 RAM 分配分析器,称为 tracemalloc。该模块提供tracemalloc.start()tracemalloc.stop()功能,分别启用和禁用 profiler。通过使用tracemalloc.take_snapshot()功能,可以随时请求轮廓结果。清单 10-3 给出了一个在我们的绘图代码中使用它的例子。

这样做的结果是一个Snapshot对象,它有一个statistics(...)方法来返回单个统计数据的列表。该函数的第一个参数是对结果进行分组的关键字。最有用的两个键是"lineno"(用于逐行分析)和"filename"(用于整个文件分析)。一个cumulative=标志允许用户选择是否包含间接调用函数的内存使用。也就是说,每个统计行应该直接表示一行做了什么,还是表示运行该行的所有结果?

import tracemalloc

from apd.aggregation.analysis import interactable_plot_multiple_charts

tracemalloc.start()
plot = interactable_plot_multiple_charts()()
snapshot = tracemalloc.take_snapshot()
tracemalloc.stop()
for line in snapshot.statistics("lineno", cumulative=True):
    print(line)

Listing 10-3Example script to debug memory usage after plotting the charts

标准库中的文档提供了一些帮助函数,以提供更好的输出数据格式,尤其是display_top(...)函数的代码示例。 9

Caution

tracemalloc 分配器只显示在生成快照时仍处于活动状态的内存分配。对我们的程序进行分析表明,SQL 解析使用了大量的 ram,但是不会显示我们的DataPoint对象,尽管它们占用了更多的 RAM。与 SQL 对象不同,我们的对象是短命的,所以在我们生成快照时它们已经被丢弃了。调试内存使用峰值时,必须在峰值时创建快照。

新遗迹

如果你正在运行一个基于网络的应用,那么商业服务 New Relic 可能会提供有用的剖析见解。它提供了一个紧密集成的分析系统,允许您监控来自 web 请求的控制流、服务这些请求所涉及的功能,以及作为呈现过程的一部分与数据库和第三方服务的交互。

New Relic 和它的竞争对手之间的权衡是巨大的。您可以访问一组优秀的概要分析数据,但是它并不适合所有的应用类型,并且要花费大量的金钱。此外,使用真实用户的动作来执行概要分析这一事实意味着,在向您的系统引入新的遗留系统之前,您应该考虑用户隐私。也就是说,新的遗迹剖析提供了一些我见过的最有用的性能分析。

优化控制流程

更常见的情况是,在 Python 系统中,不是单个函数导致性能问题。正如我们前面看到的,以一种天真的方式编写代码通常会导致一个函数除了改变它正在做的事情之外不能被优化。

以我的经验来看,低性能最常见的原因是函数的计算量超过了它所需要的。例如,在我们第一次实现获取整理数据的功能时,我们还没有数据库端过滤,所以我们添加了一个循环来从不相关的数据中过滤我们想要的数据。

稍后过滤输入数据不只是移动工作区;它可以增加正在完成的总工作量。在这种情况下,完成的工作是从数据库加载数据,建立数据点记录,并从这些记录中提取相关数据。通过将过滤从加载步骤转移到提取步骤,我们为那些我们不关心的对象建立了数据点记录。

Complexity

函数所花费的时间并不总是与输入的大小成正比,但是对于在数据上循环一次的函数来说,这是一个很好的近似值。排序和其他更复杂的操作表现不同。

函数需要多长时间(或需要多少内存)和它们的输入大小之间的关系称为计算复杂度。大多数程序员从来不需要担心函数的确切复杂性类别,但是在优化代码时,有必要了解它们的大致区别。

您可以使用具有不同输入的 timeit 函数来估计输入大小和时间之间的关系,但是根据经验,最好避免在循环中嵌套循环。迭代次数很少的嵌套循环是可以的,但是在用户输入上的另一个循环中循环用户输入会导致函数花费的时间随着用户输入量的增加而迅速增加。

对于给定的输入大小,函数花费的时间越长,就越需要最小化它处理的无关数据量。

在图 10-3 中,横轴表示花费的时间,纵轴表示流水线中一个阶段必须处理的输入量。一个步骤的宽度,也就是它处理的时间,与它处理的数据量成正比。

这两个流说明了处理单个传感器所需的工作量,上面的流具有数据库级别的过滤,下面的流具有 Python 中的过滤。在这两种情况下,输出总量是相同的,但是中间阶段要处理的数据量不同,因此花费的时间也不同。

img/481001_1_En_10_Fig3_HTML.jpg

图 10-3

数据库中过滤的代码数据集大小与清理期间过滤的关系图

我们在两个地方丢弃数据:当我们只找到有问题的传感器的数据时,以及当丢弃无效数据时。通过将传感器过滤器移到数据库中,我们减少了加载步骤中完成的工作量,从而减少了所需的时间。我们正在转移大部分过滤,用于移除无效数据的更复杂的过滤仍在清理步骤中进行。如果我们能够将这种过滤转移到数据库中,将会进一步减少加载步骤所花费的时间,尽管不是那么多。

我们已经假设我们在编写函数时需要过滤数据库,部分是为了提高 API 的可用性,但是我们可以通过使用 yappi profiler 和为我们的绘图系统提供显式配置的能力来测试这一假设。然后,我们可以直接比较使用数据库支持的过滤和 Python 过滤绘制图表所需的时间。数据库中过滤性能分析的实现如清单 10-4 所示。

import yappi

from apd.aggregation.analysis import (interactable_plot_multiple_charts, Config)
from apd.aggregation.analysis import (clean_temperature_fluctuations, get_one_sensor_by_deployment)
from apd.aggregation.utils import profile_with_yappi

yappi.set_clock_type("wall")

filter_in_db = Config(
    clean=clean_temperature_fluctuations,
    title="Ambient temperature",
    ylabel="Degrees C",
    get_data=get_one_sensor_by_deployment("Temperature"),
)

with profile_with_yappi():
    plot = interactable_plot_multiple_charts(configs=[filter_in_db])
    plot()

yappi.get_func_stats().print_all()

Listing 10-4Jupyter cell to profile a single chart, filtering in SQL

下面的统计数据是 cells 输出的一部分,显示了我们最感兴趣的一些条目。我们可以看到加载了 10828 个数据对象,get_data(...)函数花费了 2.7 秒,6 次数据库调用总共花费了 2.4 秒。analysis.py ( points = [dp async for dp in config.clean(query_results)])的第 304 行的 list comprehension 是调用 cleaner 函数的地方。清理数据需要 0.287 秒,但是清理函数本身的时间可以忽略不计。

name                                  ncall  tsub      ttot      tavg
..lectorEventLoop.run_until_complete  1      0.000240  3.001717  3.001717
..alysis.py:341 plot_multiple_charts  1      2.843012  2.999702  2.999702
..gation\analysis.py:282 plot_sensor  1      0.000000  2.720996  2.720996
..query.py:86 get_data_by_deployment  1      2.706142  2.706195  2.706195
..d\aggregation\query.py:39 get_data  1      2.569511  2.663460  2.663460
..lchemy\orm\query.py:3197 Query.all  6      0.008771  2.407840  0.401307
..lchemy\orm\loading.py:35 instances  10828  0.005485  1.588923  0.000147
..egation\analysis.py:304 <listcomp>  4      0.000044  0.286975  0.071744
..175 clean_temperature_fluctuations  4      0.000000  0.286888  0.071722

我们可以重新运行相同的测试,但是使用相同图表的新版本,其中所有的过滤都在 Python 中进行。清单 10-5 演示了这一点,通过添加一个新的更干净的函数来进行过滤,并使用现有的get_data_by_deployment(...)函数作为数据源。这代表了如果我们没有给get_data(...)添加一个sensor_name=参数,我们需要如何过滤数据。

import yappi

from apd.aggregation.analysis import (interactable_plot_multiple_charts, Config, clean_temperature_fluctuations, get_data_by_deployment)
from apd.aggregation.utils import (jupyter_page_file, profile_with_yappi, YappiPackageFilter)

async def filter_and_clean_temperature_fluctuations(datapoints):
    filtered = (item async for item in datapoints if item.sensor_name=="Temperature")
    cleaned = clean_temperature_fluctuations(filtered)
    async for item in cleaned:
        yield item

filter_in_python = Config(
    clean=filter_and_clean_temperature_fluctuations,
    title="Ambient temperature",
    ylabel="Degrees C",
    get_data=get_data_by_deployment,
)

with profile_with_yappi():
    plot = interactable_plot_multiple_charts(configs=[filter_in_python])
    plot()

yappi.get_func_stats().print_all()

Listing 10-5Jupyter cell to profile drawing the same chart but without any database filtering

在这个版本中,过滤发生在filter_and_clean_temperature_fluctuations(...)中,所以我们预计这需要很长时间。所花费的额外时间部分存在于该函数的生成器表达式中,但不是全部。plot_multiple_charts(...)花费的总时间从 3.0 秒增加到了 8.0 秒,其中 1.3 秒是过滤。这表明,通过在数据库中进行过滤,我们节省了 3.7 秒的开销,这代表了 21%的加速。

name                                  ncall  tsub      ttot      tavg
..lectorEventLoop.run_until_complete  1      0.000269  7.967136  7.967136
..alysis.py:341 plot_multiple_charts  1      7.637066  7.964143  7.964143
..gation\analysis.py:282 plot_sensor  1      0.000000  6.977470  6.977470
..query.py:86 get_data_by_deployment  1      6.958155  6.958210  6.958210
..d\aggregation\query.py:39 get_data  1      6.285337  6.881415  6.881415
..lchemy\orm\query.py:3197 Query.all  6      0.137161  6.112309  1.018718
..lchemy\orm\loading.py:35 instances  67305  0.065920  3.424629  0.000051
..egation\analysis.py:304 <listcomp>  4      0.000488  1.335928  0.333982
..and_clean_temperature_fluctuations  4      0.000042  1.335361  0.333840
..175 clean_temperature_fluctuations  4      0.000000  1.335306  0.333826
..-input-4-927271627100>:7 <genexpr>  4      0.000029  1.335199  0.333800

可视化分析数据

复杂的迭代器函数很难分析,正如clean_temperature_fluctuations(...)将它的tsub时间列为零。这是一个调用其他方法的复杂函数,但是对于它来说,花费的时间正好为零,这一定是一个舍入误差。剖析运行代码可以为你指出正确的方向,但你只能从这种方法中得到指示性的数字。从这个角度也很难看出 0.287 秒的总时间是如何按组成函数分解的。

内置的 profile 模块和 yappi 都支持以 pstats 格式导出数据,这是一种特定于 Python 的 profile 格式,可以传递给可视化工具。Yappi 还支持 Valgrind 分析工具中的 callgrind 格式。

我们可以使用yappi.get_func_stats().save("callgrind.filter_in_db", "callgrind")保存来自 yappi 的 callgrind 配置文件,然后将其加载到类似 KCachegrind 的 callgrind 可视化工具中。 12 图 10-4 显示了在 QCachegrind 中显示该代码的数据库过滤版本的示例,其中块的面积对应于在相应函数中花费的时间。

img/481001_1_En_10_Fig4_HTML.jpg

图 10-4

过滤数据库中的数据时,调用 clean _ temperature _ contractions 图表

你可能会惊讶地发现get_data(...)不仅出现在这张图表中,而且是迄今为止最大的单个区块。clean_temperature_fluctuations(...)函数似乎没有调用get_data(...)函数,所以为什么这个函数会占用大部分时间还不是很明显。

迭代器使得对调用流的推理变得困难,因为当你在一个循环中从一个 iterable 中取出一个项目时,它看起来不像一个函数调用。在引擎盖下,Python 正在调用youriterable.__next__()(或youriterable.__anext__()),它将执行传递回底层函数,完成前面的 yield。因此,一个 for 循环可以调用任意数量的函数,即使它的主体是空的。async for的结构让这一点更清楚,因为它明确表示底层代码可能在等待。底层代码不可能等待,除非控制传递给其他代码,而不仅仅是与静态数据结构交互。当分析使用 iterable 的代码时,您会发现使用 iterable 的函数调用的底层数据生成函数出现在输出中。

Consuming Iterables and Single Dispatch Functions

我们可以尽快编写一个使用迭代器的函数,这在一定程度上简化了调用堆栈。使用迭代器会由于阻止任务并行运行而降低性能,并且需要足够的内存来容纳整个 iterable,但是它确实大大简化了分析工具的输出。清单 10-6 显示了在保留相同接口的同时使用可迭代和异步可迭代的简单函数。

def consume(input_iterator):
    items = [item for item in input_iterator]
    def inner_iterator():
        for item in items:
            yield item
    return inner_iterator()

async def consume_async(input_iterator):
    items = [item async for item in input_iterator]
    async def inner_iterator():
        for item in items:
            yield item
    return inner_iterator()

Listing 10-6Pair of functions for consuming iterators in place

这对函数接受一个迭代器(或异步迭代器),一被调用(或等待)就使用它,返回一个新的迭代器,这个迭代器只从预先使用的源产生。这些功能的用法如下:

# Synchronous
nums = (a for a in range(10))
consumed = consume(nums)

# Async
async def async_range(num):
    for a in range(num):
        yield a
nums = async_range(10)
consumed = await consume_async(nums)

我们可以使用标准库中的 functools 模块,特别是@singledispatch装饰器来简化这个过程。回到第二章,我们看了 Python 的动态分派功能,它允许函数被它所连接的类查找。我们正在做类似的事情。我们有一对与底层数据类型相关联的函数,但是这些数据类型不是我们编写的类。我们无法控制附加到它们上面的函数,因为这两种类型是核心语言的特性,而不是我们已经创建并可以编辑的类。

@singledispatch decorator 根据第一个参数的类型将函数标记为有多个不同的实现。使用这种方法重写我们的函数(清单 10-7 )只需要向它们添加装饰器,将替代实现加入到基本实现中,并添加一个类型提示来区分变量。

import functools

@functools.singledispatch
def consume(input_iterator):
    items = [item for item in input_iterator]
    def inner_iterator():
        for item in items:
            yield item
    return inner_iterator()

@consume.register
async def consume_async(input_iterator: collections.abc.AsyncIterator):
    items = [item async for item in input_iterator]
    async def inner_iterator():
        for item in items:
            yield item
    return inner_iterator()

Listing 10-7Pair of functions for consuming iterators in place with single dipatch

这两个函数的行为与前面的实现完全相同,除了consume(...)函数可以用于任一类型的迭代器。它基于其输入类型透明地在同步和异步实现之间切换。如果第一个参数是 AsyncIterator,那么使用consume_async(...)变量;否则使用consume(...)变体。

nums = (a for a in range(10))
consumed = consume(nums)
nums = async_range(10)
consumed = await consume (nums)

传递给 register 的函数必须有类型定义或传递给 register 函数本身的类型。我们在这里使用了collections.abc.AsyncIterator而不是typing.AsyncIterator作为类型,因为类型必须是运行时可检查的。这意味着@singledispatch仅限于在具体类或抽象基类上调度。

typing.AsyncIterator类型是一个泛型类型:我们可以用typing.AsyncIterator[int]来表示int的迭代器。这是 mypy 用于静态分析的,但不在运行时使用。如果不消耗整个迭代器并检查其内容,一个正在运行的 Python 程序不可能知道一个任意的异步迭代器是否是一个typing.AsyncIterator[int]迭代器。

collections.abc.AsyncIterator不保证迭代器的内容,所以它类似于typing.AsyncIterator[typing.Any],但是因为它是一个抽象基类,所以可以在运行时用isinstance(...)进行检查。

贮藏

另一种提高性能的方法是缓存函数调用的结果。缓存的函数调用记录了过去的调用及其结果,以避免多次计算相同的值。到目前为止,我们一直使用摄氏温度系统来绘制温度,但有几个国家保留了古老的华氏测量系统。如果我们能够指定我们希望使用哪个温度系统来显示我们的图表,那就太好了,这样用户就可以选择他们最熟悉的系统。

转换温标的工作与现有clean_temperature_fluctuations(...)方法完成的任务正交;例如,我们可能想在不消除波动的情况下转换温度。为了实现这一点,我们创建一个新的函数,它接受一个 cleaner 和一个温度系统,并返回一个新的 cleaner 来调用底层的那个,然后进行温度转换。

def convert_temperature(magnitude: float, origin_unit: str, target_unit: str) -> float:
    temp = ureg.Quantity(magnitude, origin_unit)
    return temp.to(target_unit).magnitude
def convert_temperature_system(cleaner, temperature_unit):
    async def converter(datapoints):
        results = cleaner(datapoints)
        async for date, temp_c in results:
            yield date, convert_temperature(temp_c, "degC", temperature_unit)

    return converter

前面的函数没有任何类型提示,因为它们非常冗长。cleaner 参数和来自convert_temperature_system(...)的返回值都是类型t.Callable[[t.AsyncIterator[DataPoint]], t.AsyncIterator[t.Tuple[datetime.datetime, float]]],这是一个非常复杂的结构,在一行代码中包含两次。这些类型在我们的分析函数中被重复使用,虽然很难一眼识别,但是它们映射到容易理解的概念。这些都是分解成变量的很好的候选者,其结果如清单 10-8 所示。

CLEANED_DT_FLOAT = t.AsyncIterator[t.Tuple[datetime.datetime, float]]
CLEANED_COORD_FLOAT = t.AsyncIterator[t.Tuple[t.Tuple[float, float], float]]

DT_FLOAT_CLEANER = t.Callable[[t.AsyncIterator[DataPoint]], CLEANED_DT_FLOAT]
COORD_FLOAT_CLEANER = t.Callable[[t.AsyncIterator[DataPoint]], CLEANED_COORD_FLOAT]

def convert_temperature(magnitude: float, origin_unit: str, target_unit: str) -> float:
    temp = ureg.Quantity(magnitude, origin_unit)
    return temp.to(target_unit).magnitude

def convert_temperature_system(

    cleaner: DT_FLOAT_CLEANER, temperature_unit: str,
) -> DT_FLOAT_CLEANER:
    async def converter(datapoints: t.AsyncIterator[DataPoint],) -> CLEANED_DT_FLOAT:
        results = cleaner(datapoints)
        reveal_type(temperature_unit)
        reveal_type(convert_temperature)
        async for date, temp_c in results:
            yield date, convert_temperature(temp_c, "degC", temperature_unit)

    return converter

Listing 10-8Typed conversion functions

Typing Protocols, Typevars and Variance

我们以前使用过t.TypeVar(...)来表示泛型类型中的占位符,比如在 config 类中定义了draw(...)函数。我们必须使用T_keyT_value类型的变量,因为类中的一些函数使用了一组键和值,而另一些函数使用了一对键和值的可迭代变量。

也就是说,当clean=函数的类型为

t.Callable[t.AsyncIterator[DataPoint]], t.AsyncIterator[t.Tuple[datetime.datetime, float]]

相应的draw=功能属于以下类型

t.Callable[[t.Any, t.Iterable[datetime.datetime], t.Iterable[float], t.Optional[str]], None]

我们需要独立地访问datetimefloat组件类型来构建这两种类型声明。类型变量允许我们告诉 mypy,类型是一个占位符,以后会提供;这里我们需要一个T_key和一个T_value类型的变量。我们还可以使用它们来定义一个名为Cleaned的泛型类型的模式,以及该类型的两个具有特定值的实例。

Cleaned = t.AsyncIterator[t.Tuple[T_key, T_value]]
CLEANED_DT_FLOAT = Cleaned[datetime.datetime, float]
CLEANED_COORD_FLOAT = Cleaned[t.Tuple[float, float], float]

如果你期望有许多不同类型的 cleaned/cleaner 类型,那么这种方法比显式地给每个函数分配完整的类型要清晰一些。

返回这些数据的 cleaner 函数有点复杂,因为 mypy 推断可调用函数中使用泛型类型的能力是有限的。要为可调用类型和类类型创建复杂的别名(相对于数据变量),我们必须使用协议特性。协议是一个类,它定义了底层对象必须拥有的被认为是匹配的属性,非常类似于自定义抽象基类的 subclasshook,但采用声明式风格,用于静态类型而不是运行时类型检查。

我们想定义一个接受数据点的AsyncIterator和其他类型的可调用函数。这里的另一种类型由T_cleaned_co类型变量表示,如下所示:

T_cleaned_co = t.TypeVar("T_cleaned_co", covariant=True, bound=Cleaned)

class CleanerFunc(Protocol[T_cleaned_co]):
    def __call__(self, datapoints: t.AsyncIterator[DataPoint]) -> T_cleaned_co:
        ...

这个CleanerFunc类型可以用来生成与之前的*_CLEANED变量相匹配的*_CLEANER变量。方括号中用于CleanerFunc的类型是这个特定函数提供的Cleaned的变体。

DT_FLOAT_CLEANER = CleanerFunc[CLEANED_DT_FLOAT]
COORD_FLOAT_CLEANER = CleanerFunc[CLEANED_COORD_FLOAT]

TypeVar中的covariant=参数是新增加的,我们用于变量名的_co后缀也是新增加的。以前,我们的类型变量被用来定义函数参数和函数返回值。这些是不变的类型:类型定义必须完全匹配。如果我们声明一个函数期望一个Sensor[float]作为参数,我们不能传递一个Sensor[int]。正常情况下,如果我们要定义一个期望一个float作为参数的函数,传递一个int就可以了。

这是因为我们没有允许 mypy 在Sensor类的组成类型上使用它的兼容性检查逻辑。这个权限是通过可选的covariant=contravariant=参数赋予类型变量的。一个共变类型是正常子类型逻辑适用的类型,因此如果传感器的T_value是共变的,那么期望Sensor[float]的函数可以接受Sensor[int],以期望float的函数可以接受int的相同方式。这对于泛型类来说是有意义的,泛型类的函数向它们被传递的函数提供数据。

一个逆变类型(通常以_contra后缀命名)是一个反向逻辑成立的类型。如果传感器的T_value是逆变的,那么期望Sensor[float]的函数不能接受Sensor[int],但是它们必须接受比float更具体的东西,比如Sensor[complex]。这对于泛型类很有用,这些泛型类的函数从传递给它们的函数中消耗数据。

我们正在定义一个提供数据的协议, 13 ,所以协变类型是最合适的。传感器同时是数据的提供者(sensor.value())和消费者(sensor.format(...)),因此必须不变

Mypy 在检查协议时会检测到适当类型的差异,如果不匹配,就会引发错误。因为我们正在定义一个提供数据的函数,所以我们必须设置covariant=True来防止这个错误出现。

bound=参数指定了可以推断出该变量的最小规格。由于这被指定为CleanedT_cleaned_co只有在可以被推断为与Cleaned[Any, Any]匹配时才有效。CleanerFunc[int]无效,因为int不是Cleaned[Any, Any]的子类型。bound=参数也可用于创建对现有变量类型的引用,在这种情况下,它允许定义遵循某些外部提供的函数签名的类型。

协议和类型变量是强大的特性,可以简化类型,但是如果过度使用,也会使代码看起来混乱。将类型作为变量存储在一个模块中是一个很好的中间点,但是您应该确保所有的类型样板文件都经过了很好的注释,甚至可能放在一个实用程序文件中,以避免给代码带来过多的新贡献者。

有了新的转换代码,我们可以创建一个绘图配置,以华氏度为单位绘制温度图表。清单 10-9 展示了 apd.aggregation 包的最终用户如何创建一个新的Config对象,该对象的行为方式与现有对象相同,但以他们喜欢的温度范围呈现其值。

import yappi
from apd.aggregation.analysis import (interactable_plot_multiple_charts, Config)
from apd.aggregation.analysis import (convert_temperature_system, clean_temperature_fluctuations)
from apd.aggregation.analysis import get_one_sensor_by_deployment

filter_in_db = Config(
    clean=convert_temperature_system(clean_temperature_fluctuations, "degF"),
    title="Ambient temperature",
    ylabel="Degrees F",
    get_data=get_one_sensor_by_deployment("Temperature"),
)
display(interactable_plot_multiple_charts(configs=[filter_in_db])())

Listing 10-9Jupyter cell to generate a single chart showing temperature in degrees F

我们已经通过添加这个函数改变了控制流,所以我们应该再运行一次分析来发现它做了什么改变。我们不希望温度转换花费大量的时间。

..ation\analysis.py:191 datapoint_ok  10818  0.031250  0.031250  0.000003
..on\utils.py:41 convert_temperature  8455   0.078125  6.578125  0.000778

虽然datapoint_ok(...)被调用了 10818 次,但是convert_temperature(...)函数本身被调用了 8455 次。这告诉我们,通过在转换温度之前过滤掉datapoint_ok(...)和清理函数,我们避免了 2363 次调用convert_temperature(...)来获取绘制当前图表时不需要知道的数据。然而,我们所做的调用仍然花费了 6.58 秒,是绘制这个图表的总时间的三倍。这太过分了。

我们可以通过重新实现这个函数来优化它,消除对 pint 的依赖,从而减少相关的开销。如果convert_temperature(...)是一个简单的算术函数,花费的时间会减少到 0.02 秒,代价是牺牲了很多灵活性。这对于两个单位都需要的简单转换来说很好;pint 擅长于事先不知道确切转换的情况。

或者,我们可以缓存convert_temperature(...)函数的结果。简单的缓存可以通过创建一个字典来实现,该字典在以摄氏度输入的值和所选温度系统中的值之间进行映射。清单 10-10 中的实现为迭代器的每次调用建立了一个字典,防止相同的条目被计算多次。

def convert_temperature_system(
    cleaner: DT_FLOAT_CLEANER, temperature_unit: str,
) -> DT_FLOAT_CLEANER:
    async def converter(datapoints: t.AsyncIterator[DataPoint],) -> CLEANED_DT_FLOAT:
        temperatures = {}
        results = cleaner(datapoints)
        async for date, temp_c in results:
            if temp_c in temperatures:
                temp_f = temperatures[temp_c]
            else:
                temp_f = temperatures[temp_c] = convert_temperature(temp_c, "degC", temperature_unit)
            yield date, temp_f

    return converter

Listing 10-10A simple manual cache

一个缓存的效率 14 通常用命中率来衡量。如果我们的数据集是[21.0, 21.0, 21.0, 21.0],那么我们的命中率将是 75%(未命中、命中、命中、命中)。如果是[1, 2, 3, 4],那么命中率会降到零。前面的缓存实现假设了一个合理的命中率,因为它没有努力从缓存中清除未使用的值。缓存总是在额外使用的内存和节省的时间之间进行权衡。它变得有价值的确切临界点取决于存储的数据大小以及您对内存和时间的个人要求。

从缓存中逐出数据的常见策略是 LRU(最近最少使用)缓存。该策略定义了最大缓存大小。如果缓存已满,当要添加新项目时,它将替换最长时间未被访问的项目。

functools模块提供了一个 LRU 缓存作为装饰器的实现,这使得包装我们的函数变得很方便。我们还可以用它来创建现有函数的缓存版本,方法是在 LRU 缓存装饰器中手工包装一个函数。

Caution

如果函数只接受可哈希类型作为参数,可以使用 LRU 缓存。如果一个可变类型(比如没有frozen=True的字典、列表、集合或数据类)被传递给一个包装在 LRU 缓存中的函数,就会引发一个TypeError

如果我们使用原来的基于 pint 的convert_temperature(...)函数,并添加 LRU 缓存装饰器,我们现在就可以对使用缓存所花费的时间进行基准测试。这样做的结果是,对函数的调用次数大大减少了,但是每次调用花费的时间却保持不变。没有缓存的 8455 次调用变成了 67 次调用,对应于 99.2%的命中率,并将提供该特性的时间开销从 217%减少到 1%。

..on\utils.py:40 convert_temperature  67     0.000000  0.031250  0.000466

通过对修饰函数使用cache_info()方法,可以在不运行分析器的情况下检索关于 LRU 缓存效率的附加信息。这在调试复杂系统时很有用,因为您可以检查哪些缓存性能良好,哪些性能不佳。

>>> from apd.aggregation.utils import convert_temperature
>>> convert_temperature.cache_info()
CacheInfo(hits=8455, misses=219, maxsize=128, currsize=128)

图 10-5 以对数标度显示了所有三种方法所用的时间(水平线代表十倍增长,而不是线性增长)。这有助于证明缓存和优化方法有多接近;对于我们的特殊问题,缓存一个非常昂贵的函数会导致与另一个不太灵活的实现相同数量级的性能。

img/481001_1_En_10_Fig5_HTML.png

图 10-5

三种方法的性能总结

重写函数以避免使用 pint 仍然可以提高性能,但是缓存结果可以以小得多的变化提供大致相同的改进,无论是在代码行还是概念上。

和往常一样,这里需要平衡。很可能人们只想要摄氏度或华氏度的温度,所以只提供这两种温度的转换函数可能就足够了。转换本身是简单易懂的,所以引入错误的风险是最小的。更复杂的函数可能不那么容易优化,这使得缓存成为更有吸引力的方法。或者,他们可能处理命中率较低的数据,使得重构更有吸引力。

@lru_cache装饰器的好处不在于缓存的内在效率(它是一个相当简单的缓存实现),而在于它很容易为 Python 函数实现。需要使用缓存的每个人都可以理解用缓存修饰的函数的实现,因为他们可以忽略缓存,而专注于函数体。例如,如果您正在编写一个定制的缓存层,使用像 Redis 这样的系统作为存储而不是字典,那么您应该构建您的集成,这样它就不会用特定于缓存的指令污染修饰代码。

缓存属性

functools 模块中另一个可用的缓存装饰器是@functools.cached_property。这种类型的缓存比 LRU 缓存更受限制,但它适合一种非常常见的用例,这种用例足以保证包含在 Python 标准库中。用@cached_property修饰的函数与用@property修饰的函数行为相同,但是底层函数只被调用一次。

程序第一次读取属性时,它会被底层函数调用的结果透明地替换。 15 只要底层函数的行为是可预测的并且没有副作用, 16 a @cached_property与常规的@property没有区别。像@property一样,这只能作为一个类的属性,并且必须采用除了self之外不接受任何参数的函数形式。

这可以用在apd.sensors包中 DHT 传感器的实现中。这两个传感器的value()方法从 Adafruit 接口包大量委托给 DHT22 类。在下面的方法中,只有一小部分代码与提取值相关;剩下的是设置代码:

    def value(self) -> t.Optional[t.Any]:
        try:
            import adafruit_dht
            import board

            # Force using legacy interface
            adafruit_dht._USE_PULSEIO = False

            sensor_type = getattr(adafruit_dht, self.board)
            pin = getattr(board, self.pin)
        except (ImportError, NotImplementedError, AttributeError):
            # No DHT library results in an ImportError.
            # Running on an unknown platform results in a
            # NotImplementedError when getting the pin
            return None
        try:
            return ureg.Quantity(sensor_type(pin).temperature, ureg.celsius)
        except (RuntimeError, AttributeError):
            return None

我们可以对此进行更改,将创建传感器接口的公共代码分解到一个基类中,该基类包含一个传感器属性。温度和湿度传感器可以丢弃所有的接口代码,转而依赖于self.sensor的存在。

class DHTSensor:

    def __init__(self) -> None:
        self.board = os.environ.get("APD_SENSORS_TEMPERATURE_BOARD", "DHT22")
        self.pin = os.environ.get("APD_SENSORS_TEMPERATURE_PIN", "D20")

    @property
    def sensor(self) -> t.Any:
        try:
            import adafruit_dht
            import board

            # Force using legacy interface
            adafruit_dht._USE_PULSEIO = False

            sensor_type = getattr(adafruit_dht, self.board)
            pin = getattr(board, self.pin)
            return sensor_type(pin)
        except (ImportError, NotImplementedError, AttributeError):
            # No DHT library results in an ImportError.
            # Running on an unknown platform results in a
            # NotImplementedError when getting the pin
            return None

class Temperature(Sensor[t.Optional[t.Any]], DHTSensor):
    name = "Temperature"
    title = "Ambient Temperature"

    def value(self) -> t.Optional[t.Any]:
        try:
            return ureg.Quantity(self.sensor.temperature, ureg.celsius)
        except RuntimeError:
            return None

    ...

DHTSensor类中的@property行可以用@cached_property替换,以便在调用之间缓存传感器对象。在这里添加一个缓存不会影响我们现有代码的性能,因为我们不保存对传感器的长期引用并重复查询它们的值,但是传感器代码的任何第三方用户可能会发现这是一个优势。

Exercise 10-1: Optimizing Clean_Watthours_To_Watts

在本章的开始,我们确定了最需要优化的clean_watthours_to_watts(...)函数。在我的测试数据集上,它增加了几秒钟的执行时间。

在本章的代码中,有一些扩展的测试来测量这个函数的行为和它的性能。验证性能的测试很棘手,因为它们通常是最慢的测试,所以我不建议理所当然地添加它们。如果您添加了它们,请确保将它们标记出来,这样您就可以在正常的测试运行中跳过它们。

修改clean_watthours_to_watts(...)功能,使测试通过。您需要实现大约 16 倍的加速才能通过测试。本章讨论的策略足以实现大约 100 倍的加速。

摘要

从这一章学到的最重要的一课是,无论你对你的问题空间理解得多好,你都应该衡量你的性能改进,而不仅仅是假设它们是改进。通常有一系列选项可供您用来提高性能,其中一些选项比其他选项更具性能。想到一个聪明的方法可以让事情变得更快,却发现它实际上没有帮助,这可能会令人失望,但知道这一点还是更好。

最快的选择可能需要比合理假设可用的更多的 RAM,或者可能需要删除某些功能。你必须仔细考虑这些,因为不能满足用户需求的快速代码是没有用的。

functools 中的两个缓存函数是日常编程中需要注意的。将@functools.lru_cache用于带参数的函数,将@functools.cached_property用于多处需要的对象的计算属性。

如果你的输入提示开始看起来很麻烦,那么你应该整理它们。您可以为变量分配类型,并用类似于TypedDictProtocol的类来表示它们,尤其是当您需要定义更复杂的结构化类型时。请记住,这些不是用于运行时类型检查的,并考虑将它们移到类型实用程序模块中,以获得更清晰的代码。这种重组已经在本章的示例代码中得到应用。

额外资源

以下链接更深入地介绍了本节涵盖的主题:

Footnotes 1

如果您使用的是除 CPython 之外的 Python 实现(比如 PyPy 或 Jython),这个优化的分析器将不可用,您需要使用参考实现。

  2

这个函数被调用了两次,因为它是作为交互式小部件的一部分使用的。interactable_plot_multiple_charts(...)接受设置参数并返回一个可以挂接到小部件的函数。我们在这里调用它两次,因为我们想设置这个函数,并且在没有特殊参数的情况下调用它一次,而不是将它插入到交互式小部件中。

  3

将循环和包装变量作为显式局部变量提供,还可以确保 Python 知道如何在这些变量上创建闭包,并使它们可用于分析表达式。如果我们传递了locals=locals(),我们就不会看到这些变量被传递下来,除非我们使用nonlocal loopnonlocal wrapped语句给 Python 一个提示,告诉它我们需要来自包含作用域的这些变量。

  4

timeit profiler(将在下一节中介绍)可用于演示这种关系:

>>> def func(a, b, c, d, e, f, g, h, i, j, k):

...   return a+b+c+d+e+f+g+h+i+j+k

...

>>> timeit.timeit(“func(**vals)”, “vals={‘a’:1, ‘b’:1, ‘c’:1, ‘d’:1, ‘e’:1, ‘f’:1, ‘g’:1, ‘h’:1, ‘i’:1, ‘j’:1, ‘k’:1}”, globals={‘func’:func})

0.7101785999999777

>>> timeit.timeit(“func(a=1,b=1,c=1,d=1,e=1,f=1,g=1,h=1,i=1,j=1,k=1)”, globals={‘func’:func})

0.6051479999999998

>>> timeit.timeit(“a(1,1,1,1,1,1,1,1,1,1,1)”, globals={‘func’:func})

0.479350299999993

这些方法之间的差别对于琐碎的泛函来说是微不足道的,对于更复杂的函数来说是无关紧要的。你应该继续使用使你的代码最清晰的那个;在我们的示例中,我们只是将此作为提高性能的最后手段。

  5

一个需要 1 毫秒执行的函数转换成时间,在默认参数下需要 15 分钟以上。

  6

https://github.com/rkern/line_profiler

  7

https://github.com/sumerc/yappi

  8

我见过真实世界中的 Python 代码,在 OSX 主机上的 Linux VM 中比在主机上快一个数量级,即使运行相同版本的 Python 和所有依赖项。Python 版本、操作系统版本和剖析器都会产生很大的不同,所以无论何时进行基准测试,都应该建立一个基线;不要依赖于你前几天创造的。

  9

https://docs.python.org/3/library/tracemalloc.html#pretty-top

  10

其他商业分析工具也是可用的。

  11

具体来说,这就是多项式复杂度,有时写成 O(n c )。花费的时间是执行循环体的时间,乘以每个循环的长度。

  12

截图来自 Windows 端口,QCachegrind。由于 Valgrind 是一个 Linux 工具,如果你使用 Linux,你会发现更多的实用程序。

  13

虽然它消耗 DataPoint 对象,但那是固定类型。重要的只是 TypeVar 对象的使用方式。

  14

也就是说,使用一个缓存,而不是一种缓存。只有当我们知道缓存请求的信息时,我们才能谈论缓存的效率。

  15

这种替换是线程安全的,因此即使多个线程试图读取该属性,也不会对给定对象多次调用该函数。

  16

函数式编程环境中的副作用是函数除了返回输出变量之外所做的事情。如果一个函数操纵可变数据,比如改变一个全局变量,那么返回一个缓存的返回值也会阻止这些改变在以后的调用中发生。

 

十一、容错

从乐观的角度来看,开发人员编写代码是很自然的。我们编写不工作的代码,然后我们反复调整它,直到它给出我们想要的结果。希望我们也编写测试,允许我们验证代码在未来仍然工作,并且测试检查我们正在处理我们已经正确意识到的任何边缘情况。我们永远无法编写测试来涵盖我们还没有想到的问题,所以遵守代码如何划分和处理遇到的小问题的规则是我们编写行为符合我们预期的软件的最佳策略。

错误处理

从一开始,我们就在本书附带的代码中发现了异常。有些例外是我们知道可以由我们正在使用的代码引发的(例如,DHT 接口代码在无法连接到传感器时引发RuntimeError)。其他的是由于对象的不正确使用而导致的异常(例如,如果我们试图从逆变器获得一条输出中不存在的数据,则在太阳能发电传感器中使用KeyError)。

我们还提出了NotImplementedError作为我们Sensor基类的一部分,以表达方法必须被传感器开发人员和各种RuntimeErrorValueError覆盖,作为命令行接口错误处理的一部分。

编程语言通常遵循“三思而后行”或“请求原谅比请求允许更容易”的错误处理哲学。三思而后行的哲学意味着你应该使用条件句来确定某事是否可能,并留下例外来代表意料之外的情况。请求原谅的理念意味着你应该编写代码来预测最常见的情况,并为你所知道的边缘情况补充异常处理程序。

Python 很大程度上属于后一种阵营;在许多情况下,在编写 Python 代码时依靠异常处理程序进行控制流被认为是正确的风格。

从容器中获取项目

我们用 Python 编写的最常见的表达式之一是从容器类型中获取一个条目,比如从字典中获取一个值或者从列表中获取一个条目。这两个都使用variable[other]结构。如果other没有指向variable中的有效项,那么就会引发一个异常。否则,返回关联的值。

尽管这些操作使用相同的方括号结构,但是底层数据类型和变量的含义却大不相同。当我们编写一个使用这个特性的函数时,我们需要意识到可能的结果是多么的不同。

你有时会看到字典被称为映射,但是这些术语是不可互换的。字典是映射的一个例子,映射是将键映射到值并提供某些方法的任何对象的名称。如果variable是一个映射(比如字典),那么other应该是一个可散列的类型:一个定义了hash(other)的类型。

另一方面,如果variable是一个列表或元组,那么对于序列的条目访问是被使用的。在这种情况下,other应该是一个整数,表示我们正在寻找的容器中的索引。我们不能使用方括号语法从生成器中获取一个项目,但可以将其用于列表的原因是因为生成器不是一个序列。所有序列(事实上,所有映射)都是可迭代的,但不是所有可迭代的都是序列。

抽象基类

映射、序列和散列的定义是collections.abc模块中对应的MappingSequenceHashable类。MappingSequence都是Collection的子类。如果一个对象实现了__len__()__iter__()__contains__(...)魔法方法,那么它就是一个Collection。也就是说,如果一个对象有一个定义的长度,可以被迭代,并且可以被查询以查看一个值是否在对象的迭代结果中,那么它就是一个集合。

虽然collections.abc.Sizedcollections.abc.Iterablecollections.abc.Container、 1collections.abc.Collection对象都是提供子类钩子的抽象基类(意味着任何实现所需方法的对象都被认为是抽象基类的子类),MappingSequence实现不会被自动检测。映射或序列的实现必须根据适当的基类进行注册。

映射和序列都实现了一个__getitem__(...)方法,但是具有非常不同的含义。一个Sequence是一个对象,其中variable[0]返回底层集合中的第一项,而一个Mapping是一个对象,其中variable[0]返回附加的值,即键0

当出错时,__getitem__(...)方法的两种不同语义会引发不同的异常。当代码试图检索序列末尾之外的项目时,序列版本会引发一个IndexError(例如空序列上的variable[0])。相反,当代码在不包含与该键关联的值的映射上使用项目访问时,会引发KeyError

当相应的键不是适当的类型时,调用任一类型的__getitem__(...)的代码都会引发一个TypeError。例如,一个序列上的variable[1.2]或一个映射上的variable[{}]都会引发TypeError。当被索引的变量没有__getitem__(...)方法时,Python 解释器也会抛出一个TypeError,例如None[0]

您应该预料到行variable[other]可能会引发这三种不同异常中的任何一种。通过更多地了解变量的底层数据类型,我们可以排除一个TypeErrorIndexErrorKeyError,但是只有通过更多地了解实际数据,我们才能确保不会引发异常。

对于许多简单的任务(例如表 11-1 中的函数,该函数包装__getitem__(...)以在所请求的项目不可用的情况下返回默认值 2 ),“宽恕”风格明显更加直接。它本身并不简单。完全有可能通过嵌套许多try / except块来编写控制流混乱的代码,但是这通常会简化代码。也许更重要的是,这是人们期望从 Python 程序中得到的风格。

表 11-1

两种风格中带有默认函数的 get 的详细实现

| *三思而后行*`from collections.abc import Sequence, Mapping``from collections.abc import Hashable``def get_item(variable, key, default=None):``if isinstance(variable, Sequence):``if isinstance(key, int):``if (0 <= key <  len(variable)):``return variable[key]``else:``# key is too big``return default``else:``# Key isn't an int``return default``elif isinstance(variable, Mapping):``if isinstance(key, Hashable):``if key in variable:``return variable[key]``else:``# key is not known``return default``else:``# Key isn't hashable``return default``else:``# variable isn't a known type``return default` | *请求原谅*`def get_item(variable, key, default=None):``try:``return variable[key]``except TypeError:``# variable has no get item # method``# or key isn't a valid type``return default``except KeyError:``# Variable is a mapping but``# doesn't contain key``return default``except IndexError:``# Variable is a sequence``# shorter than key``return default` |

问题在于决定在哪里捕捉异常,以及在哪里让异常冒出来调用代码。前面提到的两个实现的关键区别在于,左侧有两条成功代码路径和四条失败代码路径,而右侧有一条成功代码路径和三条失败代码路径。如果我们想为特定的条件定制行为,左边比右边容易,但只是因为左边的控制流比右边的代码更复杂。

这种复杂性在这个函数的性能上也很明显,如图 11-1;虽然某些操作的性能与任一实现大致相同,但异常处理程序路线有时要快得多。根据我的经验,在使用请求原谅方法时,通常更容易避免过于聪明的代码。

img/481001_1_En_11_Fig1_HTML.jpg

图 11-1

每种不同情况下两种实现的性能图表

想象一下,如果variable=参数的值是一个不支持项目访问的对象,我们希望我们的get_item(...)函数引发一个TypeError,但是我们仍然希望未知的关键代码路径导致返回一个默认值。这对应的是左边自定义底部条件,而右边TypeError的两个来源只有一个。我们可以在TypeError异常处理程序中添加一个条件来确定哪个代码路径导致了问题。为了补偿复杂性的增加,我们还可以将KeyErrorIndexError异常处理程序合并到一个块中,因为它们代表相同的行为,如清单 11-1 所示。

def get_item(variable, key, default=None):
    try:
        return variable[key]
    except (KeyError, IndexError):
        # Key is invalid for variable, the error raised depends on the type # of variable
        return default
    except TypeError:
        if hasattr(variable, "__getitem__"):
            return default
        else:
            raise

Listing 11-1A get with default function that raises on noncontainer arguments

Tip

在异常处理程序中,您可以使用raise而不用显式的异常来重新引发当前正在处理的相同错误。

异常类型

例外是具有自己的类层次结构的类。所有的异常都继承自BaseException,但是只有那些继承自Exception的异常才是开发者想要使用的。 3 当我们捕捉异常时,我们需要指定我们想要捕捉哪种类型的异常。没有指定要捕获的异常类型的except块被称为 bare except 并捕获所有异常,甚至是内部异常。由于KeyboardInterrupt是这些内部异常之一,一个裸露的try / except会抑制用户使用<CTRL+c>来停止程序的能力。

Tip

捕捉多种异常类型总比捕捉一个过于宽泛的超类好。您可以在一个块中指定许多异常类型,或者使用多个except块来实现这一点。

异常的类层次相对较浅,但是一些超类值得记住。最有用的是LookupError,它是KeyErrorIndexError的超类。LookupError特指请求的键不存在的情况,所以这个词不太宽泛。这允许我们通过用except LookupError替换except (KeyError, IndexError)来稍微简化我们的get_item(...)功能。

类型错误和值错误

我们经常不得不提出自己的异常,而不仅仅是从调用堆栈的底层重新提出现有的异常。在这种情况下,我们需要确保选择适当的异常类型和有用的消息。如果不清楚哪个异常类最适合,那么TypeErrorValueError是默认的好的异常类型。

当传递给函数的值是错误的类型时,TypeError是合适的,当传递的值是正确的类型但在某些方面不合适时,ValueError是合适的,除了LookupError s 所涵盖的情况

四个异常TypeErrorValueErrorKeyErrorIndexError一起代表了您将遇到的大多数逻辑类型的异常。如果您需要在自己的代码中引发一个异常,那么很有可能其中一个非常合适。

RuntimeError 和 SystemExit

还有针对非特定问题行为的异常类,其中伴随的消息是对问题的描述。RuntimeError是一个万不得已的异常类,用来处理不匹配任何其他类别的错误,但是可能需要通过调用函数来捕获。SystemExitsys.exit(...)函数调用在内部引发,表示程序应该结束。 4 在这两种情况下,给出的论点都是至关重要的,因为这是问题所在的唯一信息。

一般来说,except SystemExit:块仅适用于定制如何向最终用户显示最终错误消息。代码捕获RuntimeError并继续正常操作可能是有意义的,但这很大程度上取决于底层代码的构造方式和RuntimeError的含义。创建一个新的异常类通常比依赖RuntimeError要好。

断言错误

当一条assert语句失败时,解释器自动引发AssertionError s。在编写测试时,你经常会遇到这些问题,因为大多数assert语句都是在测试中编写的。将assert语句添加到任意的 Python 代码中是完全可能的,但是开发人员很少这样做。

Python 并不保证它会为任何失败的assert语句引发一个AssertionError,所以你不能依靠一个 assert 语句来进行正常的错误处理。在非测试代码中使用assert语句的一个可能的用途是添加断言来覆盖你对必须总是为真的事情的假设。例如,您可以使用assert行来验证函数参数之间的某种关系,这种关系不能用静态类型声明来表达,或者参数列表排序正确。同样,这并不能代替函数中正确的错误处理,但是拥有assert可以帮助跟踪不明显的错误。

使用assert语句的好处在于它们并不总是引发错误。如果使用python -OPYTHONOPTIMIZE=1环境变量运行程序,那么 assert 语句将被忽略,除了在调试会话期间,可能会禁用代价高昂的健全性检查。

assert语句添加到代码中来实现对程序的正确运行必要的检查是不正确的,因为不能保证它们会被运行。这种检查应该用一个 if 语句保护一个raise来实现。你应该只对你认为应该总是正确的检查使用 assert,但是你想知道你是否错了。

自定义例外

每当您使用新的第三方库时,通常会遇到各种自定义异常。例如,Pint 为 pint 数据库中没有列出的单位提供了UndefinedUnitError,为不可能的转换提供了DimensionalityErrorUndefinedUnitErrorAttributeError的一种,与访问单元的ureg.watt方法相匹配。DimensionalityErrorTypeError的子类,意味着库的开发人员希望开发人员将不同单位的数量视为不同的类型。

Click 在处理与我们的代码无关的命令行选项解析时有一系列例外;请求在requests.exception模块中提供具体的异常(如ConnectTimeoutReadTimeoutInvalidSchemaInvalidURL等)。)来处理特定的错误情况,或者通过父类(如用于所有超时错误的requests.exception.Timeout,甚至是IOError,因为它是所有特定于请求的异常的基类。

并不总是清楚第三方代码会引发什么类型的异常;开发人员的意图以及他们如何看待自己的代码是一个重要的影响因素。知道应该从第三方代码中捕捉哪些异常的唯一方法是阅读文档 5 ,并相信它是准确的。

创建新的异常类型

当您编写定义新异常类型的库代码时,您应该站在未来用户的角度考虑问题。确保有足够的多样性来准确地传达发生了哪些错误,但是要以这样的方式安排它们,使它们形成一个内聚的整体,既包括默认的异常类型,也包括彼此。像所有的 API 设计一样,最重要的成功标准是你的最终用户觉得它很直观。

我们的apd.sensors包使用None作为无法确定传感器值时的信号值。传感器可能由于一系列原因而无法返回值:可能是检索值时出现暂时错误(如太阳能输出传感器中的连接错误)或永久性错误(如没有电池充电电路的机器上的交流状态传感器)。

未能返回数据点的传感器不是任何一种类型的LookupError:代码找到了传感器,它只是不能正常工作。它不是一个TypeErrorValueError,因为没有参数是错误的类型或不可接受的值。内置异常类型的最接近的匹配是RuntimeError,我们最后使用的异常类型。为了避免直接引发RuntimeError,我们可以定义一些异常子类并重新编写代码来引发这些异常,而不是将None作为一个标记对象返回。

清单 11-2 展示了我们可以添加到apd.sensors包中的新异常,包括一个用于所有apd.sensors异常的基类,一个更具体的用于数据收集问题的基类,以及两个用于数据收集问题类型的子类。这些类别允许代码的用户识别他们的传感器代码中的特定问题,或者寻找与传感器相关的故障的广泛类别。

class APDSensorsError(Exception):
    """An exception base class for all exceptions raised by the
    sensor data collection system."""

class DataCollectionError(APDSensorsError, RuntimeError):
    """An error that represents the inability of a Sensor instance
    to retrieve a value"""

class IntermittentSensorFailureError(DataCollectionError):
    """A DataCollectionError that is expected to resolve itself
    in short order"""

class PersistentSensorFailureError(DataCollectionError):
    """A DataCollectionError that is unlikely to resolve itself
    if retried."""

Listing 11-2New exceptions for apd.sensors, stored as exceptions.py

这四个异常允许最终用户直观地捕捉错误。用捕获RuntimeErrorAPDSensorsErrorDataCollectionError中任何一个的try / except来包装sensor.value()都将捕获失败。有一个IntermittentSensorFailureError的事实也允许下游代码识别该特定情况并重试读取,如清单 11-3 中的示例函数。

from apd.sensors.base import Sensor, T_value
from apd.sensors.exceptions import IntermittentSensorFailureError

def get_value_with_retries(sensor: Sensor[T_value], retries: int=3) -> T_value:
    for i in range(retries):
        try:
            return sensor.value()
        except IntermittentSensorFailureError as err:
            if i == (retries - 1):
                # This is the last retry, reraise the underlying error
                raise
            else:
                continue
    # It shouldn't be

possible to get here, but it's better to
    # fall through with an appropriate exception rather than a
    # None
    raise IntermittentSensorFailureError(f"Could not find a value " f"after {retries} retries")

Listing 11-3Example function to retry a sensor read if there’s an intermittent problem

然后,我们可以使用这些误差来代替各种传感器中的返回信号None。这允许我们去除各种传感器类型中的t.Optional[...]结构。改变这种类型是否意味着之前 JSON 编码的传感器值不再有效,因为None不再是该传感器的有效传感器值。任何调用sensor.from_json_compatible(...)sensor.format(...)的代码都可能引发异常。当编写存储传感器值并在以后恢复它们的代码时,确保捕获任何错误并丢弃数据点是很重要的。如果我们希望确保未来变化的兼容性,我们可以编写迁移函数,并将版本号与传感器数据一起存储。

附加元数据

我们已经在 CLI 界面中引发了RuntimeError来传达错误消息。此代码路径是自定义异常的另一个好用途;我们可以在清单 11-4 中创建一个异常,它不是一个通常被隐藏的类型 6 并存储额外的元数据,比如所需的退出状态代码。

@dataclasses.dataclass(frozen=True)
class UserFacingCLIError(APDSensorsError, SystemExit):
    """A fatal error for the CLI"""
    message: str
    return_code: int

    def __str__(self):
        return f"[{self.return_code}] {self.message}"

Listing 11-4A new exception type with additional metadata

通常用一个参数实例化一个异常:一个人类可读的异常解释。这种方法不是异常的唯一格式;例如,OSError异常类型有数字错误标识符的参数以及人类可读的字符串。

Note

虽然大多数内置异常接受任意数量的参数,但我建议不要用它来存储关于异常的元数据。对于如何解释元组的参数,具有良好定义的参数的自定义异常类型总是比约定更清晰。

异常类型是 Python 类,因此我们可以使用任何标准技术来存储额外的信息作为异常的一部分。我会推荐一个数据类,就像我们处理任何主要存储数据的 Python 类一样。然后,我们可以在异常处理期间提取这些元数据,从而允许我们将失败的返回代码和可读消息合并到一个对象中。在这里,我们显式地添加了两项元数据。需要自定义的UserFacingCLIError.__str__()方法,因为将Exception转换为字符串必须只返回面向用户的错误表示,而 dataclasses 的默认实现显示所有参数的元组。

然后,我们可以使用这个异常向用户显示一条消息,并向操作系统返回正确的退出代码。

if develop:
    try:
        sensors = [get_sensor_by_path(develop)]
    except UserFacingCLIError as error:
        click.secho(error.message, fg="red", bold=True)
        sys.exit(error.return_code)

涉及多个异常的回溯

当我们从 Python 代码中抛出一个我们随后没有捕捉到的异常时,解释器打印一个回溯。回溯为最终用户提供了关于引发了什么异常以及代码的哪个部分触发了异常的信息。以下是通过在 IP 地址传感器中故意引入一个错误而获得的回溯示例:

Traceback (most recent call last):
  File "...\Scripts\sensors-script.py", line 11, in <module>
    load_entry_point('apd.sensors', 'console_scripts', 'sensors')()
  File "...\site-packages\click\core.py", line 764, in __call__
    return self.main(*args, **kwargs)
  File "...\site-packages\click\core.py", line 717, in main
    rv = self.invoke(ctx)
  File "...\site-packages\click\core.py", line 956, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "...\site-packages\click\core.py", line 555, in invoke
    return callback(*args, **kwargs)
  File "...\src\apd\sensors\cli.py", line 72, in show_sensors
    click.echo(str(sensor))
  File "...\src\apd\sensors\base.py", line 31, in __str__
    return self.format(self.value())
  File "...\src\apd\sensors\sensors.py", line 41, in value
    addresses = socket.getaddrinfo("hostname", None)
  File "...\Lib\socket.py", line 748, in getaddrinfo
    for res in _socket.getaddrinfo(host, port, family, type, proto, flags):
socket.gaierror: [Errno 11001] getaddrinfo failed

回溯中的每一对File和代码行代表调用堆栈中的一个函数。最下面的一行是引发异常的行,上面的每一行都提供了错误发生在软件的哪个部分的上下文。在这种情况下,标准库的socket.py中出现了异常,尽管原因并不明显。如果我们回到上一层,我们会看到我们控制的代码对标准库的调用。如果您假设您正在使用的库没有错误(这通常是一个公平的假设),那么指向我们控制的代码的最低堆栈条目很可能是罪魁祸首。并不总是这一行,有时是堆栈的较高部分负责(例如,由于变量设置不正确),但这通常是开始调试的最佳位置。

在这种情况下,我们可以从堆栈跟踪中看到,我们传递了一个包含“hostname”的字符串文字,但是getaddrinfo(...)的第一个参数应该是一个实际的主机名。在这种情况下,错误是由于意外地将变量名用引号括起来而不是传递变量引起的,这可能是 linter 捕捉到的。

异常通常是 Python 开发人员首先看到的事情之一(无论是在他们的职业生涯中还是在解决某个特定问题时),所以回溯对于大多数开发人员来说是相当熟悉的;然而,在回溯上有一些不常见但非常有用的小变化。

except 或 finally 块中的异常

第一种替代形式表示在处理一个异常的同时引发另一个异常。通常,异常处理程序中唯一的 raise 语句是一个空的raise来重新引发被捕获的异常,通常是在自省系统状态以确定异常是否应该被抑制之后。但是,自检代码本身可能包含导致未处理异常的错误。还有一种可能是,finally:块中的代码可能会引发一个异常。

当我们通过"hostname"而不是hostname时导致的错误暴露了一个我们目前没有处理的错误案例。如果我们传递一个无法通过 DNS 系统解析的主机名,那么就会引发一个异常。如果我们想以不同于这里提出的其他潜在的OSError的方式处理这个案例,我们需要在处理程序中反思这个异常。

OSError s 提供了一个errno=属性来获得一个数字代码来识别特定的问题,而不是为每个可能的错误创建子类。在捕捉异常时,如果我们错误地检查了一个err_no=属性而不是errno=,那么就会引发一个AttributeError。原始的OSError异常和AttributeError异常都是传递给最终用户的有用信息,所以提供了两个回溯。

不正确的条件代码如下所示:

41\.        try:
42\.            addresses = socket.getaddrinfo("hostname", None)
43\.        except OSError as err:
44\.            if err.err_no == 11001:
45\.                raise

结果是显示了两个堆叠的异常,如下所示:

Traceback (most recent call last):
  File "...\src\apd\sensors\sensors.py", line 42, in value
    addresses = socket.getaddrinfo("hostname", None)
  File "...\Lib\socket.py", line 748, in getaddrinfo
    for res in _socket.getaddrinfo(host, port, family, type, proto, flags):
socket.gaierror: [Errno 11001] getaddrinfo failed

During handling of the preceding

exception, another exception occurred:

Traceback (most recent call last):
  File "...\Scripts\sensors-script.py", line 11, in <module>
    load_entry_point('apd.sensors', 'console_scripts', 'sensors')()
  File "...\site-packages\click\core.py", line 764, in __call__
    return self.main(*args, **kwargs)
  File "...\site-packages\click\core.py", line 717, in main
    rv = self.invoke(ctx)
  File "...\site-packages\click\core.py", line 956, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "...\site-packages\click\core.py", line 555, in invoke
    return callback(*args, **kwargs)
  File "...\src\apd\sensors\cli.py", line 72, in show_sensors
    click.echo(str(sensor))
  File "...\src\apd\sensors\base.py", line 31, in __str__
    return self.format(self.value())
  File "...\src\apd\sensors\sensors.py", line 44, in value
    if err.err_no == 11001:
AttributeError: 'gaierror' object has no attribute 'err_no'

要显示的第一个异常是第一个发生的异常:触发第二个异常时我们正在处理的较低级别的异常。回溯要短得多,因为省略了第二个异常常见的所有回溯行。第一个回溯中上下文行的最顶端(sensors.py,第 42 行)指向一个try / except结构的 try 块。第二个回溯中必须有一行指向与try对应的except块中的一行。这种情况下是sensors.py,44 线。该行以上的所有行也作为上下文应用于第一次回溯。

第一个和第二个回溯用一行“During handling of the above exception, another exception occurred:”隔开。这表明第二个异常发生在包含触发第一个异常的代码的try块中。解释器以与任何正常异常回溯相同的格式打印第二个异常的完整回溯。

作为这种格式的一部分,可以显示任意数量的回溯,尽管很少会超过两个。这仅仅是因为在 except 或 finally 块中最小化代码量被认为是一种好的风格,所以看到更多代码也不是什么新鲜事。

从...升起

有时我们希望用另一个异常替换我们已经捕获的异常,例如用一个PersistentSensorFailureError替换我们的温度传感器中的adafruit_dhtImportError,表明传感器不能提供一个值,并且它不希望在短时间内改变。当我们为一个库定义了新的异常类型时,这尤其有用,因为它让我们简化了函数可能引发的异常。

如果我们编写一个直接引发新的PersistentSensorFailureErrortry / except构造,那么任何回溯都会将两者分开,说明我们的异常是在处理导入错误时引发的,正如我们前面看到的。这不是对情况的准确描述,因为从用户的角度来看,我们并没有真正地处理异常。Python 在这里提供了一个raise ... from ...结构,将一个异常标记为另一个异常的替代。

我们应该从 DHT sensor 基类中更新 sensor 属性来使用这种方法,如清单 11-5 所示。

import os
import typing as t

from .exceptions import PersistentSensorFailureError

class DHTSensor:
    def __init__(self) -> None:
        self.board = os.environ.get("APD_SENSORS_TEMPERATURE_BOARD", "DHT22")
        self.pin = os.environ.get("APD_SENSORS_TEMPERATURE_PIN", "D20")

    @property
    def sensor(self) -> t.Any:
        try:
            import adafruit_dht
            import board
            sensor_type = getattr(adafruit_dht, self.board)
            pin = getattr(board, self.pin)
            return sensor_type(pin)
        except (ImportError, NotImplementedError, AttributeError) as err:
            # No DHT library results in an ImportError.
            # Running on an unknown platform results in a
            # NotImplementedError when getting the pin.
            # An unknown sensor type causes an AttributeError
            raise PersistentSensorFailureError("Unable to initialise sensor interface") from err

Listing 11-5New version of DHT base class

这导致了与没有from err子句时完全相同的输出格式,但是使用了不同的分隔线。不是说在处理第一个异常时发生了第二个异常,而是用“The above exception was the direct cause of the following exception:”分隔两个回溯。

作为前一个例子的特例,使用raise PersistentSensorFailureError("Unable to initialise sensor interface") from None会导致原来的ImportError被完全隐藏。在这种情况下,只有我们的异常会显示给最终用户,并且它会在回溯行中包含完整的上下文。

异常处理测试

我们有一些涉及 CLI 测试异常的测试。具体来说,我们尝试用各种无效的传感器路径调用get_sensor_by_path(...)函数,并断言RuntimeError被引发。Pytest 的raises(...)上下文管理器用于断言我们期望代码块引发某个异常。它有两个参数:异常的类型和一个可选的match=参数,用于在错误的字符串表示上定义一个正则表达式过滤器。

with pytest.raises(RuntimeError, match="Could not import module"):
    subject("apd.nonsense.sensor:FakeSensor")

上下文管理器捕获RuntimeError并检查字符串表示是否匹配match=参数。 7 如果引发了任何其他异常,包括与字符串不匹配的不同的RuntimeError,则上下文管理器会正常地重新引发它们。如果到with pytest.raises(...):块结束时还没有出现匹配的异常,那么上下文管理器将出现一个AssertionError,这意味着测试失败。

这种方法让我们可以测试代码是否会引发我们期望的异常,因此我们可以确信我们的函数在我们知道数据是坏的情况下会引发异常。这只是成功的一半;测试中异常的另一面是在可能引发异常的地方注入异常,并测试调用代码的行为是否正确。例如,我们可能想要测试引发IntermittentSensorFailureError(...)的传感器不会导致整个数据收集运行失败。

新行为

我们已经决定传感器的value()函数应该返回泛型Sensor[type]声明中指定类型的对象,或者应该引发DataCollectionError。我们还没有定义如果传感器出现故障,CLI 或 API 应该做什么。在我们知道我们想要什么行为之前,测试异常行为没有什么意义。

我们将从 CLI 开始。当出现错误时,我希望在命令行界面中显示错误字符串,并继续其余的传感器查找。如果有一个可选的标志来显示整个异常回溯,帮助开发人员准确地调试传感器不工作的原因,这也是非常有用的。实现这个的代码在清单 11-6 中。

@click.command(help="Displays the values of the sensors")
@click.option(
    "--develop", required=False, metavar="path", help="Load a sensor by Python path"
)
@click.option(
    "--verbose", is_flag=True, help="Show additional info"
)
def show_sensors(develop: str, verbose: bool) -> int:
    sensors: t.Iterable[Sensor[t.Any]]
    if develop:
        try:
            sensors = [get_sensor_by_path(develop)]
        except UserFacingCLIError as error:
            if verbose:
                tb = traceback.format_exception(type(error), error, error.__traceback__)
                click.echo("".join(tb))
            click.secho(error.message, fg="red", bold=True)
            return error.return_code
    else:
        sensors = get_sensors()
    for sensor in sensors:
        click.secho(sensor.title, bold=True)
        try:
            click.echo(str(sensor))
        except DataCollectionError as error

:
            if verbose:
                tb = traceback.format_exception(type(error), error, error.__traceback__)
                click.echo("".join(tb))
                continue
            click.echo(error)
        click.echo("")
    return 0

Listing 11-6Updated command-line entrypoint with exception handling

Note

我们编写的格式化整个异常的代码相当笨拙。从 Python 1 时代开始,traceback.format_exception(...)函数就一直保持着它的特征, 8 尽管增加了一些东西。三个参数是必需的,但是它们都可以从异常对象本身提取。可以用 None 替换 traceback 对象,以指示只应该格式化异常信息,而不是整个 traceback。

我们还应该修改 API 的行为。为了保持向后兼容性,我们应该让 API 用一个None代替现有 API 版本中的任何DataCollectionError。有可能(尽管不太可能)一些用户会编写代码,通过在 API 响应中寻找None值来监控错误发生的频率。展望未来,我们希望创建一个新版本的 API 来智能地处理错误,这样 API 用户就可以获得关于失败的有用信息。

为了测试这个新的行为,我们需要创建一个测试子类(清单 11-7 ),它引发特定的异常,这样我们就可以验证周围的代码行为是否正确。这让我们能够在测试中可靠地触发传感器错误。

from apd.sensors.base import JSONSensor
from apd.sensors.exceptions import IntermittentSensorFailureError

class FailingSensor(JSONSensor[bool]):

    title = "Sensor which fails"
    name = "FailingSensor"

    def __init__(self, n: int=3, exception_type: Exception=IntermittentSensorFailureError):
        self.n = n
        self.exception_type = exception_type

    def value(self) -> bool:
        self.n -= 1
        if self.n:
            raise self.exception_type(f"Failing {self.n} more times")
        else

:
            return True

    @classmethod
    def format(cls, value: bool) -> str:
        raise "Yes" if value else "No"

Listing 11-7Definition of FailingSensor test sensor

在清单 11-8 中,我们将测试 v1.0 API 服务器,但是模拟出get_sensors(...)方法来返回FailingSensorPythonVersion传感器。

    @pytest.mark.functional
    def test_erroring_sensor_shows_None(self, api_server, api_key):
        from .test_utils import FailingSensor

        with mock.patch("apd.sensors.cli.get_sensors") as get_sensors:
            # Ensure the failing sensor is first, to test that subsequent
            # sensors are still processed
            get_sensors.return_value = [FailingSensor(10), PythonVersion()]
            value = api_server.get("/sensors/", headers={"X-API-Key": api_key}).json
        assert value['Sensor which fails'] == None
        assert "Python Version" in value.keys()

Listing 11-8Test to verify the 1.0 API remains compatible

使用 unittest 进行高级模拟。模拟的

正如我们在第八章中看到的,创建模拟对象的另一种方法是使用标准库的单元测试包中的模拟支持。之前,我们创建了原始的Mock对象,但是它们也可以用可选的spec=参数来创建。这导致它们只模拟传递的对象的属性,而不是为任何任意的属性访问返回一个新的模拟。这种方法很有帮助,因为任何试图检测对象上属性存在的代码在传递模拟时的行为都与传递真实对象时一样。

这使得模拟对象更接近被测试的真实事物,并修复了一整类测试错误。如果您正在使用isinstance(...)条件,特别是当与实现子类钩子的抽象基类结合使用时,那么不使用spec=参数的Mock对象会导致采用错误的代码路径,如下面的控制台会话示例所示:

>>> import collections.abc
>>> import unittest.mock
>>> from apd.sensors.base import Sensor

>>> unspecced = unittest.mock.MagicMock()
>>> isinstance(unspecced, Sensor)
False
>>> isinstance(unspecced, collections.abc.Container)
True

>>> specced = unittest.mock.MagicMock(spec=Sensor)
>>> isinstance(specced, Sensor)
True
>>> isinstance(specced, collections.abc.Container)
False

我们可以使用这个模拟对象来创建模拟传感器,以触发异常或返回特定的值。这种方法的一个小问题是不涉及真正的Sensor基类代码,所以我们不能依赖我们的模拟对象有基类提供的帮助方法。我们需要定制整个面向用户的 API 的行为(比如__str__()方法),而不仅仅是实现我们需要定制的功能,就像我们对第一个FailingSensor实现所做的那样,如清单 11-9 所示。

from apd.sensors.base import Sensor
from apd.sensors.exceptions import IntermittentSensorFailureError

FailingSensor = mock.MagicMock(spec=Sensor)
FailingSensor.title = "Sensor which fails"
FailingSensor.name = "FailingSensor"
FailingSensor.value.side_effect = IntermittentSensorFailureError("Failing sensor")
FailingSensor.__str__.side_effect = IntermittentSensorFailureError("Failing sensor")

Listing 11-9An alternative way to create a FailingSensor object

需要设置titlename属性,因为传感器基类上没有titlename属性,只有类型声明暗示它们在子类上可用。如果我们没有在这里设置它们,那么任何访问它们的尝试都会导致一个AttributeError

我们之前已经在一个Mock对象上使用了return_value属性来定义如果一个对象被调用,应该返回什么值:FailingSensor.__str__.return_value = "Yes"将配置 mock 使得str(FailingSensor) == "Yes"。但是,我们不能使用这种方法来引发异常。

side_effect属性可以包含要引发的异常、要从多次调用中返回的 iterable 项或被调用来确定结果的函数。将副作用设置为 iterable 是指定变化行为的一种便捷方式。例如,在下面的副作用配置中,第一次使用str(FailingSensor)时,它会产生一个IntermittentSensorFailureError,告诉用户还会有两次失败。如果str(FailingSensor)被重复调用,它将通过链表提升下两个IntermittentSensorFailureErrors,然后在第四次尝试时返回"Yes"

FailingSensor.__str__.side_effect = [
    IntermittentSensorFailureError("Failing 2 more times"),
    IntermittentSensorFailureError("Failing 1 more times"),
    IntermittentSensorFailureError("Failing 0 more times"),
    "Yes"
]

不幸的是,任何进一步的调用都会导致StopIteration错误,因为指定返回值的side_effect方法具有列表项到调用结果的一对一映射。可以使用来自itertools模块 9 的函数来创建一个无限长的可迭代对象,允许任意多次调用str(FailingSensor)

FailingSensor.__str__.side_effect = itertools.chain(
    [
        IntermittentSensorFailureError("Failing 2 more times"),
        IntermittentSensorFailureError("Failing 1 more times"),
        IntermittentSensorFailureError("Failing 0 more times"),
    ],
    itertools.cycle(["Yes"])
)

这个例子使用了itertools.cycle(...)函数来创建一个无限长的 iterable,它重复 iterable 中作为参数给出的项,以及将任意 iterable 追加在一起的。其结果是一个 iterable,它可以作为副作用引发三次异常,然后一致返回"Yes"

警告信息

警告的实现方式与异常类似,但行为方式却非常不同。尽管开发人员有时会谈论发出警告,但警告并不与关键字raise10一起使用,而是由warnings.warn(...)函数触发。开发人员遇到的最常见的警告是DeprecationWarning。在运行本书的示例代码时,您可能已经看到了一些。这是不可避免的,因为底层库可能会随时弃用某些功能,或者它们自己可能会使用弃用的功能来维护对旧版本代码的支持。

例如,在本书写作期间的一个短时间内,aiohttp 模块在 Python 3.8 中运行时触发了一个弃用警告,警告它对asyncio.shield(...) 11 使用了一个较旧的签名。

   ...\lib\site-packages\aiohttp\connector.py:944: DeprecationWarning: The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.
    hosts = await asyncio.shield(self._resolve_host(

DeprecationWarning意在告诉开发人员他们正在使用的模式不再被认为是最佳实践。应该清楚哪里出了问题(在这种情况下,不应该传递loop=参数),并且应该给出一个明确的时间表,说明何时必须修复问题(在升级到 Python 3.10 之前)。

在这种情况下,是 Python 标准库发出了反对警告,目标受众是 aiohttp 的开发人员。作为 aiohttp 的用户,我们不是预期的受众,我们不应该担心看到反对警告,只要它们指定的时间范围不会结束。在这种特殊情况下,aiohttp 开发人员在 Python 3.8 发布后的两周内修复了这个不推荐警告。

aiohttp 的connector.py中触发该问题的特定代码位于第 944 行,如警告消息中所述。如果我们查看代码,我们可以看到触发异常的代码。

944\.            hosts = await asyncio.shield(self._resolve_host(
945\.                host,
946\.                port,
947\.                traces=traces), loop=self._loop)

Python 标准库中实现该警告的代码如下:

    if loop is not None:
        warnings.warn("The loop argument is deprecated since Python 3.8, "
                      "and scheduled for removal in Python 3.10.",
                      DeprecationWarning, stacklevel=2)

warn(...)函数可以将一个字符串和一种警告作为前两个参数,或者将一个警告实例作为第一个参数。如果只传递了一个没有警告类型的字符串,则假定它是一个UserWarningstacklevel=参数对应于相关代码从回溯的底部算起有多少行。这一点非常重要,因为警告应该总是暗示用户的代码,而不是检测问题并发出警告的代码。

默认值是stacklevel=1,它将弃用警告的来源显示为warnings.warn(...)调用。这里,stacklevel=2使上下文显示为调用warnings.warn(...)所在函数的代码行。类似地,stacklevel=3将是进一步移除的一个功能。

当我们添加了对基于地图的图像的支持时,我们对apd.aggregation包中的Config对象进行了更改。我们有效地弃用了sensor_name=参数,取而代之的是另外指定的get_data=参数,但是我们没有向用户公开这一点。这是一个很好的DeprecationWarning候选,如清单 11-10 所示。

@dataclasses.dataclass
class Config(t.Generic[T_key, T_value]):
    title: str
    clean: CleanerFunc[Cleaned[T_key, T_value]]
    draw: t.Optional[
        t.Callable[
            [t.Any, t.Iterable[T_key], t.Iterable[T_value], t.Optional[str]], None
        ]
    ] = None
    get_data: t.Optional[
        t.Callable[..., t.AsyncIterator[t.Tuple[UUID, t.AsyncIterator[DataPoint]]]]
    ] = None
    ylabel: t.Optional[str] = None
    sensor_name: dataclasses.InitVar[str] = None

    def __post_init__(self, sensor_name: t.Optional[str] = None) -> None:
        if self.draw is None:
            self.draw = draw_date  # type: ignore
        if sensor_name is not None:
            warnings.warn(
                DeprecationWarning(
                    f"The sensor_name parameter is deprecated. Please pass "
                    f"get_data=get_one_sensor_by_deployment('{sensor_name}') "
                    f"to ensure the same behaviour. The sensor_name= f"parameter "
                    f"will be removed in apd.aggregation 3.0."
                ),
                stacklevel=3,
            )
            if self.get_data is None:
                self.get_data = get_one_sensor_by_deployment(sensor_name)
        if self.get_data is None:
            raise ValueError("You must specify a get_data function")

Listing 11-10Updated Config data class that issues a deprecation warning for sensor_name

Note

这里的stacklevel=参数是 3,不是 2。我们希望当用户实例化一个Config对象时显示这个警告。@dataclass装饰器生成一个调用__post_init__(...)__init__(...)函数。stacklevel 为 2 将显示弃用警告与生成的__init__(...)函数相关联,而不是与调用代码相关联。如果您不确定,请尝试引发一个异常并查看堆栈跟踪。

生成的警告显示了错误代码的位置(analysis.py,第 287 行),给出了修复的确切说明,并包含了修复的截止日期。它还显示了有问题的行,在本例中是多行Config(...)构造函数调用的第一行。

...\src\apd\aggregation\analysis.py:287: DeprecationWarning: The sensor_name parameter is deprecated. Please pass get_data=get_one_sensor_by_deployment('Temperature') to ensure the same behaviour. The sensor_name= parameter will be removed in apd.aggregation 3.0.
    Config(

警告过滤器

可以定义新的警告类型来补充内置的警告类型,但是这不如子类化异常有用。创建新警告类型的主要原因是允许最终用户更好地利用警告过滤器。警告过滤器改变警告的默认行为,使它们更突出或不突出。

更改过滤器可用于更准确地控制显示给最终用户的警告集。如果您正在维护一个依赖于库的工具,该库会导致多个弃用警告,那么为最终用户抑制这些警告会提高他们对该工具的信心。 12

warnings.simplefilter("ignore", DeprecationWarning)

相反,您可以提高警告的严重性,使其成为异常,以帮助您准确地调试它们的原因。警告过滤器的动作"error"导致任何警告被视为异常。也就是说,会显示完整的回溯,一旦代码遇到第一个警告,执行就会停止。 13 结合使用事后调试器和此选项是调查警告原因的有效方法。

warnings.simplefilter("error", DeprecationWarning)

Tip

当以python script.py的身份直接运行 Python 代码时,可以用-W命令行选项设置默认的警告行为,如python -Werror script.py。设置PYTHONWARNINGS环境变量具有相同的效果,但是它适用于不通过解释器直接调用的基于 Python 的可执行文件,比如我们的 sensors 命令行工具。

如果下游组件没有定义自定义警告(大多数都没有),您还可以按文件、行号、 14 消息或它们的任意组合来过滤警告。这种灵活性允许您隐藏您知道的特定警告,而不隐藏您可能不知道的任何其他警告。

import re, warnings

warnings.filterwarnings(
    "ignore",
    message=re.escape("The sensor_name parameter is deprecated"),
    category=DeprecationWarning,
    module=re.escape("apd.aggregation.analysis"),
    lineno=275
)

最后,您可以临时修改警告过滤器,并自动恢复旧的过滤器。如果单个函数引发了许多您想要抑制的不同警告,但在通过不同的代码路径触发时又不隐藏它们,这可能会很有用。

import warnings

with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    function_that_warns_a_lot()

如果您想断言代码中出现了警告,那么相同的上下文管理器在测试中非常有用。如果您希望确信在某些复杂的情况下会显示警告,这是很有用的,但通常没有必要。catch_warnings(...)函数接受一个可选的record=True参数,该参数允许访问上下文管理器主体中出现的所有警告的记录。您应该确保警告过滤器没有忽略任何警告,因为只记录向最终用户显示的警告。清单 11-11 显示了一个利用该功能的示例测试。

def test_deprecation_warning_raised_by_config_with_no_getdata():
    with warnings.catch_warnings(record=True) as captured_warnings:
        warnings.simplefilter("always", DeprecationWarning)
        config = analysis.Config(
            sensor_name="Temperature",
            clean=analysis.clean_passthrough,
            title="Temperaure",
            ylabel="Deg C"
        )
        assert len(captured_warnings) == 1
        deprecation_warning = captured_warnings[0]
        assert deprecation_warning.filename == __file__
        assert deprecation_warning.category == DeprecationWarning
        assert str(deprecation_warning.message) == (
            "The sensor_name parameter is deprecated. Please pass "
            "get_data=get_one_sensor_by_deployment('Temperature') "
            "to ensure the same behaviour. The sensor_name= parameter "
            "will be removed in apd.aggregation 3.0."
        )

Listing 11-11A test to ensure a warning is raised

记录

所有类型的应用都广泛使用日志记录。它帮助最终用户调试问题,并允许更详细的错误报告,这反过来又节省了试图重现问题的时间。日志的使用方式与print(...)调试的方式非常相似,但是对于大型应用和库来说,它有一些显著的优势。

print(...)调试相比,日志记录的最大优势是日志记录框架将每个日志条目与严重性相关联。用户可以选择一个日志级别来控制记录多少日志信息,因此他们可以选择仅在需要时生成调试日志(例如)。

Tip

如果您正在编写有助于调试的日志语句,请为最终用户提供一种获取日志的简单方式。Pipenv 用一个--support标志很好地做到了这一点,它以 markdown 格式打印所有相关数据,以便粘贴到 GitHub 问题中。考虑添加一个类似的选项,设置一个较低的日志级别,并将格式化的版本和配置数据与日志文件一起输出,作为界面设计的一部分。但是,不要在没有得到用户明确许可的情况下自动整理用户系统的日志,因为这可能会侵犯他们的隐私。

日志记录的默认级别是调试、信息、警告、错误和严重。 15 我们可以使用日志模块中的匹配函数来记录消息,比如logging.warning(...),将警告级别的消息记录到根日志记录器

>>> logging.warning("This is a warning")
WARNING:root:This is a warning

默认情况下,Python 会丢弃调试和信息日志消息,只有警告级别及以上的消息才会以格式LEVEL:logger:message记录到终端。记录器从丢弃消息变为显示消息的阈值就是该记录器的级别。用于显示的格式是首次使用 root logger 时设置的,可以通过使用新的格式化程序调用logging.basicConfig(...)函数进行调整。 16 这也允许您更改根日志记录器的过滤器阈值级别,例如在以下示例中将其设置为 debug:

logging.basicConfig(format="{asctime}: {levelname} - {message}", style="{", level=logging.DEBUG)

多年来,Python 已经有了许多字符串格式语法;要使用现代风格,请将style="{"作为另一个参数传递。您可能会看到旧程序中的日志配置使用不同的格式,但可用的键仍然是相同的。这些键列在标准库的文档中的LogRecord属性下,但是最有用的是

  1. asctime–格式化的日期/时间

  2. levelname–日志级别的名称

  3. pathname–引发日志消息的文件的路径

  4. funcName–产生日志消息的函数的名称

  5. message–记录的字符串

嵌套记录器

在程序中使用嵌套层次的记录器是很常见的。可以通过调用logging.getLogger(name)函数来检索记录器,其中 name 是要检索的记录器的名称。

当一个记录器被检索时,其名称将与由.字符分割的现有记录器进行比较。如果现有的日志记录器的名称是新日志记录器的前缀,那么它将成为父日志记录器。那就是:

>>> import logging
>>> root_logger = logging.getLogger()
>>> apd_logger = logging.getLogger("apd")
>>> apd_aggregation_logger = logging.getLogger("apd.aggregation")

>>> print(apd_aggregation_logger)
<Logger apd.aggregation (WARNING)>

>>> print(apd_aggregation_logger.parent)
<Logger apd (WARNING)>

>>> print(apd_logger.parent)
<RootLogger root (WARNING)>

Caution

如果apd_aggregation_logger是在apd_logger之前创建的,那么两者都将 root logger 作为它们的父级。确保这种行为正确的最简单方法是在所有模块中添加logger = logging.getLogger(__name__)行。这确保了你的日志结构和你的代码结构是一样的,更容易推理。如果您想确保所有的父记录器都设置正确,请确保将它包含在任何__init__.py中。

这些记录器均可用于记录消息,所使用的记录器显示为日志消息的一部分(如果记录器名称包含在格式化程序中)。记录器收到的任何消息也会传递给其父级。 17 正是这种行为允许我们通过配置根记录器来配置所有记录器的格式。

>>> apd_aggregation_logger.warning("a warning")
WARNING:apd.aggregation:a warning

>>> apd_logger.warning("a warning")
WARNING:apd:a warning

>>> root_logger.warning("a warning")
WARNING:root:a warning

单个记录器可以有一个新的级别集,该级别集会传播到它们的所有子级(除非它们有自己的级别集)。这允许通过设置命名记录器的级别来配置每个包的日志记录。

>>> apd_logger.setLevel(logging.DEBUG)

>>> apd_aggregation_logger.debug("debugging")
DEBUG:apd.aggregation:debugging

>>> apd_logger.debug("debugging")
DEBUG:apd:debugging

>>> root_logger.debug("debugging")
(no output)

自定义操作

到目前为止,我们一直将 loggers 视为一种美化的打印声明,但是它们比这灵活得多。当我们记录一个字符串时,日志框架在内部创建一个LogRecord对象,然后将它传递给一个处理程序,该处理程序对它进行格式化并输出到标准错误流上。

记录器也可以有自定义的处理程序,以其他方式记录信息。最常用的处理程序是StreamHandler,它格式化日志消息(可能使用自定义格式化程序)并在终端中显示。我们可以用它来定义在apd.aggregation包中使用自定义日志格式进行日志记录,但是默认格式用于所有其他日志记录,例如。

额外元数据

我们可以使用日志方法的额外字典向格式化程序添加特定于应用的方面。这样做的缺点是,所有遵循该格式的日志消息都必须为额外的关键字提供一个值,如果它们是日志格式的一部分。如果您在根日志记录器上设置了一个定制格式,需要一个特定的额外数据,这将导致所有不受您直接控制的日志调用引发一个KeyError。这是只将自定义格式化程序应用于您自己的记录器而不是根记录器的一个很好的理由。

为了做到这一点,我们需要用一个新的格式化程序定制一个单独的记录器。我们不能使用logging.basicConfig(...)函数,因为它只能操纵根日志记录器;我们需要提供一个新的函数,按照我们希望的那样设置处理程序。清单 11-12 有一个这个函数的例子。

import logging

def set_logger_format(logger, format_str):
    """Set up a new stderr handler for the given logger
    and configure the formatter with the provided string
    """
    logger.propagate = False
    formatter = logging.Formatter(format_str, None, "{")

    std_err_handler = logging.StreamHandler(None)
    std_err_handler.setFormatter(formatter)

    logger.handlers.clear()
    logger.addHandler(std_err_handler)
    return logger

logger = set_logger_format(
    logging.getLogger(__name__),
    format_str="{asctime}: {levelname} - {message}",
)

Listing 11-12Helper function to configure a logger with a specific formatter

我们在set_logger_format(...)调用中添加的任何附加字段也必须在每个日志记录调用中提供,作为一个extra=字典,如下所示:

>>> logger = set_logger_format(
...     logging.getLogger(__name__),
...     format_str="[{sensorname}/{levelname}] - {message}",
... )
>>> logger.warn("hi", extra={"sensorname": "Temperature"})
[Temperature/WARNING] – hi

我们可以通过在格式化日志记录之前对其进行操作来解决这一限制。有几种不同的方法可以将变量注入日志记录:定制工厂、添加适配器或添加过滤器。当从我们自己的代码进行日志记录时,自动注入数据还允许更方便的接口,因为我们不再需要显式地传递格式化程序可能希望作为关键字参数的所有数据。

测井适配器

日志适配器是一段代码,它包装了一个日志记录器,允许定制它的任何行为。它提供了一个流程函数,可用于将消息和参数转换为底层日志功能,并可如清单 11-13 所示进行创建。

import copy
import logging

class ExtraDefaultAdapter(logging.LoggerAdapter):
    def process(self, msg, kwargs):
        extra = copy.copy(self.extra)
        extra.update(kwargs.pop("extra", {}))
        kwargs["extra"] = extra
        return msg, kwargs

def set_logger_format(logger, format_str):
    """Set up a new stderr handler for the given logger
    and configure the formatter with the provided string
    """
    logger.propagate = False
    formatter = logging.Formatter(format_str, None, "{")

    std_err_handler = logging.StreamHandler(None)
    std_err_handler.setFormatter(formatter)

    logger.handlers.clear()
    logger.addHandler(std_err_handler)
    return logger

Listing 11-13A log adapter that provides defaults for some additional keywords

使用这个适配器允许我们省略额外的字典,除非我们有希望添加到这个日志消息中的数据,允许我们在不相关的时候省略它。这也使得向格式字符串添加新项变得更加容易,因为我们不需要改变每个日志记录函数调用来匹配。

>>> logger = set_logger_format(
...     logging.getLogger(__name__),
...     format_str=" [{sensorname}/{levelname}] - {message}",
... )
>>> logger = ExtraDefaultAdapter(logger, {"sensorname": "none"})
>>> logger.warn("hi")
[none/WARNING] - hi
>>> logger.warn("hi", extra={"sensorname": "Temperature"})
[Temperature/WARNING] - hi

这种方法的缺点是我们需要用适配器包装每个日志记录器。它非常适合在单个模块中自动填充额外的数据,但是它不能帮助我们提供跨多个记录器的缺省值,因为不能保证使用记录器的所有代码也将使用适配器(事实上,对于根记录器,几乎可以保证会有使用日志记录的代码不知道我们的定制适配器)。

我们可以向适配器本身添加任何我们想要的逻辑。例如,我们可以从上下文变量中提取这个值,而不是为sensorname提供一个显式的默认值。适配器最适合于只有一个记录器需要定制元数据的情况。如果您已经为一个只有您登录的记录器定义了一个自定义格式化程序,那么很有可能确保所有的日志调用都通过适配器。

日志记录工厂

另一种方法是定制内部日志记录对象本身的创建。定制工厂允许任意数据存储在所有的LogRecords上,而记录日志的代码不会意识到任何差异。这允许自定义元数据以第三方代码使用的记录器格式使用,例如根记录器。使这种格式对所有日志记录器通用意味着没有不同日志格式的混合,这对于用户来说可能是一个显著的优势。缺点是这里设置的属性不能在额外的字典中传递。 18

在前面的例子中,我们可以灵活地将额外的数据传递给日志记录系统。当覆盖LogRecord工厂时,我们别无选择,只能使用一个上下文变量来传递额外的数据。这限制了该方法的使用方式,因为我们不能简单地将我们想要的值作为参数传递。

清单 11-14 显示了定制记录工厂的示例代码,以便在所有记录中包含来自 sensorname_var 上下文变量的值。

from contextvars import ContextVar
import functools
import logging

sensorname_var = ContextVar("sensorname", default="none")

def add_sensorname_record_factory(existing_factory, *args, **kwargs):
    record = existing_factory(*args, **kwargs)
    record.sensorname = sensorname_var.get()
    return record

def add_record_factory_wrapper(fn):
    old_factory = logging.getLogRecordFactory()
    wrapped = functools.partial(fn, old_factory)
    logging.setLogRecordFactory(wrapped)

add_record_factory_wrapper(add_sensorname_record_factory)
logging.basicConfig(
    format="[{sensorname}/{levelname}] - {message}", style="{", level=logging.INFO
)

Listing 11-14Customizing a LogRecord factory to add contextual information and include in all logs

这种方法与以前的方法有很大不同,因为它在全局级别上更改日志记录配置。适配器示例涉及到对每个模块的更改,以将记录器包装在适当的适配器中,并且每个模块都可以有自己的适配记录器。一次只能有一个唱片工厂处于活动状态。虽然我们可以多次覆盖它以提供额外的数据,但是所有的覆盖都必须以这样一种方式编写,以便不会相互冲突。这种方法可以如下使用:

>>> logger = logging.getLogger(__name__)
>>> logger.warning("hi")
[none/WARNING] – hi
>>> token = sensorname_var.set("Temperature")
>>> logging.warning("hi")
[Temperature/WARNING] - hi
>>> sensorname_var.reset(token)

日志过滤器

在我看来,日志过滤器在这两种方法之间提供了一个很好的中间地带。名称过滤器可能使这种方法有点违反直觉,因为过滤器旨在用于动态地丢弃日志记录,但这也是改变日志记录的最灵活的方法。

您可以将日志过滤器与记录器相关联,这将导致记录器处理的每个日志消息都调用它,但是您也可以根据处理程序注册它。控制格式的是处理程序,因此将筛选器与处理程序相关联可以确保自定义格式和默认值筛选器紧密关联。无论何时使用该处理程序,您都知道过滤器也是活动的。

这种方法意味着默认传感器名称仅作为格式化过程的一部分来填充。附加信息仍然可以作为额外字典的一部分传递,这是正常的,当显式传递时,所有日志处理程序都可以使用它。清单 11-15 显示了一个更新的设置函数,它可以选择关联一个过滤器和处理器。

import logging

class AddSensorNameDefault(logging.Filter):
    def filter(self, record):
        if not hasattr(record, "sensorname"):
            record.sensorname = "none"
        return True

def set_logger_format(logger, format_str, filters=None):
    """Set up a new stderr handler for the given logger
    and configure the formatter with the provided string
    """
    logger.propagate = False
    formatter = logging.Formatter(format_str, None, "{")

    std_err_handler = logging.StreamHandler(None)
    std_err_handler.setFormatter(formatter)

    logger.handlers.clear()
    logger.addHandler(std_err_handler)
    if filters is not None:
        for filter in filters:
            std_err_handler.addFilter(filter)
    return logger

Listing 11-15Using a handler filter to add a default sensorname

设置这个记录器与适配器模式非常相似,但是有一个重要的区别。set_logger_format(...)呼叫只需要进行一次。对logging.getLogger(...)的任何后续调用都会返回一个正确配置的记录器,而不需要记录器的每个用户都配置过滤器。初始使用如下进行:

logger = set_logger_format(
    logging.getLogger(),
    "[{sensorname}/{levelname}] - {message}",
    filters=[AddSensorNameDefault(), ]
)
>>> logger.warning("hi")
[none/WARNING] - hi
>>> logger.warning("hi", extra={"sensorname": "Temperature"})
[Temperature/WARNING] - hi

日志记录配置

上述代码的缺点是,为了更改格式化程序或添加过滤器,我们必须为日志系统做大量的设置工作。对于除了简单的自包含工具之外的所有应用,最终用户可能希望配置他们自己的处理程序或日志格式化程序。对于大型应用中使用的库来说尤其如此。

因此,在现实应用中很少使用 Python 代码配置日志记录。通常,日志配置是通过某种配置系统提供的,比如配置迁移系统的alembic.ini文件的[logging]配置部分。logging.config.fileConfig(...)助手函数可用于从文件中加载日志配置,少量粘合代码(清单 11-16 )可用于使我们添加的任何过滤器可供最终用户在 ini 风格的日志配置中利用(清单 11-17 )。

[loggers]
keys=root

[handlers]
keys=stderr_with_sensorname

[formatters]
keys=sensorname

[logger_root]
level=INFO
handlers=stderr_with_sensorname

[handler_stderr_with_sensorname]
class=apd.aggregation.utils.SensorNameStreamHandler
formatter = sensorname

[formatter_sensorname]
format = {asctime}: [{sensorname}/{levelname}] - {message}
style = {

Listing 11-17A sample logging configuration file that uses a filter to provide default values for the formatter

import logging

class AddSensorNameDefault(logging.Filter):
    def filter(self, record):
        if not hasattr(record, "sensorname"):
            record.sensorname = "none"
        return True

class SensorNameStreamHandler(logging.StreamHandler):
    def __init__(self, *args, **kwargs):
        super().__init__()
        self.addFilter(AddSensorNameDefault())

Listing 11-16Glue code to provide a handler that has a filter added by default

Caution

日志配置文件格式允许嵌入一些逻辑,以简化复杂配置的设置。这使得从配置文件中运行任意代码成为可能。这很少成为问题,但是如果您有由系统管理员运行的工具,那么应该只有管理员能够编辑日志配置。

其他处理者

除了我们目前使用的StreamHandler之外,还有其他有用的处理程序。最常见的是FileHandler,它将日志信息输出到一个命名文件中。将此设置为根日志记录器上的处理程序用于构建持久日志文件。

更复杂的处理程序,如TimedRotatingFileHandlerSysLogHandlerHTTPHandler,不太常用,但非常强大。这些允许将日志集成到任何方式的现有日志管理解决方案中。甚至有商业日志管理系统也以同样的方式集成,比如 Sentry 及其定制的EventHandler类。

审计日志

拥有定制的记录器和处理程序允许编写在复杂系统中记录用户操作的审计日志记录系统。审核日志是一种日志,旨在提供有关用户已执行的某些重要操作的信息。它不是用于调试,而是用于验证系统没有被滥用。

为了实现这一点,您通常会获得一个带有logging.getLogger("audit")名称的新日志记录器,并将其配置为审计日志记录器。与大多数日志记录器不同,大多数审计日志的命名并不与 Python 模块相匹配。通常,审计记录器使用特殊的日志处理程序,例如将审计日志事件附加到系统日志或通过电子邮件发送的处理程序。我建议将审计日志条目输出到与其他日志条目相同的输出流位置。将审计日志条目与调试信息混合在一起可以增加高级上下文,这在调试问题时非常有用。

日志处理程序可以与多个记录器相关联,因此可以通过为每个文件定义一个处理程序并将其与应该输入到该文件中的每个单独的记录器相关联,来配置自定义日志文件以包含多个记录器的输出。您还可以使用记录器的嵌套结构为应用的逻辑组件创建日志文件。

日志处理程序是用提供emit(record)函数的 Python 类实现的,因此可以编写定制的处理程序来执行任何适当的特定于应用的审计日志记录操作。实际上,大多数常见需求都有可用的处理程序实现。

围绕问题进行设计

前面的策略允许我们交流在程序组件中遇到的问题(使用异常)和最终用户(使用警告和日志)。它们让我们更容易理解用户遇到了什么问题(当他们被报告时)。然而,大多数问题都没有被报告,我们永远不可能提前想到每一个可能的边缘情况。

编写可靠软件的一个关键部分是设计能够自动补偿正常运行中遇到的问题的过程。对我们来说,与传感器通信的任何问题都会导致我们正在收集的传感器历史数据出现缺口。

这种失败有两种可能的原因。传感器服务器工作正常,但聚合进程(或网络)出现故障;或者聚合进程(或网络)工作正常,但传感器出现故障。

调度传感器查找

聚合器或网络故障的问题是最容易解决的。我们可以修改传感器来定期收集和存储数据,而不是通过聚合过程从传感器获取实时数据。然后,它可以通过 API 提供这些收集的数据。这允许聚合过程检测何时收集了数据但未下载,并通过下载自上次成功同步以来的所有数据来纠正问题。

要实现这一点,需要对聚合过程和传感器本身进行重大更改。所涉及的服务器不仅需要在特定时间触发传感器数据收集,还需要能够存储数据并通过 API 公开存储的数据集。

我们需要像创建聚合流程一样创建一个数据库集成。我们还需要一个新的命令行选项来存储数据,并为 alembic 和 sqlalchemy 添加一组依赖项,以确保我们可以将数据存储到数据库中。这些需要是可选的依赖项:并不是所有的apd.sensors包的用户都必须使用聚合器,如果用户只需要命令行工具来检查当前状态,那么要求他们安装完整的数据库系统就太过分了。一旦添加了这个新特性,setup.cfg的可选依赖部分将如下所示。

Note

有些要求只有在我们同时安装了 webapp 和 scheduled extras 的情况下才是相关的,因为我们稍后将使用它们来实现数据库查找。我们可以为这些内容创建另一个额外的内容,但这确实会让用户更难理解。您可能更喜欢将这些依赖项添加到其他额外的定义中。当我们使用第三个额外的时候,我们必须记住,在编写代码的时候,并不是所有的依赖项都是可用的。没有任何东西可以阻止用户为这些额外的依赖项安装额外的组件,而没有这两个组件。

[options.extras_require]
webapp = flask
scheduled =
  sqlalchemy
  alembic
storedapi =
  flask-sqlalchemy
  python-dateutil

然后,我们需要使用pipenv install确保我们的本地开发环境被标记为需要这组新的可选依赖项。就像聚合过程一样,我们需要创建一个数据库表定义(清单 11-18 ,将元数据对象连接到 alembic 配置,并生成一个初始的 alembic 迁移。

from __future__ import annotations

import datetime
import typing as t

import sqlalchemy
from sqlalchemy.schema import Table
from sqlalchemy.orm.session import Session

from apd.sensors.base import Sensor

metadata = sqlalchemy.MetaData()

sensor_values = Table(
    "recorded_values",
    metadata,
    sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
    sqlalchemy.Column("sensor_name", sqlalchemy.String, index=True),
    sqlalchemy.Column("collected_at", sqlalchemy.TIMESTAMP, index=True),
    sqlalchemy.Column("data", sqlalchemy.JSON),
)

def store_sensor_data(sensor: Sensor[t.Any], data: t.Any, db_session: Session) -> None:
    now = datetime.datetime.now()
    record = sensor_values.insert().values(
        sensor_name=sensor.name, data=sensor.to_json_compatible(data), collected_at=now
    )
    db_session.execute(record)

Listing 11-18Database table for caching sensor values locally

清单 11-19 中的变化是添加了一个命令行选项,用于指定应该连接到哪个数据库,以及一个标志,用于标记应该将数据保存到本地数据库,而不仅仅是输出给用户的信息。有了这个,我们的用户可以设置一个调度任务来调用我们的脚本,并根据调度保存数据。

@click.command(help="Displays the values of the sensors")
@click.option(
    "--develop", required=False, metavar="path", help="Load a sensor by Python path"
)
@click.option("--verbose", is_flag=True, help="Show additional info")
@click.option("--save", is_flag=True, help="Store collected data to a database")
@click.option(
    "--db",
    metavar="<CONNECTION_STRING>",
    default="sqlite:///sensor_data.sqlite",
    help="The connection string to a database",
    envvar="APD_SENSORS_DB_URI",
)
def show_sensors(develop: str, verbose: bool, save: bool, db: str) -> None:
    sensors: t.Iterable[Sensor[t.Any]]
    if develop:
        try:
            sensors = [get_sensor_by_path(develop)]
        except UserFacingCLIError as error:
            if verbose:
                tb = traceback.format_exception(type(error), error, error.__traceback__)
                click.echo("".join(tb))
            click.secho(error.message, fg="red", bold=True)
            sys.exit(error.return_code)
    else:
        sensors = get_sensors()

    db_session = None
    if save:
        from sqlalchemy import create_engine
        from sqlalchemy.orm import sessionmaker

        engine = create_engine(db)
        sm = sessionmaker(engine)
        db_session = sm()

    for sensor in sensors:
        click.secho(sensor.title, bold=True)
        try:
            value = sensor.value()
        except DataCollectionError as error:
            if verbose:
                tb = traceback.format_exception(type(error), error, error.__traceback__)
                click.echo("".join(tb))
                continue
            click.echo(error)
        else:
            click.echo(sensor.format(value))
            if save and db_session is not None:
                store_sensor_data(sensor, value, db_session)
                db_session.commit()

        click.echo("")
    sys.exit(ReturnCodes.OK)

Listing 11-19Updated command-line script to add saving of data

这足以确保在出现网络或聚合故障时不会丢失数据;然而,一旦错误条件结束,集成丢失的数据是不够的。

API 和过滤

我们需要更新我们的 API,以便提取过去记录的任何数据。同时,我们可以更新 API,将失败的传感器分离到一个独立的错误列表中,补充我们在本章前面添加的异常处理。

复杂的 API 通常为用户提供指定他们需要哪些数据的能力,通过只计算最终用户需要的信息,允许 API 实现更高效。更常见的是,API 提供某种形式的过滤选项来减少传递的数据量。

我们需要一个新的 API 端点来公开已经收集的数据,以便聚合过程可以将其与数据库同步。这个端点的实现如清单 11-20 所示。

@version.route("/historical")
@version.route("/historical/<start>")
@version.route("/historical/<start>/<end>")
@require_api_key
def historical_values(
    start: str = None, end: str = None
) -> t.Tuple[t.Dict[str, t.Any], int, t.Dict[str, str]]:
    try:
        import dateutil.parser
        from sqlalchemy import create_engine
        from sqlalchemy.orm import sessionmaker
        from apd.sensors.database import sensor_values
        from apd.sensors.wsgi import db
    except ImportError:
        return {"error": "Historical data support is not installed"}, 501, {}

    db_session = db.session
    headers = {"Content-Security-Policy": "default-src 'none'"}

    query = db_session.query(sensor_values)
    if start:
        query = query.filter(
            sensor_values.c.collected_at >= dateutil.parser.parse(start)
        )
    if end:
        query = query.filter(
            sensor_values.c.collected_at <= dateutil.parser.parse(end)
        )

    known_sensors = {sensor.name: sensor for sensor in cli.get_sensors()}
    sensors = []
    for data in query:
        if data.sensor_name not in known_sensors:
            continue
        sensor = known_sensors[data.sensor_name]
        sensor_data = {
            "id": sensor.name,
            "title": sensor.title,
            "value": data.data,
            "human_readable": sensor.format(sensor.from_json_compatible(data.data)),
            "collected_at": data.collected_at.isoformat(),
        }
        sensors.append(sensor_data)
    data = {"sensors": sensors}
    return data, 200, headers

Listing 11-20New historical values endpoint for the v3.0 API

将此信息导入聚合过程的处理程序与普通传感器收集非常相似,因为数据采用相同的格式。可以通过添加一个新的命令行工具来同步一个时间范围内的任何丢失数据,或者通过检测自上次成功数据收集以来的长时间,并使用/historical端点而不是正常端点来实现该过程。

EXERCISE 11-1: Supporting Historical Data Collection

在运行传感器的服务器出现故障的情况下,这一更改不会直接帮助我们。对于我们现有的传感器类型,这是不可能恢复的,但这是我们特定传感器的属性,而不是不可改变的事实。其他传感器可能能够找到某个时间点的值。例如,报告服务器状态的传感器可以从现有的系统日志中提取过去的状态。

考虑需要对代码库进行哪些更改,以支持可以报告它们在过去某个时间点的值的传感器。考虑如何修改现有的类来提供这种额外的功能,使其向后兼容现有的传感器。

和往常一样,本章附带的代码中有一个如何实现这一点的例子。然而,这不会被合并到代码的主分支中,因为它离整理我们当前存储的数据的要求太远了。

摘要

当编写您希望其他开发人员使用的库时,包括自定义异常并在相关时发出警告;这是一种比README.txt文件更有效的与观众交流的方式。特别是,计划任何不推荐使用的功能,并确保在使用旧功能时显示警告。

自定义异常类型允许下游开发人员为特定的错误条件编写处理程序,就像您使用的库中的自定义异常允许您捕捉依赖项中的错误一样。

即使您没有编写一个供其他人使用的库,日志框架也允许您的用户配置他们想要存储的调试信息以及他们想要如何处理它。如果你不提供日志记录语句或者只使用print(...)来记录,它们更有可能被丢弃,而不是作为错误报告返回给你。

虽然这些功能有助于调试和编写代码来处理故障,但编写对错误情况具有鲁棒性的代码的最重要方面是将故障转移设计到流程本身中。

无论您决定使用哪种策略组合,都要确保测试您的代码行为正确。自动化测试能够并且应该验证当事情出错时,而不仅仅是当事情按预期运行时,您的代码以可接受的方式运行。

额外资源

以下链接提供了本章所涵盖主题的额外背景信息:

Footnotes 1

这三个类对应于要使对象成为集合必须存在的三个方法。

  2

映射通过它们的variable.get(key, default)方法提供了一些这样的功能,但是这仍然会引发一个TypeError,,而且序列没有内置的等价功能。

  3

不属于这个类别的内置异常有GeneratorExitKeyboardInterruptSystemExit

  4

SystemExit也用于提前结束程序,即使没有实际问题。不过,通常用sys.exit(0)来做这件事,而不是引发SystemExit异常。

  5

通常,不幸的是,代码就是文档。

  6

直接继承Exception在这里也能很好地工作。我们保留APDSensorsError主要是出于美观的原因,因为这个代码不太可能被想要消除任何和所有 APD 传感器错误的消费者调用,但它确实使之成为可能。SystemExit的意思非常匹配,所以也包括在内,但是我想存储SystemExit没有提供的额外元数据。

  7

匹配参数可以是正则表达式字符串或编译的正则表达式模式。要匹配一个表示不同正则表达式的字符串,使用re.escape(string_literal)

  8

在 Python 1.x 中,异常不是将消息作为参数的对象。他们被提升为raise ValueError, “Value is out of range”(比如)。追溯必须从sys.exc_traceback全局变量中提取。要格式化一个异常,您需要它的类型、字符串表示和回溯。Python 2 中合并了类型和字符串表示,但直到 Python 3,异常对象才开始拥有自己的回溯信息。

  9

我们之前看了itertools.groupby(...),但是整个 itertools 模块是值得学习的。这是标准库中我最喜欢的模块之一,因为它为许多涉及生成器的常见任务提供了帮助函数。

  10

然而,由于它们是BaseException类型层次结构的一部分,从技术上讲,用 raise 关键字发出警告是可能的,但这只是为了支持警告框架的一些内部实现细节。警告应该永远不要直接提出;没有意义,很混乱。

  11

如果调用任务被取消,防止 asyncio 任务被取消的功能。在这种情况下,它用于允许在请求之间共享 DNS 查找,因为即使首先触发它的请求被取消,也需要进行查找。

  12

只是不要忘记在反对警告过期之前修复任何问题,因为如果工具停止工作,将对用户的信心产生更大的影响。

  13

这就是警告是一种异常的原因,因此它们可以由该筛选器操作引发。

  14

请注意,如果发布了新版本的库,文件名和行号可能会改变。

  15

可以使用logging.addLevelName(level, levelName)创建新的级别,其中 level 是一个整数,它与logging.DEBUGlogging.INFO和其他整数常量进行比较以进行排序。要登录到这个级别,你必须使用logging.log(level, message)而不是logging.info(message)风格的便利功能。

  16

最好在生成任何日志消息之前完成此操作。如果已经有一个日志配置,那么这个函数将不会做任何事情,除非已经传递了force=True参数。在 Python 3.8 之前,force=参数是不可用的。

  17

除非记录器设置了logger.propagate=False属性,在这种情况下,它们不会被设置。如果您曾经看到重复的日志条目,很可能您已经配置了一个带有自定义输出的日志记录器(如本节后面所演示的),但是却忽略了为该日志记录器禁用日志传播。

  18

合并额外字典的代码显式地检查冲突,如果发现,就引发一个KeyError