原文:RealPython

协议:CC BY-NC-SA 4.0

Python 计时器函数:监控代码的三种方式

原文:https://realpython.com/python-timer/

虽然许多开发人员认为 Python 是一种有效的编程语言,但纯 Python 程序可能比编译语言(如 C 、Rust 和 Java )中的程序运行得更慢。在本教程中,你将学习如何使用一个 Python 定时器来监控你的程序运行的速度。

在本教程中,你将学习如何使用:

  • time.perf_counter() 用 Python 来度量时间
  • 保持状态
  • 上下文管理器处理代码块
  • 装饰者定制一个功能

您还将获得关于类、上下文管理器和装饰器如何工作的背景知识。当您探索每个概念的示例时,您会受到启发,在代码中使用其中的一个或几个来计时代码执行,以及在其他应用程序中。每种方法都有其优点,您将根据具体情况学习使用哪种方法。另外,您将拥有一个工作的 Python 计时器,可以用来监控您的程序!

Decorators Q &文字记录: 点击此处获取我们 Python decorators Q &的 25 页聊天记录,这是真实 Python 社区 Slack 中的一个会话,我们在这里讨论了常见的 decorator 问题。

Python 定时器

首先,您将看到一些在整个教程中使用的示例代码。稍后,您将向该代码添加一个 Python 计时器来监控其性能。您还将学习一些最简单的方法来测量这个例子的运行时间。

Remove ads

Python 定时器功能

如果你查看 Python 内置的 time 模块,你会注意到几个可以测量时间的函数:

Python 3.7 引入了几个新的函数,像 thread_time() ,以及上面所有函数的纳秒版本,以_ns后缀命名。比如 perf_counter_ns() 就是perf_counter()的纳秒版本。稍后您将了解更多关于这些函数的内容。现在,请注意文档中对perf_counter()的描述:

返回性能计数器的值(以秒为单位),即具有最高可用分辨率来测量短时间的时钟。(来源)

首先,您将使用perf_counter()创建一个 Python 定时器。稍后,您将把它与其他 Python 计时器函数进行比较,并了解为什么perf_counter()通常是最佳选择。

示例:下载教程

为了更好地比较向代码中添加 Python 计时器的不同方法,在本教程中,您将对同一代码示例应用不同的 Python 计时器函数。如果您已经有了想要度量的代码,那么您可以自由地跟随示例。

本教程中您将使用的示例是一个简短的函数,它使用 realpython-reader 包下载 Real Python 上的最新教程。要了解更多关于真正的 Python Reader 及其工作原理,请查看如何将开源 Python 包发布到 PyPI 。您可以使用 pip 在您的系统上安装realpython-reader:

$ python -m pip install realpython-reader

然后,你可以导入这个包作为reader

您将把这个例子存储在一个名为latest_tutorial.py的文件中。代码由一个函数组成,该函数下载并打印 Real Python 的最新教程:

 1# latest_tutorial.py
 2
 3from reader import feed
 4
 5def main():
 6    """Download and print the latest tutorial from Real Python"""
 7    tutorial = feed.get_article(0)
 8    print(tutorial)
 9
10if __name__ == "__main__":
11    main()

处理大部分艰难的工作:

  • 三号线realpython-reader进口feed。该模块包含从真实 Python 提要下载教程的功能。
  • 第 7 行从 Real Python 下载最新教程。数字0是一个偏移量,其中0表示最近的教程,1是以前的教程,依此类推。
  • 第 8 行将教程打印到控制台。
  • 运行脚本时,第 11 行调用main()

当您运行此示例时,您的输出通常如下所示:

$ python latest_tutorial.py
# Python Timer Functions: Three Ways to Monitor Your Code

While many developers recognize Python as an effective programming language,
pure Python programs may run more slowly than their counterparts in compiled
languages like C, Rust, and Java. In this tutorial, you'll learn how to use
a Python timer to monitor how quickly your programs are running.

[ ... ]

## Read the full article at https://realpython.com/python-timer/ »

* * *

根据您的网络,代码可能需要一段时间运行,因此您可能希望使用 Python 计时器来监控脚本的性能。

你的第一个 Python 定时器

现在,您将使用time.perf_counter()向示例添加一个基本的 Python 计时器。同样,这是一个性能计数器,非常适合为你的代码计时。

perf_counter()以秒为单位测量从某个未指定的时刻开始的时间,这意味着对函数的单次调用的返回值是没有用的。然而,当您查看对perf_counter()的两次调用之间的差异时,您可以计算出两次调用之间经过了多少秒:

>>> import time
>>> time.perf_counter()
32311.48899951

>>> time.perf_counter()  # A few seconds later
32315.261320793

在这个例子中,你给perf_counter()打了两个电话,几乎相隔 4 秒。您可以通过计算两个输出之间的差异来确认这一点:32315.26 - 32311.49 = 3.77。

现在,您可以将 Python 计时器添加到示例代码中:

 1# latest_tutorial.py
 2
 3import time 4from reader import feed
 5
 6def main():
 7    """Print the latest tutorial from Real Python"""
 8    tic = time.perf_counter() 9    tutorial = feed.get_article(0)
10    toc = time.perf_counter() 11    print(f"Downloaded the tutorial in {toc - tic:0.4f} seconds") 12
13    print(tutorial)
14
15if __name__ == "__main__":
16    main()

注意,在下载教程之前和之后都要调用perf_counter()。然后,通过计算两次调用之间的差异,打印下载教程所用的时间。

**注意:**在第 11 行,字符串前的f表示这是一个 f 字符串,这是一种格式化文本字符串的便捷方式。:0.4f是一个格式说明符,表示数字toc - tic应该打印为一个有四位小数的十进制数。

有关 f 字符串的更多信息,请查看 Python 3 的 f 字符串:一种改进的字符串格式化语法。

现在,当您运行该示例时,您将看到教程开始之前所用的时间:

$ python latest_tutorial.py
Downloaded the tutorial in 0.6721 seconds # Python Timer Functions: Three Ways to Monitor Your Code

[ ... ]

就是这样!您已经讲述了为自己的 Python 代码计时的基础知识。在本教程的其余部分,您将了解如何将 Python 计时器封装到一个类、一个上下文管理器和一个装饰器中,以使它更加一致和方便使用。

Remove ads

一个 Python 定时器类

回头看看您是如何将 Python 计时器添加到上面的示例中的。注意,在下载教程之前,您至少需要一个变量(tic)来存储 Python 定时器的状态。稍微研究了一下代码之后,您可能还会注意到,添加这三个突出显示的行只是为了计时!现在,您将创建一个与您手动调用perf_counter()相同的类,但是以一种更加可读和一致的方式。

在本教程中,您将创建并更新Timer,这个类可以用来以几种不同的方式为您的代码计时。带有一些额外特性的最终代码也可以在 PyPI 上以codetiming的名字获得。您可以像这样在您的系统上安装它:

$ python -m pip install codetiming

你可以在本教程后面的部分找到更多关于codetiming的信息,Python 计时器代码

理解 Python 中的类

面向对象编程的主要构件。一个本质上是一个模板,你可以用它来创建对象。虽然 Python 并不强迫你以面向对象的方式编程,但类在这种语言中无处不在。为了快速证明,研究 time模块:

>>> import time
>>> type(time)
<class 'module'>

>>> time.__class__
<class 'module'>

type()返回对象的类型。这里你可以看到模块实际上是从一个module类创建的对象。您可以使用特殊属性.__class__来访问定义对象的类。事实上,Python 中的几乎所有东西都是一个类:

>>> type(3)
<class 'int'>

>>> type(None)
<class 'NoneType'>

>>> type(print)
<class 'builtin_function_or_method'>

>>> type(type)
<class 'type'>

在 Python 中,当您需要对需要跟踪特定状态的东西建模时,类非常有用。一般来说,一个类是称为属性的属性和称为方法的行为的集合。关于类和面向对象编程的更多背景知识,请查看 Python 3 中的面向对象编程(OOP)或官方文档

创建 Python 定时器类

类有利于跟踪状态。在一个Timer类中,你想要记录一个计时器何时开始计时,以及从那时起已经过了多长时间。对于Timer的第一个实现,您将添加一个._start_time属性,以及.start().stop()方法。将以下代码添加到名为timer.py的文件中:

 1# timer.py
 2
 3import time
 4
 5class TimerError(Exception):
 6    """A custom exception used to report errors in use of Timer class"""
 7
 8class Timer:
 9    def __init__(self):
10        self._start_time = None
11
12    def start(self):
13        """Start a new timer"""
14        if self._start_time is not None:
15            raise TimerError(f"Timer is running. Use .stop() to stop it")
16
17        self._start_time = time.perf_counter()
18
19    def stop(self):
20        """Stop the timer, and report the elapsed time"""
21        if self._start_time is None:
22            raise TimerError(f"Timer is not running. Use .start() to start it")
23
24        elapsed_time = time.perf_counter() - self._start_time
25        self._start_time = None
26        print(f"Elapsed time: {elapsed_time:0.4f} seconds")

这里发生了一些不同的事情,所以花点时间一步一步地浏览代码。

在第 5 行,您定义了一个TimerError类。(Exception)符号意味着TimerError 从另一个名为Exception的类继承了。Python 使用这个内置的类进行错误处理。您不需要给TimerError添加任何属性或方法,但是拥有一个自定义错误会让您更加灵活地处理Timer内部的问题。更多信息,请查看 Python 异常:简介

Timer本身的定义从第 8 行开始。当您第一次创建或实例化一个来自类的对象时,您的代码调用特殊方法.__init__()。在Timer的第一个版本中,您只初始化了._start_time属性,它将用于跟踪您的 Python 定时器的状态。当计时器不运行时,它的值为None。一旦定时器开始运行,._start_time会跟踪定时器的启动时间。

注意:._start_time下划线 ( _)前缀是 Python 约定。它表明._start_time是一个内部属性,用户不应该操纵Timer类。

当您调用.start()来启动一个新的 Python 计时器时,您首先检查计时器是否已经运行。然后你将当前值存储在._start_time中。

另一方面,当您调用.stop()时,您首先检查 Python 计时器是否正在运行。如果是的话,那么你可以计算出perf_counter()的当前值和你存储在._start_time中的值之间的差值。最后,您重置._start_time以便计时器可以重新启动,并打印经过的时间。

下面是使用Timer的方法:

>>> from timer import Timer
>>> t = Timer()
>>> t.start()

>>> t.stop()  # A few seconds later
Elapsed time: 3.8191 seconds

将这个与之前的例子进行比较,在那里你直接使用了perf_counter()。代码的结构相当相似,但是现在代码更清晰了,这是使用类的好处之一。通过仔细选择您的类、方法和属性名,您可以使您的代码非常具有描述性!

Remove ads

使用 Python 定时器类

现在将Timer应用到latest_tutorial.py。您只需要对之前的代码做一些修改:

# latest_tutorial.py

from timer import Timer from reader import feed

def main():
    """Print the latest tutorial from Real Python"""
 t = Timer() t.start()    tutorial = feed.get_article(0)
 t.stop() 
    print(tutorial)

if __name__ == "__main__":
    main()

请注意,该代码与您之前使用的代码非常相似。除了使代码更具可读性之外,Timer还负责将经过的时间打印到控制台,这使得记录花费的时间更加一致。当您运行代码时,您将得到几乎相同的输出:

$ python latest_tutorial.py
Elapsed time: 0.6462 seconds # Python Timer Functions: Three Ways to Monitor Your Code

[ ... ]

打印从Timer开始经过的时间可能是一致的,但是这种方式似乎不是很灵活。在下一节中,您将看到如何定制您的类。

增加更多便利性和灵活性

到目前为止,您已经了解了当您想要封装状态并确保代码中行为一致时,类是合适的。在本节中,您将为 Python 计时器增加更多的便利性和灵活性:

  • 在报告花费的时间时,使用适应性文本和格式
  • 灵活的日志应用到屏幕、日志文件或程序的其他部分
  • 创建一个 Python 计时器,它可以在几次调用中累计 T1
  • 构建一个 Python 定时器的信息表示

首先,看看如何定制用于报告花费时间的文本。在前面的代码中,文本f"Elapsed time: {elapsed_time:0.4f} seconds"被硬编码为.stop()。您可以使用实例变量为类增加灵活性,实例变量的值通常作为参数传递给.__init__(),并存储为self属性。为了方便起见,您还可以提供合理的默认值。

要添加.text作为一个Timer实例变量,您可以在timer.py中这样做:

# timer.py

def __init__(self, text="Elapsed time: {:0.4f} seconds"):
    self._start_time = None
 self.text = text

请注意,默认文本"Elapsed time: {:0.4f} seconds"是作为常规字符串给出的,而不是 f 字符串。你不能在这里使用 f-string,因为 f-string 立即计算,当你实例化Timer时,你的代码还没有计算运行时间。

**注意:**如果你想用一个 f-string 来指定.text,那么你需要用双花括号来转义实际运行时间将替换的花括号。

一个例子就是f"Finished {task} in {{:0.4f}} seconds"。如果task的值是"reading",那么这个 f 字符串将被评估为"Finished reading in {:0.4f} seconds"

.stop()中,您使用.text作为模板,使用.format()来填充模板:

# timer.py

def stop(self):
    """Stop the timer, and report the elapsed time"""
    if self._start_time is None:
        raise TimerError(f"Timer is not running. Use .start() to start it")

    elapsed_time = time.perf_counter() - self._start_time
    self._start_time = None
 print(self.text.format(elapsed_time))

更新到timer.py后,您可以按如下方式更改文本:

>>> from timer import Timer
>>> t = Timer(text="You waited {:.1f} seconds")
>>> t.start()

>>> t.stop()  # A few seconds later
You waited 4.1 seconds

接下来,假设您不只是想在控制台上打印一条消息。也许您想保存您的时间测量值,以便可以将它们存储在数据库中。您可以通过从.stop()返回elapsed_time的值来实现这一点。然后,调用代码可以选择忽略该返回值或保存它供以后处理。

也许你想将Timer集成到你的日志程序中。为了支持来自Timer的日志或其他输出,您需要更改对print()的调用,以便用户可以提供他们自己的日志功能。这可以类似于您之前定制文本的方式来完成:

 1# timer.py
 2
 3# ...
 4
 5class Timer:
 6    def __init__(
 7        self,
 8        text="Elapsed time: {:0.4f} seconds",
 9        logger=print 10    ):
11        self._start_time = None
12        self.text = text
13        self.logger = logger 14
15    # Other methods are unchanged
16
17    def stop(self):
18        """Stop the timer, and report the elapsed time"""
19        if self._start_time is None:
20            raise TimerError(f"Timer is not running. Use .start() to start it")
21
22        elapsed_time = time.perf_counter() - self._start_time
23        self._start_time = None
24
25        if self.logger: 26            self.logger(self.text.format(elapsed_time)) 27
28        return elapsed_time

您没有直接使用print(),而是在第 13 行创建了另一个实例变量self.logger,它应该引用一个以字符串作为参数的函数。除了print(),你还可以在文件对象上使用类似 logging.info() 或者.write()的函数。还要注意第 25 行的if测试,它允许您通过logger=None完全关闭打印。

下面是两个展示新功能的例子:

>>> from timer import Timer
>>> import logging
>>> t = Timer(logger=logging.warning)
>>> t.start()

>>> t.stop()  # A few seconds later
WARNING:root:Elapsed time: 3.1610 seconds
3.1609658249999484

>>> t = Timer(logger=None)
>>> t.start()

>>> value = t.stop()  # A few seconds later
>>> value
4.710851433001153

当您在交互式 shell 中运行这些示例时,Python 会自动打印返回值。

您将添加的第三个改进是累积时间测量值的能力。例如,当你在一个循环中调用一个慢速函数时,你可能想这样做。您将使用一个字典以命名计时器的形式添加更多的功能,该字典跟踪您代码中的每个 Python 计时器。

假设您正在将latest_tutorial.py扩展为一个latest_tutorials.py脚本,该脚本下载并打印来自 Real Python 的十个最新教程。以下是一种可能的实现方式:

# latest_tutorials.py

from timer import Timer
from reader import feed

def main():
    """Print the 10 latest tutorials from Real Python"""
    t = Timer(text="Downloaded 10 tutorials in {:0.2f} seconds")
    t.start()
    for tutorial_num in range(10):
        tutorial = feed.get_article(tutorial_num)
        print(tutorial)
    t.stop()

if __name__ == "__main__":
    main()

代码循环遍历从 0 到 9 的数字,并将它们用作feed.get_article()的偏移参数。当您运行该脚本时,您会将大量信息打印到您的控制台:

$ python latest_tutorials.py
# Python Timer Functions: Three Ways to Monitor Your Code

[ ... The text of the tutorials ... ]
Downloaded 10 tutorials in 0.67 seconds

这段代码的一个微妙问题是,您不仅要测量下载教程所花费的时间,还要测量 Python 将教程打印到屏幕上所花费的时间。这可能没那么重要,因为与下载时间相比,打印时间可以忽略不计。尽管如此,在这种情况下,有一种方法可以精确地确定你所追求的是什么,这将是一件好事。

**注意:**下载十个教程所花的时间和下载一个教程所花的时间差不多。这不是你代码中的错误!相反,reader在第一次调用get_article()时缓存真正的 Python 提要,并在以后的调用中重用这些信息。

有几种方法可以在不改变当前Timer.实现的情况下解决这个问题。然而,支持这个用例将会非常有用,你只需要几行代码就可以做到。

首先,您将引入一个名为.timers的字典作为Timer上的类变量,这意味着Timer的所有实例将共享它。您可以通过在任何方法之外定义它来实现它:

class Timer:
    timers = {}

可以直接在类上或通过类的实例来访问类变量:

>>> from timer import Timer
>>> Timer.timers
{}

>>> t = Timer()
>>> t.timers
{}

>>> Timer.timers is t.timers
True

在这两种情况下,代码都返回相同的空类字典。

接下来,您将向 Python 计时器添加可选名称。您可以将该名称用于两个不同的目的:

  1. 在代码中查找经过的时间
  2. 累积同名的个计时器

要向 Python 计时器添加名称,需要对timer.py再做两处修改。首先,Timer应该接受name作为参数。第二,当定时器停止时,经过的时间应该加到.timers:

 1# timer.py
 2
 3# ...
 4
 5class Timer:
 6    timers = {} 7
 8    def __init__(
 9        self,
10        name=None, 11        text="Elapsed time: {:0.4f} seconds",
12        logger=print,
13    ):
14        self._start_time = None
15        self.name = name 16        self.text = text
17        self.logger = logger
18
19        # Add new named timers to dictionary of timers 20        if name: 21            self.timers.setdefault(name, 0) 22
23    # Other methods are unchanged
24
25    def stop(self):
26        """Stop the timer, and report the elapsed time"""
27        if self._start_time is None:
28            raise TimerError(f"Timer is not running. Use .start() to start it")
29
30        elapsed_time = time.perf_counter() - self._start_time
31        self._start_time = None
32
33        if self.logger:
34            self.logger(self.text.format(elapsed_time))
35        if self.name: 36            self.timers[self.name] += elapsed_time 37
38        return elapsed_time

注意,在向.timers添加新的 Python 定时器时,使用了.setdefault()。这是一个很棒的特性,它只在name还没有在字典中定义的情况下设置值。如果name已经在.timers中使用,则该值保持不变。这允许您累积几个计时器:

>>> from timer import Timer
>>> t = Timer("accumulate")
>>> t.start()

>>> t.stop()  # A few seconds later
Elapsed time: 3.7036 seconds
3.703554293999332

>>> t.start()

>>> t.stop()  # A few seconds later
Elapsed time: 2.3449 seconds
2.3448921170001995

>>> Timer.timers
{'accumulate': 6.0484464109995315}

您现在可以重新访问latest_tutorials.py,并确保只计算下载教程所花费的时间:

# latest_tutorials.py

from timer import Timer
from reader import feed

def main():
    """Print the 10 latest tutorials from Real Python"""
 t = Timer("download", logger=None)    for tutorial_num in range(10):
 t.start()        tutorial = feed.get_article(tutorial_num)
 t.stop()        print(tutorial)

 download_time = Timer.timers["download"] print(f"Downloaded 10 tutorials in {download_time:0.2f} seconds") 
if __name__ == "__main__":
    main()

重新运行该脚本将给出与前面类似的输出,尽管现在您只是对教程的实际下载进行计时:

$ python latest_tutorials.py
# Python Timer Functions: Three Ways to Monitor Your Code

[ ... The text of the tutorials ... ]
Downloaded 10 tutorials in 0.65 seconds

你将对Timer做的最后一个改进是,当你交互地使用它时,它会提供更多的信息。尝试以下方法:

>>> from timer import Timer
>>> t = Timer()
>>> t
<timer.Timer object at 0x7f0578804320>

最后一行是 Python 表示对象的默认方式。虽然您可以从中收集一些信息,但通常不是很有用。相反,最好能看到类似于Timer的名字,或者它将如何报告时间的信息。

在 Python 3.7 中,数据类被添加到标准库中。这些为您的类提供了一些便利,包括更丰富的表示字符串。

**注意:**数据类仅包含在 Python 3.7 及更高版本中。然而,Python 3.6 的 PyPI 上有一个反向端口

您可以使用pip来安装它:

$ python -m pip install dataclasses

更多信息请参见 Python 3.7+(指南)中的数据类。

使用@dataclass装饰器将 Python 定时器转换成数据类。在本教程的后面,你会学到更多关于装饰师的知识。现在,你可以把这看作是告诉 PythonTimer是一个数据类的符号:

 1# timer.py
 2
 3import time
 4from dataclasses import dataclass, field
 5from typing import Any, ClassVar
 6
 7# ...
 8
 9@dataclass
10class Timer:
11    timers: ClassVar = {}
12    name: Any = None
13    text: Any = "Elapsed time: {:0.4f} seconds"
14    logger: Any = print
15    _start_time: Any = field(default=None, init=False, repr=False)
16
17    def __post_init__(self):
18        """Initialization: add timer to dict of timers"""
19        if self.name:
20            self.timers.setdefault(self.name, 0)
21
22    # The rest of the code is unchanged

这段代码取代了您之前的.__init__()方法。请注意数据类如何使用看起来类似于您在前面看到的用于定义所有变量的类变量语法的语法。事实上,.__init__()是根据类定义中的注释变量自动为数据类创建的。

要使用数据类,您需要对变量进行注释。您可以使用该注释将类型提示添加到代码中。如果你不想使用类型提示,那么你可以用Any来注释所有的变量,就像上面所做的一样。您将很快学会如何向数据类添加实际的类型提示。

以下是关于Timer数据类的一些注意事项:

  • 第 9 行:@dataclass装饰器将Timer定义为一个数据类。

  • **第 11 行:**数据类需要特殊的ClassVar注释来指定.timers是一个类变量。

  • 第 12 到 14 行: .name.text.logger将被定义为Timer上的属性,其值可以在创建Timer实例时指定。它们都有给定的默认值。

  • **第 15 行:**回想一下._start_time是一个特殊的属性,用于跟踪 Python 定时器的状态,但是它应该对用户隐藏。利用dataclasses.field(),你说._start_time应该从.__init__()Timer的表象中去掉。

  • **第 17 到 20 行:**除了设置实例属性,您还可以使用特殊的.__post_init__()方法进行任何需要的初始化。在这里,您使用它将命名计时器添加到.timers

新的Timer数据类的工作方式与之前的常规类一样,只是它现在有了一个很好的表示:

>>> from timer import Timer
>>> t = Timer()
>>> t
Timer(name=None, text='Elapsed time: {:0.4f} seconds',
 logger=<built-in function print>)

>>> t.start()

>>> t.stop()  # A few seconds later
Elapsed time: 6.7197 seconds
6.719705373998295

现在你有了一个非常简洁的版本Timer,它是一致的、灵活的、方便的、信息丰富的!您也可以将本节中所做的许多改进应用到项目中的其他类型的类中。

在结束这一部分之前,重新看看目前的完整源代码。您会注意到在代码中添加了类型提示,以获得额外的文档:

# timer.py

from dataclasses import dataclass, field
import time
from typing import Callable, ClassVar, Dict, Optional

class TimerError(Exception):
    """A custom exception used to report errors in use of Timer class"""

@dataclass
class Timer:
    timers: ClassVar[Dict[str, float]] = {}
    name: Optional[str] = None
    text: str = "Elapsed time: {:0.4f} seconds"
    logger: Optional[Callable[[str], None]] = print
    _start_time: Optional[float] = field(default=None, init=False, repr=False)

    def __post_init__(self) -> None:
        """Add timer to dict of timers after initialization"""
        if self.name is not None:
            self.timers.setdefault(self.name, 0)

    def start(self) -> None:
        """Start a new timer"""
        if self._start_time is not None:
            raise TimerError(f"Timer is running. Use .stop() to stop it")

        self._start_time = time.perf_counter()

    def stop(self) -> float:
        """Stop the timer, and report the elapsed time"""
        if self._start_time is None:
            raise TimerError(f"Timer is not running. Use .start() to start it")

        # Calculate elapsed time
        elapsed_time = time.perf_counter() - self._start_time
        self._start_time = None

        # Report elapsed time
        if self.logger:
            self.logger(self.text.format(elapsed_time))
        if self.name:
            self.timers[self.name] += elapsed_time

        return elapsed_time

使用类创建 Python 计时器有几个好处:

  • 可读性:如果你仔细选择类名和方法名,你的代码读起来会更自然。
  • 一致性:如果你将属性和行为封装到属性和方法中,你的代码会更容易使用。
  • **灵活性:**如果您使用带有默认值的属性,而不是硬编码的值,您的代码将是可重用的。

这个类非常灵活,您几乎可以在任何想要监控代码运行时间的情况下使用它。然而,在接下来的部分中,您将学习如何使用上下文管理器和装饰器,这对于定时代码块和函数来说更加方便。

Remove ads

Python 定时器上下文管理器

您的 Python Timer类已经取得了很大的进步!与你创建的第一个Python 定时器相比,你的代码已经变得相当强大了。然而,仍然有一些样板代码是使用您的Timer所必需的:

  1. 首先,实例化该类。
  2. 在你想要计时的代码块之前调用.start()
  3. 代码块后调用.stop()

幸运的是,Python 有一个在代码块前后调用函数的独特构造:上下文管理器**。在本节中,您将了解什么是上下文管理器和 Python 的with语句,以及如何创建自己的上下文管理器。然后您将扩展Timer,这样它也可以作为上下文管理器工作。最后,您将看到使用Timer作为上下文管理器如何简化您的代码。*

*### 理解 Python 中的上下文管理器

上下文管理器成为 Python 的一部分已经有很长时间了。它们是由 PEP 343 在 2005 年提出的,并在 Python 2.5 中首次实现。您可以通过使用 with 关键字来识别代码中的上下文管理器:

with EXPRESSION as VARIABLE:
    BLOCK

EXPRESSION是返回上下文管理器的 Python 表达式。上下文管理器可选地绑定到名称VARIABLE。最后,BLOCK是任何常规的 Python 代码块。上下文管理器将保证你的程序在BLOCK之前调用一些代码,在BLOCK执行之后调用另一些代码。后者会发生,即使BLOCK引发异常。

上下文管理器最常见的用途可能是处理不同的资源,比如文件、锁和数据库连接。在您使用完资源后,上下文管理器将用于释放和清理资源。下面的例子通过打印包含冒号的行揭示了timer.py的基本结构。更重要的是,它展示了在 Python 中打开文件的通用习语:

>>> with open("timer.py") as fp:
...     print("".join(ln for ln in fp if ":" in ln))
...
class TimerError(Exception):
class Timer:
 timers: ClassVar[Dict[str, float]] = {}
 name: Optional[str] = None
 text: str = "Elapsed time: {:0.4f} seconds"
 logger: Optional[Callable[[str], None]] = print
 _start_time: Optional[float] = field(default=None, init=False, repr=False)
 def __post_init__(self) -> None:
 if self.name is not None:
 def start(self) -> None:
 if self._start_time is not None:
 def stop(self) -> float:
 if self._start_time is None:
 if self.logger:
 if self.name:

请注意,文件指针fp从未被显式关闭,因为您使用了open()作为上下文管理器。您可以确认fp已经自动关闭:

>>> fp.closed
True

在本例中,open("timer.py")是一个返回上下文管理器的表达式。该上下文管理器被绑定到名称fp。上下文管理器在print()执行期间有效。这一行代码块在fp的上下文中执行。

fp是上下文管理器是什么意思?从技术上讲,这意味着fp实现了上下文管理器协议。Python 语言下有许多不同的协议。您可以将协议视为一个契约,它规定了您的代码必须实现哪些特定的方法。

上下文管理器协议由两种方法组成:

  1. 进入与上下文管理器相关的上下文时,调用.__enter__()
  2. 退出与上下文管理器相关的上下文时,调用.__exit__()

换句话说,要自己创建一个上下文管理器,需要编写一个实现.__enter__().__exit__()的类。不多不少。试试*你好,世界!*上下文管理器示例:

# greeter.py

class Greeter:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"Hello {self.name}")
        return self

    def __exit__(self, exc_type, exc_value, exc_tb):
        print(f"See you later, {self.name}")

Greeter是上下文管理器,因为它实现了上下文管理器协议。你可以这样使用它:

>>> from greeter import Greeter
>>> with Greeter("Akshay"):
...     print("Doing stuff ...")
...
Hello Akshay
Doing stuff ...
See you later, Akshay

首先,注意如何在你做事情之前调用.__enter__(),而在之后调用.__exit__()。在这个简化的例子中,您没有引用上下文管理器。在这种情况下,您不需要给上下文管理器起一个带有as的名字。

接下来,注意.__enter__()如何返回self.__enter__()的返回值被as绑定。在创建上下文管理器时,通常希望从.__enter__()返回self。您可以按如下方式使用返回值:

>>> from greeter import Greeter
>>> with Greeter("Bethan") as grt:
...     print(f"{grt.name} is doing stuff ...")
...
Hello Bethan
Bethan is doing stuff ...
See you later, Bethan

最后,.__exit__()带三个参数:exc_typeexc_valueexc_tb。这些用于上下文管理器中的错误处理,它们反映了sys.exc_info()返回值。

如果在执行代码块时发生了异常,那么您的代码会调用带有异常类型、异常实例和回溯对象的.__exit__()。通常,您可以在上下文管理器中忽略这些,在这种情况下,会在重新引发异常之前调用.__exit__():

>>> from greeter import Greeter
>>> with Greeter("Rascal") as grt:
...     print(f"{grt.age} does not exist")
...
Hello Rascal
See you later, Rascal Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
AttributeError: 'Greeter' object has no attribute 'age'

您可以看到"See you later, Rascal"被打印出来,尽管代码中有一个错误。

现在您知道了什么是上下文管理器,以及如何创建自己的上下文管理器。如果你想更深入,那么在标准库中查看 contextlib 。它包括定义新的上下文管理器的方便方法,以及现成的上下文管理器,您可以使用它们来关闭对象抑制错误,甚至什么都不做!更多信息,请查看上下文管理器和 Python 的with语句

Remove ads

创建 Python 定时器上下文管理器

您已经看到了上下文管理器一般是如何工作的,但是它们如何帮助处理计时代码呢?如果您可以在代码块之前和之后运行某些函数,那么您就可以简化 Python 计时器的工作方式。到目前为止,在为代码计时时,您需要显式调用.start().stop(),但是上下文管理器可以自动完成这项工作。

同样,对于作为上下文管理器工作的Timer,它需要遵守上下文管理器协议。换句话说,它必须实现.__enter__().__exit__()来启动和停止 Python 定时器。所有必要的功能都已经可用,所以不需要编写太多新代码。只需将以下方法添加到您的Timer类中:

# timer.py

# ...

@dataclass
class Timer:
    # The rest of the code is unchanged

    def __enter__(self):
        """Start a new timer as a context manager"""
        self.start()
        return self

    def __exit__(self, *exc_info):
        """Stop the context manager timer"""
        self.stop()

Timer现在是上下文管理器。实现的重要部分是当进入上下文时,.__enter__()调用.start()启动 Python 定时器,当代码离开上下文时,.__exit__()使用.stop()停止 Python 定时器。尝试一下:

>>> from timer import Timer
>>> import time
>>> with Timer():
...     time.sleep(0.7)
...
Elapsed time: 0.7012 seconds

您还应该注意两个更微妙的细节:

  1. .__enter__() 返回selfTimer实例,允许用户使用asTimer实例绑定到变量。例如,with Timer() as t:将创建指向Timer对象的变量t

  2. .__exit__() 期望三个参数带有关于在上下文执行期间发生的任何异常的信息。在您的代码中,这些参数被打包到一个名为exc_info的元组中,然后被忽略,这意味着Timer不会尝试任何异常处理。

.__exit__()在这种情况下不做任何错误处理。尽管如此,上下文管理器的一个重要特性是,无论上下文如何退出,它们都保证调用.__exit__()。在以下示例中,您通过除以零故意制造了一个误差:

>>> from timer import Timer
>>> with Timer():
...     for num in range(-3, 3):
...         print(f"1 / {num} = {1 / num:.3f}")
...
1 / -3 = -0.333
1 / -2 = -0.500
1 / -1 = -1.000
Elapsed time: 0.0001 seconds Traceback (most recent call last):
  File "<stdin>", line 3, in <module>
ZeroDivisionError: division by zero

请注意,Timer打印出运行时间,即使代码崩溃。可以检查和抑制.__exit__()中的错误。更多信息参见文档

使用 Python 定时器上下文管理器

现在您将学习如何使用Timer上下文管理器来为真正的 Python 教程下载计时。回想一下您之前是如何使用Timer的:

# latest_tutorial.py

from timer import Timer
from reader import feed

def main():
    """Print the latest tutorial from Real Python"""
    t = Timer()
    t.start()
    tutorial = feed.get_article(0)
    t.stop()

    print(tutorial)

if __name__ == "__main__":
    main()

您正在为呼叫feed.get_article()计时。您可以使用上下文管理器使代码更短、更简单、更易读:

# latest_tutorial.py

from timer import Timer
from reader import feed

def main():
    """Print the latest tutorial from Real Python"""
 with Timer():        tutorial = feed.get_article(0)

    print(tutorial)

if __name__ == "__main__":
    main()

这段代码实际上和上面的代码做的一样。主要的区别在于,您没有定义无关变量t,这使得您的名称空间更加清晰。

运行该脚本应该会得到一个熟悉的结果:

$ python latest_tutorial.py
Elapsed time: 0.71 seconds # Python Timer Functions: Three Ways to Monitor Your Code

[ ... ]

将上下文管理器功能添加到 Python 计时器类中有几个好处:

  • **省力:**你只需要一行额外的代码来计时一段代码的执行。
  • **可读性:**调用上下文管理器是可读的,您可以更清楚地可视化您正在计时的代码块。

使用Timer作为上下文管理器几乎和直接使用.start().stop()一样灵活,而且样板代码更少。在下一节中,您将学习如何使用Timer作为装饰器。这将使监控完整函数的运行时变得更加容易。

Remove ads

一个 Python 定时器装饰器

你的Timer课现在很全能。然而,有一个用例您可以进一步简化它。假设您想要跟踪代码库中一个给定函数所花费的时间。使用上下文管理器,您有两种不同的选择:

  1. 每次调用函数时使用Timer:

    with Timer("some_name"):
        do_something()` 
    

    如果你在很多地方调用do_something(),那么这将变得很繁琐,很难维护。

  2. 将函数中的代码包装在上下文管理器中:

    def do_something():
        with Timer("some_name"):
            ...` 
    

    只需要在一个地方添加Timer,但是这给do_something()的整个定义增加了一级缩进。

更好的解决方案是使用Timer作为装饰器。装饰器是用来修改函数和类的行为的强大构造。在这一节中,您将了解装饰器是如何工作的,如何将Timer扩展为装饰器,以及这将如何简化计时功能。关于装饰者的更深入的解释,请参见【Python 装饰者入门

理解 Python 中的装饰者

一个装饰器是一个包装另一个函数来修改其行为的函数。这种技术是可行的,因为函数是 Python 中的一级对象。换句话说,函数可以赋给变量,也可以用作其他函数的参数,就像任何其他对象一样。这为您提供了很大的灵活性,并且是 Python 几个最强大特性的基础。

作为第一个例子,您将创建一个什么都不做的装饰器:

def turn_off(func):
    return lambda *args, **kwargs: None

首先,注意turn_off()只是一个常规函数。使它成为装饰器的是,它将一个函数作为唯一的参数,并返回一个函数。您可以使用turn_off()来修改其他功能,如下所示:

>>> print("Hello")
Hello
  >>> print = turn_off(print)
>>> print("Hush")
>>> # Nothing is printed

print = turn_off(print) 行用turn_off()修饰符修饰打印语句。实际上,它用由turn_off()返回的lambda *args, **kwargs: None代替了print()lambda 语句表示一个除了返回None之外什么也不做的匿名函数。

要定义更多有趣的装饰器,你需要了解内部函数。一个内部函数是定义在另一个函数内部的函数。内部函数的一个常见用途是创建函数工厂:

def create_multiplier(factor):
    def multiplier(num):
        return factor * num
    return multiplier

multiplier()是一个内部函数,定义在create_multiplier()内部。请注意,您可以访问multiplier()内的factor,而create_multiplier()外的multiplier()没有定义:

>>> multiplier
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'multiplier' is not defined

相反,您可以使用create_multiplier()来创建新的乘数函数,每个函数都基于不同的因子:

>>> double = create_multiplier(factor=2)
>>> double(3)
6

>>> quadruple = create_multiplier(factor=4)
>>> quadruple(7)
28

同样,您可以使用内部函数来创建装饰器。记住,装饰器是一个返回函数的函数:

 1def triple(func):
 2    def wrapper_triple(*args, **kwargs):
 3        print(f"Tripled {func.__name__!r}")
 4        value = func(*args, **kwargs)
 5        return value * 3
 6    return wrapper_triple

triple()是一个装饰器,因为它是一个期望函数func()作为其唯一参数并返回另一个函数wrapper_triple()的函数。注意triple()本身的结构:

  • 第 1 行开始定义triple(),并期望一个函数作为参数。
  • 第 2 到 5 行定义了内部函数wrapper_triple()
  • 第 6 行返回wrapper_triple()

这种模式普遍用于定义装饰者。有趣的部分发生在内部函数中:

  • 第 2 行开始定义wrapper_triple()。这个函数将取代triple()修饰的函数。参数是 *args**kwargs ,它们收集您传递给函数的任何位置和关键字参数。这给了你在任何函数上使用triple()的灵活性。
  • 第 3 行打印出被修饰函数的名称,并注意到triple()已经被应用于它。
  • 第 4 行调用func()triple()修饰过的功能。它将传递给wrapper_triple()的所有参数。
  • 第 5 行func()的返回值三倍并返回。

试试吧!knock()是返回单词 Penny 的函数。看看如果增加三倍会发生什么:

>>> def knock():
...     return "Penny! "
...
>>> knock = triple(knock)
>>> result = knock()
Tripled 'knock'

>>> result
'Penny! Penny! Penny! '

将一个文本字符串乘以一个数字是一种重复形式,所以Penny重复三次。装饰发生在knock = triple(knock)

一直重复knock感觉有点笨拙。相反, PEP 318 引入了一个更方便的语法来应用装饰器。下面的knock()定义与上面的定义相同:

>>> @triple
... def knock():
...     return "Penny! "
...
>>> result = knock()
Tripled 'knock'

>>> result
'Penny! Penny! Penny! '

符号用来应用装饰符。在这种情况下,@triple意味着triple()被应用于紧随其后定义的函数。

标准库中定义的少数装饰者之一是@functools.wraps。在定义自己的装饰者时,这一条非常有用。因为装饰者有效地用一个函数替换了另一个函数,所以他们给你的函数制造了一个微妙的问题:

>>> knock
<function triple.<locals>.wrapper_triple at 0x7fa3bfe5dd90>

@triple修饰knock(),然后被wrapper_triple()内部函数替换,正如上面的输出所证实的。这也将替换名称、文档字符串和其他元数据。通常,这不会有太大的效果,但会使自省变得困难。

有时,修饰函数必须有正确的元数据。@functools.wraps解决了这个问题:

import functools 
def triple(func):
 @functools.wraps(func)    def wrapper_triple(*args, **kwargs):
        print(f"Tripled {func.__name__!r}")
        value = func(*args, **kwargs)
        return value * 3
    return wrapper_triple

使用这个新的@triple定义,元数据被保留:

>>> @triple
... def knock():
...     return "Penny! "
...
>>> knock
<function knock at 0x7fa3bfe5df28>

请注意,knock()现在保持其正确的名称,即使在装饰之后。在定义装饰器时使用@functools.wraps是一种好的形式。您可以为大多数装饰者使用的蓝图如下:

import functools

def decorator(func):
    @functools.wraps(func)
    def wrapper_decorator(*args, **kwargs):
        # Do something before
        value = func(*args, **kwargs)
        # Do something after
        return value
    return wrapper_decorator

要查看更多关于如何定义 decorator 的例子,请查看 Python Decorators 入门教程中列出的例子。

Remove ads

创建 Python 计时器装饰器

在这一节中,您将学习如何扩展您的 Python 计时器,以便您也可以将它用作装饰器。然而,作为第一个练习,您将从头开始创建一个 Python 计时器装饰器。

基于上面的蓝图,您只需要决定在调用修饰函数之前和之后做什么。这类似于在进入和退出上下文管理器时要做什么的考虑。您希望在调用修饰函数之前启动 Python 计时器,并在调用完成后停止 Python 计时器。您可以定义一个 @timer装饰者,如下所示:

import functools
import time

def timer(func):
    @functools.wraps(func)
    def wrapper_timer(*args, **kwargs):
        tic = time.perf_counter()
        value = func(*args, **kwargs)
        toc = time.perf_counter()
        elapsed_time = toc - tic
        print(f"Elapsed time: {elapsed_time:0.4f} seconds")
        return value
    return wrapper_timer

注意wrapper_timer()与您为计时 Python 代码建立的早期模式有多么相似。您可以如下应用@timer:

>>> @timer
... def latest_tutorial():
...     tutorial = feed.get_article(0)
...     print(tutorial)
...
>>> latest_tutorial()
# Python Timer Functions: Three Ways to Monitor Your Code

[ ... ]
Elapsed time: 0.5414 seconds

回想一下,您还可以将装饰器应用于先前定义的函数:

>>> feed.get_article = timer(feed.get_article)

因为@在定义函数时适用,所以在这些情况下需要使用更基本的形式。使用装饰器的一个好处是你只需要应用一次,它每次都会计算函数的时间:

>>> tutorial = feed.get_article(0)
Elapsed time: 0.5512 seconds

做这项工作。然而,在某种意义上,你又回到了起点,因为@timer没有Timer的任何灵活性或便利性。你也能让你的Timer类表现得像一个装饰者吗?

到目前为止,您已经将 decorators 用作应用于其他函数的函数,但这并不完全正确。装饰者必须是可召唤者。Python 中有很多可调用类型。您可以通过在自己的类中定义特殊的.__call__()方法来使自己的对象可调用。以下函数和类的行为类似:

>>> def square(num):
...     return num ** 2
...
>>> square(4)
16

>>> class Squarer:
...     def __call__(self, num):
...         return num ** 2
...
>>> square = Squarer()
>>> square(4)
16

这里,square是一个可调用的实例,可以平方数字,就像第一个例子中的square()函数一样。

这为您提供了一种向现有的Timer类添加装饰功能的方法:

# timer.py

import functools

# ...

@dataclass
class Timer:

    # The rest of the code is unchanged

    def __call__(self, func):
        """Support using Timer as a decorator"""
        @functools.wraps(func)
        def wrapper_timer(*args, **kwargs):
            with self:
                return func(*args, **kwargs)

        return wrapper_timer

.__call__()利用Timer已经是一个上下文管理器的事实来利用您已经在那里定义的便利。确定你也在timer.py上方导入 functools

您现在可以使用Timer作为装饰器:

>>> @Timer(text="Downloaded the tutorial in {:.2f} seconds")
... def latest_tutorial():
...     tutorial = feed.get_article(0)
...     print(tutorial)
...
>>> latest_tutorial()
# Python Timer Functions: Three Ways to Monitor Your Code

[ ... ]
Downloaded the tutorial in 0.72 seconds

在结束这一部分之前,要知道有一种更直接的方法可以将 Python 计时器变成装饰器。您已经看到了上下文管理器和装饰器之间的一些相似之处。它们通常都用于在执行给定代码之前和之后做一些事情。

基于这些相似性,标准库中定义了一个 mixin 类,称为 ContextDecorator 。您可以简单地通过继承ContextDecorator来为您的上下文管理器类添加装饰功能:

from contextlib import ContextDecorator

# ...

@dataclass
class Timer(ContextDecorator):
    # Implementation of Timer is unchanged

当你以这种方式使用ContextDecorator时,没有必要自己实现.__call__(),所以你可以安全地从Timer类中删除它。

Remove ads

使用 Python 计时器装饰器

接下来,您将最后一次重做latest_tutorial.py示例,使用 Python 定时器作为装饰器:

 1# latest_tutorial.py
 2
 3from timer import Timer
 4from reader import feed
 5
 6@Timer() 7def main():
 8    """Print the latest tutorial from Real Python"""
 9    tutorial = feed.get_article(0)
10    print(tutorial)
11
12if __name__ == "__main__":
13    main()

如果您将这个实现与没有任何计时的原始实现进行比较,那么您会注意到唯一的区别是第 3 行Timer的导入和第 6 行@Timer()的应用。使用 decorators 的一个显著优点是它们通常很容易应用,正如你在这里看到的。

然而,装饰器仍然适用于整个函数。这意味着除了下载时间之外,您的代码还考虑了打印教程所需的时间。最后一次运行脚本:

$ python latest_tutorial.py
# Python Timer Functions: Three Ways to Monitor Your Code

[ ... ]
Elapsed time: 0.69 seconds

运行时间输出的位置是一个信号,表明您的代码也在考虑打印所花费的时间。正如您在这里看到的,您的代码在教程的之后打印了经过的时间*。*

当您使用Timer作为装饰器时,您会看到与使用上下文管理器类似的优势:

  • 你只需要一行额外的代码来计时一个函数的执行。
  • **可读性:**当您添加装饰器时,您可以更清楚地注意到您的代码将为函数计时。
  • **一致性:**你只需要在定义函数的时候添加装饰器。每次调用时,您的代码都会持续计时。

然而,装饰器不像上下文管理器那样灵活。您只能将它们应用于完整的功能。可以在已经定义的函数中添加装饰器,但是这有点笨拙,也不太常见。

Python 定时器代码

您可以展开下面的代码块来查看 Python 计时器的最终源代码:

# timer.py

import time
from contextlib import ContextDecorator
from dataclasses import dataclass, field
from typing import Any, Callable, ClassVar, Dict, Optional

class TimerError(Exception):
    """A custom exception used to report errors in use of Timer class"""

@dataclass
class Timer(ContextDecorator):
    """Time your code using a class, context manager, or decorator"""

    timers: ClassVar[Dict[str, float]] = {}
    name: Optional[str] = None
    text: str = "Elapsed time: {:0.4f} seconds"
    logger: Optional[Callable[[str], None]] = print
    _start_time: Optional[float] = field(default=None, init=False, repr=False)

    def __post_init__(self) -> None:
        """Initialization: add timer to dict of timers"""
        if self.name:
            self.timers.setdefault(self.name, 0)

    def start(self) -> None:
        """Start a new timer"""
        if self._start_time is not None:
            raise TimerError(f"Timer is running. Use .stop() to stop it")

        self._start_time = time.perf_counter()

    def stop(self) -> float:
        """Stop the timer, and report the elapsed time"""
        if self._start_time is None:
            raise TimerError(f"Timer is not running. Use .start() to start it")

        # Calculate elapsed time
        elapsed_time = time.perf_counter() - self._start_time
        self._start_time = None

        # Report elapsed time
        if self.logger:
            self.logger(self.text.format(elapsed_time))
        if self.name:
            self.timers[self.name] += elapsed_time

        return elapsed_time

    def __enter__(self) -> "Timer":
        """Start a new timer as a context manager"""
        self.start()
        return self

    def __exit__(self, *exc_info: Any) -> None:
        """Stop the context manager timer"""
        self.stop()

GitHub 上的 codetiming中也有该代码。

您可以将代码保存到一个名为timer.py的文件中,然后导入到您的程序中,这样您就可以自己使用代码了:

>>> from timer import Timer

TimerPyPI 上也有,所以更简单的选择是使用 pip 安装它:

$ python -m pip install codetiming

注意 PyPI 上的包名是codetiming。在安装软件包和导入Timer时,您都需要使用这个名称:

>>> from codetiming import Timer

除了名字和的一些额外特性codetiming.Timer的工作方式与timer.Timer完全一样。总而言之,你可以用三种不同的方式使用Timer:

  1. 作为:

    t = Timer(name="class")
    t.start()
    # Do something
    t.stop()` 
    
  2. 作为上下文管理器:

    with Timer(name="context manager"):
        # Do something` 
    
  3. 作为一名装饰师:

    @Timer(name="decorator")
    def stuff():
        # Do something` 
    

这种 Python 计时器主要用于监控代码在单个关键代码块或函数上花费的时间。在下一节中,如果您想要优化代码,您将得到一个备选方案的快速概述。

Remove ads

其他 Python 定时器函数

使用 Python 为代码计时有很多选择。在本教程中,您已经学习了如何创建一个灵活方便的类,您可以用几种不同的方式来使用它。在 PyPI 上快速搜索显示已经有许多项目提供 Python 定时器解决方案。

在本节中,您将首先了解标准库中用于测量时间的不同函数,包括为什么perf_counter()更好。然后,您将探索优化代码的替代方法,而Timer并不适合。

使用替代的 Python 定时器函数

在本教程中,您一直在使用perf_counter()来进行实际的时间测量,但是 Python 的time库附带了其他几个也可以测量时间的函数。以下是一些备选方案:

拥有多个函数的一个原因是 Python 将时间表示为一个float。浮点数本质上是不准确的。您可能以前见过这样的结果:

>>> 0.1 + 0.1 + 0.1
0.30000000000000004

>>> 0.1 + 0.1 + 0.1 == 0.3
False

Python 的float遵循浮点运算的 IEEE 754 标准,试图用 64 位表示所有浮点数。因为浮点数有无限多种,你不可能用有限的位数全部表示出来。

IEEE 754 规定了一个系统,在这个系统中,您可以表示的数字密度是变化的。你越接近一,你能代表的数字就越多。对于更大的数字,你可以表达的数字之间有更多的空间。当你用一个float来表示时间时,这会产生一些后果。

考虑一下time()。这个函数的主要目的是表示现在的实际时间。这是从给定时间点开始的秒数,称为时期time()返回的数字相当大,这意味着可用的数字较少,分辨率受到影响。具体来说,time()无法测量纳秒的差异:

>>> import time
>>> t = time.time()
>>> t
1564342757.0654016

>>> t + 1e-9
1564342757.0654016

>>> t == t + 1e-9
True

一纳秒是十亿分之一秒。注意,给t加一纳秒不会影响结果。另一方面,perf_counter()使用某个未定义的时间点作为其历元,允许其使用较小的数字,从而获得更好的分辨率:

>>> import time
>>> p = time.perf_counter()
>>> p
11370.015653846

>>> p + 1e-9
11370.015653847

>>> p == p + 1e-9
False

这里,您会注意到在p上增加一纳秒实际上会影响结果。有关如何使用time()的更多信息,请参见Python 时间模块的初学者指南。

float表示时间的挑战是众所周知的,所以 Python 3.7 引入了一个新的选项。每个time测量函数现在都有一个相应的_ns函数,它返回纳秒数作为int,而不是秒数作为float。例如,time()现在有了一个纳秒级的对应物,叫做time_ns():

>>> import time
>>> time.time_ns()
1564342792866601283

在 Python 中整数是无限的,所以这允许time_ns()永远给出纳秒级的分辨率。类似地,perf_counter_ns()perf_counter()的纳秒变体:

>>> import time
>>> time.perf_counter()
13580.153084446

>>> time.perf_counter_ns()
13580765666638

因为perf_counter()已经提供了纳秒级的分辨率,所以使用perf_counter_ns()的优势更少。

注意: perf_counter_ns()仅在 Python 3.7 及更高版本中可用。在本教程中,你已经在你的Timer类中使用了perf_counter()。这样,您也可以在旧版本的 Python 上使用Timer

有关time_ns函数的更多信息,请查看 Python 3.7 中的新功能。

time中有两个函数不测量睡眠花费的时间。这些是process_time()thread_time(),在某些设置中很有用。然而,对于Timer,您通常想要测量花费的全部时间。上面列表中的最后一个函数是monotonic()。这个名字暗示这个函数是一个单调计时器,是一个永远不能向后移动的 Python 计时器。

所有这些功能都是单调的,只有time()除外,如果调整系统时间,它可以倒退。在某些系统上,monotonic()perf_counter()的功能相同,可以互换使用。然而,情况并非总是如此。您可以使用time.get_clock_info()获得关于 Python 定时器函数的更多信息:

>>> import time
>>> time.get_clock_info("monotonic")
namespace(adjustable=False, implementation='clock_gettime(CLOCK_MONOTONIC)',
 monotonic=True, resolution=1e-09)

>>> time.get_clock_info("perf_counter")
namespace(adjustable=False, implementation='clock_gettime(CLOCK_MONOTONIC)',
 monotonic=True, resolution=1e-09)

在您的系统上,结果可能有所不同。

PEP 418 描述了引入这些功能背后的一些基本原理。它包括以下简短描述:

  • time.monotonic(): Timeout and scheduling, not affected by system clock update.
  • time.perf_counter(): benchmark test, the most accurate short-period clock.
  • time.process_time(): profiling, CPU time of the process ( source )

可以看出,perf_counter()通常是 Python 计时器的最佳选择。

Remove ads

timeit 估算运行时间

假设您试图从代码中挤出最后一点性能,并且您想知道将列表转换为集合的最有效方法。您希望使用set()和设置的文字{...}进行比较。为此,您可以使用 Python 计时器:

>>> from timer import Timer
>>> numbers = [7, 6, 1, 4, 1, 8, 0, 6]
>>> with Timer(text="{:.8f}"):
...     set(numbers)
...
{0, 1, 4, 6, 7, 8}
0.00007373 
>>> with Timer(text="{:.8f}"):
...     {*numbers}
...
{0, 1, 4, 6, 7, 8}
0.00006204

这个测试似乎表明 set literal 可能会稍微快一些。然而,这些结果是相当不确定的,如果您重新运行代码,您可能会得到非常不同的结果。那是因为你只试了一次代码。例如,您可能很不走运,在您的计算机正忙于其他任务时运行该脚本。

更好的方法是使用 timeit 标准库。它旨在精确测量小代码片段的执行时间。虽然您可以作为常规函数从 Python 导入和调用timeit.timeit(),但是使用命令行接口通常更方便。您可以对两个变量计时,如下所示:

$ python -m timeit --setup "nums = [7, 6, 1, 4, 1, 8, 0, 6]" "set(nums)"
2000000 loops, best of 5: 163 nsec per loop

$ python -m timeit --setup "nums = [7, 6, 1, 4, 1, 8, 0, 6]" "{*nums}"
2000000 loops, best of 5: 121 nsec per loop

timeit多次自动调用您的代码,以消除噪声测量。来自timeit的结果证实了 set literal 比set()快。

**注意:**在可以下载文件或访问数据库的代码上使用timeit时要小心。由于timeit会自动调用你的程序几次,你可能会无意中向服务器发送垃圾请求!

最后, IPython 交互外壳Jupyter 笔记本通过%timeit魔法命令对此功能提供了额外的支持:

In [1]: numbers = [7, 6, 1, 4, 1, 8, 0, 6]

In [2]: %timeit set(numbers)
171 ns ± 0.748 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

In [3]: %timeit {*numbers}
147 ns ± 2.62 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

同样,测量表明使用 set 文字更快。在 Jupyter 笔记本中,您还可以使用%%timeit cell-magic 来测量运行整个电池的时间。

用评测器寻找代码中的瓶颈

timeit非常适合对特定的代码片段进行基准测试。然而,用它来检查程序的所有部分并定位哪个部分花费的时间最多是非常麻烦的。相反,你可以使用一个分析器

cProfile 是一个可以随时从标准库中访问的剖析器。您可以通过多种方式使用它,尽管通常最直接的方式是将其用作命令行工具:

$ python -m cProfile -o latest_tutorial.prof latest_tutorial.py

该命令在剖析打开的情况下运行latest_tutorial.py。按照-o选项的指定,将cProfile的输出保存在latest_tutorial.prof中。输出数据是二进制格式,需要专门的程序来理解它。同样,Python 在标准库中有一个选项!在您的.prof文件上运行 pstats 模块会打开一个交互式概要统计浏览器:

$ python -m pstats latest_tutorial.prof
Welcome to the profile statistics browser.
latest_tutorial.prof% help 
Documented commands (type help <topic>):
========================================
EOF  add  callees  callers  help  quit  read  reverse  sort  stats  strip

要使用pstats,您可以在提示符下键入命令。这里您可以看到集成的帮助系统。通常你会使用sortstats命令。要获得更清晰的输出,strip可能很有用:

latest_tutorial.prof% strip latest_tutorial.prof% sort cumtime latest_tutorial.prof% stats 10
 1393801 function calls (1389027 primitive calls) in 0.586 seconds

 Ordered by: cumulative time
 List reduced from 1443 to 10 due to restriction <10>

 ncalls tottime percall cumtime percall filename:lineno(function)
 144/1   0.001   0.000   0.586   0.586 {built-in method builtins.exec}
 1   0.000   0.000   0.586   0.586 latest_tutorial.py:3(<module>)
 1   0.000   0.000   0.521   0.521 contextlib.py:71(inner)
 1   0.000   0.000   0.521   0.521 latest_tutorial.py:6(read_latest_tutorial)
 1   0.000   0.000   0.521   0.521 feed.py:28(get_article)
 1   0.000   0.000   0.469   0.469 feed.py:15(_feed)
 1   0.000   0.000   0.469   0.469 feedparser.py:3817(parse)
 1   0.000   0.000   0.271   0.271 expatreader.py:103(parse)
 1   0.000   0.000   0.271   0.271 xmlreader.py:115(parse)
 13   0.000   0.000   0.270   0.021 expatreader.py:206(feed)

该输出显示总运行时间为 0.586 秒。它还列出了代码花费时间最多的十个函数。这里你已经按累计时间(cumtime)排序,这意味着当给定的函数调用另一个函数时,你的代码计算时间。

您可以看到您的代码几乎所有的时间都花在了latest_tutorial模块中,尤其是在read_latest_tutorial()中。虽然这可能是对您已经知道的东西的有用确认,但是发现您的代码实际花费时间的地方通常更有意思。

总时间(tottime)列表示代码在一个函数中花费的时间,不包括在子函数中的时间。您可以看到,上面的函数都没有真正花时间来做这件事。为了找到代码花费时间最多的地方,发出另一个sort命令:

latest_tutorial.prof% sort tottime latest_tutorial.prof% stats 10
 1393801 function calls (1389027 primitive calls) in 0.586 seconds

 Ordered by: internal time
 List reduced from 1443 to 10 due to restriction <10>

 ncalls tottime percall cumtime percall filename:lineno(function)
 59   0.091   0.002   0.091   0.002 {method 'read' of '_ssl._SSLSocket'}
 114215   0.070   0.000   0.099   0.000 feedparser.py:308(__getitem__)
 113341   0.046   0.000   0.173   0.000 feedparser.py:756(handle_data)
 1   0.033   0.033   0.033   0.033 {method 'do_handshake' of '_ssl._SSLSocket'}
 1   0.029   0.029   0.029   0.029 {method 'connect' of '_socket.socket'}
 13   0.026   0.002   0.270   0.021 {method 'Parse' of 'pyexpat.xmlparser'}
 113806   0.024   0.000   0.123   0.000 feedparser.py:373(get)
 3455   0.023   0.000   0.024   0.000 {method 'sub' of 're.Pattern'}
 113341   0.019   0.000   0.193   0.000 feedparser.py:2033(characters)
 236   0.017   0.000   0.017   0.000 {method 'translate' of 'str'}

你现在可以看到,latest_tutorial.py实际上大部分时间都在使用套接字或者处理 feedparser 内部的数据。后者是用于解析教程提要的真正 Python 阅读器的依赖项之一。

您可以使用pstats来了解您的代码在哪里花费了大部分时间,然后尝试优化您发现的任何瓶颈。您还可以使用该工具来更好地理解代码的结构。例如,命令calleescallers将显示给定的函数调用了哪些函数,以及哪些函数被调用了。

您还可以研究某些功能。通过使用短语timer过滤结果,检查Timer导致了多少开销:

latest_tutorial.prof% stats timer
 1393801 function calls (1389027 primitive calls) in 0.586 seconds

 Ordered by: internal time
 List reduced from 1443 to 8 due to restriction <'timer'>

 ncalls tottime percall cumtime percall filename:lineno(function)
 1   0.000   0.000   0.000   0.000 timer.py:13(Timer)
 1   0.000   0.000   0.000   0.000 timer.py:35(stop)
 1   0.000   0.000   0.003   0.003 timer.py:3(<module>)
 1   0.000   0.000   0.000   0.000 timer.py:28(start)
 1   0.000   0.000   0.000   0.000 timer.py:9(TimerError)
 1   0.000   0.000   0.000   0.000 timer.py:23(__post_init__)
 1   0.000   0.000   0.000   0.000 timer.py:57(__exit__)
 1   0.000   0.000   0.000   0.000 timer.py:52(__enter__)

幸运的是,Timer只会产生最小的开销。完成调查后,使用quit离开pstats浏览器。

对于一个更强大的界面到配置文件数据,检查出 KCacheGrind 。它使用自己的数据格式,但是您可以使用 pyprof2calltree 转换来自cProfile的数据:

$ pyprof2calltree -k -i latest_tutorial.prof

该命令将转换latest_tutorial.prof并打开 KCacheGrind 来分析数据。

最后一个选项是 line_profilercProfile可以告诉你你的代码在哪个函数中花费的时间最多,但是它不能让你知道在那个函数中哪个行是最慢的。这就是line_profiler可以帮助你的地方。

**注意:**您还可以分析代码的内存消耗。这超出了本教程的范围。但是,如果您需要监控程序的内存消耗,您可以查看 memory-profiler

请注意,行分析需要时间,并且会给运行时增加相当多的开销。正常的工作流程是首先使用cProfile来确定要调查哪些函数,然后对这些函数运行line_profilerline_profiler不是标准库的一部分,所以你应该首先按照安装说明来设置它。

在运行分析器之前,您需要告诉它要分析哪些函数。您可以通过在源代码中添加一个@profile装饰器来做到这一点。例如,为了对Timer.stop()进行概要分析,您可以在timer.py中添加以下内容:

@profile def stop(self) -> float:
    # The rest of the code is unchanged

请注意,您没有在任何地方导入profile。相反,当您运行探查器时,它会自动添加到全局命名空间中。不过,在完成分析后,您需要删除这一行。否则你会得到一个NameError

接下来,使用kernprof运行分析器,它是line_profiler包的一部分:

$ kernprof -l latest_tutorial.py

该命令自动将 profiler 数据保存在一个名为latest_tutorial.py.lprof的文件中。您可以使用line_profiler查看这些结果:

$ python -m line_profiler latest_tutorial.py.lprof
Timer unit: 1e-06 s

Total time: 1.6e-05 s
File: /home/realpython/timer.py
Function: stop at line 35

# Hits Time PrHit %Time Line Contents
=====================================
35                      @profile
36                      def stop(self) -> float:
37                          """Stop the timer, and report the elapsed time"""
38  1   1.0   1.0   6.2     if self._start_time is None:
39                              raise TimerError(f"Timer is not running. ...")
40
41                          # Calculate elapsed time
42  1   2.0   2.0  12.5     elapsed_time = time.perf_counter() - self._start_time
43  1   0.0   0.0   0.0     self._start_time = None
44
45                          # Report elapsed time
46  1   0.0   0.0   0.0     if self.logger:
47  1  11.0  11.0  68.8         self.logger(self.text.format(elapsed_time))
48  1   1.0   1.0   6.2     if self.name:
49  1   1.0   1.0   6.2         self.timers[self.name] += elapsed_time
50
51  1   0.0   0.0   0.0     return elapsed_time

首先,注意这个报告中的时间单位是微秒(1e-06 s)。通常,最容易查看的数字是%Time,它告诉您代码在每一行中花费在函数中的时间占总时间的百分比。在这个例子中,您可以看到您的代码在第 47 行花费了几乎 70%的时间,这是格式化和打印计时器结果的行。

结论

在本教程中,您已经尝试了几种不同的方法来将 Python 计时器添加到代码中:

  • 您使用了一个来保存状态并添加一个用户友好的界面。类非常灵活,直接使用Timer可以完全控制如何以及何时调用计时器。

  • 您使用了一个上下文管理器来为代码块添加特性,并且如果必要的话,在之后进行清理。上下文管理器使用起来很简单,添加with Timer()可以帮助你在视觉上更清楚地区分你的代码。

  • 您使用了一个装饰器来为函数添加行为。Decorators 简洁而引人注目,使用@Timer()是监控代码运行时的一种快捷方式。

您还了解了在对代码进行基准测试时为什么应该选择time.perf_counter()而不是time.time(),以及在优化代码时有哪些其他选择。

现在您可以在自己的代码中添加 Python 计时器函数了!在日志中记录程序运行的速度有助于监控脚本。对于类、上下文管理器和装饰器一起很好地发挥作用的其他用例,你有什么想法吗?在下面留下评论吧!

资源

要更深入地了解 Python 计时器函数,请查看以下资源:

  • codetiming 是 PyPI 上可用的 Python 定时器。
  • time.perf_counter() 是用于精确计时的性能计数器。
  • timeit 是一个比较代码片段运行时的工具。
  • cProfile 是一个在脚本和程序中寻找瓶颈的剖析器。
  • pstats 是一个查看分析器数据的命令行工具。
  • KCachegrind 是查看 profiler 数据的 GUI。
  • line_profiler 是一个用于测量单独代码行的分析器。
  • memory-profiler 是一个用于监控内存使用情况的分析器。************

Python 和 TOML:新的好朋友

原文:# t0]https://realython . com/python-toml/

TOML——Tom 显而易见的最小语言——是一种相当新的配置文件格式,Python 社区在过去几年里已经接受了这种格式。TOML 在 Python 生态系统中扮演着重要的角色。许多您喜欢的工具依赖于 TOML 进行配置,当您构建和发布自己的包时,您将使用pyproject.toml

在本教程中,你会学到更多关于 TOML 的知识以及如何使用它。特别是,您将:

  • 学习并理解 TOML 的语法
  • 使用tomlitomllib解析 TOML 文档
  • 使用tomli_w数据结构写成 TOML
  • 当你需要对你的 TOML 文件有更多的控制时,使用tomlkit

在 Python 3.11 中,一个新的 TOML 解析模块被添加到 Python 的标准库中。稍后在本教程中,你将学习如何使用这个新模块。如果你想了解更多关于tomllib为什么被加入 Python 的信息,那么看看配套教程, Python 3.11 预览:TOML 和tomllibT5。

免费下载: 从 Python 技巧中获取一个示例章节:这本书用简单的例子向您展示了 Python 的最佳实践,您可以立即应用它来编写更漂亮的+Python 代码。

使用 TOML 作为配置格式

TOML 是汤姆的显而易见的最小语言的缩写,并以它的创造者汤姆·普雷斯顿-沃纳的名字谦逊地命名。它被明确设计成一种配置文件格式,应该“易于解析成各种语言的数据结构”(来源)。

在这一节中,您将开始考虑配置文件,并看看 TOML 带来了什么。

Remove ads

配置和配置文件

配置几乎是任何应用程序或系统的重要组成部分。它允许你在不改变源代码的情况下改变设置或行为。有时,您将使用配置来指定连接到另一个服务(如数据库或云存储)所需的信息。其他时候,您将使用配置设置来允许用户自定他们对项目的体验。

为您的项目使用一个配置文件是将您的代码与其设置分开的一个好方法。它还鼓励您意识到系统的哪些部分是真正可配置的,为您提供了一个在源代码中命名神奇值的工具。现在,考虑一个假想的井字游戏的配置文件:

player_x_color  =  blue player_o_color  =  green board_size  =  3 server_url  =  https://tictactoe.example.com/

您可以直接在源代码中编写这种代码。但是,通过将设置移动到一个单独的文件中,您可以实现一些目标:

  • 你给值一个明确的名字。
  • 你提供这些值更多的可见性
  • 你使得改变这些值变得更简单。

更仔细地看看您假设的配置文件。这些值在概念上是不同的。颜色是您的框架可能支持更改的值。换句话说,如果您将blue替换为red,那么在代码中不会有任何特殊处理。您甚至可以考虑是否值得通过您的前端向您的最终用户公开此配置。

然而,电路板尺寸可以配置,也可以不配置。井字游戏是在一个三乘三的格子上玩的。不确定你的逻辑是否还适用于其他尺寸的电路板。将该值保存在配置文件中仍然是有意义的,这既是为了给该值命名,也是为了使其可见。

最后,在部署应用程序时,项目 URL 通常是必不可少的。这不是一个普通用户会改变的事情,但是一个超级用户可能想把你的游戏重新部署到一个不同的服务器上。

为了更清楚地了解这些不同的用例,您可能希望在您的配置中添加一些组织。一种流行的选择是将您的配置分成附加文件,每个文件处理不同的问题。另一个选择是以某种方式对配置值进行分组。例如,您可以按如下方式组织假设的配置文件:

[user] player_x_color  =  blue player_o_color  =  green [constant] board_size  =  3 [server] url  =  https://tictactoe.example.com

文件的组织使得每个配置项的角色更加清晰。您还可以向配置文件添加注释,并向任何想对其进行更改的人提供说明。

**注意:**配置文件的实际格式对于这个讨论并不重要。上述原则与您如何指定配置值无关。碰巧的是,到目前为止你看到的例子都可以被 Python 的 ConfigParser 类解析。

您可以通过多种方式来指定配置。Windows 传统上使用 INI 文件,它类似于上面的配置文件。Unix 系统也依赖于纯文本、人类可读的配置文件,尽管不同服务之间的实际格式有所不同。

随着时间的推移,越来越多的应用程序开始使用定义良好的格式,如 XMLJSONYAML 来满足它们的配置需求。这些格式被设计成数据交换串行化格式,通常用于计算机通信。

另一方面,配置文件通常是由人编写或编辑的。许多开发人员在更新他们的 Visual Studio 代码设置时对 JSON 严格的逗号规则感到失望,或者在建立云服务时对 YAML 的嵌套缩进感到失望。尽管它们无处不在,但这些文件格式并不是最容易手写的。

汤姆:汤姆明显的最小语言

TOML 是一种相当新的格式。第一个格式规范版本 0.1.0 于 2013 年发布。从一开始,它就专注于成为人类可读的最小配置文件格式。根据 TOML 的网页,TOML 的目标如下:

TOML 的目标是成为一种最小化的配置文件格式,由于明显的语义,这种格式易于阅读。TOML 被设计成明确地将映射到散列表。TOML 应该容易解析成各种语言的数据结构。(来源,重点添加)

当您阅读本教程时,您将会看到 TOML 是如何达到这些目标的。不过,很明显,TOML 在其短暂的生命周期中变得非常流行。越来越多的 Python 工具,包括 Blackpytestmypyisort ,都使用 TOML 进行配置。对于大多数流行的编程语言来说,TOML 解析器是可用的。

回忆一下上一小节中的配置。用 TOML 表达它的一种方法如下:

[user] player_x.color  =  "blue" player_o.color  =  "green" [constant] board_size  =  3 [server] url  =  "https://tictactoe.example.com"

在下一节的中,您将了解更多关于 TOML 格式的细节。现在,试着自己阅读和解析这些信息。注意,和早前没太大区别。最大的变化是在一些值中添加了引号(")。

TOML 的语法受到传统配置文件的启发。与 Windows INI 文件和 Unix 配置文件相比,它的一个主要优势是 TOML 有一个规范,它精确地说明了 TOML 文档中允许的内容以及不同的值应该如何解释。规范在 2021 年初达到 1.0.0 版本后稳定成熟。

相比之下,INI 格式没有正式的规范。相反,有许多变体和方言,其中大部分是由一个实现定义的。Python 附带了对标准库中读取 INI 文件的支持。虽然ConfigParser相当宽松,但它并不支持所有类型的 INI 文件。

TOML 和许多传统格式的另一个区别是 TOML 值有类型。在上面的例子中,"blue"被解释为一个字符串,而3是一个数字。对 TOML 的一个潜在的批评是,编写 TOML 的人需要知道类型。在更简单的格式中,这个责任在于程序员解析配置。

TOML 不是像 JSON 或 YAML 那样的数据序列化格式。换句话说,您不应该试图将一般数据存储在 TOML 中以便以后恢复。TOML 在几个方面有限制:

  • 所有键都被解释为字符串。你不能轻易使用,比如说,一个数字作为密钥。
  • TOML 没有空类型。
  • 一些空白很重要,这会降低压缩 TOML 文档大小的效率。

即使 TOML 是一把好锤子,但并不是所有的数据文件都是钉子。您应该主要使用 TOML 进行配置。

Remove ads

TOML 模式验证

在下一节中,您将更深入地研究 TOML 语法。在那里,您将了解一些 TOML 文件的语法要求。然而,在实践中,给定的 TOML 文件也可能带有一些非语法要求。

这些是模式需求。例如,您的井字游戏应用程序可能要求配置文件包含服务器 URL。另一方面,播放器颜色可以是可选的,因为应用程序定义了默认颜色。

目前,TOML 不包括一种可以在 TOML 文档中指定必填和可选字段的模式语言。有几个提议存在,尽管还不清楚它们中的任何一个是否会很快被接受。

在简单的应用程序中,您可以手动验证 TOML 配置。比如可以使用结构模式匹配,这是在 Python 3.10 中引入的。假设您已经将配置解析成 Python,并将其命名为config。然后,您可以按如下方式检查其结构:

match config:
    case {
 "user": {"player_x": {"color": str()}, "player_o": {"color": str()}}, "constant": {"board_size": int()}, "server": {"url": str()},    }:
        pass
    case _:
        raise ValueError(f"invalid configuration: {config}")

第一个case语句详细说明了您期望的结构。如果config匹配,那么你使用 pass 继续你的代码。否则,您会引发一个错误。

如果您的 TOML 文档更复杂,这种方法可能不太适用。如果想提供好的错误消息,还需要做更多的工作。更好的替代方法是使用 pydantic ,它利用类型注释在运行时进行数据验证。pydantic 的一个优点是它内置了精确而有用的错误消息。

还有一些工具可以利用针对 JSON 等格式的现有模式验证。例如, Taplo 是一个 TOML 工具包,可以根据 JSON 模式验证 TOML 文档。Taplo 也可用于 Visual Studio 代码,捆绑到更好的 TOML 扩展中。

在本教程的其余部分中,您不必担心模式验证。相反,您将更加熟悉 TOML 语法,并了解您可以使用的所有不同的数据类型。稍后,您将看到如何在 Python 中与 TOML 交互的示例,并且您将探索 TOML 非常适合的一些用例。

了解 TOML:键值对

TOML 是围绕键值对构建的,这些键值对可以很好地映射到哈希表数据结构。TOML 值有不同的类型。每个值必须具有以下类型之一:

此外,您可以使用表数组作为组织几个键值对的集合。在本节的剩余部分,您将了解到更多关于所有这些的内容,以及如何在 TOML 中指定它们。

注意: TOML 支持与 Python 相同语法的注释。散列符号(#)将该行的其余部分标记为注释。使用注释可以使您的配置文件更容易理解,便于您和您的用户使用。

在本教程中,您将看到 TOML 的所有不同元素。然而,一些细节和边缘情况将被掩盖。如果你对细则感兴趣,请查阅文档

如前所述,键值对是 TOML 文档中的基本构件。您用一个<key> = <value>语法指定它们,其中键用等号与值分开。以下是具有一个键值对的有效 TOML 文档:

greeting  =  "Hello, TOML!"

在这个例子中,greeting是键,而"Hello, TOML!"是值。值有类型。在本例中,该值是一个文本字符串。您将在下面的小节中了解不同的值类型。

键总是被解释为字符串,即使引号没有将它们括起来。考虑下面的例子:

greeting  =  "Hello, TOML!" 42  =  "Life, the universe, and everything"

这里,42是一个有效的键,但是它被解释为一个字符串,而不是一个数字。通常,你要使用光杆键。这些密钥仅由 ASCII 字母和数字以及下划线和破折号组成。所有这样的键都可以不用引号,就像上面的例子一样。

TOML 文档必须用 UTF-8 Unicode 编码。这让你在表达自己的价值观时有很大的灵活性。尽管对空键有限制,但是在拼写键时也可以使用 Unicode。然而,这是有代价的。要使用 Unicode 键,必须在它们周围加上引号:

"realpython.com"  =  "Real Python" "blåbærsyltetøy"  =  "blueberry jam" "Tom Preston-Werner"  =  "creator"

所有这些键都包含裸键中不允许的字符:点(.)、挪威语字符(åæø)和一个空格。您可以在任何键周围使用引号,但是一般来说,您希望坚持使用不使用或不需要引号的简单键。

点(.)在 TOML 键中起着特殊的作用。您可以在未加引号的键中使用点,但在这种情况下,它们会通过在每个点处拆分带点的键来触发分组。考虑下面的例子:

player_x.symbol  =  "X" player_x.color  =  "purple"

这里,您指定了两个点键。因为它们都以player_x开始,所以键symbolcolor将被组合在一个名为player_x的部分中。当你开始探索时,你会学到更多关于点键的知识。

接下来,把注意力转向价值观。在下一节中,您将了解 TOML 中最基本的数据类型。

Remove ads

字符串、数字和布尔值

TOML 对基本数据类型使用熟悉的语法。从 Python 中,您可以识别字符串、整数、浮点数和布尔值:

string  =  "Text with quotes" integer  =  42 float  =  3.11 boolean  =  true

TOML 和 Python 最直接的区别就是 TOML 的布尔值是小写的:truefalse

一个 TOML 字符串通常应该使用双引号(")。在字符串内部,可以借助反斜杠对特殊字符进行转义:"\u03c0 is less than four"。这里,\u03c0表示带有 codepoint U+03c0 的 Unicode 字符,恰好是希腊字母π。该字符串将被解释为"π is less than four"

还可以使用单引号(')指定 TOML 字符串。单引号字符串被称为文字字符串,其行为类似于 Python 中的原始字符串。在文字字符串中没有任何东西被转义和解释,所以'\u03c0 is the Unicode codepoint of π'从文字\u03c0字符开始。

最后,还可以使用三重引号 ( """''')来指定 TOML 字符串。三重引号字符串允许您在多行上编写一个字符串,类似于 Python 多行字符串:

partly_zen  =  """
Flat is better than nested.
Sparse is better than dense.
"""

基本字符串中不允许出现控制字符,包括文字换行符。不过,您可以使用\n来表示基本字符串中的换行符。如果要将字符串格式化为多行,必须使用多行字符串。您也可以使用三重引号文字字符串。除了多行之外,这是在文字字符串中包含单引号的唯一方法:'''Use '\u03c0' to represent π'''

**注意:**在 Python 代码中创建 TOML 文档时要小心特殊字符,因为 Python 也会解释这些特殊字符。例如,下面是一个有效的 TOML 文档:

numbers  =  "one\ntwo\nthree"

在这里,numbers的值是一个分成三行的字符串。您可以尝试用 Python 表示同一个文档,如下所示:

>>> 'numbers = "one\ntwo\nthree"'
'numbers = "one\ntwo\nthree"'

这是行不通的,因为 Python 解析了\n字符并创建了一个无效的 TOML 文档。您需要让特殊字符远离 Python,例如使用原始字符串:

>>> r'numbers = "one\ntwo\nthree"'
'numbers = "one\\ntwo\\nthree"'

该字符串表示与原始文档相同的 TOML 文档。

TOML 中的数字要么是整数,要么是浮点数。整数代表整数,被指定为普通的数字字符。与 Python 中一样,您可以使用下划线来增强可读性:

number  =  42 negative  =  -8 large  =  60_481_729

浮点数代表十进制数,包括整数部分、代表小数点的点和小数部分。浮点数可以使用科学记数法来表示非常小或非常大的数字。TOML 还支持特殊的浮点值,比如无穷大和非数字(NaN) :

number  =  3.11 googol  =  1e100 mole  =  6.22e23 negative_infinity  =  -inf not_a_number  =  nan

注意,TOML 规范要求整数至少要表示为 64 位有符号整数。Python 处理任意大的整数,但是只有大约 19 位数的整数才能保证在所有的 TOML 实现中工作。

注意: TOML 是一种配置文件格式,不是编程语言。不支持类似1 + 2的表达式,只支持文字数字。

非负整数值也可以分别用前缀0x0o0b表示为十六进制、八进制或二进制值。例如,0xffff00是十六进制表示,0b00101010是二进制表示。

布尔值表示为truefalse。这些必须是小写的。

TOML 还包括几种时间和日期类型。但是,在探索这些之前,您将看到如何使用表来组织和结构化您的键值对。

表格

您已经了解了 TOML 文档由一个或多个键值对组成。当用编程语言表示时,这些应该存储在一个散列表数据结构中。在 Python 中,这将是一个字典或另一个类似于字典的数据结构。为了组织键值对,可以使用

TOML 支持三种不同的指定表格的方式。您将很快看到这些例子。最终结果将是相同的,与您如何表示您的表无关。尽管如此,不同的表确实有稍微不同的用例:

  • 在大多数情况下,使用带有标题的常规
  • 当您需要指定一些与其父表紧密相关的键值对时,使用点状键表
  • 内联表仅用于最多有三个键值对的非常小的表,其中的数据构成了一个明确定义的实体。

不同的表格表示通常是可以互换的。您应该默认使用常规表,只有当您认为这样可以提高配置的可读性或阐明您的意图时,才切换到点键表或内联表。

这些不同的表格类型在实践中看起来如何?从普通桌子开始。它们是通过在键值对上方添加一个表头来定义的。头是一个没有值的,用方括号([])括起来。您之前遇到的以下示例定义了三个表:

[user]  player_x.color  =  "blue" player_o.color  =  "green" [constant]  board_size  =  3 [server]  url  =  "https://tictactoe.example.com"

突出显示的三行是表格标题。它们指定了三个表,分别命名为userconstantserver。表的内容或值是列在标题下面和下一个标题上面的所有键值对。例如,constantserver各包含一个嵌套的键值对。

你也可以在上面的配置中找到虚线键表。在user中,您有以下内容:

[user] player_x.color  =  "blue"  player_o.color  =  "green"

键中的句点或点(.)创建一个由点之前的键部分命名的表。您也可以通过嵌套常规表来表示配置的相同部分:

[user] [user.player_x] color  =  "blue" [user.player_o] color  =  "green"

缩进在 TOML 中并不重要。这里用它来表示表格的嵌套。您可以看到,user表包含两个子表,player_xplayer_o。每个子表都包含一个键值对。

注意:你可以任意深度的嵌套 TOML 表。例如,player.x.color.name这样的键或表头表示color表中的nameplayer表中的x

请注意,您需要在嵌套表的标题中使用点键,并命名所有中间表。这使得 TOML 头规范相当冗长。例如,在 JSON 或 YAML 的类似规范中,您只需指定子表的名称,而不必重复外部表的名称。同时,这使得 TOML 非常显式,在深度嵌套的结构中更难迷失方向。

现在,您将在user桌面上扩展一点,为每个玩家添加一个标签符号。您将用三种不同的形式来表示这个表,首先只使用常规表,然后使用点键表,最后使用内联表。您还没有看到后者,所以这将是对内联表以及如何表示它们的介绍。

从嵌套的常规表格开始:

[user] [user.player_x] symbol  =  "X" color  =  "blue" [user.player_o] symbol  =  "O" color  =  "green"

这种表示非常清楚地表明,你有两个不同的球员表。您不需要显式定义只包含子表而不包含任何常规键的表。在前面的例子中,您可以删除线[user]

将嵌套表与点键配置进行比较:

[user] player_x.symbol  =  "X" player_x.color  =  "blue" player_o.symbol  =  "O" player_o.color  =  "green"

这比上面的嵌套表更短更简洁。然而,结构现在不太清楚了,在您意识到在user中嵌套了两个玩家表之前,您需要花费一些精力来解析这些键。当您有几个嵌套表,每个表有一个键时,点键表会更有用,就像前面的例子中只有color个子键一样。

接下来,您将使用内联表格来表示user:

[user] player_x  =  {  symbol  =  "X",  color  =  "blue"  }  player_o  =  {  symbol  =  "O",  color  =  "green"  }

内联表是用花括号({})定义的,用逗号分隔的键值对。在这个例子中,内联表带来了可读性和紧凑性的良好平衡,因为玩家表的分组变得清晰。

不过,您应该谨慎地使用内联表,主要是在这种情况下,一个表代表一个小型的、定义良好的实体,比如一个播放器。与常规表格相比,内联表格被有意地限制。特别是,内联表必须写在 TOML 文件中的一行上,并且不能使用像结尾逗号这样的便利。

在结束 TOML 中的表之旅之前,您将简要地看一下几个小问题。一般来说,您可以按任何顺序定义您的表,并且您应该努力以一种对您的用户有意义的方式来排列您的配置。

TOML 文档由一个包含所有其他表和键值对的无名根表表示。您在 TOML 配置的顶部,在任何表头之前编写的键-值对直接存储在根表中:

title  =  "Tic-Tac-Toe" [constant] board_size  =  3

在这个例子中,title是根表中的一个键,constant是嵌套在根表中的一个表,board_sizeconstant表中的一个键。

请注意,一个表包括所有写在它的表头和下一个表头之间的键值对。实际上,这意味着您必须在属于该表的键值对下定义嵌套子表。考虑这份文件:

[user] [user.player_x] color  =  "blue" [user.player_o] color  =  "green" background_color  =  "white"

缩进表明background_color应该是user表中的一个键。但是,TOML 忽略缩进,只检查表头。在这个例子中,background_coloruser.player_o表的一部分。要纠正这一点,background_color应该在嵌套表之前定义:

[user] background_color  =  "white" [user.player_x] color  =  "blue" [user.player_o] color  =  "green"

在这种情况下,background_coloruser中的一个键。如果你使用点键表,那么你可以更自由地使用任何顺序的键:

[user] player_x.color  =  "blue" player_o.color  =  "green" background_color  =  "white"

现在除了[user]之外没有显式的表头,所以background_color将是user表中的一个键。

您已经了解了 TOML 中的基本数据类型,以及如何使用表来组织数据。在接下来的小节中,您将看到可以在 TOML 文档中使用的最终数据类型。

Remove ads

时间和日期

TOML 支持直接在文档中定义时间和日期。您可以在四种不同的表示法之间进行选择,每种表示法都有其特定的用例:

  • 偏移日期时间是一个带有时区信息的时间戳,代表特定的时刻。
  • 本地日期时间是没有时区信息的时间戳。
  • 本地日期是没有任何时区信息的日期。你通常用它来代表一整天。
  • 本地时间是具有任何日期或时区信息的时间。您使用本地时间来表示一天中的某个时间。

TOML 基于 RFC 3339 来表示时间和日期。本文档定义了一种时间和日期格式,通常用于表示互联网上的时间戳。一个完整定义的时间戳应该是这样的:2021-01-12T01:23:45.654321+01:00。时间戳由几个字段组成,由不同的分隔符分隔:

例子 细节
2021
01 01(1 月)到12(12 月)的两位数
一天 12 两位数,十位以下用零填充
小时 01 0023的两位数
分钟 23 0059的两位数
第二 45 0059的两位数
微秒 654321 000000999999的六位数字
抵消 +01:00 时区相对于 UTC 的偏移量,Z代表 UTC

偏移日期时间是包含偏移信息的时间戳。本地日期时间是不包括这个的时间戳。本地时间戳也称为简单时间戳。

在 TOML 中,微秒字段对于所有日期时间和时间类型都是可选的。您还可以用空格替换分隔日期和时间的T。在这里,您可以看到每种时间戳相关类型的示例:

offset_date-time  =  2021-01-12 01:23:45+01:00 offset_date-time_utc  =  2021-01-12 00:23:45Z local_date-time  =  2021-01-12  01:23:45 local_date  =  2021-01-12 local_time  =  01:23:45 local_time_with_us  =  01:23:45.654321

注意,不能用引号将时间戳值括起来,因为这会将它们转换成文本字符串。

这些不同的时间和日期类型为您提供了相当大的灵活性。如果您有这些没有涵盖的用例—例如,如果您想要指定一个像1 day这样的时间间隔—那么您可以使用字符串并使用您的应用程序来正确地处理它们。

TOML 支持的最后一种数据类型是数组。这些允许您在一个列表中组合其他几个值。请继续阅读,了解更多信息。

数组

TOML 数组表示一个有序的值列表。您使用方括号([])来指定它们,以便它们类似于 Python 的列表:

packages  =  ["tomllib",  "tomli",  "tomli_w",  "tomlkit"]

在这个例子中,packages的值是一个包含四个字符串元素的数组:"tomllib""tomli""tomli_w""tomlkit"

**注:**你会学到更多关于 tomllibtomlitomli_wtomlkit 的知识,以及它们在 Python 的 TOML 版图中所扮演的角色,在本教程后面的的实际章节中。

您可以在数组中使用任何 TOML 数据类型,包括其他数组,并且一个数组可以包含不同的数据类型。您可以在几行中指定一个数组,并且可以在数组的最后一个元素后使用尾随逗号。以下所有示例都是有效的 TOML 数组:

potpourri  =  ["flower",  1749,  {  symbol  =  "X",  color  =  "blue"  },  1994-02-14] skiers  =  ["Thomas",  "Bjørn",  "Mika"] players  =  [ {  symbol  =  "X",  color  =  "blue",  ai  =  true  }, {  symbol  =  "O",  color  =  "green",  ai  =  false  }, ]

这定义了三个数组。potpourri是包含四个不同数据类型元素的数组,而skiers是包含三个字符串的数组。最后一个数组players修改了前面的例子,将两个内联表格表示为一个数组中的元素。注意players是在四行中定义的,在最后一个行内表格后面有一个可选的逗号。

最后一个例子展示了创建表格数组的一种方法。您可以将内联表格放在方括号内。但是,正如您之前看到的,内联表的伸缩性不好。如果您想要表示一个表的数组,其中的表比较大,那么您应该使用不同的语法。

一般来说,您应该通过在双方括号([[]])内编写表格标题来表达表格的数组。语法不一定漂亮,但相当有效。您可以用下面的例子来表示players:

[[players]] symbol  =  "X" color  =  "blue" ai  =  true [[players]] symbol  =  "O" color  =  "green" ai  =  false

这个表格数组相当于您上面编写的内联表格数组。双方括号定义了一个表格数组,而不是一个常规的表格。您需要为数组中的每个嵌套表重复数组名称。

作为一个更广泛的例子,考虑下面的 TOML 文档摘录,该文档列出了一个测验应用程序的问题:

[python] label  =  "Python" [[python.questions]] question  =  "Which built-in function can get information from the user" answers  =  ["input"] alternatives  =  ["get",  "print",  "write"] [[python.questions]] question  =  "What's the purpose of the built-in zip() function" answers  =  ["To iterate over two or more sequences at the same time"] alternatives  =  [ "To combine several strings into one", "To compress several files into one archive", "To get information from the user", ]

在这个例子中,python表有两个键,labelquestionsquestions的值是一个包含两个元素的表格数组。每个元素是一个带有三个键的表格:questionanswersalternatives

您现在已经看到了 TOML 必须提供的所有数据类型。除了简单的数据类型,如字符串、数字、布尔值、时间和日期,您还可以用表和数组来组合和组织您的键和值。在这篇概述中,您忽略了一些细节和边缘情况。你可以在 TOML 规范中了解所有细节。

在接下来的章节中,当您学习如何在 Python 中使用 TOML 时,您会变得更加实际。您将了解如何读写 TOML 文档,并探索如何组织您的应用程序来有效地使用配置文件。

Remove ads

用 Python 加载 TOML】

是时候把手弄脏了。在本节中,您将启动 Python 解释器并将 TOML 文档加载到 Python 中。您已经看到了 TOML 格式的主要用例是配置文件。这些通常是手工编写的,因此在这一节中,您将了解如何使用 Python 读取这样的配置文件,并在您的项目中使用它们。

tomlitomllib 读取 TOML 文件

自从 TOML 规范在 2013 年首次出现以来,已经有几个包可以使用这种格式。随着时间的推移,这些包中的一些变得不可维护。一些曾经流行的库不再兼容最新版本的 TOML。

在本节中,您将使用一个相对较新的包,名为 tomli 及其兄弟包 tomllib 。当您只想将一个 TOML 文档加载到 Python 中时,这些是很好的库。在以后的章节中,您还将探索tomlkit。该包为桌面带来了更高级的功能,并为您开辟了一些新的使用案例。

**注意:**在 Python 3.11 中,TOML 支持被添加到 Python 标准库中。新的tomllib模块可以帮助你读取和解析 TOML 文档。关于添加库的动机和原因详见 Python 3.11 预览版:TOML 和tomllib

这个新的tomllib模块实际上是通过将现有的tomli库复制到 CPython 代码库中而创建的。这样做的结果是,你可以在 Python 版本 3.73.83.93.10 上使用tomli作为兼容的后端口。

按照下面的说明,你将学会如何使用tomli。如果你使用的是 Python 3.11,那么你可以跳过tomli的安装,用tomllib替换任何提到tomli的代码。

是时候探索如何读取 TOML 文件了。首先创建以下 TOML 文件,并将其另存为tic_tac_toe.toml:

# tic_tac_toe.toml [user] player_x.color  =  "blue" player_o.color  =  "green" [constant] board_size  =  3 [server] url  =  "https://tictactoe.example.com"

这与您在上一节中使用的配置相同。接下来,使用 piptomli安装到您的虚拟环境中:

(venv) $ python -m pip install tomli

tomli模块只公开了两个函数:load()loads()。您可以使用它们分别从 file 对象和 string 加载 TOML 文档。首先使用load()读取您在上面创建的文件:

>>> import tomli
>>> with open("tic_tac_toe.toml", mode="rb") as fp:
...     config = tomli.load(fp) ...

你首先打开文件,使用一个上下文管理器来处理可能出现的任何问题。重要的是,您需要通过指定mode="rb"以二进制模式打开文件。这允许tomli正确处理您的 TOML 文件的编码。

您将 TOML 配置存储在一个名为config的变量中。继续探索它的内容:

>>> config
{'user': {'player_x': {'color': 'blue'}, 'player_o': {'color': 'green'}},
 'constant': {'board_size': 3},
 'server': {'url': 'https://tictactoe.example.com'}}

>>> config["user"]["player_o"]
{'color': 'green'}

>>> config["server"]["url"]
'https://tictactoe.example.com'

在 Python 中,TOML 文档被表示为字典。TOML 文件中的所有表和子表都显示为config中的嵌套字典。您可以通过跟踪嵌套字典中的键来挑选单个值。

如果您已经将 TOML 文档表示为字符串,那么您可以使用loads()代替load()。可以把函数名后面的s看作是字符串的助记符。以下示例解析存储为toml_str的 TOML 文档:

>>> import tomli
>>> toml_str = """
... offset_date-time_utc = 2021-01-12 00:23:45Z
... potpourri = ["flower", 1749, { symbol = "X", color = "blue" }, 1994-02-14]
... """

>>> tomli.loads(toml_str) {'offset_date-time_utc': datetime.datetime(2021, 1, 12, 0, 23, 45,
 tzinfo=datetime.timezone.utc),
 'potpourri': ['flower',
 1749,
 {'symbol': 'X', 'color': 'blue'},
 datetime.date(1994, 2, 14)]}

同样,您将生成一个字典,其中的键和值对应于 TOML 文档中的键值对。注意,TOML 时间和日期类型由 Python 的datetime类型表示,TOML 数组被转换成 Python 列表。您可以看到,正如预期的那样,在.tzinfo属性中表示的时区信息被附加到了offset_date-time_utc

**注意:**偏移日期时间是具有指定时区的日期时间。将时区添加到datetime意味着您提供了足够的信息来描述一个确切的时刻,这在许多处理真实世界数据的应用程序中非常重要。

看看 Python 3.9:很酷的新特性供你尝试阅读更多关于 Python 如何处理时区的信息,并查看 Python 3.9 版本中添加的zoneinfo模块。

load()loads()都将 TOML 文档转换成 Python 字典,并且可以互换使用。选择最适合您的使用情形的一个。作为最后一个例子,您将结合loads()pathlib来重建井字游戏配置示例:

>>> from pathlib import Path
>>> import tomli
>>> tomli.loads(Path("tic_tac_toe.toml").read_text(encoding="utf-8")) {'user': {'player_x': {'color': 'blue'}, 'player_o': {'color': 'green'}},
 'constant': {'board_size': 3},
 'server': {'url': 'https://tictactoe.example.com'}}

load()loads()的一个区别是当你使用后者时,你使用常规的字符串而不是字节。在这种情况下,tomli假设您已经正确处理了编码。

**注:**这些例子都用了tomli。然而,如上所述,如果您使用的是 Python 3.11 或更新版本,您可以用tomllib替换任何提到tomli的代码。

你可能想在你的应用程序中自动执行这个决定。您可以通过将下面一行添加到您的requirements.txt依赖项规范中来实现这一点:

tomli >= 1.1.0 ; python_version < "3.11"

这将确保tomli只安装在 3.11 之前的 Python 版本上。此外,您应该用一个稍微复杂一点的咒语替换您导入的tomli:

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib

这段代码将首先尝试导入tomllib。如果失败,它将导入tomli,但是将tomli模块的别名改为tomllib名称。由于这两个库是兼容的,你现在可以在你的代码中引用tomllib,它将在所有 Python 版本 3.7 和更高版本上工作。

您已经开始使用 Python 加载并解析了您的第一个 TOML 文档。在下一小节中,您将更仔细地查看 TOML 数据类型和来自tomli的输出之间的对应关系。

Remove ads

比较 TOML 类型和 Python 类型

在前一小节中,您加载了一些 TOML 文档,并学习了tomlitomllib如何表示,例如,将 TOML 字符串表示为 Python 字符串,将 TOML 数组表示为 Python 列表。TOML 规范没有明确定义 Python 应该如何表示 TOML 对象,因为这超出了它的范围。然而,TOML 规范提到了对其自身类型的一些要求。例如:

  • TOML 文件必须是有效的 UTF-8 编码的 Unicode 文档。
  • 应该无损地接受和处理任意 64 位有符号整数(从−2^63 到 2^63−1)。
  • 浮点应该实现为 IEEE 754 二进制 64 值。

总的来说,TOML 的需求与 Python 的相应类型的实现匹配得很好。Python 通常在处理文件时默认使用 UTF-8,一个 Python float 遵循 IEEE 754。Python 的int 类实现了任意精度的整数,可以处理所需的范围和更大的数字。

对于像tomlitomllib这样的基本库,TOML 的数据类型和 Python 的数据类型之间的映射是相当自然的。您可以在tomllib的文档中找到以下换算表:

汤姆 计算机编程语言
线 str
整数 int
漂浮物 float
布尔型 bool
桌子 dict
偏移日期时间 datetime.datetime ( .tzinfodatetime.timezone的一个实例)
当地日期时间 datetime.datetime ( .tzinfoNone)
当地日期 datetime.date
当地时间 datetime.time
排列 list

所有的 Python 数据类型要么是内置的,要么是标准库中 datetime 的一部分。重申一下,并不要求 TOML 类型必须映射到本地 Python 类型。这是tomlitomllib选择实现的便利。

仅使用标准类型也是一种限制。实际上,您只能表示值,而不能表示 TOML 文档中编码的其他信息,如注释或缩进。您的 Python 表示也没有区分在常规表或内联表中定义的值。

在许多用例中,这个元信息是不相关的,所以不会丢失任何东西。然而,有时这很重要。例如,如果您试图在现有的 TOML 文件中插入一个表格,那么您不希望所有的注释都消失。稍后你会了解到tomlkit。这个库将 TOML 类型表示为定制的 Python 对象,这些对象保留了恢复完整的 TOML 文档所必需的信息。

load()loads()函数有一个参数,可以用来定制 TOML 解析。您可以向parse_float提供一个参数来指定应该如何解析浮点数。默认实现满足了使用 64 位浮点数的要求,这通常精确到大约 16 位有效数字。

但是,如果您的应用程序依赖于非常精确的数字,16 位数字可能不够。作为例子,考虑天文学中使用的儒略日的概念。这是一个时间戳的表示,它是一个计数自 6700 多年前的儒略历开始以来的天数的数字。例如,UTC 时间 2022 年 7 月 11 日中午是儒略日 2,459,772。

天文学家有时需要在非常小的时间尺度上工作,比如纳秒甚至皮秒。要以纳秒的精度表示一天中的时间,在小数的小数点后需要大约 14 位数字。例如,UTC 时间 2022 年 7 月 11 日下午 2:01,表示为具有纳秒精度的儒略日,即 245。58661 . 86768678671

像这样的数字,既有很大的值,又精确到许多小数位,不太适合表示为浮点数。如果你用tomli读这个儒略日,你会损失多少精度?打开 REPL,体验一下:

>>> import tomli
>>> ts = tomli.loads("ts = 2_459_772.084027777777778")["ts"]
>>> ts
2459772.084027778

>>> seconds = (ts - int(ts)) * 86_400
>>> seconds
7260.000009834766

>>> seconds - 7260
9.834766387939453e-06

首先使用tomli解析儒略日,挑选出值,并将其命名为ts。您可以看到ts的值被截断了几个小数位。为了弄清楚截断的效果有多糟糕,您计算由ts的小数部分表示的秒数,并将其与 7260 进行比较。

整数儒略日代表某一天的中午。下午 2:01 是中午之后的两小时零一分钟,两小时零一分钟等于 7260 秒,所以seconds - 7260向您展示了您的解析引入了多大的误差。

在这种情况下,您的时间戳大约有 10 微秒的误差。这听起来可能不多,但在许多天文应用中,信号以光速传播。在这种情况下,10 微秒可能会导致大约 3 公里的误差!

这个问题的一个常见解决方案是不将非常精确的时间戳存储为儒略日。取而代之的是许多具有更高精度的变体。然而,您也可以通过使用 Python 的Decimal类来修复您的示例,该类提供任意精度的十进制数。

回到你的 REPL,重复上面的例子:

>>> import tomli
>>> from decimal import Decimal
>>> ts = tomli.loads(
...     "ts = 2_459_772.084027777777778",
...     parse_float=Decimal, ... )["ts"]
>>> ts
Decimal('2459772.084027777777778')

>>> seconds = (ts - int(ts)) * 86_400
>>> seconds
Decimal('7260.000000000019200')

>>> seconds - 7260
Decimal('1.9200E-11')

现在,剩下的小误差来自你的原始表示,大约是 19 皮秒,相当于光速下的亚厘米误差。

当你知道你需要精确的浮点数时,你可以使用Decimal。在更具体的用例中,您还可以将数据存储为字符串,并在读取 TOML 文件后解析应用程序中的字符串。

到目前为止,您已经看到了如何用 Python 读取 TOML 文件。接下来,您将讨论如何将配置文件合并到您自己的项目中。

Remove ads

在项目中使用配置文件

您有一个项目,其中包含一些您想要提取到配置文件中的设置。回想一下,配置可以通过多种方式改进您的代码库:

  • 命名价值观和概念。
  • 它为特定值提供了更多可见性
  • 它使得改变的值更简单。

配置文件可以帮助您了解源代码,并增加用户与应用程序交互的灵活性。您知道如何阅读基于 TOML 的配置文件,但是如何在您的项目中使用它呢?

特别是,你如何确保配置文件只被解析一次,你如何从不同的模块访问配置**?**

原来 Python 的导入系统已经支持这两个开箱即用的特性。当您导入一个模块时,它会被缓存以备后用。换句话说,如果您将您的配置包装在一个模块中,您知道该配置将只被读取一次,即使您从几个地方导入该模块。

是时候举个具体的例子了。调用前面的tic_tac_toe.toml配置文件:

# tic_tac_toe.toml [user] player_x.color  =  "blue" player_o.color  =  "green" [constant] board_size  =  3 [server] url  =  "https://tictactoe.example.com"

创建一个名为config/的目录,并将tic_tac_toe.toml保存在该目录中。另外,在config/中创建一个名为__init__.py的空文件。您的小型目录结构应该如下所示:

config/
├── __init__.py
└── tic_tac_toe.toml

名为__init__.py的文件在 Python 中起着特殊的作用。它们将包含目录标记为包。此外,在__init__.py中定义的名字通过包公开。您将很快看到这在实践中意味着什么。

现在,向__init__.py添加代码以读取配置文件:

# __init__.py

import pathlib
import tomli

path = pathlib.Path(__file__).parent / "tic_tac_toe.toml"
with path.open(mode="rb") as fp:
    tic_tac_toe = tomli.load(fp)

像前面一样,使用load()读取 TOML 文件,并将 TOML 数据存储到名称tic_tac_toe中。你使用 pathlib 和特殊的 __file__ 变量来设置path,TOML 文件的完整路径。实际上,这指定了 TOML 文件存储在与__init__.py文件相同的目录中。

通过从config/的父目录启动 REPL 会话来试用您的小软件包:

>>> import config
>>> config.path
PosixPath('/home/realpython/config/tic_tac_toe.toml')

>>> config.tic_tac_toe
{'user': {'player_x': {'color': 'blue'}, 'player_o': {'color': 'green'}},
 'constant': {'board_size': 3},
 'server': {'url': 'https://tictactoe.example.com'}}

您可以检查配置的路径并访问配置本身。要读取特定值,可以使用常规项目访问:

>>> config.tic_tac_toe["server"]["url"]
'https://tictactoe.example.com'

>>> config.tic_tac_toe["constant"]["board_size"]
3

>>> config.tic_tac_toe["user"]["player_o"]
{'color': 'green'}

>>> config.tic_tac_toe["user"]["player_o"]["color"]
'green'

现在,您可以通过将config/目录复制到您的项目中,并用您自己的设置替换井字游戏配置,来将配置集成到您现有的项目中。

在代码文件中,您可能希望为配置导入设置别名,以便更方便地访问您的设置:

>>> from config import tic_tac_toe as CFG 
>>> CFG["user"]["player_x"]["color"]
'blue'

在这里,您可以在导入过程中将配置命名为CFG,这使得访问配置设置既高效又易读。

这个菜谱为您提供了一种在您自己的项目中使用配置的快速而可靠的方法。

Remove ads

将 Python 对象转储为 TOML

您现在知道如何用 Python 读取 TOML 文件了。怎么能反其道而行之呢?TOML 文档通常是手写的,因为它们主要用作配置。尽管如此,有时您可能需要将嵌套字典转换成 TOML 文档。

在这一节中,您将从手工编写一个基本的 TOML 编写器开始。然后,您会看到哪些工具已经可用,并使用第三方的tomli_w库将您的数据转储到 TOML。

将字典转换为 TOML

回想一下您之前使用的井字游戏配置。您可以将其稍加修改的版本表示为嵌套的 Python 字典:

{
    "user": {
        "player_x": {"symbol": "X", "color": "blue", "ai": True},
        "player_o": {"symbol": "O", "color": "green", "ai": False},
        "ai_skill": 0.85,
    },
    "board_size": 3,
    "server": {"url": "https://tictactoe.example.com"},
}

在这一小节中,您将编写一个简化的 TOML 编写器,它能够将本词典编写为 TOML 文档。你不会实现 TOML 的所有特性。特别是,您忽略了一些值类型,如时间、日期和表格数组。您也没有处理需要加引号的键或多行字符串。

尽管如此,您的实现将处理 TOML 的许多典型用例。在下一小节中,您将看到如何使用一个库来处理规范的其余部分。打开编辑器,创建一个名为to_toml.py的新文件。

首先,编写一个名为_dumps_value()的助手函数。该函数将接受某个值,并基于值类型返回其 TOML 表示。您可以通过isinstance()检查来实现这一点:

# to_toml.py

def _dumps_value(value):
    if isinstance(value, bool):
        return "true" if value else "false"
    elif isinstance(value, (int, float)):
        return str(value)
    elif isinstance(value, str):
        return f'"{value}"'
    elif isinstance(value, list):
        return f"[{', '.join(_dumps_value(v) for v in value)}]"
    else:
        raise TypeError(f"{type(value).__name__}  {value!r} is not supported")

您为布尔值返回truefalse,并在字符串两边加上双引号。如果你的值是一个列表,你可以通过递归调用_dumps_value()来创建一个 TOML 数组。如果你正在使用 Python 3.10 或更新版本,那么你可以用一个matchcase语句来替换你的isinstance()检查。

接下来,您将添加处理这些表的代码。您的 main 函数循环遍历一个字典,并将每个条目转换成一个键值对。如果值碰巧是一个字典,那么您将添加一个表头并递归地填写该表:

# to_toml.py

# ...

def dumps(toml_dict, table=""):
    toml = []
    for key, value in toml_dict.items():
        if isinstance(value, dict):
            table_key = f"{table}.{key}" if table else key
            toml.append(f"\n[{table_key}]\n{dumps(value, table_key)}")
        else:
            toml.append(f"{key} = {_dumps_value(value)}")
    return "\n".join(toml)

为了方便起见,可以使用一个列表,在添加表或键值对时跟踪它们。在返回之前,将这个列表转换成一个字符串。

除了前面提到的限制,这个函数中还隐藏着一个微妙的错误。考虑一下如果您尝试转储前面的示例会发生什么:

>>> import to_toml
>>> config = {
...     "user": {
...         "player_x": {"symbol": "X", "color": "blue", "ai": True},
...         "player_o": {"symbol": "O", "color": "green", "ai": False},
...         "ai_skill": 0.85,
...     },
...     "board_size": 3,
...     "server": {"url": "https://tictactoe.example.com"},
... }

>>> print(to_toml.dumps(config))

[user]

[user.player_x]
symbol = "X"
color = "blue"
ai = true

[user.player_o]
symbol = "O"
color = "green"
ai = false
ai_skill = 0.85 board_size = 3 
[server]
url = "https://tictactoe.example.com"

请特别注意突出显示的行。看起来ai_skillboard_sizeuser.player_o表中的键。但根据原始数据,它们应该分别是user和根表的成员。

问题是没有办法标记 TOML 表的结束。相反,常规键必须列在任何子表之前。修复代码的一种方法是对字典项进行排序,使字典值排在所有其他值之后。按如下方式更新您的函数:

# to_toml.py

# ...

def dumps(toml_dict, table=""):
 def tables_at_end(item): _, value = item return isinstance(value, dict) 
    toml = []
 for key, value in sorted(toml_dict.items(), key=tables_at_end):        if isinstance(value, dict):
            table_key = f"{table}.{key}" if table else key
            toml.append(f"\n[{table_key}]\n{dumps(value, table_key)}")
        else:
            toml.append(f"{key} = {_dumps_value(value)}")
    return "\n".join(toml)

实际上,tables_at_end()为所有非字典值返回False0,为所有字典值返回True,T3 相当于1。使用它作为排序关键字可以确保嵌套字典在其他类型的值之后被处理。

现在,您可以重做上面的示例。当您将结果打印到您的终端屏幕时,您将看到下面的 TOML 文档:

board_size  =  3 [user] ai_skill  =  0.85 [user.player_x] symbol  =  "X" color  =  "blue" ai  =  true [user.player_o] symbol  =  "O" color  =  "green" ai  =  false [server] url  =  "https://tictactoe.example.com"

这里,board_size作为根表的一部分列在顶部,这是意料之中的。另外,ai_skill现在是user中的一个键,就像它应该的那样。

尽管 TOML 不是一种复杂的格式,但是在创建自己的 TOML 编写器时,您需要考虑一些细节。您不再继续这个任务,而是转而研究如何使用现有的库将数据转储到 TOML 中。

Remove ads

tomli_w 编写 TOML 文档

在本节中,您将使用 tomli_w 库。顾名思义,tomli_wtomli有关。它有两个功能,dump()dumps(),其设计或多或少与load()loads()相反。

**注意:**Python 3.11 中新增的tomllib不包括 dump()dumps(),也没有tomllib_w。相反,你可以使用tomli_w在 Python 3.7 以后的所有版本上编写 TOML。

您必须将tomli_w安装到您的虚拟环境中,然后才能使用它:

(venv) $ python -m pip install tomli_w

现在,尝试重复上一小节中的示例:

>>> import tomli_w
>>> config = {
...     "user": {
...         "player_x": {"symbol": "X", "color": "blue", "ai": True},
...         "player_o": {"symbol": "O", "color": "green", "ai": False},
...         "ai_skill": 0.85,
...     },
...     "board_size": 3,
...     "server": {"url": "https://tictactoe.example.com"},
... }

>>> print(tomli_w.dumps(config))
board_size = 3

[user]
ai_skill = 0.85

[user.player_x]
symbol = "X"
color = "blue"
ai = true

[user.player_o]
symbol = "O"
color = "green"
ai = false

[server]
url = "https://tictactoe.example.com"

毫无疑问:tomli_w编写了与您在上一节中手写的dumps()函数相同的 TOML 文档。此外,第三方库支持您没有实现的所有功能,包括时间和日期、内联表格和表格数组。

dumps()写入可以继续处理的字符串。如果您想将新的 TOML 文档直接存储到磁盘,那么您可以调用dump()来代替。与load()一样,您需要传入一个以二进制模式打开的文件指针。继续上面的例子:

>>> with open("tic-tac-toe-config.toml", mode="wb") as fp:
...     tomli_w.dump(config, fp)
...

这将把config数据结构存储到文件tic-tac-toe-config.toml中。查看一下您新创建的文件:

# tic-tac-toe-config.toml board_size  =  3 [user] ai_skill  =  0.85 [user.player_x] symbol  =  "X" color  =  "blue" ai  =  true [user.player_o] symbol  =  "O" color  =  "green" ai  =  false [server] url  =  "https://tictactoe.example.com"

您可以在需要的地方找到所有熟悉的表和键值对。

tomlitomli_w都很基本,功能有限,同时实现了对 TOML v1.0.0 的完全支持。一般来说,只要它们兼容,您就可以通过 TOML 往返处理您的数据结构:

>>> import tomli, tomli_w
>>> data = {"fortytwo": 42}
>>> tomli.loads(tomli_w.dumps(data)) == data
True

在这里,您确认您能够在第一次转储到 TOML 然后加载回 Python 之后恢复data

**注意:**不应该使用 TOML 进行数据序列化的一个原因是有许多数据类型不受支持。例如,如果您有一个带数字键的字典,那么tomli_w理所当然地拒绝将它转换成 TOML:

>>> import tomli, tomli_w
>>> data = {1: "one", 2: "two"}
>>> tomli.loads(tomli_w.dumps(data)) == data
Traceback (most recent call last):
  ...
TypeError: 'int' object is not iterable

这个错误消息不是很有描述性,但是问题是 TOML 不支持像12这样的非字符串键。

之前,您已经了解到tomli会丢弃评论。此外,您无法在字典中区分由load()loads()返回的文字字符串、多行字符串和常规字符串。总的来说,这意味着当您解析一个 TOML 文档并将其写回时,您会丢失一些元信息:

>>> import tomli, tomli_w
>>> toml_data = """
... [nested]  # Not necessary
... ...     [nested.table]
...     string       = "Hello, TOML!"
...     weird_string = '''Literal
...         Multiline'''
... """
>>> print(tomli_w.dumps(tomli.loads(toml_data)))
[nested.table]
string = "Hello, TOML!"
weird_string = "Literal\n        Multiline"

TOML 内容保持不变,但是您的输出与您传入的完全不同!父表nested没有明确地包含在输出中,注释也不见了。此外,nested.table中的等号不再对齐,weird_string也不再表示为多行字符串。

**注意:**您可以使用multiline_strings参数来指示tomli_w在适当的时候使用多行字符串。

总之,tomli_w是编写 TOML 文档的一个很好的选择,只要您不需要对输出进行很多控制。在下一节中,您将使用tomlkit,如果需要的话,它会给您更多的控制权。您将从头开始创建一个专用的 TOML 文档对象,而不是简单地将字典转储到 TOML。

Remove ads

创建新的 TOML 文档

你知道如何用tomlitomli_w快速读写 TOML 文档。您还注意到了tomli_w的一些局限性,尤其是在格式化生成的 TOML 文件时。

在这一节中,您将首先探索如何格式化 TOML 文档,使它们更易于用户使用。然后,您将尝试另一个名为tomlkit的库,您可以用它来完全控制您的 TOML 文档。

TOML 文档的格式和样式

一般来说,空白在 TOML 文件中会被忽略。您可以利用这一点来使您的配置文件组织良好、易读和直观。此外,散列符号(#)将该行的其余部分标记为注释。自由地使用它们。

没有针对 TOML 文档的样式指南,也就是说 PEP 8 是针对 Python 代码的样式指南。然而,规范确实包含了一些建议,同时也为你留下了一些风格方面的选择。

TOML 中的一些特性非常灵活。例如,您可以任意顺序定义表格。因为表名是完全限定的,所以您甚至可以在父表之前定义子表。此外,键周围的空白被忽略。标题[nested.table][ nested . table]从同一个嵌套表开始。

TOML 规范中的建议可以总结为不要滥用灵活性。保持你对一致性和可读性的关注,你和你的用户会更开心!

要查看样式选项列表,您可以根据个人偏好做出合理的选择,请查看 Taplo 格式化程序可用的配置选项。以下是一些你可以思考的问题:

  • 缩进子表还是只依靠表头来表示结构?
  • 在每个表中对齐键-值对中的等号还是始终坚持在等号的每一侧留一个空格?
  • 将长数组分割成多行还是总是将它们集中在一行
  • 在多行数组的最后一个值后添加一个尾随逗号让它保持空白
  • 对表和键按语义按字母顺序排序?

每一个选择都取决于个人品味,所以请随意尝试,找到你觉得舒服的东西。

尽管如此,努力保持一致还是有好处的。为了保持一致性,您可以在项目中使用类似于 Taplo 的格式化程序,并将其配置文件包含在您的版本控制中。你也可以将集成到你的编辑器中。

回头看看上面的问题。如果使用tomli_w编写 TOML 文档,那么唯一可以选择的问题就是如何对表和键进行排序。如果你想更好地控制你的文档,那么你需要一个不同的工具。在下一小节中,您将开始关注tomlkit,它赋予您更多的权力和责任。

tomlkit 从头开始创建 TOML

TOML Kit 最初是为诗歌项目打造的。作为其依赖性管理的一部分,poem 操作pyproject.toml文件。然而,由于这个文件用于多种用途,诗歌必须保留文件中的风格和注释。

在这一小节中,您将使用tomlkit从头开始创建一个 TOML 文档,以便使用它的一些功能。首先,您需要将软件包安装到您的虚拟环境中:

(venv) $ python -m pip install tomlkit

你可以从确认tomlkittomlitomli_w更强大开始。重复前面的往返示例,注意所有的格式都保留了下来:

>>> import tomlkit
>>> toml_data = """
... [nested]  # Not necessary
... ...     [nested.table]
...     string       = "Hello, TOML!"
...     weird_string = '''Literal
...         Multiline'''
... """
>>> print(tomlkit.dumps(tomlkit.loads(toml_data)))

[nested]  # Not necessary

 [nested.table]
 string       = "Hello, TOML!"
 weird_string = '''Literal
 Multiline'''

>>> tomlkit.dumps(tomlkit.loads(toml_data)) == toml_data
True

你可以像前面一样使用loads()dumps()——load()dump()来读写 TOML。但是,现在所有的字符串类型、缩进、注释和对齐方式都保留了下来。

为了实现这一点,tomlkit使用了定制的数据类型,其行为或多或少类似于您的本地 Python 类型。稍后你会学到更多关于这些数据类型的知识。首先,您将看到如何从头开始创建一个 TOML 文档:

>>> from tomlkit import comment, document, nl, table

>>> toml = document()
>>> toml.add(comment("Written by TOML Kit"))
>>> toml.add(nl())
>>> toml.add("board_size", 3)

一般来说,你需要通过调用document()来创建一个 TOML 文档实例。然后,您可以使用.add()向这个文档添加不同的对象,比如注释、换行符、键值对和表格。

**注意:**调用.add()返回更新后的对象。在本节的示例中您不会看到这一点,因为额外的输出会分散示例流的注意力。稍后您将看到如何利用这一设计,并将几个调用链接到一起.add()

你可以使用上面的dump()dumps()toml转换成一个实际的 TOML 文档,或者你可以使用.as_string()方法:

>>> print(toml.as_string())
# Written by TOML Kit

board_size = 3

在本例中,您开始重新创建之前使用过的井字游戏配置的各个部分。注意输出中的每一行如何对应到代码中的一个.add()方法。首先是注释,然后是代表空行的nl(),然后是键值对。

继续您的示例,添加几个表格:

>>> player_x = table()
>>> player_x.add("symbol", "X")
>>> player_x.add("color", "blue")
>>> player_x.comment("Start player")
>>> toml.add("player_x", player_x)

>>> player_o = table()
>>> player_o.update({"symbol": "O", "color": "green"})
>>> toml["player_o"] = player_o

您可以通过调用table()来创建表格,并向其中添加内容。创建了一个表格后,就可以将它添加到 TOML 文档中。您可以坚持使用.add()来组合您的文档,但是这个例子也展示了一些添加内容的替代方法。例如,您可以使用.update()直接从字典中添加键和值。

当您将文档转换为 TOML 字符串时,它将如下所示:

>>> print(toml.as_string())
# Written by TOML Kit

board_size = 3

[player_x] # Start player
symbol = "X"
color = "blue"

[player_o]
symbol = "O"
color = "green"

将此输出与您用来创建文档的命令进行比较。如果您正在创建一个具有固定结构的 TOML 文档,那么将文档写成一个 TOML 字符串并用tomlkit加载它可能更容易。然而,您在上面看到的命令在动态组合配置时为您提供了很大的灵活性。

在下一节中,您将更深入地研究tomlkit,看看如何使用它来更新现有的配置。

更新现有的 TOML 文档

假设您已经花了一些时间将一个组织良好的配置和良好的注释放在一起,指导您的用户如何更改它。然后一些其他的应用程序出现并把它的配置存储在同一个文件中,同时破坏你精心制作的艺术品。

这可能是将您的配置保存在一个其他人不会接触到的专用文件中的一个理由。然而,有时使用公共配置文件也很方便。 pyproject.toml 文件就是这样一个通用文件,尤其是对于开发和构建包时使用的工具。

在这一节中,您将深入了解tomlkit如何表示 TOML 对象,以及如何使用这个包来更新现有的 TOML 文件。

将 TOML 表示为tomlkit对象

在前面,您看到了tomlitomllib将 TOML 文档解析成本地 Python 类型,如字符串、整数和字典。你已经看到一些迹象表明tomlkit是不同的。现在,是时候仔细看看tomlkit如何表示一个 TOML 文档了。

首先,复制并保存下面的 TOML 文件为tic-tac-toe-config.toml:

# tic-tac-toe-config.toml board_size  =  3 [user] ai_skill  =  0.85  # A number between 0 (random) and 1 (expert) [user.player_x] symbol  =  "X" color  =  "blue" ai  =  true [user.player_o] symbol  =  "O" color  =  "green" ai  =  false # Settings used when deploying the application [server] url  =  "https://tictactoe.example.com"

打开 REPL 会话并用tomlkit加载此文档:

>>> import tomlkit
>>> with open("tic-tac-toe-config.toml", mode="rt", encoding="utf-8") as fp:
...     config = tomlkit.load(fp)
...
>>> config
{'board_size': 3, 'user': {'ai_skill': 0.85, 'player_x': { ... }}}

>>> type(config)
<class 'tomlkit.toml_document.TOMLDocument'>

使用load()从文件中加载 TOML 文档。看config的时候,第一眼就像一本字典。然而,深入挖掘,你会发现这是一种特殊的TOMLDocument类型。

**注意:**与tomli不同,tomlkit希望你以文本模式打开文件。你还应该记得指定文件应该使用utf-8编码来打开。

这些自定义数据类型的行为或多或少类似于您的本地 Python 类型。例如,您可以使用方括号([])访问文档中的子表和值,就像字典一样。继续上面的例子:

>>> config["user"]["player_o"]["color"]
'green'

>>> type(config["user"]["player_o"]["color"])
<class 'tomlkit.items.String'>

>>> config["user"]["player_o"]["color"].upper()
'GREEN'

尽管这些值也是特殊的tomlkit数据类型,但是您可以像处理普通的 Python 类型一样处理它们。例如,您可以使用.upper()字符串方法。

特殊数据类型的一个优点是,它们允许您访问关于文档的元信息,包括注释和缩进:

>>> config["user"]["ai_skill"]
0.85

>>> config["user"]["ai_skill"].trivia.comment
'# A number between 0 (random) and 1 (expert)'

>>> config["user"]["player_x"].trivia.indent
'    '

例如,您可以通过.trivia访问器恢复注释和缩进信息。

正如您在上面看到的,您可以将这些特殊对象视为本地 Python 对象。事实上,他们从本地的同类那里继承了 T2。但是,如果您真的需要,您可以使用.unwrap()将它们转换成普通的 Python:

>>> config["board_size"] ** 2
9

>>> isinstance(config["board_size"], int)
True

>>> config["board_size"].unwrap()
3

>>> type(config["board_size"].unwrap())
<class 'int'>

在调用了.unwrap()之后,3现在是一个普通的 Python 整数。总之,这个调查让你对tomlkit如何能够保持 TOML 文档的风格有了一些了解。

在下一小节中,您将了解如何使用tomlkit数据类型来定制 TOML 文档,而不影响现有的样式。

无损读写 TOML】

您知道tomlkit表示使用定制类的 TOML 文档,并且您已经看到如何从头开始创建这些对象,以及如何读取现有的 TOML 文档。在这一小节中,您将加载一个现有的 TOML 文件,并在将其写回磁盘之前对其进行一些更改。

首先加载您在上一小节中使用的同一个 TOML 文件:

>>> import tomlkit
>>> with open("tic-tac-toe-config.toml", mode="rt", encoding="utf-8") as fp:
...     config = tomlkit.load(fp)
...

正如您之前看到的,config现在是一个TOMLDocument。您可以使用.add()向其中添加新元素,就像您从头开始创建文档时一样。但是,您不能使用.add()来更新现有键的值:

>>> config.add("app_name", "Tic-Tac-Toe")
{'board_size': 3, 'app_name': 'Tic-Tac-Toe', 'user': { ... }}

>>> config["user"].add("ai_skill", 0.6)
Traceback (most recent call last):
  ...
KeyAlreadyPresent: Key "ai_skill" already exists.

你试图降低人工智能的技能,这样你就有一个更容易对付的对手。但是,你不能用.add()做到这一点。相反,您可以分配新值,就像config是一个常规字典一样:

>>> config["user"]["ai_skill"] = 0.6 >>> print(config["user"].as_string())
ai_skill = 0.6  # A number between 0 (random) and 1 (expert) 
 [user.player_x]
 symbol = "X"
 color = "blue"
 ai = true

 [user.player_o]
 symbol = "O"
 color = "green"
 ai = false

当您像这样更新一个值时,tomlkit仍然会注意保留样式和注释。如你所见,关于ai_skill的评论没有被改动。

部分tomlkit支持所谓的流畅界面。实际上,这意味着像.add()这样的操作会返回更新后的对象,这样你就可以在其上链接另一个对.add()的调用。当您需要构造包含多个字段的表时,可以利用这一点:

>>> from tomlkit import aot, comment, inline_table, nl, table
>>> player_data = [
...     {"user": "gah", "first_name": "Geir Arne", "last_name": "Hjelle"},
...     {"user": "tompw", "first_name": "Tom", "last_name": "Preston-Werner"},
... ]

>>> players = aot()
>>> for player in player_data:
...     players.append(
...         table()
...         .add("username", player["user"])
...         .add("name",
...             inline_table()
...             .add("first", player["first_name"])
...             .add("last", player["last_name"])
...         )
...     )
...
>>> config.add(nl()).add(comment("Players")).add("players", players)

在本例中,您创建了一个包含球员信息的表数组。首先用aot()构造函数创建一个空的表数组。然后循环遍历玩家数据,将每个玩家添加到数组中。

您使用方法链接来创建每个玩家表。实际上,您的调用是table().add().add(),它将两个元素添加到一个新表中。最后,在配置的底部,在一个简短的注释下面添加新的玩家表数组。

对配置的更新完成后,您现在可以将它写回同一个文件:

>>> with open("tic-tac-toe-config.toml", mode="wt", encoding="utf-8") as fp:
...     tomlkit.dump(config, fp)

打开tic-tac-toe-config.toml,注意你的更新已经包含在内。与此同时,原有的风格得以保留:

# tic-tac-toe-config.toml board_size  =  3 app_name  =  "Tic-Tac-Toe"  
[user] ai_skill  =  0.6  # A number between 0 (random) and 1 (expert)  
  [user.player_x] symbol  =  "X" color  =  "blue" ai  =  true [user.player_o] symbol  =  "O" color  =  "green" ai  =  false # Settings used when deploying the application [server] url  =  "https://tictactoe.example.com" # Players  
[[players]]  username  =  "gah"  name  =  {first  =  "Geir Arne",  last  =  "Hjelle"}  
[[players]]  username  =  "tompw"  name  =  {first  =  "Tom",  last  =  "Preston-Werner"}

请注意,app_name已经被添加,user.ai_skill的值已经被更新,players表的数组已经被附加到您的配置的末尾。您已经成功地以编程方式更新了您的配置。

结论

这是您对 TOML 格式以及如何在 Python 中使用它的广泛探索的结束。您已经看到了一些使 TOML 成为一种灵活方便的配置文件格式的特性。同时,您还发现了一些限制其在其他应用程序(如数据序列化)中使用的局限性。

在本教程中,您已经:

  • 了解了 TOML 语法及其支持的数据类型
  • tomlitomllib解析 TOML 文档
  • tomli_w编写 TOML 文档
  • 无损的tomlkit更新了的 TOML 文件

您是否有需要方便配置的应用程序?汤姆可能就是你要找的人。

免费下载: 从 Python 技巧中获取一个示例章节:这本书用简单的例子向您展示了 Python 的最佳实践,您可以立即应用它来编写更漂亮的+Python 代码。**********

Logo

欢迎加入我们的广州开发者社区,与优秀的开发者共同成长!

更多推荐