Python-超现代工具指南-早期发布--全-

Python 超现代工具指南(早期发布)(全)

原文:annas-archive.org/md5/6f8649e4315891fb5ab5d94daae45017

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书是现代 Python 开发工具的指南——这些程序帮助你执行诸如:

  • 管理你系统上的 Python 安装

  • 安装当前项目的第三方包

  • 构建一个 Python 包,用于在包仓库上分发

  • 在多个环境中反复运行测试套件

  • 对你的代码进行 linting 和类型检查以捕获 bug

你并不严格需要这些工具来编写 Python 软件。启动你系统的 Python 解释器,获取一个交互式提示符。将你的 Python 代码保存为脚本以供以后使用。为什么要使用编辑器和命令行之外的任何东西呢?

这不是一个修辞性问题。你将添加到开发工作流程中的每个工具都应该有一个明确的目的,并带来超过使用它的成本的好处。通常情况下,当你需要使开发能够持续 长期 时,开发工具的好处会显现出来。在某些时候,将你的模块发布到 Python 软件包索引上会比将其发送电子邮件给用户更容易。

在从编写一次性脚本到分发和维护包的旅程中,会遇到一些挑战:

  • 在多个操作系统上支持多个 Python 版本

  • 保持依赖项的最新状态,并扫描它们以发现漏洞

  • 保持代码库的可读性和一致性

  • 与社区中的 bug 报告和外部贡献进行交互

  • 保持高测试覆盖率,以减少代码更改中的缺陷率

  • 自动化重复任务,减少摩擦并避免意外

本书将向你展示开发工具如何帮助解决这些挑战。这里描述的工具极大地提升了 Python 项目的代码质量、安全性和可维护性。

但工具也增加了复杂性和开销。本书力求通过将工具组合成易于使用的工具链,并通过可靠和可重复的自动化工作流来最小化这些问题——无论是在开发者的本地机器上执行,还是在跨多个平台和环境的持续集成服务器上执行。尽可能地,你应该能够专注于编写软件,而你的工具链则在后台运行。

懒惰被称为“程序员的最大优点”,¹ 这句话也适用于开发工具:保持你的工作流程简单,不要为了工具而使用工具。与此同时,优秀的程序员也是好奇心重。尝试本书中的工具,看看它们能为你的项目带来什么价值。

谁应该阅读本书?

如果你是这些人之一,阅读本书将使你受益匪浅:

  • 你精通 Python,但不确定如何创建一个包。

  • 多年来,你一直在做这些事情——setuptools、virtualenv 和 pip 是你的朋友。你对工具链的最新发展很感兴趣,以及它们能为你的项目带来什么。

  • 你维护在生产环境中运行的重要代码。但肯定有更好的方法来处理所有这些事情。你想了解最先进的工具和不断发展的最佳实践。

  • 你希望作为 Python 开发者更加高效。

  • 你是一名寻找稳健且现代的项目基础设施的开源维护者。

  • 你在项目中使用了许多 Python 工具,但很难看到它们如何完美地配合在一起。你希望减少所有这些工具带来的摩擦。

  • “事情总是出问题 — 为什么 Python 现在找不到我的模块?我刚安装的包为什么无法导入?”

本书假定你具有基本的 Python 编程语言知识。你需要熟悉的唯一工具是 Python 解释器、编辑器或 IDE 以及操作系统的命令行。

本书大纲

本书分为三个部分:

第一部分,“使用 Python”

  • 第一章,“安装 Python”,教你如何随时间管理不同平台上的 Python 安装。本章还介绍了 Windows 和 Unix 的 Python 启动器 — 你将在全书中使用它们。

  • 第二章,“Python 环境”,深入讨论了 Python 安装,并讨论了你的代码如何与之交互。你还将了解帮助你有效使用虚拟环境的工具。

第二部分,“Python 项目”

  • 第三章,“Python 包”,教你如何将项目设置为 Python 包,并如何构建和发布打包工件。本章还介绍了贯穿全书的示例应用程序。

  • 第四章,“依赖管理”,讲述了如何将第三方包添加到 Python 项目中,以及如何随时间跟踪你的项目依赖关系。

  • 第五章,“使用 Poetry 管理项目”,教你如何使用 Poetry 处理 Python 项目。Poetry 让你可以在更高的层次管理环境、依赖关系和打包。

第三部分,“测试与静态分析”

  • 第六章,“使用 pytest 进行测试”,讨论如何测试 Python 项目,并有效地使用 pytest 框架及其生态系统。

  • 第七章,“使用 Coverage.py 进行代码覆盖率测量”,教你如何通过测量测试套件的代码覆盖率来发现未经测试的代码。

  • 第八章,“使用 Nox 进行自动化”,介绍了 Nox 自动化框架。你将使用它在 Python 环境中运行测试,并在项目中自动化检查和其他开发任务。

  • 第九章,“使用 Ruff 和 pre-commit 进行检查”,展示了如何找到和修复可能的错误,并使用 Ruff 格式化代码。您还将学习有关 pre-commit 的知识,这是一个与 Git 集成的跨语言代码检查框架。

  • 第十章,“使用类型进行安全和检查”,教您如何使用静态和运行时类型检查器验证类型安全性,并在运行时检查类型以执行真正的魔术(适用条款和条件)。

参考资料和进一步阅读

在查阅本书之外的第一手参考文档时,请访问每个工具的官方文档。此外,许多有趣的与包装相关的讨论都发生在Python Discourse。包装类别的讨论经常是塑造 Python 包装和工具生态系统未来的地方,通过Python Enhancement Proposal(PEP)流程形成包装标准。最后,Python Packaging Authority(PyPA)是一个维护 Python 包装中使用的核心软件项目的工作组。他们的网站跟踪当前活动的互操作性标准列表,管理 Python 包装。PyPA 还发布Python Packaging User Guide

本书使用的约定

本书使用以下印刷约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序列表,以及段落内用于引用程序元素(如变量或函数名、数据库、数据类型、环境变量、语句和关键字)。

等宽字体加粗

显示用户应直接输入的命令或其他文本。

等宽字体斜体

显示应由用户提供的值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素表示警告或注意事项。

使用代码示例

可以在https://oreil.ly/hmpt-code下载补充材料(代码示例、练习等)。

如果您有技术问题或使用代码示例遇到问题,请发送电子邮件至bookquestions@oreilly.com

此书旨在帮助您完成工作任务。通常情况下,如果此书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则无需征得我们的许可。例如,编写一个使用此书中多个代码片段的程序不需要许可。售卖或分发 O’Reilly 书籍中的示例代码需要许可。引用本书并引述示例代码以回答问题不需要许可。将本书中大量示例代码整合到产品文档中需要许可。

我们感谢,但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Hypermodern Python Tooling by Claudio Jolowicz (O’Reilly)。Copyright 2024 Claudio Jolowicz, 978-1-098-13958-2。”

如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时联系我们:permissions@oreilly.com

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media 提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。更多信息,请访问https://oreilly.com

如何联系我们

有关此书的评论和问题,请联系出版商:

  • O’Reilly Media, Inc.

  • Gravenstein Highway North 1005

  • 加利福尼亚州塞巴斯托波尔 95472

  • 800-889-8969(美国或加拿大)

  • 707-827-7019(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

我们有这本书的网页,上面列出了勘误、示例和任何额外信息。您可以访问https://oreil.ly/hypermodern-python-tooling

有关我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 YouTube 上观看我们:https://youtube.com/oreillymedia

致谢

这本书涵盖了许多开源 Python 项目。我非常感谢它们的作者和维护者,他们大多数在业余时间长达多年来致力于这些项目。特别是,我要感谢 PyPA 的无名英雄们,他们在打包标准上的工作使得生态系统能朝着更好的工具化方向发展。特别感谢 Thea Flowers 编写了 Nox 并建立了一个友好的社区。

在这本书之前,有《超现代 Python》文章系列。我要感谢 Brian Okken、Michael Kennedy 和 Paul Everitt 帮助传播这些内容,以及 Brian 给我鼓励将其改编成书的勇气。

我要感谢我的审阅者 Pat Viafore、Jürgen Gmach、Hynek Schlawack、William Jamir Silva、Ganesh Hark 和 Karandeep Johar,他们提供了深刻的见解和富有主见的反馈。没有他们,这本书将不会如此。我对任何剩余错误负责。

制作一本书需要整个村庄的帮助。我要感谢我的编辑 Zan McQuade、Brian Guerin、Sarah Grey 和 Greg Hyman,以及 O’Reilly 的整个团队。特别感谢 Sarah 在这段旅程中帮助我保持方向并改进我的写作。我要感谢 Cloudflare 的经理 Jakub Borys 给予我时间来完成这本书。

这本书献给我生命中的爱人 Marianna。没有她的支持、鼓励和灵感,我不可能完成这本书。

¹ Larry Wall, Programming Perl, (Sebastopol: O’Reilly, 1991).

第一部分:使用 Python

第一章:安装 Python

如果您已经拿起这本书,很可能您的机器上已经安装了 Python。大多数常见操作系统都提供python3命令。这可能是系统本身使用的解释器;在 Windows 和 macOS 上,它是一个占位符,当您第一次调用它时为您安装 Python。

如果将 Python 安装到新机器上如此简单,为什么要专门开辟一整章来讨论?答案是,长期开发中安装 Python 可能是一个复杂的问题,而且存在多种原因:

  • 通常您需要在同一台机器上安装多个版本的 Python。(如果您想知道为什么,我们很快会讨论到。)

  • 在常见平台上安装 Python 有几种不同的方法,每种方法都有独特的优势、权衡和有时候的陷阱。

  • Python 是一个不断发展的目标:您需要保持现有安装与最新的维护版本同步,发布新功能版本时添加安装,移除不再支持的版本。您甚至可能需要测试下一个 Python 的预发行版本。

  • 您可能希望您的代码能在多个平台上运行。虽然 Python 可以轻松编写可移植程序,但设置开发环境需要一些对每个平台特殊特性的熟悉。

  • 您可能希望使用 Python 的另一种替代实现来运行您的代码。¹

在本章中,我将向您展示如何在一些主要操作系统上以可持续的方式安装多个 Python 版本,以及如何保持您的小蛇农场状态良好。

小贴士

即使您只在一个平台上开发,我也建议您了解如何在其他操作系统上使用 Python。这很有趣,而且熟悉其他平台可以让您为软件的贡献者和用户提供更好的体验。

支持多个 Python 版本

Python 程序经常同时针对多个语言版本和标准库版本。这可能会让人感到惊讶。为什么不用最新的 Python 运行您的代码?毕竟,这可以让您的程序立即受益于新的语言功能和库改进。

事实证明,运行时环境通常会带有多个旧版本的 Python。² 即使您对部署环境有严格的控制,您可能也希望养成对多个版本进行测试的习惯。当您的生产环境中信赖的 Python 出现安全通告时,最好不要从那天开始将代码移植到新版本。

出于这些原因,通常会支持 Python 的当前版本和过去的版本,直到官方的终止支持日期,并在开发者机器上并排设置这些版本的安装。每年都会推出新的功能版本,并且支持延续五年,这使得您可以测试五个活跃支持的版本(参见 图 1-1)。如果听起来很费力,别担心:Python 生态系统提供了使这一切变得轻松的工具。

定位 Python 解释器

如果您的系统上有多个 Python 解释器,如何选择正确的 Python 解释器?让我们看一个具体的例子。当您在命令行中键入 python3 时,Shell 会从左到右搜索 PATH 环境变量中的目录,并调用第一个名为 python3 的可执行文件。在 macOS 和 Linux 上,还提供了命名为 python3.12python3.11 等命令,让您可以区分不同的功能版本。

注意

在 Windows 上,基于 PATH 的解释器发现不那么重要,因为 Python 的安装可以通过 Windows 注册表找到(参见 “Python Launcher for Windows”)。Windows 安装程序只提供了一个未版本化的 python.exe 可执行文件。

图 1-2 显示了一台 macOS 机器上安装了多个 Python 版本。从底部开始,第一个解释器位于 /usr/bin/python3,是苹果的命令行工具的一部分(Python 3.9)。接下来,在 /opt/homebrew/bin 目录下,有几个来自 Homebrew 分发的解释器;这里的 python3 是其主要解释器(Python 3.11)。Homebrew 的解释器之后是来自 python.org 的预发布版本(Python 3.13)。最顶部条目包含了当前发布版本(本文写作时为 Python 3.12),同样来自 Homebrew。

macOS 机器上自定义 PATH 的示例,包含来自 Homebrew、python.org 和苹果命令行工具的 Python 安装。

图 1-2. 一台安装了多个 Python 的开发系统。搜索路径显示为一堆目录;位于顶部的命令会遮盖掉下面的命令。

搜索路径中目录的顺序很重要,因为较早的条目优先于或者“遮盖”后面的条目。在 图 1-2 中,python3 指的是当前稳定版本(Python 3.12)。如果省略了顶部条目,python3 将指向预发布版本(Python 3.13)。如果省略了前两个条目,它将指向 Homebrew 的默认解释器,该解释器仍然是之前的稳定版本(Python 3.11)。

PATH 上定位 Python 解释器是常见的错误来源。有些安装会覆盖共享目录中(例如 /usr/local/bin)的 python3 命令。其他安装则将 python3 放置在不同的目录,并修改 PATH 以使其优先,覆盖先前安装的版本。为了解决这些问题,本书使用了 Unix 上的 Python Launcher(参见 “Python Launcher for Unix”)。然而,了解 PATH 变量的工作原理将有助于避免在 Windows、macOS 和 Linux 上出现 Python 发现问题。

在 Unix-like 系统上,PATH 变量的常见默认值是 /usr/local/bin:/usr/bin:/bin,通常与一些依赖于操作系统的位置结合使用。你可以使用许多 shell 的 export 内建来修改这个变量。下面是如何在 Bash shell 中添加 /usr/local/opt/python 中的 Python 安装:

export PATH="/usr/local/opt/python/bin:$PATH"

你添加的是 bin 子目录而不是安装根目录,因为这些系统上通常是解释器的正常位置。我们将在 第二章 中更详细地看一下 Python 安装的布局。另外,你将目录添加到 PATH 变量的前面。我马上会解释为什么这通常是你想要的。

上述行也适用于 Zsh,在 macOS 上是默认的 shell。尽管如此,Zsh 有一种更符合习惯的方式来操作搜索路径:

typeset -U path ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
path=(/usr/local/opt/python/bin $path) ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)

1

这指示 shell 从搜索路径中删除重复的条目。

2

Shell 会保持 path 数组与 PATH 变量同步。

Fish shell 提供了一个函数,用于将条目唯一和持久地添加到搜索路径:

fish_add_path /usr/local/opt/python/bin

每次启动 shell 会话时手动设置搜索路径将会很繁琐。相反,你可以将上述命令放置在你的 shell profile 中——这是一个位于你的主目录中,在启动时由 shell 读取的文件。Table 1-1 展示了一些常见的启动文件。

Table 1-1. 一些常见 shell 的启动文件

Shell 启动文件
Bash .bash_profile(Debian 和 Ubuntu:.profile
Zsh .zshrc
Fish .config/fish/fish.config

为什么将新目录添加到 PATH 变量的前面很重要?在干净的 macOS 或 Linux 安装中,python3 命令通常指向旧版本的 Python。作为 Python 开发者,你的默认解释器应该是最新稳定版本的 Python。将条目添加到 PATH 可以让你控制当 shell 遇到类似 python3 这样的模糊命令时选择哪个 Python 安装。你可以确保 python3 指向最新稳定版本的 Python,而每个 python3.x 则指向 3.x 系列的最新 bugfix 或安全发布版本。

Tip

除非您的系统已经配备了经过精心筛选和最新的解释器选择,否则应该将 Python 安装添加到PATH环境变量中,并确保最新稳定版本位于最前面。

在 Windows 上安装 Python

Python 核心团队在 Python 网站的Windows 下载部分提供了官方二进制安装程序。找到您希望支持的每个 Python 版本的最新发布版本,并下载每个版本的 64 位 Windows 安装程序。

注意

根据您的领域和目标环境,您可能更喜欢使用 Windows 子系统来进行 Python 开发。在这种情况下,请参考“在 Linux 上安装 Python”部分。

通常情况下,应该很少需要定制安装—除非有一个例外:当安装最新的稳定版本(仅限于这种情况下),请在安装程序对话框的第一页启用将 Python 添加到您的PATH环境变量选项。这样可以确保您的默认python命令使用一个众所周知且最新的 Python 版本。

python.org的安装程序是在 Windows 上设置多版本 Python 环境的高效方式,原因如下:

  • 它们会在 Windows 注册表中注册每个 Python 安装,使开发工具能够轻松发现系统上的解释器(参见“Windows 的 Python 启动器”)。

  • 它们不会像 Python 的重新分发版本那样存在一些缺点,例如落后于官方发布或受到下游修改的影响。

  • 它们不要求您构建 Python 解释器,这除了需要宝贵时间外,还涉及在系统上设置 Python 的构建依赖关系。

二进制安装程序仅提供了每个 Python 版本的最后一个 bugfix 发布版本,这通常在初始发布后大约 18 个月内完成。与此相反,较旧版本的安全更新仅作为源分发提供。如果您不想从源代码构建 Python,⁴ 您可以使用出色的Python 独立构建,这是一组自包含、高度可移植的 Python 发行版。

当您使用python.org的二进制安装程序时,保持 Python 安装的最新状态取决于您。新版本的发布将在多个位置公告,包括Python 博客Python 论坛。如果您安装了已经存在于系统上的 Python 版本的 bugfix 发布版本,它将替换现有安装。这将保留项目和开发工具,并应该是一个无缝的体验。

安装新版本的 Python 功能时,需注意以下额外步骤:

  • 启用选项以将新的 Python 添加到PATH环境变量。

  • PATH中删除先前的 Python 版本。您可以使用 Windows 的系统设置工具编辑您账户的环境变量。

  • 您可能还希望重新安装一些开发工具,以确保其在最新的 Python 版本上运行。

最终,某个 Python 版本将达到其生命周期的终点,您可能希望卸载它以释放资源。您可以使用已安装应用程序工具来删除现有安装。在已安装软件列表中,选择其条目的卸载操作。请注意,删除 Python 版本将会破坏仍在使用它的项目和工具,因此您应先将这些项目升级到更新的 Python 版本。

Windows 的 Python 启动器

在 Windows 上进行 Python 开发是特殊的,因为工具可以通过 Windows 注册表定位 Python 安装位置。Windows 的 Python 启动器利用这一点提供系统上解释器的单一入口点。它是每个python.org发布版本附带的实用程序,并与 Python 文件扩展名相关联,允许您从 Windows 文件资源管理器启动脚本。

双击运行应用程序非常方便,但当您从命令行提示符调用 Python 启动器时,它将发挥其最大的功能。打开 Powershell 窗口并运行py命令以启动交互会话:

> py
Python 3.12.2 (tags/v3.12.2:6abddd9, Feb  6 2024, 21:26:36) [...] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>>

默认情况下,Python 启动器选择系统上安装的最新版本。值得注意的是,这可能与系统上最近安装的版本不同。这是好事—​您不希望在安装较旧版本的错误修复版本时默认 Python 发生变化。

如果您想启动特定版本的解释器,可以将特性版本作为命令行选项传递:

> py -3.11
Python 3.11.8 (tags/v3.11.8:db85d51, Feb  6 2024, 22:03:32) [...] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>>

py的任何剩余参数都将转发给所选的解释器。让我们看看如何显示系统上两个解释器的版本:

> py -V
Python 3.12.2

> py -3.11 -V
Python 3.11.8

使用相同的机制,您可以在特定解释器上运行脚本:

> py -3.11 path\to\script.py
注意

出于历史原因,py还检查脚本的第一行,看是否指定了版本。规范形式是#!/usr/bin/env python3,对应于py -3,适用于所有主要平台。

正如您所见,Python 启动器默认选择系统上最新的版本。有一个例外:如果处于虚拟环境中,py将默认使用虚拟环境中的解释器。⁵

当您安装 Python 的预发布版本时,Python 启动器将使用它作为默认解释器,而不是当前发布的版本—​毕竟,这是系统上最新的版本。在这种情况下,您应通过设置PY_PYTHONPY_PYTHON3环境变量来覆盖默认设置为当前版本:

> setx PY_PYTHON 3.12
> setx PY_PYTHON3 3.12

重新启动控制台以使设置生效。不要忘记一旦从预发布版本升级到最终版本后删除这些变量。

结束我们对 Python 启动器的简短介绍,请使用命令py --list列举系统上的解释器:

> py --list
 -V:3.13          Python 3.13 (64-bit)
 -V:3.12 *        Python 3.12 (64-bit)
 -V:3.11          Python 3.11 (64-bit)
 -V:3.10          Python 3.10 (64-bit)
 -V:3.9           Python 3.9 (64-bit)
 -V:3.8           Python 3.8 (64-bit)

在此清单中,星号标记了 Python 的默认版本。

提示

即使您自己始终使用 Python 启动器,仍应保持您的PATH更新。一些第三方工具直接运行python.exe命令 — 您不希望它们使用过时的 Python 版本或回退到 Microsoft Store 的替代品。

在 macOS 上安装 Python

您可以通过几种方式在 macOS 上安装 Python。在本节中,我将看看 Homebrew 包管理器和官方python.org安装程序。两者都提供 Python 的多版本二进制发行版。一些在 Linux 上常见的安装方法 — 例如 Pyenv — 也适用于 macOS。Conda 包管理器甚至支持 Windows、macOS 和 Linux。我将在后面的章节中讨论它们。

Homebrew Python

Homebrew 是 macOS 和 Linux 的第三方软件包管理器。它提供一个覆盖发行,这是一个安装在现有操作系统之上的开源软件集合。安装包管理器非常简单;请参阅官方网站获取说明。

Homebrew 为每个维护的特性版本的 Python 发布包。使用brew命令行界面来管理它们:

brew install python@3.*x*

安装新的 Python 版本。

brew upgrade python@3.*x*

将 Python 版本升级到维护版本。

brew uninstall python@3.*x*

卸载 Python 版本。

注意

每当您在本节看到类似python3.*x*python@3.*x*的名称时,请用实际特性版本替换3.*x*。例如,对于 Python 3.12,请使用python3.12python@3.12

您可能会发现已安装某些 Python 版本,这是其他依赖它们的 Homebrew 包安装的结果。尽管如此,重要的是您显式安装每个版本。当您运行brew autoremove清理资源时,自动安装的包可能会被删除。

Homebrew 在您的PATH上放置了每个版本的python3.*x*命令,以及其主要 Python 包的python3命令 — 可能是当前或以前的稳定发布版。您应该覆盖此设置以确保python3指向最新版本。首先,查询包管理器的安装根目录(这取决于平台):

$ brew --prefix python@3.12
/opt/homebrew/opt/python@3.12

接下来,在您的PATH中添加此安装的bin目录。以下是在 Bash shell 上工作的示例:

export PATH="/opt/homebrew/opt/python@3.12/bin:$PATH"

Homebrew 在官方python.org安装程序上有一些优势:

  • 您可以使用命令行来安装、升级和卸载 Python 版本。

  • Homebrew 包含旧版本的安全更新 — 相比之下,python.org安装程序仅提供最后的 bugfix 发布版。

  • Homebrew Python 与分发的其余部分紧密集成。特别是,软件包管理器可以满足 Python 的依赖项如 OpenSSL。这使得在需要时可以独立升级它们。

另一方面,Homebrew Python 也有一些限制:

  • Homebrew 不提供即将发布的 Python 版本的预发布包。

  • 包通常会滞后于官方发布几天或几周。它们也包含一些下游修改,尽管这些修改是相当合理的。例如,Homebrew 将图形用户界面(GUI)相关的模块与主 Python 包分开。

  • 除非它们也作为 Homebrew 包提供,否则你无法系统范围内安装和卸载 Python 包。 (参见 “虚拟环境” 为什么你不应该系统范围内安装开发包。)

默认情况下,Homebrew 会自动将 Python 升级到维护版本。这种行为曾引发一些争议,因为它会破坏安装在先前版本上的虚拟环境。⁶ 但截至本文撰写时—​Homebrew Python 完全安全用于 Python 开发。

提示

就我个人而言,我推荐在 macOS 上使用 Homebrew 管理 Python—​它与系统的其余部分集成良好,并且易于保持更新。使用 python.org 的安装程序来测试你的代码与预发布版本的兼容性,Homebrew 并不提供这些预发布版本。

python.org 安装程序

核心 Python 团队在 Python 网站的 macOS 下载 部分提供官方二进制安装程序。下载你希望安装的版本的 64 位 universal2 安装程序。解释器的 universal2 二进制版本可以在 Apple Silicon 和 Intel 芯片上原生运行。⁷

对于多版本开发,我建议使用自定义安装—​在安装程序对话框中找到 自定义 按钮。在可安装组件的结果列表中,禁用 Unix 命令行工具Shell 档案更新程序。这两个选项旨在将解释器和其他一些命令添加到你的 PATH 中。⁸ 相反,手动编辑你的 shell 档案。在 PATH 前面加上目录 /Library/Frameworks/Python.framework/Versions/3.x/bin,将 3.*x* 替换为实际的特性版本。确保当前稳定版本位于 PATH 的最前面。

注意

安装 Python 版本后,请在 /Applications/Python 3.x/ 文件夹中运行 Install Certificates 命令。此命令安装 Mozilla 的策划根证书集,用于从 Python 建立安全的互联网连接。

当你安装一个已经存在于系统中的 Python 版本的 bug 修复版本时,它将替换现有的安装。你可以通过移除以下两个目录来卸载一个 Python 版本:

  • /Library/Frameworks/Python.framework/Versions/3.x/

  • /Applications/Python 3.x/

在 Linux 上安装 Python

Python 核心团队不为 Linux 提供二进制安装程序。通常,在 Linux 发行版上安装软件的首选方法是使用官方包管理器。然而,在开发 Python 时,这并不绝对正确——以下是一些重要的注意事项:

  • Linux 发行版中的系统 Python 可能相当旧,而且并非所有发行版的主要包存储库都包含替代 Python 版本。

  • Linux 发行版对应用程序和库的打包方式有强制性规定。例如,Debian 的 Python 政策要求标准的ensurepip模块必须在单独的包中提供;因此,在默认的 Debian 系统上无法创建虚拟环境(通常通过安装python3-full包解决)。

  • Linux 发行版中的主要 Python 包作为需要 Python 解释器的其他包的基础。这些包可能包括系统的关键部分,例如 Fedora 的包管理器 DNF。因此,发行版会采取保障措施来保护系统的完整性;例如,大多数发行版阻止使用 pip 在系统范围内安装或卸载包。

在接下来的章节中,我将讨论在两个主要的 Linux 发行版 Fedora 和 Ubuntu 上安装 Python。之后,我将介绍一些不使用官方包管理器的通用安装方法:Homebrew、Nix、Pyenv 和 Conda。我还会向您介绍 Python Launcher for Unix,这是一个第三方软件包,旨在将py实用程序引入 Linux、macOS 和类似系统。

Fedora Linux

Fedora 是一个由 Red Hat 主要赞助的开源 Linux 发行版,是 Red Hat Enterprise Linux (RHEL)的上游来源。它旨在保持与上游项目的紧密联系,并采用快速发布周期以促进创新。Fedora 以其出色的 Python 支持而闻名,Red Hat 雇用了几位 Python 核心开发者。

Python 预装在 Fedora 上,您可以使用 DNF 安装额外的 Python 版本:

sudo dnf install python3.*x*

安装一个新的 Python 版本。

sudo dnf upgrade python3.*x*

将 Python 版本升级到一个维护版本。

sudo dnf remove python3.*x*

卸载 Python 版本。

Fedora 为 CPython(Python 的参考实现)的所有活跃特性版本和预发行版提供包,还有像 PyPy 这样的替代实现的包。一种便捷的方法是一次性安装所有这些包,即安装tox包:

$ sudo dnf install tox

如果你在想,tox 是一个测试自动化工具,可以轻松地针对多个 Python 版本运行测试套件;它的 Fedora 包会引入大多数可用的解释器作为推荐的依赖项。Tox 也是 Nox 的精神祖先,Nox 是第八章的主题。

Ubuntu Linux

Ubuntu 是一个基于 Debian 的流行 Linux 发行版,由 Canonical Ltd. 赞助。Ubuntu 主要仓库中只提供一个 Python 版本;其他 Python 版本,包括预发布版本,由个人软件包存档(PPA)提供。PPA 是在由 Canonical 运行的软件协作平台 Launchpad 上维护的社区软件仓库。

在 Ubuntu 系统上的第一步应该是添加 deadsnakes PPA:

$ sudo apt update && sudo apt install software-properties-common
$ sudo add-apt-repository ppa:deadsnakes/ppa && sudo apt update

现在你可以使用 APT 包管理器安装 Python 版本:

sudo apt install python3.*x*-full

安装一个新的 Python 版本。

sudo apt upgrade python3.*x*-full

将 Python 版本升级到维护版本。

sudo apt remove python3.*x*-full

卸载 Python 版本。

提示

在 Debian 和 Ubuntu 上安装 Python 时,请记住始终包含 -full 后缀。python3.*x*-full 包会拉取整个标准库和最新的根证书。特别是,它们确保你可以创建虚拟环境。

其他 Linux 发行版

如果你的 Linux 发行版没有打包多个 Python 版本,你该怎么办?传统的答案是“自行编译 Python”。这可能看起来吓人,但我们会看到,在当今日子里编译 Python 已经变得非常简单,详情请见“使用 Pyenv 安装 Python”。然而,事实证明,从源代码构建并不是你唯一的选择。几个跨平台包管理器提供了 Python 的二进制包;实际上,你已经看到其中之一了。

Homebrew 发行版(参见“Homebrew Python”)也适用于 Linux 和 macOS,并且大部分上述内容同样适用于 Linux。两个平台之间的主要区别在于安装根目录:Linux 上的 Homebrew 默认安装在 /home/linuxbrew/.linuxbrew 而不是 /opt/homebrew。在将 Homebrew 的 Python 安装添加到你的 PATH 时请记住这一点。

安装 Python 的一个流行的跨平台方法是 Anaconda 发行版,专为科学计算而设计,支持 Windows、macOS 和 Linux。本章末尾将专门介绍 Anaconda(参见“从 Anaconda 安装 Python”)。

Python 的 Unix 版本启动器

Python 的 Unix 版本启动器是官方 py 实用程序的 Linux 和 macOS 版本,以及支持 Rust 编程语言的任何其他操作系统的端口。它的主要优点是提供了一个统一的、跨平台的启动 Python 的方式,在未指定版本时具有明确定义的默认值:系统上的最新解释器。

py 命令是一种方便的便携式方法,用于启动解释器,避免了直接调用 Python 的一些问题(参见“定位 Python 解释器”)。因此,在本书中我将一直使用它。你可以使用多种包管理器安装 python-launcher 包,包括 Homebrew、DNF 和 Cargo。

Unix 上的 Python 启动器通过扫描 PATH 环境变量来发现解释器,寻找 python*x*.*y* 命令。否则,它的工作方式与其 Windows 对应项类似(见 “Windows 上的 Python 启动器”)。如果仅输入 py,将启动最新的 Python 版本,但你也可以请求特定版本——例如,py -3.12 等同于运行 python3.12

下面是使用 macOS 系统的示例会话,来自 图 1-2。(编写本文时,Python 3.13 是预发行版本,所以我通过设置 PY_PYTHONPY_PYTHON33.12 更改了默认解释器。)

$ py -V
3.12.1
$ py -3.11 -V
3.11.7
$ py --list
 3.13 │ /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13
 3.12 │ /opt/homebrew/bin/python3.12
 3.11 │ /opt/homebrew/bin/python3.11
 3.10 │ /opt/homebrew/bin/python3.10

如果虚拟环境处于活动状态,py 将默认使用该环境中的解释器,而不是系统范围内的解释器(参见 “虚拟环境”)。在 Python 启动器的 Unix 版本中,有一条特殊规则使得与虚拟环境的工作更加方便:如果当前目录(或其父目录之一)包含标准名称为 .venv 的虚拟环境,则无需显式激活它。

您可以通过将其导入名称传递给 -m 解释器选项来运行许多第三方工具。假设您已在多个 Python 版本上安装了 pytest(一个测试框架)。使用 py -m pytest 可以确定应该使用哪个解释器来运行该工具。相比之下,裸露的 pytest 使用在您的 PATH 中首次出现的命令。

如果你使用 py 调用 Python 脚本,但未指定版本,则 py 将检查脚本的第一行是否有 shebang——指定脚本解释器的行。在这里保持规范的形式:#!/usr/bin/env python3入口点脚本 是将脚本链接到特定解释器的更可持续的方法,因为包安装程序可以在安装期间生成正确的解释器路径(见 “入口点脚本”)。

警告

为了与 Windows 版本兼容,Python 启动器仅使用 shebang 中的 Python 版本,而不使用完整的解释器路径。因此,你可能得到的解释器与直接调用脚本而不带 py 不同。

使用 Pyenv 安装 Python

Pyenv 是 macOS 和 Linux 的 Python 版本管理器。它包括一个构建工具——也可以作为一个独立的程序命名为 python-build——它在你的主目录中下载、构建和安装 Python 版本。Pyenv 允许您在全局、每个项目目录或每个 shell 会话中激活和取消激活这些安装。

注意

在本节中,您将使用 Pyenv 作为构建工具。如果您有兴趣将 Pyenv 用作版本管理器,请参阅官方文档了解其他设置步骤。我将讨论“使用 Pyenv 管理 Python 版本”中的一些权衡考虑。

在 macOS 和 Linux 上安装 Pyenv 的最佳方式是使用 Homebrew:

$ brew install pyenv

从 Homebrew 安装 Pyenv 的一个巨大好处是您还将获得 Python 的构建依赖项。如果您使用不同的安装方法,请查看Pyenv wiki以获取关于如何设置您的构建环境的特定于平台的说明。

使用以下命令显示可用的 Python 版本:

$ pyenv install --list

解释器列表令人印象深刻。它不仅涵盖了 Python 的所有活跃功能版本,还包括预发布版本、未发布的开发版本、过去 20 年中发布的几乎每个点版本以及丰富的替代实现,如 PyPy、GraalPy、MicroPython、Jython、IronPython 和 Stackless Python。

您可以通过将它们传递给pyenv install来构建和安装任何这些版本:

$ pyenv install 3.*x*.*y*

当作为纯构建工具使用 Pyenv 时(就像我们在这里做的那样),您需要手动将每个安装添加到PATH中。您可以使用命令pyenv prefix 3.*x*.*y*找到其位置,并在其后附加/bin。以下是 Bash shell 的示例:

export PATH="$HOME/.pyenv/versions/3.x.y/bin:$PATH"

使用 Pyenv 安装维护版本并不会隐式升级相同功能版本上的现有虚拟环境和开发工具,因此您需要使用新版本重新创建这些环境。

当您不再需要某个安装时,可以像这样删除它:

$ pyenv uninstall 3.*x*.*y*

默认情况下,Pyenv 在构建解释器时不启用基于配置文件的优化(PGO)或链接时优化(LTO)。根据Python 性能基准套件,这些优化可以显著加快 CPU 密集型 Python 程序的速度,速度提升在 10%至 20%之间。您可以通过设置PYTHON_CONFIGURE_OPTS环境变量来启用它们:

$ export PYTHON_CONFIGURE_OPTS='--enable-optimizations --with-lto'

与大多数 macOS 安装程序不同,Pyenv 默认使用 POSIX 安装布局,而不是这个平台上典型的框架构建。如果您在 macOS 上,建议您为了一致性启用框架构建。您可以通过将配置选项--enable-framework添加到上述列表中来执行此操作。

从 Anaconda 安装 Python

Anaconda是由 Anaconda Inc.维护的科学计算开源软件发行版。其核心是Conda,一个适用于 Windows、macOS 和 Linux 的跨平台包管理器。Conda 包可以包含用任何语言编写的软件,如 C、C++、Python、R 或 Fortran。

在本节中,您将使用 Conda 安装 Python。Conda 不会在系统上全局安装软件包。每个 Python 安装都包含在一个 Conda 环境中,并与系统的其余部分隔离开来。典型的 Conda 环境围绕特定项目的依赖项展开,比如一组用于机器学习或数据科学的库,其中 Python 仅是其中之一。

在您可以创建 Conda 环境之前,您需要引导一个包含 Conda 本身的基础环境。有几种方法可以做到这一点:您可以安装完整的 Anaconda 发行版,或者只使用 Miniconda 安装程序安装 Conda 和几个核心包。Anaconda 和 Miniconda 都会从defaults通道下载包,这可能需要商业许可证进行企业使用。

Miniforge 是第三种选择—​它类似于 Miniconda,但从社区维护的conda-forge通道安装软件包。您可以从GitHub获取 Miniforge 的官方安装程序,或者在 macOS 和 Linux 上使用 Homebrew 安装它:

$ brew install miniforge

当您激活或取消激活环境时,Conda 需要 shell 集成来更新搜索路径和 shell 提示。如果您从 Homebrew 安装了 Miniforge,请使用conda init命令更新您的 shell 配置文件,并指定您的 shell 名称。例如:

$ conda init bash

默认情况下,shell 初始化代码会在每个会话中自动激活基础环境。如果您还使用未由 Conda 管理的 Python 安装,可能需要禁用此行为:

$ conda config --set auto_activate_base false

Windows 安装程序不会全局激活基础环境。您可以通过 Windows 开始菜单中的 Miniforge Prompt 与 Conda 进行交互。

恭喜!您现在在系统上安装了一个可工作的 Conda!让我们使用 Conda 创建一个带有特定 Python 版本的环境:

$ conda create --name=*name* python=3.*x*

在您可以使用这个 Python 安装之前,您需要激活环境:

$ conda activate *name*

将 Python 升级到新版本非常简单:

$ conda update python

此命令将在活动的 Conda 环境中运行。Conda 的优点在于它不会将 Python 升级到环境中尚不支持的版本。

当您在环境中完成工作时,请像这样取消激活它:

$ conda deactivate

Conda 不会在系统范围内安装 Python;相反,每个 Python 安装都是一个隔离的 Conda 环境的一部分。Conda 对环境有一个全面的视角:Python 只是项目的一个依赖项,与系统库、第三方 Python 包甚至其他语言生态系统的软件包并列。

一种全新的世界:使用 Hatch 和 Rye 安装 Brave New World: Installing with Hatch and Rye

在我写这本书的时候,Python 项目管理者RyeHatch增加了对所有主要平台上 Python 解释器安装的支持。两者都使用来自Python Standalone Builds集合以及PyPy项目的解释器。

Rye 和 Hatch 都作为独立的可执行文件分发—​换句话说,您可以轻松地将它们安装到尚未安装 Python 的系统上。请参考它们的官方文档获取详细的安装说明。

Hatch 允许您使用单个命令安装与您平台兼容的所有 CPython 和 PyPy 解释器:

$ hatch python install all

这条命令还会将安装目录添加到你的PATH。¹⁰ 使用--update选项重新运行命令以升级解释器到更新版本。Hatch 根据特性版本组织解释器,因此补丁发布会覆盖现有安装。

Rye 将解释器获取到~/.rye/py目录中。通常情况下,当您同步项目的依赖项时,此过程在幕后进行。但也可以作为专用命令使用:

$ rye fetch 3.12
$ rye fetch 3.11.8
$ rye fetch pypy@3.10

第二个示例将解释器放置在~/.rye/py/cpython@3.11.8/bin(Linux 和 macOS)。你可以使用选项--target-path=*<dir>*将其安装到其他目录。这会将解释器放置在 Windows 上的*<dir>*和 Linux、macOS 上的*<dir>*/bin。在项目外工作时,Rye 不会将解释器添加到你的PATH中。

安装程序概览

图示 1-3 提供了 Windows、Linux 和 macOS 的主要 Python 安装方法概览。

Windows、Linux 和 macOS 的 Python 安装方法概览

图 1-3. Windows、Linux 和 macOS 的 Python 安装程序

这里提供了一些具体情况下如何选择安装程序的指导:

  • 通常情况下,使用 Hatch 安装 Python 独立构建版本。

  • 对于科学计算,我建议使用 Conda 替代。

  • 如果你在 Windows 或 macOS 上,请从 python.org 获取预发行版本。如果你在 Linux 上,则使用 pyenv 从源代码构建它们。

  • 在 Fedora Linux 上,始终使用 DNF。

  • 在 Ubuntu Linux 上,始终使用 APT 与 deadsnakes PPA。

如果需要 Python 的可重现构建,请在 macOS 和 Linux 上选择 Nix。

摘要

在本章中,您学会了如何在 Windows、macOS 和 Linux 上管理 Python 安装。使用 Python 启动器选择安装在系统上的解释器。此外,审核您的搜索路径以确保您具有明确定义的pythonpython3命令。

下一章详细介绍了 Python 安装:其内容、结构及您的代码与之交互的方式。您还将了解到它的轻量级兄弟虚拟环境以及围绕其发展的工具。

¹ 虽然 CPython 是 Python 的参考实现,但还有许多其他选择:如面向性能的分支,例如 PyPy 和 Cinder,重新实现如 RustPython 和 MicroPython,以及到其他平台的移植,如 WebAssembly、Java 和 .NET。

² 截至 2024 年初的写作时间,Debian Linux 的长期支持版本提供了 Python 2.7.16 和 3.7.3 的修订版本,这两个版本发布已有半个世纪之久。(Debian 的“测试”发行版,广泛用于开发,提供了当前版本的 Python。)

³ 从 Python 3.13 开始,将提供两年的 bug 修复版本支持。

Stack Overflow 提供了一个构建 Windows 安装程序的逐步指南。

⁵ “虚拟环境”详细介绍了虚拟环境。目前,你可以将虚拟环境视为完整 Python 安装的浅拷贝,它允许你安装一套独立的第三方包。

⁶ Justin Mayer: “Homebrew Python 不适合你”, 2021 年 2 月 3 日。

⁷ 如果你有一台搭载 Apple Silicon 的 Mac,但必须运行基于 Intel 处理器的程序,你会很高兴知道python.org的安装程序也提供了一个使用x86_64指令集的python3-intel64二进制文件。由于苹果的 Rosetta 翻译环境,你可以在 Apple Silicon 上运行它。

Unix 命令行工具选项会在/usr/local/bin目录中放置符号链接,这可能会与 Homebrew 安装的软件包和其他python.org版本产生冲突。符号链接是一种特殊类型的文件,类似于 Windows 中的快捷方式,它指向另一个文件。

⁹ 基于历史原因,框架构建使用不同的路径来存储每用户站点目录,即如果在非虚拟环境中以非管理员权限调用 pip 安装包时的位置。这种不同的安装布局可能会阻止你导入先前安装的包。

¹⁰ 在未来的版本中,Hatch 还将在 Windows 注册表中添加解释器,使你可以使用 Python 启动器使用它们。

第二章:Python 环境

在核心层面,每个 Python 安装都包括两部分:一个解释器和模块。模块又来自标准库和第三方包(如果您已安装)。这些组成了执行 Python 程序所需的基本组件:一个Python 环境(见图 2-1](#figure_environment))。

Python 安装并非唯一的 Python 环境。虚拟环境是简化的环境,与完整安装共享解释器和标准库。您可以在特定项目或应用程序中使用它们来安装第三方包,同时保持系统环境干净。

注意

本书将Python 环境作为一个总称,包括系统范围的安装和虚拟环境。请注意,有些人仅将该术语用于特定项目的环境,如虚拟环境或 Conda 环境。

Python 环境由解释器和模块组成。

图 2-1. Python 环境由解释器和模块组成。虚拟环境与其父环境共享解释器和标准库。

管理环境是 Python 开发的重要方面。您需要确保您的代码在用户的系统上正常运行,特别是在支持的语言版本之间,可能还涉及到重要第三方包的主要版本。Python 环境只能包含每个第三方包的单个版本——如果两个项目需要不同版本的同一包,则不能并存。这就是为什么将每个 Python 应用程序和每个项目都安装在专用虚拟环境中被视为良好实践的原因。

在本章中,您将深入了解 Python 环境的概念及其工作原理。本章分为三部分:

  • 第一部分介绍了三种 Python 环境——Python 安装、每个用户环境和虚拟环境——以及两个基本工具:Python 包安装程序 pip 和标准 venv 模块。

  • 第二部分介绍了两个现代工具,可以更高效地管理环境:pipx,一个用于 Python 应用程序的安装器,以及 uv,一个用 Rust 编写的 Python 打包工具的替代品。

  • 本章的最后一部分深入探讨了 Python 导入模块的方式和位置——如果您对此过程感到好奇,可以跳过。

注意

本章使用 Python Launcher 来调用解释器(参见 “Windows 下的 Python Launcher” 和 “Unix 下的 Python Launcher”)。如果您没有安装它,请在运行示例时用 py 替换 python3

Python 环境之旅

每个 Python 程序都在“内部”运行于一个 Python 环境中:环境中的解释器执行程序的代码,并且import语句从环境中加载模块。通过启动其解释器来选择环境。

Python 提供了两种在解释器上运行程序的机制。您可以将一个 Python 脚本作为参数传递:

$ py hello.py

或者,您可以通过-m选项传递一个模块,前提是解释器可以导入该模块:

$ py -m hello

大多数情况下,解释器从环境中导入hello.py —— 但为了本示例的目的,将其放置在当前目录中也可以。

另外,许多 Python 应用程序在您的PATH中安装了一个入口脚本(参见“入口脚本”)。这个机制允许您在不指定解释器的情况下启动应用程序。入口脚本始终使用安装它们的环境中的解释器:

$ hello

这种方法很方便,但也有一个缺点:如果您在多个环境中安装了该程序,则PATH中的第一个环境“胜出”。在这种情况下,py -m hello形式为您提供更多的控制。

“解释器决定环境。” 如上所述,这条规则适用于导入模块时。它也适用于互补情况:当您将一个包安装到一个环境中时。Python 包安装程序 Pip 默认将包安装到自己的环境中。换句话说,您通过在该环境中运行 pip 来选择包的目标环境。

因此,使用-m形式安装包的规范方式是:

$ py -m pip install *<package>*

或者,您可以使用其--python选项为 pip 提供虚拟环境或解释器:

$ pip --python=*<env>* install *<package>*

第二种方法的优点是不需要在每个环境中都安装 pip。

Python 安装

本节带您了解 Python 安装的情况。随时跟随您自己系统上的操作。表 2-1 显示了最常见的位置——将 3.x 和 3x 替换为 Python 特性版本,如 3.12312

表 2-1. Python 安装位置

平台 Python 安装位置
Windows(单用户) %LocalAppData%\Programs\Python\Python3x
Windows(多用户) %ProgramFiles%\Python3x
macOS(Homebrew) /opt/homebrew/Frameworks/Python.framework/Versions/3.x^(a)
macOS(python.org) /Library/Frameworks/Python.framework/Versions/3.x
Linux(通用) /usr/local
Linux(包管理器) /usr
^(a) macOS Intel 上的 Homebrew 使用 /usr/local 而不是 /opt/homebrew

安装可能会与系统其余部分干净地分离,但并非一定如此。在 Linux 上,安装通常放在/usr/usr/local等共享位置,其文件分布在整个文件系统中。相比之下,Windows 系统将所有文件保存在一个单一位置。在 macOS 上,框架构建同样是自包含的,尽管分布也可能在传统的 Unix 位置安装符号链接。

在接下来的章节中,你将更深入地了解 Python 安装的核心部分——解释器和模块,以及其他一些组件,比如入口脚本和共享库。

Python 安装布局在不同系统上可能差异很大。好消息是,你很少需要关心——Python 解释器知道它的环境。供参考的是,Table 2-2 提供了主要平台上安装布局的基准。所有路径均相对于安装根目录。

表 2-2. Python 安装布局

文件 Windows Linux 和 macOS 注释
解释器 python.exe bin/python3.x
标准库 LibDLLs lib/python3.x 在 Windows 上,扩展模块位于DLLs下。Fedora 将标准库放在lib64而非lib下。
第三方包 Lib\site-packages lib/python3.x/site-packages Debian 和 Ubuntu 将包放置在dist-packages下。Fedora 将扩展模块放在lib64而非lib下。
入口脚本 Scripts bin

解释器

运行 Python 程序的可执行文件在 Windows 上名为python.exe,位于完整安装的根目录下¹。在 Linux 和 macOS 上,解释器名为python3.x,存储在bin目录中,同时还有一个python3的符号链接。

Python 解释器将环境与三个要素联系起来:

  • 特定版本的 Python 语言

  • Python 的特定实现

  • 特定版本的解释器构建

实现可能是CPython,即 Python 的参考实现,但也可能是多种备选实现之一——比如PyPy,一个快速的解释器,支持即时编译,用 Python 编写,或者GraalPy,一个高性能的实现,支持 Java 互操作性,使用 GraalVM 开发工具包。

构建过程中,CPU 架构可能会有所不同——例如,32 位与 64 位,或者 Intel 与 Apple Silicon——以及它们的构建配置,这些配置会影响编译时的优化或安装布局。

Python 模块

模块是通过import语句加载的 Python 对象的容器。它们通常组织在Lib(Windows)或lib/python3.x(Linux 和 macOS)下,具体的平台相关变化不尽相同。第三方包被放置在名为site-packages的子目录中。

模块以各种形式存在。如果您使用过 Python,您可能已经使用了大多数形式。让我们来看看不同的类型:

简单模块

在最简单的情况下,module是一个包含 Python 源代码的单个文件。语句import string执行string.py中的代码,并将结果绑定到本地范围中的名称string

带有init.py文件的目录称为packages,它们允许您在层次结构中组织模块。语句import email.message加载email包中的message模块。

命名空间包

没有init.py但包含模块的目录称为namespace packages。您可以使用它们来在共同的命名空间中组织模块,例如公司名称(比如acme.unicycleacme.rocketsled)。与常规包不同,您可以单独分发命名空间包中的每个模块。

扩展模块

扩展模块,例如math模块,包含从低级语言如 C 编译而来的本机代码。它们是共享库²,具有特殊的入口点,使您可以从 Python 中将它们作为模块导入。人们出于性能原因编写它们,或者将现有的 C 库作为 Python 模块提供。它们在 Windows 上以.pyd结尾,在 macOS 上以.dylib结尾,在 Linux 上以.so结尾。

内置模块

标准库中的一些模块,如sysbuiltins模块,已编译到解释器中。变量sys.builtin_module_names列出了所有这些模块。

冻结模块

一些标准库中的模块是用 Python 编写的,但其字节码嵌入在解释器中。最初,只有importlib的核心部分得到了这种处理。Python 的最新版本会在解释器启动期间冻结每个导入的模块,例如osio

注意

在 Python 世界中,术语package具有一些歧义。它既指模块,也指用于分发模块的工件,也称为distributions。除非另有说明,本书将package用作distribution的同义词。

Bytecode是 Python 代码的中间表示,它是平台无关的,并且经过优化以实现快速执行。当解释器首次加载纯 Python 模块时,它将其编译为字节码。字节码模块被缓存在环境中的.pyc文件中,位于pycache目录下。

入口点脚本

一个入口点脚本是一个可执行文件,在Scripts(Windows)或bin(Linux 和 macOS)中,其单一目的是启动 Python 应用程序,通过导入具有其入口点函数的模块并调用该函数来实现。

此机制有两个关键优点。首先,您可以通过运行一个简单的命令(例如 pydoc3)在 shell 中启动应用程序。³ 第二,入口脚本使用其环境中的解释器和模块,避免因 Python 版本错误或缺少第三方包而导致的意外。

类似 pip 这样的包安装程序可以为它们安装的第三方包生成入口脚本。包的作者只需指定脚本应调用的函数即可。这是为 Python 应用程序提供可执行文件的一种方便方法(参见 “入口脚本”)。

各平台在直接执行入口脚本的方式上有所不同。在 Linux 和 macOS 上,它们是具有 执行 权限的常规 Python 文件,例如 示例 2-3 中所示的那个。Windows 将 Python 代码嵌入到 Portable Executable(PE)格式的二进制文件中,通常称为 .exe 文件。该二进制文件启动解释器并执行嵌入的代码。

示例 2-3. Linux 安装中的入口脚本 pydoc3
#!/usr/local/bin/python3.12 ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
import pydoc ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)
if __name__ == "__main__": ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/3.png)
    pydoc.cli() ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/4.png)

1

从当前环境请求解释器。

2

加载包含指定入口点函数的模块。

3

检查脚本是否未从另一个模块导入。

4

最后,调用入口点函数启动程序。

注意

#! 行在类 Unix 操作系统上被称为 shebang。当您运行脚本时,程序加载器使用该行来定位并启动解释器。程序加载器 是操作系统的一部分,用于将程序加载到主存中。

其他组件

除了解释器、模块和脚本之外,Python 环境还可以包含一些额外的组件:

共享库

Python 环境有时会包含一些不是扩展模块的共享库,如 Windows 上的 .dll、macOS 上的 .dylib 和 Linux 上的 .so。第三方包可能会捆绑它们使用的共享库,因此您无需单独安装它们。Python 安装可能还会捆绑共享库,例如标准的 ssl 模块使用 OpenSSL,这是一个用于安全通信的开源库。

头文件

Python 安装包含用于 Python/C API 的头文件,这是一个用于编写扩展模块或将 Python 嵌入到较大应用程序中作为组件的应用程序编程接口。它们位于 Include(Windows)或 include/python3.x(Linux 和 macOS)下。

静态数据

Python 安装还在各个位置包含各种静态数据。这包括配置文件、文档以及与第三方包一起提供的任何资源文件。

Tcl/Tk

默认情况下,Python 安装还包括 Tcl/Tk,用于使用 Tcl 编写的图形用户界面(GUI)的工具包。 标准的 tkinter 模块允许您从 Python 使用此工具包。

用户环境

用户环境 允许您为单个用户安装第三方软件包。 它相对于系统范围内安装软件包有两个主要优点:您无需管理权限即可安装软件包,而且您不会影响多用户系统上的其他用户。

用户环境位于 Linux 和 macOS 的主目录中,而在 Windows 的应用数据目录中(参见 表 2-3)。 它包含每个 Python 版本的 site-packages 目录。 入口点脚本在 Python 版本之间共享,但在 macOS 上,整个用户环境安装保存在特定于版本的目录下。⁴

表 2-3. 每个用户目录的位置

平台 第三方软件包 入口点脚本
Windows %AppData%\Python\Python3x\site-packages %AppData%\Python\Scripts
macOS ~/Library/Python/3.x/lib/python/site-packages ~/Library/Python/3.x/bin
Linux ~/.local/lib/python3.x/site-packages^(a) ~/.local/bin
^(a) Fedora 将扩展模块放在 lib64 下。

使用 py -m pip install --user *<package>* 将软件包安装到用户环境中。 如果在虚拟环境之外调用 pip,并且 pip 发现它无法写入到系统范围的安装位置,它也会默认使用此位置。 如果用户环境尚不存在,则 pip 会为您创建它。

提示

默认情况下,用户脚本目录可能不在 PATH 中。 如果将应用程序安装到用户环境中,请记住编辑您的 shell 配置文件以更新搜索路径。 当 Pip 检测到此情况时,它会发出友好的提醒。

用户环境有一个重要缺点:按设计,它们与全局环境不隔离。 如果全局环境中没有被用户环境中同名模块遮蔽,您仍然可以导入全局范围的站点软件包。 用户环境中的应用程序也不彼此隔离—​特别是,它们不能依赖于另一个软件包的不兼容版本。 即使系统范围内的应用程序也可以从用户环境导入模块。

还有另一个缺点:如果 Python 安装标记为 externally managed(例如,如果使用发行版的软件包管理器安装了 Python),则无法将软件包安装到用户环境中。

在 “使用 Pipx 安装应用程序” 中,我将介绍 pipx,它允许您在隔离的环境中安装应用程序。 它使用用户脚本目录将应用程序放到您的搜索路径上,但在幕后依赖于虚拟环境。

虚拟环境

当您在一个使用第三方包的 Python 项目上工作时,通常不建议将这些包安装到系统范围或每个用户环境中。首先,这样会污染全局命名空间。在隔离和可重现环境中运行测试和调试项目会变得更加容易。其次,如果两个项目依赖于相同包的冲突版本,单个环境甚至都不是一个选项。第三,正如前一节提到的,您不能将包安装到标记为externally managed的环境中。⁵

虚拟环境的发明是为了解决这些问题。它们与系统范围的安装以及彼此之间隔离。在底层,虚拟环境是一个轻量级的 Python 环境,用于存储第三方包,并将大部分其他功能委托给完整安装。虚拟环境中的包仅对环境中的解释器可见。

使用命令py -m venv *<directory>*创建虚拟环境。最后一个参数是您希望环境存在的位置—​它的根目录—​通常命名为.venv

虚拟环境的目录树看起来非常像 Python 安装,只是缺少一些文件,尤其是整个标准库。Table 2-4 显示了虚拟环境中的标准位置。

表 2-4. 虚拟环境的布局

文件 Windows Linux 和 macOS
解释器 Scripts bin
入口点脚本 Scripts bin
第三方包 Lib\site-packages lib/python3.x/site-packages^(a)
环境配置 pyvenv.cfg pyvenv.cfg
^(a) Fedora 将第三方扩展模块放置在lib64而不是lib下。

虚拟环境有自己的python命令,该命令位于入口点脚本旁边。在 Linux 和 macOS 上,该命令是到您用于创建环境的解释器的符号链接。在 Windows 上,它是一个小的包装可执行文件,启动父解释器。⁶

安装包

虚拟环境包括 pip 作为将包安装到其中的手段。⁷ 让我们创建一个虚拟环境,安装httpx(一个 HTTP 客户端库),并启动一个交互式会话。在 Windows 上,输入以下命令。

> py -m venv .venv
> .venv\Scripts\python -m pip install httpx
> .venv\Scripts\python

在 Linux 和 macOS 上,输入以下命令。如果环境使用已知名称.venv,则无需详细说明解释器的路径。Unix 的 Python Launcher 会默认选择其解释器。

$ py -m venv .venv
$ py -m pip install httpx
$ py

在交互式会话中,使用httpx.get执行对 Web 主机的GET请求:

>>> import httpx
>>> httpx.get("https://example.com/")
<Response [200 OK]>

虚拟环境附带了 Python 发布时当前的 pip 版本。当你使用旧版 Python 时,这可能会成为问题。使用选项--upgrade-deps创建环境,以确保从 Python Package Index 获取最新的 pip 发布。

你也可以使用选项--without-pip创建没有 pip 的虚拟环境,并使用外部安装程序安装包。如果全局安装了 pip,可以通过其--python选项传递目标环境,像这样:

$ pip --python=.venv install httpx

如果你习惯直接调用pip,很容易在 Python 安装或每个用户的环境中意外安装一个包。如果你的 Python 安装未标记为外部管理,你甚至可能都不会注意到。幸运的是,你可以配置 pip,在安装包时总是要求使用虚拟环境:

$ pip config set global.require-virtualenv true

激活脚本

虚拟环境附带了在binScripts目录中的激活脚本——这些脚本使得从命令行更方便地使用虚拟环境,并为多种支持的 shell 和命令解释器提供了支持。以下是 Windows 的示例,这次使用激活脚本:

> py -m venv .venv
> .venv\Scripts\activate
(.venv) > py -m pip install httpx
(.venv) > py

激活脚本为你的 shell 会话带来了三个特性:

  • 它们将脚本目录前置到PATH变量中。这使你可以在不用路径前缀的情况下调用pythonpip和入口点脚本。

  • 它们将VIRTUAL_ENV环境变量设置为虚拟环境的位置。像 Python Launcher 这样的工具使用该变量来检测环境是否处于激活状态。

  • 默认情况下,它们更新你的 shell 提示符,以提供可视化参考,显示哪个环境处于活动状态(如果有的话)。

小贴士

创建环境时,可以使用选项--prompt提供自定义提示符。特殊值.表示当前目录;在项目仓库内时尤其有用。

在 macOS 和 Linux 上,你需要激活脚本,以使其影响当前的 shell 会话。以下是 Bash 和类似的 shell 的示例:

$ source .venv/bin/activate

环境还附带了一些其他 shell 的激活脚本。例如,如果你使用 Fish shell,可以源激活提供的activate.fish脚本。

在 Windows 上,你可以直接调用激活脚本。PowerShell 有一个Activate.ps1脚本,cmd.exe 有一个activate.bat脚本。你无需提供文件扩展名;每个 shell 都会选择适合自己的脚本。

> .venv\Scripts\activate

在 Windows 上的 PowerShell 默认情况下不允许执行脚本,但是你可以将执行策略更改为更适合开发的选项:RemoteSigned 策略允许在本地计算机上编写的或由受信任发布者签名的脚本。在 Windows 服务器上,该策略已经是默认值。你只需要执行一次这个操作——设置将存储在注册表中。

> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

激活脚本提供了一个 deactivate 命令,用于恢复对 shell 环境的更改。它通常作为一个 shell 函数实现,在 Windows、macOS 和 Linux 上的工作方式相同。

$ deactivate

一探究竟

Python 如何知道要从虚拟环境而不是 Python 安装中导入第三方包,比如 httpx 这样的第三方包?由于虚拟环境与 Python 安装共享解释器,因此位置不能硬编码在解释器二进制文件中。相反,Python 查看你用来启动解释器的 python 命令的位置。如果其父目录包含 pyvenv.cfg 文件,Python 将其视为虚拟环境的 地标,并从 site-packages 目录下导入第三方模块。

解释了如何从虚拟环境导入第三方模块,但是 Python 如何找到标准库的模块呢?毕竟它们既没有被复制也没有被链接到虚拟环境中。答案再次在 pyvenv.cfg 文件中:当你创建一个虚拟环境时,解释器会记录自己的位置,并将其位置记录在此文件的 home 键下。如果后来它发现自己在一个虚拟环境中,它会相对于那个 home 目录寻找标准库。

注意

pyvenv.cfg 的名称是 pyvenv 脚本的遗留物,该脚本曾经随 Python 一起提供。使用 py -m venv 格式使得清楚知道你用来创建虚拟环境的解释器——因此环境本身将使用哪个解释器。

虚拟环境虽然可以访问系统范围环境中的标准库,但与第三方模块隔离开来。(虽然不推荐,你可以在创建环境时使用 --system-site-packages 选项来给予环境访问这些模块的权限。)

pip 如何知道安装包的位置?简短的答案是 pip 会询问它正在运行的解释器,并且解释器会根据自己的路径推断出位置——就像当你导入一个模块时一样。这就是为什么最好使用 py -m pip 习语来显式地运行 pip。如果直接调用 pip,系统将搜索你的 PATH,可能会找到来自不同环境的入口脚本。

使用 Pipx 安装应用程序

在 “虚拟环境” 中,你看到为什么将项目安装在单独的虚拟环境中是有道理的:与系统范围和每用户环境不同,虚拟环境将你的项目隔离开来,避免依赖冲突。

当您安装第三方 Python 应用程序时,相同的推理也适用——比如,像 Black 这样的代码格式化器或像 Hatch 这样的包管理器。应用程序往往依赖于比库更多的软件包,并且它们对其依赖项的版本可能非常挑剔。

不幸的是,为每个应用程序管理和激活单独的虚拟环境非常繁琐和令人困惑——并且限制您一次只能使用一个应用程序。如果我们能将应用程序限制在虚拟环境中并仍然能够全局使用它们,那将是非常好的事情。

这正是pipx的功能,它利用了一个简单的思路使其成为可能:从其虚拟环境复制或创建符号链接到应用程序的入口脚本,并将其复制到搜索路径上的目录中。入口脚本包含环境解释器的完整路径,因此您可以将它们复制到任何位置,它们仍然可以正常工作。

Pipx 简介

让我简要介绍一下这是如何工作的——下面的命令适用于 Linux 或 macOS。首先,您创建一个用于应用程序入口脚本的共享目录,并将其添加到您的PATH环境变量中:

$ mkdir -p ~/.local/bin
$ export PATH="$HOME/.local/bin:$PATH"

接下来,您在专用虚拟环境中安装一个应用程序——我选择了 Black 代码格式化器作为示例:

$ py -m venv black
$ black/bin/python -m pip install black

最后,将入口脚本复制到您在第一步创建的目录中——这将是环境的 bin 目录中名为black的脚本:

$ cp black/bin/black ~/.local/bin

现在,即使虚拟环境未激活,您也可以调用black

$ black --version
black, 24.2.0 (compiled: yes)
Python (CPython) 3.12.2

在这个简单的思路之上,pipx 项目构建了一个跨平台的 Python 应用程序包管理器,具有出色的开发者体验。

提示

如果有一个单独的 Python 应用程序应该安装在开发机器上,那可能就是 pipx。它让您可以方便地安装、运行和管理所有其他 Python 应用程序,避免了麻烦。

安装 Pipx

如果您的系统包管理器作为软件包分发 pipx,我建议使用它作为首选的安装方法,因为它更可能提供良好的集成。

$ apt install pipx
$ brew install pipx
$ dnf install pipx

作为安装后的步骤,更新您的PATH环境变量以包含共享脚本目录,使用ensurepath子命令。(如果在运行上述命令时修改了您的PATH变量,请先打开一个新的终端。)

$ pipx ensurepath

在 Windows 上,如果您的系统包管理器不分发 pipx,我建议将 pipx 安装到每个用户的环境中,像这样:

$ py -m pip install --user pipx
$ py -m pipx ensurepath

第二步还将pipx命令本身放置在您的搜索路径上。

如果您尚未为 pipx 启用 shell 自动完成,请按照您的 shell 的说明操作,您可以使用以下命令打印这些说明:

$ pipx completions

使用 Pipx 管理应用程序

在您的系统上安装了 pipx 后,您可以使用它从 Python 包索引(PyPI)安装和管理应用程序。例如,这是如何使用 pipx 安装 Black 的方法:

$ pipx install black

你也可以使用 pipx 来将应用程序升级到新版本、重新安装它,或从系统中卸载它:

$ pipx upgrade black
$ pipx reinstall black
$ pipx uninstall black

作为一个软件包管理器,pipx 跟踪其安装的应用程序,并允许你在所有应用程序上执行批量操作。这对于保持开发工具更新到最新版本并在新版本的 Python 上重新安装它们特别有用。

$ pipx upgrade-all
$ pipx reinstall-all
$ pipx uninstall-all

你也可以列出之前安装过的应用程序:

$ pipx list

一些应用程序支持插件,扩展其功能。这些插件必须安装在与应用程序相同的环境中。例如,打包管理器 Hatch 和 Poetry 都带有插件系统。以下是如何安装 Hatch 及其从版本控制系统获取包版本的插件(参见“项目版本的单一来源”)的方法:

$ pipx install hatch
$ pipx inject hatch hatch-vcs

使用 Pipx 运行应用程序

上述命令提供了管理全局开发工具的所有基本操作,但它更进一步。大多数情况下,你只想使用最新版本的开发工具。你不希望负责保持工具更新、在新的 Python 版本上重新安装它们,或者在不再需要时将其删除。Pipx 允许你直接从 PyPI 运行应用程序,无需显式安装步骤。让我们使用经典的 Cowsay 应用程序来试试:

$ pipx run cowsay moo
  ___
| moo |
  ===
   \
    \
      ^__^
      (oo)\_______
      (__)\       )\/\
          ||----w |
          ||     ||

在幕后,pipx 在一个临时虚拟环境中安装 Cowsay,并使用你提供的参数运行它。它会保留环境一段时间⁹,因此你不会在每次运行时重新安装应用程序。使用--no-cache选项强制 pipx 创建新环境并重新安装最新版本。

你可能已经注意到在run命令中有一个隐含的假设:即 PyPI 包必须与其提供的命令具有相同的名称。这似乎是一个合理的期望——但如果一个 Python 包提供了多个命令呢?例如,pip-tools 包(参见“使用 pip-tools 和 uv 编译依赖关系”)提供了命令pip-compilepip-sync

如果你发现自己处于这种情况,请使用--spec选项提供 PyPI 名称,如下所示:

$ pipx run --spec pip-tools pip-sync
提示

使用pipx run *<app>*作为从 PyPI 安装和运行开发工具的默认方法。如果需要对应用程序环境进行更多控制,例如需要安装插件,则使用pipx install *<app>*。(将*<app>*替换为应用程序的名称。)

配置 Pipx

默认情况下,pipx 将应用程序安装在其自身运行的相同 Python 版本上。这可能不是最新的稳定版本,特别是如果您使用 APT 等系统包管理器安装了 pipx。如果是这种情况,我建议将环境变量PIPX_DEFAULT_PYTHON设置为最新的稳定 Python 版本。您使用 pipx 运行的许多开发工具都会创建自己的虚拟环境;例如,virtualenv、Nox、tox、Poetry 和 Hatch 都会这样做。通过默认确保所有下游环境使用最新的 Python 版本,这是值得的。

$ export PIPX_DEFAULT_PYTHON=python3.12 # Linux and macOS
> setx PIPX_DEFAULT_PYTHON python3.12   # Windows

在底层,pipx 使用 pip 作为包安装程序。这意味着您为 pip 设置的任何配置也适用于 pipx。一个常见的用例是从私有索引而不是 PyPI 安装 Python 包,例如公司范围的包存储库。

您可以使用pip config持久地设置您首选的包索引的 URL:

$ pip config set global.index-url https://example.com

或者,您可以仅为当前 shell 会话设置包索引。大多数 pip 选项也可作为环境变量使用:

$ export PIP_INDEX_URL=https://example.com

两种方法都会导致 pipx 从指定的索引安装应用程序。

使用 uv 管理环境

工具uv是 Rust 编程语言编写的核心 Python 打包工具的替代品。它在单个静态二进制文件中不依赖于任何依赖项,相比其替代的 Python 工具,性能提升数量级。虽然其uv venvuv pip子命令旨在与 virtualenv 和 pip 兼容,但 uv 也积极接受不断发展的最佳实践,例如默认情况下在虚拟环境中运行。

使用 pipx 安装 uv:

$ pipx install uv

默认情况下,uv 使用众所周知的名称.venv创建虚拟环境(您可以将另一个位置作为参数传递):

$ uv venv

使用--python选项指定虚拟环境的解释器,其规范如3.12python3.12;也可以使用解释器的完整路径。uv 通过扫描您的PATH发现可用的解释器。在 Windows 上,它还会检查py --list-paths的输出。如果不指定解释器,则 uv 在 Linux 和 macOS 上默认为python3,在 Windows 上默认为python.exe

注意

尽管其名称为uv venv模拟了 Python 工具 virtualenv,而不是内置的venv模块。Virtualenv 在系统上使用任何 Python 解释器创建环境。它将解释器发现与积极缓存结合起来,使其快速且无瑕疵。

默认情况下,uv 将包安装到当前目录或其父目录中名为.venv的环境中(使用与 Unix 上 Python 启动器相同的逻辑):

$ uv pip install httpx

您可以通过激活它将包安装到另一个环境中—​这适用于虚拟环境(VIRTUAL_ENV)和 Conda 环境(CONDA_PREFIX)。如果既没有活动环境也没有.venv目录,则 uv 将以错误退出。它永远不会从全局环境安装或卸载包,除非您使用--system选项明确要求它这样做。

虽然 uv 的初始开发主要专注于为标准 Python 工具提供替代品,但其最终目标是成为长期以来 Python 一直缺少的统一打包工具——具有 Rust 开发者喜爱 Cargo 的开发者体验。即使在这个早期阶段,uv 也能够通过具有良好默认设置的统一和流畅的功能集提供统一和流畅的工作流程。而且它速度非常快。

查找 Python 模块

Python 环境首先由 Python 解释器和 Python 模块组成。因此,有两种机制在将 Python 程序链接到环境中起到关键作用:解释器发现和模块导入。

解释器发现 是定位 Python 解释器以执行程序的过程。你已经看到了定位解释器的最重要方法:

  • 入口点脚本直接在其环境中引用解释器,使用 shebang 或包装可执行文件(详见 “入口点脚本”)。

  • Shell 通过在 PATH 中搜索目录来定位解释器,例如 pythonpython3python3.x 命令(详见 “定位 Python 解释器”)。

  • Python 启动器通过 Windows 注册表、PATH(在 Linux 和 macOS 上)、以及 VIRTUAL_ENV 变量来定位解释器(详见 “Python 启动器(Windows)” 和 “Python 启动器(Unix)”)。

  • 当你激活一个虚拟环境时,激活脚本会将其解释器和入口点脚本放置在 PATH 上。它还为 Python 启动器和其他工具设置 VIRTUAL_ENV 变量(详见 “虚拟环境”)。

本节深入探讨了将程序链接到环境的另一种机制:模块导入,即为程序定位和加载 Python 模块的过程。

提示

简言之,就像 Shell 在 PATH 中搜索可执行文件一样,Python 在 sys.path 中搜索模块。这个变量包含 Python 可以加载模块的位置列表,通常是本地文件系统上的目录。

import 语句背后的机制存放在标准库的 importlib 中(详见 “使用 importlib 检查模块和包”)。解释器将 import 语句的每次使用都转换为对 importlib 中的 __import__ 函数的调用。importlib 模块还暴露了一个 import_module 函数,允许你在运行时导入只有名称已知的模块。

将导入系统放入标准库允许你从 Python 内部检查和定制导入机制。例如,导入系统支持直接从目录和 zip 归档文件加载模块。但是sys.path上的条目可以是任何东西——比如,一个 URL 或一个数据库查询——只要你在sys.path_hooks中注册一个函数,它知道如何从这些路径条目中找到并加载模块。

模块对象

当你导入一个模块时,导入系统返回一个module object,这是一个types.ModuleType类型的对象。被导入模块定义的任何全局变量都成为模块对象的属性。这使得你可以从导入代码中使用点符号(module.var)访问模块变量。

在内部,模块变量存储在模块对象的__dict__属性的字典中。(这是存储任何 Python 对象属性的标准机制。)当导入系统加载模块时,它创建一个模块对象,并使用__dict__作为全局命名空间执行模块的代码。稍微简化一下,它像这样调用内置的exec函数:

exec(code, module.__dict__)

此外,模块对象还有一些特殊的属性。例如,__name__属性保存模块的完全限定名称,如email.message__spec__模块保存module spec,我稍后会讨论。包还有一个__path__属性,其中包含搜索子模块的位置。

注意

最常见的情况是,包的__path__属性包含一个条目:包含其init.py文件的目录。另一方面,命名空间包可以分布在多个目录中。

模块缓存

当你首次导入一个模块时,导入系统将模块对象存储在sys.modules字典中,使用其完全限定名称作为键。后续的导入直接从sys.modules返回模块对象。这种机制带来了许多好处:

性能

导入是昂贵的,因为导入系统从磁盘加载大多数模块。导入一个模块还涉及执行其代码,这可能会进一步增加启动时间。sys.modules字典作为一个缓存以加快速度。

幂等性

导入模块可能会产生副作用,例如在执行模块级语句时。将模块缓存在sys.modules中确保这些副作用只发生一次。导入系统还使用锁来确保多个线程可以安全地导入相同的模块。

递归

模块可能会出现递归导入自身的情况。一个常见的情况是circular imports,即模块a导入模块b,而b又导入a。导入系统通过在执行前将模块添加到sys.modules中来支持此功能。当b导入a时,导入系统从sys.modules字典中返回(部分初始化的)模块a,从而防止无限循环。

模块规格

在概念上,Python 导入模块分为两个步骤:查找加载。首先,给定模块的完全限定名称,导入系统会定位模块并生成模块规范(importlib.machinery.ModuleSpec)。其次,导入系统从模块规范创建一个模块对象并执行模块的代码。

模块规范是这两个步骤之间的链接。模块规范包含有关模块的元数据,例如其名称和位置,以及适当的加载器用于该模块(表 2-5)。您还可以直接从模块对象上的特殊属性访问大多数元数据。

表 2-5. 模块规范和模块对象的属性

规范属性 模块属性 描述
name __name__ 模块的完全限定名称。
loader __loader__ 一个知道如何执行模块代码的加载器对象。
origin __file__ 模块的位置。
submodule_search_locations __path__ 如果模块是一个包,那么在哪里搜索子模块。
cached __cached__ 模块的编译字节码的位置。
parent __package__ 包含包的完全限定名称。

模块的__file__属性通常包含 Python 模块的文件名。在特殊情况下,它是一个固定的字符串,例如内置模块的"builtin",或者对于命名空间包(没有单一位置)是None

查找器和加载器

导入系统使用两种类型的对象查找和加载模块。查找器importlib.abc.MetaPathFinder)负责根据其完全限定名称查找模块。成功时,它们的find_spec方法返回一个带有加载器的模块规范;否则,它返回None加载器importlib.abc.Loader)是具有exec_module函数的对象,该函数加载并执行模块的代码。该函数接受一个模块对象,并在执行模块时将其用作命名空间。查找器和加载器可以是相同的对象,然后称为导入器

查找器注册在sys.meta_path变量中,导入系统依次尝试每个查找器。当一个查找器返回了带有加载器的模块规范时,导入系统将创建并初始化一个模块对象,然后将其传递给加载器执行。

默认情况下,sys.meta_path变量包含三个查找器,用于处理不同类型的模块(参见“Python 模块”):

  • 对于内置模块,使用importlib.machinery.BuiltinImporter

  • 对于冻结模块,使用importlib.machinery.FrozenImporter

  • 使用importlib.machinery.PathFindersys.path上搜索模块

PathFinder 是导入机制的中央枢纽。它负责所有未嵌入到解释器中的模块,并搜索 sys.path 来定位它。¹⁰ 路径查找器使用称为路径条目查找器importlib.abc.PathEntryFinder)的第二级查找器对象,每个对象在 sys.path 的特定位置下查找模块。标准库提供了两种类型的路径条目查找器,注册在 sys.path_hooks 下:

  • zipimport.zipimporter 用于从 zip 存档中导入模块

  • importlib.machinery.FileFinder 用于从目录导入模块

通常,模块存储在文件系统上的目录中,因此 PathFinder 将其工作委托给 FileFinder。后者扫描目录以查找模块,并根据其文件扩展名确定适当的加载器。有三种类型的加载器用于不同类型的模块:

  • importlib.machinery.SourceFileLoader 用于纯 Python 模块

  • importlib.machinery.SourcelessFileLoader 用于字节码模块

  • importlib.machinery.ExtensionFileLoader 用于二进制扩展模块

Zip 导入器的工作原理类似,但不支持扩展模块,因为当前操作系统不允许从 zip 存档中加载动态库。

模块路径

当程序无法找到特定模块或导入模块的错误版本时,查看 sys.path,即模块路径,可能会有所帮助。但首先, sys.path 上的条目来自哪里?让我们解开一些模块路径的奥秘。

当解释器启动时,它通过两个步骤构建模块路径。首先,它使用一些内置逻辑构建初始模块路径。最重要的是,此初始路径包括标准库。其次,解释器从标准库导入 site 模块。 site 模块扩展模块路径以包括当前环境中的站点包。

在本节中,我们将查看解释器如何使用标准库构建初始模块路径。下一节解释了 site 模块如何附加包含站点包的目录。

注意

您可以在 CPython 源代码的 Modules/getpath.py 中找到构建 sys.path 的内置逻辑。尽管外表如此,但这并不是普通模块。在构建 Python 时,其代码被冻结为字节码并嵌入到可执行文件中。

初始模块路径上的位置可分为三类,并按以下顺序出现:

  1. 当前目录或包含 Python 脚本的目录(如果有)

  2. PYTHONPATH 环境变量中的位置(如果已设置)

  3. 标准库的位置

让我们更详细地看一下每个。

当前目录或包含脚本的目录

sys.path 上的第一项可以是以下任何一种:

  • 如果您运行了 py *<script>*,则是脚本所在的目录

  • 如果您运行了 py -m *<module>*,则是当前目录

  • 否则,空字符串,也表示当前目录

传统上,这种机制长期以来一直是构建应用程序的便捷方式:只需将主入口脚本和所有应用程序模块放在同一个目录中。在开发过程中,从该目录启动解释器进行交互式调试,你的导入仍然有效。

不幸的是,将工作目录放在 sys.path 上非常不安全,因为攻击者(或者你误操作)可以通过将 Python 文件放置在受害者目录中来覆盖标准库。为了避免这种情况,在 Python 3.11 开始,你可以使用 -P 解释器选项或 PYTHONSAFEPATH 环境变量来从 sys.path 中省略当前目录。如果你使用脚本调用解释器,此选项还会省略脚本所在的目录。

将应用程序安装到虚拟环境中比将其模块放在当前目录中更安全且更灵活。这需要对应用程序进行打包,这是第三章的主题。

PYTHONPATH 变量

PYTHONPATH 环境变量提供了另一种在 sys.path 上标准库之前添加位置的方式。它使用与 PATH 变量相同的语法。出于与当前工作目录相同的原因,避免使用此机制,而改用虚拟环境。

标准库

表 2-6 显示了初始模块路径上的其余条目,这些条目专用于标准库。位置以安装路径为前缀,并且在某些平台上可能有细微差别。值得注意的是,Fedora 将标准库放在 lib64 而不是 lib 下。

表 2-6. sys.path 上的标准库

Windows Linux 和 macOS 描述
python3x.zip lib/python3x.zip 为了紧凑性,标准库可以安装为 zip 存档。即使存档不存在(通常不会存在),此条目也会存在。
Lib lib/python3.x 纯 Python 模块
DLLs lib/python3.x/lib-dynload 二进制扩展模块

标准库的位置没有硬编码在解释器中(参见“虚拟环境”)。相反,Python 在寻找自己可执行文件的路径上查找标志性文件,并使用它们来定位当前环境 (sys.prefix) 和 Python 安装 (sys.base_prefix)。这样的标志性文件之一是 pyvenv.cfg,它标记了一个虚拟环境并通过 home 键指向其父安装。另一个标志性文件是包含标准 os 模块的 os.py 文件:Python 使用 os.py 来发现虚拟环境外的前缀,并定位标准库本身。

站点包

解释器在初始化过程中的早期构建初始 sys.path 使用一个相当固定的过程。相比之下,sys.path 上的其余位置,称为站点软件包,高度可定制并由名为 site 的 Python 模块负责。

site 模块在文件系统上存在时添加以下路径条目:

用户站点软件包

此目录保存来自每个用户环境的第三方软件包。它位于取决于操作系统的固定位置(参见“每个用户环境”)。在 Fedora 和一些其他系统上,有两个路径条目:分别用于纯 Python 模块和扩展模块。

站点软件包

此目录保存来自当前环境的第三方软件包,该环境可以是虚拟环境或系统范围的安装。在 Fedora 和一些其他系统上,纯 Python 模块和扩展模块位于不同的目录中。许多 Linux 系统还将分发拥有的站点软件包与本地站点软件包分开,分别放在 /usr/usr/local 下。

通常情况下,站点软件包位于名为 site-packages 的标准库的子目录中。如果 site 模块在解释器路径上找到 pyvenv.cfg 文件,则它使用与系统安装中相同的相对路径,但是从该文件标记的虚拟环境开始。site 模块还修改 sys.prefix 以指向虚拟环境。

site 模块提供了一些用于定制的钩子:

.pth 文件

在站点软件包目录中,任何具有 .pth 扩展名的文件都可以列出用于 sys.path 的额外目录,每行一个目录。这类似于 PYTHONPATH 的工作方式,但是这些目录中的模块永远不会覆盖标准库。此外,.pth 文件还可以直接导入模块——site 模块会执行以 import 开头的任何行作为 Python 代码。第三方软件包可以提供 .pth 文件以配置环境中的 sys.path。一些打包工具在后台使用 .pth 文件来实现可编辑安装。可编辑安装会将项目的源目录放置在 sys.path 中,使代码更改立即在环境内可见。

sitecustomize 模块

在设置 sys.path 如上所述之后,site 模块会尝试导入 sitecustomize 模块,通常位于 site-packages 目录中。这提供了一个钩子,允许系统管理员在解释器启动时运行站点特定的自定义。

usercustomize 模块

如果存在每个用户环境,则site模块还会尝试导入usercustomize模块,通常位于用户site-packages目录中。你可以使用这个模块在解释器启动时运行用户特定的自定义。与PYTHONSTARTUP环境变量相比,后者允许你指定一个 Python 脚本在交互会话之前运行,位于同一命名空间内。

如果你将site模块作为命令运行,它会打印出你当前的模块路径,以及一些关于每个用户环境的信息:

$ py -m site
sys.path = [
    '/home/user',
    '/usr/local/lib/python312.zip',
    '/usr/local/lib/python3.12',
    '/usr/local/lib/python3.12/lib-dynload',
    '/home/user/.local/lib/python3.12/site-packages',
    '/usr/local/lib/python3.12/site-packages',
]
USER_BASE: '/home/user/.local' (exists)
USER_SITE: '/home/user/.local/lib/python3.12/site-packages' (exists)
ENABLE_USER_SITE: True

回到基础知识

如果你读到这里,模块路径可能看起来有点—复杂

这里有一个对 Python 如何定位模块的良好、坚实的直觉:解释器在sys.path上搜索模块的目录—​首先是包含标准库模块的目录,然后是带有第三方包的site-packages目录。虚拟环境中的解释器使用该环境的site-packages目录。

正如你在本节中所看到的,事实远比那个简单的故事复杂。但我有好消息告诉你:Python 可以让你实现这个故事。-P解释器选项省略了包含你的脚本的目录(或者如果你用py -m *<module>*运行程序,则省略当前目录)在模块路径中。-I解释器选项省略了每个用户环境在模块路径中的位置,以及任何使用PYTHONPATH设置的目录。如果你想要更可预测的模块路径,运行 Python 程序时同时使用这两个选项。

如果你使用-I-P选项重新运行site模块,模块路径将被缩减为仅包括标准库和站点包:

$ py -IPm site
sys.path = [
    '/usr/local/lib/python312.zip',
    '/usr/local/lib/python3.12',
    '/usr/local/lib/python3.12/lib-dynload',
    '/usr/local/lib/python3.12/site-packages',
]
USER_BASE: '/home/user/.local' (exists)
USER_SITE: '/home/user/.local/lib/python3.12/site-packages' (exists)
ENABLE_USER_SITE: False

当前目录不再出现在模块路径上,每个用户的站点包也消失了,即使该目录在此系统上存在。

摘要

在本章中,你已经了解了 Python 环境是什么,如何找到它们以及它们内部的样子。在核心,Python 环境由 Python 解释器和 Python 模块组成,以及运行 Python 应用程序的入口点脚本。环境与特定版本的 Python 语言相关联。

有三种类型的 Python 环境。 Python 安装是完整的、独立的环境,包括解释器和完整的标准库。 每个用户环境 是安装的附属环境,你可以在其中为单个用户安装模块和脚本。 虚拟环境是轻量级的环境,用于项目特定模块和入口点脚本,它们通过一个pyvenv.cfg文件引用其父环境。它们带有一个解释器,通常是父解释器的符号链接或小包装器,并带有用于 shell 集成的激活脚本。你可以使用命令py -m venv创建一个虚拟环境。

使用 pipx 安装 Python 应用程序,使它们在全局可用,同时将它们保留在单独的虚拟环境中。你可以使用类似 pipx run black 的单个命令安装和运行应用程序。设置 PIPX_DEFAULT_PYTHON 变量以确保 pipx 在当前 Python 版本上安装工具。

Uv 是一个极其快速的 virtualenv 和 pip 的替代品,并且具有更好的默认设置。使用 uv venv 创建虚拟环境,以及 uv pip 安装包到其中。这两个命令默认使用 .venv 目录,就像 Unix 上的 py 工具一样。--python 选项允许你选择环境的 Python 版本。

在本章的最后部分,你已经了解了 Python 在导入模块时如何使用 sys.path 定位模块,以及解释器启动期间如何构建模块路径。你还学习了模块导入在幕后的工作原理,使用查找器和加载器以及模块缓存。解释器发现和模块导入是将 Python 程序与运行时环境链接起来的关键机制。

¹ 还有一个 pythonw.exe 可执行文件,用于运行没有控制台窗口的程序,比如 GUI 应用程序。

² 共享库 是一个具有可执行代码的文件,多个程序可以在运行时使用。操作系统只在内存中保留代码的单一副本。

³ Windows 安装不包括 pydoc 的入口脚本 —— 可以使用 py -m pydoc 来启动它。

⁴ 在 2008 年成为标准之前,macOS 框架构建在用户安装方面具有先驱性。

⁵ 这是一件好事:在你的包管理器背后安装和卸载 Python 包会带来真正破坏系统的风险。

⁶ 你可以通过 --symlinks 选项强制在 Windows 上使用符号链接 —— 但是不建议这样做。这些在 Windows 上的工作方式有微妙的差异。例如,文件资源管理器在启动 Python 之前解析符号链接,这会阻止解释器检测到虚拟环境。

⁷ 在 Python 3.12 之前,venv 模块还预先安装了 setuptools,以满足不声明它为构建依赖项的旧版包的需求。

⁸ 在内部,pip 查询 sysconfig 模块以获得适当的 安装方案 —— Python 环境布局。该模块使用 Python 的构建配置和解释器在文件系统中的位置构建安装方案。

⁹ 在撰写本文时的 2024 年,pipx 会缓存临时环境 14 天。

¹⁰ 对于位于包内的模块,包的 __path__ 属性代替了 sys.path

第二部分:Python 项目

第三章:Python 包

在本章中,你将学习如何为分发打包你的 Python 项目。 是一个单一文件,包含了你的代码的归档及描述它的元数据,比如项目名称和版本。

注意

Python 社区使用 这个词来表示两个不同的概念。导入包 是包含其他模块的模块。分发包 则是用于分发 Python 软件的归档文件,它们是本章的主题。

你可以使用像 pip 这样的包安装程序将一个包安装到 Python 环境中。你也可以将它上传到一个包仓库,以使他人受益。Python 软件基金会(PSF)运营着一个称为 Python Package Index(PyPI)的包仓库。如果你的包在 PyPI 上,任何人都可以通过将其项目名称传递给 pip install 来安装它。

打包你的项目使其易于与他人共享,但还有另一个好处。当你安装你的包时,它成为 Python 环境中的一等公民:

  • 解释器从环境中导入你的模块,而不是从文件系统中的任意目录导入,这样可能会根据你如何调用 Python 而有所不同,也可能会无法正常工作。

  • 安装程序使用包元数据来确保环境符合包的先决条件,例如最低的 Python 版本以及它依赖的任何第三方包。

  • 安装程序可以生成入口点脚本,以确保你的代码始终在环境中的解释器上运行。与手写的 Python 脚本相比,后者可能在错误的 Python 版本上运行,或者没有所需的第三方包,或者无法导入自己的模块。

在本章中,我将解释如何打包你的 Python 项目,并介绍一些帮助进行打包任务的工具。本章分为三部分:

  • 在第一部分中,我将讨论 Python 包的生命周期。我还将介绍一个示例应用程序,你将在本书中使用它。我会问:为什么你要打包你的代码?

  • 在第二部分中,我将介绍 Python 的包配置文件 pyproject.toml,以及用于处理包的工具:buildhatchling 和 Twine。工具 pip、uv 和 pipx 也会再次出现。最后,我将介绍 Rye,一个将这些打包工具整合到统一工作流程中的项目管理器。在此过程中,你将了解构建前端和后端、wheels 和 sdists、可编辑安装和 src 布局。

  • 在第三部分中,我将详细讨论项目元数据——在 pyproject.toml 中定义和描述你的包的各种字段,以及如何高效利用它们。

包的生命周期

图 3-1 展示了一个包的典型生命周期。

本图示作者如何将项目构建成包并上传到包索引,用户随后将其下载并安装到环境中。

图 3-1. 包的生命周期:作者将项目构建成包并将其上传到包索引,然后用户将包下载并安装到环境中。

1

一切都始于一个项目:应用程序、库或其他软件的源代码。

2

作为作者,您的第一步是从项目构建一个包。该包是一个可安装的工件,包含项目此时点的快照,由唯一的名称和版本标识。

3

接下来,您将包发布到知名的存储库,如 PyPI。

4

用户现在可以通过指定其名称和版本获取您的包。

5

最后,用户将您的包安装到他们的环境中。

您可以直接将刚构建的包安装到环境中,而无需先将其上传到包存储库—例如,当您在测试包或您是其唯一用户时。

在实际应用中,工具通常将获取和安装、构建和安装,甚至构建和发布组合为单个命令。

一个示例应用程序

许多应用程序最初是小型的临时脚本。示例 3-1 从维基百科获取随机文章,并在控制台中显示其标题和摘要。该脚本限制于标准库,因此可在任何 Python 3 环境中运行。

示例 3-1. 显示从随机维基百科文章中提取的内容
import json
import textwrap
import urllib.request

API_URL = "https://en.wikipedia.org/api/rest_v1/page/random/summary" ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)

def main():
    with urllib.request.urlopen(API_URL) as response: ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)
        data = json.load(response) ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/3.png)

    print(data["title"], end="\n\n") ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/4.png)
    print(textwrap.fill(data["extract"])) ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/4.png)

if __name__ == "__main__":
    main()

1

API_URL常量指向英文维基百科的 REST API,更具体地说是其 /page/random/summary 端点。

2

urllib.request.urlopen调用发送 HTTP GET 请求到维基百科 API。with语句确保连接在块结束时关闭。

3

响应体以 JSON 格式包含资源数据。方便地,响应是一个类似文件的对象,所以json模块可以像从磁盘加载文件一样加载它。

4

titleextract键分别包含维基百科页面的标题和简短的纯文本摘录。textwrap.fill函数用于将文本包装,使每行最多 70 个字符长。

将此脚本存储在名为random_wikipedia_article.py的文件中并运行它。以下是一个示例运行:

> py -m random_wikipedia_article
Jägersbleeker Teich

The Jägersbleeker Teich in the Harz Mountains of central Germany
is a storage pond near the town of Clausthal-Zellerfeld in the
county of Goslar in Lower Saxony. It is one of the Upper Harz Ponds
that were created for the mining industry.

为什么需要打包?

分享像 示例 3-1 这样的脚本不需要打包。你可以在博客或托管的仓库上发布它,或通过电子邮件或聊天发送给朋友。Python 的普及性,其标准库的“电池包含”方法,以及作为解释语言的特性使这一切成为可能。

与世界共享模块的便利性曾是 Python 在早期被广泛采用的一个优势。Python 编程语言早于语言特定的包仓库的出现——PyPI 在十多年后才出现。¹

分发未打包的自包含模块似乎一开始是个好主意:你可以保持项目没有打包的杂物。它们不需要单独的工件,也不需要像构建那样的中间步骤或专用工具。但使用模块作为分发单元也有限制:

由多个模块组成的项目

当你的项目超出单个文件脚本时,你应该将其拆分开来,但是为用户安装一堆文件很麻烦。打包能让你将所有东西都保持在一个单一文件中进行分发。

具有第三方依赖项的项目

Python 拥有丰富的第三方包生态系统,因此你站在巨人的肩膀上。但你的用户不应该担心安装每个所需包的正确版本。打包能让你声明对其他包的依赖关系,安装程序会自动满足这些依赖。

查找项目

那个有用模块的仓库网址是什么?还是说在某个博客上?如果你在 PyPI 上发布一个包,你的用户只需知道其名称即可安装最新版本。在企业环境中情况类似,开发者的机器配置为使用公司范围的包仓库。

安装项目

大多数情况下,下载并双击脚本是行不通的。你不应该需要将模块放在神秘的目录中,并执行特殊操作来确保你的脚本在正确的解释器上执行。打包让用户可以用单一命令在可移植和安全的方式下安装你的项目。

更新项目

用户需要确定项目是否为最新版本,并在需要时升级到最新版本。作为作者,你需要一种方法让用户从新功能、错误修复和改进中受益。包仓库让你发布项目的一系列版本(开发快照的一个子集)。

在正确环境中运行项目

不要让程序在支持的 Python 版本上运行成为偶然,也不要忽视所需的第三方包。包安装程序会检查和在可能的情况下满足你的先决条件。它们还确保你的代码在预期的环境中运行。

二进制扩展

用 C 或 Rust 等编译语言编写的 Python 模块需要构建步骤。打包允许您分发常见平台的预构建二进制文件。此外,它允许您发布源代码存档作为备用;安装程序在最终用户的计算机上运行构建步骤。

元数据

您可以在模块内嵌入元数据,使用诸如 __author____version____license__ 等属性。但是工具必须执行模块才能读取这些属性。包含静态元数据的包可以在不运行 Python 的情况下供任何工具读取。

正如您所见,打包解决了许多问题,但有什么开销呢?简而言之,您只需将名为 pyproject.toml 的声明文件放入项目中—​这是一个指定项目元数据及其构建系统的标准文件。作为回报,您获得了用于构建、发布和安装包的命令。

总之,Python 包带来了许多优势:

  • 您可以轻松安装和升级它们

  • 您可以将它们发布到包存储库中

  • 它们可以依赖于其他包

  • 它们在满足其要求的环境中运行

  • 它们可以包含多个模块

  • 它们可以包含预构建的二进制扩展

  • 它们可以包含带有自动构建步骤的源分发

  • 它们附带描述包的元数据

pyproject.toml 文件

示例 3-2 展示了如何使用最少的元数据—​项目名称和版本—​以及一个入口脚本来打包 示例 3-1 中的脚本。项目和脚本使用连字符 (random-wikipedia-article),而模块使用下划线 (random_wikipedia_article)。将模块和 pyproject.toml 文件并排放在空目录中。

示例 3-2. 一个最小的 pyproject.toml 文件
[project]
name = "random-wikipedia-article"
version = "0.1"

[project.scripts]
random-wikipedia-article = "random_wikipedia_article:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
提示

PyPI 项目共享单一命名空间—​它们的名称不由用户或拥有项目的组织所限定。选择一个唯一的名称,比如 random-wikipedia-article-{your-name},并相应地重命名 Python 模块。

在顶层,pyproject.toml 文件可以包含最多三个部分—​或者 ,正如 TOML 标准所称:

[project]

project 表包含项目元数据。nameversion 字段是必需的。对于真实项目,您应该提供额外的信息,比如描述、许可证和所需的 Python 版本(参见 “项目元数据”)。scripts 部分声明入口脚本的名称以及它应该调用的函数。

[build-system]

build-system 表指定了如何为项目构建包(参见 “使用 build 构建包”)—特别是您的项目使用的构建工具。我在这里选择了 hatchling,它随 Hatch 一起提供,这是一个现代且符合标准的 Python 项目管理器。

[tool]

tool表存储了项目使用的每个工具的配置。例如,Ruff 代码检查器从[tool.ruff]表中读取其配置,而类型检查器 mypy 使用[tool.mypy]

使用 build 构建包

让我们使用build为你的新项目创建一个包,这是一个由 Python 包管理局(PyPA)维护的专用构建前端。PyPA 是一组志愿者,负责维护 Python 打包中使用的一组核心软件项目。

构建前端是一个为 Python 包编排构建过程的应用程序。构建前端不知道如何从源代码树中组装打包工件。实际构建的工具称为构建后端

打开一个终端,切换到项目目录,并使用 pipx 调用build

$ pipx run build
* Creating venv isolated environment...
* Installing packages in isolated environment... (hatchling)
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel from sdist
* Creating venv isolated environment...
* Installing packages in isolated environment... (hatchling)
* Getting build dependencies for wheel...
* Building wheel...
Successfully built random_wikipedia_article-0.1.tar.gz
 and random_wikipedia_article-0.1-py2.py3-none-any.whl

默认情况下,build为项目创建两种类型的包,即源分发包(sdist)和轮子(wheel)(参见“轮子和源分发包”)。你可以在项目的dist目录中找到这些文件。

如上输出所示,build将实际工作委派给了hatchling,你在示例 3-2 中指定的构建后端。构建前端使用build-system表来确定项目的构建后端(见表 3-1)。

表 3-1. build-system

字段 类型 描述
requires 字符串数组 构建项目所需的包的列表
build-backend 字符串 构建后端的导入名称,格式为package.module:object
build-path 字符串 用于导入构建后端所需的sys.path条目(可选)

图 3-2 展示了构建前端和构建后端如何协作构建一个包。

图示了构建前端如何编排包构建,通过创建构建环境以及安装和运行构建后端。

图 3-2. 构建前端和构建后端

1

构建前端创建了一个虚拟环境,构建环境

2

构建前端安装了列在requires下的包:构建后端本身以及可选的后端插件。这些包称为构建依赖项

3

构建前端通过两个步骤触发实际的包构建。首先,它导入了在build-backend中声明的模块或对象。其次,它调用了用于创建包和相关任务的众所周知的函数,称为构建钩子

下面是构建前端在为项目构建轮子时执行的命令的简化版本:²

$ py -m venv buildenv
$ buildenv/bin/python -m pip install hatchling
$ buildenv/bin/python
>>> import hatchling.build as backend
>>> backend.get_requires_for_build_wheel()
[]  # no additional build dependencies requested
>>> backend.build_wheel("dist")
'random_wikipedia_article-0.1-py2.py3-none-any.whl'

一些构建前端允许你在当前环境中构建。如果禁用构建隔离,前端仅检查构建依赖项。如果已安装它们,则不同包的构建和运行时依赖项可能会冲突。

为什么将构建前端与构建后端分开?这意味着工具可以触发包构建,而无需了解构建过程的复杂性。例如,包安装器如 pip 和 uv 可以在从源代码目录安装时即时构建包(参见 “从源码安装项目”)。

在构建前端和构建后端之间标准化合同,极大地增加了包装生态系统的多样性和创新。构建前端包括 build、pip 和 uv,不考虑后端的 Python 项目管理器 Rye、Hatch 以及 PDM,以及诸如 tox 等测试自动化工具。构建后端包括随项目管理器一起提供的 Flit、Hatch、PDM 和 Poetry,传统的构建后端 setuptools,以及像 Maturin 这样的用 Rust 编写的 Python 模块构建后端,以及 Sphinx Theme Builder,用于 Sphinx 文档主题的构建后端(见 Table 3-2)。

表 3-2. 构建后端

项目 requires^(a) build-backend
Flit flit-core flit_core.buildapi
孵化 hatchling hatchling.build
Maturin maturin maturin
PDM pdm-backend pdm.backend
Poetry poetry-core poetry.core.masonry.api
Setuptools setuptools setuptools.build_meta
Sphinx Theme Builder sphinx-theme-builder sphinx_theme_builder
^(a) 请参阅每个工具的官方文档,了解推荐的版本范围

使用 Twine 上传包

是时候发布你的包了。在这一节中,你将使用TestPyPI,这是 Python 包索引的一个单独实例,用于测试和实验。只需省略下面的--repository--index-url选项,即可使用真实的 PyPI。

首先,在 TestPyPI 的首页使用链接注册一个账户。其次,从你的账户页面创建一个 API 令牌,并将令牌复制到你首选的密码管理器中。现在,你可以使用 Twine(官方的 PyPI 上传工具)上传 dist 中的包。

$ pipx run twine upload --repository=testpypi dist/*
Uploading distributions to https://test.pypi.org/legacy/
Enter your API token: *********
Uploading random_wikipedia_article-0.1-py2.py3-none-any.whl
Uploading random_wikipedia_article-0.1.tar.gz

View at:
https://test.pypi.org/project/random-wikipedia-article/0.1/

恭喜,你已经发布了你的第一个 Python 包!让我们从 TestPyPI 安装这个包:

$ pipx install --index-url=https://test.pypi.org/simple random-wikipedia-article
  installed package random-wikipedia-article 0.1, installed using Python 3.12.2
  These apps are now globally available
    - random-wikipedia-article
done!

现在,你可以在任何地方调用你的应用程序:

$ random-wikipedia-article

从源码安装项目

如果您为项目分发软件包,将这些软件包在本地安装以进行开发和测试是一个好主意。对已安装的软件包运行测试,而不是源代码,意味着您正在测试您的项目,就像您的用户看到的那样。如果您正在开发服务,这将有助于使开发、暂存和生产尽可能相似。

可以使用 build 构建一个轮子,并将其安装到虚拟环境中:

$ pipx run build
$ uv venv
$ uv pip install dist/*.whl

不过,还有一种快捷方式。pip 和 uv 都可以直接从源目录(例如 . 代表当前目录)安装您的项目。在幕后,它们使用项目的构建后端创建一个轮子进行安装——它们就像 build 一样是构建前端:

$ uv venv
$ uv pip install .

如果您的项目带有入口点脚本,您也可以使用 pipx 安装它:

$ pipx install .
  installed package random-wikipedia-article 0.1, installed using Python 3.10.8
  These apps are now globally available
    - random-wikipedia-article

可编辑安装

在开发过程中,立即在环境中看到代码更改反映出来,而无需反复安装项目,这样可以节省时间。您可以直接从源树导入模块,但这样做会丧失打包项目的所有好处。

可编辑安装实现了最佳效果,通过以特殊方式安装您的软件包,将导入重定向到源树(参见“站点包”)。您可以将这种机制视为 Python 软件包的一种“热重载”。--editable选项(-e)与 uv、pip 和 pipx 一起工作:

$ uv pip install --editable .
$ py -m pip install --editable .
$ pipx install --editable .

一旦您以这种方式安装了软件包,您就不需要重新安装它以查看源代码的更改——只有在编辑 pyproject.toml 以更改项目元数据或添加第三方依赖项时才需要重新安装。

可编辑安装是模仿 setuptools 中的开发模式功能建立的,如果您已经足够长时间了解它。但与 setup.py develop 不同,它们依赖于任何构建后端都可以提供的标准构建钩子。

项目布局

pyproject.toml 放在单个文件模块旁边是一种非常简单的方法。不幸的是,这种项目布局带来了一个严重的问题,正如您将在本节中看到的那样。让我们从项目中破坏一些东西开始:

def main():
    raise Exception("Boom!")

在发布软件包之前,使用本地构建的轮子进行最后的冒烟测试:

$ pipx run build
$ uv venv
$ uv pip install dist/*.whl
$ py -m random_wikipedia_article
Exception: Boom!

找到的错误是修复的错误。删除有问题的行之后,验证程序是否按预期工作:

$ py -m random_wikipedia_article
Cystiscus viaderi

Cystiscus viaderi is a species of very small sea snail, a marine
gastropod mollusk or micromollusk in the family Cystiscidae.

一切都很好,是时候发布一个版本了!首先,将您的修复和新版本的 Git 标签推送到您的代码存储库。接下来,使用 Twine 将轮子上传到 PyPI:

$ pipx run twine upload dist/*

但是,天哪——您从未重建过轮子。这个错误现在已经在一个公共版本中!怎么可能发生这种情况?

使用 py -m 运行您的应用程序可以防止意外地从另一个安装运行入口点脚本(并且它有不需要在 macOS 和 Linux 上有活动环境的优点)。但它也会将当前目录添加到 sys.path 的前面(参见 “Site Packages”)。一直以来,您一直在源树中测试模块,而不是您打算发布的 wheel!

您可以设置 PYTHONSAFEPATH 环境变量,并且永远不再考虑此事——这是 py -P 的别名,并省略模块路径中的当前目录。但这将使您的贡献者不被冷落——以及当您在另一台机器上工作时。

取而代之的是,将您的模块移出顶级目录,以免人们误导入它。按照惯例,Python 源树放入 src 目录中——这就是为什么在 Python 社区中这种安排被称为 src 布局

此时,将单文件模块转换为导入包也是有意义的。通过将 random_wikipedia_article.py 文件替换为一个 random_wikipedia_article 目录,其中包含一个 init.py 模块。

将您的代码放入导入包中基本上等同于将其放入单文件模块中——但有一个区别:您不能使用 py -m random_wikipedia_article 运行应用程序,除非您还将特殊的 main.py 模块添加到包中(参见 Example 3-3)。

示例 3-3. main.py 模块
from random_wikipedia_article import main

main()

main.py 模块替代了 init.py 中的 if __name__ == "__main__" 块。从模块中删除该块。

这将给您留下一个经典的初始项目结构:

random-wikipedia-article
├── pyproject.toml
└── src
    └── random_wikipedia_article
        ├── __init__.py
        └── __main__.py

导入包使得项目更容易扩展:您可以将代码移到单独的模块中并从那里导入。例如,您可以将与 Wikipedia API 交互的代码提取到一个名为 fetch 的函数中。接下来,您可以将该函数移动到包中的 fetch.py 模块中。以下是从 init.py 导入该函数的方法:

from random_wikipedia_article.fetch import fetch

最终,init.py 将只包含您公共 API 的 import 语句。

使用 Rye 管理包

许多现代编程语言都配备了一个工具来构建、打包和执行其他开发任务。您可能想知道 Python 社区是如何最终拥有如此多的分离职责的打包工具的。

答案与 Python 项目的性质和历史有关:Python 是一个由成千上万志愿者驱动的分散式开源项目,历史跨越三十多年的有机增长。这使得单一的打包工具难以满足所有需求并牢固确立。³

Python 的优势在于其丰富的生态系统—​和互操作标准促进了这种多样性。作为 Python 开发人员,你可以选择那些良好协同的小型单一目的工具。这种方法符合“做一件事,并做好”的 Unix 哲学。

但 Unix 的方法不再是你唯一的选择。Python 项目管理器提供了一个更集成的工作流程。其中首要的是 Poetry(见第五章),它设定了重新发明 Python 打包的目标,并倡导了静态元数据和跨平台锁定文件等概念。

Rye是一个用 Rust 编写的 Python 项目管理器,选择了一条不同的道路。它在这本书中已经看到(也即将看到)的广泛使用的单一目的工具之上提供了统一的开发体验。由 Armin Ronacher 作为私人项目开始,并于 2023 年首次向公众发布,现在由 Astral 管理,Astral 是 Ruff 和 uv 背后的公司。

请参阅 Rye 的官方文档以获取安装说明。

使用 rye init 初始化新项目是你使用 Rye 的第一步。如果没有传递项目名称,Rye 将使用当前目录的名称。使用 --script 选项包含入口点脚本:

$ rye init random-wikipedia-article --script

Rye 在 random-wikipedia-article 中初始化了一个 Git 存储库,并用一个 pyproject.toml 文件,一个 src 目录中的导入包 random_wikipedia_article,一个包含项目描述的 README.md,一个包含默认 Python 版本的 .python-version 文件,以及一个位于 .venv 中的虚拟环境来填充它。Rye 支持各种构建后端,其中 hatchling 是默认选择。

random-wikipedia-article
├── .git
├── .gitignore
├── .python-version
├── .venv
├── README.md
├── pyproject.toml
└── src
    └── random_wikipedia_article
        ├── __init__.py
        └── __main__.py

Rye 的许多命令是前端工具,这些工具已成为 Python 世界中事实上的标准,或者有望在未来成为标准。rye build命令使用build创建包,rye publish命令使用 Twine 上传包,rye sync命令使用 uv 执行可编辑安装:

$ rye build
$ rye publish --repository testpypi --repository-url https://test.pypi.org/legacy/
$ rye sync

rye sync其实功能远不止于此。 Rye 使用 Python Standalone Builds 项目管理私有 Python 安装(参见“全新世界:使用 Hatch 和 Rye 安装”),rye sync在首次使用时获取每个 Python 版本。该命令还为项目依赖项生成锁定文件,并将环境与该文件同步(参见第四章)。

Wheels 和 Sdists

在“使用 build 构建包”中,build 为您的项目创建了两个包:

  • random_wikipedia_article-0.1.tar.gz

  • random_wikipedia_article-0.1-py2.py3-none-any.whl

这些工件被称为wheelsdist。Wheels 是带有.whl扩展名的 ZIP 归档文件,它们是构建分发——大多数情况下,安装程序会将它们原封不动地解压到环境中。相比之下,sdist 是源分发:它们是包含打包元数据的源代码压缩归档文件。需要额外的构建步骤来生成可安装的 wheel。

小贴士

Python 包的名称“wheel”是对奶酪轮的引用。PyPI 最初以奶酪商店而闻名,这是源自蒙提·派森有关没有奶酪的奶酪商店的小品。如今,PyPI 每天提供超过一 PB 的软件包。

对于像 Python 这样的解释语言,源分发和构建分发之间的区别可能显得有些奇怪。但是你也可以使用编译语言编写 Python 模块。在这种情况下,当某个平台上没有预构建的 wheel 可用时,源分发提供了一个有用的备选方案。

作为包的作者,您应该为您的发布构建和发布 sdist 和 wheels。这样一来,用户可以选择:如果他们的环境兼容(对于纯 Python 包始终如此),他们可以获取并安装 wheel——或者他们可以获取 sdist 并在本地构建 wheel(参见图 3-3)。

本图显示了作者如何构建和发布 sdist 和 wheel。用户可以下载并安装 wheel,或者下载 sdist 并从中构建和安装 wheel。

图 3-3. Wheels 和 sdists

对于包的使用者来说,sdist 有几个注意事项。首先,构建步骤涉及任意代码执行,这可能是一个安全问题⁴。其次,安装 wheels 比安装 sdist 要快得多,特别是对于基于传统setup.py的包来说。最后,如果用户系统上缺少所需的构建工具链,他们可能会遇到扩展模块的构建错误。

通常,纯 Python 包在给定版本发布时会有一个单独的 sdist 和一个单独的 wheel。另一方面,二进制扩展模块通常会针对一系列平台和环境提供 wheels。

小贴士

如果您是扩展模块的作者,请查看cibuildwheel项目:它可以自动在多个平台上构建和测试 wheels,并支持 GitHub Actions 和其他各种持续集成(CI)系统。

核心元数据

如果您感兴趣,可以使用unzip实用程序解压缩 wheel,以查看安装程序放置在site-packages目录中的文件。请在 Linux 或 macOS 的 Shell 中执行以下命令——最好在空目录中执行。如果您使用 Windows,可以使用 Windows 子系统来进行操作。

$ py -m pip download attrs
$ unzip *.whl
$ ls -1
attr
attrs
attrs-23.2.0.dist-info
attrs-23.2.0-py3-none-any.whl

$ head -n4 attrs-23.2.0.dist-info/METADATA
Metadata-Version: 2.1
Name: attrs
Version: 23.2.0
Summary: Classes Without Boilerplate

除了导入的包(在此案例中命名为 attrattrs),wheel 包含一个 .dist-info 目录,其中包含管理文件。该目录中的 METADATA 文件包含包的核心元数据,一组标准化的属性,用于安装程序和其他打包工具的好处。您可以使用标准库在运行时访问已安装包的核心元数据:

$ uv pip install attrs
$ py
>>> from importlib.metadata import metadata
>>> metadata("attrs")["Version"]
23.2.0
>>> metadata("attrs")["Summary"]
Classes Without Boilerplate

在下一节中,您将看到如何在您自己的包中嵌入核心元数据,使用 pyproject.toml 中的 project 表。

注意

核心元数据标准比 pyproject.toml 提前多年。大多数项目元数据字段对应于核心元数据字段,但它们的名称和语法略有不同。作为包作者,您可以安全地忽略此翻译,专注于项目元数据。

项目元数据

根据你在 pyproject.tomlproject 表中指定的内容,构建后端会输出核心元数据字段。表格 3-3 提供了 project 表中可用字段的概述。

表格 3-3. project

字段 类型 描述
name 字符串 项目名称
version 字符串 项目的版本
description 字符串 项目的简要描述
keywords 字符串数组 项目的关键词列表
readme 字符串或表 项目详细描述的文件
license 管理该项目使用的许可证
authors 表的数组 作者列表
maintainers 表的数组 维护者列表
classifiers 字符串数组 描述项目的分类器列表
urls 字符串表 项目的网址
dependencies 字符串数组 所需的第三方包列表
optional-dependencies 表的数组的字符串 可选第三方包的命名列表(extras
scripts 字符串表 入口点脚本
gui-scripts 字符串表 提供图形用户界面的入口点脚本
entry-points 表的表的字符串 入口点组
requires-python 字符串 该项目所需的 Python 版本
dynamic 字符串数组 动态字段列表

每个包必需的两个关键字段是 project.nameproject.version。项目名称唯一标识项目本身。项目版本标识一个发布—项目在其生命周期中的已发布快照。除名称和版本外,还可以提供一些可选字段,例如作者和许可证,描述项目的简短文本,或项目使用的第三方包(见 示例 3-4)。

示例 3-4. 带有项目元数据的 pyproject.toml 文件
[project]
name = "random-wikipedia-article"
version = "0.1"
description = "Display extracts from random Wikipedia articles"
keywords = ["wikipedia"]
readme = "README.md"  # only if your project has a README.md file
license = { text = "MIT" }
authors = [{ name = "Your Name", email = "you@example.com" }]
classifiers = ["Topic :: Games/Entertainment :: Fortune Cookies"]
urls = { Homepage = "https://yourname.dev/projects/random-wikipedia-article" }
requires-python = ">=3.8"
dependencies = ["httpx>=0.27.0", "rich>=13.7.1"]

在接下来的章节中,我将更仔细地研究各种项目元数据字段。

项目命名

project.name 字段包含您项目的官方名称。

[project]
name = "random-wikipedia-article"

用户通过指定此名称来使用 pip 安装项目。此字段还确定了项目在 PyPI 上的 URL。您可以使用任何 ASCII 字母或数字来命名您的项目,其中可以插入句点、下划线和连字符。打包工具会将项目名称标准化以便比较:所有字母都转换为小写,并且标点符号连续运行被替换为一个单破折号(或者在包文件名的情况下替换为下划线)。例如,Awesome.Packageawesome_packageawesome-package 都指代同一个项目。

项目名称与导入名称不同,导入名称是用户指定用于导入您的代码的名称。导入名称必须是有效的 Python 标识符,因此不能有连字符或句点,并且不能以数字开头。它们区分大小写,并且可以包含任何 Unicode 字母或数字。作为一个经验法则,您应该对每个分发包使用单个导入包,并且两者使用相同的名称(或直接翻译,如 random-wikipedia-articlerandom_wikipedia_article)。

项目版本管理

project.version 字段存储您在发布发布时的项目版本。

[project]
version = "0.1"

Python 社区对版本号有一个规范,以确保自动化工具可以做出有意义的决策,比如选择项目的最新发布版。在核心,版本是一个点分隔的数字序列。这些数字可以是零,并且可以省略尾随零:11.01.0.0 都指代同一个版本。此外,您还可以附加某些类型的后缀到一个版本(参见 表 3-4)。最常见的后缀用于标识预发布版本:1.0.0a2 是第二个 alpha 发布版,1.0.0b3 是第三个 beta 发布版,1.0.0rc1 是第一个候选发布版。这些版本都在下一个版本之前,所有这些版本都在最终发布版之前:1.0.0。Python 版本还可以使用额外的组件以及备用拼写;请参阅 PEP 440 获取完整的规范。

表格 3-4. 版本标识符

发布类型 描述 示例
最终发布 稳定的、公开的快照(默认) 1.0.02017.5.25
预发布 支持测试的最终发布的预览 1.0.0a11.0.0b11.0.0rc1
开发版本发布 像夜间构建一样的常规内部快照 1.0.0.dev1
后发布 修正代码之外的次要错误 1.0.0.post1

Python 版本规范故意宽松。两个广泛采用的跨语言标准附加了版本号的额外含义:语义化版本 使用 major.minor.patch 方案,其中 patch 指代 bug 修复版本,minor 指代兼容性特性发布,而 major 指代带有破坏性变更的发布。日历版本 使用各种形式的基于日期的版本,如 year.month.dayyear.month.sequenceyear.quarter.sequence

动态字段

pyproject.toml 标准鼓励项目静态定义其元数据,而不是依赖于构建后端在打包期间计算字段。静态元数据有利于打包生态系统,因为它使得字段对其他工具可访问。它还减少了认知负担:构建后端使用相同的配置格式,并以直观透明的方式填充字段。

但有时让构建后端动态填充字段也很有用。例如,接下来的部分展示了如何从 Python 模块或 Git 标签派生包版本,而不是在 pyproject.toml 中重复它。

因此,项目元数据标准通过 动态字段 提供了一种逃生通道:如果项目在 dynamic 键下列出其名称,则允许使用后端特定的机制动态计算字段。

[project]
dynamic = ["version", "readme"]

单一来源项目版本

许多项目在 Python 模块的顶部声明其版本,如下所示:

__version__ = "0.2"

在几个位置更新频繁更改的项目是繁琐且容易出错的。因此,一些构建后端允许您从代码中提取版本号,而不是在 project.version 中重复它。此机制特定于您的构建后端,因此您需要在 tool 表中配置它。示例 3-5 演示了 Hatch 如何运作。

示例 3-5. 从 Python 模块派生项目版本
[project]
name = "random-wikipedia-article"
dynamic = ["version"] ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)

[tool.hatch.version]
path = "random_wikipedia_article.py" ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

1

将版本字段标记为动态。

2

告诉 Hatch 在哪里查找 __version__ 属性。

您也可以通过另一种方式避免重复:在 pyproject.toml 中静态声明版本,并在运行时从安装的元数据中读取,如示例 3-6 所示。

示例 3-6. 从安装的元数据中读取版本
from importlib.metadata import version

__version__ = version("random-wikipedia-article")

但是,不要在所有项目中都添加此样板代码。在程序启动期间不应执行环境中的元数据查找。第三方库如 click 会按需执行元数据查找——例如,当用户指定类似 --version 的命令行选项时。您可以通过为模块提供 __getattr__ 函数按需读取版本(示例 3-7)⁵。

示例 3-7. 按需从安装的元数据中读取版本
def __getattr__(name):
    if name != "__version__":
        msg = f"module {__name__} has no attribute {name}"
        raise AttributeError(msg)

    from importlib.metadata import version

    return version("random-wikipedia-article")

遗憾的是,您仍未真正实现版本的单一来源。最有可能,您还会使用类似 git tag v1.0.0 的命令在版本控制系统(VCS)中标记发布(如果没有,请务必——如果发布存在错误,版本标签可帮助您找到引入该错误的提交)。

幸运的是,许多构建后端都带有从 Git、Mercurial 和类似系统中提取版本号的插件。这种技术由 setuptools-scm 插件首创。对于 Hatch,您可以使用 hatch-vcs 插件,它是 setuptools-scm 的包装器(示例 3-8)。

示例 3-8. 从版本控制系统中派生项目版本
[project]
name = "random-wikipedia-article"
dynamic = ["version"]

[tool.hatch.version]
source = "vcs"

[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

如果您从存储库构建此项目并检出了标签 v1.0.0,Hatch 将使用版本 1.0.0 的元数据。如果您检出了未标记的提交,则 Hatch 将生成类似 0.1.dev1+g6b80314 的开发版本。⁶ 换句话说,您可以在包构建期间从 Git 读取项目版本,并在运行时从包元数据读取。

入口点脚本

入口点脚本 是小型可执行文件,它们从其环境启动解释器,导入一个模块并调用一个函数(参见 “入口点脚本”)。安装程序如 pip 在安装包时会动态生成它们。

project.scripts 表允许您声明入口点脚本。将脚本名称指定为键,并指定应调用的模块和函数为值,使用格式 *package*.*module*:*function*

[project.scripts]
random-wikipedia-article = "random_wikipedia_article:main"

此声明允许用户使用其给定名称调用程序:

$ random-wikipedia-article

project.gui-scripts 表使用与 project.scripts 表相同的格式——如果您的应用程序具有图形用户界面(GUI),请使用此表。

[project.gui-scripts]
random-wikipedia-article-gui = "random_wikipedia_article:gui_main"

入口点

入口点脚本 是更一般机制 入口点 的特例。入口点允许您在包中注册一个 Python 对象,公开一个名称。Python 环境附带一个入口点注册表,任何包都可以查询此注册表以发现和导入模块,使用标准库中的函数 importlib.metadata.entry_points。应用程序通常使用此机制来支持第三方插件。

project.entry-points表格包含这些通用入口点。它们使用与入口点脚本相同的语法,但分组在被称为入口点组的子表中。如果你想为另一个应用程序编写一个插件,你可以在其指定的入口点组中注册一个模块或对象。

[project.entry-points.some_application]
my-plugin = "my_plugin"

你还可以使用点号表示法注册子模块,以及模块内的对象,使用格式 *module*:*object*

[project.entry-points.some_application]
my-plugin = "my_plugin.submodule:plugin"

让我们通过一个示例来看看它是如何工作的。随机的维基百科文章看起来像是有趣的小幸运饼干,但它们也可以作为测试固件⁷,用于维基百科查看器和类似应用程序的开发者。让我们把这个应用程序转变成 pytest 测试框架的一个插件。(如果你还没有使用过 pytest,不用担心;我会在第六章中深入讲解测试。)

Pytest 允许第三方插件通过测试固件和其他功能来扩展其功能。它定义了一个名为pytest11的入口点组,用于这些插件。你可以通过在这个组中注册一个模块来为 pytest 提供插件。让我们也把 pytest 添加到项目依赖中。

[project]
dependencies = ["pytest"]

[project.entry-points.pytest11]
random-wikipedia-article = "random_wikipedia_article"

为简单起见,我选择了主函数所在的顶级模块,在示例 3-1 中展示。接下来,扩展 pytest,添加一个测试固件,返回一个随机的维基百科文章,如示例 3-9 所示。

示例 3-9. 使用随机维基百科文章的测试固件
import json
import urllib.request

import pytest

API_URL = "https://en.wikipedia.org/api/rest_v1/page/random/summary"

@pytest.fixture
def random_wikipedia_article():
    with urllib.request.urlopen(API_URL) as response:
        return json.load(response)

维基百科查看器的开发者现在可以在 pytest 旁边安装你的插件了。测试函数通过将其作为函数参数引用来使用你的测试固件(参见示例 3-10)。Pytest 识别出函数参数是一个测试固件,并调用带有固件返回值的测试函数。

示例 3-10. 使用随机文章固件的测试函数
# test_wikipedia_viewer.py
def test_wikipedia_viewer(random_wikipedia_article):
    print(random_wikipedia_article["extract"]) ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
    assert False ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)

1

一个真正的测试会运行查看器而不是print()

2

让测试失败以便我们能够看到完整的输出。

你可以在项目目录中的活动虚拟环境中自己尝试这个:

$ py -m pip install .
$ py -m pytest test_wikipedia_viewer.py
============================= test session starts ==============================
platform darwin -- Python 3.12.2, pytest-8.1.1, pluggy-1.4.0
rootdir: ...
plugins: random-wikipedia-article-0.1
collected 1 item

test_wikipedia_viewer.py F                                               [100%]

=================================== FAILURES ===================================
____________________________ test_wikipedia_viewer _____________________________

    def test_wikipedia_viewer(random_wikipedia_article):
        print(random_wikipedia_article["extract"])
>       assert False
E       assert False

test_wikipedia_viewer.py:4: AssertionError
----------------------------- Captured stdout call -----------------------------
Halgerda stricklandi is a species of sea slug, a dorid nudibranch, a shell-less
marine gastropod mollusk in the family Discodorididae.
=========================== short test summary info ============================
FAILED test_wikipedia_viewer.py::test_wikipedia_viewer - assert False
============================== 1 failed in 1.10s ===============================

作者和维护者

project.authorsproject.maintainers字段包含项目的作者和维护者列表。这些列表中的每个条目都是一个带有nameemail键的表格—​你可以指定这些键中的任一个或两者都指定。

[project]
authors = [{ name = "Your Name", email = "you@example.com" }]
maintainers = [
  { name = "Alice", email = "alice@example.com" },
  { name = "Bob", email = "bob@example.com" },
]

对字段的含义有些开放于解释。如果你开始一个新项目,我建议在authors中包括你自己,并省略maintainers字段。长期的开源项目通常在authors下列出原始作者,而负责项目持续维护的人员则出现在maintainers下。

描述和 README

project.description 字段包含一个短描述作为字符串。此字段将作为您在 PyPI 上项目页面的副标题出现。一些打包工具在显示可读性描述的紧凑包列表时也使用此字段。

[project]
description = "Display extracts from random Wikipedia articles"

project.readme 字段通常是一个字符串,包含项目长描述文件的相对路径。通常的选择是用于 Markdown 格式的 README.md 和用于 reStructuredText 格式的 README.rst。此文件的内容将出现在 PyPI 上的项目页面上。

[project]
readme = "README.md"

您还可以指定带有 filecontent-type 键的表格,而不是字符串。

[project]
readme = { file = "README", content-type = "text/plain" }

您甚至可以使用 text 键将长描述嵌入到 pyproject.toml 文件中。

[project.readme]
content-type = "text/markdown"
text = """
# random-wikipedia-article

Display extracts from random Wikipedia articles
"""

编写良好的 README 并非易事—通常,项目描述会出现在不同的地方,例如 PyPI、类似 GitHub 的仓库托管服务,以及像 Read the Docs 这样的官方文档服务内部。如果需要更多灵活性,您可以声明字段是动态的,并使用像 hatch-fancy-pypi-readme 这样的插件从多个片段组装项目描述。

关键词和分类器

project.keywords 字段包含一个字符串列表,供人们在 PyPI 上搜索您的项目使用。

[project]
keywords = ["wikipedia"]

project.classifiers 字段包含一个分类器列表,以标准化方式对项目进行分类。

[project]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Environment :: Console",
    "Topic :: Games/Entertainment :: Fortune Cookies",
]

PyPI 维护 Python 项目的 官方注册表。它们被称为 Trove classifiers,由双冒号分隔的分层组织标签组成(见 表 3-5)。Trove 项目由 Eric S. Raymond 发起,是一个早期设计的开源软件存储库。

表 3-5. Trove 分类器

分类器组 描述 示例
开发状态 此版本的成熟度 Development Status :: 5 - Production/Stable
环境 项目运行的环境 Environment :: No Input/Output (Daemon)
操作系统 项目支持的操作系统 Operating System :: OS Independent
框架 项目使用的任何框架 Framework :: Flask
受众 项目服务的用户类型 Intended Audience :: Developers
许可证 项目分发的许可证 License :: OSI Approved :: MIT License
自然语言 项目支持的自然语言 Natural Language :: English
编程语言 项目所用的编程语言 Programming Language :: Python :: 3.12
主题 与项目相关的各种主题 Topic :: Utilities

分类器完全是可选的。我建议指明开发状态和支持的操作系统,这些信息在其他元数据字段中没有涵盖。如果你想包含更多分类器,请提供每个分类器组中的一个。

项目 URL

project.urls 表格允许你将用户指向项目主页、源代码、文档、问题跟踪器和类似项目相关的 URL。你在 PyPI 上的项目页面使用提供的键作为每个链接的显示文本。它还为许多常见名称和 URL 显示适当的图标。

[project.urls]
Homepage = "https://yourname.dev/projects/random-wikipedia-article"
Source = "https://github.com/yourname/random-wikipedia-article"
Issues = "https://github.com/yourname/random-wikipedia-article/issues"
Documentation = "https://readthedocs.io/random-wikipedia-article"

许可证

project.license 字段是一个表格,你可以在 text 键下指定项目的许可证,或者通过 file 键引用文件。你可能还希望添加相应的 Trove 分类器来说明许可证。

[project]
license = { text = "MIT" }
classifiers = ["License :: OSI Approved :: MIT License"]

我建议使用 text 键,并使用类似“MIT”或“Apache-2.0”的 SPDX 许可证标识符。软件包数据交换 (SPDX) 是 Linux 基金会支持的开放标准,用于传达软件物料清单信息,包括许可证。

注意

截至本文撰写时,正在讨论一项 Python 增强提案 (PEP),该提案将 license 字段更改为使用 SPDX 语法的字符串,并添加一个 license-files 键用于包内分发的许可证文件:PEP 639

如果不确定要为项目选择哪种开源许可证,choosealicense.com 提供了一些有用的指导。对于专有项目,通常会指定“proprietary”。你还可以添加特殊的分类器 Private :: Do Not Upload,以防止意外上传到 PyPI。

[project]
license = { text = "proprietary" }
classifiers = [
    "License :: Other/Proprietary License",
    "Private :: Do Not Upload",
]

所需 Python 版本

使用 project.requires-python 字段来指定你的项目支持的 Python 版本。⁸

[project]
requires-python = ">=3.8"

大多数情况下,人们将最低 Python 版本指定为下限,使用格式为 >=3.x 的字符串。此字段的语法更通用,并遵循项目依赖的 版本规范 相同的规则(参见 第四章)。

Nox 和 tox 等工具使得在多个 Python 版本上运行检查变得更加容易,帮助你确保字段反映现实情况。作为基准,我建议要求最旧的仍然接收安全更新的 Python 版本。你可以在 Python 开发者指南 上找到所有当前和过去 Python 版本的生命周期终止日期。

关于 Python 版本更加严格的主要原因有三点。首先,你的代码可能依赖于较新的语言特性——例如,Python 3.10 引入了结构模式匹配。其次,你的代码可能依赖于标准库中的更新特性——请查看官方文档中关于“在 3.x 版本中变更”的说明。第三,可能依赖于对 Python 版本要求更为严格的第三方包。

一些包声明 Python 版本的上限,例如>=3.8,<4。虽然不建议这种做法,但是依赖此类包可能会迫使您为自己的包声明相同的上限。依赖解决器无法在环境中降级 Python 版本;它们要么失败,要么更糟,降级包到带有更宽松 Python 约束的旧版本。未来的 Python 4 不太可能引入像人们从 Python 2 转向 3 时那样的重大更改。

警告

除非您确信您的包与任何更高版本不兼容,否则不要为所需的 Python 版本指定上限。上限会在发布新版本时在生态系统中引起混乱。

依赖和可选依赖

剩余的两个字段,project.dependenciesproject.optional-dependencies,列出了项目依赖的任何第三方包。您将在下一章节更详细地查看这些字段以及依赖关系。

概要

打包允许您发布 Python 项目的版本,使用源分发(sdists)和构建分发(wheels)。这些构件包含您的 Python 模块以及项目元数据,以一种用户可以轻松安装到其环境中的归档格式。标准的pyproject.toml文件定义了 Python 项目的构建系统以及项目元数据。构建前端工具如build、pip 和 uv 使用构建系统信息在隔离环境中安装和运行构建后端。构建后端从源代码树中组装 sdist 和 wheel,并嵌入项目元数据。您可以使用类似 Twine 的工具将包上传到 Python 包索引(PyPI)或私有仓库。Python 项目管理器 Rye 在这些工具之上提供了更集成的工作流程。

¹ 即使是备受尊敬的综合 Perl 存档网络(CPAN),也不存在于 1991 年 2 月,当时 Guido van Rossum 在 Usenet 上发布了 Python 的第一个版本。

² 默认情况下,build工具从 sdist 而不是源代码树构建 wheel,以确保 sdist 有效。构建后端可以使用get_requires_for_build_wheelget_requires_for_build_sdist构建钩子请求额外的构建依赖。

³ Python 的打包生态系统也是康威定律的一个很好的演示。1967 年,Melvin Conway——一位美国计算机科学家,也因开发协程概念而闻名——观察到组织会设计其通信结构的系统副本。

⁴ 特别是考虑到错字滥用——即攻击者上传一个名字类似于流行包的恶意包——以及依赖混淆攻击——即公共服务器上的恶意包使用与私有公司仓库中包名相同的情况。

⁵ 这个巧妙的技巧来自我的审阅者 Hynek Schlawack。

⁶ 如果您好奇的话,+g6b80314 后缀是本地版本标识符,指代下游更改,本例中使用了 git describe 命令的输出。

测试固件 可以设置您需要对代码运行重复测试的对象。

⁸ 您还可以为每个支持的 Python 版本添加 Trove 分类器。某些后端会为您填充分类器——例如,Poetry 对 Python 版本和项目许可证可以直接进行此操作。

第四章:依赖管理

Python 程序员受益于一个丰富的第三方库和工具生态系统。站在巨人的肩膀上是有代价的:你的项目所依赖的包通常也依赖于许多其他包。所有这些都是活动目标——只要任何项目存在,它的维护者就会发布一系列的版本来修复错误、添加功能,并适应不断发展的生态系统。

随着时间的推移,当你维护软件时,管理依赖关系是一个重要的挑战。你需要保持项目的最新状态,即使只是及时关闭安全漏洞。通常,这需要将你的依赖项更新到最新版本——很少有开源项目有资源为旧版本发布安全更新。你会一直更新依赖项!让这个过程尽可能无摩擦、自动化和可靠,会带来巨大的回报。

Python 项目的依赖项是必须在其环境中安装的第三方包。¹最常见的情况是,你因为它分发了一个你导入的模块而产生了对一个包的依赖。我们也说该项目需要一个包。

许多项目还使用第三方工具来进行开发者任务——比如运行测试套件或构建文档。这些包称为开发依赖项:最终用户不需要它们来运行你的代码。一个相关的情况是来自 第三章 的构建依赖项,它们允许你为你的项目创建包。

依赖项就像亲戚一样。如果你依赖于一个包,它的依赖项也是你的依赖项——不管你有多喜欢它们。这些包被称为间接依赖项;你可以把它们想象成一个以你的项目为根的树。

本章将解释如何有效地管理依赖项。在下一节中,您将学习如何在 pyproject.toml 中指定依赖项,作为项目元数据的一部分。之后,我将讨论开发依赖项和要求文件。最后,我会解释如何锁定依赖项以便于可靠的部署和可重复的检查。

向示例应用程序添加依赖项

作为一个工作示例,让我们使用 HTTPX 库来增强 示例 3-1 中的 random-wikipedia-article,这是一个功能齐全的 HTTP 客户端,支持同步和异步请求,以及更新(和更高效)的协议版本 HTTP/2。你还将使用 Rich 改进程序的输出,这是一个用于丰富文本和美观格式的终端库。

使用 HTTPX 消费 API

Wikipedia 要求开发者使用包含联系方式的User-Agent标头。这不是为了让他们可以寄明信片祝贺您熟练使用 Wikipedia API。而是为了在客户端无意中打击他们的服务器时提供联系方式。

示例 4-1 展示了如何使用httpx发送带有标头的请求到 Wikipedia API。您也可以使用标准库发送带有User-Agent标头的请求。但是,即使您不使用其高级功能,httpx也提供了更直观、明确和灵活的接口。试试看:

示例 4-1. 使用httpx消耗 Wikipedia API
import textwrap
import httpx

API_URL = "https://en.wikipedia.org/api/rest_v1/page/random/summary"
USER_AGENT = "random-wikipedia-article/0.1 (Contact: you@example.com)"

def main():
    headers = {"User-Agent": USER_AGENT}

    with httpx.Client(headers=headers) as client: ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
        response = client.get(API_URL, follow_redirects=True) ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)
        response.raise_for_status() ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/3.png)
        data = response.json() ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/4.png)

    print(data["title"], end="\n\n")
    print(textwrap.fill(data["extract"]))

1

在创建客户端实例时,可以指定应该随每个请求发送的标头,如User-Agent标头。将客户端作为上下文管理器使用确保在with块结束时关闭网络连接。

2

此行执行了两个 HTTP GET请求到 API。第一个请求发送到random端点,响应一个重定向到实际文章。第二个请求跟随重定向。

3

raise_for_status方法在服务器响应通过其状态代码指示错误时引发异常。

4

json方法抽象了将响应体解析为 JSON 的细节。

使用 Rich 进行控制台输出

顺便说一句,让我们改进程序的外观和感觉。示例 4-2 使用 Rich 库在粗体中显示文章标题。这只是展示了 Rich 格式选项的冰山一角。现代终端功能强大,而 Rich 让您可以轻松利用它们的潜力。请查看其官方文档获取详细信息。

示例 4-2. 使用 Rich 增强控制台输出
import httpx
from rich.console import Console

def main():
    ...
    console = Console(width=72, highlight=False) ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
    console.print(data["title"], style="bold", end="\n\n") ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)
    console.print(data["extract"])

1

控制台对象提供了一个功能丰富的print方法用于控制台输出。将控制台宽度设置为 72 个字符取代了我们早期对textwrap.fill的调用。您还会希望禁用自动语法高亮,因为您正在格式化散文而不是数据或代码。

2

style关键字允许您使用粗体字设置标题。

为项目指定依赖关系

如果尚未这样做,请为项目创建并激活虚拟环境,并从当前目录执行可编辑安装:

$ uv venv
$ uv pip install --editable .

您可能会尝试手动将httpxrich安装到环境中。相反,请将它们添加到pyproject.toml中的项目依赖项中。这样可以确保每次安装项目时,这两个包都会与之一起安装。

[project]
name = "random-wikipedia-article"
version = "0.1"
dependencies = ["httpx", "rich"]
...

如果重新安装项目,您会发现 uv 也会安装其依赖项:

$ uv pip install --editable .

dependencies 字段中的每个条目都是依赖说明。除了包名称外,它还允许您提供其他信息:版本说明符、额外功能和环境标记。以下各节将详细解释这些内容。

版本说明符

版本说明符 定义了包的可接受版本范围。当您添加新的依赖项时,包括其当前版本作为下限是个好主意——除非您的项目需要与旧版本兼容。每当开始依赖包的新特性时,更新下限。

[project]
dependencies = ["httpx>=0.27.0", "rich>=13.7.1"]

为什么要声明依赖的下限?安装程序默认选择依赖的最新版本。有三个原因需要关注。首先,库通常与其他软件包一起安装,这些软件包可能有额外的版本约束。其次,即使应用程序不总是独立安装——例如,Linux 发行版可能为系统范围的环境打包您的应用程序。第三,下限有助于检测您自己依赖树中的版本冲突——例如,当您需要一个包的最新版本时,但另一个依赖项仅适用于其旧版本时。

避免推测性的上限版本——除非您知道它们与您的项目不兼容,否则不应防范更新版本。关于版本上限的问题,请参见“Python 中的上限版本边界”。

锁文件 是比上限更好的依赖性破坏解决方案——它们在部署服务或运行自动化检查时请求“已知良好”的依赖版本(参见“锁定依赖”)。

如果一个失败的发布破坏了您的项目,请发布一个修复错误的版本以排除该特定的破损版本:

[project]
dependencies = ["awesome>=1.2,!=1.3.1"]

如果依赖项永久性地破坏了兼容性,请作为最后的手段使用上限版本。一旦能够适应您的代码,解除版本限制:

[project]
dependencies = ["awesome>=1.2,<2"]
警告

排除后期版本存在一个需要注意的陷阱。依赖解析器可以决定将您的项目降级到没有排除的版本,并且仍然升级依赖项。锁文件可以帮助解决这个问题。

版本说明符支持多个运算符,如表 4-1 所示。简而言之,请使用您从 Python 中了解的相等和比较运算符:==, !=, <=, >=, <, 和 >

表 4-1. 版本说明符

运算符 名称 描述
== 版本匹配 规范化后版本必须相等。尾随零将被去除。
!= 版本排除 == 运算符的反义词
<=, >= 包含的有序比较 执行词典顺序比较。预发布版本优先于最终版本。
<, > 排他性有序比较 类似于上述,但版本不能相等
~= 兼容版本 等同于>=x.y,==x.*到指定的精度
=== 任意相等性 用于非标准版本的简单字符串比较

三个运算符值得额外讨论:

  • ==运算符支持通配符(*),尽管只能在版本字符串的末尾使用。换句话说,您可以要求版本匹配特定的前缀,比如1.2.*

  • ===运算符允许您执行简单的逐字符比较。最好作为非标准版本的最后一招使用。

  • ~=运算符用于兼容版本的指定,指定版本应大于或等于给定值,同时以相同的前缀开头。例如,~=1.2.3等同于>=1.2.3,==1.2.*,而~=1.2等同于>=1.2,==1.*

你不需要对预发布版本进行保护—版本指定符默认排除它们。只有三种情况下预发布版本才是有效的候选项:当它们已安装时、当没有其他版本满足依赖规范时、以及明确请求它们时,使用像>=1.0.0rc1这样的子句。

额外

假设你想要使用更新的 HTTP/2 协议与httpx。这只需要对创建 HTTP 客户端的代码进行小修改:

def main():
    headers = {"User-Agent": USER_AGENT}
    with httpx.Client(headers=headers, http2=True) as client:
        ...

在幕后,httpx将 HTTP/2 的详细信息委托给另一个包h2。但是,默认情况下不会引入该依赖项。这样,不需要新协议的用户可以得到更小的依赖树。但是在这里你需要它,所以使用语法httpx[http2]来激活可选功能:

[project]
dependencies = ["httpx[http2]>=0.27.0", "rich>=13.7.1"]

需要额外依赖的可选功能称为额外,您可以有多个。例如,您可以指定httpx[http2,brotli]以允许解码使用Brotli 压缩的响应,这是 Google 开发的一种压缩算法,在 Web 服务器和内容传递网络中很常见。

可选依赖项

让我们从httpx的角度看一下这种情况。h2brotli依赖项是可选的,因此httpx将它们声明在optional-dependencies而不是dependencies下(示例 4-3)。

示例 4-3。简化的httpx可选依赖项
[project]
name = "httpx"

[project.optional-dependencies]
http2 = ["h2>=3,<5"]
brotli = ["brotli"]

optional-dependencies字段是一个 TOML 表。它可以容纳多个依赖项列表,每个额外由包提供。每个条目都是一个依赖规范,并且使用与dependencies字段相同的规则。

如果您向项目添加一个可选依赖项,如何在代码中使用它?不要检查是否使用了额外的包—只需导入可选包。如果用户没有请求额外的内容,您可以捕获ImportError异常:

try:
    import h2
except ImportError:
    h2 = None

# Check h2 before use.
if h2 is not None:
    ...

Python 中的一个常见模式——EAFP(Easier to Ask Forgiveness than Permission,宁愿请求原谅,而不是事先征得许可),已经如此常见,以至于它有一个名字和一个缩写。它不那么符合 Python 风格的对应物被称为LBYL(Look Before You Leap,事先审慎而后行)。

环境标记

对于依赖项的第三个元数据,你可以指定环境标记。在我解释这些标记有什么作用之前,让我向你展示一个它们何时非常有用的例子。

如果你查看了 示例 4-1 中的 User-Agent 头部,并想,“我不应该在代码中重复版本号”,你是完全正确的。正如你在 “单一来源项目版本” 中看到的,你可以从环境中的元数据中读取包的版本。

示例 4-4 展示了如何使用函数 importlib.metadata.metadata 从核心元数据字段 NameVersionAuthor-email 构建 User-Agent 头部。这些字段对应于项目元数据中的 nameversionauthors。³

示例 4-4. 使用importlib.metadata构建User-Agent头部
from importlib.metadata import metadata

USER_AGENT = "{Name}/{Version} (Contact: {Author-email})"

def build_user_agent():
    fields = metadata("random-wikipedia-article") ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
    return USER_AGENT.format_map(fields) ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)

def main():
    headers = {"User-Agent": build_user_agent()}
    ...

1

metadata函数用于检索包的核心元数据字段。

2

str.format_map 函数查找映射中的每个占位符。

importlib.metadata库是在 Python 3.8 中引入的。虽然现在它在所有支持的版本中都可用,但并非总是如此。如果你需要支持一个较旧的 Python 版本,那就不那么幸运了。

不完全是。幸运的是,许多标准库的新增功能都有后备——提供老版本解释器功能的第三方包。对于 importlib.metadata,你可以从 PyPI 上回退到 importlib-metadata 后备包。这个后备包依然有用,因为该库在引入后多次改变。

你只需要在使用特定 Python 版本的环境中使用后备包。环境标记允许你将此表达为条件依赖项:

importlib-metadata; python_version < '3.8'

安装程序只会在 Python 3.8 之前的解释器上安装该包。

更一般地说,环境标记 表达了环境必须满足的条件,以便应用该依赖项。安装程序会在目标环境的解释器上评估该条件。

环境标记允许你针对特定的操作系统、处理器架构、Python 实现或 Python 版本请求依赖项。表 4-2 列出了所有可用的环境标记,如 PEP 508 中所指定的。⁴

表 4-2. 环境标记^(a)

环境标记 标准库 描述 示例
os_name os.name() 操作系统家族 posix, nt
sys_platform sys.platform() 平台标识符 linux, darwin, win32
platform_system platform.system() 系统名称 Linux, Darwin, Windows
platform_release platform.release() 操作系统版本 23.2.0
platform_version platform.version() 系统版本 Darwin Kernel Version 23.2.0: ...
platform_machine platform.machine() 处理器架构 x86_64, arm64
python_version platform.python_version_tuple() 格式为 x.y 的 Python 特性版本 3.12
python_full_version platform.python_version() 完整的 Python 版本 3.12.0, 3.13.0a4
platform_python_implementation platform.python_implementation() Python 实现 CPython, PyPy
implementation_name sys.implementation.name Python 实现 cpython, pypy
implementation_version sys.implementation.version Python 实现版本 3.12.0, 3.13.0a4
^(a) python_versionimplementation_version 标记应用转换。详细信息请参阅 PEP 508。

回到 Example 4-4,这里是带有 importlib-metadatadependencies 字段的完整示例:

[project]
dependencies = [
    "httpx[http2]>=0.27.0",
    "rich>=13.7.1",
    "importlib-metadata>=7.0.2; python_version < '3.8'",
]

后备的导入名称为 importlib_metadata,而标准库模块名为 importlib.metadata。您可以通过检查 sys.version_info 中的 Python 版本,在代码中导入适当的模块:

if sys.version_info >= (3, 8):
    from importlib.metadata import metadata
else:
    from importlib_metadata import metadata

我刚才听见有人喊“EAFP”了吗?如果您的导入依赖于 Python 版本,最好避免使用 “Optional dependencies” 和 “look before you leap” 技术。显式版本检查能够向静态分析工具(例如 mypy 类型检查器,见 第十章)传达您的意图。由于无法检测每个模块的可用性,EAFP 可能会导致这些工具报错。

标记支持与版本规范符号相同的相等和比较运算符(见 Table 4-1)。此外,您可以使用 innot in 来匹配标记中的子字符串。例如,表达式 'arm' in platform_version 检查 platform.version() 是否包含字符串 'arm'

您还可以使用布尔运算符 andor 结合多个标记。以下是一个相当牵强的示例,结合了所有这些特性:

[project]
dependencies = [""" \
 awesome-package; python_full_version <= '3.8.1' \
 and (implementation_name == 'cpython' or implementation_name == 'pypy') \
 and sys_platform == 'darwin' \
 and 'arm' in platform_version \
"""]

该示例还依赖于 TOML 对多行字符串的支持,使用与 Python 相同的三重引号。依赖规范不能跨越多行,因此您必须使用反斜杠转义换行符。

开发依赖

开发依赖是你在开发过程中需要的第三方软件包。作为开发者,你可能会使用 pytest 测试框架来运行项目的测试套件,Sphinx 文档系统来构建其文档,或者其他一些工具来帮助项目维护。另一方面,你的用户不需要安装这些软件包来运行你的代码。

一个示例:使用 pytest 进行测试

以一个具体例子来说,让我们为示例 4-4 中的build_user_agent函数添加一个小测试。创建一个包含两个文件的目录tests:一个空的init.py和一个模块test_random_wikipedia_article.py,其中包含来自示例 4-5 的代码。

示例 4-5. 测试生成的User-Agent标头
from random_wikipedia_article import build_user_agent

def test_build_user_agent():
    assert "random-wikipedia-article" in build_user_agent()

示例 4-5 仅使用内置的 Python 功能,因此你可以只是导入并手动运行测试。但即使对于这个小测试,pytest 也添加了三个有用的功能。首先,它发现名称以test开头的模块和函数,因此你可以通过调用pytest而不带参数来运行测试。其次,pytest 在执行测试时显示测试,并在最后显示带有测试结果的摘要。第三,pytest 重写测试中的断言,以便在失败时提供友好且信息丰富的消息。

让我们使用 pytest 运行测试。我假设你已经有了一个带有可编辑安装的活动虚拟环境。在该环境中输入以下命令来安装和运行 pytest:

$ uv pip install pytest
$ py -m pytest
========================= test session starts ==========================
platform darwin -- Python 3.12.2, pytest-8.1.1, pluggy-1.4.0
rootdir: ...
plugins: anyio-4.3.0
collected 1 item

tests/test_random_wikipedia_article.py .                          [100%]

========================== 1 passed in 0.22s ===========================

目前看起来一切都很好。测试有助于项目在不破坏功能的情况下发展。对于build_user_agent的测试是朝这个方向迈出的第一步。与这些长期利益相比,安装和运行 pytest 只是一个小的基础设施成本。

随着你获得更多的开发依赖项,设置项目环境变得更加困难——文档生成器、代码检查器、代码格式化程序、类型检查器或其他工具。即使你的测试套件可能需要比 pytest 更多的东西:pytest 的插件、用于测量代码覆盖率的工具,或者只是帮助你练习代码的包。

你还需要这些软件包的兼容版本——你的测试套件可能需要最新版本的 pytest,而你的文档可能无法在新的 Sphinx 发布版上构建。每个项目可能有稍微不同的要求。将这些乘以每个项目上工作的开发人员数量,就会清楚地表明你需要一种跟踪开发依赖关系的方式。

截至目前为止,Python 没有声明项目开发依赖项的标准方法——尽管许多 Python 项目管理工具在它们的[tool]表中支持它们,并且有一个草案 PEP 存在。⁵ 除了项目管理工具外,人们使用两种方法来填补这一空白:可选依赖和要求文件。

可选依赖项

正如你在“额外内容”中看到的,optional-dependencies 表格包含了命名为额外内容的可选依赖项组。它具有使其适合跟踪开发依赖项的三个属性。首先,默认情况下不会安装这些包,因此最终用户不会在其 Python 环境中污染它们。其次,它允许你将包分组到有意义的名称下,例如 testsdocs。第三,该字段具有完整的依赖关系规范的表达性,包括版本约束和环境标记。

另一方面,开发依赖项和可选依赖项之间存在阻抗不匹配。可选依赖项通过包元数据向用户公开—它们让用户选择需要额外包的功能。相比之下,用户不应该安装开发依赖项—这些包不需要任何用户可见的功能。

此外,你无法在没有项目本身的情况下安装额外的内容。相比之下,并不是所有的开发工具都需要安装你的项目。例如,代码检查器会分析你的源代码中的错误和潜在改进。你可以在不将项目安装到环境中的情况下运行它们。除了浪费时间和空间外,“臃肿”的环境还会不必要地限制依赖关系的解析。例如,当 Flake8 代码检查器对 importlib-metadata 设置版本限制时,许多 Python 项目就无法再升级重要的依赖项。

特别要记住的是,额外内容被广泛用于开发依赖项,并且是包装标准涵盖的唯一方法。它们是一种实用的选择,特别是如果你使用 pre-commit 管理代码检查器(见第九章)。示例 4-6 展示了如何使用额外内容来跟踪测试和文档所需的包。

示例 4-6:使用额外内容表示开发依赖项
[project.optional-dependencies]
tests = ["pytest>=8.1.1", "pytest-sugar>=1.0.0"] ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
docs = ["sphinx>=7.2.6"] ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)

1:共依赖管理

pytest-sugar 插件可以增强 pytest 的输出,添加了进度条并立即显示失败情况。

2:共依赖管理

Sphinx 是官方 Python 文档和许多开源项目使用的文档生成器。

现在你可以使用 tests 额外内容安装测试依赖项了:

$ uv pip install -e ".[tests]"
$ py -m pytest

你还可以定义一个包含所有开发依赖项的 dev 额外内容。这样,你可以一次性设置一个开发环境,包括你的项目和它使用的每个工具:

$ uv pip install -e ".[dev]"

当你定义 dev 时,没有必要重复所有的包。相反,你可以只引用其他的额外内容,就像示例 4-7 所示。

示例 4-7:提供了一个包含所有开发依赖项的 dev 额外内容
[project.optional-dependencies]
tests = ["pytest>=8.1.1", "pytest-sugar>=1.0.0"]
docs = ["sphinx>=7.2.6"]
dev = ["random-wikipedia-article[tests,docs]"]

声明额外内容的这种风格也被称为递归可选依赖,因为具有 dev 额外内容的包依赖于自身(具有 testsdocs 额外内容)。

需求文件

Requirements 文件是带有每行依赖规范的纯文本文件(Example 4-8)。此外,它们可以包含 URL 和路径,可选地以-e为前缀进行可编辑安装,以及全局选项,如-r用于包含另一个 requirements 文件或--index-url用于使用除 PyPI 以外的包索引。该文件格式还支持 Python 风格的注释(以#字符开头)和行继续(以\字符结尾)。

示例 4-8. 一个简单的 requirements.txt 文件。
pytest>=8.1.1
pytest-sugar>=1.0.0
sphinx>=7.2.6

您可以使用 pip 或 uv 安装 requirements 文件中列出的依赖项。

$ uv pip install -r requirements.txt

根据惯例,一个需求文件通常命名为requirements.txt。但是,变种是很常见的。你可能有一个dev-requirements.txt用于开发依赖,或者一个requirements目录,每个依赖组有一个文件(Example 4-9)。

示例 4-9. 使用 requirements 文件指定开发依赖。
# requirements/tests.txt
-e . ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
pytest>=8.1.1
pytest-sugar>=1.0.0

# requirements/docs.txt
sphinx>=7.2.6 ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)

# requirements/dev.txt
-r tests.txt ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/3.png)
-r docs.txt

1

tests.txt文件需要项目的可编辑安装,因为测试套件需要导入应用程序模块。

2

docs.txt文件不需要项目。(这是假设您仅从静态文件构建文档。如果您使用autodoc Sphinx 扩展从代码中的文档字符串生成 API 文档,则还需要在此处添加项目。)

3

dev.txt文件包含其他 requirements 文件。

注意。

如果使用-r包含其他 requirements 文件,则它们的路径相对于包含文件进行评估。相比之下,依赖项的路径是相对于您当前的目录进行评估的,通常是项目目录。

创建并激活虚拟环境,然后运行以下命令以安装开发依赖项并运行测试套件:

$ uv pip install -r requirements/dev.txt
$ py -m pytest

Requirements 文件不是项目元数据的一部分。您可以通过版本控制系统与其他开发人员共享它们,但它们对于您的用户来说是不可见的。对于开发依赖项来说,这正是您想要的。此外,requirements 文件不会隐式地将项目包含在依赖项中。这减少了不需要安装项目的所有任务所需的时间。

Requirements 文件也有缺点。它们不是一个打包的标准,也不太可能成为一个——requirements 文件的每一行基本上是传递给pip install的一个参数。“pip 会做什么”可能仍然是 Python 打包中许多边缘情况的潜规则,但社区标准越来越多地取代了它。另一个缺点是,与pyproject.toml中的表格相比,这些文件在项目中造成了混乱。

如上所述,Python 项目管理器允许你在pyproject.toml中声明依赖组,超出了项目元数据—​Rye、Hatch、PDM 和 Poetry 都提供了这一功能。查看第五章以了解 Poetry 的依赖组描述。

锁定依赖关系

你已经在本地环境或持续集成(CI)中安装了依赖项,并运行了测试套件和其他检查。一切看起来都很好,你准备部署你的代码了。但是,如何在生产环境中安装与你运行检查时使用的相同包?

在开发和生产中使用不同的包装载具有后果。生产可能最终会得到一个与你的代码不兼容的包,或者有缺陷或安全漏洞的包,甚至—​在最坏的情况下—​被攻击者劫持的包。如果你的服务曝光度很高,这种情况令人担忧—​而且它可能涉及依赖树中的任何包,而不仅仅是你直接导入的包。

警告

供应链攻击通过针对其第三方依赖项来渗透系统。例如,2022 年,“JuiceLedger”威胁行动者通过网络钓鱼活动篡改了合法的 PyPI 项目并上传了恶意包装载。⁶

环境在给定相同依赖规范的情况下最终以不同包装载的原因有很多。其中大部分原因可以归类为两类:上游更改和环境不匹配。首先,如果可用包的集合发生上游更改,你可能会得到不同的包:

  • 在你部署之前发布了新版本。

  • 为现有版本上传了一个新的工件。例如,当新的 Python 版本发布时,维护者有时会上传额外的轮子。

  • 维护者删除或取消发布或工件。取消发布是一种软删除,它会隐藏文件以防止依赖解析,除非你明确请求它。

其次,如果你的开发环境与生产环境不匹配,你可能会得到不同的包装载:

  • 环境标记在目标解释器上的评估有所不同(参见“环境标记”)。例如,生产环境可能使用一个旧的 Python 版本,需要像importlib-metadata这样的后移植。

  • 轮子兼容性标签可能会导致安装程序为同一个包选择不同的轮子(参见“轮子兼容性标签”)。例如,如果你在配有苹果硅芯的 Mac 上开发,而生产环境使用 x86-64 架构的 Linux,则可能会发生这种情况。

  • 如果发布不包括目标环境的轮子,安装程序会即时从源分布(sdist)构建它。当新的 Python 版本推出时,扩展模块的轮子通常会滞后。

  • 如果环境不使用相同的安装程序(或者使用不同版本的相同安装程序),每个安装程序可能会以不同的方式解析依赖关系。例如,uv 使用 PubGrub 算法进行依赖关系解析,⁷ 而 pip 使用回溯解析器用于 Python 包,resolvelib

  • 工具配置或状态也可能导致不同的结果——例如,您可能从不同的软件包索引或本地缓存中安装。

您需要一种定义应用程序所需确切包集合的方法,并且您希望其环境是该包清单的确切映像。这个过程称为锁定,或者固定,列在锁定文件中的项目依赖项。

到目前为止,我已经谈论了为了可靠和可复制的部署而锁定依赖关系。锁定在开发过程中也很有益处,无论是应用程序还是库。通过与团队和贡献者共享锁定文件,您使每个开发人员在运行测试套件、构建文档或执行其他任务时使用相同的依赖项。在强制检查中使用锁定文件可避免出现在本地通过后,在 CI 中检查失败的情况。为了获得这些好处,锁定文件还必须包含开发依赖项。

就目前而言,Python 缺乏用于锁定文件的打包标准——尽管这个话题正在积极考虑中。⁸ 与此同时,许多 Python 项目管理器,如 Poetry、PDM 和 pipenv,已经实现了自己的锁定文件格式;而其他一些项目,如 Rye,则使用要求文件来锁定依赖项。

在本节中,我将介绍使用要求文件锁定依赖项的两种方法:冻结编译要求。在第五章中,我将描述 Poetry 的锁定文件。

使用 pip 和 uv 冻结要求

要求文件是锁定依赖项的流行格式。它们允许您将依赖信息与项目元数据分开。Pip 和 uv 可以从现有环境生成这些文件:

$ uv pip install .
$ uv pip freeze
anyio==4.3.0
certifi==2024.2.2
h11==0.14.0
h2==4.1.0
hpack==4.0.0
httpcore==1.0.4
httpx==0.27.0
hyperframe==6.0.1
idna==3.6
markdown-it-py==3.0.0
mdurl==0.1.2
pygments==2.17.2
random-wikipedia-article @ file:///Users/user/random-wikipedia-article
rich==13.7.1
sniffio==1.3.1

对环境中安装的软件包进行清单的操作被称为冻结。将列表存储在requirements.txt中,并将文件提交到源代码控制——只有一个更改:将文件 URL 替换为当前目录的点。这样,只要您在项目目录内,就可以在任何地方使用要求文件。

当将项目部署到生产环境时,您可以像这样安装项目及其依赖项:

$ uv pip install -r requirements.txt

假设您的开发环境使用的是最新的解释器,那么要求文件中不会列出 importlib-metadata——该库仅在 Python 3.8 之前才需要。如果您的生产环境运行的是古老的 Python 版本,您的部署将会失败。这里有一个重要的教训:在与生产环境匹配的环境中锁定您的依赖项。

小贴士

锁定依赖关系与用于生产的相同 Python 版本、Python 实现、操作系统和处理器架构。如果部署到多个环境,请为每个环境生成一个需求文件。

冻结需求有一些限制。首先,每次刷新需求文件时都需要安装依赖项。其次,如果临时安装一个软件包并忘记从头开始创建环境,则很容易意外污染需求文件。第三,冻结不允许记录软件包哈希 —— 它仅仅是对环境进行清单的记录,而环境不记录安装在其中的软件包的哈希(我将在下一节介绍软件包哈希)。

使用 pip-tools 和 uv 编译需求

pip-tools 项目使您能够在不受这些限制的情况下锁定依赖关系。您可以直接从 pyproject.toml 编译需求,而无需安装软件包。在底层,pip-tools 利用 pip 及其依赖解析器。

Pip-tools 提供两个命令:pip-compile,从依赖规范创建需求文件;pip-sync,将需求文件应用到现有环境。uv 工具提供了这两个命令的替代方案:uv pip compileuv pip sync

在与项目目标环境匹配的环境中运行 pip-compile。如果使用 pipx,请指定目标 Python 版本:

$ pipx run --python=3.12 --spec=pip-tools pip-compile

默认情况下,pip-compilepyproject.toml 读取,并写入 requirements.txt。您可以使用 --output-file 选项指定不同的目标。该工具还将需求打印到标准错误,除非您指定 --quiet 关闭终端输出。

Uv 要求您明确指定输入和输出文件:

$ uv pip compile --python-version=3.12 pyproject.toml -o requirements.txt

Pip-tools 和 uv 为每个依赖包注释文件,指示依赖关系以及用于生成文件的命令。与 pip freeze 的输出还有一个区别:编译后的需求文件不包括您自己的项目。您需要在应用需求文件后单独安装它。

需求文件允许您为每个依赖项指定软件包哈希。这些哈希为您的部署增加了另一层安全性:它们使您只能在生产中安装经过审查的包装成品。选项 --generate-hashes 包含需求文件中每个软件包的 SHA256 哈希。例如,以下是 httpx 发布的 sdist 和 wheel 文件的哈希:

httpx==0.27.0 \
--hash=sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5 \
--hash=sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5

软件包哈希使安装更加确定性和可重现。它们还是需要筛选每个进入生产的工件的组织中的重要工具。验证软件包的完整性可以防止“中间人”攻击,在此攻击中,威胁行为者拦截软件包下载以提供已篡改的工件。

哈希值还有一个副作用,即 pip 拒绝安装没有哈希值的软件包:要么所有软件包都有哈希值,要么没有。因此,哈希值保护您免受安装未列在需求文件中的文件的影响。

在目标环境中使用 pip 或 uv 安装需求文件,然后再安装项目本身。您可以通过几个选项加固安装:选项 --no-deps 确保您只安装需求文件中列出的软件包,选项 --no-cache 防止安装程序重复使用下载或本地构建的文件。

$ uv pip install -r requirements.txt
$ uv pip install --no-deps --no-cache .

定期更新依赖项。对于生产环境中运行的成熟应用程序,每周更新一次可能是可以接受的。对于正在积极开发的项目,每天更新甚至在发布后立即更新可能更合适。Dependabot 和 Renovate 等工具可帮助完成这些工作:它们在您的存储库中打开拉取请求,以自动升级依赖项。

如果您不定期升级依赖项,您可能会被迫在时间紧迫的情况下进行“大爆炸”升级。单个安全漏洞可能会迫使您将项目移植到多个软件包的主要版本以及 Python 本身的最新版本。

您可以一次性升级所有依赖项,也可以逐个依赖项升级。使用 --upgrade 选项将所有依赖项升级到它们的最新版本,或者使用 --upgrade-package 选项(-P)传递特定软件包。

例如,这是您如何升级 Rich 到最新版本的方式:

$ uv pip compile -p 3.12 pyproject.toml -o requirements.txt -P rich

到目前为止,您已从头开始创建了目标环境。您还可以使用 pip-sync 同步目标环境与更新后的需求文件。为此不要在目标环境中安装 pip-tools:其依赖可能与您项目的依赖发生冲突。而是像您在 pip-compile 中使用 pipx 一样,使用 --python-executable 选项将 pip-sync 指向目标解释器:

$ pipx run --spec=pip-tools pip-sync --python-executable=.venv/bin/python

该命令会删除项目本身,因为它没有列在需求文件中。在同步后重新安装它:

$ .venv/bin/python -m pip install --no-deps --no-cache .

Uv 默认使用 .venv 中的环境,因此您可以简化这些命令:

$ uv pip sync requirements.txt
$ uv pip install --no-deps --no-cache .

在 “开发依赖项” 中,您看到了声明开发依赖项的两种方式:extras 和需求文件。Pip-tools 和 uv 都支持它们作为输入。如果您在 dev extra 中跟踪开发依赖项,请像这样生成 dev-requirements.txt 文件:

$ uv pip compile --extra=dev pyproject.toml -o dev-requirements.txt

如果您有更精细化的 extras,流程是相同的。您可能希望将需求文件存储在 requirements 目录中,以避免混乱。

如果您将开发依赖项指定为需求文件而不是 extras,在此顺序编译每个文件。按照惯例,输入需求使用 .in 扩展名,而输出需求使用 .txt 扩展名(示例 4-10)。

示例 4-10. 开发依赖项的输入需求
# requirements/tests.in
pytest>=8.1.1
pytest-sugar>=1.0.0

# requirements/docs.in
sphinx>=7.2.6

# requirements/dev.in
-r tests.in
-r docs.in

与 示例 4-9 不同,输入的需求列表不包括项目本身。如果包括,输出的需求会包含项目的路径—每位开发者的路径可能不同。相反,将 pyproject.toml 与输入需求一起传递以锁定整套依赖项:

$ uv pip compile requirements/tests.in pyproject.toml -o requirements/tests.txt
$ uv pip compile requirements/docs.in -o requirements/docs.txt
$ uv pip compile requirements/dev.in pyproject.toml -o requirements/dev.txt

安装输出需求后,请记得安装项目。

到底为什么要编译 dev.txt 呢?它不能仅包括 docs.txttests.txt 吗?如果你分别安装已锁定的需求,它们可能会冲突。让依赖解析器看到完整的情况。如果你传递所有输入需求,它将给你一个一致的依赖树作为回报。

表 4-3 总结了本章中您看到的 pip-compile 的命令行选项:

表 4-3. pip-compile 的选定命令行选项

选项 描述
--generate-hashes 为每个打包工件包含 SHA256 哈希值
--output-file 指定目标文件
--quiet 不要将需求打印到标准错误输出
--upgrade 将所有依赖项升级到它们的最新版本
--upgrade-package=*<package>* 将特定包升级到其最新版本
--extra=*<extra>* pyproject.toml 中包含给定额外依赖项

摘要

在本章中,您学习了如何使用 pyproject.toml 声明项目依赖关系,以及如何使用额外项或需求文件声明开发依赖关系。您还学习了如何使用 pip-tools 锁定依赖项以实现可靠的部署和可重现的检查。在下一章中,您将看到项目管理器 Poetry 如何使用依赖组和锁文件来帮助依赖管理。

¹ 从更广泛的意义上讲,项目的依赖包括所有用户运行其代码所需的所有软件包—包括解释器、标准库、第三方包和系统库。Conda 和像 APT、DNF 和 Homebrew 这样的发行级包管理器支持这种泛化的依赖概念。

² Henry Schreiner: “是否应使用上限版本约束?”, 2021 年 12 月 9 日。

³ 为简单起见,代码不处理多个作者—头部最终显示的作者未定义。

⁴ Robert Collins: “PEP 508 – Python 软件包的依赖规范”, 2015 年 11 月 11 日。

⁵ Stephen Rosen: “PEP 735 – pyproject.toml 中的依赖组”, 2023 年 11 月 20 日。

⁶ Dan Goodin: “PyPI 供应链攻击背后的行动者自 2021 年末以来一直活跃,” 2022 年 9 月 2 日。

⁷ Natalie Weizenbaum: “PubGrub: 下一代版本解决方案,” 2018 年 4 月 2 日

⁸ Brett Cannon: “再次谈论锁定文件(但这次包括 sdists!),” 2024 年 2 月 22 日。

⁹ 卸载软件包并不足够:安装可能会对您的依赖树产生副作用。例如,它可能会升级或降级其他软件包,或者引入额外的依赖关系。

第五章:管理使用 Poetry 的项目

前几章介绍了发布高质量 Python 包的基础模块。到目前为止,你为项目编写了一个pyproject.toml;创建了一个环境,并使用 uv、pip 或 pip-tools 安装了依赖;并使用build和 Twine 构建并发布了软件包。

通过标准化项目元数据和构建后端,pyproject.toml打破了 setuptools 的垄断(见“Python 项目管理器的演变”),并为打包生态系统带来了多样性。定义 Python 包也变得更简单:一个明确定义的文件配合优秀的工具支持,取代了setup.py及不计其数的配置文件的传统模板。

然而,仍然存在一些问题。

在你可以开始处理基于pyproject.toml的项目之前,你需要研究打包工作流程、配置文件以及相关工具。你必须从多个可用的构建后端中选择一个(见表 3-2)—而很多人不知道这些是什么,更不用说如何选择了。Python 包的重要方面仍然未指明—例如,项目源代码的布局及哪些文件应包含在打包工件中。

依赖和环境管理也可以更简单。你需要手工制作你的依赖规范,并使用 pip-tools 编译它们,使你的项目混乱不堪。在典型的开发者系统上跟踪多个 Python 环境可能很困难。

pyproject.toml的标准规范制定之前,Python 项目管理器 Poetry 已经在解决这些问题。它友好的命令行界面让你可以执行大多数与打包、依赖和环境相关的任务。Poetry 带来了符合标准的构建后端poetry.core—但你可以毫不知情地继续使用。它还配备了严格的依赖解析器,并默认锁定所有依赖项,所有这些都在幕后进行。

如果 Poetry 在很大程度上将这些细节抽象化掉,为什么要学习打包标准和底层管道?因为虽然 Poetry 在新领域中大展拳脚,但它仍在打包标准定义的框架内运作。像依赖规范和虚拟环境这样的机制支持其核心功能。互操作性标准使 Poetry 能够与软件包存储库以及其他构建后端和包安装程序进行交互。

对这些底层机制的理解有助于你调试情况,例如 Poetry 方便的抽象出现问题时—例如,当配置错误或错误导致软件包进入错误的环境时。最后,过去几十年的经验告诉我们,工具来了又走,而标准和算法却是长存的。

安装 Poetry

使用 pipx 全局安装 Poetry,以使其依赖与系统其余部分隔离开来:

$ pipx install poetry

单个 Poetry 安装可以与多个 Python 版本一起使用。但是,Poetry 默认使用其自己的解释器作为默认 Python 版本。因此,建议在最新稳定的 Python 发行版上安装 Poetry。安装新的 Python 功能版本时,请像这样重新安装 Poetry:

$ pipx reinstall --python=3.12 poetry

如果 pipx 已经使用新的 Python 版本,请忽略 --python 选项(见“配置 Pipx”)。

当 Poetry 的预发行版可用时,您可以将其与稳定版本并行安装:

$ pipx install poetry --suffix=@preview --pip-args=--pre

上述示例中,我使用了 --suffix 选项来重命名命令,因此您可以将其称为 poetry@preview,同时保持 poetry 为稳定版本。--pip-args 选项允许您将选项传递给 pip,例如用于包括预发行版的 --pre

注意

Poetry 还提供了一个官方安装程序,您可以使用 Python 下载并运行。虽然不像 pipx 那样灵活,但提供了一个现成的替代方案:

$ curl -sSL https://install.python-poetry.org | python3 -

定期升级 Poetry 以获取改进和错误修复:

$ pipx upgrade poetry

在终端上输入 poetry 来检查 Poetry 的安装情况。Poetry 会打印其版本和用法,包括所有可用子命令的有用列表。

$ poetry

成功安装 Poetry 后,您可能希望为您的 shell 启用选项卡完成。使用命令 poetry help completions 获取特定于 shell 的说明。例如,以下命令在 Bash shell 中启用选项卡完成:

$ poetry completions bash >> ~/.bash_completion
$ echo ". ~/.bash_completion" >> ~/.bashrc

重启您的 shell 以使更改生效。

创建项目

您可以使用命令 poetry new 创建一个新项目。作为示例,我将使用前几章中的 random-wikipedia-article 项目。在您想要保存新项目的父目录中运行以下命令:

$ poetry new --src random-wikipedia-article

运行此命令后,您将看到 Poetry 创建了一个名为 random-wikipedia-article 的项目目录,其结构如下:

random-wikipedia-article
├── README.md
├── pyproject.toml
├── src
│   └── random_wikipedia_article
│       └── __init__.py
└── tests
    └── __init__.py

--src 选项指示 Poetry 将导入包放置在名为 src 的子目录中,而不是直接放在项目目录中。

让我们来看看生成的 pyproject.toml 文件(示例 5-1):

示例 5-1. Poetry 的 pyproject.toml 文件
[tool.poetry]
name = "random-wikipedia-article"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]
readme = "README.md"
packages = [{include = "random_wikipedia_article", from = "src"}]

[tool.poetry.dependencies]
python = "³.12"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

Poetry 使用其构建后端 poetry.core 创建了一个标准的 build-system 表。这意味着任何人都可以使用 pip 或 uv 从源代码安装您的项目,无需设置或了解 Poetry 项目管理器。同样,您可以使用任何标准的构建前端(例如 build)构建包:

$ pipx run build
* Creating venv isolated environment...
* Installing packages in isolated environment... (poetry-core)
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel from sdist
* Creating venv isolated environment...
* Installing packages in isolated environment... (poetry-core)
* Getting build dependencies for wheel...
* Building wheel...
Successfully built random_wikipedia_article-0.1.0.tar.gz
 and random_wikipedia_article-0.1.0-py3-none-any.whl

项目元数据

你可能会惊讶地看到项目元数据出现在tool.poetry下,而不是熟悉的project表(参见“项目元数据”)。Poetry 项目计划在其下一个主要发布版中支持项目元数据标准。¹ 正如您在表格 5-1 中所见,大多数字段具有相同的名称、类似的语法和含义。

示例 5-2 填写了项目的元数据。我突出了与示例 3-4 的一些不同之处。(稍后您将使用命令行界面添加依赖项。)

示例 5-2. Poetry 项目的元数据
[tool.poetry]
name = "random-wikipedia-article"
version = "0.1.0"
description = "Display extracts from random Wikipedia articles"
keywords = ["wikipedia"]
license = "MIT" ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
classifiers = [
    "License :: OSI Approved :: MIT License",
    "Development Status :: 3 - Alpha",
    "Environment :: Console",
    "Topic :: Games/Entertainment :: Fortune Cookies",
]
authors = ["Your Name <you@example.com>"] ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)
readme = "README.md" ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/3.png)
homepage = "https://yourname.dev/projects/random-wikipedia-article" ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/4.png)
repository = "https://github.com/yourname/random-wikipedia-article"
documentation = "https://readthedocs.io/random-wikipedia-article"
packages = [{include = "random_wikipedia_article", from = "src"}]

[tool.poetry.dependencies]
python = ">=3.10" ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/5.png)

[tool.poetry.urls]
Issues = "https://github.com/yourname/random-wikipedia-article/issues"

[tool.poetry.scripts]
random-wikipedia-article = "random_wikipedia_article:main"

1

license字段是一个带有 SPDX 标识符的字符串,而不是表格。

2

authors字段包含格式为"name <email>"的字符串,而不是表格。Poetry 会从 Git 中预填充此字段的姓名和电子邮件地址。

3

readme字段是一个字符串,其中包含文件路径。您还可以将多个文件指定为字符串数组,例如README.mdCHANGELOG.md。Poetry 将它们之间用空行连接起来。

4

Poetry 专门为一些项目 URL 提供了字段,即其主页、存储库和文档;对于其他 URL,还有一个通用的urls表。

5

dependencies中的python条目允许您声明兼容的 Python 版本。对于这个项目,您需要 Python 3.10 或更高版本。

表格 5-1. tool.poetry中的元数据字段

字段 类型 描述 project字段
name string 项目名称 name
version string 项目的版本号 version
description string 项目的简要描述 description
keywords 字符串数组 项目的关键词列表 keywords
readme string 或字符串数组 项目描述文件或文件列表 readme
license string SPDX 许可证标识符,或“Proprietary” license
authors 字符串数组 作者列表 authors
maintainers 字符串数组 维护者列表 maintainers
classifiers 字符串数组 描述项目的分类器列表 classifiers
homepage string 项目主页的 URL urls
repository string 项目存储库的 URL urls
documentation string 项目文档的 URL urls
urls 字符串表格 项目的 URL 列表 urls
dependencies 字符串或表格的数组 所需第三方包的列表 dependencies
extras 字符串或表的数组的表 可选的第三方包的命名列表 optional-dependencies
groups 字符串或表的数组的表 开发依赖项的命名列表 none
scripts 字符串或表的数组的表 入口点脚本 scripts
plugins 表的表的字符串 入口点组 entry-points

tool.poetry 下的一些 project 字段没有直接等价项:

  • 没有 requires-python 字段;相反,您可以使用 dependencies 表中的 python 键指定所需的 Python 版本。

  • 没有专门的 GUI 脚本字段;请改用 plugins.gui_scripts

  • 没有 dynamic 字段—​所有元数据都是特定于 Poetry 的,因此声明动态字段没有多大意义。

在我们继续之前,让我们确保 pyproject.toml 文件是有效的。Poetry 提供了一个方便的命令,可以针对其配置模式验证 TOML 文件:

$ poetry check
All set!

包内容

Poetry 允许您指定要包含在分发中的文件和目录,这是 pyproject.toml 标准中仍然缺失的功能(表 5-2)。

表 5-2. tool.poetry 中的包内容字段

Field Type Description
packages 表的数组 分发中要包含的模块的模式
include 字符串或表的数组 分发中要包含的文件的模式
exclude 字符串或表的数组 分发中要排除的文件的模式

packages 下的每个表都有一个 include 键,指定文件或目录。您可以在名称和路径中使用 *** 通配符。 from 键允许您包含来自子目录(如 src)的模块。最后,您可以使用 format 键限制模块到特定的分发格式;有效值为 sdistwheel

includeexclude 字段允许您列出要包含在分发中或从分发中排除的其他文件。如果存在 .gitignore 文件,Poetry 将使用该文件填充 exclude 字段。除了字符串外,还可以使用带有 pathformat 键的表,仅适用于 sdist 或 wheel 文件。 示例 5-3 展示了如何在源分发中包含测试套件。

示例 5-3. 将测试套件包含在源分发中
packages = [{include = "random_wikipedia_article", from = "src"}]
include  = [{path = "tests", format = "sdist"}]

源代码

将 示例 5-4 的内容复制到新项目的 init.py 文件中。

示例 5-4. random-wikipedia-article 的源代码
import httpx
from rich.console import Console

from importlib.metadata import metadata

API_URL = "https://en.wikipedia.org/api/rest_v1/page/random/summary"
USER_AGENT = "{Name}/{Version} (Contact: {Author-email})"

def main():
    fields = metadata("random-wikipedia-article")
    headers = {"User-Agent": USER_AGENT.format_map(fields)}

    with httpx.Client(headers=headers, http2=True) as client:
        response = client.get(API_URL, follow_redirects=True)
        response.raise_for_status()
        data = response.json()

    console = Console(width=72, highlight=False)
    console.print(data["title"], style="bold", end="\n\n")
    console.print(data["extract"])

pyproject.tomlscripts 部分中声明了一个入口点脚本,因此用户可以使用 random-wikipedia-article 调用应用程序。如果您希望用户还可以使用 py -m random_wikipedia_article 调用程序,请像 示例 3-3 中显示的那样,创建一个与 init.py 并列的 main.py 模块。

管理依赖项

让我们为random-wikipedia-article添加依赖项,首先是控制台输出库 Rich:

$ poetry add rich
Using version ¹³.7.1 for rich

Updating dependencies
Resolving dependencies... (0.2s)

Package operations: 4 installs, 0 updates, 0 removals

  - Installing mdurl (0.1.2)
  - Installing markdown-it-py (3.0.0)
  - Installing pygments (2.17.2)
  - Installing rich (13.7.1)

Writing lock file

如果你运行这个命令后检查pyproject.toml,你会发现 Poetry 已经将 Rich 添加到dependencies表中(见示例 5-5):

示例 5-5. 添加 Rich 后的dependencies
[tool.poetry.dependencies]
python = ">=3.10"
rich = "¹³.7.1"

Poetry 还会将包安装到项目的环境中。如果你已经有一个名为.venv的虚拟环境,Poetry 会使用它。否则,它会在一个共享位置创建一个虚拟环境(参见“管理环境”)。

脱字符约束

脱字符(^)是版本指定器的 Poetry 特定扩展,从 Node.js 的包管理器 npm 借鉴而来。脱字符约束允许以给定的最低版本发布,除了可能包含破坏性更改的版本,符合语义化版本规范。在1.0.0之后,脱字符约束允许补丁版本和次要版本的发布,但不允许主要版本的发布。在0.*时代之前,只允许补丁版本的发布——次要版本允许引入破坏性更改。

脱字符约束类似于波浪线约束(见“版本指定器”),但后者只允许最后一个版本段增加。例如,以下约束是等效的:

rich = "¹³.7.1"
rich = ">=13.7.1,<14"

另一方面,波浪线约束通常排除次要版本:

rich = "~13.7.1"
rich = ">=13.7.1,==13.7.*"

脱字符约束对版本设置了一个上限。如“Python 中的上限版本边界”中所解释的那样,如果可能的话,应避免上限。只添加 Rich 的下限:

$ poetry add "rich>=13.7.1"

你可以使用这个命令从现有的脱字符约束中移除上限。如果你在第一次添加依赖时指定了额外的内容或标记,你需要再次指定它们。²

额外和环境标记

让我们添加random-wikipedia-article的另一个依赖项,HTTP 客户端库httpx。就像在第四章中一样,你将激活http2的额外功能以支持 HTTP/2。

$ poetry add "httpx>=0.27.0" --extras=http2

Poetry 相应地更新了pyproject.toml文件:

[tool.poetry.dependencies]
python = ">=3.10"
rich = ">=13.7.1"
httpx = {version = ">=0.27.0", extras = ["http2"]}

该项目需要较新的 Python 版本,因此不需要importlib-metadata的后移版本。如果你需要支持 Python 3.8 之前的版本,可以按以下方式为这些版本添加库:

$ poetry add "importlib-metadata>=7.0.2" --python="<3.8"

除了--pythonpoetry add命令还支持--platform选项,用于限制依赖关系到特定操作系统,比如 Windows。这个选项接受一个平台标识符,格式与标准的sys.platform属性相同:linuxdarwinwin32。对于其他环境标记,编辑pyproject.toml并在依赖关系的 TOML 表中使用markers属性:

[tool.poetry.dependencies]
awesome = {version = ">=1", markers = "implementation_name == 'pypy'"}

锁文件

Poetry 在名为 poetry.lock 的文件中记录了每个依赖项的当前版本,包括它们的打包工件的 SHA256 哈希值。如果你打开文件看一眼,你会注意到 richhttpx 的 TOML 段落,以及它们的直接和间接依赖项。示例 5-6 展示了 Rich 的锁定条目的简化版本。

示例 5-6. Rich 在 poetry.lock 中的 TOML 段落(简化版)
[[package]]
name = "rich"
version = "13.7.1"
python-versions = ">=3.7.0"
dependencies = {markdown-it-py = ">=2.2.0", pygments = ">=2.13.0,<3.0.0"}
files = [
    {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae3..."},
    {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308c..."},
]

使用命令 poetry show 在终端中显示锁定的依赖项。这是我添加 Rich 后的输出:

$ poetry show
markdown-it-py 3.0.0  Python port of markdown-it. Markdown parsing, done right!
mdurl          0.1.2  Markdown URL utilities
pygments       2.17.2 Pygments is a syntax highlighting package written in Python.
rich           13.7.1 Render rich text, tables, progress bars, ...

你也可以将依赖项显示为树形结构以可视化它们的关系:

$ poetry show --tree
rich 13.7.1 Render rich text, tables, progress bars, ...
├── markdown-it-py >=2.2.0
│   └── mdurl >=0.1,<1.0
└── pygments >=2.13.0,<3.0.0

如果你手动编辑了 pyproject.toml,请记得更新锁定文件以反映你的更改:

$ poetry lock --no-update
Resolving dependencies... (0.1s)

Writing lock file

没有 --no-update 选项,Poetry 将每个锁定的依赖项升级到其约束范围内的最新版本。

你可以检查 poetry.lock 文件是否与 pyproject.toml 保持一致:

$ poetry check --lock

提前解决依赖关系可以让你以可靠和可重复的方式部署应用程序。它还为团队中的开发人员提供了一个共同的基线,并使检查更加确定性——避免在持续集成(CI)中出现意外情况。你应该将 poetry.lock 提交到源代码控制中以获得这些好处。

Poetry 的锁定文件设计用于跨操作系统和 Python 解释器工作。如果你的代码必须在不同环境中运行,或者如果你是一个开源维护者,有来自世界各地的贡献者,拥有一个单一的环境无关或“通用”的锁定文件是有益的。

相比之下,编译的需求文件很快变得难以管理。如果你的项目支持 Python 的最近四个主要版本上的 Windows、macOS 和 Linux,你将需要管理十几个需求文件。添加另一个处理器架构或 Python 实现只会让情况变得更糟。

通用的锁定文件却并非没有代价。Poetry 在安装包到环境中时会重新解决依赖关系。其锁定文件本质上是一种缩小的世界观:它记录了项目在特定环境中可能需要的每个包。相比之下,编译的需求文件是环境的精确镜像。这使得它们更易于审计,更适合安全部署。

更新依赖项

你可以使用单个命令将锁定文件中的所有依赖项更新到它们的最新版本:

$ poetry update

你还可以提供一个特定的直接或间接依赖项来进行更新:

$ poetry update rich

poetry update 命令不会修改 pyproject.toml 中的项目元数据。它只会更新兼容版本范围内的依赖项。如果需要更新版本范围,请使用带有新约束的 poetry add,包括任何额外的和标记。或者,编辑 pyproject.toml 并使用 poetry lock --no-update 刷新锁定文件。

如果你不再需要一个包用于你的项目,使用 poetry remove 将其移除:

$ poetry remove *<package>*

管理环境

Poetry 的 addupdateremove 命令不仅会更新 pyproject.tomlpoetry.lock 文件中的依赖关系。它们还通过安装、更新或删除软件包,将项目环境与锁定文件同步。Poetry 根据需要为项目创建虚拟环境。

默认情况下,Poetry 将所有项目的环境存储在共享文件夹中。配置 Poetry 将环境保存在项目内的 .venv 目录中:

$ poetry config virtualenvs.in-project true

此设置使得环境在生态系统中的其他工具(如 pyuv)中可发现,当您需要检查其内容时,在项目中具有该目录非常方便。尽管此设置将您限制为单个环境,但这种限制很少是一个问题。像 Nox 和 tox 这样的工具专门用于跨多个环境进行测试(参见 第 8 章)。

您可以使用命令 poetry env info --path 检查当前环境的位置。如果您想为项目创建一个干净的状态,请使用以下命令删除现有环境并使用指定的 Python 版本创建新环境:

$ poetry env remove --all
$ poetry env use 3.12

您可以重新运行第二条命令以在不同的解释器上重新创建环境。您不仅可以使用类似 3.12 的版本,还可以为 PyPy 解释器传递类似 pypy3 的命令,或者为系统 Python 传递类似 /usr/bin/python3 的完整路径。

在使用环境之前,您应该安装项目。Poetry 执行可编辑安装,因此环境会立即反映任何代码更改:

$ poetry install

通过使用 poetry shell 启动一个 shell 会话来进入项目环境。Poetry 使用当前 shell 的激活脚本激活虚拟环境。在环境激活后,您可以从 shell 提示符中运行应用程序。完成后,只需退出 shell 会话:

$ poetry shell
(random-wikipedia-article-py3.12) $ random-wikipedia-article
(random-wikipedia-article-py3.12) $ exit

您还可以在当前的 shell 会话中运行应用程序,使用命令 poetry run

$ poetry run random-wikipedia-article

该命令还适用于在项目环境中启动交互式 Python 会话:

$ poetry run python
>>> from random_wikipedia_article import main
>>> main()

使用 poetry run 运行程序时,Poetry 会激活虚拟环境,而不启动 shell。这通过将环境添加到程序的 PATHVIRTUAL_ENV 变量中实现(参见 “激活脚本”)。

提示

只需在 Linux 和 macOS 上输入 py,即可为您的 Poetry 项目获取 Python 会话。这需要 Unix 上的 Python 启动器,并且您必须配置 Poetry 以使用项目内的环境。

依赖组

Poetry 允许您声明开发依赖项,这些依赖项组织在依赖组中。依赖组不是项目元数据的一部分,对最终用户不可见。让我们从 “开发依赖项” 中添加依赖组:

$ poetry add --group=tests pytest pytest-sugar
$ poetry add --group=docs sphinx

Poetry 在 pyproject.tomlgroup 表下添加依赖组:

[tool.poetry.group.tests.dependencies]
pytest = "⁸.1.1"
pytest-sugar = "¹.0.0"

[tool.poetry.group.docs.dependencies]
sphinx = "⁷.2.6"

默认情况下,依赖组会安装到项目环境中。您可以使用其 optional 键将组标记为可选,例如:

[tool.poetry.group.docs]
optional = true

[tool.poetry.group.docs.dependencies]
sphinx = "⁷.2.6"
警告

使用 poetry add 添加依赖组时,请勿指定 --optional 标志,因为它不会将组标记为可选。该选项用于指定作为额外项的可选依赖项;在依赖组的上下文中,它没有有效用途。

poetry install 命令有几个选项,可更精细地控制哪些依赖项安装到项目环境中(表 5-3)。

表 5-3. 使用 poetry install 安装依赖项

选项 描述
--with=*<group>* 将一个依赖组包含在安装中。
--without=*<group>* 从安装中排除一个依赖组。
--only=*<group>* 从安装中排除所有其他依赖组。
--no-root 从安装中排除项目本身。
--only-root 从安装中排除所有依赖项。
--sync 从环境中删除除安装计划外的包。

您可以指定单个组或多个组(用逗号分隔)。特殊组 main 指的是在 tool.poetry.dependencies 表中列出的包。使用选项 --only=main 可以从安装中排除所有开发依赖项。类似地,选项 --without=main 可让您限制安装到开发依赖项。

包仓库

Poetry 允许您将包上传到 Python 包索引(PyPI)和其他包仓库。它还允许您配置从中添加包到项目的仓库。本节同时涵盖与包仓库的互动的发布者和使用者两方面。

注意

如果您在本节中跟随进行,请不要将示例项目上传到 PyPI。请使用 TestPyPI 仓库,它是一个用于测试、学习和实验的游乐场。

发布包到包仓库

在您可以上传包到 PyPI 之前,您需要一个帐户和 API 令牌来验证仓库,如 “使用 Twine 上传包” 中所述。接下来,将 API 令牌添加到 Poetry:

$ poetry config pypi-token.pypi *<token>*

您可以使用标准工具(如 build)或 Poetry 的命令行界面为 Poetry 项目创建包:

$ poetry build
Building random-wikipedia-article (0.1.0)
  - Building sdist
  - Built random_wikipedia_article-0.1.0.tar.gz
  - Building wheel
  - Built random_wikipedia_article-0.1.0-py3-none-any.whl

build 类似,Poetry 将包放置在 dist 目录中。您可以使用 poetry publish 发布 dist 中的包:

$ poetry publish

您还可以将这两个命令合并为一个:

$ poetry publish --build

让我们将示例项目上传到 TestPyPI,这是 Python 包索引的一个单独实例,用于测试发布。如果您想将包上传到 PyPI 之外的仓库,需要将该仓库添加到 Poetry 配置中:

$ poetry config repositories.testpypi https://test.pypi.org/legacy/

首先,在 TestPyPI 上创建一个帐户和 API 令牌。接下来,配置 Poetry 使用该令牌上传到 TestPyPI:

$ poetry config pypi-token.testpypi *<token>*

您现在可以在发布项目时指定存储库。请随意尝试将其应用于您自己版本的示例项目:

$ poetry publish --repository=testpypi
Publishing random-wikipedia-article (0.1.0) to TestPyPI
 - Uploading random_wikipedia_article-0.1.0-py3-none-any.whl 100%
 - Uploading random_wikipedia_article-0.1.0.tar.gz 100%

某些包存储库使用带有用户名和密码的 HTTP 基本身份验证。您可以像这样配置此类存储库的凭据:

$ poetry config http-basic.*<repo>* *<username>*

该命令提示您输入密码,并将其存储在系统密钥环中(如果可用),或者存储在磁盘上的 auth.toml 文件中。

或者,您还可以通过环境变量配置存储库(将 *<REPO>* 替换为大写的存储库名称,例如 PYPI):

$ export POETRY_REPOSITORIES_*<REPO>*_URL=*<url>*
$ export POETRY_PYPI_TOKEN_*<REPO>*=*<token>*
$ export POETRY_HTTP_BASIC_*<REPO>*_USERNAME=*<username>*
$ export POETRY_HTTP_BASIC_*<REPO>*_PASSWORD=*<password>*

诗歌还支持通过相互 TLS 安全保护或使用自定义证书颁发机构的存储库;详细信息请参阅官方文档

从包源获取包

在上文中,您已经了解了如何将您的包上传到除 PyPI 外的存储库。Poetry 还支持消费端的备用存储库:您可以从非 PyPI 的来源向项目添加包。虽然上传目标是用户设置并存储在 Poetry 配置中,但包源是项目设置,并存储在 pyproject.toml 中。

使用命令 poetry source add 添加包源:

$ poetry source add *<repo>* *<url>* --priority=supplemental

如果要禁用 PyPI 并希望从主要来源配置,请在这里配置补充源(默认优先级):

$ poetry source add *<repo>* *<url>*

您可以像配置存储库一样为包源配置凭据:

$ poetry config http-basic.*<repo>* *<username>*

您现在可以从备用来源添加包:

$ poetry add httpx --source=*<repo>*

下面的命令列出了项目的包源:

$ poetry source show

警告

在添加来自辅助来源的包时,请指定来源。否则,Poetry 在查找包时会搜索所有来源。攻击者可以向 PyPI 上传与您内部包相同名称的恶意包(依赖混淆攻击)。

使用插件扩展 Poetry

Poetry 提供了一个插件系统,可以扩展其功能。使用 pipx 将插件注入到 Poetry 的环境中:

$ pipx inject poetry *<plugin>*

*<plugin>* 替换 PyPI 上插件的名称。

如果插件影响项目的构建阶段,请在 pyproject.toml 中的构建依赖项中添加它。例如,请参阅“动态版本插件”。

默认情况下,pipx 在没有注入包的情况下升级应用程序。使用选项 --include-injected 也升级应用程序插件。

$ pipx upgrade --include-injected poetry

如果您不再需要该插件,请从注入的包中移除它:

$ pipx uninject poetry *<plugin>*

如果您不确定已安装了哪些插件,请像这样列出它们:

$ poetry self show plugins

在本节中,我将为您介绍 Poetry 的三个有用插件:

  • poetry-plugin-export 允许您生成需求和约束文件

  • poetry-plugin-bundle 允许您将项目部署到虚拟环境中

  • poetry-dynamic-versioning 从版本控制系统中填充项目版本

使用导出插件生成需求文件

Poetry 的锁定文件非常适合确保您团队中的每个人和每个部署环境都使用相同的依赖关系。但是如果您在某些情况下无法使用 Poetry 该怎么办?例如,您可能需要在仅具有 Python 解释器和捆绑的 pip 的系统上部署项目。

截至目前为止,在更广泛的 Python 世界中没有锁定文件标准;支持锁定文件的每个打包工具都实现了自己的格式。³ 这些锁定文件格式都不受 pip 支持。但是我们确实有要求文件。

要求文件允许您将软件包固定到确切的版本,要求其构件与加密哈希匹配,并使用环境标记将软件包限制在特定的 Python 版本和平台上。如果您可以从 poetry.lock 生成一个要求文件以与非 Poetry 环境进行互操作,那会很好吗?这正是导出插件所实现的。

使用 pipx 安装插件:

$ pipx inject poetry poetry-plugin-export

该插件为 poetry export 命令提供动力,该命令具有 --format 选项以指定输出格式。默认情况下,该命令写入标准输出流;使用 --output 选项指定目标文件。

$ poetry export --format=requirements.txt --output=requirements.txt

将要求文件分发到目标系统并使用 pip 安装依赖项(通常是安装您项目的 wheel 之后)。

$ python3 -m pip install -r requirements.txt

将导出为 requirements 格式对部署之外的用途也很有用。许多工具都使用 requirements 文件作为事实上的行业标准。例如,您可以使用像 safety 这样的工具扫描具有已知安全漏洞的依赖关系的要求文件。

使用 Bundle 插件部署环境

在上一节中,您看到了如何在没有 Poetry 的系统上部署项目。如果您确实有 Poetry 可用,您可能会想知道:是否可以只使用 poetry install 部署?您可以,但是 Poetry 执行的是可编辑安装您的项目—​您将从源代码树运行应用程序。这在生产环境中可能不可接受。可编辑安装还限制了将虚拟环境发送到另一个目的地的能力。

Bundle 插件允许您将项目和已锁定的依赖项部署到您选择的虚拟环境中。它创建环境,从锁定文件安装依赖项,然后构建并安装您项目的 wheel。

使用 pipx 安装插件:

$ pipx inject poetry poetry-plugin-bundle

安装后,您将看到一个新的 poetry bundle 子命令。让我们使用它将项目捆绑到名为 app 的虚拟环境中。使用 --python 选项指定环境的解释器,并使用 --only=main 选项排除开发依赖关系。

$ poetry bundle venv --python=/usr/bin/python3 --only=main app

  - Bundled random-wikipedia-article (0.1.0) into app

通过运行应用程序的入口点脚本来测试环境。⁴

$ app/bin/random-wikipedia-article

您可以使用 bundle 插件为生产环境创建一个精简的 Docker 镜像。Docker 支持 多阶段构建,第一阶段在全功能的构建环境中构建应用程序,第二阶段将构建产物复制到精简的运行时环境中。这使您能够在生产环境中快速部署、减少冗余并降低潜在漏洞的风险。

在 示例 5-7 中,第一个阶段安装 Poetry 和 bundle 插件,复制 Poetry 项目,并将其打包成一个自包含的虚拟环境。第二个阶段将虚拟环境复制到一个精简的 Python 镜像中。

示例 5-7. 使用 Poetry 的多阶段 Dockerfile
FROM debian:12-slim AS builder ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
RUN apt-get update && \
    apt-get install --no-install-suggests --no-install-recommends --yes pipx
ENV PATH="/root/.local/bin:${PATH}"
RUN pipx install poetry
RUN pipx inject poetry poetry-plugin-bundle
WORKDIR /src
COPY . .
RUN poetry bundle venv --python=/usr/bin/python3 --only=main /venv

FROM gcr.io/distroless/python3-debian12 ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)
COPY --from=builder /venv /venv ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/3.png)
ENTRYPOINT ["/venv/bin/random-wikipedia-article"] ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/4.png)

1

第二个 FROM 指令定义了部署到生产环境的镜像。基础镜像是 distroless Python 镜像,适用于 Debian 稳定版:提供 Python 语言支持但不包括操作系统。

2

第一个 FROM 指令定义了构建阶段的基础镜像,用于构建和安装项目。基础镜像是 Debian 稳定版的精简变体。

3

COPY 指令允许您从构建阶段复制虚拟环境过来。

4

ENTRYPOINT 指令允许您在用户使用该镜像运行 docker run 时运行入口点脚本。

如果已安装 Docker,则可以尝试此操作。首先,在项目中创建一个 Dockerfile,内容来自 示例 5-7。接下来,构建并运行 Docker 镜像:

$ docker build -t random-wikipedia-article .
$ docker run --rm -ti random-wikipedia-article

您应该在终端中看到 random-wikipedia-article 的输出。

动态版本插件

动态版本插件从 Git 标签中填充项目元数据中的版本信息。将版本信息集中管理可减少变更(参见 “Single-sourcing the project version”)。该插件基于 Dunamai,一个用于从版本控制系统标签中派生符合标准的版本字符串的 Python 库。

使用 pipx 安装插件并为您的项目启用它:

$ pipx inject poetry "poetry-dynamic-versioning[plugin]"
$ poetry dynamic-versioning enable

第二步在 pyproject.tomltool 部分启用插件:

[tool.poetry-dynamic-versioning]
enable = true

请记住,您已全局安装了 Poetry 插件。显式选择加入确保您不会意外地开始在不相关的 Poetry 项目中重写版本字段。

前端构建工具如 pip 和 build 在构建项目时需要该插件。因此,启用插件还将其添加到 pyproject.toml 中作为构建依赖项。该插件提供了自己的构建后端,用于包装 Poetry 提供的构建系统:

[build-system]
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
build-backend = "poetry_dynamic_versioning.backend"

Poetry 仍然要求在其自己的部分中有 version 字段。将该字段设置为 "0.0.0" 表示未使用该字段。

[tool.poetry]
version = "0.0.0"

现在你可以添加一个 Git 标签来设置你的项目版本:

$ git tag v1.0.0
$ poetry build
Building random-wikipedia-article (1.0.0)
  - Building sdist
  - Built random_wikipedia_article-1.0.0.tar.gz
  - Building wheel
  - Built random_wikipedia_article-1.0.0-py3-none-any.whl

插件还会替换 Python 模块中的 __version__ 属性。这在大多数情况下都可以直接使用,但如果你使用它,你需要声明 src 布局:

[tool.poetry-dynamic-versioning]
enable = true
substitution.folders = [{path = "src"}]

让我们为应用程序添加一个 --version 选项。编辑包中的 init.py 文件,添加以下几行:

import argparse

__version__ = "0.0.0"

def main():
    parser = argparse.ArgumentParser(prog="random-wikipedia-article")
    parser.add_argument(
        "--version", action="version", version=f"%(prog)s {__version__}"
    )
    parser.parse_args()
    ...

在继续之前,请提交你的更改,但不要添加另一个 Git 标签。让我们在项目的新安装中尝试这个选项:

$ rm -rf .venv
$ uv venv
$ uv pip install --no-cache .
$ py -m random_wikipedia_article --version
random-wikipedia-article 1.0.0.post1.dev0+51c266e

正如你所看到的,插件在构建过程中重写了 __version__ 属性。由于你没有给提交打标签,Dunamai 将版本标记为 1.0.0 的开发后发布,并使用本地版本标识符附加提交哈希。

摘要

Poetry 提供了统一的工作流来管理打包、依赖和环境。Poetry 项目与标准工具兼容:你可以使用 build 构建它们,并使用 Twine 将它们上传到 PyPI。但是 Poetry 命令行界面还提供了这些任务和更多任务的便捷缩写。

Poetry 在其锁文件中记录了精确的软件包工作集,为你提供确定性的部署和检查,以及与他人合作时一致的体验。Poetry 可以跟踪开发依赖项;它将它们组织在你可以单独或一起安装的依赖组中。你可以使用插件扩展 Poetry,例如将项目部署到虚拟环境或从 Git 派生版本号。

如果你需要为一个应用程序进行可复制的部署,如果你的团队在多个操作系统上开发,或者如果你觉得标准的工具链给你的工作流增加了太多负担,那么你应该试试 Poetry。

¹ Sébastien Eustace: “PEP 621 的支持”, 2020 年 11 月 6 日。

² 这个命令还会保持你的锁文件和项目环境保持更新。如果你编辑了 pyproject.toml 中的约束条件,你需要自己做这些。继续阅读以了解有关锁文件和环境的更多信息。

³ 除了 Poetry 自己的 poetry.lock 和密切相关的 PDM 锁文件格式外,还有 pipenv 的 Pipfile.lock 和 Conda 环境的 conda-lock 格式。

⁴ 如果你在 Windows 上,将 bin 替换为 Scripts

第三部分:测试和静态分析

第六章:使用 pytest 进行测试

如果你回想起编写你的第一个程序时的经历,你可能会想起一个常见的情景:你有一个想法,认为程序可以帮助解决实际任务,然后花了大量时间从头到尾编写它,最后当你运行它时,却看到一屏幕都是令人沮丧的错误消息。或者更糟糕的是,它给出了微妙错误的结果。

我们从这样的经历中学到了一些经验教训。其中之一是从简单开始,并在迭代过程中保持简单。另一个教训是早期和重复测试。最初,这可能只是手动运行程序并验证其是否按预期工作。后来,如果将程序分解为更小的部分,可以独立且自动地测试这些部分。作为副作用,程序也更容易阅读和处理。

在本章中,我将谈论如何测试可以帮助您及时和一致地产生价值。良好的测试相当于对您拥有的代码的可执行规范。它们使您摆脱了团队或公司的制度化知识,通过及时反馈您的变更加快了开发速度。

第三方测试框架 pytest 已经成为 Python 世界的一种事实标准。使用 pytest 编写的测试简单易读:你编写的大多数测试都像没有框架一样,使用基本的语言原语如函数和断言。尽管框架简单,但通过测试固件和参数化测试等概念,它强大且富有表现力。Pytest 是可扩展的,并且配备了丰富的插件生态系统。

注意

Pytest 起源于 PyPy 项目,这是一个用 Python 编写的 Python 解释器。早期,PyPy 的开发人员致力于一个名为 std 的单独标准库,后来更名为 py。其测试模块 py.test 成为了一个名为 pytest 的独立项目。

编写测试

示例 6-1 重新访问了来自第 3 章的维基百科示例。该程序非常简单,但你可能不清楚如何为其编写测试。main 函数没有输入和输出,只有副作用,比如向标准输出流写入内容。你会如何测试这样的函数?

示例 6-1. 来自 random-wikipedia-articlemain 函数
def main():
    with urllib.request.urlopen(API_URL) as response:
        data = json.load(response)

    print(data["title"], end="\n\n")
    print(textwrap.fill(data["extract"]))

让我们编写一个端到端测试,在子进程中运行程序并检查其是否完成且输出非空。端到端测试会像最终用户一样运行整个程序(示例 6-2)。

示例 6-2. 对 random-wikipedia-article 的测试
import subprocess
import sys

def test_output():
    args = [sys.executable, "-m", "random_wikipedia_article"]
    process = subprocess.run(args, capture_output=True, check=True)
    assert process.stdout
提示

使用 pytest 编写的测试是以 test 开头的函数。使用内置的 assert 语句来检查预期行为。Pytest 重写语言构造以提供丰富的错误报告,以防测试失败。

将示例 6-2 的内容放入一个名为test_main.py的文件中,放在一个名为tests的目录中。包含一个空的init.py文件将测试套件转换为一个可导入的包。这样可以使您模仿您正在测试的包的布局,¹并且可以选择导入具有测试实用工具的模块。

此时,您的项目应该按以下方式结构化:

random-wikipedia-article
├── pyproject.toml
├── src
│   └── random_wikipedia_article
│       ├── __init__.py
│       └── __main__.py
└── tests
    ├── __init__.py
    └── test_main.py

管理测试依赖项

测试必须能够导入您的项目及其依赖项,因此您需要在项目环境中安装 pytest。例如,将一个tests的额外项添加到您的项目中:

[project.optional-dependencies]
tests = ["pytest>=8.1.1"]

您现在可以在项目环境中安装 pytest:

$ uv pip install -e ".[tests]"

或者,编译一个需求文件并同步您的环境:

$ uv pip compile --extra=tests pyproject.toml -o dev-requirements.txt
$ uv pip sync dev-requirements.txt
$ uv pip install -e . --no-deps

如果您使用 Poetry,请使用poetry add将 pytest 添加到您的项目中:

$ poetry add --group=tests "pytest>=8.1.1"

请在本章后面我要求您添加测试依赖项时参考这些步骤。

最后,让我们运行测试套件。如果您使用的是 Windows,在运行以下命令之前,请激活环境。

$ py -m pytest
========================= test session starts ==========================
platform darwin -- Python 3.12.2, pytest-8.1.1, pluggy-1.4.0
rootdir: ...
collected 1 item

tests/test_main.py .                                              [100%]
========================== 1 passed in 0.01s ===========================

提示

即使在 Poetry 项目中,也要使用py -m pytest。这既更短,也更安全,比poetry run pytest更安全。如果忘记将 pytest 安装到环境中,Poetry 将退回到您的全局环境中。(安全的变体将是poetry run python -m pytest。)

设计可测试性

为程序编写更精细的测试要困难得多。API 终点返回一个随机文章,那么测试应该期望哪个标题和摘要?每次调用都会向真实的维基百科 API 发送一个 HTTP 请求。这些网络往返将使测试套件变得极其缓慢——而且只有在您的计算机连接到互联网时才能运行测试。

Python 程序员在这种情况下有一系列工具可供使用。其中大部分涉及某种形式的猴子补丁,即在运行时替换函数或对象,以使代码更易于测试。例如,您可以通过将sys.stdout替换为一个写入内部缓冲区以供后续检查的文件样对象来捕获程序输出。您可以将urlopen替换为返回您喜欢的固定 HTTP 响应的函数。像responsesrespxvcr.py这样的库提供了在幕后猴子补丁 HTTP 机制的高级接口。更通用的方法使用标准的unittest.mock模块或 pytest 的monkeypatch装置。

注意

猴子补丁一词是在 Zope 公司首创的用于在运行时替换代码的术语。最初,Zope 公司的人们将这项技术称为“游击补丁”,因为它不遵守常规的补丁提交规则。人们听到的是“大猩猩补丁”——很快,更精心制作的补丁就被称为“猴子补丁”。

虽然这些工具能够完成它们的任务,但我建议你专注于问题的根源:示例 6-1 没有关注点分离。一个函数既作为应用程序的入口点,又与外部 API 通信,并在控制台上呈现结果。这使得很难单独测试其功能。

该程序还缺乏抽象化,有两个方面。首先,在与其他系统交互时,如与维基百科 API 交互或写入终端时,它没有封装实现细节。其次,它的中心概念——维基百科文章——只是一个无定形的 JSON 对象:程序未以任何方式抽象其域模型,例如定义一个Article类。

示例 6-3 展示了一种使代码更易于测试的重构。虽然这个版本的程序更长,但它更清晰地表达了逻辑,并且更容易修改。好的测试不仅能捕获错误:它们还改善了代码的设计。

示例 6-3. 可测试性重构
import sys ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
from dataclasses import dataclass

@dataclass
class Article:
    title: str = ""
    summary: str = ""

def fetch(url):
    with urllib.request.urlopen(url) as response:
        data = json.load(response)
    return Article(data["title"], data["extract"])

def show(article, file):
    summary = textwrap.fill(article.summary)
    file.write(f"{article.title}\n\n{summary}\n")

def main():
    article = fetch(API_URL)
    show(article, sys.stdout)

1

为了简洁起见,本章节中的示例仅在第一次使用时显示导入。

重构将main中的fetchshow函数提取出来。它还将Article类定义为这些函数的共同基础。让我们看看这些改变如何让你能够以隔离和可重复的方式测试程序的各个部分。

show函数接受任何类似文件的对象。虽然main传递了sys.stdout,测试可以传递一个io.StringIO实例以将输出存储在内存中。示例 6-4 使用这种技术来检查输出是否以换行符结束。最后的换行符确保输出不会延伸到下一个 shell 提示符。

示例 6-4. 测试show函数
import io
from random_wikipedia_article import Article, show

def test_final_newline():
    article = Article("Lorem Ipsum", "Lorem ipsum dolor sit amet.")
    file = io.StringIO()
    show(article, file)
    assert file.getvalue().endswith("\n")

重构还有另一个好处:函数将实现隐藏在只涉及你的问题域——URL、文章、文件的接口后面。这意味着当你替换实现时,你的测试不太可能会失败。继续更改show函数以使用 Rich,就像示例 6-5 中所示那样。² 你不需要调整你的测试!

示例 6-5. 替换show的实现
from rich.console import Console

def show(article, file):
    console = Console(file=file, width=72, highlight=False)
    console.print(article.title, style="bold", end="\n\n")
    console.print(article.summary)

实际上,测试的整个目的是在你进行此类更改后依然确保程序正常工作。另一方面,模拟和猴子补丁是脆弱的:它们将你的测试套件与实现细节捆绑在一起,使得今后更改程序变得越来越困难。

Fixture 和参数化

这里还有一些show函数的其他属性,你可能会检查:

  • 它应该包含标题和摘要的所有单词。

  • 标题后应有一个空行。

  • 摘要不应超过 72 个字符的行长。

每个 show 函数的测试都以设置输出缓冲区开始。你可以使用 fixture 来消除此代码重复。Fixture 是使用 pytest.fixture 装饰器声明的函数:

@pytest.fixture
def file():
    return io.StringIO()

测试(及 fixture)可以通过包含与相同名称的函数参数使用 fixture。当 pytest 调用测试函数时,它传递 fixture 函数的返回值。让我们重写 Example 6-4 来使用该 fixture:

def test_final_newline(file):
    article = Article("Lorem Ipsum", "Lorem ipsum dolor sit amet.")
    show(article, file)
    assert file.getvalue().endswith("\n")
警告

如果忘记将参数 file 添加到测试函数中,会出现令人困惑的错误:'function' object has no attribute 'write'。这是因为现在名称 file 指向同一模块中的 fixture 函数。³

如果每个测试都使用相同的文章,你可能会遗漏一些边界情况。例如,如果一篇文章标题为空,你不希望程序崩溃。Example 6-6 对多篇文章使用 @pytest.mark.parametrize 装饰器运行测试。⁴

Example 6-6. 对多篇文章运行测试
articles = [
    Article(),
    Article("test"),
    Article("Lorem Ipsum", "Lorem ipsum dolor sit amet."),
    Article(
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
        "Nulla mattis volutpat sapien, at dapibus ipsum accumsan eu."
    ),
]

@pytest.mark.parametrize("article", articles)
def test_final_newline(article, file):
    show(article, file)
    assert file.getvalue().endswith("\n")

如果你以相同方式参数化许多测试,可以创建一个参数化 fixture,一个带有多个值的 fixture(参见 Example 6-7)。与之前一样,pytest 会对每篇文章在 articles 中运行测试一次。

Example 6-7. 参数化 fixture,用于对多篇文章运行测试
@pytest.fixture(params=articles)
def article(request):
    return request.param

def test_final_newline(article, file):
    show(article, file)
    assert file.getvalue().endswith("\n")

那么你在这里得到了什么?首先,你不需要为每个测试都添加 @pytest.mark.parametrize 装饰器。如果你的测试不都在同一个模块中,还有另一个优势:你可以将 fixture 放在名为 conftest.py 的文件中,在整个测试套件中使用而无需导入。

参数化 fixture 的语法有些晦涩。为了保持简单,我喜欢定义一个小助手:

def parametrized_fixture(*params):
    return pytest.fixture(params=params)(lambda request: request.param)

使用 helper 简化 Example 6-7 中的 fixture。还可以从 Example 6-6 中内联 articles 变量:

article = parametrized_fixture(Article(), Article("test"), ...)
提示

如果你有一个使用 unittest 编写的测试套件,没有必要重写它以开始使用 pytest——pytest 也“说” unittest。立即使用 pytest 作为测试运行器,稍后逐步重写你的测试套件。

Fixture 的高级技术

对于 fetch 函数,测试可以设置一个本地 HTTP 服务器并执行往返检查。这在 Example 6-8 中展示:你通过 HTTP 提供一个 Article 实例,从服务器获取文章,并检查提供和获取的实例是否相等。

Example 6-8. 测试 fetch 函数(版本 1)
def test_fetch(article):
    with serve(article) as url:
        assert article == fetch(url)

serve 辅助函数接受一篇文章并返回一个用于获取文章的 URL。更确切地说,它将 URL 包装在上下文管理器中,这是一个可以在 with 块中使用的对象。这允许 serve 在退出 with 块时进行清理——通过关闭服务器:

from contextlib import contextmanager

@contextmanager
def serve(article):
    ... # start the server
    yield f"http://localhost:{server.server_port}"
    ... # shut down the server

您可以使用标准库中的http.server模块实现serve函数(示例 6-9)。不过,不必太担心细节。本章稍后将介绍pytest-httpserver插件,它将承担大部分工作。

示例 6-9. serve函数
import http.server
import json
import threading

@contextmanager
def serve(article):
    data = {"title": article.title, "extract": article.summary}
    body = json.dumps(data).encode()

    class Handler(http.server.BaseHTTPRequestHandler): ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
        def do_GET(self):
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

    with http.server.HTTPServer(("localhost", 0), Handler) as server: ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)
        thread = threading.Thread(target=server.serve_forever, daemon=True) ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/3.png)
        thread.start()
        yield f"http://localhost:{server.server_port}"
        server.shutdown()
        thread.join()

1

请求处理程序用 UTF-8 编码的 JSON 表示文章响应每个 GET 请求。

2

服务器仅接受本地连接。操作系统会随机分配端口号。

3

服务器在后台线程中运行。这样可以使控制权返回到测试中。

每次测试都启动和关闭 Web 服务器是昂贵的。将服务器转换为夹具是否有所帮助?乍一看,效果不大—​每个测试都获得其自己的夹具实例。但是,您可以指示 pytest 在整个测试会话期间仅创建一次夹具,使用会话范围的夹具

@pytest.fixture(scope="session")
def httpserver():
    ...

看起来更有希望,但是当测试完成时如何关闭服务器呢?到目前为止,您的夹具仅准备了一个测试对象并返回它。您不能在return语句之后运行代码。但是,在yield语句之后您可以运行代码—​因此 pytest 允许您将夹具定义为生成器。

生成器夹具准备一个测试对象,yield 它,并在结束时清理资源—​类似于上下文管理器。您可以像普通夹具一样使用它,该夹具返回其测试对象。Pytest 在幕后处理设置和拆卸阶段,并使用 yield 的值调用您的测试函数。

示例 6-10 使用生成器技术定义了httpserver夹具。

示例 6-10. httpserver夹具
@pytest.fixture(scope="session")
def httpserver():
    class Handler(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            article = self.server.article ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
            data = {"title": article.title, "extract": article.summary}
            body = json.dumps(data).encode()
            ... # as before

    with http.server.HTTPServer(("localhost", 0), Handler) as server:
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        yield server
        server.shutdown()
        thread.join()

1

在示例 6-9 中,范围内没有article。相反,请求处理程序从服务器的article属性中访问它(详见下文)。

还有一个遗漏的部分:您需要定义serve函数。现在该函数依赖于httpserver夹具来完成其工作,因此您不能简单地将其定义为模块级别。让我们暂时将其移动到测试函数中(示例 6-11)。

示例 6-11. 测试fetch函数(版本 2)
def test_fetch(article, httpserver):
    def serve(article):
        httpserver.article = article ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
        return f"http://localhost:{httpserver.server_port}"

    assert article == fetch(serve(article))

1

将文章存储在服务器中,以便请求处理程序可以访问它。

serve 函数不再返回上下文管理器,只是一个简单的 URL—​httpserver fixture 处理所有的设置和拆卸。但是你仍然可以做得更好。内部函数会使测试代码变得混乱—​还有 fetch 函数的所有其他测试。相反,让我们在其自己的 fixture 中定义 serve—​毕竟,fixtures 可以返回任何对象,包括函数(Example 6-12)。

Example 6-12. serve fixture
@pytest.fixture
def serve(httpserver): ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
    def f(article): ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)
        httpserver.article = article
        return f"http://localhost:{httpserver.server_port}"
    return f

1

外部函数定义了一个 serve fixture,依赖于 httpserver

2

内部函数就是你在测试中调用的 serve 函数。

多亏了 serve fixture,测试函数变成了一个单行代码(Example 6-13)。它也更快,因为你每个会话只启动和停止一次服务器。

Example 6-13. 测试 fetch 函数(版本 3)
def test_fetch(article, serve):
    assert article == fetch(serve(article))

你的测试与任何特定的 HTTP 客户端库无关。Example 6-14 替换了 fetch 函数的实现以使用 HTTPX。⁵ 这可能会破坏使用猴子补丁的任何测试—​但你的测试仍然会通过!

Example 6-14. 替换 fetch 的实现
import httpx

from importlib.metadata import metadata

USER_AGENT = "{Name}/{Version} (Contact: {Author-email})"

def fetch(url):
    fields = metadata("random-wikipedia-article")
    headers = {"User-Agent": USER_AGENT.format_map(fields)}

    with httpx.Client(headers=headers, http2=True) as client:
        response = client.get(url, follow_redirects=True)
        response.raise_for_status()
        data = response.json()

    return Article(data["title"], data["extract"])

使用插件扩展 pytest

正如你在“入口点”中看到的,pytest 的可扩展设计允许任何人贡献 pytest 插件并将其发布到 PyPI,⁶ 因此插件的丰富生态系统已经形成。你已经看到了 pytest-sugar 插件,它增强了 pytest 的输出,包括添加了一个进度条。在本节中,你将看到更多插件。

pytest-httpserver 插件

pytest-httpserver 插件提供了一个更多功能且经过实战测试的 httpserver fixture,比 Example 6-10 更加灵活。让我们使用这个插件。

首先,将 pytest-httpserver 添加到你的测试依赖中。接下来,从你的测试模块中移除现有的 httpserver fixture。最后,更新 serve fixture 以使用该插件(Example 6-15)。

Example 6-15. 使用 pytest-httpserverserve fixture
@pytest.fixture
def serve(httpserver):
    def f(article):
        json = {"title": article.title, "extract": article.summary}
        httpserver.expect_request("/").respond_with_json(json)
        return httpserver.url_for("/")
    return f

Example 6-15 配置服务器以响应对 "/" 的请求,并返回文章的 JSON 表示。该插件提供了远比这个用例更灵活的功能,例如,你可以添加自定义请求处理程序或者通过 HTTPS 进行通信。

pytest-xdist 插件

随着你的测试套件的增长,你会寻找加快测试运行速度的方法。这里有一个简单的方法:利用所有的 CPU 核心。pytest-xdist 插件在每个处理器上生成一个工作进程,并随机分发测试。这种随机化也有助于检测测试之间的隐藏依赖关系。

pytest-xdist添加到您的测试依赖项并更新您的环境。使用选项--numprocesses-n指定工作进程数。指定auto以使用系统上的所有物理核心:

$ py -m pytest -n auto

factory-boy 和 faker 库

在“夹具和参数化”中,您硬编码了测试运行的文章。让我们避免这种样板代码—​它使您的测试难以维护。

相反,factory-boy库允许您为测试对象创建工厂。您可以使用序列号生成具有可预测属性的对象批次。或者,您可以使用faker库随机填充属性。

factory-boy添加到您的测试依赖项并更新您的环境。示例 6-16 定义了一个用于随机文章的工厂,并为article夹具创建了十篇文章的批次。(如果您想看到pytest-xdist的效果,请增加文章数并使用-n auto运行 pytest。)

示例 6-16. 使用factory-boyfaker创建一批文章
from factory import Factory, Faker

class ArticleFactory(Factory):
    class Meta:
        model = Article ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)

    title = Faker("sentence") ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)
    summary = Faker("paragraph")

article = parametrized_fixture(*ArticleFactory.build_batch(10)) ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/3.png)

1

使用Meta.model指定测试对象的类。

2

标题使用随机句子,摘要使用随机段落。

3

使用build_batch方法生成一批文章。

这个简化的工厂不能很好地处理边界情况。对于真实的应用程序,您应该包括空和非常大的字符串,以及控制字符等异常字符。另一个可以让您探索可能输入搜索空间的优秀测试库是hypothesis

其他插件

Pytest 插件执行各种功能(表 6-1)。这些功能包括并行或随机顺序执行测试,以自定义方式呈现或报告测试结果,以及与框架和其他工具集成。许多插件提供有用的夹具,例如用于与外部系统交互或创建测试双。

表 6-1. 一些 pytest 插件的选择

插件 类别 描述 选项
pytest-xdist 执行 将测试分布到多个 CPU 上 --numprocesses
pytest-sugar 展示 用进度条增强输出
pytest-icdiff 展示 在测试失败时显示着色差异
anyio 框架 使用异步测试与 asyncio 和 trio
pytest-httpserver 伪服务器 启动带有预设响应的 HTTP 服务器
pytest-factoryboy 伪数据 将工厂转换为夹具
pytest-datadir 存储 在测试套件中访问静态数据
pytest-cov 覆盖率 使用 Coverage.py 生成覆盖率报告 --cov
xdoctest 文档 运行来自文档字符串的代码示例 --xdoctest
typeguard 类型检查 在运行时对你的代码进行类型检查 --typeguard-packages
提示

在 PyPI 上找到每个项目:https://pypi.org/project/<name>。项目主页在导航栏的项目链接下可用。

总结

在本章中,你学会了如何使用 pytest 测试你的 Python 项目:

  • 测试是运行你的代码并使用内置的assert来检查预期行为的函数。将它们的名称前缀—和包含模块的名称—与test_,pytest 会自动发现它们。

  • 固定装置是设置和撤销测试对象的函数或生成器;使用@pytest.fixture装饰器声明它们。你可以通过包含与装置名称相同的参数在测试中使用装置。

  • pytest 的插件可以提供有用的固定装置,以及修改测试执行,增强报告等等。

优秀软件的主要特征之一是易于变更,因为任何实际使用的代码都必须适应不断变化的需求和环境。测试以多种方式简化变更:

  • 它们将软件设计引向松散耦合的构建模块,你可以单独测试:减少依赖意味着减少变更障碍。

  • 它们记录并强制执行预期行为。这使你有自由和信心不断重构你的代码库—随着它的增长和转变保持可维护性。

  • 它们通过早期检测缺陷来降低变更成本。你越早发现问题,修复根本原因和开发修复方案的成本就越低。

端到端测试能够高度确保功能按设计方式工作—但它们速度慢、不稳定,并且很难分离失败的原因。让大部分测试都是单元测试。避免猴子补丁以打破代码的依赖性以进行可测试性。而是,将你的应用程序核心与 I/O、外部系统和第三方框架解耦。良好的软件设计和坚实的测试策略将使你的测试套件速度飞快且对变更具有弹性。

本章侧重于工具方面的事情,但良好的测试实践远不止这些。幸运的是,其他人已经写了一些关于这个主题的精彩文章。以下是我一直喜爱的三本书:

  • Kent Beck,《测试驱动开发:通过示例》(伦敦:Pearson,2002)。

  • Michael Feathers,《与遗留代码高效工作》(伦敦:Pearson,2004)。

  • Harry Percival 和 Bob Gregory,《Python 中的架构模式》(Sebastopol:O’Reilly,2020)。

如果你想了解如何使用 pytest 进行测试的所有内容,请阅读 Brian 的书:

  • Brian Okken,《Python Testing with pytest: Simple, Rapid, Effective, and Scalable》,第二版(Raleigh: The Pragmatic Bookshelf,2022)。

¹ 大型包可能有相同名称的模块——比如,gizmo.foo.registrygizmo.bar.registry。在 pytest 的默认导入模式下,测试模块必须具有唯一的完全限定名称——因此,你必须将 test_registry 模块放置在单独的 tests.footests.bar 包中。

² 记得按照 “为项目指定依赖项” 中描述的方式将 Rich 添加到你的项目中。如果你使用 Poetry,请参考 “管理依赖项”。

³ 我的审阅员 Hynek 建议一种避免这种陷阱并获得习惯性的 NameError 的技巧。诀窍是使用 @pytest.fixture(name="file") 明确地为 fixture 命名。这样你就可以使用一个私有名称来命名函数,比如 _file,它不会与参数发生冲突。

⁴ 注意略有不同的拼写变体 parametrize,而不是 parameterize

⁵ 记得将 httpx[http2] 添加为你项目的依赖。

cookiecutter-pytest-plugin 模板为你提供了一个稳固的项目结构,用于编写你自己的插件。

测试替身 是测试中使用的各种对象的总称,用以代替生产代码中使用的真实对象。Martin Fowler 在 2007 年 1 月 2 日发表的文章 “Mocks Aren’t Stubs” 提供了一个很好的概述。

第七章:使用 Coverage.py 测量覆盖率

当您的测试通过时,您对代码更改的信心有多大?

如果您将测试视为检测缺陷的一种方式,您可以描述它们的灵敏度和特异性。

您的测试套件的灵敏度是代码存在缺陷时测试失败的概率。如果大部分代码未经测试,或者测试未检查预期行为,则灵敏度低。

您测试的特异性是如果代码没有缺陷,则它们通过的概率。如果您的测试不稳定(它们偶尔失败)或脆弱(更改实现细节时失败),则特异性低。人们总是不重视失败的测试。不过,本章并非讨论特异性。

有一种极有效的策略可以提高您测试的灵敏度:在添加或更改行为时,在能够使其通过的代码之前编写一个失败的测试。如果这样做,您的测试套件将捕捉您对代码的期望。

另一个有效的策略是使用您期望在真实世界中遇到的各种输入和环境约束测试您的软件。覆盖函数的边缘情况,如空列表或负数。测试常见的错误场景,而不仅仅是“快乐路径”。

代码覆盖率是测试套件执行代码的程度的一种衡量。完全覆盖并不保证高灵敏度:如果您的测试覆盖了代码中的每一行,仍可能存在错误。尽管如此,它是一个上限。如果您的代码覆盖率为 80%,那么即使有多少错误悄然而至,也有 20%的代码将永远不会触发测试失败。它还是一个适合自动化工具的量化指标。这两个属性使覆盖率成为灵敏度的有用代理。

简而言之,覆盖率工具在运行代码时记录每行代码。完成后,它们报告执行的代码行相对于整个代码库的总百分比。

覆盖率工具不仅限于测量测试覆盖率。例如,代码覆盖率可帮助您查找大型代码库中 API 端点使用的模块。或者,您可以使用它来确定代码示例在多大程度上记录了项目。

在本章中,我将解释如何使用 Coverage.py 测量代码覆盖率,这是 Python 的一种覆盖率工具。在本章的主要部分中,您将学习如何安装、配置和运行 Coverage.py,以及如何识别源代码中缺失的行和控制流中缺失的分支。我将解释如何在多个环境和进程中测量代码覆盖率。最后,我将讨论您应该追求的代码覆盖率目标以及如何实现这些目标。

在 Python 中如何进行代码覆盖率测量?解释器允许您注册一个回调—​一个跟踪函数—​使用函数sys.settrace。从那时起,解释器在执行每行代码时—​以及在某些其他情况下,如进入或返回函数或引发异常时—​调用回调。覆盖工具注册一个跟踪函数,记录每个执行的源代码行到本地数据库中。

使用 Coverage.py

Coverage.py是一个成熟且广泛使用的 Python 代码覆盖工具。创建于 20 多年前—​早于 PyPI 和 setuptools—​并自那时以来一直活跃维护,它已在 Python 2.1 及以后的每个解释器上测量覆盖率。

coverage[toml]添加到您的测试依赖项(参见“管理测试依赖项”)。toml额外允许 Coverage.py 从pyproject.toml文件中读取配置,适用于旧版本的解释器。自 Python 3.11 起,标准库包括用于解析 TOML 文件的tomllib模块。

测量覆盖率是一个两步过程。首先,您使用coverage run在测试运行期间收集覆盖数据。其次,您使用coverage report从数据中编制汇总报告。每个命令在pyproject.toml文件的tool.coverage下都有一个表格。

首先配置您想要测量的包—​它让 Coverage.py 报告从未在执行中显示过的模块,就像之前的__main__模块一样。(即使没有此设置,它也不会淹没您关于标准库的报告。)指定您的顶级导入包,以及测试套件:

[tool.coverage.run]
source = ["random_wikipedia_article", "tests"]
提示

为测试套件测量代码覆盖率可能看起来很奇怪—​但您应该始终这样做。它在测试未运行时会提醒您,并帮助您识别其中的不可达代码。将您的测试视为任何其他代码一样对待。¹

您可以通过 Python 脚本调用coverage run,然后跟上其命令行参数。或者,您可以使用其-m选项与可导入模块。使用第二种方法—​确保您从当前环境运行 pytest:

$ py -m coverage run -m pytest

运行此命令后,您会在当前目录中找到一个名为.coverage的文件。Coverage.py 使用它来存储测试运行期间收集的覆盖数据。²

覆盖报告显示代码覆盖率的整体百分比,以及每个源文件的详细情况。使用show_missing设置还可以包括缺少覆盖的语句的行号:

[tool.coverage.report]
show_missing = true

运行coverage report以在终端中显示覆盖报告:

$ py -m coverage report
Name                                       Stmts   Miss  Cover   Missing
------------------------------------------------------------------------
src/random_wikipedia_article/__init__.py      26      2    92%   38-39
src/random_wikipedia_article/__main__.py       2      2     0%   1-3
tests/__init__.py                              0      0   100%
tests/test_main.py                            33      0   100%
------------------------------------------------------------------------
TOTAL                                         61      4    93%

总体而言,您的项目覆盖率为 93%—4 条语句在测试中从未出现。测试套件本身具有完整的覆盖率,正如您所期望的那样。

让我们仔细看看那些缺失的语句。覆盖率报告中的Missing列按行号列出它们。您可以使用代码编辑器显示带有行号的源代码,或者在 Linux 和 macOS 上使用标准的cat -n命令。同样,整个__main__模块的覆盖率也没有:

   1  from random_wikipedia_article import main  # missing
   2
   3  main()                                     # missing

init.py中缺失的行对应于main函数的主体:

  37  def main():
  38      article = fetch(API_URL)   # missing
  39      show(article, sys.stdout)  # missing

这很令人惊讶——示例 6-2 中的端到端测试运行整个程序,因此所有这些行肯定都在进行测试。暂时禁用对__main__模块的覆盖率测量:

[tool.coverage.run]
omit = ["*/__main__.py"]

您可以使用特殊注释来排除main函数:

def main():  # pragma: no cover
    article = fetch(API_URL)
    show(article, sys.stdout)

如果感觉这样做有些作弊,请继续阅读“在子进程中进行测量”,在那里您将重新启用这些行的覆盖率测量。

如果再次运行这两个步骤,Coverage.py 将报告完整的代码覆盖率。确保您能够注意到您的测试没有执行到的任何行。再次配置 Coverage.py,如果百分比再次低于 100%,则失败:

[tool.coverage.report]
fail_under = 100

分支覆盖率

如果一篇文章有空摘要,random-wikipedia-article会打印尾随空行(哎呀)。这些空摘要很少见,但它们确实存在,这应该是一个快速修复。示例 7-1 修改show以仅打印非空摘要。

示例 7-1. 仅在show中打印非空摘要
def show(article, file):
    console = Console(file=file, width=72, highlight=False)
    console.print(article.title, style="bold", end="\n\n")
    if article.summary:
        console.print(article.summary)

有趣的是,覆盖率保持在 100%——尽管您没有先编写测试。

默认情况下,Coverage.py 测量语句覆盖率——解释器在测试期间执行的模块中语句的百分比。如果摘要不为空,则会执行函数中的每条语句。

另一方面,测试只执行了函数中两条代码路径中的一条——它们从未跳过if体。Coverage.py 还支持分支覆盖率,它查看代码中所有语句之间的所有转换,并测量测试期间遍历这些转换的百分比。您应该始终启用它,因为它比语句覆盖更精确:

[tool.coverage.run]
branch = true

重新运行测试,您将看到 Coverage.py 标记了从第 34 行的if语句到函数退出的缺失转换:

$ py -m coverage run -m pytest
$ py -m coverage report
Name                  Stmts   Miss Branch BrPart  Cover   Missing
-----------------------------------------------------------------
src/.../__init__.py      24      0      6      1    97%   34->exit
tests/__init__.py         0      0      0      0   100%
tests/test_main.py       33      0      6      0   100%
-----------------------------------------------------------------
TOTAL                    57      0     12      1    99%
Coverage failure: total of 99 is less than fail-under=100

示例 7-2 使覆盖率恢复到 100%。它包括一个具有空摘要的文章,并添加了对尾随空行缺失测试。

示例 7-2. 测试空摘要的文章
article = parametrized_fixture(
    Article("test"), *ArticleFactory.build_batch(10)
)

def test_trailing_blank_lines(article, file):
    show(article, file)
    assert not file.getvalue().endswith("\n\n")

再次运行测试——它们失败了!您能找出示例 7-1 中的错误吗?

空摘要会产生两行空白行:一行用于分隔标题和摘要,另一行用于打印空摘要。您只删除了第二行。示例 7-3 也删除了第一行。感谢,Coverage.py!

示例 7-3. 在show中避免尾随空行
def show(article, file):
    console = Console(file=file, width=72, highlight=False)
    console.print(article.title, style="bold")
    if article.summary:
        console.print(f"\n{article.summary}")

在多个环境中进行测试

你经常需要支持各种 Python 版本。Python 每年发布新版本,而长期支持(LTS)发行版可以回溯到 Python 历史的十年前。终止生命周期的 Python 版本可能会有出人意料的余生—​发行商可能在核心 Python 团队结束支持后数年提供安全补丁。

让我们更新random-wikipedia-article以支持 Python 3.7,该版本在 2023 年 6 月已经终止生命周期。我假设你的项目需要 Python 3.10,并对所有依赖项设置了较低限制。首先,在pyproject.toml中放宽 Python 要求:

[project]
requires-python = ">=3.7"

接下来,检查你的依赖项是否与 Python 版本兼容。使用uv为 Python 3.7 环境编译一个单独的要求文件:

$ uv venv -p 3.7
$ uv pip compile --extra=tests pyproject.toml -o py37-dev-requirements.txt
  × No solution found when resolving dependencies: ...

错误表明你首选的 HTTPX 版本已经放弃了 Python 3.7。移除你的较低版本限制并重试。经过几个类似的错误和移除其他包的较低限制后,依赖解析最终成功。使用这些包的旧版本恢复较低限制。

你还需要后移植的importlib-metadata(参见“环境标记”)。将以下条目添加到project.dependencies字段:

importlib-metadata>=6.7.0; python_version < '3.8'

更新init.py模块以回退到后移植:

if sys.version_info >= (3, 8):
    from importlib.metadata import metadata
else:
    from importlib_metadata import metadata

再次编译要求。最后,更新你的项目环境:

$ uv pip sync py37-dev-requirements.txt
$ uv pip install -e . --no-deps

并行覆盖率

如果现在在 Python 3.7 下重新运行 Coverage.py,它会报告if语句的第一个分支缺失。这是有道理的:你的代码执行else分支并导入后移植而不是标准库。

可能会诱人地排除此行不计入覆盖率测量—​但不要这样做。像后移植这样的第三方依赖项也可能破坏你的代码。相反,从两个环境收集覆盖率数据。

首先,使用原始要求将环境切换回 Python 3.12:

$ uv venv -p 3.12
$ uv pip sync dev-requirements.txt
$ uv pip install -e . --no-deps

默认情况下,coverage run会覆盖任何现有的覆盖率数据—​但你可以告诉它追加数据。使用--append选项重新运行 Coverage.py,并确认你有完整的测试覆盖率:

$ py -m coverage run --append -m pytest
$ py -m coverage report

有一个覆盖率数据的单个文件,很容易意外擦除数据。如果忘记传递--append选项,你将不得不重新运行测试。你可以配置 Coverage.py 默认追加,但这也容易出错:如果忘记定期运行coverage erase,你的报告中将出现陈旧的数据。

有一种更好的方法可以跨多个环境收集覆盖率。Coverage.py 允许你在每次运行时在单独的文件中记录覆盖率数据。使用parallel设置启用此行为:³

[tool.coverage.run]
parallel = true

覆盖率报告始终基于单个数据文件,即使在并行模式下也是如此。你可以使用命令coverage combine合并数据文件。这将之前的两步过程变成三步过程:coverage run — coverage combine — coverage report

让我们把所有这些都整合在一起。对于每个 Python 版本,设置环境并运行测试,如下所示,例如 Python 3.7:

$ uv venv -p 3.7
$ uv pip sync py37-dev-requirements.txt
$ uv pip install -e . --no-deps
$ py -m coverage run -m pytest

此时,项目中将会有多个 .coverage. 文件。使用命令 coverage combine 将它们聚合成一个 .coverage 文件:

$ py -m run coverage combine
Combined data file .coverage.somehost.26719.001909
Combined data file .coverage.somehost.26766.146311

最后,使用 coverage report 生成覆盖率报告:

$ py -m coverage report

这样收集覆盖率听起来极其乏味吗?在 第八章 中,你将学习如何在多个 Python 环境中自动化测试。你将使用一个简单的三个字母的命令来运行整个过程。

在子进程中测量

在 “Using Coverage.py” 的结尾,你必须为 main 函数和 __main__ 模块禁用覆盖率。但端到端测试肯定会执行此代码。让我们移除 # pragma 注释和 omit 设置,搞清楚这个问题。

想想 Coverage.py 如何注册一个跟踪函数来记录执行的行。也许你已经猜到这里正在发生什么:端到端测试在单独的进程中运行你的程序。Coverage.py 从未在该进程的解释器上注册其跟踪函数。没有这些执行的行被记录在任何地方。

Coverage.py 提供了一个公共 API 来启用当前进程的跟踪:coverage.process_startup 函数。你可以在应用程序启动时调用此函数。但肯定有更好的方法 —— 你不应该修改代码来支持代码覆盖率。

结果表明,你并不需要这样做。你可以在环境中放置一个 .pth 文件,在解释器启动时调用该函数。这利用了一个鲜为人知的 Python 特性(见 “Site Packages”):解释器会执行 .pth 文件中以 import 语句开头的行。

将一个 _coverage.pth 文件安装到你的环境的 site-packages 目录中,内容如下:

import coverage; coverage.process_startup()

在 Linux 和 macOS 下,可以在 lib/python3.x 目录下找到 site-packages 目录,在 Windows 下可以在 Lib 目录下找到。

另外,你需要设置环境变量 COVERAGE_PROCESS_START。在 Linux 和 macOS 上,使用以下语法:

$ export COVERAGE_PROCESS_START=pyproject.toml

在 Windows 上,改用以下语法:

> $env:COVERAGE_PROCESS_START = 'pyproject.toml'

重新运行测试套件,合并数据文件,并显示覆盖率报告。由于在子进程中测量覆盖率,程序应该再次实现全面覆盖。

注意

测量子进程中的覆盖率仅在并行模式下有效。没有并行模式,主进程会覆盖子进程的覆盖数据,因为两者使用同一个数据文件。

目标覆盖率是多少

任何低于 100%的覆盖率意味着你的测试无法检测到代码库某些部分的 bug。如果你正在开发一个新项目,没有其他有意义的覆盖率目标。

这并不意味着您应该测试每一行代码。 考虑用于调试罕见情况的日志语句。 从测试中执行该语句可能会很困难。 同时,它可能是低风险,微不足道的代码。 编写该测试不会显着增加代码的信心。 使用pragma注释将该行从覆盖范围中排除:

if rare_condition:
    print("got rare condition")  # pragma: no cover

不要因为测试麻烦而将代码排除在覆盖范围之外。 当您开始使用新库或与新系统进行接口时,通常需要一些时间来弄清楚如何测试您的代码。 但是,通常这些测试最终会检测到在生产中可能未被注意到并引起问题的错误。

传统项目通常由具有最小测试覆盖率的大型代码库组成。 作为一般规则,在这种项目中,覆盖范围应单调增加—没有个别更改应导致覆盖率下降。

您经常会在这里陷入困境:要进行测试,您需要重构代码,但是没有测试的重构太有风险。 找到最小的安全重构来增加可测试性。 通常,这包括破坏被测试代码的依赖关系。⁴

例如,您可能正在测试一个大型函数,该函数除了其他功能之外还连接到生产数据库。 添加一个可选参数,让您可以从外部传递连接。 然后,测试可以将连接传递给内存数据库。

示例 7-4 概述了本章中使用的 Coverage.py 设置。

示例 7-4. 在pyproject.toml中配置 Coverage.py
[tool.coverage.run]
source = ["random_wikipedia_article", "tests"]
branch = true
parallel = true
omit = ["*/__main__.py"]  # avoid this if you can

[tool.coverage.report]
show_missing = true
fail_under = 100

总结

您可以使用 Coverage.py 来测量测试套件对项目的影响程度。 覆盖报告对发现未测试的行很有用。 分支覆盖率捕获程序的控制流,而不是源代码的孤立行。 并行覆盖允许您在多个环境中测量覆盖范围。 您需要在报告之前合并数据文件。 在子进程中测量覆盖率需要设置.pth文件和环境变量。

有效地为项目测量测试覆盖范围需要一定量的配置(),以及正确的工具咒语。 在下一章中,您将看到如何使用 Nox 自动化这些步骤。 您将设置检查,以便在更改时给您信心,同时不妨碍您的工作。

¹ Ned Batchelder:“您应该将测试包含在覆盖范围内,” 2020 年 8 月 11 日。

² 在幕后,.coverage文件只是一个 SQLite 数据库。 如果您的系统已准备好sqlite3命令行实用程序,则可以随意查看。

³ 名称parallel有点误导性; 这个设置与并行执行无关。

⁴ 马丁·福勒:“遗留系统的缝隙,” 2024 年 1 月 4 日。

第八章:自动化与 Nox

当你维护一个 Python 项目时,你面临许多任务。对你的代码进行检查是其中重要的一部分:

  • 测试帮助你降低代码的缺陷率(第六章)。

  • 覆盖率报告可以找出你代码中未经测试的部分(第七章)。

  • 代码检查器分析你的源代码,找到改进的方法(第九章)。

  • 代码格式化器以可读的方式排列源代码(第九章)。

  • 类型检查器验证你的代码的类型正确性(第十章)。

其他任务包括:

  • 你需要为分发构建和发布包(第三章)。

  • 你需要更新你的项目的依赖关系(第四章)。

  • 你需要部署你的服务(见 示例 5-7 在 第五章)。

  • 你需要为你的项目构建文档。

自动化这些任务有许多好处。你专注于编码,而检查套件则保护你的背后。你对将代码从开发到生产的步骤充满信心。你消除了人为错误,并对每个流程进行编码,以便他人可以审查和改进它。

自动化使你能够将每个步骤都尽可能地可重复,每个结果都尽可能地可再现。检查和任务在开发者机器上和持续集成(CI)中以相同的方式运行。它们跨不同的 Python 版本、操作系统和平台运行。

在本章中,你将学习 Nox,一个 Python 自动化框架。Nox 作为你的检查和任务的单一入口点—​为你的团队、外部贡献者和自动化系统如 CI 运行器。

你在纯 Python 中编写 Nox 会话:每个 Nox 会话都是一个执行命令的 Python 函数,它在专用的、隔离的环境中执行命令。使用 Python 作为自动化语言给予了 Nox 很好的简单性、可移植性和表现力。

Nox 初步

使用 pipx 全局安装 Nox:

$ pipx install --python=3.12 nox

指定最新稳定版本的 Python—​Nox 在创建环境时默认使用该版本。Pipx 使命令 nox 在全局范围内可用,同时将其依赖项与全局 Python 安装隔离开来(见 “使用 Pipx 安装应用程序”)。

在你的项目中,通过在 pyproject.toml 旁边创建一个名为 noxfile.py 的 Python 文件来配置 Nox。示例 8-1 展示了一个用于运行测试套件的 noxfile.py。它适用于一个没有测试依赖项的简单 Python 项目,除了 pytest。

示例 8-1. 用于运行测试套件的会话
import nox

@nox.session
def tests(session):
    session.install(".", "pytest")
    session.run("pytest")

会话 是 Nox 中的核心概念:每个会话包括一个环境和一些在其中运行的命令。您通过编写一个使用 @nox.session 装饰的 Python 函数来定义会话。该函数接收一个会话对象作为参数,您可以使用它来在会话环境中安装包 (session.install) 和运行命令 (session.run)。

您可以尝试使用前几章的示例项目来运行会话。现在,将您的测试依赖项添加到 session.install 参数中:

session.install(".", "pytest", "pytest-httpserver", "factory-boy")

无需参数即可调用 nox 来运行 noxfile.py 中的所有会话:

$ nox
nox > Running session tests
nox > Creating virtual environment (virtualenv) using python in .nox/tests
nox > python -m pip install . pytest
nox > pytest
========================= tests session starts =========================
...
========================== 21 passed in 0.94s ==========================
nox > Session tests was successful.

正如您从输出中看到的那样,Nox 首先通过使用 virtualenvtests 会话创建虚拟环境。如果您好奇,您可以在项目的 .nox 目录下找到此环境。

注意

默认情况下,环境使用与 Nox 本身相同的解释器。在 “使用多个 Python 解释器” 中,您将学习如何在另一个解释器上运行会话,甚至跨多个解释器运行。

首先,会话将项目和 pytest 安装到其环境中。函数 session.install 实际上是 pip install 的简单封装。您可以向 pip 传递任何适当的选项和参数。例如,您可以从要求文件中安装依赖项:

session.install("-r", "dev-requirements.txt")
session.install(".", "--no-deps")

如果将开发依赖项保存在额外位置,请使用以下方法:

session.install(".[tests]")

在上述示例中,您使用了 session.install(".") 来安装您的项目。在 pyproject.toml 中指定的构建后端下,pip 在包含 noxfile.py 的目录中运行 Nox,因此该命令假定这两个文件位于同一个目录中。

Nox 允许您使用 uv 替代 virtualenv 和 pip 来创建环境并安装包。您可以通过设置环境变量来将后端切换到 uv

$ export NOX_DEFAULT_VENV_BACKEND=uv

其次,会话运行刚刚安装的 pytest 命令。如果命令失败,则会将会话标记为失败。默认情况下,Nox 继续执行下一个会话,但如果任何会话失败,则最终将以非零状态退出。在上述运行中,测试套件通过,Nox 报告成功。

示例 8-2 添加了一个用于为项目构建包的会话(参见 第三章)。该会话还使用 Twine 的 check 命令验证包。

示例 8-2. 用于构建包的一个会话
import shutil
from pathlib import Path

@nox.session
def build(session):
    session.install("build", "twine")

    distdir = Path("dist")
    if distdir.exists():
        shutil.rmtree(distdir)

    session.run("python", "-m", "build")
    session.run("twine", "check", *distdir.glob("*"))

示例 8-2 依赖于标准库来清除陈旧的包并定位新构建的包:Path.glob 使用通配符匹配文件,而 shutil.rmtree 删除目录及其内容。

提示

Nox 不像 make 等工具那样隐式在 shell 中运行命令。由于平台之间的 shell 差异很大,它们会使 Nox 会话的可移植性降低。出于同样的原因,在会话中避免使用类 Unix 工具如 rmfind ——应使用 Python 的标准库代替!

使用 session.run 调用的程序应该在环境内可用。如果不可用,Nox 会打印友好的警告并回退到系统范围的环境。在 Python 世界中,以错误的环境运行程序是一个容易犯而难以诊断的错误。将警告转为错误!示例 8-3 展示了如何做到这一点。

示例 8-3. 在 Nox 会话中阻止外部命令
nox.options.error_on_external_run = True ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)

1

noxfile.py 的顶部(会话之外)修改 nox.options

有时,您确实需要运行非 Python 构建工具等外部命令。您可以通过向 session.run 传递 external 标志来允许外部命令。示例 8-4 展示了如何使用系统中现有的 Poetry 安装构建软件包。

示例 8-4. 使用外部命令构建软件包
@nox.session
def build(session):
    session.install("twine")
    session.run("poetry", "build", external=True)
    session.run("twine", "check", *Path().glob("dist/*"))

在这里,您正在权衡可靠性与速度。示例 8-2 可与 pyproject.toml 中声明的任何构建后端一起工作,并在每次运行时将其安装在隔离环境中。示例 8-4 假设贡献者系统中有最新版本的 Poetry,并且如果没有,会出问题。除非每个开发环境都有一个已知的 Poetry 版本,否则请优先考虑第一种方法。

会话操作

随着时间推移,noxfile.py 可能会积累多个会话。 --list 选项可以快速概览它们。如果您为模块和函数添加了有用的描述性文档字符串,Nox 也会将它们包含在列表中。

$ nox --list
Run the checks and tasks for this project.

Sessions defined in /path/to/noxfile.py:

* tests -> Run the test suite.
* build -> Build the package.

sessions marked with * are selected, sessions marked with - are skipped.

使用 --session 选项运行 Nox,可以按名称选择单个会话:

$ nox --session tests

在开发过程中,反复运行 nox 可以让您及早捕捉错误。另一方面,您不需要每次验证包。幸运的是,您可以通过设置 nox.options.sessions 来更改默认运行的会话:

nox.options.sessions = ["tests"]

现在,当您无参数运行 nox 时,只会运行 tests 会话。您仍然可以使用 --session 选项选择 build 会话。命令行选项会覆盖 noxfile.pynox.options 中指定的值。¹

小贴士

保持默认会话与项目的强制检查一致。贡献者应该能够无需参数运行 nox 来检查他们的代码更改是否可接受。

每次会话运行时,Nox 都会创建一个新的虚拟环境并安装依赖项。这是一个很好的默认设置,因为它使检查严格、可预测且可重复。您不会因为会话环境中的过期软件包而错过代码问题。

然而,Nox 给了您选择的余地。如果您在编码时快速连续重新运行测试,每次设置环境可能会有点慢。您可以使用 -r--reuse-existing-virtualenvs 选项重用环境。此外,您可以通过指定 --no-install 跳过安装命令,或者使用 -R 简写组合这些选项。

$ nox -R
nox > Running session tests
nox > Re-using existing virtual environment at .nox/tests.
nox > pytest
...
nox > Session tests was successful.

使用多个 Python 解释器

如果你的项目支持多个版本的 Python,你应该在所有这些版本上运行测试。当涉及到在多个解释器上运行会话时,Nox 真的非常出色。当你使用 @nox.session 定义会话时,可以使用 python 关键字请求一个或多个特定的 Python 版本,如 示例 8-5 所示。

示例 8-5. 在多个 Python 版本上运行测试
@nox.session(python=["3.12", "3.11", "3.10"])
def tests(session):
    session.install(".[tests]")
    session.run("pytest")

Nox 为每个版本创建一个环境,并依次在这些环境中运行命令:

$ nox
nox > Running session tests-3.12
nox > Creating virtual environment (virtualenv) using python3.12 in .nox/tests-3-12
nox > python -m pip install . pytest
nox > pytest
...
nox > Session tests-3.12 was successful.
nox > Running session tests-3.11
...
nox > Running session tests-3.10
...
nox > Ran multiple sessions:
nox > * tests-3.12: success
nox > * tests-3.11: success
nox > * tests-3.10: success

小贴士

刚才在运行 Nox 时是否收到了来自 pip 的错误?不要为每个 Python 版本使用相同的编译后的依赖文件。你需要为每个环境单独锁定依赖项(参见 “会话依赖项”)。

你可以使用 --python 选项按 Python 版本缩小会话范围:

$ nox --python 3.12
nox > Running session tests-3.12
...

在开发过程中,--python 选项非常方便,因为它只让你在最新版本上运行测试,从而节省时间。

Nox 通过在 PATH 中搜索 python3.12python3.11 等命令来发现解释器。你还可以指定类似 "pypy3.10" 的字符串来请求 PyPy 解释器—任何可以在 PATH 中解析的命令都可以工作。在 Windows 上,Nox 还查询 Python 启动器以查找可用的解释器。

假设你已经安装了 Python 的预发行版本,并想在其上测试你的项目。--python 选项将要求会话列出预发行版。相反,你可以指定 --force-python:它将覆盖解释器以进行单次运行。例如,以下调用在 Python 3.13 上运行 tests 会话:

$ nox --session tests --force-python 3.13

会话参数

到目前为止,tests 会话以无参数运行 pytest:

session.run("pytest")

可以传递额外的选项—比如 --verbose,它会在输出中单独列出每个单独的测试:

session.run("pytest", "--verbose")

但并非每次都需要相同的选项来运行 pytest。例如,--pdb 选项会在测试失败时启动 Python 调试器。在调查神秘错误时,调试提示符可能是救命稻草。但在 CI 环境中却比毫无用处更糟糕:它会永远挂起,因为没有人来输入命令。同样,当你在开发一个功能时,-k 选项允许你运行具有特定关键字名称的测试,但你也不希望在 noxfile.py 中硬编码它。

幸运的是,Nox 允许你为会话传递额外的命令行参数。会话可以将这些参数转发给一个命令或者用于自己的目的。会话参数可以在会话中作为 session.posargs 使用。示例 8-6 展示了如何将它们转发给像 pytest 这样的命令。

示例 8-6. 将会话参数转发给 pytest
@nox.session(python=["3.12", "3.11", "3.10"])
def tests(session):
    session.install(".[tests]")
    session.run("pytest", *session.posargs)

你必须使用 -- 分隔符将会话参数与 Nox 自己的命令行参数分开:

$ nox --session tests -- --verbose

自动化覆盖率

覆盖工具可以帮助您了解您的测试覆盖了多少代码库(参见第 7 章)。简而言之,您需要安装coverage包,并通过coverage run调用 pytest。示例 8-7 展示了如何使用 Nox 自动化此过程:

示例 8-7. 带代码覆盖运行测试
@nox.session(python=["3.12", "3.11", "3.10"])
def tests(session):
    session.install(".[tests]")
    session.run("coverage", "run", "-m", "pytest", *session.posargs)

当您在多个环境中进行测试时,您需要将每个环境的覆盖数据存储在单独的文件中(参见“并行覆盖”)。要启用此模式,请向pyproject.toml添加以下行:

[tool.coverage.run]
parallel = true

在第 7 章中,您以可编辑模式安装了您的项目。Nox 会话将构建并安装您项目的轮子。这确保您正在测试最终分发给用户的工件。但这也意味着 Coverage.py 需要将安装的文件映射回您的源树。请在pyproject.toml中配置映射:

[tool.coverage.paths]
source = ["src", "*/site-packages"] ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)

1

这将环境中安装的文件映射到您src目录中的文件。键source是任意标识符;这是必需的,因为此部分可以有多个映射。

示例 8-8 聚合覆盖文件并显示覆盖报告:

示例 8-8. 报告代码覆盖率
@nox.session
def coverage(session):
    session.install("coverage[toml]")
    if any(Path().glob(".coverage.*")):
        session.run("coverage", "combine")
    session.run("coverage", "report")

仅当存在覆盖数据文件时,会话才会调用coverage combine—否则该命令会失败。因此,您可以安全地使用nox -s coverage检查测试覆盖率,而无需首先重新运行测试。

与示例 8-7 不同,此会话在默认 Python 版本上运行,并且仅安装 Coverage.py。您无需安装项目即可生成覆盖报告。

如果您在示例项目上运行这些会话,请确保按照第 7 章中所示配置 Coverage.py。如果您的项目使用条件导入importlib-metadata,请在tests会话中包含 Python 3.7。

$ nox --session coverage
nox > Running session coverage
nox > Creating virtual environment (uv) using python in .nox/coverage
nox > uv pip install 'coverage[toml]'
nox > coverage combine
nox > coverage report
Name                  Stmts   Miss Branch BrPart  Cover   Missing
-----------------------------------------------------------------
src/.../__init__.py      29      2      8      0    95%   42-43
src/.../__main__.py       2      2      0      0     0%   1-3
tests/__init__.py         0      0      0      0   100%
tests/test_main.py       36      0      6      0   100%
-----------------------------------------------------------------
TOTAL                    67      4     14      0    95%
Coverage failure: total of 95 is less than fail-under=100
nox > Command coverage report failed with exit code 2
nox > Session coverage failed.

注意

coverage会话仍然报告了main函数和__main__模块的缺失覆盖。您将在“子进程中自动化覆盖率”中处理这些问题。

会话通知

就目前而言,这个noxfile.py存在一个微妙的问题。在运行coverage会话之前,您的项目将充斥着等待处理的数据文件。如果您最近没有运行tests会话,则这些文件中的数据可能已过时—因此您的覆盖报告将不会反映代码库的最新状态。

示例 8-9 在测试套件之后自动触发coverage会话运行。Nox 通过session.notify方法支持此功能。如果通知的会话尚未选定,则它会在其他会话完成后运行。

示例 8-9. 从测试触发覆盖率报告
@nox.session(python=["3.12", "3.11", "3.10"])
def tests(session):
    session.install(".[tests]")
    try:
        session.run("coverage", "run", "-m", "pytest", *session.posargs)
    finally:
        session.notify("coverage")

try...finally块确保即使测试失败,您也能获得覆盖报告。当您从一个失败的测试开始开发时,这非常有帮助:您希望确保测试覆盖您正在编写的代码以使其通过。

在子进程中自动化覆盖率

Alan Kay,面向对象编程和图形用户界面设计的先驱,曾经说过:“简单的事情应该是简单的;复杂的事情应该是可能的。”² 许多 Nox 会话只有两行:一行用于安装依赖项,一行用于运行命令。然而,一些自动化任务需要更复杂的逻辑,而 Nox 也擅长处理这些,主要是通过不阻碍您并推迟到 Python 作为一种通用编程语言。

让我们在tests会话中进行迭代,并在子进程中测量覆盖率。正如您在第七章中看到的那样,设置这个需要一些技巧。首先,您需要将.pth文件安装到环境中;这使得在子进程启动时coverage有机会初始化。其次,您需要设置一个环境变量来指向coverage的配置文件。这些操作都有点繁琐且需要一定技巧才能做好。让我们“自动化”它吧!

首先,您需要确定.pth文件的位置。目录名为site-packages,但确切的路径取决于您的平台和 Python 版本。您可以通过查询sysconfig模块来获取它,而不是猜测:

sysconfig.get_path("purelib")

如果您直接在会话中调用函数,它将返回您已经安装 Nox 的环境中的位置。而实际上,您需要查询会话环境中的解释器。您可以通过使用session.run来运行python来实现这一点:

output = session.run(
    "python",
    "-c",
    "import sysconfig; print(sysconfig.get_path('purelib'))",
    silent=True,
)

silent关键字允许您捕获输出而不是将其回显到终端。由于标准库中的pathlib,现在编写.pth文件只需要几个语句:

purelib = Path(output.strip())
(purelib / "_coverage.pth").write_text(
    "import coverage; coverage.process_startup()"
)

示例 8-10 将这些语句提取到一个助手函数中。该函数接受一个session参数,但它不是一个 Nox 会话——它缺少@nox.session装饰器。换句话说,如果不从会话中调用它,该函数将不会运行。

示例 8-10. 将coverage.pth安装到环境中
def install_coverage_pth(session):
    output = session.run(...)  # see above
    purelib = Path(output.strip())
    (purelib / "_coverage.pth").write_text(...)  # see above

您已经快完成了。剩下的是从tests会话中调用助手函数并将环境变量传递给coverage。示例 8-11 展示了最终的会话。

示例 8-11. 启用子进程覆盖率的tests会话
@nox.session(python=["3.12", "3.11", "3.10"])
def tests(session):
    session.install(".[tests]") ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
    install_coverage_pth(session)

    try:
        args = ["coverage", "run", "-m", "pytest", *session.posargs]
        session.run(*args, env={"COVERAGE_PROCESS_START": "pyproject.toml"})
    finally:
        session.notify("coverage")

1

.pth文件之前安装依赖项。顺序很重要,因为.pth文件会导入coverage包。

启用子进程覆盖率后,端到端测试将为main函数和__main__模块生成缺失的覆盖数据。调用nox并观看它运行您的测试并生成覆盖报告。以下是报告的示例内容:

$ nox --session coverage
nox > coverage report
Name                  Stmts   Miss Branch BrPart  Cover   Missing
-----------------------------------------------------------------
src/.../__init__.py      29      0      8      0   100%
src/.../__main__.py       2      0      0      0   100%
tests/__init__.py         0      0      0      0   100%
tests/test_main.py       36      0      6      0   100%
-----------------------------------------------------------------
TOTAL                    67      0     14      0   100%
nox > Session coverage was successful.

参数化会话

“对我有效”这个短语描述了一个常见的情况:用户报告您代码的问题,但您无法在自己的环境中重现该错误。真实世界的运行时环境在许多方面都是不同的。跨 Python 版本进行测试涵盖了一个重要变量。另一个常见的令人惊讶的原因是您的项目直接或间接使用的包——即其依赖树。

Nox 为测试项目与不同版本依赖提供了强大的技术。参数化允许您向会话函数添加参数并为其提供预定义的值;Nox 会使用每个值运行会话。

您在名为@nox.parametrize的装饰器中声明参数及其值。³示例 8-12 演示了此功能及其如何允许您针对 Django Web 框架的不同版本进行测试。

示例 8-12. 使用多个 Django 版本测试项目
@nox.session
@nox.parametrize("django", ["5.*", "4.*", "3.*"])
def tests(session, django):
    session.install(".", "pytest-django", f"django=={django}")
    session.run("pytest")

参数化会话类似于 pytest 中的参数化测试(参见第六章),这是 Nox 借用的概念。您可以堆叠@nox.parametrize装饰器,以针对所有参数组合运行会话:

@nox.session
@nox.parametrize("a", ["1.0", "0.9"])
@nox.parametrize("b", ["2.2", "2.1"])
def tests(session, a, b):
    print(a, b)  # all combinations of a and b

如果您只想检查某些组合,可以将参数组合在单个@nox.parametrize装饰器中:

@nox.session
@nox.parametrize(["a", "b"], [("1.0", "2.2"), ("0.9", "2.1")])
def tests(session, a, b):
    print(a, b)  # only the combinations listed above

当跨 Python 版本运行会话时,您实际上是通过解释器对会话进行参数化。事实上,Nox 让您可以这样写,而不是将版本传递给@nox.session:⁴

@nox.session
@nox.parametrize("python", ["3.12", "3.11", "3.10"])
def tests(session):
    ...

在您想要特定 Python 和依赖组合时,此语法非常有用。以下是一个示例:截至本文撰写时,Django 3.2(LTE)并不正式支持比 3.10 更新的 Python 版本。因此,您需要从测试矩阵中排除这些组合。示例 8-13 展示了如何实现这一点。

示例 8-13. 使用有效的 Python 和 Django 组合进行参数化
@nox.session
@nox.parametrize(
    ["python", "django"],
    [
        (python, django)
        for python in ["3.12", "3.11", "3.10"]
        for django in ["3.2.*", "4.2.*"]
        if (python, django) not in [("3.12", "3.2"), ("3.11", "3.2")]
    ]
)
def tests(session, django):
    ...

会话依赖

如果您仔细阅读了第四章,您可能会注意到示例 8-8 和示例 8-11 安装包的方式存在一些问题。这里再次列出相关部分:

@nox.session
def tests(session):
    session.install(".[tests]")
    ...

@nox.session
def coverage(session):
    session.install("coverage[toml]")
    ...

首先,coverage会话没有指定项目需要的coverage版本。tests会话做得对:它引用了pyproject.toml中的tests额外部分,其中包含适当的版本说明符(参见“管理测试依赖”)。

coverage会话不需要项目,所以额外的似乎不太合适。但在我继续之前,让我指出上述会话的另一个问题:它们没有锁定它们的依赖关系。

在不锁定依赖项的情况下运行检查有两个缺点。首先,检查不是确定性的:同一会话的后续运行可能会安装不同的包。其次,如果一个依赖关系破坏了你的项目,检查将失败,直到你排除该发布或另一个发布修复了问题。⁵ 换句话说,你依赖的任何项目,甚至间接依赖的项目,都有可能阻塞你整个持续集成流水线。

另一方面,锁定文件的更新是一个不断变化的过程,并且会混淆你的 Git 历史记录。减少它们的频率是以使用过时依赖项来运行检查为代价的。如果你没有其他原因要求锁定,比如安全部署,且愿意在不兼容的发布混乱你的持续集成时快速修复构建,你可能更愿意保持你的依赖项未锁定。没有免费的午餐。

在“开发依赖”中,你将额外的依赖项分组并从每个编译需求文件。在本节中,我将向你展示一种更轻量级的锁定方法:约束文件。你只需要一个额外的依赖项。它也不需要像通常那样安装项目本身,这有助于coverage会话。

约束文件看起来类似于需求文件:每行列出一个带有版本说明符的包。然而,与需求文件不同,约束文件不会导致 pip 安装一个包——它们只控制 pip 在需要安装包时选择的版本。

约束文件非常适合用于锁定会话依赖项。你可以在会话之间共享它,同时仅安装每个会话所需的包。与使用一组需求文件相比,它唯一的缺点是需要一起解决所有依赖关系,因此存在更高的依赖冲突的可能性。

你可以使用 pip-tools 或 uv 生成约束文件(请参阅“使用 pip-tools 和 uv 编译需求”)。Nox 也可以自动化这一部分,如示例 8-14 所示。

示例 8-14. 使用 uv 锁定依赖项
@nox.session(venv_backend="uv") ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
def lock(session):
    session.run(
        "uv",
        "pip",
        "compile",
        "pyproject.toml",
        "--upgrade",
        "--quiet",
        "--all-extras",
        "--output-file=constraints.txt",
    )

1

明确要求将 uv 作为环境后端。(你也可以在会话中安装 uv,方法是session.install("uv"),但 uv 支持比你可以用来安装其 PyPI 包的 Python 版本范围更广。)

--output-file选项指定约束文件的传统名称,constraints.txt--upgrade选项确保您在每次运行会话时获取最新的依赖关系。--all-extras选项包含项目的所有可选依赖项。

提示

不要忘记将约束文件提交到源代码控制。你需要与每个贡献者分享这个文件,并且它需要在持续集成中可用。

使用 --constraint-c 选项将约束文件传递给 session.install。示例 8-15 显示了带有锁定依赖项的 testscoverage 会话。

示例 8-15. 使用约束文件锁定会话依赖项
@nox.session(python=["3.12", "3.11", "3.10"])
def tests(session):
    session.install("-c", "constraints.txt", ".[tests]")
    ...

@nox.session
def coverage(session):
    session.install("-c", "constraints.txt", "coverage[toml]")
    ...

使用单个约束文件需要定位一个众所周知的解释器和平台。你不能在不同的环境中使用相同的约束文件,因为每个环境可能需要不同的包。

如果你支持多个 Python 版本、操作系统或处理器架构,请为每个环境编译一个单独的约束文件。将约束文件保存在子目录中以避免混乱。示例 8-16 显示了一个辅助函数,用于构建类似 constraints/python3.12-linux-arm64.txt 的文件名。

示例 8-16. 构建约束文件的路径
import platform, sys
from pathlib import Path

def constraints(session):
    filename = f"python{session.python}-{sys.platform}-{platform.machine()}.txt"
    return Path("constraints") / filename

示例 8-17 更新了 lock 会话以生成约束文件。该会话现在在每个 Python 版本上运行。它使用辅助函数构建约束文件的路径,确保目标目录存在,并将文件名传递给 uv。

示例 8-17. 在多个 Python 版本上锁定依赖项
@nox.session(python=["3.12", "3.11", "3.10"], venv_backend="uv")
def lock(session):
    filename = constraints(session)
    filename.parent.mkdir(exist_ok=True)
    session.run("uv", "pip", "compile", ..., f"--output-file={filename}")

testscoverage 会话现在可以引用每个 Python 版本的适当约束文件。为使其正常工作,你必须为 coverage 会话声明一个 Python 版本。

示例 8-18. testscoverage 会话与多 Python 约束
@nox.session(python=["3.12", "3.11", "3.10"])
def tests(session):
    session.install("-c", constraints(session), ".", "pytest", "coverage[toml]")
    ...

@nox.session(python="3.12")
def coverage(session):
    session.install("-c", constraints(session), "coverage[toml]")
    ...

使用 Nox 处理 Poetry 项目

如果你使用 Poetry 管理项目,则将依赖项组织在依赖组中(参见 “Dependency Groups”)。依赖组与 Nox 会话自然对齐:tests 会话的包进入 tests 组,docs 会话的包进入 docs 组,依此类推。使用 Poetry 作为安装程序意味着你免费获得了锁定的依赖项——所有安装都遵循锁定文件。

在我向你展示如何在 Nox 会话中使用 Poetry 之前,让我先指出 Poetry 环境和 Nox 环境之间的一些区别。

首先,Poetry 环境非常全面:默认情况下,它包括项目、其主要依赖项以及每个非可选的依赖组。Nox 环境仅安装任务自动化所需的包。

其次,Poetry 环境使用可编辑安装项目,因此你不需要在每次代码更改后重新安装。Nox 环境构建并安装轮子,因此自动化检查会看到与最终用户相同的项目。

这里没有对错之分。Poetry 环境非常适合在开发过程中与项目进行临时交互,每个工具都只需执行 poetry run 即可。另一方面,Nox 环境则针对可靠和可重复的检查进行了优化;它们旨在尽可能地隔离和确定性。

在 Nox 会话中使用 Poetry 时,需要注意这些差异。我建议在使用 Nox 调用poetry install时遵循以下准则:

  • 使用选项--no-root避免对项目进行可编辑的安装。如果会话需要安装项目(并非每个会话都需要),请在命令后跟随session.install(".")来构建和安装一个 wheel。

  • 使用选项--only=*<group>*为会话安装适当的依赖组。如果会话需要安装项目,请同时列出特殊组main—​这确保每个软件包都使用poetry.lock进行固定。

  • 使用选项--sync从重用的会话环境中删除项目不再依赖的软件包。

示例 8-19 将此逻辑放入一个辅助函数中,您可以在会话函数之间共享它。

示例 8-19. 使用 Poetry 安装会话依赖项
def install(session, groups, root=True):
    if root:
        groups = ["main", *groups]

    session.run_install(
        "poetry",
        "install",
        "--no-root",
        "--sync",
        f"--only={','.join(groups)}",
        external=True,
    )
    if root:
        session.install(".")

辅助函数使用session.run_install而不是session.run。这两个函数的工作方式完全相同,但session.run_install将命令标记为安装操作。这在您使用--no-install-R重用环境时避免了软件包安装。

如果您已经在第六章和第七章中使用 Poetry 进行了跟随,那么您的pyproject.toml应该有一个名为tests的依赖组,其中包含 pytest 和 coverage。让我们将coverage依赖项拆分到单独的组中,因为您不需要coverage会话的测试依赖项:⁶

[tool.poetry.group.coverage.dependencies]
coverage = {extras = ["toml"], version = ">=7.4.4"}

[tool.poetry.group.tests.dependencies]
pytest = ">=8.1.1"

下面是如何在tests会话中安装依赖项的示例:

@nox.session(python=["3.12", "3.11", "3.10"])
def tests(session):
    install(session, groups=["coverage", "tests"])
    ...

下面是带有辅助函数的coverage会话的样子:

@nox.session
def coverage(session):
    install(session, groups=["coverage"], root=False)
    ...
小贴士

Poetry 如何知道要使用 Nox 环境而不是 Poetry 环境?Poetry 会将软件包安装到活动环境中(如果存在)。当 Nox 运行 Poetry 时,它通过导出VIRTUAL_ENV环境变量来激活会话环境(参见“虚拟环境”)。

使用nox-poetry锁定依赖关系

使用 Nox 与 Poetry 项目的另一种方法是选择。nox-poetry包是 Nox 的非官方插件,允许您使用简单的session.install编写会话,无需担心锁定。在幕后,nox-poetry通过poetry export导出一个 pip 的约束文件。

nox-poetry安装到与 Nox 相同的环境中:

$ pipx inject nox nox-poetry

使用nox-poetry包中的@session装饰您的会话,它是@nox.session的替代品:

from nox_poetry import session

@session
def tests(session):
    session.install(".", "coverage[toml]", "pytest")
    ...

使用session.install安装软件包时,约束文件会将它们的版本与poetry.lock保持同步。您可以将依赖项管理在单独的依赖组中,也可以将它们放入单个dev组中。

尽管这种方法方便且简化了结果的 noxfile.py,但它并非免费的。将 Poetry 的依赖信息翻译为约束文件并非无损—例如,它不包括包哈希或私有包存储库的 URL。另一个缺点是贡献者需要担心另一个全局依赖。当我在 2020 年编写 nox-poetry 时,不存在依赖组。截至撰写本文时,我建议直接使用 Poetry,正如前一节所述。

摘要

Nox 让你能够自动化项目的检查和任务。它的 Python 配置文件 noxfile.py 将它们组织成一个或多个会话。会话是使用 @nox.session 装饰的函数。它们接收一个名为 session 的参数,提供会话 API(表 8-1)。每个会话在独立的虚拟环境中运行。如果你向 @nox.session 传递一个 Python 版本列表,Nox 将在所有这些版本上运行该会话。

表 8-1. 会话对象

属性 描述 示例
run() 运行命令 session.run("coverage", "report")
install() 使用 pip 安装包 session.install(".", "pytest")
run_install() 运行安装命令 session.run_install("poetry", "install")
notify() 将另一个会话加入队列 session.notify("coverage")
python 该会话的解释器 "3.12"
posargs 额外的命令行参数 nox -- --verbose --pdb

命令 nox(表 8-2)为你的一套检查提供了单一入口点。如果没有参数,它将运行 noxfile.py 中定义的每个会话(或者你在 nox.options.sessions 中列出的会话)。尽早发现代码问题,修复起来就越便宜—因此使用 Nox 在本地运行与持续集成(CI)中相同的检查。除了检查,你还可以自动化许多其他任务,如构建包或文档。

表 8-2. nox 的命令行选项

选项 描述 示例
--list 列出可用的会话 nox -l
--session 通过名称选择会话 nox -s tests
--python 选择解释器会话 nox -p 3.12
--force-python 为会话强制指定解释器 nox --force-python 3.13
--reuse-existing-virtualenvs 重用现有的虚拟环境 nox -rs tests
--no-install 跳过安装命令 nox -Rs tests

Nox 还有更多内容本章没有覆盖到。例如,你可以使用 Conda 或 Mamba 创建环境并安装包。你可以使用关键字和标签来组织会话,并使用 nox.param 分配友好的标识符。最后但同样重要的是,Nox 提供了一个 GitHub Action,方便在 CI 中运行 Nox 会话。请查阅官方文档以了解更多。

¹ 如果你在想,始终在noxfile.py中使用复数形式nox.options.sessions。在命令行中,--session--sessions都可用。这些选项可以指定任意数量的会话。

² 艾伦·凯,“艾伦·凯的格言 简单的事物应该简单,复杂的事物应该是可能的 的背后有什么故事?”Quora 回答,2020 年 6 月 19 日。

³ 与 pytest 类似,Nox 使用替代拼写“parametrize”来保护你的“E”键帽免受过度磨损。

⁴ 眼尖的读者可能会注意到,这里python不是一个函数参数。如果你确实需要在会话函数中使用它,请改用session.python

⁵ 在这里,语义版本控制的约束比它的帮助更有害。所有版本都可能出现错误,而你的上游定义的破坏性更改可能比你想象的要狭窄。请参见 Hynek Schlawack 的文章:“语义版本控制无法拯救你”, 2021 年 3 月 2 日。

⁶ 编辑pyproject.toml后运行poetry lock --no-update,以更新poetry.lock文件。

第九章:使用 Ruff 和 pre-commit 进行代码检查

1978 年,贝尔实验室的研究员 Stephen C. Johnson 编写了一个程序,可以检测 C 代码中的许多错误和模糊之处。他将该程序命名为您从洗衣机中取出毛衣时的绒毛:Lint。它成为一系列 linters 的第一个,这些程序分析源代码并指出问题构造。

Linters 不会 运行 程序来发现问题;它们读取并分析源代码。这个过程被称为 静态分析,与 运行时(或 动态分析 相对。这使得 linters 既快速又安全 —— 你不必担心副作用,比如对生产系统的请求。静态检查可以很智能,也相当完整 —— 你不必命中边缘情况的正确组合来挖掘潜在的 bug。

注意

静态分析很强大,但你仍然应该为你的程序编写测试。静态检查使用推断,而测试使用观察。Linters 验证一组有限的通用代码属性,而测试可以验证程序是否满足其要求。

Linters 在强制执行可读和一致的风格方面也表现出色,它们更倾向于惯用和现代构造,而不是晦涩和已弃用的语法。组织多年来一直采用风格指南,比如 PEP 8 中的建议或 Google Style Guide for Python。Linters 可以作为 可执行 风格指南:通过自动标记违规构造,它们使代码审查专注于更改的含义,而不是风格上的琐碎细节。

本章分为三部分:

  • 第一部分介绍了 Ruff linter,这是 Python linters 的 Rust 实现,它可以自动修复检测到的许多问题。

  • 第二部分描述了 pre-commit,一个与 Git 集成的代码检查框架。

  • 第三部分介绍了 Ruff 代码格式化程序,这是 Black 代码风格的 Rust 实现。

但首先,让我们看看 linters 帮你解决的典型问题。

检查基础知识

linters 标记的结构可能不是绝对不合法的。更多情况下,它们只是触发了你的直觉,感觉可能有问题。考虑一下 Example 9-1 中的 Python 代码。

Example 9-1。你能发现问题吗?
import subprocess

def run(command, args=[], force=False):
    if force:
        args.insert(0, "--force")
    subprocess.run([command, *args])

如果你以前没有遇到过这个 bug,你可能会惊讶地发现这个函数有时会在不应该的时候传递--force给命令:

>>> subprocess.run = print  # print commands instead of running them
>>> run("myscript.py", force=True)
['myscript.py', '--force']
>>> run("myscript.py")
['myscript.py', '--force']

这个 bug 被称为可变参数默认值。Python 在定义函数时计算参数默认值,而不是在调用函数时。换句话说,你的两次调用都使用了相同的列表作为args的默认值。第一次调用附加了项目"--force",所以这个项目也传递给了第二次调用。

代码审查工具可以检测到这样的陷阱,提醒您并甚至为您修复它们。让我们在这个函数上使用一个名为 Ruff 的代码审查工具——在本章中,您会听到更多关于它的内容。目前,只需注意其错误消息,该消息标识了错误:¹

$ pipx run ruff check --extend-select B
bad.py:3:23: B006 Do not use mutable data structures for argument defaults
Found 1 error.
No fixes available (1 hidden fix can be enabled with the `--unsafe-fixes` option)

Ruff 代码审查工具

Ruff 是一个采用 Rust 编程语言编写的极快的开源 Python 代码审查工具和代码格式化程序。Ruff 的代码审查工具重新实现了数十个 Python 代码审查工具——包括许多 Flake8 插件、Pylint、Pyupgrade、Bandit、Isort 等等。

Astral 公司,Ruff 的背后,还创建了 Python 打包工具 uv(见 “使用 uv 管理环境”),他们还承担了 Rye 的管理工作,这是一个 Python 项目管理器(见 “使用 Rye 管理软件包”)。所有这些工具都是用 Rust 实现的。

提示

如果您使用 Rye 管理项目,则可以在命令 rye lintrye fmt 下使用 Ruff 代码审查工具和代码格式化程序,分别。

使用 pipx 全局安装 Ruff:

$ pipx install ruff

但等等——pipx 怎么会安装 Rust 程序呢?Ruff 二进制程序作为一个 wheel 文件在 PyPI 上可用,所以像您我这样的 Python 爱好者可以用老 pip 和 pipx 安装它。您甚至可以用 py -m ruff 运行它。

让我们再看一个使用 Ruff 的例子。考虑对 HTTP 标头进行的这次重构,将列表替换为字典:

示例 9-2. 将 HTTP 标头列表转换为字典
headers = [f"User-Agent: {USER_AGENT}"] # version 1
headers = {f"User-Agent": USER_AGENT}   # version 2

当您重构 f-strings 时,很容易在移除占位符后留下 f 前缀。Ruff 会标记没有占位符的 f-strings——它们很吵闹,会让读者困惑,也可能有人忘记包含占位符。

运行命令 ruff check——Ruff 代码审查工具的前端。没有参数,该命令会审查当前目录下的每个 Python 文件,除非它在 .gitignore 文件中列出:

$ ruff check
example.py:2:12: F541 [*] f-string without any placeholders
Found 1 error.
[*] 1 fixable with the `--fix` option.

图 9-1 详细介绍了 Ruff 的诊断消息。

图中显示了 Ruff 的一个示例警告并标识了其各部分。第一部分是位置 'example.py:1:7:',由文件名、行和列组成。第二部分是规则代码 'F541',由代码审查工具前缀 'F' 和数字代码 '541' 组成,表示该代码审查工具的特定规则。第三部分是符号 '[*]',通知您可以使用修复功能。第四部分也是最后一部分是概要 'f-string 没有任何占位符'。

图 9-1. Ruff 的诊断消息

Ruff 会告诉你违规出现的位置——文件、行和行偏移——并简要说明出了什么问题:f-string 没有任何占位符。在位置和概要之间夹杂着两个有趣的部分:一个字母数字代码(F541)标识了代码审查工具规则,而符号 [*] 表示 Ruff 可以自动修复该问题。

如果您对收到的警告感到困惑,可以使用命令 ruff rule 要求 Ruff 解释它:

$ ruff rule F541
f-string-missing-placeholders (F541)

Derived from the Pyflakes linter.

Fix is always available.

What it does
Checks for f-strings that do not contain any placeholder expressions.

Why is this bad?
f-strings are a convenient way to format strings, but they are not
necessary if there are no placeholder expressions to format. In this
...

规则代码以一个或多个字母开头,后跟三个或更多数字。前缀标识特定的代码检查工具,例如 F541 中的 F 表示 Pyflakes 检查工具。Ruff 重新实现了许多 Python 代码质量工具,截至目前,它内置了超过 50 个插件,这些插件模仿了现有工具。你可以使用命令 ruff linter 查看可用的检查工具:

$ ruff linter
   F Pyflakes
 E/W pycodestyle
 C90 mccabe
   I isort
   N pep8-naming
   D pydocstyle
  UP pyupgrade
... (50+ more lines)

你可以在 pyproject.toml 文件中为项目激活检查工具和单独的规则。设置 tool.ruff.lint.select 可以启用任何代码以给定前缀开头的规则。Ruff 默认启用了一些基本的全面检查,来自 Pyflakes 和 Pycodestyle:

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]

Pyflakes 和 Pycodestyle

Pyflakes (F) 标记几乎肯定是错误的结构,比如未使用的导入或前面看到的无意义的 f-字符串。它避免了任何风格问题。Pycodestyle(其规则使用前缀 EW)检测违反 PEP 8 的情况,这是由 Python 的发明者 Guido van Rossum、Barry Warsaw 和 Alyssa Coghlan 原创的风格指南。

Ruff 默认只启用 Pycodestyle 的部分功能,因为代码格式化工具已经替代了其中的许多检查。然而,PEP 8 提出了超出纯代码格式化的风格建议。你是否认同 x is not Nonenot x is None 更自然?默认规则能够检测和修复许多这类问题,使得代码更易读和理解。

提示

如果你没有使用特定的代码格式化工具,考虑启用整个 EW 块。它们的自动修复有助于确保最小的 PEP 8 符合性。它们类似于 Autopep8 格式化器(参见 “代码格式化方法:Autopep8”)。³

奇幻检查工具及其去向

Ruff 包含太多规则,无法在本书中详细描述,而且还在不断添加新规则。如何找到适合项目的好规则?试试它们吧!根据项目的需要,你可以启用单个规则 ("B006"),规则组 ("E4"),整个插件 ("B"),甚至同时启用所有现有插件 ("ALL")。

警告

保留特殊代码 ALL 用于实验:它将在您升级 Ruff 时隐式启用新的检查工具。注意:某些插件需要配置才能产生有用的结果,而某些规则可能与其他规则冲突。⁴

除了 select,Ruff 还有一个 extend-select 指令,可以选择额外的规则集(参见 “检查基础知识”)。通常建议使用 select 指令,因为它保持配置的自包含性和明确性:

[tool.ruff.lint]
select = ["E", "W", "F", "B006"]

如果你不确定从哪里开始,表 9-1 描述了一些内置插件,可以尝试使用。

表 9-1. 十二个广泛使用的 Ruff 插件

前缀 名称 描述
RUF Ruff 特定规则 Ruff 本地的一系列检查项
I isort 对导入语句进行分组和排序
UP pyupgrade 使用与目标 Python 版本兼容的现代语言特性
SIM flake8-simplify 使用惯用结构简化代码
FURB refurb 使用惯用结构使优秀代码更加完善
PIE flake8-pie 一系列杂项检查项
PERF Perflint 避免性能反模式
C4 flake8-comprehensions 使用 listsetdict 推导式
B flake8-bugbear 消除可能的错误和设计问题
PL Pylint 来自所有 Python 代码检查工具之母的大量规则集合
D pydocstyle 要求函数、类和模块的文档字符串格式良好
S flake8-bandit 检测潜在的安全漏洞

在将传统项目接入 Ruff 时,首要任务是决定哪些代码检查工具提供了最有用的反馈。在此阶段,单独的诊断可能会非常压倒性。使用 --statistics 选项可以放大视角:

$ ruff check --statistics --select ALL
123	I001   	[*] Import block is un-sorted or un-formatted
 45	ARG001 	[ ] Unused function argument: `bindings`
 39	UP007  	[*] Use `X | Y` for type annotations
 32	TRY003 	[ ] Avoid specifying long messages outside the exception class
 28	SIM117 	[ ] Use a single `with` statement with multiple contexts
 23	SLF001 	[ ] Private member accessed: `_blob`
 17	FBT001 	[ ] Boolean-typed positional argument in function definition
 10	PLR0913	[ ] Too many arguments in function definition (6 > 5)
...

此时,你有两个选择。首先,如果某个代码检查工具的输出特别嘈杂,可以使用 --ignore 选项隐藏它。例如,如果尚未准备好添加类型注解和文档字符串,可以使用 --ignore ANN,D 排除 flake8-annotationspydocstyle。其次,如果发现某个代码检查工具有有趣的发现,可以在 pyproject.toml 中永久启用它并修复其警告。反复进行此过程。

小贴士

为了在所有项目中推行相同的一组代码检查工具及其配置,并优先选择默认配置而非定制配置。这样可以使整个组织的代码库更加一致和易于访问。

禁用规则和警告

select 设置非常灵活,但纯粹是增量的:它允许你选择那些代码以特定前缀开头的规则。ignore 设置则让你可以微调,反向操作:禁用单独的规则和规则组。和 select 一样,它通过前缀匹配规则代码。

当你需要大部分而非全部的代码检查规则,或者逐步采用代码检查工具时,减法方法非常实用。pydocstyle 插件 (D) 检查每个模块、类和函数是否具有格式良好的文档字符串。你的项目可能已经接近目标,只剩下模块文档字符串 (D100) 未达标。在完全接入项目之前,使用 ignore 设置禁用所有关于缺少模块文档字符串的警告:

[tool.ruff.lint]
select = ["D", "E", "F"]
ignore = ["D100"]  # Don't require module docstrings for now.

per-file-ignore 设置允许您在代码库的一部分禁用规则。另一个例子是:bandit 插件 (S) 具有丰富的检查清单,可帮助您检测代码中的安全漏洞。它的规则 S101 标记了每个使用 assert 关键字的地方。⁵ 但是在 pytest 中,您仍然需要 assert 来表达预期(参见 第六章)。如果您的测试套件位于 tests 目录中,可以像这样为其文件禁用 S101

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]  # Tests can use assertions.

禁用规则应该是最后的选择。通常最好通过为有问题的行添加特殊注释来抑制单个警告。这种注释的形式是 # noqa:,后面跟着一个或多个规则代码。

警告

在您的 noqa 注释中始终包含规则代码。一般的 noqa 注释可能会隐藏无关的问题。标记违规行也会使它们在您准备修复时更易于查找。使用 pygrep-hooks linter 的规则 PGH004 要求规则代码。

noqa 系统允许您消除虚假阳性以及您选择在此时不优先考虑的合法警告。例如,MD5 消息摘要算法通常被认为是不安全的,Bandit 的 S324 标记了其使用。但是,如果您的代码与需要您计算 MD5 哈希的旧系统交互,您可能别无选择。使用 noqa 注释来禁用此警告:

md5 = hashlib.md5(text.encode()).hexdigest()  # noqa: S324

Bandit 的检查通常会标记出值得仔细审查的结构,但并不打算彻底禁止它们。其想法是您将逐个审查有问题的行,并在确定特定使用无害时抑制警告。

启用一个规则并抑制 所有 其警告可能是合理的。这样可以只在您触及代码区域时才执行规则—​即,只有当您触及代码区域时才执行。Ruff 支持此工作流程,其 --add-noqa 选项会为所有有问题的行代表您插入 noqa 注释:

$ ruff check --add-noqa

就像每个注释一样,noqa 注释可能会过时—​例如,重构可能会无意中修复被抑制的警告。过时的 noqa 注释会产生噪音,并在试图消除 linter 违规时产生摩擦。幸运的是,Ruff 处于优越的位置来修复这个问题。其规则 RUF100 可自动移除不再适用的 noqa 注释。

使用 Nox 进行自动化

如果没有纠正措施,大型项目的代码质量会随时间而下降。作为自动化的强制检查,linting 有助于抵消自然熵的影响。Nox(参见 第八章)是一个自动化框架,可以作为强制检查的一部分运行 linters。

这是一个 Nox 会话,它在当前目录中的每个 Python 文件上运行 Ruff:

@nox.session
def lint(session):
    session.install("ruff")
    session.run("ruff", "check")

Nox 在这里是一个合理的选择,但是在 linting 方面,有一个更方便和强大的选择:pre-commit,一个具有 Git 集成的跨语言 linter 框架。

预提交框架

Pre-commit 是一个工具和框架,允许你将第三方 linter 添加到项目中,并带有最少的样板文件。各种语言的 linter 都有为 pre-commit 准备的即用集成,称为 hooks。你可以从命令行显式运行这些 hooks,或者在本地仓库中配置以在提交更改时运行它们(以及其他一些事件)。

使用 pipx 全局安装 pre-commit:

$ pipx install pre-commit

初识 pre-commit

让我们为你的项目添加一个 Ruff 的 pre-commit 钩子。在顶级目录创建一个名为 .pre-commit-config.yaml 的文件,内容如 示例 9-3 所示。你会在支持 pre-commit 的大多数 linter 的公共文档中找到类似这样的短 YAML 片段。

示例 9-3. 一个包含 Ruff 钩子的 .pre-commit-config.yaml 文件
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.4
    hooks:
      - id: ruff

作者通过 Git 仓库分发他们的 pre-commit 钩子。在 .pre-commit-config.yaml 文件中,你需要为每个想要使用的仓库指定 URL、修订版本和钩子。URL 可以是 Git 可以克隆的任何位置。修订版本通常是指向 linter 最新发布版本的 Git tag。一个仓库可以有多个钩子—例如,Ruff 提供了 ruffruff-format 钩子用于其 linter 和代码格式化器。

Pre-commit 与 Git 密切相关,你必须在 Git 仓库内部调用它。我们将通过使用带有 --all-files 选项的命令 pre-commit run 来为仓库中的每个文件建立基准进行 linting:

$ pre-commit run --all-files
[INFO] Initializing environment for https://github.com/astral-sh/ruff-pre-commit.
[INFO] Installing environment for https://github.com/astral-sh/ruff-pre-commit.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
ruff.....................................................................Passed

当你第一次运行一个钩子时,pre-commit 会克隆钩子仓库并将 linter 安装到隔离环境中。这可能需要一些时间,但你不必经常这样做:pre-commit 在多个项目中缓存 linter 环境。

靠近钩子

如果你想知道 pre-commit 钩子在幕后是如何工作的,可以看一下 Ruff 的 hook repository。仓库中的 .pre-commit-hooks.yaml 文件定义了这些 hooks。示例 9-4 展示了文件中的一个摘录。

示例 9-4. 来自 Ruff 的 .pre-commit-hooks.yaml 文件的摘录
- id: ruff
  name: ruff
  language: python
  entry: ruff check --force-exclude
  args: []
  types_or: [python, pyi]

每个钩子都有一个唯一的标识符和一个友好的名称(idname)。与 pre-commit 交互时,请通过其唯一标识符引用钩子。它们的名称只出现在来自该工具的控制台消息中。

钩子定义告诉 pre-commit 如何安装和运行 linter,通过指定其实现语言(language)和其命令及命令行参数(entryargs)。Ruff 钩子是一个 Python 包,因此它将 Python 指定为语言。--force-exclude 选项确保你可以排除 linting 的文件。它告诉 Ruff 即使 pre-commit 明确传递了排除的源文件,也要遵循其 exclude 设置。

提示

您可以在.pre-commit-config.yaml文件中覆盖args键,以向钩子传递自定义命令行选项。相比之下,entry键中的命令行参数是强制性的——您无法覆盖它们。

最后,该钩子声明了 linter 理解的文件类型(types_or)。python文件类型匹配具有.py或相关扩展名的文件以及带有 Python shebang 的可执行脚本。pyi文件类型是指带有类型注解的存根文件(参见“使用 Python 包分发类型”)。

对于 Python 钩子,pre-commit 在其缓存目录中创建一个虚拟环境。它通过在钩子存储库中运行类似于pip install .的命令来安装钩子。当运行钩子时,pre-commit 会激活虚拟环境并使用任何选定的源文件调用命令。

图 9-2 展示了一个开发者机器,上面有三个使用 pre-commit 钩子的 Python 项目。pre-commit 会将钩子存储库克隆到其缓存目录并将钩子安装到隔离环境中。钩子存储库在.pre-commit-hooks.yaml文件中定义钩子,而项目在.pre-commit-config.yaml文件中引用这些钩子。

该图显示了带有 Ruff、Flake8 和 Black 预提交钩子的存储库以及带有这些钩子引用的三个项目的开发者机器。在底部,该图显示了带有克隆存储库和已安装钩子环境的 pre-commit 缓存。

图 9-2。三个项目使用了 Ruff、Black 和 Flake8 的 pre-commit 钩子。

自动修复

现代的 linter 可以通过直接修改违规的源文件来修复许多违规。具有自动修复功能的 linter 在几乎零成本的情况下消除了整个类别的错误和代码异味⁶。像代码格式化工具一样,它们在软件开发中引起了范式转变,使您可以专注于更高级别的问题,而不会牺牲代码质量。

根据约定,大多数 pre-commit 钩子默认启用自动修复。由于 Git 的存在,它们可以相对安全地应用修复,而不会不可逆地覆盖您的工作。尽管如此,如果您早早地提交并频繁地提交,它们的效果会更好。

警告

自动修复带来了巨大的好处,但它们假设了一些基本的 Git 卫生习惯:不要在您的存储库中堆积未提交的更改(或在 linting 之前将它们存储)。在某些情况下,pre-commit 会保存和恢复您的本地修改,但并非所有情况都是如此。

让我们试试这个。当 Ruff 检测到可变参数默认值时,表示您可以启用一个“隐藏”的修复(Ruff 要求您选择修复,因为人们可能依赖于可变默认值,例如用于缓存)。首先,在pyproject.toml中启用 linter 规则和修复:

[tool.ruff.lint]
extend-select = ["B006"]
extend-safe-fixes = ["B006"]

Ruff 的 pre-commit 钩子要求你选择 --fix 选项,就像 示例 9-5 中展示的那样。选项 --show-fixes--exit-non-zero-on-fix 确保所有违规将在终端显示,并导致非零退出状态,即使 Ruff 能够修复它们。

示例 9-5. 启用 Ruff 钩子的自动修复功能
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.4
    hooks:
      - id: ruff
        args: ["--fix", "--show-fixes", "--exit-non-zero-on-fix"]

将 示例 9-1 保存到名为 bad.py 的文件中,提交该文件,并运行 pre-commit:

$ pre-commit run --all-files
ruff.....................................................................Failed
- hook id: ruff
- exit code: 1
- files were modified by this hook

Fixed 1 error:
- bad.py:
    1 × B006 (mutable-argument-default)

Found 1 error (1 fixed, 0 remaining).

如果你检查修改后的文件,你会看到 Ruff 已经用 None 替换了参数默认值。空列表现在分配给函数内部,使得每次调用都有自己的 args 实例。

def run(command, args=None, force=False):
    if args is None:
        args = []
    if force:
        args.insert(0, "--force")
    subprocess.run([command, *args])

你可以运行 git diff 来查看应用于代码的更改,而不是检查修改后的文件。或者,你可以告诉 pre-commit 立即显示修复的差异,使用选项 --show-diff-on-fail

从 Nox 运行 pre-commit

Pre-commit 为许多语言的 linters 提供了生产就绪的集成。光是因为这个原因,我建议使用 pre-commit 而不是 Nox 运行 linters。(如果你还需要说服,请看下一节,它会为使用 pre-commit 提供另一个令人信服的理由。)

尽管如此,你仍然应该为 pre-commit 本身包含一个 Nox 会话。这可以确保你可以通过单个命令 nox 运行项目的所有检查。示例 9-6 展示了如何定义该会话。如果你的 noxfile.py 设置了 nox.options.sessions,请将该会话添加到该列表中。

示例 9-6. 使用 pre-commit 进行 lint 的 Nox 会话
nox.options.sessions = ["tests", "lint"]

@nox.session
def lint(session):
    options = ["--all-files", "--show-diff-on-fail"]
    session.install("pre-commit")
    session.run("pre-commit", "run", *options, *session.posargs)

默认情况下,pre-commit 会运行你为项目配置的所有钩子。你可以通过将它们作为额外的命令行参数传递来运行特定的钩子。这在解决特定 linter 的问题时非常方便。借助 session.posargs(参见 “Session Arguments”),在 Nox 中也同样适用:

$ nox --session=lint -- ruff

对于所有在项目上工作的人来说,包括 linters 在内的检查和任务具有单一的入口点,可以极大地减少摩擦。但你不应该止步于此。Pre-commit 的设计目标是在每次提交时从 Git 触发。下一节将解释如何设置项目,以便在提交更改时进行 lint(以及为什么这样做是很好的)。

从 Git 运行 pre-commit

在每次提交时运行 linters 是一个改变游戏规则的操作,原因有三:

  • 你消除了手动调用检查的开销和干扰。Linters 在后台运行,只有在发现违规时才会提醒你。

  • 你尽早进行检查。一般来说,越早发现问题,修复成本就越低。(对于因风格细微差异导致的 CI 失败,告别吧。)

  • 它速度很快:你只需要对提交阶段的文件进行 lint,而不是整个代码库。⁷

设置 Git 在每次提交时调用 pre-commit 的方法是在项目内运行以下命令:

$ pre-commit install
pre-commit installed at .git/hooks/pre-commit

这个命令将一个简短的包装脚本安装到 .git/hooks 目录中,将控制权转移给预提交(见 图 9-3)。.git/hooks 目录中的程序被称为 Git 钩子。当你运行git commit时,Git 调用 pre-commit Git 钩子。这个钩子又调用预提交,其中运行了 Ruff 和你拥有的任何其他预提交钩子。

该图示了 Git 钩子如何调用预提交钩子,使用预提交作为交换机。

图 9-3. Git 钩子和预提交钩子

Git 钩子允许你在 Git 执行的预定义点触发动作。例如,pre-commitpost-commit Git 钩子在 Git 创建提交之前和之后运行。你可能已经猜到预提交默认安装了哪些 Git 钩子——但它也支持其他 Git 钩子,如果你需要的话。

图 9-4 描述了带有预提交的典型工作流程。左边是你项目中正在编辑的文件(工作树);中间代表下一个提交的暂存区(索引);右边是当前提交(HEAD)。

该图示了预提交如何拒绝一个提交。

图 9-4. 带有预提交的工作流程

最初,这三个区域是同步的。假设你从 f-string 中移除了占位符,但忘记从字符串字面量中移除f前缀(在 图 9-4 中标记为 1)。你使用git add (2) 阶段性地提交你的编辑,并运行git commit来创建一个提交 (3a)。在你的编辑器弹出提交消息之前,Git 将控制权转移给预提交。Ruff 迅速捕捉到你的错误,并在你的工作树中修复了字符串字面量 (3b)

此时,所有三个区域具有不同的内容。你的工作树包含了带有 Ruff 修复的修改,暂存区包含了没有修复的修改,而HEAD仍然指向修改之前的提交。这允许你通过使用git diff来比较工作树和暂存区来审计修复。如果你对所看到的结果满意,你可以使用git add (4) 将修复加入暂存区,并使用git commit (5) 重新尝试提交。

通过自动修复,这个工作流程将最小化 lint 工具的干扰,并重新运行提交。但有时候,你可能根本不想被 lint 工具打扰——例如,你可能想记录一些正在进行中的工作。Git 和预提交为你提供了两个选项,以便你的提交能够通过顽固的 lint 工具。首先,你可以完全跳过 Git 钩子,使用--no-verify-n选项:

$ git commit -n

或者,你可以使用SKIP环境变量跳过特定的预提交钩子(如果需要跳过多个钩子,也可以使用逗号分隔的列表):

$ SKIP=ruff git commit

Git 钩子控制哪些更改进入您的本地存储库,但它们是自愿的 — 不会替换 CI 检查作为共享存储库中默认分支的门卫。如果您已在 CI 中运行 Nox,则示例 9-6 中的会话会处理此问题。

跳过钩子对于假阳性或者当您想部署关键修复时并没有帮助:您的强制检查仍会在 CI 中失败。在这些情况下,您需要建议代码检查器忽略特定违规行为(见“禁用规则和警告”)。

Ruff 格式化程序

几个月来,Ruff 在ruff check命令背后重新实现了大量 Python 代码检查器,并在 Python 社区中广泛采用。一年多一点后,Ruff 获得了ruff format命令[⁸]。Ruff 格式化程序在 Rust 中重新实现了 Python 代码格式化的事实标准 Black。它为 Ruff 在 Python 领域中成为集成和高性能工具链的又一个构建块提供了支持。

但让我们从头开始。

代码格式化方法:Autopep8

考虑以下这段 Python 代码的珍珠:

def create_frobnicator_factory(the_factory_name,
                                  interval_in_secs=100,  dbg=False,
                                use_singleton=None,frobnicate_factor=4.5):
  if dbg:print('creating frobnication factory '+the_factory_name+"...")
  if(use_singleton):   return _frob_sngltn      #we're done
  return FrobnicationFactory( the_factory_name,

    intrvl = interval_in_secs             ,f=frobnicate_factor     )

该函数展示了各种格式问题,例如不一致的对齐、缺少或过多的空白以及单行if结构。其中一些显然影响可读性。另一些,例如两空格缩进,偏离了 PEP 8 中编码的广泛接受做法。

处理此类风格问题的最小方法是 Autopep8,这是自动代码检查和修复的早期先驱,并仍在使用中。建立在 Pycodestyle 的基础上,它在保留代码布局的同时手术性地纠正违规行为。

让我们用默认设置运行 Autopep8 代码示例:

$ pipx run autopep8 example.py

这是由 Autopep8 格式化的函数:

def create_frobnicator_factory(the_factory_name,
                               interval_in_secs=100,  dbg=False,
                               use_singleton=None, frobnicate_factor=4.5):
    if dbg:
        print('creating frobnication factory '+the_factory_name+"...")
    if (use_singleton):
        return _frob_sngltn  # we're done
    return FrobnicationFactory(the_factory_name,

                               intrvl=interval_in_secs, f=frobnicate_factor)

您可能会发现这样更容易阅读。无论好坏,Autopep8 没有触及其他一些可疑的风格选择,例如return语句中的流浪空行和不一致的引号字符。Autopep8 使用 Pycodestyle 来检测问题,在这里没有投诉。

提示

与大多数代码格式化程序不同,Autopep8 允许您通过传递--select和适当的规则代码来应用选定的修复。例如,您可以运行autopep8 --select=E111来强制使用四空格缩进。

代码格式化方法:YAPF

2015 年由 Google 开发,YAPF 格式化程序从clang-format借用其设计和复杂的格式化算法。YAPF 的名称代表“Yet Another Python Formatter”[⁹]。YAPF 根据丰富的配置选项重新格式化代码库。

运行方式如下:

$ pipx run yapf example.py

这是 YAPF 代码的版本,使用默认设置:

def create_frobnicator_factory(the_factory_name,
                               interval_in_secs=100,
                               dbg=False,
                               use_singleton=None,
                               frobnicate_factor=4.5):
    if dbg: print('creating frobnication factory ' + the_factory_name + "...")
    if (use_singleton): return _frob_sngltn  #we're done
    return FrobnicationFactory(the_factory_name,
                               intrvl=interval_in_secs,
                               f=frobnicate_factor)

YAPF 的格式规则覆盖的范围比 Autopep8 更广泛——例如,它以一致的方式排列函数参数并移除伪空行。只要与你的配置兼容,YAPF 就会尊重现有的格式选择。例如,它不会分割单行的 if 语句或删除 if 条件周围的括号。

一个不妥协的代码格式化工具。

在 2018 年,一个名为 Black 的新代码格式化工具进入了舞台。它的核心原则是:最小可配置性!

让我们在代码示例中尝试一下 Black:

$ pipx run black example.py

Black 对函数的格式化如下所示:

def create_frobnicator_factory(
    the_factory_name,
    interval_in_secs=100,
    dbg=False,
    use_singleton=None,
    frobnicate_factor=4.5,
):
    if dbg:
        print("creating frobnication factory " + the_factory_name + "...")
    if use_singleton:
        return _frob_sngltn  # we're done
    return FrobnicationFactory(
        the_factory_name, intrvl=interval_in_secs, f=frobnicate_factor
    )

Black 不像 Autopep8 那样修复个别的风格问题,也不像 YAPF 那样强制你的风格偏好。相反,Black 将源代码简化为一个规范形式,使用确定性算法——大多数情况下不考虑现有的格式。从某种意义上说,Black 使代码风格“消失”。这种归一化大大减少了使用 Python 代码时的认知负担。

Black 促进了 Python 生态系统中普遍的可读性。代码阅读的频率远高于编写——透明的风格有助于此。与此同时,Black 也使得代码更易“书写”。当你贡献于他人的代码时,无需遵循定制和手动的代码风格。即使在独立项目中,Black 也能提升你的生产力:如果你配置编辑器在保存时重排格式,编码时可以将击键减少到最小。

Black 席卷了 Python 世界,一个接一个项目决定“黑化”他们的源文件。

Black 的代码风格。

一旦你使用了一段时间,Black 的代码风格就会变得无形。不可避免地,一些选择引起了争议,甚至分岐。要理解其格式化规则,有助于查看 Black 旨在以可预测和可重复的方式生成可读和一致的源代码的目标。

例如,采用双引号作为字符串文字的默认值。根据 PEP 257 和 PEP 8 的风格建议,文档字符串和带撇号的英文文本已经要求使用双引号。因此,选择双引号而不是单引号会导致更一致的风格。

有时候,Black 在函数的参数后放置一个孤立的 ):。这被昵称为“悲伤的脸”,清晰地划分了函数签名和函数体,而无需使参数缩进超出标准的四个空格。这种布局也将参数列表视为其他的括号结构,例如元组、列表和集合字面量。

Black 还将行限制在 88 个字符以内(这是它少数可配置的设置之一)。这种横向眼动与垂直滚动之间的权衡是基于数据驱动的方法,在 Meta 的数百万行 Python 代码上进行了测试。

Black 的另一个目标是避免合并冲突,当对同一代码区域的并发更改无法在不人工干预的情况下合并时。尾逗号—​在序列的最后一项后面放置逗号—​正是为此目的而设计:它允许您在最后一项之前和之后插入而无冲突。

注意

减少编辑之间的依赖有助于不同的人在同一段代码上工作。但这也让您可以将驱动式修复或重构分离或重新排序,并在提交更改进行代码审查之前撤销尝试性提交。

如上所述,Black 算法是确定性的,现有的布局几乎不会影响它。一个“变黑”的文件看起来就像是直接从 AST 生成的。¹¹ 然而实际情况并非如此。首先,AST 不包括注释,因为它们不影响程序执行。

除了注释,Black 还从格式化的源代码中获取一些线索。一个例子是将函数体分成逻辑分区的空行。然而,影响 Black 输出最强大的方式可能是神奇的尾逗号:如果序列包含尾逗号,Black 会将其元素拆分成多行,即使它们可以在单行上放得下。

Black 提供了一个逃生通道,让您可以禁用代码区域的格式化(示例 9-7)。

示例 9-7. 禁用代码区域的格式化
@pytest.mark.parametrize(
    ("value", "expected"),
    # fmt: off
    [
        ("first test value",       "61df19525cf97aa3855b5aeb1b2bcb89"),
        ("another test value",     "5768979c48c30998c46fb21a91a5b266"),
        ("and here's another one", "e766977069039d83f01b4e3544a6a54c"),
    ]
    # fmt: on
)
def test_frobnicate(value, expected):
    assert expected == frobnicate(value)

对于程序数据,如具有正确对齐列的大表格,手动格式化可能很有用。

使用 Ruff 格式化代码

代码格式化程序可以批处理处理数百万行代码,或者在触发编辑器或繁忙 CI 服务器上运行时快速连续运行。从一开始,Black 的明确目标是快速的—​它通过mypyc生成的本地代码二进制轮速度快。虽然 Black 速度快,但 Ruff 格式化程序通过其高效的 Rust 实现进一步提高了性能三十倍。

Ruff 的目标是与 Black 代码风格完全兼容。与 Black 不同,Ruff 允许您选择使用单引号和使用制表符进行缩进。但无论如何,我建议坚持采用 Black 广泛采纳的风格。

在没有参数运行时,ruff format处理当前目录下的任何 Python 文件。不要手动调用 Ruff,而是将其添加到您的 pre-commit 钩子中,如示例 9-8 所示。

示例 9-8. 作为 linter 和代码格式化程序从 pre-commit 运行 Ruff
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.4
    hooks:
      - id: ruff
        args: ["--fix", "--show-fixes", "--exit-non-zero-on-fix"]
      - id: ruff-format

代码格式化程序的 pre-commit 钩子最后运行。这为它提供了重新格式化任何由 linters 自动修复的机会。

摘要

在本章中,您已经了解了如何通过使用 linter 和代码格式化工具来提高和保护项目中的代码质量。Ruff 是许多 Python 代码质量工具的高效重写,包括 Flake8 和 Black。虽然可以手动运行 Ruff 和其他工具,但是您应该自动化此过程并将其作为 CI 中的强制性检查。最佳选项之一是 pre-commit,它是一个具有 Git 集成的跨语言 linter 框架。从 Nox 会话中调用 pre-commit,以保持您的一套检查的单一入口点。

¹ B 短代码激活了由 flake8-bugbear 开创的一组检查,这是 Flake8 linter 的一个插件。

² Charlie Marsh: “Python tooling could be much, much faster”,2022 年 8 月 30 日。

³ 在撰写本文时,您还需要启用 Ruff 的预览模式。将 tool.ruff.lint.preview 设置为 true

⁴ 我的审阅员 Hynek 不同意。他将项目设置为 ALL 并取消了对他不适用的规则。“否则,您会错过新规则。如果更新后出现问题,您可以采取行动。”

⁵ 断言有什么问题吗?没有,但是 Python 在优化运行时会跳过它们,使用 -O 命令行参数可以实现这一点——这是加速生产环境的常用方式。因此,不要使用 assert 来验证不受信任的输入!

⁶ Kent Beck 和 Martin Fowler 将 code smells 描述为“代码中的某些结构,暗示——有时候,大声呼喊——需要重构的可能性。” Martin Fowler: Refactoring: Improving the Design of Existing Code,第二版,波士顿:Addison-Wesley,2019 年。

⁷ 从 Git 运行 pre-commit 是运行带有自动修复功能的 linter 的最安全方式:Pre-commit 保存并恢复了您未暂存的任何更改,并在修复冲突时撤销这些修复。

⁸ Charlie Marsh: “The Ruff Formatter”,2023 年 10 月 24 日。

⁹ Lint 的作者 Stephen C. Johnson 也通过在 1970 年代早期在贝尔实验室编写 Yacc(Yet Another Compiler-Compiler)来建立了这个臭名昭著的命名约定。

¹⁰ “AST before and after formatting,” Black documentation,上次访问时间:2024 年 3 月 22 日。

¹¹ 您可以使用标准 ast 模块检查源文件的 AST,使用 py -m ast example.py

第十章:使用类型来保证安全和检查

类型是什么?作为一个初步的近似,让我们说变量的类型指定了你可以分配给它的值的种类——例如整数或字符串列表。当吉多·范·罗苏姆创建 Python 时,大多数流行的编程语言在类型方面分为两大派别:静态类型和动态类型。

静态类型语言,如 C++,要求您提前声明变量的类型(除非编译器足够智能以自动推断)。作为交换,编译器确保变量只能存储兼容的值。这消除了整个类别的错误。它还能进行优化:编译器知道变量需要多少空间来存储它的值。

动态类型语言打破了这种范式:它们允许您将任何值赋给任何变量。像 Javascript 和 Perl 这样的脚本语言甚至隐式转换值——比如,从字符串到数字。这极大地加快了编写代码的过程。它还给了您更多的自由度来射击自己的脚。

Python 是一种动态类型语言,但它选择了两大派别之间的中间路线。让我们用一个例子来演示它的方法:

import math

number = input("Enter a number: ")
number = float(number)
result = math.sqrt(number)

print(f"The square root of {number} is {result}.")

在 Python 中,变量只是一个值的名称。变量没有类型——有。程序首先将相同的名称number与类型为str的值关联起来,然后与类型为float的值关联起来。但与 Perl 和类似语言不同,Python 永远不会在您背后转换值,热切地期待您的愿望:

>>> math.sqrt("1.21")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: must be real number, not str

Python 并不像一些同行那样宽容,但请考虑此类型检查的两个限制。首先,在运行有问题的代码之前,您不会看到TypeError。其次,Python 解释器不会引发错误——库函数显式检查是否传递了除整数或浮点数之外的东西。

大多数 Python 函数根本不检查它们的参数类型。相反,它们只是调用它们期望其参数提供的操作。从本质上讲,Python 对象的类型并不重要,只要其行为正确。这种方法受到路易十五时期沃克松的机械鸭子的启发,被称为鸭子类型:“如果它看起来像鸭子,叫起来像鸭子,那么它就是鸭子。”

1899 年《科学美国人》杂志上刊登的沃克松的虚构机械鸭图像

举个例子,考虑并行代码中的join操作。此操作允许您等待直到一些后台工作完成,“加入”控制线程,如此类推。示例 10-1 定义了一个鸭子类型的函数,依次在多个任务上调用join

示例 10-1。鸭子类型的实现
def join_all(joinables):
    for task in joinables:
        task.join()

可以使用threadingmultiprocessing模块中的ThreadProcess——或者任何具有正确签名的join方法的其他对象。 (不能用于字符串,因为str.join需要一个字符串可迭代对象作为参数。) 鸭子类型意味着这些类不需要共同的基类来实现重用。这些类型只需要一个正确签名的join方法。

鸭子类型很棒,因为函数及其调用者可以相对独立地演变——这是一种称为松耦合的属性。没有鸭子类型,函数参数必须实现明确规定其行为的显式接口。Python 为您免费提供了松耦合:只要它满足预期的行为,您可以传递任何东西。

不幸的是,这种自由性可能使一些函数难以理解。

如果你曾经不得不阅读整个代码库以了解其中几行代码的目的,你就知道我说的是什么:孤立地理解一个 Python 函数有时是不可能的。有时,唯一解释正在发生的事情的方法是查看其调用者、它们的调用者等等 (示例 10-2)。

示例 10-2. 一个晦涩的函数
def _send(objects, sender):
    """send them with the sender..."""
    for obj in objects[0].get_all():
        if p := obj.get_parent():
            sender.run(p)
        elif obj is not None and obj._next is not None:
            _send_next_object(obj._next, sender)

写出函数的参数和返回类型——它的类型签名——显著减少了理解函数所需的上下文量。传统上,人们通过在文档字符串中列出类型来做到这一点。不幸的是,文档字符串经常缺失、不完整或不正确。更重要的是,并没有一个正式的语言来精确和可验证地描述类型。没有工具来强制执行类型签名,它们几乎等同于一厢情愿的想法。

虽然在几百行代码的代码库中这种问题可能有点恼人,但当您处理数百万行代码时,它很快会变成一种存在威胁。像 Google、Meta、Microsoft 和 Dropbox 这样的公司在 2010 年代赞助了静态类型检查器的开发。静态类型检查器是一种工具,它在不运行程序的情况下验证程序的类型安全性。换句话说,它检查程序是否在不支持这些操作的值上执行操作。

在某种程度上,类型检查器可以自动推断函数或变量的类型,使用一种称为类型推断的过程。当程序员能够在他们的代码中显式指定类型时,它们变得更加强大。由于上世纪中期的基础工作,特别是由 Jukka Lehtosalo 及其合作者的工作,¹ Python 语言获得了一种在源代码中表达函数和变量类型的方式,称为类型注解 (示例 10-3)。

示例 10-3. 带有类型注解的函数
def format_lines(lines: list[str], indent: int = 0) -> str:
    prefix = " " * indent
    return "\n".join(f"{prefix}{line}" for line in lines)

类型注解已成为丰富的开发工具和库生态系统的基础。

单独看,类型注解主要不会影响程序的运行时行为。解释器不会检查赋值是否与注释的类型兼容;它只是将注释存储在包含模块、类或函数的特殊__annotations__属性中。虽然这会在运行时产生一些小的开销,但这意味着您可以在运行时检查类型注解,从而进行一些有趣的操作——比如说,根据传输到网络的值构建您的域对象,而无需任何样板文件。

类型注解的最重要用途之一是在运行时不会发生:静态类型检查器(如 mypy)使用它们来验证代码的正确性,而不必运行代码。

类型注解的利与弊

您不必自己使用类型注解也能从中受益。类型注解已经为标准库和许多 PyPI 包提供。静态类型检查器可以在您使用模块不正确时警告您,包括当库中的重大更改导致您的代码不再与该库一起正常工作时——而类型检查器可以在运行代码之前警告您。

编辑器和 IDE 利用类型注解为您提供更好的编码体验,包括自动完成、工具提示和类浏览器。您还可以在运行时检查类型注解,解锁诸如数据验证和序列化等强大功能。

如果您在自己的代码中使用类型注解,您将获得更多好处。首先,您也是自己函数、类和模块的用户——因此,以上所有的好处也适用,例如自动完成和类型检查。此外,您会发现更容易理解您的代码、在不引入微妙错误的情况下重构它,并构建清晰的软件架构。作为库的作者,类型注解允许您指定用户可以依赖的接口契约,而您则可以自由演化实现。

即使在它们引入十年后,类型注解仍然不乏争议——这或许是可以理解的,考虑到 Python 自豪地作为动态类型语言的立场。在现有代码中添加类型与将单元测试引入不考虑测试的代码库面临相似的挑战。正如您可能需要重构以增强可测试性一样,您可能需要重构以增强“可类型化性”——用更简单、更可预测的类型替换深度嵌套的原始类型和高度动态的对象。您很可能会发现这是值得努力的。

另一个挑战是 Python 类型语言的快速演变。如今,Python 类型注解受到 Typing Council 的管辖,该组织维护着类型语言的单一、持续更新的规范。² 未来几年内,预计这一规范将会经历更大的变化。虽然带类型的 Python 代码需要适应这一演变,但类型语言对 Python 的向后兼容政策没有例外。

在本章中,你将学习如何使用静态类型检查器 mypy 和运行时类型检查器 Typeguard 来验证你的 Python 程序的类型安全性。你还将看到如何通过运行时检查类型注解来极大地增强程序的功能。但首先,让我们来看看在过去十年内在 Python 中演变的类型语言。

Python 类型语言的简要介绍

提示

在此节中的一个类型检查器 playground 中尝试一些小例子:

变量注解

在程序运行过程中,你可以使用类型注解为变量指定可能分配的值的类型。此类类型注解的语法由变量名、冒号和类型组成:

answer: int = 42

除了像boolintfloatstrbytes这样的简单内置类型外,你还可以在类型注解中使用标准的容器类型,例如listtuplesetdict。例如,以下是如何初始化一个用于存储从文件中读取的行的列表变量的示例:

lines: list[str] = []

虽然前面的例子有些多余,但这个例子提供了实际价值:没有类型注解,类型检查器无法推断你想要将字符串存储在列表中。

内置容器是泛型类型的示例——即接受一个或多个参数的类型。以下是将字符串映射到整数的字典示例。dict的两个参数分别指定键和值的类型:

fruits: dict[str, int] = {
    "banana": 3,
    "apple": 2,
    "orange": 1,
}

元组有些特别,因为它们有两种形式。元组可以是固定数量类型的组合,例如字符串和整数对:

pair: tuple[str, int] = ("banana", 3)

另一个示例涉及在三维空间中保存坐标的情况:

coordinates: tuple[float, float, float] = (4.5, 0.1, 3.2)

另一种元组的常见用途是作为不可变的任意长度序列。为此,类型语言允许你为相同类型的零个或多个项目编写省略号。例如,以下是一个可以保存任意数量整数的元组(包括一个也没有):

numbers: tuple[int, ...] = (1, 2, 3, 4, 5)

你在自己的 Python 代码中定义的任何类也是一种类型:

class Parrot:
    pass

class NorwegianBlue(Parrot):
    pass

parrot: Parrot = NorwegianBlue()

子类型关系

赋值语句两边的类型不一定相同。在上面的示例中,你将NorwegianBlue值分配给Parrot变量。这是有效的,因为挪威蓝是鹦鹉的一种——或者从技术上讲,因为NorwegianBlueParrot的子类。

总的来说,Python 类型语言要求变量赋值右侧的类型是左侧类型的子类型。一个典型的子类型关系例子是子类与其基类的关系,比如NorwegianBlueParrot

然而,子类型是一个比子类更一般的概念。例如,整数元组(如上面的numbers)是对象元组的一个子类型。下一节介绍的联合类型是另一个例子。

小贴士

类型规则还允许在右侧类型与左侧类型一致时进行赋值。这使你可以将int赋给float,即使int不是float的派生类。Any类型与任何其他类型都一致(参见“逐步类型化”)。

联合类型

使用管道操作符(|)可以组合两种类型来构造联合类型,这种类型的值可以是其组成类型的任意值。例如,你可以将其用于用户 ID,其值可以是数字或字符串:

user_id: int | str = "nobody"  # or 65534

联合类型的一个最重要的用途是处理“可选”值,其中缺失的值用None来编码。以下是一个例子,从README中读取描述,前提是文件存在:

description: str | None = None

if readme.exists():
    description = readme.read_text()

联合类型是子类型关系的另一个例子:联合中涉及的每种类型都是联合的子类型。例如,strNone分别是联合类型str | None的子类型。

在上面讨论内置类型时,我跳过了None。严格来说,None是一个值,不是一个类型。None的类型称为NoneType,可以从标准types模块中获取。为了方便起见,Python 允许你在注释中使用None来指代这种类型。

托尼·霍尔(Tony Hoare),一位对许多编程语言做出基础性贡献的英国计算机科学家,因其发明的空引用或None而被称为“十亿美元的错误”,因为自 1965 年在ALGOL中引入以来它们导致的 bug 数量。如果你曾经看到系统因为访问一个实际上为None的对象属性而崩溃,可能会同意他的看法。 (如果你尝试访问一个实际上为None的对象属性时,Python 会引发此错误。)

AttributeError: 'NoneType' object has no attribute '...'

好消息是类型检查器可以在你使用可能为None的变量时发出警告。这可以大大减少生产系统中崩溃的风险。

如何告诉类型检查器你对description的使用是正确的?一般来说,你应该检查变量不是None。类型检查器会注意到这一点并允许你使用该变量:

if description is not None:
    for line in description.splitlines():
        print(f" {line}")

存在几种 类型缩小 的方法,这就是这种技术的称呼。我不会在这里详细讨论它们。作为经验法则,控制流仅在值具有正确类型时才会到达所在行——并且类型检查器必须能够从源代码推断出这一事实。例如,你还可以使用 assert 关键字与内置函数如 isinstance

assert isinstance(description, str)
for line in description.splitlines():
    ...

如果你已经 知道 值具有正确的类型,你可以使用 typing 模块的 cast 函数来帮助类型检查器:

description_str = cast(str, description)
for line in description_str.splitlines():
    ...

在运行时,cast 函数只是返回其第二个参数。与 isinstance 不同,它可以处理任意类型的注解。

渐进式类型化

在 Python 中,每种类型最终都派生自 object。这对于用户定义的类和原始类型都是如此,即使对于诸如 intNone 这样的类型也是如此。换句话说,object 是 Python 中的 通用超类型——你可以将任何东西都赋给此类型的变量。

这听起来可能很强大,但实际上并非如此。在行为方面,object 是所有 Python 值的最小公共分母,所以在类型检查器的视角下,你几乎无法使用它:

number: object = 2
print(number + number)  # error: Unsupported left operand type for +

在 Python 中还有一种类型,像 object 一样,可以容纳任何值。它称为 Any(理由显而易见),并且可以从标准的 typing 模块中获取。就行为而言,Anyobject 的完全对立面。你可以对 Any 类型的值执行任何操作,概念上来说,它表现为所有可能类型的交集。Any 充当了一个逃生口,让你可以选择在某段代码中不进行类型检查:

from typing import Any

number: Any = NorwegianBlue()
print(number + number)  # valid, but crashes at runtime!

在第一个例子中,object 类型导致了一个假阳性:代码在运行时有效,但类型检查器会拒绝它。在第二个例子中,Any 类型导致了一个假阴性:代码在运行时崩溃,但类型检查器不会标记它。

警告

当你在类型化的 Python 代码中工作时,要注意 Any。它可以在相当大的程度上禁用类型检查。例如,如果你访问 Any 值的属性或调用操作,你最终会得到更多的 Any 值。

Any 类型是 Python 的法宝,它允许你将类型检查限制在代码库的部分区域内,正式称为 渐进式类型化。在变量赋值和函数调用中,Any 与任何其他类型保持一致,而每种类型也与它保持一致。

渐进式类型化之所以有价值,至少有几个原因。首先,Python 在两十年间不存在类型注解,而且 Python 的管理机构没有意图要强制类型注解。因此,有类型和无类型的 Python 将在可预见的未来共存。其次,Python 的优势部分来自于其在需要时能够高度动态化——例如,Python 让动态组装甚至修改类变得容易。在某些情况下,对这种高度动态代码应用严格的类型可能很困难(甚至根本不可能)。³

函数注解

正如你可能从 示例 10-3 中记得的那样,函数参数的类型注解看起来与变量的注解非常相似。而返回类型则使用右箭头而不是冒号—毕竟,冒号已经在 Python 中引入了函数体。例如,这里是一个类型注解的函数用于添加两个数字:

def add(a: int, b: int) -> int:
    return a + b

Python 函数如果不包含return语句,则隐式返回None。你可能期望在这种情况下返回类型注解是可选的。但事实并非如此!作为一般准则,总是在注解函数时指定返回类型:

def greet(name: str) -> None:
    print(f"Hello, {name}")

类型检查器假定没有返回类型的函数返回Any。同样,没有注解的函数参数默认为Any。这有效地禁用了对函数的类型检查—在一个大量未类型化的 Python 代码的世界中,这正是你想要的行为。

让我们来看一个稍微复杂的函数签名:

import subprocess
from typing import Any

def run(*args: str, check: bool = True, **kwargs: Any) -> None:
    subprocess.run(args, check=check, **kwargs)

具有默认参数的参数,例如check,使用类似变量赋值的语法。*args参数持有位置参数的元组—每个参数必须是str**kwargs参数持有关键字参数的字典—使用Any意味着关键字参数不限于任何特定类型。

在 Python 中,你可以在函数内部使用yield来定义一个生成器,它是一个可以在for循环中产生一系列值的对象。生成器支持一些超出迭代的行为;当仅用于迭代时,它们被称为迭代器。以下是如何写出它们的类型:

from collections.abc import Iterator

def fibonacci() -> Iterator[int]:
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

函数在 Python 中是一等公民。你可以将函数赋给一个变量或传递给另一个函数—例如用于注册回调。因此,Python 允许你在函数定义外表达函数的类型。Callable是一个泛型类型,接受两个参数—参数类型的列表和返回类型:

from collections.abc import Callable

Serve = Callable[[Article], str]

注解类

变量和函数注解的规则也适用于类定义的上下文中,它们描述了实例变量和方法。在方法中,可以省略对self参数的注解。类型检查器可以从在__init__方法中的赋值推断出实例变量。

class Swallow:
    def __init__(self, velocity: float) -> None:
        self.velocity = velocity

标准的dataclasses模块会从任何用@dataclass装饰的类的类型注解生成规范的方法定义:

from dataclasses import dataclass

@dataclass
class Swallow:
    velocity: float

dataclass 风格的定义不仅比手写的更简洁,还赋予了类额外的运行时行为—比如能够根据其属性比较实例的相等性,或者对它们进行排序。

当你为类进行注解时,前向引用问题经常会出现。考虑一个二维点,带有一个计算其与另一点欧几里德距离的方法:

import math
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

    def distance(self, other: Point) -> float:
        dx = self.x - other.x
        dy = self.y - other.y
        return math.sqrt(dx*dx + dy*dy)

尽管类型检查器对此定义感到满意,但是当你在 Python 解释器中运行时,代码会引发异常:⁴

NameError: name 'Point' is not defined. Did you mean: 'print'?

Python 不允许你在方法定义中使用Point,因为你还没有完成类的定义,该名称尚不存在。有几种方法可以解决这种情况。首先,你可以将前向引用写为字符串,以避免NameError,这种技术称为字符串化注解。

@dataclass
class Point:
    def distance(self, other: "Point") -> float:
        ...

第二,你可以通过annotations未来引入隐式字符串化当前模块中的所有注解:

from __future__ import annotations

@dataclass
class Point:
    def distance(self, other: Point) -> float:
        ...

第三种方法不适用于所有前向引用,但在这里适用。你可以使用特殊的Self类型来引用当前类:

from typing import Self

@dataclass
class Point:
    def distance(self, other: Self) -> float:
        ...

在这第三个版本中与之前的版本相比要注意语义上的差异。如果你从Point类派生出一个SparklyPoint类,Self将指代派生类而不是基类。换句话说,你无法计算闪光点到普通点的距离。

类型别名

你可以使用type关键字为类型引入一个别名:⁵

type UserID = int | str

当类型变得笨重时,这种技术对于使你的代码自我描述和保持可读性非常有用。类型别名还允许你定义否则无法表达的类型。考虑一个本质上递归的数据类型,如 JSON 对象:

type JSON = None | bool | int | float | str | list[JSON] | dict[str, JSON]

递归类型别名是前向引用的另一个示例。如果你的 Python 版本尚未支持type关键字,你需要在右侧将JSON替换为"JSON",以避免NameError

泛型

正如你在本节开头看到的那样,像list这样的内置容器是泛型类型。你还可以自己定义泛型函数和类,这非常简单。考虑一个返回字符串列表中第一个项目的函数:

def first(values: list[str]) -> str:
    for value in values:
        return value
    raise ValueError("empty list")

没有理由将元素类型限制为字符串:逻辑不依赖于此。让我们将函数泛化到所有类型。首先,用占位符T替换str。其次,通过在函数名后方括号中声明占位符作为类型变量。 (T只是一种约定,你可以使用任何名称。)此外,没有理由将函数限制为列表,因为它适用于任何你可以在for循环中迭代的类型,换句话说,任何可迭代对象。

from collections.abc import Iterable

def firstT -> T:
    for value in values:
        return value
    raise ValueError("no values")

这是你在代码中如何使用泛型函数的方式:

fruit: str = first(["banana", "orange", "apple"])
number: int = first({1, 2, 3})

你可以在泛型函数的注解中省略fruitnumber的变量注释,类型检查器会从泛型函数的注解中推断它们。

注意

Python 3.12+和 Pyright 类型检查器支持使用[T]语法的泛型。如果出现错误,请省略first[T]后缀,并使用typing模块中的TypeVar

T = TypeVar("T")

协议

来自示例 10-1 的 join_all 函数可以与线程、进程或其他任何可以加入的对象一起工作。鸭子类型使得您的函数简单且可重用。但是如何验证函数与其调用者之间的隐式契约呢?

协议 消除了鸭子类型和类型注解之间的差距。协议描述了对象的行为,而无需对象从其继承。它看起来有些像 抽象基类 —— 一个不实现任何方法的基类:

from typing import Protocol

class Joinable(Protocol):
    def join(self) -> None:
        ...

Joinable 协议要求对象具有一个不接受参数且返回 Nonejoin 方法。 join_all 函数可以使用该协议来指定其支持的对象:

def join_all(joinables: Iterable[Joinable]) -> None:
    for task in joinables:
        task.join()

令人惊讶的是,尽管标准库类型如 ThreadProcess 并不了解您的 Joinable 协议,但此代码片段仍然能够正常工作 —— 这是松耦合的一个典型例子。

这种技术被称为 结构子类型化ThreadProcess 的内部结构使它们成为 Joinable 的子类型。相比之下,名义子类型化 要求您明确地从超类型派生子类型。

与旧版 Python 的兼容性

上述描述基于撰写本文时的最新 Python 发行版,即 Python 3.12。表 10-1 列出了在旧版 Python 中尚不可用的 typing 特性及其在这些版本中的替代方案。

Table 10-1. Typing 特性的可用性

特性 示例 可用性 替代方案
标准集合中的泛型 list[str] Python 3.9 typing.List
联合操作符 str &#124; int Python 3.10 typing.Union
自类型 Self Python 3.11 typing_extensions.Self
type 关键字 type UserID = ... Python 3.12 typing.TypeAlias(Python 3.10)
类型参数语法 def firstT Python 3.12 typing.TypeVar

typing-extensions 库为许多在旧版 Python 中不可用的特性提供了后向兼容,参见“使用 Nox 自动化 mypy”。

这就是我们对 Python typing 语言的简要介绍。虽然 Python 的类型检查还有很多内容,但我希望本概述已经教会您足够的知识,让您能够深入探索类型检查的激动人心世界。

使用 mypy 进行静态类型检查

Mypy 是广泛使用的 Python 静态类型检查器。静态类型检查器利用类型注解和类型推断来在程序运行之前检测错误。Mypy 是在 PEP 484 中将类型系统规范化时的原始参考实现。这并不意味着 mypy 总是第一个实现 typing 语言新特性的类型检查器 —— 例如,type 关键字首次在 Pyright 中实现。然而,它确实是一个很好的默认选择,typing 社区的核心成员参与了其开发。

mypy 的第一步

将 mypy 添加到项目的开发依赖项中,例如通过添加一个typing额外:

[project.optional-dependencies]
typing = ["mypy>=1.9.0"]

您现在可以在项目环境中安装 mypy:

$ uv pip install -e ".[typing]"

如果您使用 Poetry,请使用poetry add将 mypy 添加到项目中:

$ poetry add --group=typing "mypy>=1.9.0"

最后,在项目的src目录上运行 mypy:

$ py -m mypy src
Success: no issues found in 2 source files

让我们使用带有类型相关错误的代码进行类型检查。考虑以下程序,它将None传递给期望字符串的函数:

import textwrap

data = {"title": "Gegenes nostrodamus"}

summary = data.get("extract")
summary = textwrap.fill(summary)

如果您在此代码上运行 mypy,则它将忠实地报告在调用textwrap.fill时,参数不保证是一个字符串:

$ py -m mypy example.py
example.py:5: error: Argument 1 to "fill" has incompatible type "str | None";
  expected "str"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

重新访问维基百科示例

让我们从示例 6-3 重新审视维基百科 API 客户端。在一个虚构的场景中,通过了广泛的审查法规。根据您所连接的国家,维基百科 API 可能会省略文章摘要。

当发生这种情况时,您可以存储一个空字符串。但让我们遵循原则:一个空摘要并不等同于没有摘要。当响应省略该字段时,让我们存储None

作为第一步,将summary默认更改为None,而不是空字符串。使用联合类型来表明该字段可以容纳None而不是字符串。

@dataclass
class Article:
    title: str = ""
    summary: str | None = None

在下面的几行中,show函数重新格式化摘要,以确保每行不超过 72 个字符:

def show(article, file):
    summary = textwrap.fill(article.summary)
    file.write(f"{article.title}\n\n{summary}\n")

据推测,mypy 会对这个错误提出异议,就像之前一样。但是,当你在文件上运行它时,一切都很顺利。你能猜到为什么吗?

$ py -m mypy src
Success: no issues found in 2 source files

Mypy 不会对调用抱怨,因为article参数没有类型注释。它认为articleAny,因此表达式article.summary也变成了Any。(Any是具有传染性的。)就 mypy 而言,该表达式可以同时是strNone和一只粉色大象。这是渐进类型在起作用,也是为什么您应该警惕在代码中使用Any类型和缺少注释的原因。

您可以通过将参数注释为article: Article来帮助 mypy 检测错误。实际上尝试修复错误,考虑如何处理真实程序中的摘要为None的情况。以下是一种解决方法:

def show(article: Article, file):
    if article.summary is not None:
        summary = textwrap.fill(article.summary)
    else:
        summary = "[CENSORED]"
    file.write(f"{article.title}\n\n{summary}\n")

严格模式

如果不对函数进行类型注释,则 mypy 默认将参数和返回值视为Any,将其视为渐进类型的一部分。在pyproject.toml中打开严格模式以退出这种宽松的默认设置:

[tool.mypy]
strict = true

strict设置更改了十多个更精细的设置的默认值。如果您再次在模块上运行 mypy,您会注意到类型检查器对您的代码变得更加主观。在严格模式下,定义和调用未注释函数将导致错误。⁶

$ py -m mypy src
__init__.py:16: error: Function is missing a type annotation
__init__.py:22: error: Function is missing a type annotation
__init__.py:30: error: Function is missing a return type annotation
__init__.py:30: note: Use "-> None" if function does not return a value
__init__.py:31: error: Call to untyped function "fetch" in typed context
__init__.py:32: error: Call to untyped function "show" in typed context
__main__.py:3: error: Call to untyped function "main" in typed context
Found 6 errors in 2 files (checked 2 source files)

示例 10-4 展示了带有类型注释的模块,并介绍了两个你还没有见过的概念。首先,Final注释将API_URL标记为常量——一个你不能再给它赋值的变量。其次,TextIO类型是一个文件样对象,用于读取和写入字符串(str),比如标准输出流。除此之外,类型注释应该看起来相当熟悉。

示例 10-4. 具有类型注释的维基百科 API 客户端
import json
import sys
import textwrap
import urllib.request
from dataclasses import dataclass
from typing import Final, TextIO

API_URL: Final = "https://en.wikipedia.org/api/rest_v1/page/random/summary"

@dataclass
class Article:
    title: str = ""
    summary: str = ""

def fetch(url: str) -> Article:
    with urllib.request.urlopen(url) as response:
        data = json.load(response)
    return Article(data["title"], data["extract"])

def show(article: Article, file: TextIO) -> None:
    summary = textwrap.fill(article.summary)
    file.write(f"{article.title}\n\n{summary}\n")

def main() -> None:
    article = fetch(API_URL)
    show(article, sys.stdout)

我建议对于任何新的 Python 项目都使用严格模式,因为这样在你编写代码时注释代码会更容易。严格检查给了你更多对程序正确性的信心,因为类型错误不太可能被 Any 掩盖。

提示

我在 pyproject.toml 中的另一个喜欢的 mypy 设置是 pretty 标志。它显示源代码片段并指示错误发生的位置:

[tool.mypy]
pretty = true

当你在现有的 Python 代码库中添加类型时,让 mypy 的严格模式成为你的北极星。Mypy 给了你一系列更精细和更粗粒度的方式来在你还没有准备好修复类型错误时放松类型检查。

你的第一道防线是形式为 # type: ignore 的特殊注释。始终在方括号中跟随错误代码。例如,这是来自 mypy 输出的一行,其中包含了错误代码:

__main__.py:3: error: Call to untyped function "main" in typed context
  [no-untyped-call]

你可以允许这个特定调用一个未标注类型的函数,如下所示:

main()  # type: ignore[no-untyped-call]

如果你有一个包含大量未标注调用的模块,你可以使用以下在你的 pyproject.toml 中的段落来忽略整个模块的错误:

[tool.mypy."<module>"] ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
allow_untyped_calls = true

1

<module> 替换具有未标注调用的模块。如果模块名包含任何点,请使用双引号。

你也可以全局忽略一个错误,就像这样:

[tool.mypy]
allow_untyped_calls = true

你甚至可以为给定模块禁用所有类型错误:

[tool.mypy."<module>"]
ignore_errors = true

使用 Nox 自动化 mypy

在本书中,你已经使用 Nox 自动化了项目的检查。Nox 会话允许你和其他贡献者在本地开发期间轻松重复地运行检查,就像它们在持续集成(CI)服务器上运行的方式一样。

示例 10-5 展示了一个使用 mypy 对你的项目进行类型检查的 Nox 会话:

示例 10-5. 使用 mypy 进行类型检查的 Nox 会话
import nox

@nox.session(python=["3.12", "3.11", "3.10"])
def mypy(session: nox.Session) -> None:
    session.install(".[typing]")
    session.run("mypy", "src")

就像你在所有支持的 Python 版本上运行测试套件一样,你还应该在每个 Python 版本上对你的项目进行类型检查。这种做法相当有效,可以确保你的项目与这些版本兼容,即使你的测试套件没有运行那个你忘记了向后兼容性的代码路径。

注意

你也可以使用 mypy 的 --python-version 选项传递目标版本。然而,安装项目在每个版本上确保了 mypy 检查你的项目是否与正确的依赖关系匹配。这些在所有 Python 版本上可能不一样。

不可避免地,在对多个版本进行类型检查时,你会遇到以下情况:运行时代码或类型注解在所有版本上都无法正常工作。例如,Python 3.9 已弃用typing.Iterable,推荐使用collections.abc.Iterable。根据 Python 版本进行条件导入,如下所示。静态类型检查器识别你代码中的 Python 版本检查,并基于当前版本的相关代码进行类型检查。

import sys

if sys.version_info >= (3, 9):
    from collections.abc import Iterable
else:
    from typing import Iterable

另一个难点:在支持的 Python 版本范围的低端尚未提供的类型特性。幸运的是,这些通常在名为typing-extensions的第三方库中提供了后续版本。例如,Python 3.11 添加了有用的Self注解,表示当前封闭类。如果你支持比这更旧的版本,将typing-extensions添加到你的依赖项中,并从那里导入Self

import sys

if sys.version_info >= (3, 11):
    from typing import Self
else:
    from typing_extensions import Self

使用 Python 包分发类型

你可能会想为什么示例 10-5 中的 Nox 会将项目安装到 mypy 的虚拟环境中。静态类型检查器本质上操作源代码;它不运行你的代码。那么除了类型检查器本身,为什么还要安装其他东西呢?

要理解这一点的重要性,请考虑示例 6-5 和示例 6-14 中维基百科项目的版本,你在那里使用了 Rich 和httpx实现了showfetch函数。类型检查器如何验证你对特定版本的第三方包的使用?

Rich 和httpx实际上都已经完全类型注解。它们在源文件旁边包含了一个名为py.typed的空标记文件。当你将这些包安装到虚拟环境时,标记文件使得静态类型检查器能够找到它们的类型。

许多 Python 包使用py.typed标记内联分发其类型。然而,存在其他类型分发机制。当 mypy 无法导入包的类型时,了解它们非常有用。

例如,factory-boy库尚未提供类型支持——你需要从 PyPI 安装名为types-factory-boy的存根包。⁷ 存根包是一个 Python 包,其中包含类型存根,这是一种特殊的 Python 源文件,后缀为.pyi,仅包含类型注解而没有可执行代码。

如果你彻底不走运,或者你的依赖库根本没有类型支持,可以在pyproject.toml中禁用 mypy 错误,像这样:

[tool.mypy.<package>] ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
ignore_missing_imports = true

1

用导入包的名称替换<package>

注意

Python 标准库不包含类型注解。类型检查器为标准库类型提供了第三方包typeshed,因此你不必担心提供它们。

类型检查测试

将你的测试视为任何其他代码一样对待。对你的测试进行类型检查有助于你在使用项目、pytest 或测试库时发现错误。

提示

在你的测试套件上运行 mypy 也会对你的项目的公共 API 进行类型检查。当你无法为每个支持的 Python 版本完全类型化实现代码时,这可能是一个很好的后备方案。

示例 10-6 扩展了 Nox 会话以对你的测试套件进行类型检查。安装你的测试依赖项,以便 mypy 可以访问 pytest 和其他库的类型信息。

示例 10-6. 使用 mypy 进行类型检查的 Nox 会话
nox.options.sessions = ["tests", "lint", "mypy"]

@nox.session(python=["3.12", "3.11", "3.10"])
def mypy(session: nox.Session) -> None:
    session.install(".[typing,tests]")
    session.run("mypy", "src", "tests")

测试套件从环境中导入你的包。因此,类型检查器期望你的包分发类型信息。在导入包中添加一个空的 py.test 标记文件,紧挨着 __init____main__ 模块(参见“使用 Python 包分发类型”)。

对测试套件进行类型检查没有任何特别之处。最新版本的 pytest 配备了高质量的类型注释。当你的测试使用 pytest 的内置 fixture 之一时,这些注释很有帮助。许多测试函数没有参数,并返回 None。以下是一个稍微复杂的示例,使用一个 fixture 和来自第六章的测试:

import io
import pytest
from random_wikipedia_article import Article, show

@pytest.fixture
def file() -> io.StringIO:
    return io.StringIO()

def test_final_newline(article: Article, file: io.StringIO) -> None:
    show(article, file)
    assert file.getvalue().endswith("\n")

最后,让我们沉迷于一种自我指涉的状态,并对 noxfile.py (示例 10-7) 进行类型检查。你将需要 nox 包来验证你对 Nox 的使用。我在这里使用了一个小技巧:当会话运行时,已经有一个安装了 Nox 的合适环境——你就在其中!而不是创建另一个带有 Nox 的环境,请使用其 --python-executable 选项指向现有的环境。

示例 10-7. 使用 mypy 对 noxfile.py 进行类型检查
import sys

@nox.session(python=["3.12", "3.11", "3.10"])
def mypy(session: nox.Session) -> None:
    session.install(".[typing,tests]")
    session.run("mypy", "src", "tests")
    session.run("mypy", f"--python-executable={sys.executable}", "noxfile.py")

在运行时检查类型注释

不同于 TypeScript,在那里静态类型仅在编译期间可用,Python 类型注释也可以在运行时使用。运行时检查类型注释是强大功能的基础,并且围绕其使用已经形成了第三方库的生态系统。

解释器将类型注释存储在封闭函数、类或模块的名为 __annotations__ 的特殊属性中。然而,不要直接访问这个属性——将其视为 Python 的内部工具。Python 故意不会屏蔽该属性,但提供了一个易于正确使用的高级接口:函数 inspect.get_annotations()

让我们检查来自示例 10-4 的Article类的类型注释:

>>> import inspect
>>> inspect.get_annotations(Article)
{'title': <class 'str'>, 'summary': <class 'str'>}

回想一下,fetch 函数以这种方式实例化类:

return Article(data["title"], data["extract"])

如果你可以实例化一个 Article,它必须有一个标准的 __init__ 方法来初始化其属性。(你可以通过在交互式会话中访问它来确信这一事实。)这个方法来自哪里?

Python 之禅⁸说:“特例并不足以打破规则。”数据类也不例外:它们只是普通的 Python 类,没有任何秘密配方。鉴于该类没有定义方法本身,它的唯一可能来源是@dataclass类装饰器。事实上,该装饰器会根据你的类型注释动态合成__init__方法以及几个其他方法!当然,不要只听我的话。在这一节中,你将编写自己的迷你@dataclass装饰器。

警告

不要在生产中使用这个!使用标准的dataclasses模块,或者更好的是attrs库。Attrs 是一个积极维护的、具有更好性能、干净 API 和附加功能的行业强度实现,它直接启发了dataclasses

编写 @dataclass 装饰器

首先,成为一个好的类型公民,并考虑@dataclass装饰器的签名。一个类装饰器接受一个类并返回它,通常在某种方式上转换它,比如通过添加一个方法。在 Python 中,类是可以传递和操纵的对象。

输入语言允许你通过写type[str]来引用str类。你可以把它读作“字符串的类型”。(在这里不能单独使用str。在类型注释中,str仅仅是指一个具体的字符串。)一个类装饰器应该适用于任何类对象,虽然——它应该是泛化的。因此,你将使用一个类型变量而不是像str这样的实际类:⁹

def dataclassT -> type[T]:
    ...

类型检查器需要多一点东西才能正确理解你的@dataclass装饰器:它们需要知道你正在向类中添加哪些方法,以及它们可以在从类实例化的对象上期望哪些实例变量。传统上,你必须编写一个类型检查器插件来将这些知识注入到工具中。如今,标准库中的@dataclass_transform标记允许你告知类型检查器该类展示了类似数据类的行为。

from typing import dataclass_transform

@dataclass_transform()
def dataclassT -> type[T]:
    ...

函数签名的问题解决了,现在让我们考虑如何实现这个装饰器。你可以将其分解为两个步骤。首先,你需要组装一个包含数据类上类型注释的__init__方法的源代码字符串。其次,你可以使用 Python 内置的exec函数在运行中评估该源代码。

你可能已经在你的职业生涯中写过几个__init__方法——它们纯粹是样板文件。对于Article类,该方法看起来像这样:

def __init__(self, title: str, summary: str) -> None:
    self.title = title
    self.summary = summary

让我们来解决第一步:从注释中组装源代码(Example 10-8)。此时不要太过担心参数类型——只需使用每个参数类型的__name__属性,在许多情况下都会有效。

示例 10-8。
def build_dataclass_initT -> str: ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
    annotations = inspect.get_annotations(cls) ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)

    args: list[str] = ["self"] ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/3.png)
    body: list[str] = []

    for name, type in annotations.items():
        args.append(f"{name}: {type.__name__}")
        body.append(f" self.{name} = {name}")

    return "def __init__({}) -> None:\n{}".format(
        ', '.join(args),
        '\n'.join(body),
    )

1

在签名中使用类型变量T使其对任何类都通用。

2

将类的注释作为名称和类型的字典检索出来。

3

变量注释仅对body是必需的。大多数类型检查器不会推断body包含字符串,因为此时它是一个空列表。我为对称性注释了这两个变量。

现在,您可以将源代码传递给内置的exec。除了源代码外,此函数还接受全局和局部变量的字典。

检索全局变量的经典方法是使用globals()内置函数。但是,您需要在定义类的模块的上下文中评估源代码,而不是在装饰器的上下文中。Python 将该模块的名称存储在类的__module__属性中,因此您可以在sys.modules中查找模块对象,并从其__dict__属性中检索变量(参见“模块缓存”):

globals = sys.modules[cls.__module__].__dict__

对于局部变量,可以传递一个空字典—这是exec将方法定义放置的地方。唯一需要做的就是将方法从局部字典复制到类对象中,并返回类。例 10-9 展示了整个装饰器。

示例 10-9. 您自己的@dataclass装饰器
@dataclass_transform()
def dataclassT -> type[T]:
    sourcecode = build_dataclass_init(cls)

    globals = sys.modules[cls.__module__].__dict__ ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/1.png)
    locals = {}
    exec(sourcecode, globals, locals) ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/2.png)

    cls.__init__ = locals["__init__"] ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/hpmdn-py-tl/img/3.png)
    return cls

1

从定义类的模块中检索全局变量。

2

这就是魔法发生的地方:让解释器即时编译生成的代码。

3

Et voilà—类现在有了一个__init__方法。

运行时类型检查

除了生成类的样板外,您还可以在运行时处理类型。一个重要的例子是运行时类型检查。为了看到这种技术有多有用,让我们再次看一下fetch函数:

def fetch(url: str) -> Article:
    with urllib.request.urlopen(url) as response:
        data = json.load(response)
    return Article(data["title"], data["extract"])

如果您仔细观察,您可能会注意到fetch不是类型安全的。没有任何保证维基百科 API 将返回预期形状的 JSON 负载。您可能会反对说,维基百科的OpenAPI 规范准确告诉我们可以从端点期望什么数据形状。但是,不要基于对外部系统的假设来制定静态类型—除非您愿意在程序由于错误或 API 更改而中断这些假设时崩溃。

正如您可能已经猜到的那样,mypy 对此问题静默通过,因为json.load返回Any。我们如何使函数类型安全?首先,让我们用您在“类型别名”中定义的JSON类型替换Any

def fetch(url: str) -> Article:
    with urllib.request.urlopen(url) as response:
        data: JSON = json.load(response)
    return Article(data["title"], data["extract"])

我们还没有修复 bug,但至少 mypy 现在为我们提供了诊断信息(编辑以简洁化):

$ py -m mypy src
error: Value of type "..." is not indexable
error: No overload variant of "__getitem__" matches argument type "str"
error: Argument 1 to "Article" has incompatible type "..."; expected "str"
error: Invalid index type "str" for "JSON"; expected type "..."
error: Argument 2 to "Article" has incompatible type "..."; expected "str"
Found 5 errors in 1 file (checked 1 source file)

Mypy 的诊断归结为函数中的两个单独问题。首先,代码索引 data 时没有验证它是否是字典。其次,它将结果传递给 Article 而没有确保它们是字符串。

现在让我们检查一下 data 的类型——它必须是一个带有 titleextract 键的字典。您可以使用结构化模式匹配来简洁地表达这一点:

def fetch(url: str) -> Article:
    with urllib.request.urlopen(url) as response:
        data: JSON = json.load(response)

    match data:
        case {"title": str(title), "extract": str(extract)}:
            return Article(title, extract)

    raise ValueError("invalid response")

由于类型缩小,运行时类型检查也满足了 mypy——在某种程度上,它架起了运行时和静态类型检查的桥梁。如果您想看到运行时类型检查的实际效果,可以使用来自第六章的测试工具,并修改 HTTP 服务器以返回意外响应,如 null"teapot"

使用 cattrs 进行序列化和反序列化

现在该函数是类型安全的了,但我们能做得比这更好吗?验证代码复制了 Article 类的结构——您不应该需要再次声明其字段的类型。如果您的应用程序必须验证多个输入,模板代码可能会影响可读性和可维护性。只使用原始类型注释即可从 JSON 对象中组装文章,这应该是可能的——而且确实如此。

cattrs 库为诸如数据类和 attrs 之类的类型注释类提供了灵活和类型安全的序列化和反序列化。它非常简单易用——您只需将 JSON 对象和预期类型传递给其 structure 函数,就可以得到组装好的对象。¹⁰ 还有一个 destructure 函数,用于将对象转换为原始类型以进行序列化。

在维基百科示例的最后迭代中,将 cattrs 添加到您的依赖项中:

[project]
dependencies = ["cattrs>=23.2.3"]

用下面的三行代码替换 fetch 函数(尚未运行此代码,我们稍后将达到最终版本):

import cattrs

def fetch(url: str) -> Article:
    with urllib.request.urlopen(url) as response:
        data: JSON = json.load(response)
    return cattrs.structure(data, Article)

现在,Article 对象的反序列化完全由其类型注释决定。除了清晰简洁外,这个版本的代码还是类型安全的,这要归功于 cattrs 中的内部运行时检查。

但是,您仍然需要解决一个复杂的问题。summary 属性与其对应的 JSON 字段 extract 的名称不匹配。幸运的是,cattrs 具有足够的灵活性,可以让您创建一个自定义转换器,在运行时即时重命名字段:

import cattrs.gen

converter = cattrs.Converter()
converter.register_structure_hook(
    cattrs.gen.make_dict_structure_fn(
        Article,
        converter,
        summary=override(rename="extract"),
    )
)

最后,在 fetch 函数中使用自定义转换器:

def fetch(url: str) -> Article:
    with urllib.request.urlopen(url) as response:
        data: JSON = json.load(response)
    return converter.structure(data, Article)

从软件架构的角度来看,cattrs 库相比其他流行的数据验证库有其优势。它将序列化和反序列化与您的模型(即应用程序核心的类,表达其问题域并提供所有业务逻辑)分离开来。将领域模型与数据层解耦为您提供了架构上的灵活性,并改善了代码的可测试性。¹¹

cattrs 方法也有其实际优势。如果需要的话,你可以以不同的方式序列化相同的对象。它不会侵入——不会向你的对象添加方法。它适用于各种类型:数据类、attrs-类、命名元组、类型字典,甚至是像 tuple[str, int] 这样的普通类型注解。

使用 Typeguard 进行运行时类型检查

你是否觉得原始 fetch 函数的类型不安全令人不安?在一个短脚本中很容易发现这个问题。但是在一个大型代码库中如何找到类似的问题以避免它们造成问题?毕竟,你以严格模式运行了 mypy,但它保持沉默。

静态类型检查器不会捕捉到每一个与类型相关的错误。在这种情况下,逐步类型化模糊了问题——具体来说,json.load 返回 Any。真实世界的代码存在许多类似的情况。你无法控制的库可能有过于宽容的类型注解——或者根本没有。你的持久化层可能会从磁盘加载损坏的对象。也许 mypy 本来会捕捉到这个问题,但你却在该模块中禁止了类型错误。

Typeguard 是一个第三方库和 pytest 插件,用于运行时类型检查。在静态类型检查器无法捕捉到的情况下,它可以成为验证代码类型安全的宝贵工具,比如:

动态代码

Python 代码可以是高度动态的,这迫使类型注解变得宽容。你对代码的假设可能与运行时实际得到的具体类型不一致。

外部系统

大多数真实世界的代码最终会跨越到外部系统的边界,比如一个 web 服务、数据库或文件系统。你从这些系统接收到的数据可能不是你期望的格式。它的格式也可能会在一天之内意外地改变。

第三方库

你的一些 Python 依赖项可能没有类型注解,或者它们的类型注解可能不完整或过于宽容。

将 Typeguard 添加到你的依赖项中的 pyproject.toml 文件:

[project]
dependencies = ["typeguard>=4.1.5"]

Typeguard 提供了一个名为 check_type 的函数,你可以将其视为任意类型注解的 isinstance。这些注解可能非常简单——比如,一个浮点数列表:

from typeguard import check_type

numbers = check_type(data, list[float])

这些检查也可以更加复杂。例如,你可以使用 TypedDict 结构来指定从某些外部服务获取的 JSON 对象的精确形状,比如你期望找到的键以及它们关联值应该具有的类型:

from typing import Any, TypedDict

class Person(TypedDict):
    name: str
    age: int

    @classmethod
    def check(cls, data: Any) -> Person:
        return check_type(data, Person)

以下是你可以使用它的方式:

>>> Person.check({"name": "Alice", "age": 12})
{'name': 'Alice', 'age': 12}
>>> Person.check({"name": "Carol"})
typeguard.TypeCheckError: dict is missing required key(s): "age"

Typeguard 还提供了一个名为 @typechecked 的装饰器。当作为函数装饰器使用时,它会使函数被检查其参数和返回值的类型。当作为类装饰器使用时,它会以这种方式检查每个方法。例如,你可以将此装饰器应用于从 JSON 文件中读取 Person 记录的函数:

@typechecked
def load_people(path: Path) -> list[Person]:
    with path.open() as io:
        return json.load(io)

默认情况下,Typeguard 仅检查集合中的第一个项以减少运行时开销。您可以在全局配置对象中更改此策略以检查所有项:¹³

import typeguard

typeguard.config.collection_check_strategy = CollectionCheckStrategy.ALL_ITEMS

最后,Typeguard 带有一个导入钩子,在导入时为模块中的所有函数和方法提供仪表化。虽然您可以显式使用导入钩子,但其最大的用例可能是在运行测试套件时将 Typeguard 作为 pytest 插件启用。让我们添加一个 Nox 会话,用于带有运行时类型检查的测试套件:

示例 10-10. 使用 Typeguard 进行运行时类型检查的 Nox 会话
package = "random_wikipedia_article"

@nox.session
def typeguard(session: nox.Session) -> None:
    session.install(".[tests]", "typeguard")
    session.run("pytest", f"--typeguard-packages={package}")

将 Typeguard 作为 pytest 插件运行,可以帮助您在大型代码库中找出类型安全性问题,前提是它具有良好的测试覆盖率。如果没有良好的测试覆盖率,请考虑在生产环境中为单个函数或模块启用运行时类型检查。在此要小心:注意类型检查可能出现的误报,并测量它们的运行时开销。

概要

类型注解允许您在源代码中指定变量和函数的类型。您可以使用内置类型和用户定义的类,以及许多更高级别的构造,例如联合类型、渐进式类型化中的Any、泛型和协议。字符串化的注解和Self对处理前向引用非常有用。type关键字允许您引入类型别名。

类似 mypy 这样的静态类型检查器利用类型注解和类型推断来验证程序的类型安全性,而无需运行程序。Mypy 通过将未注释的代码默认为Any来促进渐进式类型化。您应该尽可能启用严格模式以进行更彻底的检查。使用 Nox 会话作为自动化的一部分来运行 mypy 作为您的强制性检查之一。

类型注解在运行时可供检查。它们是强大功能的基础,例如使用dataclassesattrs库生成类,并且借助cattrs库实现自动序列化和反序列化。运行时类型检查器 Typeguard 允许您在运行时检查函数参数和返回值的类型。您可以将其作为 pytest 插件启用,并在运行测试套件时使用。

有一种普遍的观点认为类型注解适用于巨型科技公司的庞大代码库,而对于合理规模的项目,更不用说昨天下午匆忙拼凑的快速脚本。我不同意。类型注解使您的程序更易于理解、调试和维护,无论其规模有多大或有多少人参与开发。

尝试为您编写的任何 Python 代码使用类型。如果可能的话,最好配置您的编辑器在后台运行类型检查器(如果编辑器没有开箱即用的类型支持)。如果您觉得类型会妨碍您,考虑使用渐进式类型;但同时也要考虑是否有更简单的方法来编写代码,从而免费获取类型安全性。如果您的项目有任何强制性检查,类型检查应该是其中的一部分。

随着这一章的结束,本书也告一段落。

在整本书中,您使用 Nox 自动化了项目的检查和任务。Nox 会话允许您和其他贡献者在本地开发期间早期和重复地运行检查,就像它们在 CI 服务器上运行一样。以下是您定义的 Nox 会话列表供参考:

  • 构建软件包(示例 8-2)

  • 在多个 Python 版本上运行测试(示例 8-5)

  • 运行带代码覆盖率的测试(示例 8-9)

  • 在子进程中测量覆盖率(示例 8-11)

  • 生成覆盖率报告(示例 8-8)

  • 使用 uv 锁定依赖项(示例 8-14)

  • 使用 Poetry 安装依赖项(示例 8-19)

  • 使用 pre-commit 进行代码风格检查(示例 9-6)

  • 使用 mypy 进行静态类型检查(示例 10-7)

  • 使用 Typeguard 进行运行时类型检查(示例 10-10)

这种方法背后有一种基本的哲学理念,被称为“左移”。考虑软件开发生命周期,从左到右的时间轴—​从编写一行代码到在生产环境中运行程序。 (如果您是敏捷思维的,把时间轴想象成一个圆圈,从生产中获取的反馈再回流到计划和本地开发中。)

发现软件缺陷越早,修复成本越低。在最好的情况下,您在编辑器中发现问题—​它们的成本几乎为零。在最坏的情况下,您会将错误提交到生产环境。在开始追踪代码中的问题之前,您可能需要回滚错误的部署并控制其影响。因此,尽可能将所有检查向那个想象中的时间轴的左侧移动。

(在时间轴的右侧也要运行检查。针对生产环境的端到端测试对于增强系统按预期运行的信心非常有价值。)

CI 中的强制性检查是主要的守门员:它们决定哪些代码变更进入主分支并运行到生产环境。但不要等待 CI。尽早在本地运行检查。使用 Nox 和 pre-commit 自动化检查有助于实现此目标。

同样,请将 linters 和类型检查器与您的编辑器集成!然而,人们尚未就所有人都应该使用的单一编辑器达成一致。像 Nox 这样的工具为您的团队本地开发提供了共同的基线。

自动化还极大地降低了项目维护成本。贡献者只需运行单个命令,例如nox,作为强制检查的入口。其他任务,如刷新锁文件或生成文档,同样只需要简单的命令。通过编码每个过程,您消除了人为错误,并为持续改进奠定了基础。

感谢您阅读本书!尽管书籍到此结束,您在现代 Python 开发工具的不断变化中的旅程仍在继续。希望本书的教训仍然有效和有帮助,因为 Python 继续自我革新。

¹ Jukka Lehtosalo, “我们在 Python 4 百万行代码中的类型检查之旅,” 2019 年 9 月 5 日。

² “Python 类型系统规范.” 最后访问时间:2024 年 1 月 22 日。

³ Tin Tvrtković: “Python 如今是两种语言,这实际上很棒,” 2023 年 2 月 27 日。

⁴ 在未来的 Python 版本中,这将直接可用。参见 Larry Hastings: “PEP 649 – 使用描述符延迟评估注释,” 2021 年 1 月 11 日。

⁵ 如果您看到“PEP 695 类型别名尚未支持”的错误消息,请暂时省略type关键字。类型检查器仍将此赋值解释为类型别名。如果希望更明确,您可以从 Python 3.10 开始使用typing.TypeAlias注解。

⁶ 为简洁起见,我已从 mypy 输出中删除了错误代码和前导目录。

⁷ 我写作时,预期的factory-boy即将内联发布类型。

⁸ Tim Peters: “PEP 20 – Python 之禅,” 2004 年 8 月 19 日。

⁹ 我写作时,mypy 还未支持 PEP 695 类型变量。如果遇到 mypy 错误,请在 Pyright 游乐场中检查代码类型,或使用旧版TypeVar语法。

¹⁰ 实际上,cattrs库与格式无关,因此无论您是从 JSON、YAML、TOML 还是其他数据格式读取原始对象,都不会有影响。

¹¹ 如果你对这个主题感兴趣,绝对应该阅读 Python 架构模式,作者是哈里·佩西瓦尔和鲍勃·格雷戈里(塞巴斯托波尔:O’Reilly,2020)。

¹² 这看起来可能没那么有用。 TypedDict 类必须列出每个字段,即使你只使用其中的一个子集。

¹³ 如果你直接调用 check_type,你需要显式传递 collection_check_strategy 参数。

关于作者

克劳迪奥·约洛维奇是 Cloudflare 的高级软件工程师,拥有近 20 年的 Python 和 C++行业经验,并且是 Python 社区中的开源维护者。他是《超现代 Python》博客和项目模板的作者,以及 Python 测试自动化工具 Nox 的共同维护者。在前世中,克劳迪奥曾是法学学者和从斯堪的纳维亚到西非巡演的音乐家。欢迎在 Mastodon 上与他联系:@cjolowicz@fosstodon.org

版权信息

《超现代 Python 工具链》封面上的动物是秘鲁剪尾鸟(Thaumastura cora),它是蜂鸟族(Mellisugini)中的一员。

大多数蜂鸟族中的雄鸟都有特化的尾羽,常用于求偶展示时制造声音。正如本书封面所示,秘鲁剪尾鸟的雄性确实拥有非常长的黑白分叉尾羽。雄性和雌性的上部羽毛呈发光的绿色,而雄性的喉部羽毛则呈亮紫色至品红色。

秘鲁剪尾鸟是最小的蜂鸟之一,有人认为它是所有南美蜂鸟中体重最轻的。剪尾鸟栖息在秘鲁干旱的沿海灌木地带,以及农田、花园和果园中,寻找开花植物采集花蜜。它们的种群正在向智利扩展,个体也曾在厄瓜多尔被发现。

由于其稳定的种群数量,秘鲁剪尾鸟被 IUCN 评定为从保护角度来看是最不受关注的物种。O’Reilly 封面上的许多动物都濒临灭绝;它们对世界至关重要。

封面插图由卡伦·蒙哥马利绘制,基于伍德斯的古线刻画。系列设计由伊迪·弗里德曼、埃莉·沃尔克豪森和卡伦·蒙哥马利完成。封面字体为 Gilroy Semibold 和 Guardian Sans。正文字体为 Adobe Minion Pro;标题字体为 Adobe Myriad Condensed;代码字体为道尔顿·马格的 Ubuntu Mono。

posted @ 2024-06-17 19:07  绝不原创的飞龙  阅读(84)  评论(0编辑  收藏  举报