Python-包发布指南-全-
Python 包发布指南(全)
原文:Publishing Python Packages
译者:飞龙
前言
前言
每个人开始他们的 Python 之旅都是从不同的地方开始的。无论你从哪里开始,你最终都会开始努力上游,去体验新的景象和声音。当河流变窄,树木茂密时,你甚至可能开始考虑如何将 Python 应用程序交付给他人。在那里,你会在河流源头一个隐藏洞穴的入口处发现破碎项目的残骸。突然,你听到一个空洞的声音问道:“你最喜欢的打包工具是什么?”就在你被抛入深渊之前。
Python 为每个人提供了东西——你通常可以在 Python 包索引(pypi.org)中找到它。随着你开始担心安装、依赖、环境以及与在现实世界中使用你的软件相关的重要事项,PyPI 带来了新的挑战。这就是新的开源包被创建和维护的地方。这是这本急需的书的起点。
大多数 Python 书籍的一个安静的秘密是,没有人真的想过多地谈论打包(我说话有经验)。哦,书籍很乐意在软件组织方面谈论模块和包。一本书甚至可能提供一个基本的包的简单框架,你可以将其赠送出去。然而,制作一个生产级可下载包的实际过程通常“留给读者作为练习”。因此,这本书占据了一个独特的位置,因为它直接解决了这个具体问题。
即使大多数 Python 用户可能不愿意发布公开的包,他们仍然经常需要将 Python 代码交付给他人,这样做可能会带来相当大的挑战。这很大程度上是生态系统复杂性的自然结果。Python 包往往不仅仅涉及 Python。例如,它们可能包括 Python、C、C++、Fortran 和 Rust 等多种编程语言的混合。包可以在任何数量的操作系统环境中运行,如 Linux、Mac、Windows,或者更近期的 Web Assembly。考虑到处理多个 Python 版本之间的兼容性问题、包依赖关系以及包管理工具不断变化的格局,问题似乎只会变得更加复杂。
在写这篇前言之前,我有一个简单的问题要问——我能用这本书的知识来现代化一些我自己的项目吗?我从事 Python 工作已经超过二十五年了。在这段时间里,我发布并维护了几个小型包。然而,这始终是一项兼职工作。说实话,在打包方面,我通常希望尽可能少做工作。这意味着我带着一定程度的怀疑、老手的惰性和没有关于打包“最佳实践”的建议来到这里,因为我真的不知道。
我很高兴地报告,我从这本书中学到了很多。首先,它对当前围绕软件包的工具采取了完全现代的方法。其次,它在提出针对这些问题的实际解决方案之前,提供了很多与软件包开发者面临的问题相关的背景信息。此外,我还学会了与一些我已使用的工具相关的一些新技巧(例如,pytest、coverage 等)。最后,你甚至还能找到关于创建和管理你项目社区的建议。
所有这些话,这本书并不一定是一本“容易”的书。即使有现代的处理方法,也没有一种适合所有情况的软件包方法。你可能会尝试不同的方法,并以自己的方式调整这本书。我认为关键是保持开放的心态,并将这本书视为一个实际指南,了解可能发生的事情。这样做的话,我认为你会发现它是一个有用的灵感来源。
——大卫·比泽利 (www.dabeaz.com),《Python Distilled》一书的作者
前言
我于 2014 年秋季开始在 ITHAKA 工作。团队一直在不懈努力,摆脱一个具有数月发布周期的专有内容管理系统,这使得快速更改变得困难。这一努力得到了丰厚的回报,孕育了一个能够跟上我们所需敏捷性的新交付平台。
前端团队投资于 Django 网络框架,以支持 JSTOR (www.jstor.org) 的新用户界面开发,这个选择在我的加入中起到了作用。该团队还早期投资于支持项目,通过可安装的 Python 软件包,这在我们的产品提供多样化同时保留共享核心功能时证明是有帮助的。我们内容和访问模型周围的业务领域很复杂;这些软件包为它们提供了有用的边界和可重用性。尽管我们把这个概念推向了强有力的发展方向,但我们仍然有一些不足。
我们并没有运营一个私有软件包仓库,因此所有软件包都必须从我们的版本控制系统安装。没有语义版本控制,Git 提交哈希值也没有提供太多关于版本之间变化的信息或管理期望。我们没有维护变更日志——提交信息是我们的变更管理媒介。
在接下来的几年里,我们对 Django 和 Python 的投资演变成了数万行代码,服务于 JSTOR 上的大部分流量。我们的软件包在规模和数量上都在增长,而我们的软件包故事的摩擦力产生了牛道。我们将软件包拉到与应用程序代码一起生活,放弃了选择更改的能力,以换取在开发过程中立即看到更改反映的相对便利。代码落在了最容易的地方。
当核心基础设施团队投资于对私有打包的一流支持时,这种范式发生了转变。看到这种新功能,并诊断我们现有的组织结构后,我试图了解我们如何利用持续集成和标准化来保持我们的敏捷性,同时提高质量并恢复选择加入变化的能力。
apiron 项目(github.com/ithaka/apiron)是第一个采用新打包实践的努力,并继续成为 ITHAKA 的第一个旨在第三方使用的积极开发的开源项目。随着这种打包工作流程的好处越来越明显,我们广泛采用了这个流程。如今,ITHAKA 的前端团队维护着超过二十多个 Python 包,支持类似数量的应用程序。
ITHAKA 的使命是提高知识的获取,我希望这本书能尽其所能,在某些地方开阔你的眼界,在另一些地方实现你希望和梦想的事情。虽然实用,但我还希望你能获得一些理论和哲学工具,帮助你和你所在的团队通过自动化和可重复的过程显著提高生产力。可能会有一些你不同意的地方,这很棒——这是对我们作为创作者不断迭代的知识集体的反馈机会。我希望你喜欢、受益、不同意并批判。
你可以通过 pubpypack@danehillard.com 与我联系,提出问题、分享成功故事和辩论。
致谢
尽管这本书对我来说完成起来很困难,但如果没有 Python 软件基金会和 Python 打包权威机构那些不可估量的工作,它将无法完成。你们在保持 Python 打包故事持续上升的努力中,所付出的努力非常值得赞赏。虽然我在这里收集了知识和流程,但我很大程度上是站在巨人的肩膀上。
我有幸在一家高端家居用品店写作我的第一本书,那里有一个舒适的咖啡厅。然而,这本书是 100%自产自销——大部分是在我的伴侣 Stefanie 的办公桌前完成的。感谢你的冷静、你的善良和你的定期喜剧缓解。如果我们能在疫情引发的 cabin fever 和我肆无忌惮的拖延和抱怨之上坚持下去,我们总有一天会一起统治世界。
感谢 ITHAKA 团队对学习、改进和创新的持续热情。你们追求卓越的动力让我继续前进。
这本书差点就没能问世。感谢我的发展编辑 Toni Arritola 和我的收购编辑 Mike Stephens,在我临时加入第二个项目时给予我支持。你们的鼓励和反馈确保了这次努力变得有价值。
向我的技术编辑 Al Krinker 致谢,感谢你一直询问动机。这无疑提高了工作的影响力和清晰度。
感谢 Marjan Bace 和 Manning 团队的其他成员使这本书成为现实,并将其送到那些需要它的人手中。
感谢那些勇敢的投资者在早期就投资这本书并在过程中提供反馈的人。你们让我走上了正轨,并且跌倒了,这样其他人就不必再跌倒了。
感谢所有为这本书工作的审稿人:Aleksei Agarkov、Cage Slagel、Clifford Thurber、Daniel Holth、David Cabrero Souto、David Cronkite、Delena Malan、Edgar Hassler、Emanuele Piccinelli、Eric Chiang、Ganesh Swaminathan、Håvard Wall、Howard Bandy、Jim Amrhein、Johnny Hopkins、Jose Apablaza、Joshua A. McAdams、Katia Patkin、Kevin Etienne、Kimberly L. Winston-Jackson、Larry Cai、Laxman Singh Tomar、Marc-Anthony Taylor、Mathijs Affourtit、Matthias Busch、Mike Baran、Miki Tebeka、Richard J. Tobias、Richard Meinsen、Robert Vanderwall、Salil Athalye、Sriram Macharla、Vasudevan Surendran、Vidhya Vinay、Vraj Mohan 和 Zoheb Ainapore,你们的建议帮助使这本书变得更好。
最后,感谢任何对这本书产生积极影响的人——无论是直接的、故意的还是其他方式——。我无法列出详尽无遗的名单;这里没有出现名字是因为我自己的思维局限。感谢 Ee Durbin、Dustin Ingram、Brett Cannon、Paul Ganssle、Filipe Laíns、Bernát Gábor、Łukasz Langa、Sébastien Eustace、Thomas Kluyver、Donald Stufft、Simon Willison、Will McGugan、Dawn Wages、Reuven Lerner、David Beazley、Brett Slatkin、Tzu-Ping Chung、Henry Schreiner、Pradyun Gedam、Paul Moore、Tushar Sadhwani、Sandi Metz、Jason Coombs、Jeff Triplett、Carlton Gibson、Chris Kolosiwsky 和 Peter Ung。
关于这本书
《发布 Python 包》介绍了与 Python 打包相关的几个特定方面,以及几乎适用于任何语言的几个概念,这些概念结合起来可以使团队和个人在软件开发中更加高效。DevOps 团队、产品团队和站点可靠性团队都可以找到新的实践和工具来完善他们的工作。如果你希望尽可能自动化、标准化和编排你的 Python 项目的生命周期,这本书可能适合你。
谁应该阅读这本书?
《发布 Python 包》适合任何已经熟悉 Python 并希望分享他们的代码——无论是与朋友、团队还是全世界的人分享的人。这本书中包含的实践是特意选择的,一个人可以管理,但可以扩展到几乎任何规模的团队。协作是有效软件开发的关键,因此这些实践倾向于消除乏味,以便你可以专注于通过代码和散文进行适当的沟通。
随着软件在科学界的增长,打包软件正变得越来越有价值的商品。成功的开源项目在最近的里程碑中得到了突出,如登陆火星和成像黑洞。无论您是希望创造下一个大事件,还是只想确保您的实验室 PI 可以验证您用于生成结果的代码,可重复的过程是关键。
如果您之前没有使用过单元测试和 linting 等软件质量工具,这本书可以帮助您在轻量级介绍的基础上自动化工作,并帮助您将质量检查扩展成一个丰富的自动化套件。您可以将时间花在思考如何在问题出现之前捕捉它们,而不是扑灭火焰。
本书是如何组织的:一个路线图
《发布 Python 包》由 11 章组成,分为四个部分。第一部分涵盖了任何类型软件打包的固有价值。第二部分带您构建一个包含所需大多数功能的实际工作包。第三部分让您深入了解高度协作项目的自动化和维护需求。第四部分向您展示如何重复此过程并扩展您的用户和贡献者基础。
第一部分“基础”为 Python 打包设定了舞台,并在您开始构建自己的包时让您处于正确的思维模式,涵盖了以下领域:
-
第一章涵盖了打包是如何产生的以及为什么它今天在共享软件方面仍然具有同样的价值。它可能会拓宽您对什么算作包的看法,您将开始看到包针对许多受众。
-
第二章涵盖了从附录中获取的工具,这些工具可用于产品打包工作。
-
第三章向您展示了成为 Python 包的含义的基石,包括涉及的文件和元数据以及它们如何在整个流程中流动。
第二部分“创建可行的包”将您的最小 Python 包扩展成具有真实行为的东西,您在完成本书后可以在此基础上进行扩展:
-
第四章向您展示如何将第三方依赖项、命令行界面和非 Python 扩展纳入包中。
-
第五章介绍了单元测试工具,用于协调您的单元测试活动,以确保软件行为的质量。
-
第六章在质量方面进一步发展,包括对常见错误、类型安全和一致代码格式的检查。
第三部分“公开上市”建议您可以在任何地方采用但特别适用于与他人协作的实践:
-
第七章展示了自动化和持续集成原则的力量,帮助您思考如何为贡献者创建紧密的反馈循环。
-
第八章涵盖了文档的重要性,并展示了如何集成一个涵盖代码和散文的自动化文档构建系统。
-
第九章提供了定期以最小努力保持 Python 包更新的做法,这样你就可以避免积累技术债务。
第四部分,“长期坚持”,回答了关于在你获得新技能集后下一步该去哪里的问题:
-
第十章帮助你将之前章节中学到的做法转化为可重复的项目模板,用于未来的项目。
-
第十一章提供了围绕你的项目建立用户、贡献者和维护者社区的做法,这些社区依赖于前面章节中明确的流程。
我建议从头到尾阅读《发布 Python 包》。每一章都是在前一章的基础上构建的,沿途还有令人兴奋的里程碑。
此外,以下附录将帮助你安装工具,这些工具可以使包装工作在我的经验中变得更加愉快:
-
附录 A 帮助你安装工具,这些工具可以让你更容易地安装多个版本的 Python 以及其他语言,并调用你在旅途中创建的各种 Python 解释器和虚拟环境。
-
附录 B 帮助你安装项目无关的工具,这些工具对于书中的项目是必需的,但也可以提高你在大多数 Python 项目中的生产力。
关于代码
本书包含许多源代码示例,既有编号的列表,也有与普通文本并列。在两种情况下,源代码都以固定宽度字体如这样this来格式化,以将其与普通文本区分开来。有时代码也会加粗,以突出显示与章节中先前步骤相比已更改的代码,例如当新功能添加到现有代码行时。
在许多情况下,原始源代码已经被重新格式化;我们添加了换行并重新调整了缩进,以适应书籍中可用的页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续接标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中移除。许多列表旁边都有代码注释,突出显示重要概念。
你可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为livebook.manning.com/book/publishing-python-packages。本书中示例的完整代码可以从 Manning 网站www.manning.com和 GitHubmng.bz/69A5下载。
对于每一章,代码反映了包在章节结束时所处的完整状态。我仔细地做出了这个选择——因为包装配置需要在多个文件中精确的语法和值,这比常规编程更难正确。为了最大限度地减少你的挫败感,我相信这与按列表组织代码相比是最好的参考。
由于包装实践可能会改变,我将在一段时间内提供此代码伴侣的更新版本。我将将这些版本与伴随本版的代码分开维护,以减少混淆,并在可用时从代码伴侣中提供链接。
liveBook 讨论论坛
购买《Python 包发布》包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,您可以在全球范围内或针对特定章节或段落附加评论。为自己做笔记、提出和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/publishing-python-packages/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 论坛和行为准则的信息。
Manning 对我们读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量承诺的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书有售,论坛和先前讨论的存档将从出版社的网站提供。
关于作者

Dane Hillard 目前是高等教育非营利组织 ITHAKA 的技术架构师。他的经验包括为支持数百万用户的 JSTOR 研究平台构建应用程序架构。他目前对安全、松散耦合系统和形式方法感兴趣。
关于封面插图
《Python 包发布》封面上的图像取自 Jacques Grasset de Saint-Sauveur 的收藏,该收藏于 1797 年出版。每一幅插图都是手工精心绘制和着色的。
在那些日子里,人们通过他们的着装很容易就能识别出他们住在哪里以及他们的职业或社会地位。Manning 通过基于几个世纪前丰富多样的地区文化的封面,以及像这一系列图片这样的收藏,庆祝计算机行业的创新精神和主动性。
1 Python 包的什么和为什么
本章涵盖
-
打包代码以使其更容易被他人访问
-
使用包来使你的项目更易于管理
-
为不同平台构建 Python 包
想象一下,你已经为自动驾驶汽车编写了一款具有突破性的 Python 软件。你的最新作品将改变世界,你希望尽可能多的人使用它。你已经说服 CarCorp 使用你的解决方案,他们希望获取代码以开始使用。
当 CarCorp 打电话询问如何安装和使用你的代码时,你详细说明了将每个文件复制到正确的目录,使一些文件可执行以便作为命令运行等等。因为你编写了软件,所以这一切对你来说都是第二本能。令你惊讶的是,电话另一端的开发者有点迷茫。发生了什么?
你发现了那些创造软件的人和那些使用软件的人之间常常存在的鸿沟。如今,当人们需要新东西时,他们习惯于访问 iPhone 上的应用商店。如果你想提高你软件的用户体验,你还有一点工作要做!
在这本书中,你将学习如何将你的 Python 项目作为可安装的包分发,使其更容易被他人访问。你还将学习如何为管理你的项目创建一个可重复的过程,减少你维护它们所花费的努力,这样你就可以专注于你的真正抱负:改变世界。你将通过构建一个真实的项目并使用一些流行的打包工具来自动化流程的几个方面来实现这一切。尽管 Python 社区已经为打包的一些领域制定了标准,但“唯一正确的方式©”尚未出现。也许它永远不会出现!
即使你之前已经创建或发布过 Python 包,你在这本书中也会找到适合你的内容。你将在本书中学到的建议和工具是经过时间考验的,用于一些定义较为宽松的打包实践。Python 打包有着混乱的历史和许多当前的替代选项,因此,除了查看和使用现在可用的工具外,你还将学习它们背后的工作方法,以便随着环境的成熟而继续适应。为此,首先理解为什么软件需要打包是很重要的。
1.1 什么是包?
为了挽救与 CarCorp 的关系,你承诺几周后回来,带来一个彻底改进的过程,这将帮助他们轻松安装你的软件。你知道一些你最喜欢的 Python 代码,如pandas和requests,可以作为包在网上获得,你希望为你自己的消费者提供同样的安装便利。
打包是将软件及其描述这些文件的元数据存档的行为。开发者通常创建这些存档,或包,目的是为了共享或发布它们。
重要提示:Python 生态系统使用“软件包”一词来表示两个不同的概念。Python 打包权威机构(PyPA)在 Python 打包用户指南 (packaging.python.org) 中区分了这些术语,如下所示:
-
导入包 将多个 Python 模块组织到一个目录中,用于发现目的 (
mng.bz/wypg)。 -
分发包 将 Python 项目存档以供他人安装 (
mng.bz/qoNz)。
软件包的导入并不总是以存档的形式分发,尽管分发包通常包含一个或多个导入包。分发包是本书的主要内容,并在必要时将与导入包区分开来,以避免混淆。
在将软件及其元数据组合在一起可能有无限种方式的情况下,维护者和软件使用者如何管理期望并减少手动工作?这就是 包管理系统 发挥作用的地方。
1.1.1 标准化打包以实现自动化
软件包管理系统,或称为 包管理器,为特定领域的软件包标准化存档和元数据格式。包管理器提供工具以帮助消费者在项目、编程语言、框架或操作系统级别安装依赖项。大多数包管理器都附带一套熟悉的指令来安装、卸载或更新软件包。你可能使用过以下一些包管理器:
-
pip (
pip.pypa.io) -
conda (
docs.conda.io) -
Homebrew (
brew.sh/) -
npm (
www.npmjs.com/) -
asdf (
asdf-vm.com/)
包管理早期的日子
尽管开发者们已经非正式地打包他们的代码有一段时间了,但直到 20 世纪 90 年代初包管理系统变得广泛可用,这种方法才开始流行(参见 Jeremy Katz,“包管理简史”,Tidelift,mng.bz/7ZG4)。
通过声明性定义项目依赖关系的能力,通过抽象出管理软件项目的主要繁琐工作,极大地提高了开发者的生产力。
软件仓库 通过作为集中市场来发布和托管其他可以安装的软件包,进一步标准化了软件打包(见图 1.1)。许多编程语言社区提供官方或事实上的标准仓库以安装软件包。PyPI (pypi.org)、RubyGems (rubygems.org/) 和 Docker Hub (hub.docker.com/) 是一些流行的软件仓库。

图 1.1 软件包、包管理器和软件仓库对于软件共享都是至关重要的。
如果你拥有一部智能手机、平板电脑或台式电脑,并且从应用商店安装了应用,那么这就是在应用软件包。软件包是将软件与关于该软件的元数据捆绑在一起,这正是应用的本质。软件仓库托管人们可以安装的软件,这就是应用商店。
因此,软件包是将软件和元数据结合在一起,按照约定的格式编码在相关的包管理系统中。在更细粒度的层面上,软件包通常还包括在用户系统上构建软件的方法,或者它们可能为各种目标系统提供几个预构建的软件版本。
1.1.2 分发软件包的内容
图 1.2 展示了一些你可能选择放入分发软件包中的文件。开发者通常在软件包中包含源代码文件,但他们也可以提供编译工件、测试数据和消费者或同事可能需要的任何其他内容。通过分发软件包,你的消费者将有一个一站式商店来获取他们开始使用你的软件所需的所有部件。

图 1.2 一个软件包通常包括源代码、编译代码的 makefile、关于代码的元数据以及给消费者的说明。
分发非代码文件是一个重要的功能。尽管代码通常是分发任何内容的原因,但许多用户和工具依赖于关于代码的元数据来区分它与其他代码。开发者通常在元数据中指定软件项目的名称、其创建者、可以重新使用的许可协议等。重要的是,元数据通常包括存档的版本,以区分项目之前的和未来的发布。
软件共享的早期阶段
在 Unix 操作系统首次可用后的十多年里,团队和个人之间的软件共享仍然是一个主要的手动过程。下载源代码、编译它以及处理编译的工件都留给了试图使用代码的人。这个过程的每一步都引入了由于人为错误和系统之间的架构或环境差异而失败的机会。像 Make (www.gnu.org/software/make/) 这样的工具从过程中移除了一些这种变化,但并未触及包版本、依赖和安装管理。
现在你已经熟悉了软件包的内容,你将了解这种软件共享方法如何在实践中解决具体问题。
1.1.3 软件共享的挑战
您与 CarCorp 的通话变得越来越紧张,您意识到您忘记让他们首先安装您项目的所有依赖项。您退后几步,并引导他们进行依赖项安装。不幸的是,您忘记检查您的一个主要依赖项所使用的版本,而最新版本似乎不起作用。您引导他们安装每个之前的版本,直到您最终找到一个可以工作的版本。危机险些酿成。
随着您开发越来越复杂的系统,确保您正确安装每个依赖项所需版本的努力会迅速增加。在最坏的情况下,您可能达到需要同一依赖项的两个不同版本,它们无法共存。这被亲切地称为“依赖地狱”。从这个点开始梳理项目可能会很具挑战性。
即使没有遇到依赖地狱,如果没有标准化的包装方法,以标准方式共享软件也可能很困难,这样任何地方的人都知道他们需要安装哪些其他依赖项来使用您的项目。软件社区为管理包创建了惯例和标准,将这些实践编纂成您用来完成工作的包管理系统中。
现在您已经了解了为什么包装对分享软件有好处,请继续阅读,了解即使您并不总是公开分享您的软件,包装也能提供的一些优势。
1.2 包装如何帮助您
如果您对包装还不太熟悉,它可能看起来主要是为了与全球各地的人分享软件而有用。尽管这当然是一个包装代码的好理由,但您可能也会喜欢以下包装在软件开发中带来的好处:
-
更强的凝聚力和封装
-
更清晰的归属定义
-
代码区域之间的耦合更松
-
更多的组合机会
以下部分将详细介绍这些好处。
1.2.1 通过包装实现凝聚力和封装
代码的特定区域通常应该只完成一项工作。凝聚性衡量代码对这个工作的忠诚度。游离功能越多,代码的凝聚性就越低。
您可能已经使用函数、类、模块和导入包来组织您的 Python 代码(参见 Dane Hillard,“Python 中的分离层次结构”,《Python 专家实践》,Manning Publications,2020 年,第 25-39 页,mng.bz/m2N0)。这些构造中的每一个都在具有特定工作的代码区域周围放置了一种命名的边界。当做得好的时候,命名会向开发者传达边界内属于什么,以及,更重要的是,不属于什么。
尽管付出了最大努力,名称和人物很少是完美的。如果你把所有的 Python 代码放在一个应用程序中,有些代码最终可能会渗透到它不属于的区域。想想你开发的一些大型项目。你创建了多少次包含各种功能的utils.py或helpers.py模块?你通过函数或模块创建的边界很容易被克服。这些“实用”的代码区域往往会吸引新的“实用”功能,随着时间的推移,其内聚性呈下降趋势。
想象一下,你的自动驾驶汽车系统可以使用激光雷达(oceanservice.noaa.gov/facts/lidar.xhtml)作为输入类型之一。CarCorp 的车辆不包括激光雷达传感器。作为你勤奋的开发者,你创建了一个针对激光雷达的特定代码库部分,以将其与其他关注点分离。尽管评估命名和定期重构代码库可以提高内聚性,但这也是一种维护负担。分发包增加了将代码添加到可能一开始就不应该存在的位置的障碍。因为更新包需要经历打包、发布和安装更新的周期,这促使开发者更深入地思考他们所做的更改。你不太可能在没有明确意图的情况下向包中添加代码,这种意图值得更新周期的投资。
创建内聚性和包装内聚的代码区域是进入封装的途径。封装通过定义代码的行为是否以及如何暴露来帮助你在消费者中建立正确的期望,以便如何与你的代码交互。想想你构建并与人共享以供使用的一个项目。现在想想你修改了多少次你的代码,他们又不得不相应地修改多少次代码。这对他们来说有多令人沮丧?对你来说呢?封装可以通过更好地定义不太可能改变的 API 合同来减少这种类型的 churn。图 1.3 展示了如何从内聚的代码区域创建多个包。

图 1.3 通过引入更强的边界,包装可以减少代码区域之间意外的相互依赖。
在过去,当你发现一些仅用于模块内部使用的代码被广泛地用于整个代码中时,你可能感到过挫败。每次你更新那“内部”代码时,你都需要在其他地方更新使用情况。这种高 churn 的环境可能导致错误,当你没有在所有地方传播更改时,这会使你或你的团队的生产力降低。
优秀的封装和高度内聚的代码很少改变,即使在使用广泛的情况下。这种代码有时被标记为“成熟”。成熟的代码是作为包分发的绝佳候选者,因为你不需要频繁重新发布。你可以通过从你的代码库中提取一些更成熟的代码开始打包,然后利用你对内聚和封装的了解来提升不那么成熟的代码。
1.2.2 推广代码的明确所有权
团队从对代码区域的明确所有权中受益。所有权往往超出了维护代码本身行为。团队构建自动化工具以简化单元测试、部署、集成测试、性能测试等。这需要同时保持许多任务运转。将代码区域的范围保持小,以便团队能够拥有所有这些方面,将确保代码的长期性。打包是管理范围的一个工具。
通过打包代码创建的封装使你能够独立于其他代码开发自动化。例如,对于结构简单的代码库的自动化可能需要你编写条件逻辑来确定基于哪些文件更改运行哪些测试。或者,你可能为每次更改运行所有测试,这可能会很慢。创建可以独立于其他代码进行测试和发布的包,将导致从源代码到测试代码再到发布代码的更清晰的映射(见图 1.4)。

图 1.4 团队可以完全拥有单个包,定义他们想要如何管理开发、测试和发布生命周期。
对于一个包的明确目的划分使其更有可能拥有明确的所有权。如果一个团队不确定通过接管某些代码所做出的承诺是什么,他们可能会谨慎行事。尝试提供一个具有明确范围、故事和操作手册的包,看看情绪如何转变。
1.2.3 将实现与使用解耦
你可能已经听说过“松耦合”这个术语,用来描述代码区域之间相互依赖的程度。
定义耦合是衡量代码区域之间相互依赖程度的指标。松耦合的代码提供了多种灵活性途径,因此你可以实施和选择多种执行策略,而不是被迫走特定的路径。两个低耦合的代码片段之间几乎或没有任何依赖,并且它们可以以不同的速度进行更改。
在本章前面提到的凝聚和封装实践是一种减少由于代码组织不良而导致紧密耦合可能性的方法。高度凝聚的代码将在其内部有紧密耦合,对外部边界之外的事物有松散耦合。封装暴露了一个有意的 API,将任何耦合限制在该 API 上。因此,你在打包和封装方面的选择有助于你将消费者从代码的实现细节中解耦。打包还通过版本控制、命名空间以及甚至编写软件的编程语言,使得消费者可以从实现中解耦。
在一个混乱的大球中,你只能运行每个模块中的代码。如果你或你的团队中的某个人更新了一个模块,所有使用该模块的代码都需要立即适应这个变化。如果更新改变了调用签名或返回值,它可能会产生广泛的影响。打包显著减少了这种限制(见图 1.5)。

图 1.5 打包提供了灵活性,使得两个代码区域可以以不同的速度发展。
假设每次对requests包的更新都需要你立即反应,更新你自己的代码。那将是一场噩梦!因为包会对包含的代码进行版本控制,并且因为消费者可以指定他们想要安装的版本,所以一个包可以在不影响消费代码的情况下更新多次。开发者可以精确选择何时付出努力来更新他们的代码,以适应包较新版本的更改。
你可以在另一个地方解耦代码,那就是命名空间。命名空间将值和行为附加到可读性强的名称上。当你安装一个包时,你使它在指定的命名空间中可用。例如,requests包在requests命名空间中可用。
不同的包可以拥有相同的命名空间。这意味着如果你安装了多个包,它们可能会发生冲突,但这也使得可能发生一些有趣的事情:这种命名空间的灵活性意味着包可以作为彼此的完全替代品。如果一个开发者创建了一个比流行的包更快、更安全或更易于维护的替代品,只要 API 相同,你就可以将其安装到原始包的位置。以下包都提供了大致相当的 MySQL (www.mysql.com)客户端功能(具体来说,它们实现了某些级别的与 PEP 249 的兼容性;www.python.org/dev/peps/pep-0249/)):
-
mysqlclient (
github.com/PyMySQL/mysqlclient) -
PyMySQL (
github.com/PyMySQL/PyMySQL) -
mysql-python (
github.com/arnaudsj/mysql-python) -
oursql (
github.com/python-oursql/oursql)
最后,Python 打包甚至可以将 Python 的使用与包所写的语言解耦!许多 Python 包是用 C 甚至 Fortran 编写的,以提高性能或与遗留系统集成。包作者可以提供这些包的预编译版本,同时提供消费者如果需要可以从源代码构建的版本。这也使得包更加便携,使开发者多少与他们在使用的计算机或服务器的细节解耦。你将在第三章中了解更多关于打包构建目标的内容。
你可能想将一些代码打包起来,以实验版本解耦的自由,看看你的版本化包是如何随时间演变的。那些变化快的可能表明低内聚性,因为代码有很多理由需要改变。另一方面,这也可能仅仅意味着代码仍在成熟。至少,这些数据点将是可观察的!你将在第九章中了解更多关于版本化的内容。
1.2.4 通过组合小型包来填补角色
将代码提取到多个包中的行为有点像分解。成功的分解需要很好地掌握松散耦合。分解代码是一种艺术,它将代码片段分离出来,以便以新的方式重新组合(关于分解和耦合的精彩简洁概述,请参阅 Josh Justice 的“Breaking Up Is Hard to Do: How to Decompose Your Code”,Big Nerd Ranch,mng.bz/5mpq)。
通过打包代码的较小区域,你将开始识别那些完成非常具体目标且可以概括或扩展以履行角色的代码。例如,你可以使用内置的 Python 实用工具如urllib.request.urlopen创建一次性的 HTTP 请求。一旦你这样做了几次,你就可以看到用例之间的共性,并将概念概括为更高层次的实用工具。所以requests包并不是为了只做一次特定的 HTTP 请求;它作为一个 HTTP 客户端填补了一个通用角色。你的一些代码现在可能非常具体,但随着你在需要类似行为的新领域中发现,你可能会看到识别它所填补的角色、进行一些概括并创建一个可以填补该角色的包的机会。
当你为 CarCorp 重整软件时,你记得代码的大部分部分都处理汽车的导航系统。你意识到,通过一点调整,导航代码也可以适用于 Acme Auto 的车辆。这段代码可以充当与车辆导航系统通信的角色。因为你已经了解到包可以依赖于其他包,而且你的导航系统代码已经相当内聚,你决定在下次 CarCorp 会议之前创建两个包,而不是一个。
一个组合成功的故事
你可以通过像 Django 这样的 Python 框架看到在打包中发挥作用的优秀组合示例(www.djangoproject.com)。Django 本身就是一个包,因为它构建为一个基于插件的架构,你可以通过安装和配置额外的包来扩展其功能。浏览 Django 包(djangopackages.org)上列出的数百个包,以了解打包方法所享有的广泛采用。
考虑到组合和分解,我们可以看到,分发包可以存在于任何大小,就像函数、类、模块和导入包一样。将内聚性和解耦性视为指导灯,以找到正确的平衡点。如果有一百个分发包,每个都提供单一功能,那么维护负担将会很重;而一个提供一百个导入包的分发包,几乎等同于没有包。如果所有其他方法都失败了,始终问问自己,“我想要这段代码扮演什么角色?”
现在你已经了解到,打包可以帮助你编写具有内聚性、松散耦合且清晰所有权的代码,并以可访问的方式交付给消费者,我希望你正在卷起袖子深入细节。
摘要
-
包存档软件文件以及关于软件的元数据,例如名称、创建者、许可证和版本。
-
包管理器自动化安装包以及管理它们之间的相互依赖关系。
-
打包过程有一些陷阱,可以通过工具和可重复的过程来克服。
-
软件仓库托管发布的包,供他人安装。
-
打包是分离和封装具有高内聚性代码的绝佳方式。
-
打包可以用作解耦工具,以在开发和维护代码中获得灵活性。
-
版本化的包是减少每个单独更新在代码库中产生混乱的绝佳方式。
2 准备包开发
本章涵盖
-
使用 venv 管理虚拟环境
-
使用虚拟环境隔离项目依赖
-
使用 venv 管理虚拟环境的创建和激活
-
使用 pip 列出已安装的依赖项
在项目开始时,你可能会急于开始并取得一些有形的结果。这是可以理解的——构建事物和解决问题是有回报的。但一开始缓慢移动是有价值的,这样你就可以在项目成熟后更快、更持久地移动。当你探索新技术或流程时,先练习它也可以很有帮助,这样你就可以熟练地使用它。一些初步的计划可以大大提高你的生产力和最终的情绪。在本章中,你将使用 asdf 和 venv 为你在本书剩余部分要工作的包创建一个开发环境。
重要 在继续阅读之前,请访问附录 A 以安装本章所需的工具。
2.1 管理 Python 虚拟环境
当你更多地思考你在 CarCorp 合作中的潜在成功时,你会意识到,如果你正在努力发布的包变得流行,使用各种 Python 版本的人可能想要安装并使用它。他们不太可能总是在他们的生产系统上运行 Python 的最新版本。明确声明你的包支持的 Python 版本范围,并在所有这些版本上测试你的包是一个好习惯。因为你正在利用附录 A 中的 asdf 和 python-launcher 的力量,你已经拥有了大部分你需要的力量。最后一步是为你的包的本地开发创建一个虚拟环境。
当你安装 Python 时,它附带了一些在 Python 的标准库中可用的包。
定义 一个标准库定义了哪些功能被认为是编程语言的核心部分。一种语言的标准库是构建到语言或其安装过程中,并在你在系统上安装该语言的软件后默认可用。
与一些语言相比,Python 的标准库非常广泛,但即使如此,它也不提供你可能需要的所有功能,用于你的项目。Python 包、Python 包索引(PyPI)和 pip 包管理器存在是为了共享超出 Python 标准库或提供替代品的软件。
想象一下,当你最初与 CarCorp 合作开始你的项目时,你使用 pip 安装了一些包,比如requests。你还安装了一些来自早期项目 Vehicle Ventures 的其他包。你注意到所有这些包最终都集中在一个地方,无论你使用它们的项目是什么?
默认情况下,pip 将包安装在与 pip 本身安装的 Python 版本相关的位置,称为site packages目录。也就是说,当你安装 Python 3.7 并使用其附带的 pip 复制时,你安装的包将存储在 Python 3.7 的 site packages 目录中。将所有包安装到这个 site packages 目录可能在一开始是可管理的,但当你需要为不同的项目使用不同的包版本时会发生什么?如果你需要列出单个项目所需的最低依赖项会发生什么?随着 site packages 目录中充满了来自任何和每个项目的包,这些障碍变得难以解决或无法解决。
解决这些问题的方法之一是隔离每个项目的包。在隔离状态下,你可以保留每个项目所需的最小依赖项列表。更重要的是,一个项目可以自由使用 requests==2.1.0,即使另一个项目使用 requests==2.24.0。你在第一章中学习了解耦的价值。包依赖项的隔离使你的项目彼此解耦。你可以在 Python 中使用虚拟环境来实现这种隔离。
定义 Python 虚拟环境 是一个具有独立 site packages 目录的独立 Python 副本。虚拟环境中的 pip 副本将包安装到其独立的 site packages 目录中,从而将它们与其他环境隔离开来。
虚拟环境在概念上与正常的 Python 安装并没有太大的区别。不是安装 Python 3.7 并将所有项目的依赖项安装到其中,而是想象安装 Python 3.7 多次,并为每个安装赋予一个与你的每个项目相对应的唯一名称。然后你可以为每个具有唯一名称的 Python 安装使用其对应的项目(见图 2.1)。这在实际操作中与虚拟环境的工作方式非常相似。

图 2.1 虚拟环境为 Python 和 pip 创建了具有各自安装目录的隔离副本。
当你在虚拟环境中使用 Python 时,你将使用创建该环境的基 Python 版本的副本。
要测试你的包,你需要安装的包不仅要从其他项目中隔离,还要跨越许多基础版本的 Python。随着你的项目支持的 Python 版本数量增加,手动管理所有虚拟环境和它们的 Python 安装可能会变得繁琐(见图 2.2)。

图 2.2 在单个系统上可能存在许多基础 Python 版本,每个版本都创建了多个虚拟环境。
你可能开始意识到工具在保持所有这些事物有序方面所具有的价值。 whereas asdf 帮助你安装和管理基础 Python 版本,venv 则帮助你从这些基础 Python 版本创建虚拟环境。
2.1.1 使用 venv 创建虚拟环境
要在你的系统上使一个基础 Python 版本可用,你使用 asdf 从互联网上的源代码安装它。要创建一个虚拟环境,你创建一个具有唯一名称的已安装基础 Python 版本的副本。
要创建一个虚拟环境,使用基础 Python 版本的venv模块,并传递一个虚拟环境目录的名称。通常约定将此目录命名为.venv/。现在通过运行以下命令在你的项目中创建一个虚拟环境:
$ cd $HOME/code/first-python-package/
$ py \ ❶
-3.10 \ ❷
-m venv \ ❸
.venv ❹
❶ 使用 python-launcher 以便你可以选择基础 Python 版本
❷ 传递一个 Python 版本标志
❸ 使用内置的 venv 模块创建一个新的虚拟环境
❹ 在当前工作目录中创建一个.venv/目录
如果命令成功,你将看不到任何输出,但你应该看到一个创建的.venv/目录。Unix 系统上的 python-launcher 会检测到这个新的虚拟环境,并在你在这个目录或其子目录中时默认使用它。Windows 上的 Python 启动器如果当前激活了虚拟环境,也会检测到虚拟环境。你可以通过不带参数运行py命令来验证这一点。启动的解释器将与创建虚拟环境时使用的基 Python 版本相匹配,你可以使用以下代码来确保它是虚拟环境的 Python 副本:
>>> import sys
>>> sys.executable
'/Users/<you>/code/first-python-package/.venv/bin/python'
如果你传递了版本标志给 python-launcher,你仍然会得到基础版本。例如,当你使用py -3.9时,你应该看到类似以下的内容:
>>> import sys
>>> sys.executable
'/Users/<you>/.asdf/installs/python/3.9.3/bin/python3.9'
为了证明你的虚拟环境与创建它的基础 Python 版本是隔离的,首先从你的项目目录运行以下命令,在虚拟环境中安装requests包并检查安装后的包列表:
$ py -m pip install requests
$ py -m pip list
Package Version
------------------ ---------
certifi 2022.6.15
charset-normalizer 2.0.12
idna 3.3
pip 21.2.4
requests 2.28.0
setuptools 58.1.0
urllib3 1.26.9
现在通过明确传递-3.10版本标志来确认这些包没有安装到基础 Python 版本中:
$ py -3.10 -m pip list
Package Version
---------- -------
pip 21.1.2
setuptools 57.0.0
你可以看到,默认情况下,创建虚拟环境后,它只安装了pip和setuptools包。这些默认包及其版本由基础 Python 安装决定。养成将 pip 和 Setuptools 更新到最新可用版本并安装wheel包的习惯是好的,这样你可以安装为你的系统预先构建的包,而不是自己编译。现在安装这些包:
$ py -m pip install --upgrade pip setuptools wheel
在未来,你将能够在你的项目中使用py命令,并且可以确信你总是从项目的虚拟环境中获取 Python 的副本,除非你明确要求不同的(基础)Python 版本。这可以减少你的认知负担,因为你不需要每次开始或停止项目工作时要手动激活或关闭虚拟环境。
提示:如果你习惯在 PyCharm (www.jetbrains.com/pycharm/) 或 Visual Studio Code (code.visualstudio.com/)这样的 IDE 中自动使用虚拟环境,即使你在这里使用 python-launcher 命令行工具,你仍然可以这样做;.venv/目录仍然是一个标准的虚拟环境。
你已经掌握了使用 asdf 和 venv 管理 Python 版本和虚拟环境的方方面面。你现在可以继续前进,创建你的第一个 Python 包的内容。
摘要
-
虚拟环境将你的不同 Python 项目的依赖项解耦和隔离。
-
使用 python-launcher 可以可靠地获取正确的 Python 版本。
3 Python 最小包的解剖结构
本章涵盖
-
Python 包构建系统
-
使用 Setuptools 构建 Python 包
-
Python 包的目录结构
-
为多个目标构建包
Python 包构建是几个不同工具在标准化流程驱动下的协调产物。作为包作者,你面临的最大选择之一是使用哪套工具。评估每个工具的细微差别可能很困难,尤其是如果你是新手的话。幸运的是,工具正在围绕相同的核心工作流程进行标准化,所以一旦你学会了它,你就可以以最小的努力在工具之间切换。本章将介绍这些工具的每个类别能完成什么,以及它们如何协同工作以生成一个包,以及不同系统的包构建有何不同。
重要:在继续阅读之前,请访问附录 B 安装本章所需的工具。
你可以使用代码伴侣 (mng.bz/69A5) 来检查本章练习中的工作。
3.1 Python 构建工作流程
以下章节将介绍当你构建一个包时会发生什么,以及你需要做什么才能成功构建一个包。首先,你需要了解 Python 构建系统的各个部分。
3.1.1 Python 构建系统的各个部分
在你的项目的根目录中,首先运行以下命令来执行 build:
$ pyproject-build
由于你的包还没有内容,你应该会看到一个如下所示的错误:
ERROR Source /Users/<you>/code/first-python-package does not appear to be
➥ a Python project: no pyproject.toml or setup.py
输出提出了两个文件建议。pyproject.toml 是 PEP 518 (www.python.org/dev/peps/pep-0518/) 中引入的用于配置 Python 打包的新标准文件,除非你想要使用的第三方工具仅与 setup.py 兼容,否则应优先考虑。该文件使用 TOML (toml.io/en/),一种类似于 INI 的语言,将配置分割到相关部分。
提示:如果你在现有的包上遵循本书中的实践,并且它使用 setup.py 文件,那么如果你的项目使用静态元数据,你应该考虑迁移到本章后面将介绍的 pyproject.toml 文件和 setup.cfg 文件。Setuptools 的一些功能仍然需要 setup.py;请参阅第四章。
教学 TOML 超出了本书的范围,但你需要用于打包的组件将包含在本书中,并在需要的地方进行解释。使用以下命令创建 pyproject.toml 文件以纠正错误:
$ touch pyproject.toml
再次运行 pyproject-build 命令。这次构建应该会成功运行,你应该会看到大量输出和一些显著的行,如列表 3.1 所示。这里发生了什么?从高层次来看,构建命令消耗了你的源代码和提供的元数据,以及它生成的某些文件,以创建以下内容:
-
源代码分布软件包——一个 Python 源代码分布,或 sdist,是一个带有 .tgz 扩展名的源代码压缩存档文件。
-
二进制分布软件包——一个 Python 构建分布软件包是一个二进制文件。当前构建分布的标准是众所周知的 wheel 或 bdist_wheel,一个以 .whl 扩展名结尾的文件。
与源代码分布允许几乎任何人在他们的平台上构建您的代码相比,二进制分布是为特定平台预先构建的,从而节省了用户自己构建的工作。这两种分发类型的重要性将在第四章中深入探讨。
列表 3.1 构建空 Python 软件包的结果
...
Successfully installed setuptools-57.0.0 wheel-0.36.2 ❶
...
running sdist ❷
...
warning: sdist: standard file not found: ❸
➥ should have one of README, README.rst, README.txt, README.md
running check
warning: check: missing required meta-data: name, url ❹
warning: check: missing meta-data: either (author
➥ and author_email) ❺
➥ or (maintainer and maintainer_email) should be supplied
creating UNKNOWN-0.0.0 ❻
...
Creating tar archive ❼
...
Successfully installed setuptools-57.0.0 wheel-0.36.2
...
running bdist_wheel ❽
...
creating '/Users/<you>/code/first-python-
➥ package/dist/tmpgdfzly_7/ ❾
➥ UNKNOWN-0.0.0-py3-none-any.whl' and adding
➥ 'build/bdist.macosx-11.2-x86_64/wheel' to it
❶ Setuptools 和 wheel 软件包用于构建后端。
❷ 源代码分布软件包是由 build_sdist 钩子构建的。
❸ 构建过程期望在几种格式之一中包含一个 README 文件。
❹ 构建过程期望软件包的名称和 URL。
❺ 构建过程期望软件包的作者或维护者。
❻ 该软件包被命名为 UNKNOWN,因为没有指定名称。
❼ 源代码分布是一个压缩的存档文件。
❽ 二进制轮分布软件包是由 build_wheel 钩子构建的。
❾ 二进制轮分布是一个 .whl 文件。
由于您尚未提供任何元数据,构建过程会提醒您缺少一些重要信息,如 README 文件、作者等。在本章的后面将介绍如何添加这些信息。
注意,构建过程会安装 setuptools 和 wheel 软件包。Setuptools (setuptools.readthedocs.io) 是一个库,长期以来一直是创建 Python 软件包的唯一方法之一。现在,Setuptools 是 Python 软件包构建的多种可用 构建后端 之一。
定义 A 构建后端 是一个提供几个必需和可选钩子的 Python 对象,这些钩子实现了打包行为。核心构建后端接口在 PEP 517 (mng.bz/o5Rj) 中定义。
构建后端在构建过程中执行创建软件包工件的后勤工作,即通过 build_sdist 和 build_wheel 钩子。Setuptools 在 build_wheel 步骤中使用 wheel 软件包来构建 wheel。默认情况下,如果您没有指定,build 工具将使用 Setuptools 作为构建后端。
构建后端的出现可能会让您想知道是否也存在构建 前端。实际上,您已经使用了一个构建前端。build 工具就是一个构建前端!
定义 A 构建前端 是您运行以从源代码启动构建软件包的工具。构建前端提供用户界面并通过钩子界面与构建后端集成。
为了回顾,您使用 build 等构建前端工具来触发构建后端(如 Setuptools),以从您的源代码和元数据创建软件包工件(见图 3.1)。

图 3.1 Python 构建系统由一个前端用户界面组成,该界面与后端集成以构建包的工件。
因为构建过程会创建包的工件,你现在可以检查运行构建的效果。现在列出你项目的根目录内容。你应该看到以下内容:
$ ls -a1 $HOME/code/first-python-package/
.
..
.venv/
UNKNOWN.egg-info/
build/
dist/
pyproject.toml
UKNOWN.egg-info/ 和 build/ 目录是中间工件。列出 dist/ 目录的内容,你应该会看到源和二进制 wheel 包文件,如下所示:
$ ls -a1 $HOME/code/first-python-package/dist/
UNKNOWN-0.0.0-py3-none-any.whl
UNKNOWN-0.0.0.tar.gz
其他构建系统工具
如我之前提到的,构建前端和后端都有其他选项。一些包同时提供前端和后端。在这本书的其余部分,继续使用 build 和 Setuptools 作为构建的前端和后端。
如果你想探索一些替代的构建工具,请查看 Poetry (python-poetry.org/)、flit (flit.readthedocs.io) 和 hatch (hatch.pypa.io)。每个构建系统在配置的简便性、功能和用户界面之间都有不同的权衡。例如,flit 和 poetry 主要针对纯 Python 包,而 Setuptools 可以支持其他语言的扩展。第四章将更详细地介绍这一点。
你可以通过几个步骤切换到另一个构建系统,如下所示:
-
安装新的构建前端包。
-
将 pyproject.toml 更新为指定新的构建后端及其需求。
-
将包的元数据移动到新构建后端期望的位置。
回想一下,build 使用 Setuptools 作为后备构建后端,因为你没有指定它。你可以通过将列表 3.2 中的行添加到 pyproject.toml 中来指定 Setuptools 作为你的包的构建后端。这些行指定以下内容:
-
build-system—这一部分描述了包的构建系统。 -
requires—这是一个依赖项列表,以字符串形式表示,构建系统必须安装这些依赖项才能工作。Setuptools 构建系统需要 Setuptools 和 wheel,正如你在本章前面所看到的。 -
build-backend—这通过字符串形式使用点分路径来标识构建后端对象的入口点。Setuptools 构建后端对象在setuptools.build_meta中可用。
这些代表你需要指定的完整配置,以指定构建后端。
列表 3.2 使用 Setuptools 的构建系统后端规范
[build-system] ❶
requires = ["setuptools", "wheel"] ❷
build-backend = "setuptools.build_meta" ❸
❶ 打开一个新的 TOML 部分
❷ 将包名作为字符串的列表
❸ 将对象作为字符串的点分路径
一旦你添加了构建系统信息,再次运行构建。输出中不应该有任何变化:你只是将 Setuptools 锁定为显式后端,而不是让 build 作为默认值回退到它。现在你已经掌握了 Python 包构建系统,你需要添加一些关于你的包的元数据。
3.2 编写包元数据
你了解到每个构建后端可能会在不同的位置和格式中寻找包元数据。对于 Setuptools 后端,你可以在项目根目录中一个名为 setup.cfg 的 INI 风格文件中指定静态元数据。你将在该文件中添加键值对的部分,以提供有关包及其内容的信息。
一些元数据对于构建一个能够被正确识别的包是必不可少的。当你运行构建时,结果生成了文件名中包含“UNKNOWN-0.0.0”的文件,这是由于缺少一些核心元数据的结果。首先,先修复这些核心元数据问题。
3.2.1 所需核心元数据
为了修复你的包文件名,首先在你的项目根目录中创建 setup.cfg 文件。
注意 PEP 621 (www.python.org/dev/peps/pep-0621/)描述了在 pyproject.toml 文件中声明静态元数据的标准。尽管它已被接受,但该标准尚未得到广泛采用。特别是,截至撰写本文时,Setuptools 尚不支持它(github.com/pypa/setuptools/issues/1688),尽管可能有其他替代方案。本书和未来的章节试图在 setup.py、setup.cfg 和 pyproject.toml 之间平衡打包、测试、代码质量等方面的开发者体验。
对于一个包,至少需要两个字段:name和version。这些字段区分了你的包的分布式版本与其他包以及你自己的包的其他版本。将这些字段添加到名为metadata的部分的setup.cfg中。它应该看起来像以下这样:
[metadata] ❶
name = first-python-package ❷
version = 0.0.1
❶ 这是“元数据”部分。
❷ 部分包含一个或多个键值对。
保存文件后,删除 dist/目录,并再次运行构建过程。列出新生成的 dist/目录的内容,你应该看到以下内容:
$ ls -a1 dist/
.
..
first-python-package-0.0.1.tar.gz
first_python_package-0.0.1-py3-none-any.whl
这确认了你已正确提供了名称和版本。构建过程识别了你提供的值,并使用它们来填充包工件文件名。“UNKNOWN”已被“first-python-package”的规范化版本所替代,“0.0.0”已被“0.0.1”所替代(见表 3.1)。
表 3.1 文件名比较
| 之前 | 之后 |
|---|---|
| UNKNOWN-0.0.0.tar.gz | first-python-package-0.0.1.tar.gz |
| UNKNOWN-0.0.0-py3-none-any.whl | first-python-package-0.0.1-py3-none-any.whl |
为了确认包包含预期的文件,你可以手动检查其内容。切换到dist/目录,并使用以下命令解包源代码分发包:
$ cd $HOME/code/first-python-package/dist/
$ tar -xzf first-python-package-0.0.1.tar.gz
这将在包文件旁边创建一个名为 first-python-package-0.0.1/的目录,其中包含从你的项目中打包的文件以及一些生成的文件。你应该看到以下内容:
$ ls -1R first-python-package-0.0.1/
PKG-INFO ❶
first_python_package.egg-info
pyproject.toml ❷
setup.cfg
first-python-package-0.0.1/first_python_package.egg-info:
PKG-INFO
SOURCES.txt
dependency_links.txt
top_level.txt
❶ 源代码分发包含几个生成的文件。
❷ 源代码分发也包含你在项目中创建的文件。
小贴士:你也可以使用tree命令(linux.die.net/man/1/tree)来获得格式良好的输出。如果你没有安装tree,你可能可以从你的平台系统包管理器中获取它。
你还可以确认你指定的元数据已经忠实地复制到包中。打开任一 PKG-INFO 文件,查看其内容。PKG-INFO 文件包含元数据的规范化版本。你应该看到以下内容:
Metadata-Version: 2.1
Name: first-python-package ❶
Version: 0.0.1 ❷
Summary: UNKNOWN ❸
Home-page: UNKNOWN
License: UNKNOWN
Platform: UNKNOWN
UNKNOWN
❶ 你指定的包名映射到名称字段。
❷ 你指定的包版本映射到版本字段。
❸ 你尚未指定的字段显示为“未知”。
你指定的包名和版本将显示在这里,但还有几个其他字段仍然是“未知”。构建过程仍在提醒你缺少 URL、README 和作者信息。接下来,你将修复这些问题,并进一步完善元数据,以便让人们了解这个包。
3.2.2 可选核心元数据
根据核心元数据规范(mng.bz/nez8),名称和版本是唯一两个严格要求的字段,但其他几个字段被搜索引擎索引或在 PyPI 等网站上以高度可见的方式呈现。如果你想让人们找到并使用你的包,为尽可能多的字段提供信息是个好主意。
包元数据的概述
如果你想了解所有可用的字段以及它们随时间的变化,以下 PEPs(www.python.org/dev/peps/)处理包元数据规范:
-
PEP 241: Python 软件包的元数据 (
www.python.org/dev/peps/pep-0241/) 介绍了 PKG-INFO 文件。 -
PEP 301: Distutils 的包索引和元数据 (
www.python.org/dev/peps/pep-0301/) 提出了集中式 Python 包索引以及分类器,以更好地区分 Python 包。 -
PEP 314: Python 软件包的元数据 v1.1 (
www.python.org/dev/peps/pep-0314/) 在 PEP 241 的基础上增加了额外的字段。 -
PEP 345: Python 软件包的元数据 1.2 (
www.python.org/dev/peps/pep-0345/) 在 PEP 314 的基础上增加了额外的字段、更改的字段和已弃用的字段。 -
PEP 566: Python 软件包的元数据 2.1 (
www.python.org/dev/peps/pep-0566/) 在 PEP 345 的基础上增加了核心元数据规范、更严格的包名允许值、额外的字段以及将包元数据规范转换为 JSON 的规范转换。 -
PEP 621: 将项目元数据存储在 pyproject.toml 中 (
www.python.org/dev/peps/pep-0621/) 定义了在 pyproject.toml 文件中提供包元数据的标准,而不是像 setup.py 或 setup.cfg 这样的文件。这已经被接受,但尚未被打包工具广泛采用。 -
PEP 639: Python 软件包元数据 2.2 (
www.python.org/dev/peps/pep-0639/) 提出了一种标准化包中指定许可证的方法。
核心元数据规范提供了当前可用的字段及其格式的最新列表。
构建过程仍然在提醒你缺少 URL 和作者信息。请将以下字段添加到 setup.cfg 文件中的[metadata]部分,并在适当的位置填写你的个人信息:
...
url = https:/ /github.com/<username>/<package repo name>
author = Given Family
author_email = "Given Family" <given.family@example.com>
再次运行构建,你应该不再会看到关于缺少 URL 和作者的警告。解压源分发文件,再次查看 PKG-INFO 文件。你应该看到以下内容,包括你添加的新值:
Metadata-Version: 2.1
Name: first-python-package
Version: 0.0.1
Summary: UNKNOWN
Home-page: https:/ /github.com/<username>/
➥ <package repo name> ❶
Author: Given Family ❷
Author-email: "Given Family" <given.family@example.com>
License: UNKNOWN
Platform: UNKNOWN
UNKNOWN
❶ url 字段映射到 Home-page。
❷ author 和 author_email 字段映射到 Author 和 Author-email。
摘要仍然显示为UNKNOWN。摘要是对包目的的简短描述。你可以将其视为你包的电梯演讲:这是人们在搜索要使用的包时最常看到的内容。如果你正在阅读这本书,那么你很可能会想学习如何分享你的代码。如果你在元数据上节省,那么很可能没有人会找到它。元数据确保你的包在将来尽可能容易被发现。在 Setuptools 中,摘要被称为description。现在将description字段添加到你的元数据中,如下所示:
...
description = This package does amazing things.
文件末尾还有一个未标记的UNKNOWN。这个空间是用于包的长描述,它可以提供有关如何安装和使用包或它解决的问题的更多详细信息。回想一下,构建过程仍在抱怨缺少 README 文件。你可以通过创建一个 README 文件并在元数据中引用它来一次解决这两个问题。现在创建一个 README.md 文件,内容如下所示:
# first-python-package
This package does amazing things.
## Installation
```shell
$ python -m pip install first-python-package
在 setup.cfg 中,你现在可以使用`long_description`字段通过特殊的`file:`指令来引用你的 README 文件。`file:`指令接受相对于 setup.cfg 的文件路径,其内容应作为字段的值。此外,你还需要指定`long_description_content_type`字段来指示你的 README 不是纯文本。因为你的文件是 Markdown 文件,你应该指定`text/markdown`内容类型。现在将这两个字段添加到你的元数据中:
...
long_description = file: README.md
long_description_content_type = text/markdown
运行构建,提取源分发,并再次检查 PKG-INFO。你应该看到以下内容:
+ `Summary` 字段填充了简短描述。
+ 文件现在包含一个 `Description-Content-Type`,其值为 `text/markdown`。
+ 文件末尾的 `UNKNOWN` 现在已被你的 README.md 文件内容替换。
当你更新你的 README 文件时,这些更改将被拉入你构建的下一个版本的包中。这种自动化减少了在多个地方记住更新你的文档的问题。许可证是现在你要处理的最后一个 `UNKNOWN` 字段,并且需要一些特别的注意。
### 3.2.3 指定许可证
在大多数地区,软件默认受版权保护。如果你不提供任何许可证,你并没有给予任何人使用你的代码的权限——即使你将其作为开源软件发布(参见 Tal Einat,“Over 10% of Python Packages on PyPI Are Distributed without Any License,” *Snyk*,[`mng.bz/vX9q`](http://mng.bz/vX9q))。许可证很重要,因为它们帮助你的用户了解他们可以在什么条件下使用你的软件。选择特定许可证的详细过程超出了本书的范围,但像 Choose a License ([`choosealicense.com`](https://choosealicense.com)) 这样的网站会通过询问你希望提供哪些自由和限制来引导你完成这个过程。
许可证粒度
通常,你只需要在包元数据级别指定一次适用于你整个包的许可证。如果你需要向特定的文件或文件集提供更宽松或更严格的许可证,你可以在那些文件中直接包含覆盖许可证。Python 打包过程不提供在项目内部处理复杂文件级许可证粒度的方法,但可能存在第三方工具来帮助处理这个问题。
一旦你选择了一个许可证,你需要在代码旁边声明该许可证,以便用户可以识别他们是否可以使用你的软件。像 GitHub 这样的网站会自动从几个文件(如 LICENSE 或 LICENSE.txt)中发现许可证信息。同时,你需要在你的源和二进制包分布中提供你的许可证,以便安装你的包的人也可以查看许可证。
为了正确识别你选择的许可证并将许可证信息包含在你的构建包分布中,使用以下三个字段的组合:
+ `license`—指定与你的选择许可证对应的 SPDX 许可证列表 ([`spdx.org/licenses/`](https://spdx.org/licenses/)) 中的标识符
+ `license_files`—指定一个或多个许可证文件的路径,相对于 setup.cfg
+ `classifiers`—指定任何相关的 trove 分类器 ([`pypi.org/classifiers/`](https://pypi.org/classifiers/)),你的包在发现目的下属于哪个分类
例如,如果你选择 MIT 许可证 ([`mit-license.org/`](https://mit-license.org/)),你应在项目的根目录中放置一份许可证文本的副本,然后在你的元数据中添加以下字段:
...
license = MIT
license_files = LICENSE
classifiers =
License :: OSI Approved :: MIT License
现在你已经学会了如何为 Setuptools 构建后端指定关于你的包的各种元数据,你也看到了构建系统在构建分发包时如何标准化和使用这些元数据。输入文件和输出文件之间的元数据流总结在图 3.2 中。

图 3.2 输入项目文件与输出分发包文件之间的元数据流
现在你已经了解了元数据是如何从你的项目中流入分发包文件的,现在是时候学习你的源代码是如何做到这一点的了。
## 3.3 控制源代码和文件发现
假设你终于完成了创建一个包,包括 100%的单元测试覆盖率。你发布了它,但开始收到有关错误的报告。结果是你在运行测试时针对的是原始源代码,而不是 CarCorp 在安装包时实际收到的打包代码,而你打包的代码是错误的。
Python 并不对你的代码和测试代码指定特定的目录结构。这种灵活性可能很有帮助,但也自然会引导出多种约定。一些约定可能允许你创建因缺少文件或导入错误而损坏的包。使用一种约定,通过强制你测试打包的代码来阻止这些做法。因此,手动运行打包通常会很繁琐,但第五章将介绍一些减轻这种负担的工具。
将你的测试代码与实现代码完全分开,可以限制意外运行原始源代码测试的可能性。(参见 Ionel Cristian Mărieș,“打包 Python 库”,[`mng.bz/49Bg.`](http://mng.bz/49Bg)) 在以下模型中,实现模块和测试模块各自嵌套在其自己的目录中:
some-package/ ❶
...
src/ ❷
some_package/ ❸
init.py
module_one.py
module_two.py
module_three.py
test/ ❹
test_module_one.py
test_module_two.py
test_module_three.py
❶ 这是分发包的根目录。
❷ 此目录包含实现代码。
❸ 这是导入包。
❹ 此目录包含测试代码。
注意:你将在第五章中学习更多关于测试打包代码的内容。
这种方法也使偶然发现你的项目的每个人都更清楚地了解每个顶级目录的目的:src/ 目录可能包含实现代码,而 test/ 目录可能包含测试实现代码的代码。通过将测试与实现分开,你也将这两个区域的结构解耦了。尽管测试和实现共享相似层次结构可能是有意义的,但你并不受此限制。
练习 3.1
为你的包创建布局。你应该创建以下结构组件:
+ 一个 src/ 目录
+ 一个 test/ 目录
+ 一个名为 `imppkg` 的导入包,包含一个名为 `hello.py` 的空模块
完成后,您应该拥有以下目录和文件,除了您在本章早期创建的文件:
first-python-package/
...
src/
imppkg/
init.py
hello.py
test/
运行构建过程并解压缩发行版文件。注意到有什么缺失吗?`imppkg` 代码文件不在那里。由于项目布局的灵活性,并且因为您可以在单个发行版包中分发多个导入包,一些构建系统可能需要比您想象的更具体的设置来发现您的代码。Setuptools 需要知道以下信息:
+ 在哪些目录中查找包
+ 要查找的特定(子)包的名称,或一个递归自动查找它们的指令
+ 如何将任何找到的包目录映射到不同的导入名称(如果需要)
对于您创建的布局,您可以通过以下额外的部分和字段在 setup.cfg 中实现这一点:
+ `[options]`—此部分为 Setuptools 包构建提供额外的选项。
+ `[options].package_dir`—这是一个键值对列表,用于将发现的目录映射到导入路径。空键表示“根”,这意味着映射到根的任何目录都将从导入路径中删除,并且只包括其子目录。
+ `[options].packages`—这是一个显式的包列表或特殊的 `find:` 指令,告诉 Setuptools 递归地搜索任何包。`find:` 通常是最好的选择,因为如果您以后添加了新包,您不需要更新它。
+ `[options.packages.find]`—此部分为由 `find:` 指令触发的 Setuptools 包发现过程提供选项。
+ `[options.packages.find].where`—这告诉 Setuptools 在哪个目录中查找包。
现在将那些选项添加到您的 setup.cfg 中。配置应该看起来像下面的列表。
列表 3.3 使用 Setuptools 发现包的配置
...
[options]
package_dir = ❶
=src ❷
packages = find: ❸
[options.packages.find] ❹
where = src ❺
❶ 配置哪些目录应映射到哪些导入
❷ 将根目录映射到 src,因此只有其子目录将包含在导入路径中
❸ 告诉 Setuptools 自动查找包而不是列出它们
❹ 为 find: 指令提供额外的选项
❺ 告诉 Setuptools 在 src/ 目录中查找包
此配置将导致 Setuptools 在 src/ 目录中搜索,找到那里的 `imppkg` 包,将 src/imppkg/ 目录映射到 `imppkg` 导入包,并将 imppkg/ 目录中的任何模块包含在发行版包中。
显然,此配置不包括 test/ 目录中的任何内容。通常,从发行版包中排除测试是为了减少包的大小,同时也因为用户很少运行第三方包的测试。
小贴士:您可能希望在 `options.packages.find` 中添加一个字段,以显式排除任何测试模块,以防将来任何测试模块意外地出现在 test/ 目录之外,如下所示:
...
exclude =
test*
这将排除以`test`开头的任何(子)包从分发包中。
运行构建过程并再次解压分发包。这次,它包含具有其`hello.py`模块忠实复制的`imppkg`包。您已经得到了一个可工作的构建!尽管您已经成功打包了 Python 文件,但仍需要一项配置来确保非 Python 文件包含在您的包中。
## 3.4 在包中包含非 Python 文件
CarCorp 已经收到了您最新的包,他们一直在处理的 bug 已经修复。不幸的是,一个新的 bug 出现了——包含输入数据的 JSON 文件似乎丢失了!
您已经成功打包了 Python 代码和元数据,但您还没有考虑到非 Python 文件。现在在`hello.py`模块相同的目录中创建一个 data.json 文件。运行构建过程并观察 data.json 文件不在分发中。
使用 Setuptools,包含非 Python 文件的最直接方法之一是使用 MANIFEST.in 文件。此文件包含指定如何处理匹配文件集的指令。指令涉及包含或排除,并且具有不同的粒度级别(图 3.3)。

图 3.3 包含非 Python 文件到包中的 MANIFEST.in 文件指令
最快开始的方式之一是包含 src/目录中的所有文件,并递归地排除由 Python 生成的某些文件。您可以通过在项目的根目录中创建包含以下内容的 MANIFEST.in 文件来实现这一点:
graft src ❶
recursive-exclude pycache *.py[cod] ❷
❶ 包含 src/目录中的所有文件 . . .
❷ . . . 除了 __pycache__ 目录或以.pyc、.pyo 或.pyd 结尾的文件
运行构建过程并检查源分发中的 data.json 文件。现在,使用以下命令检查二进制轮分布:
$ unzip -l first_python_package-0.0.1-py3-none-any.whl ❶
❶ 列出 ZIP 存档的内容而不解压它
data.json 文件不存在。您可以通过在 setup.cfg 文件的[options]部分添加以下字段来告诉 Setuptools 将源分发中包含的任何非 Python 文件也包含到二进制分发中:
...
include_package_data = True
二进制轮分布文件现在已配置为包含 data.json 文件。
练习 3.2
再次运行构建过程并解压两个分发包。列出内容并确认 data.json 文件的存在。为了参考,每个分发包中的文件位于以下位置:
$ ls
init.py
data.json
hello.py
$ ls
init.py
data.json
hello.py
您已经学会了如何将源代码、元数据和辅助文件打包成单一文件的分发。您还学会了 Python 构建系统的不同部分以及它们如何交互以生成包文件。您已经准备好进入第四章,在那里您将深入了解项目细节、安装第三方依赖项以及为多个目标系统构建。
## 摘要
+ Python 包构建需要一个构建前端和后端、您的源代码以及您的元数据。
+ 构建前端和后端可以互换为替代品,但使用相同的核心工作流程。
+ 包需要依赖核心所需元数据来正确构建,而系统则依赖额外的元数据来提供丰富的发现和浏览体验。
+ 结构可能因项目而异,构建后端必须相应配置以打包正确的代码。
+ 构建后端可能需要额外的配置来打包非 Python 文件。
# 4 处理包依赖项、入口点和扩展
本章涵盖
+ 定义你的包的依赖关系
+ 将功能作为命令行工具提供
+ 使用 C 编写的扩展包
当你正准备开始将你的开创性功能添加到为 CarCorp 的 Python 包中时,他们提出了几个最后时刻的要求。他们想要确保它真的*很快*,并且可以作为独立命令运行,因为他们的开发者对 Python 的了解不如你。你甚至还没有交付包的第一个版本,需求就已经在增长!在你恐慌之前,深呼吸并阅读本章以了解更多信息。
重要提示:你可以使用代码伴侣([`mng.bz/Xa0M`](http://mng.bz/Xa0M))来检查本章练习的工作。
## 4.1 用于计算车辆漂移的包
想象一下,你为 CarCorp 开发的软件将帮助他们引导车辆在道路上行驶。在他们的测试中,他们观察到车辆在道路上漂移比他们希望的要多,他们已经开始测量漂移。尽管他们有原始数据,但他们没有很好的方法来衡量他们所做的任何潜在改进的影响。
你正在构建的包将为 CarCorp 提供工具,以便他们能够了解这个问题。你将要做的第一件事是提供一个计算给定距离内平均漂移(以毫米/秒为单位)的方法。车辆在每次通过五公里测试课程时大约测量漂移率一百万次。你的包将消耗这些测量值作为浮点数列表,并计算它们的*调和平均值*。
调和平均值
*调和平均值*与更常见的算术平均值不同,当你想要平均速率而不是速率的平均值时,这是正确的计算方法。Peter A. Lindstrom 在*《速率的平均值和平均速率》*中提供了一些例子([`mng.bz/19n1`](http://mng.bz/19n1))。
你可以通过将测量值的总数除以测量值的倒数之和来计算漂移的调和平均值:

当有百万个输入时,这个计算可能需要一些时间。你可以看到为什么 CarCorp 强调他们想要速度。在检查代码性能时,最好是分析代码而不是猜测改进的影响(参见 Dane Hillard 的“为高性能设计”,《Python Pro 实践》,Manning Publications,2020 年,第 72-76 页,[`mng.bz/m2N0`](http://mng.bz/m2N0))。在你走得太远之前,你必须首先观察 Python 版本的表现。
练习 4.1
在你的项目根目录中创建一个`harmonic_mean.py`模块。在这个模块中,编写一个`harmonic_mean`函数,该函数接受任意长度的浮点数列表,并返回它们的调和平均值。
现在你已经编写了谐波平均计算的 Python 实现,你可以使用内置的`timeit`模块([`docs.python.org/3/library/timeit.xhtml`](https://docs.python.org/3/library/timeit.xhtml))来测量其性能。当你分析代码时,你应该将其减少到最小部分,以便在比较解决方案时获得准确的图像。`timeit`模块允许你通过将设置代码作为字符串传递给`--setup`选项来将设置代码与你要测量的代码分开。设置代码将只运行一次,并且不会计入你的代码测量中。你可以直接使用`py -m timeit`和任何你想要传递的参数来调用模块。你可以多次使用`--setup`选项来分隔多个表达式,或者使用分号将表达式分隔开,如以下代码片段所示:
$ py -m timeit \ ❶
--setup '<SETUP EXPRESSION 1>'
--setup '<SETUP EXPRESSION 2>'
'
$ py -m timeit \ ❷
--setup '<SETUP EXPRESSION 1>; <SETUP EXPRESSION 2>'
'
❶ 多个设置表达式可以被分成多个参数。
❷ 设置表达式也可以在单个参数中通过分号分隔。
为了避免在分析测量中的设置开销,你应该在设置步骤中执行任何导入和创建数据输入。因为你需要`harmonic_mean`函数和`random.randint`函数,所以这些应该作为设置步骤导入。你还需要测量`harmonic_mean`对一组真实数据的性能。你可以在设置步骤中创建一个随机整数的列表,并在执行步骤中将该列表传递给`harmonic_mean`函数。你的命令应该类似于以下代码片段:
$ py -m timeit
--setup 'from harmonic_mean import harmonic_mean' \ ❶
--setup 'from random import randint' \ ❷
--setup 'nums = [randint(1, 1_000_000)
➥ for _ in range(1_000_000)]' \ ❸
'harmonic_mean(nums)' ❹
❶ 导入你想要测量的函数
❷ 导入设置所需的辅助函数
❸ 提前创建数据输入
❹ 只在执行中使用你想要测量的函数
现在运行命令。`timeit`模块将打印出分析统计信息,包括
+ 运行代码以获取平均执行时间的次数(测量循环)
+ 运行了多少组测量循环
+ 所有测量循环组中的最佳执行时间
以下代码片段显示了`harmonic_mean`函数在我的 16 GB 内存和 2.2 GHz 6 核心处理器的 MacBook Pro 上的性能统计:
5 loops, best of 5: 52.8 msec per loop
`timeit`模块运行了五组五次测量的循环,最终发现`harmonic_mean`的调用平均可以在 52.8 毫秒内完成。你可能看到类似的结果,但它们可能根据你的硬件以及测量时的计算机使用情况而有所不同。`timeit`模块试图通过测量循环来考虑这些因素。最终,重要的是要记住,分析应该用于以相对方式比较一个解决方案与另一个解决方案。
将你的分析结果保存到某个地方以供以后参考,因为现在你将看到如何将这个计算速度提升到 CarCorp 所期望的水平。
## 4.2 为 Python 创建 C 扩展
当你编写代码或安装第三方包时,你正在扩展你的软件功能,使其超越 Python 单独所能提供的功能。通常,尽管如此,你仍然使用 Python 来实现这种扩展。就像你可以使用包来扩展功能一样,你也可以创建和使用用其他语言编写的扩展来提高性能。因为参考 Python 解释器是用 C 编程语言编写的,C 是这些扩展的常见选择,但人们也用 C++ ([`mng.bz/M0om`](http://mng.bz/M0om))、Rust ([`mng.bz/aPwY`](http://mng.bz/aPwY)),甚至 Fortran ([`mng.bz/gRgn`](http://mng.bz/gRgn)) 编写扩展。
你在第三章学习了 Python 构建后端,并使用 Setuptools 开始构建你的包的骨架。Setuptools 在构建来自其他语言的扩展方面具有强大的功能。其他构建后端可能对扩展的支持程度不同。每次考虑切换构建后端时,都要考虑候选后端在此领域满足你需求的能力。现在,你将继续使用 Setuptools 将 C 扩展集成到你的包中。
### 4.2.1 创建 C 扩展源代码
深入介绍 C 级代码的编写以供 Python 使用超出了本书的范围,但鉴于扩展在数值编程中是一个常见需求,了解如何将它们集成到 Python 包中是很重要的。与 Python 构建后端和前端一样,扩展及其构建工具可以根据需要替换到你的项目中,具体细节取决于你决定投资的工具。
为了让你接触到一个既有低门槛又可用的选项,你将使用 *Cython* ([`cython.org/`](https://cython.org/)) 将你的 `harmonic_mean` 函数转换为一个 C 扩展。不要与 CPython 混淆,它是 Python 的参考实现,Cython 是一个用于创建 Python C 扩展的编译器和语言。Cython 语言是 Python 的超集,在最基本的情况下,可以用来加速一些 Python 代码,而不需要做出大的改动。Cython 编译器将 Cython 源代码转换为优化的 C 代码,这些代码将在包构建过程中进行编译(见图 4.1)。

图 4.1 扩展被编译成共享库,这些库包含在二进制 wheel 分发中。
Cython 源文件以 .pyx 结尾,可以包含 Python 或 Cython 代码。因为 Cython 语言是 Python 的超集,一个有效的 Python 程序也是一个有效的 Cython 程序。将你的 `harmonic_mean.py` 模块重命名为 `harmonic_mean.pyx` 并将其移动到 src/imppkg/ 目录下。现在你已经有了一个 Cython 源代码文件,你需要将 Cython 集成到你的包构建过程中。
### 4.2.2 将 Cython 集成到 Python 包构建中
你在第三章中学到,你可以在 pyproject.toml 文件中指定 Python 包构建过程的依赖项。Cython 本身就是一个 Python 包,因此你可以将其添加到构建依赖项列表中。这将确保在构建开始之前安装 Cython,并在编译过程中可用以将你的 Cython 文件转换为 C 代码。现在更新 pyproject.toml 文件中 `build-system` 部分的 `requires` 值,以包含 `"cython"`。
接下来,你需要确保你的 Cython 源文件包含在你的包中。你也在第三章中学到,当使用 Setuptools 时,你可以使用 MANIFEST.in 文件将非 Python 文件包含在你的包中。你使用了 `graft` 指令来包含来自 src/ 目录的所有非 Python 源文件。该表达式也包括所有 .pyx 文件。使用 `pyproject-build` 命令运行构建过程,并确认你的 Cython 文件如预期那样包含在包中。
现在你已经创建并将你的 Cython 代码包含在包中,你需要告诉 Cython 将其转换为 C 代码,以便进行编译。如果不执行此步骤,CarCorp 的朋友们只会收到原始的 .pyx 文件,这可能会让他们感到困惑。要运行 Cython,你需要创建一个名为 setup.py 的另一个文件。
为什么我看到 `setup.py` 文件被如此广泛地使用?
`setup.py` 文件已经一直是 Python 打包生态系统的一部分——它甚至早于 Setuptools。它在 2000 年的 PEP 229 ([`www.python.org/dev/peps/pep-0229/`](https://www.python.org/dev/peps/pep-0229/)) 中被引入,其目标是集中化打包配置发生的地方。你可能会看到许多不同的包仍在使用它,尽管它对于某些用例仍然是必要的,但你在第二章和第三章中学到的新的构建工作流程和工具旨在长期替代 `setup.py`。对于不需要在构建时确定任何动态信息的纯 Python 包,你可以使用 pyproject.toml 来定义你的构建,并使用 setup.cfg 作为使用 Setuptools 作为构建后端时的配置。
练习 4.2
在你的项目根目录中创建 `setup.py` 模块。该模块必须执行以下操作:
1. 导入 `setuptools.setup` 函数以挂钩到构建过程
1. 导入 `Cython.Build.cythonize` 函数以识别哪些 Cython 文件需要被转换
1. 使用 `cythonize` 命令并指定你的 Cython 文件路径,该路径相对于项目的根目录
1. 使用 `cythonize` 调用的结果将 `ext_modules` 关键字参数设置给 `setup` 函数
现在你已经创建了 `setup.py` 模块,你有一个配置,它将在包构建过程中将你的 Cython 文件转换为 C 代码,然后进行编译。现在运行构建。你应该在输出中看到以下新行,这有助于验证一切是否按预期工作:
+ Cython 作为构建依赖项已安装。
+ 你的 Cython 文件被拉入源分布。
+ `build_ext` 过程是由你调用 `setuptools.setup` 触发的。
+ 扩展被编译为二进制文件(macOS 和 Linux 上的 .so,或 Windows 上的 .pyd)并添加到二进制轮分布中。
...
Collecting cython ❶
...
copying src/imppkg/harmonic_mean.pyx
➥ -> first-python-package-0.0.1/src/imppkg ❷
...
running build_ext ❸
building 'harmonic_mean' extension
...
adding 'harmonic_mean.cpython-39-darwin.so' ❹
❶ Cython 被安装为一个构建依赖项。
❷ 你的 Cython 文件被复制到源分布中。
❸ Setuptools 构建你的扩展。
❹ 创建的二进制文件被复制到二进制轮分布中。
此外,你应在 dist/ 目录中看到你的二进制轮分布文件已更改名称。之前,它名为 first_python_package-0.0.1-py3-none-any.whl。现在,其名称将取决于你使用的系统和 Python 版本。在我的 MacBook Pro 上,使用 Python 3.10,文件名为 first_python_package-0.0.1-cp310-cp310-macosx_11_0_x86_64.whl。你将在本章后面了解更多关于为什么是这样的原因。现在,继续到下一节,你将在那里安装和配置你的 `harmonic_mean` 函数的 C 扩展版本。
### 4.2.3 安装和配置你的 C 扩展
你已经多次投入了构建你的包的工作,但你还没有安装它。在第二章中,你在你的包目录的根目录下创建了一个名为 .venv/ 的虚拟环境。你可以使用这个环境来测试你的包的安装。使用以下命令从你的项目的根目录使用 `pip` 模块来安装它:The `.` 表示 pip 应该将当前目录作为包安装:
$ py -m pip install . ❶
❶ 将当前目录安装为包
命令完成后,你的 `first-python-package` 包将像从 PyPI 安装一样安装!你应该仍然能够导入你的 `harmonic_mean` 模块并使用 `harmonic_mean` 函数,但这次,它将解析为已安装的包而不是直接从源代码中。在 Python 解释器中尝试,如下面的代码片段所示:
$ py
...
from imppkg.harmonic_mean import harmonic_mean
harmonic_mean([0.65, 0.7])
0.674074074074074
因为你的纯 Python 版本可以作为 `harmonic_mean.harmonic_mean` 导入,但 C 扩展是从 `imppkg.harmonic_mean.harmonic_mean` 导入的,所以你需要更新设置步骤来配置这个新的实现。
练习 4.3
运行命令来测量你的 `harmonic_mean` 函数的 C 扩展版本的性能。和之前一样,你的设置步骤应该执行以下操作:
+ 导入 `harmonic_mean` 函数
+ 导入 `random.randint`
+ 使用内置的 `random.randint` 函数创建一个包含一百万个介于 1 和 1,000 之间的随机数的列表
你要测量的代码应该是调用你的 `harmonic_mean` 函数,并使用随机数列表作为输入。
你看到了什么?将结果与你的早期对纯 Python 实现的测量结果进行比较。记住,你没有改变你必须写的代码——你只改变了文件名,并告诉 Cython 如何处理它。在我的系统中,这个改变单独导致了以下统计结果:
20 loops, best of 5: 18.5 msec per loop
你读得对——代码的 C 扩展版本可以运行得像 18.5 毫秒一样快,几乎比纯 Python 实现快三倍。而 Cython 做了所有繁重的工作!现在,关于那个二进制轮分布文件呢?
### 4.2.4 二进制轮分布的构建目标
虽然使用 Cython 获得的成绩可能被认为是一个容易的胜利,但它并非没有代价。当你仅使用 Python 编写包时,它们极其便携——任何运行兼容 Python 版本的系统都可以运行你的代码。一旦引入必须编译的代码,一切都会改变。
一些性能最优秀的编程语言能够通过静态类型、预定义内存分配以及在运行前进行的编译步骤来达到它们的速度。这些特性在计算密集型环境中非常有价值。不幸的是,许多这些相同的特性也依赖于它们运行的计算机架构和操作系统的知识。性能通常是通过利用这些系统的特性和行为来获得的,所以在一个地方有效的方法不一定在另一个地方也有效。在最坏的情况下,如果在错误的环境中运行,这实际上可能导致内存损坏和执行失败。
由于执行细节,这些语言的源代码通常必须为它将使用的每个架构和操作系统分别编译。再次看看 dist/目录中的二进制轮分布文件。其文件名分为几个重要部分(见图 4.2)。前两部分是标准化包名和版本。这些可能后面跟着一个可选的构建号。最后三部分是标识二进制轮兼容性的标签,如下所示:
+ *Python 版本*—代码必须执行的 Python 实现
+ *应用程序二进制接口(ABI)*—编译代码的二进制组织方式
+ *平台*—代码必须执行的操作系统和 CPU 架构

图 4.2 二进制轮分布文件的结构
当你安装包时,你的包管理器将确定哪些二进制轮分布可用,并使用这些标签来识别它应该为你的系统下载哪些。例如,二进制轮分布文件 first_python_package-0.0.1-cp310-cp310-macosx_11_0_x86_64.whl 与 CPython 3.10 实现、CPython 3.10 API 以及运行在 x86 64 位 CPU 架构上的 macOS 11 操作系统兼容。
你可能会注意到这三个文件名部分与构建覆盖所有可能目标所需的二进制 wheel 分布数量有关。幸运的是,前两个部分——Python 实现和 ABI 版本——通常是相同的。另一方面,一些操作系统可以在不同的 CPU 架构上运行,所以这个单一标签实际上是两块信息。这一切都归结为你想要支持的 Python 实现、操作系统和 CPU 架构。这意味着你需要构建的二进制 wheel 分布的数量大致如下:
*N*[Python 实现] ∙ *N*[操作系统] ∙ *N*[CPU 架构]
以一个例子来说,在撰写本文时,NumPy 项目([`numpy.org/`](https://numpy.org/))支持 CPython 3.7、3.8 和 3.9,以及 PyPy 3.7。它支持这些在不同架构上针对 macOS、Linux 和 Windows 的每个版本。总的来说,每个 NumPy 发布版本提供了二十七个 wheel。这听起来像是一项大量工作,作为一个单独的维护者,这可能确实是正确的。但是,由于 NumPy 对于构建高性能数值软件对科学社区来说如此关键,项目维护者愿意不定期地投入这份努力,以提供用户始终需要的性能。
### 4.2.5 指定所需的 Python 版本
你可能构建的包只与特定 Python 版本中可用的功能或语法兼容。在这种情况下,最好在 setup.cfg 文件中指定这一点,因为当你最终发布你的包时,它只有在用户尝试使用兼容的 Python 版本安装它时才会可用。这减少了混淆和惊喜。
你可以使用 setup.cfg 文件中[options]部分的`python _requires`关键字来指定所需的 Python 版本或版本范围,使用与指定包版本相同的 PEP 440 ([`peps.python.org/pep-0440/`](https://peps.python.org/pep-0440/))版本规范符。现在就将这个添加到你的 setup.cfg 文件中。它应该看起来像以下这样:
[options]
...
python_requires = >=3.9
当用户尝试使用 Python 3.8 或更早版本安装你的包时,他们会看到一个消息,表明没有可用的兼容版本。
到目前为止,你已经构建了一个纯 Python wheel 和一个针对你的 Python 实现和平台的特定 wheel。这可能与像 NumPy 这样的项目所运作的规模相去甚远。幸运的是,一些工具已经出现,可以减轻构建这些 wheel 的负担,你将在第七章中了解更多关于它们的信息。现在,庆祝你已经构建了一个工作的 Python 包,并准备好处理 CarCorp 的第二项请求。
## 4.3 从 Python 包提供命令行工具
CarCorp 希望能够运行一个独立的命令来快速计算调和平均值。他们熟悉使用 shell 运行命令,但不太熟悉使用 Python 编写和运行脚本。幸运的是,大多数 Python 构建系统也支持这一点。你可以告诉这些系统,作为安装过程的一部分,代码的一部分应该作为可运行的命令公开。在接下来的章节中,你将了解 Setuptools 如何处理这种用例。
### 4.3.1 使用 Setuptools 入口点创建命令
Setuptools 允许你通过它所说的 *入口点* 向用户提供命令。入口点就像一扇门——进入和离开的地方。Setuptools 入口点提供了一种以可发现的方式进入包功能的方法。命名命令是暴露这些入口点的一种方式。
你可能熟悉在许多用于命令行使用的 Python 脚本中使用的 `if __name__ == "__main__":` 语法。当你运行 `python some.py` 时,`some.py` 的 `__name__` 将是 `"__main__"`,条件语句中的代码将运行。命令是这种概念的更通用和更灵活的版本。从高层次来看,你通过将命令的名称映射到函数的点分模块路径来在 Setuptools 中创建一个命令。例如,假设你想要一个名为 `harmony` 的命令,它从 `imppkg.harmonic_mean .harmonic_mean` 函数提供计算行为。而不是要求你运行 `python harmonic_mean.py` 并在代码中响应 `if __name__ == "__main__"`,入口点允许你运行 `harmony` 命令并指向一个调用 `imppkg.harmonic_ mean.harmonic_mean` 并带有命令参数的函数(见表 4.1)。
表 4.1 从 Python 模块执行代码的不同方式
| 方法 | 命令 | 是否需要安装 | 优点 | 缺点 |
| --- | --- | --- | --- | --- |
| 直接执行模块 | `$ py /path/to/package/src/imppkg/harmony.py [args]` | 否 | | 代码内部的导入可能无法工作 |
| 作为可导入模块执行 | `$ py -m imppkg.harmony [args]` | 否(适用于任何可导入的代码) | 代码内部的导入可以工作 | 命令行较长,需要了解包结构 |
| 作为入口点执行 | `$ harmony [args]` | 是 | 命令简短,不需要了解包结构 | |
要创建一个命令入口点,首先需要创建处理函数。
练习 4.4
在你的 src/imppkg/ 目录中添加一个新的 Python 模块,名为 `harmony.py`。在该模块内部,创建一个 `main` 函数,
+ 使用 `sys.argv` 从命令行获取参数
+ 将参数转换为浮点数列表
+ 调用 `imppkg.harmonic_mean.harmonic_mean`,传入数字列表
+ 打印计算出的数值平均值
记得导入 `sys` 和 `imppkg.harmonic_mean.harmonic_mean`。
在你的处理函数就位后,你现在可以配置 Setuptools 使其作为一个命令可用。你告诉 Setuptools 在 setup.cfg 文件的 `[options.entry_points]` 部分查找命令。这个部分是一个将入口点组映射到(命令名称,处理函数)对的表格。对于命令,入口点组是 `console_scripts`。你已经在你的打包冒险中使用了其中一个控制台脚本:`build` 工具提供了一个控制台脚本 `pyproject-build` 命令([`mng.bz/2nBX`](http://mng.bz/2nBX))。
有哪些其他的入口点类型?
Setuptools 的入口点系统非常灵活。`console_scripts` 组是创建命令行工具的惯例,但组可以是任何其他有效的字符串。如果它们同意一个入口点的约定,这可以用来协调跨包的功能,使得基于插件的架构成为可能。pytest,你将在第五章中了解更多信息,使用这种方法,其他人可以编写测试插件([`mng.bz/R4jP`](http://mng.bz/R4jP))。
不同的包可以在不知道具体信息的情况下找到彼此的软件,这是扩展性的强大功能(参见 Dane Hillard,“扩展性和灵活性”,《Python 高手实践》,Manning 出版公司,2020 年,第 147-142 页,[`mng.bz/m2N0`](http://mng.bz/m2N0))。如果你想构建一个其他人可以扩展而无需你参与的包,这是你需要进一步探索的领域。
现在编写入口点部分。它应该看起来像以下片段:
...
[options.entry_points] ❶
console_scripts = ❷
harmony = imppkg.harmony:main ❸
❶ Setuptools 查找入口点的位置
❷ 创建可运行命令的组
❸ 命令名称到处理函数的映射
现在你已经有一个处理函数,并且 Setuptools 知道通过 `harmony` 命令使其可用,现在是时候验证它是否正常工作了。现在将你的包重新安装到你的虚拟环境中。一旦完成,从你项目的根目录运行以下命令:
$ ./.venv/bin/harmony 0.65 0.7
你应该看到以下输出:
0.674074074074074
小贴士 注意,你必须包含 `.venv/bin/` 前缀来执行命令。当用户将你的包安装到他们的活动虚拟环境或基础 Python 版本中时,安装的命令将自动添加到他们的 PATH 中,前缀将不再必要。
你现在有一个可构建、可安装的 Python 包,它可以快速计算调和平均值。自信你已经满足了 CarCorp 所要求的功能,你决定想要用一些他们不知道需要的东西来让他们印象深刻。因为通常在控制台中工作意味着在文本墙中找到感兴趣的行,你希望 `harmony` 的输出非常突出。你决定想要添加彩色文本,但你没有时间学习 ANSI 转义序列。你决定安装另一个包来为你处理这个问题。
## 4.4 指定 Python 包的依赖关系
到目前为止,您的包尚未依赖于任何第三方 Python 包。现在您想添加一个,可能会诱使您直接使用 pip 将其安装到您的虚拟环境中。不幸的是,这对您的用户来说是不行的,因为他们也需要自己安装该包。记住第一章中提到的,包管理系统的价值之一就是依赖项解析和安装。您真正想要做的是指定给 Python 包管理工具,您的包有一个依赖项,并让他们为您管理安装。这将帮助您获取依赖项,但也会帮助您的用户获取它。这是一个全赢的局面!
为包指定依赖项类似于使用类似 requirements.txt 文件列出依赖项的熟悉方法,但有以下两个关键区别:
+ 依赖项需要指定在您的构建系统可以看到它们的地方,这样依赖项就可以被纳入包管理器使用的元数据中,以解析和安装依赖项。
+ 依赖项应尽可能宽松地指定,以最大化用户的兼容性。
重要:强调最后一点:当包不需要时,应避免将其锁定到确切版本。想象一下,我和你各自创建了一个包,它们都依赖于`requests`包。现在想象一下,有人想在他们的项目中使用它们。他们安装了您的包,但当尝试安装我的包时,他们收到一个错误,说我的包依赖于`requests==2.1.1`,而您的包依赖于`requests==2.1.2`。这个问题没有前进的道路,因为解决一个包的问题会为另一个包创造问题。
如果我们双方都将我们的包依赖于`requests>=2.1.1,<3`,那么对于用户来说,任何大于 2.1.0 且小于 3.X 版本的 requests 都将适用。随着用户安装更多具有更多依赖项指定的包,这确保了我们不会不必要地缩小他们有效依赖项组合的空间。
使用更宽松的依赖定义对包的另一个好处是,您可以更快地发现由上游包引起的问题。如果您将版本锁定在确切版本上六个月,然后稍后尝试升级,您可能会发现一系列问题,并需要花费整整一天的时间来恢复速度。如果您宽松地定义依赖项,您将在开发测试过程中重新安装包的依赖项时发现这些问题。最初处理这些问题可能会感到令人畏惧,但您会欣赏定期对这些相对较小的更改进行迭代,而不是每隔几个月就要扑灭一场所谓的“大火”。
Setuptools 在 setup.cfg 文件的 `[options]` 部分的 `install_requires` 键中查找包依赖项。`install_requires` 的值是一个使用与 requirements.txt 文件中相同的语法指定的依赖项列表。为了给 `harmony` 命令的输出添加一些颜色,你将使用 `termcolor` 包。截至本文撰写时,termcolor 的最新版本是 1.1.0。因为你不会测试早期版本,并且你信任它们至少在 2.0.0 版本发布之前维护现有功能,你可以指定 `termcolor>=1.1.0,<2` 作为版本。
现在添加 `install_requires` 键。它应该看起来像以下片段:
[options]
...
install_requires =
termcolor>=1.1.0,<2
现在,当你的包安装后,pip 也会下载并安装 `termcolor` 的最新 1.X 版本。有了这个,你就可以在你的 `harmony.py` 模块中使用 `termcolor`。不要使用内置的 `print` 函数来打印调和平均值的计算结果,而是导入并使用 `termcolor.cprint` 函数。这个函数比 `print` 函数接受更多的参数,如下所述:
+ 可选的前景色指定符,例如 `'red'` 或 `'green'`
+ 可选的背景颜色指定符,例如 `'on_cyan'` 或 `'on_red'`
+ 样式列表的 `attrs`,例如 `['bold', 'italic']`
练习 4.5
将 `harmony.py` 模块中的 `print` 调用替换为对 `termcolor.cprint` 的调用。文本应该是粗体红色,背景为青色。重新安装你的包并重新运行 `harmony` 命令以确认输出看起来如你所预期。
看起来很壮观吗?如果不是,请尝试调整 `termcolor` 的值,找到你喜欢的配色方案。
你现在有一个相当完善的版本,可以考虑将其发送给 CarCorp。但你有一种不安的感觉,他们很快就会要求更多功能。当你准备好时,继续阅读第五章,了解如何将你的测试套件集成以验证你的更改,随着你的包的增长。
## 练习答案
**4.1**
def harmonic_mean(nums):
return len(nums) / sum(1 / num for num in nums)
**4.2**
from setuptools import setup
from Cython.Build import cythonize
setup(
ext_modules=cythonize("src/imppkg/harmonic_mean.pyx"),
)
**4.3**
$ py -m timeit
--setup 'from imppkg.harmonic_mean import harmonic_mean'
--setup 'from random import randint'
--setup 'nums = [randint(1, 1_000_000) for _ in range(1_000_000)]'
'harmonic_mean(nums)'
**4.4**
import sys
from imppkg.harmonic_mean import harmonic_mean
def main():
nums = [float(arg) for arg in sys.argv[1:]]
print(harmonic_mean(nums))
**4.5**
...
from termcolor import cprint
...
cprint(harmonic_mean(nums), 'red', 'on_cyan', attrs=['bold'])
## 摘要
+ 通过首先使用像 Cython 这样的高级翻译层来探索非 Python 扩展。
+ 提供非 Python 扩展可以获得运行时性能,但会增加构建时间复杂度,无论是对你还是对你的消费者。
+ 你的包的入口点提供了比仅仅导入代码更多与它的行为交互的方式。
+ 利用包管理系统的力量来为你处理依赖项解析。
# 5 构建和维护测试套件
本章涵盖
+ 使用 pytest 运行单元测试
+ 使用 pytest-cov 创建测试覆盖率报告
+ 使用参数化减少重复的测试代码
+ 使用 tox 自动化测试打包
+ 创建测试矩阵
测试是任何计划维护的项目的重要方面。它们可以确保新的功能表现如你所预期,并且现有的功能没有退化。测试是重构代码的护栏——随着项目的成熟,这是一个常见的活动。
虽然测试提供了所有这些价值,你可能会认为所有开源软件包都会经过彻底的测试。但许多项目因为维护负担而放弃了诸如代码覆盖率或针对多个目标平台的测试。一些维护者甚至没有意识到,由于他们设计和运行测试套件的方式,他们甚至*创造*了维护负担。在本章中,你将了解测试的一些有益方面以及如何将它们引入你的软件包测试套件,着眼于自动化和可扩展性。
如果你仍然对单元测试的概念感到陌生,你可以在 Roy Osherove 的《单元测试的艺术》(第 3 版)(Manning Publications,预计 2023 年出版,[`mng.bz/YKGj`](http://mng.bz/YKGj))中了解所有相关信息。
重要提示:你可以使用代码伴侣 ([`mng.bz/69A5`](http://mng.bz/69A5)) 来检查本章练习中的工作。
## 5.1 集成测试设置
构建健壮测试套件的第一步是配置一个*测试运行器*来运行项目的任何测试。如果你过去使用过内置的`unittest`模块,你很可能使用过类似`python -m unittest discover`的命令作为你的测试运行器。`unittest`是一个功能齐全的软件,但,就像任何 Python 内置模块一样,当你想要扩展或改变其行为时,它需要你进行一些工作。此外,`unittest`框架所采用的框架在功能和语义上都受到 xUnit ([`xunit.net/`](https://xunit.net/)) 测试框架家族的启发,这可能会感觉有些不自然,因为它的约定并不总是遵循 PEP 8 ([`www.python.org/dev/peps/pep-0008/`](https://www.python.org/dev/peps/pep-0008/)) 风格。
对于一个与 Python 运行时代码更紧密对齐的测试体验,并且可以随着测试套件的扩展而提高生产效率,pytest ([`docs.pytest.org`](https://docs.pytest.org)) 是一个强大的替代方案。你将在本章的其余部分使用 pytest,并了解它相对于`unittest`模块的一些优势。
### 5.1.1 pytest 测试框架
pytest 旨在使编写简单的测试更加容易,并支持随着项目的增长而变得越来越复杂的测试。它可以直接运行基于`unittest`的测试套件,但也提供了自己的断言语法和基于插件的架构,以扩展和改变其行为以满足你的需求。该框架还提供了一些设计可扩展测试的实用工具,例如
+ *测试固定装置*—提供额外依赖项给测试的函数,例如数据或数据库连接
+ *参数化测试*—能够编写一个单一的测试函数和多个输入参数集,为每组输入创建一个独特的测试
提示:要深入了解 pytest 及其所有功能,请查看 Brian Okken 的 *Python Testing with pytest*,第 2 版(Pragmatic Bookshelf,2022,[`mng.bz/1olg`](http://mng.bz/1olg))。
你*必须*在安装你的包及其依赖项的同一虚拟环境中安装 pytest。单元测试执行你的真实代码,并且该代码必须是可导入的。例如,如果你使用 pipx 全局安装 pytest,pytest 将不知道在哪里找到你的项目依赖项,并且无法导入它们。通过以下命令将 pytest 安装到你的项目虚拟环境中,直接跳入 pytest:
$ py -m pip install pytest
安装 pytest 使得 `pytest` 模块可用。在第四章中,你将你的包代码安装到虚拟环境中,以便可以导入。pytest 在运行测试时以相同的方式导入你的代码。现在使用以下命令运行 pytest:
$ py -m pytest
这会导致 pytest 发现它能够发现的任何测试并执行它们。因为你还没有任何测试,你将看到如下输出:
=============== test session starts ===============
platform darwin -- Python 3.10.0b2+,
➥ pytest-6.2.4, py-1.10.0, pluggy-0.13.1 ❶
rootdir: /path/to/first-python-package ❷
collected 0 items ❸
============== no tests ran in 0.00s ============== ❹
❶ 显示 Python 版本、pytest 版本和插件版本的概要环境
❷ 用于配置、测试发现等的目录
❸ 没有发现测试。
❹ 没有执行任何测试。
记住,在第三章中,你为你的项目创建了一个布局,将源代码与测试代码分开。你将实现代码添加到 src/ 目录中,并创建了一个空的 test/ 目录。为了避免将测试包含在打包的代码中,并保持你的测试在一个易于找到的地方,你应该将你的测试放在 test/ 目录中。默认情况下,pytest 会发现项目中可能存在的任何测试。这包括项目根目录中的测试或在 src/ 目录中的测试,这并不理想。你将配置 pytest 以确保它只运行放置在正确位置的测试。
练习 5.1
在项目的根目录中创建一个 `test_harmonic_mean.py` 模块,并添加一个名为 `test_always_passes` 的测试函数,该函数总是通过。如果你不熟悉 pytest,你可以直接使用 Python `assert` 语句进行测试断言;例如,`assert True` 将总是通过。
在创建测试模块后,再次运行 pytest。这次你将看到如下输出:
=============== test session starts ===============
...
collected 1 item ❶
test_harmonic_mean.py . ❷
================ 1 passed in 0.04s ================ ❸
❶ 发现了一个测试。
❷ 发现的测试模块列表以及每个通过测试的点
❸ 一个测试在 0.04 秒内执行并通过。
这表明 pytest 正在项目根目录下的所有位置寻找测试。为了鼓励将测试放置在适当的位置,你应该配置 pytest 仅在其`test/`目录中查找测试。你可以通过在包的`setup.cfg`文件中使用一个名为`[tool:pytest]`的新部分来添加 pytest 的配置。`testpaths`键映射到一个包含查找测试的路径列表。你需要只有一个:`test`。添加此配置后,pytest 应在其输出中确认它正在使用`setup.cfg`作为配置文件,并且找到了`testpaths`配置。
练习 5.2
在`setup.cfg`中添加 pytest 配置,使其仅在`test/`目录中查找测试。添加配置后,执行以下操作:
+ 再次运行 pytest,并确认它没有发现和运行任何测试。
+ 将`test_harmonic_mean.py`模块移动到它所属的`test/`目录。
+ 再次运行 pytest,并确认它发现了并运行了你编写的测试。
现在你处于添加更多测试的好位置。pytest 会根据其命名约定,自动识别你添加到`test/`目录中的任何新的测试模块,如下所示(见图 5.1):
1. 从`testpaths`中的任何目录开始。
1. 查找名为`test_*.py`的模块。
1. 在这些模块中查找名为`Test*`的类。
1. 在这些模块中查找名为`test_*`的函数,或在这些类中查找名为`test_*`的方法。

图 5.1 pytest 使用递归模式匹配发现项目中的单元测试。
现在你已经创建了一个编写和运行测试的机制,下一步是确定要编写哪些测试。在下一节中,你将集成测试覆盖率并编写更多测试,以确保你覆盖了你的包的所有代码路径。
### 5.1.2 添加测试覆盖率测量
在开始测试覆盖率之前,你必须首先明白这并不是万能的解决方案。测试覆盖率会告诉你你的运行时代码在测试执行期间执行了多少,甚至可以测量执行了多少条件分支。但是测试覆盖率并不能保证所有这些行和分支都有相应的断言来验证它们的行为。一个执行了整个代码库但以`assert True`结束的测试将拥有 100%的覆盖率,但毫无价值。
话虽如此,如果你在正确设计测试用例方面非常勤奋,覆盖率是一个有用的工具,可以帮助你找到那些肯定没有断言的代码区域。你可以利用这一点添加有价值的测试,并重构你的测试套件以获得更好的运行时代码覆盖率。要开始测量覆盖率,请在项目的虚拟环境中安装`pytest-cov`包,如下所示:
$ py -m pip install pytest-cov
此包提供了一个 pytest 插件,该插件集成了 Coverage.py 项目([`coverage.readthedocs.io/`](https://coverage.readthedocs.io/)),以便您可以使用 pytest 运行它。Coverage.py 是测量 Python 代码覆盖率的事实标准。安装了`pytest-cov`后,使用`--cov`选项运行 pytest 以收集覆盖率测量值。您将在执行结束时看到额外的输出,在您通常看到的 pytest 报告之后,列出了许多您从未听说过的文件,如下所示:
$ py -m pytest --cov
=============== test session starts ===============
...
-- coverage: platform darwin, python 3.10.0-beta-2 ❶
Name Stmts Miss Branch BrPart Cover ❷
... A lot of files ... ❸
TOTAL ❹
================ 1 passed in 0.04s ================
❶ 与 pytest 一样,Coverage.py 会打印一些环境信息。
❷ 每个文件的名字、行号、分支和整体覆盖率
❸ Coverage.py 正在测量许多不属于您的文件。
❹ 整个代码库的整体覆盖率
Coverage.py 测量它能够找到的所有已安装 Python 代码的覆盖率,包括您包的依赖项代码甚至 pytest 本身。要仅测量您包的覆盖率,您可以指定您的导入包的名称作为`--cov`选项的值。您的测试甚至还没有导入您的包,因此您应该期望覆盖率为零。再次运行 pytest 以指定覆盖率,并确认这是否如此。Coverage.py 将生成如下输出:
$ py -m pytest --cov=imppkg
=============== test session starts ===============
...
Coverage.py warning: Module imppkg was never imported. ❶
➥ (module-not-imported)
Coverage.py warning: No data was collected. ❷
➥ (no-data-collected)
WARNING: Failed to generate report: No data to report.
/path/to/first-python-package/.venv/lib/python3.10/
➥ site-packages/pytest_cov/plugin.py:285:
➥ PytestWarning: Failed to generate report:
➥ No data to report.
warnings.warn(pytest.PytestWarning(message))
-- coverage: platform darwin, python 3.10.0-beta-2 --
================ 1 passed in 0.04s ================
❶ 确认您的包在测试中没有被导入
❷ 确认您的运行时代码完全没有被覆盖
您可以通过在测试中导入您的代码来快速修复`module-not-imported`问题。在`test_harmonic_mean.py`模块的顶部,导入`harmonic_mean`函数和`main`函数,后者支持`harmony`命令。添加导入后,再次使用覆盖率运行 pytest。这次,您将在覆盖率输出中看到`__init__.py`和`harmony.py`模块,类似于以下内容:
$ py -m pytest --cov=imppkg
...
-- coverage: platform darwin, python 3.10.0-beta-2 --
Name Stmts Miss Cover
.../init.py 0 0 100% ❶
.../harmony.py 6 2 67% ❷
TOTAL 6 2 67%
❶ 这个模块中没有代码,因此它完全被覆盖。
❷ 此模块中有六个语句,其中两个没有被测试执行。
您现在应该清楚地看到测试覆盖率并不一定与测试值相关。您已经编写了一个不执行任何代码的测试,而您的 Python 模块已经有了 67%的覆盖率。
非 Python 扩展的覆盖
Coverage.py 通常覆盖 Python 源代码,但对于某些非 Python 扩展,通过在编译时启用行跟踪并使用理解行跟踪信息的 Coverage.py 插件,可能覆盖它们的源代码。例如,您可以在 Cython 的.pyx 文件中指定额外的指令以启用行跟踪,并使用`Cython.Coverage`插件来测量覆盖率。
启用分支覆盖率
除了行覆盖率外,测试的一个重要方面是了解有多少可能的替代执行路径以及哪些路径没有被测试。一段代码的*循环复杂度*(托马斯·J·麦卡贝,《复杂性度量》。*IEEE Transactions on Software Engineering* 4 [1976]: 308–20., doi:10.1109/tse.1976.233837)衡量了代码中的路径数量,为了全面覆盖代码的行为,您需要对每个路径进行测试。在 Coverage.py 中,这被称为*分支覆盖率*。
要为测试配置分支覆盖率,请向 setup.cfg 添加一个名为`[coverage:run]`的新部分。在这个部分中,添加一个值为`True`的`branch`键(参见列表 5.1)。这会在覆盖率输出中产生两列新数据:
+ `Branch`—代码中存在的分支数量
+ `BrPart`—测试仅部分覆盖的分支数量
练习 5.3
当您添加`[coverage:run]`部分时,添加一个值为`imppkg`的`source`键。这是一种方便的方法,可以停止每次在 pytest 的`--cov`选项中指定`imppkg`,并确保运行带有覆盖率测试的任何人都会看到相同的输出。您还可以通过在`[tool:pytest]`部分添加一个值为`--cov`的`addopts`键来完全避免指定`--cov`。您可以在命令行上使用相应的`--no-cov`选项来覆盖它。
在添加了这些配置后,您应该运行什么命令才能获得迄今为止的行为?
A) `pytest`
B) `pytest --cov`
C) `py -m pytest --cov`
D) `py -m pytest --no-cov`
E) `py -m pytest`
F) `py -m pytest --cov=imppkg`
列表 5.1 配置覆盖率以测量分支
[coverage:run]
branch = True
启用分支覆盖率后,可能的分支被添加到语句计数中,以确定总覆盖率。再次运行 pytest。注意,您的代码覆盖率从 67%下降到 50%,如下所示:
$ py -m pytest
...
-- coverage: platform darwin, python 3.10.0-beta-2 --
Name Stmts Miss Branch BrPart Cover
.../init.py 0 0 0 0 100%
.../harmony.py 6 2 2 0 50% ❶
TOTAL 6 2 2 0 50%
❶ 找到两个分支,且都没有部分覆盖。
注意:当考虑分支覆盖率时,总覆盖率将严格小于或等于不考虑分支的覆盖率。考虑分支的覆盖率百分比可能难以手工计算,因为它考虑了代码在执行过程中可能采取的所有不同路径。您可以在 Coverage.py 文档中了解更多关于分支测量的具体信息([`mng.bz/G1EA`](http://mng.bz/G1EA))。
现在您已经清楚地了解了测试如何覆盖您的代码及其执行路径,了解哪些路径没有被覆盖是非常有用的。
启用缺失覆盖率
Coverage.py 可以跟踪测试未覆盖的确切行和分支,这在尝试编写提高代码覆盖率测试时非常有帮助。你可以通过在 setup.cfg 中添加一个新的部分来启用此功能,该部分称为 `[coverage :report]`,并添加一个新键 `show_missing`,将其值设置为 `True`(参见列表 5.2)。这将产生一个新列 `Missing` 在覆盖率输出中。`Missing` 列列出以下内容:
+ 未覆盖的行或行范围。例如,`9` 表示第 9 行未覆盖,而 `10-12` 表示第 10、11 和 12 行未覆盖。
+ 表示未覆盖分支的逻辑流程从一个行到另一个行。例如,`13->19` 表示从第 13 行开始的执行路径将接下来执行第 19 行,这部分路径未覆盖。
列表 5.2 配置覆盖率以显示未覆盖的代码
[coverage:report]
show_missing = True
再次运行 pytest 来查看覆盖率报告显示你遗漏了哪些内容。报告中列出的行将与 `harmony.py` 模块中 `main` 函数体的行相对应,如下所示:
$ py -m pytest
...
-- coverage: platform darwin, python 3.10.0-beta-2 --
Name Stmts Miss Branch BrPart Cover Missing
.../init.py 0 0 0 0 100%
.../harmony.py 6 2 2 0 50% 9-10 ❶
TOTAL 6 2 2 0 50%
❶ 第 9 行和第 10 行未覆盖。
你可以使用未覆盖行的报告快速识别需要编写更多测试的焦点区域。
仔细查看 Coverage.py 输出中的文件路径。它们指向你在安装你的包时在虚拟环境中创建的文件,前缀类似于 .venv/lib/python3.10/site-packages/imppkg/。这是完全正确的,但有时由于每个文件前都有长前缀,阅读起来可能会有点困难。为了简化这些路径并将覆盖率映射回相关的源代码,你可以告诉 Coverage.py 它应考虑哪些文件路径是等效的。
简化覆盖率报告输出
在你的项目中,你已安装的包的 .venv/lib/python3.10/site-packages/imppkg/ 目录大致等同于包源代码的 src/imppkg/ 目录。通过在 setup.cfg 中添加一个新的部分来告诉 Coverage.py 这一点,该部分称为 `[coverage:paths]`。向此部分添加一个 `source` 键,其值为等效文件路径的列表。Coverage.py 将使用列表中的第一个条目来替换输出中的任何后续条目。此列表中的路径可以包含通配符字符 (`*`),以允许路径该部分的任何名称匹配。完成时,新部分应类似于以下列表。
列表 5.3 配置覆盖率以输出与源代码相关的路径
[coverage:paths]
source =
src/imppkg/
*/site-packages/imppkg/
再次运行 pytest。输出中的文件路径将以 src/imppkg 为前缀,而不是 .venv/lib/python3.10/site-packages/imppkg,如下所示:
$ py -m pytest
...
-- coverage: platform darwin, python 3.10.0-beta-2 --
Name Stmts Miss Branch BrPart Cover Missing
src/imppkg/init.py 0 0 0 0 100%
src/imppkg/harmony.py 6 2 2 0 50% 9-10
TOTAL 6 2 2 0 50%
随着你的项目增长和你在测试上花费更多的时间,从覆盖率报告中挑选出未覆盖的模块可能会变得更加困难。如果你为几个文件达到了 100%的覆盖率,那么在报告输出中忽略它们可能会有所帮助。你可以在`[coverage:report]`部分添加一个`skip_covered`键,其值为`True`来过滤它们(参见下一列表)。被过滤的文件仅从列表中删除;它们的覆盖率仍然被考虑在代码的总覆盖率计算中。
列表 5.4 配置覆盖率以跳过已覆盖的文件
[coverage:report]
...
skip_covered = True
再次运行 pytest。`__init__.py`模块将被从报告中过滤出来,有一条消息确认了这一点,如下所示:
$ py -m pytest
...
-- coverage: platform darwin, python 3.10.0-beta-2 --
Name Stmts Miss Branch BrPart Cover Missing
src/imppkg/harmony.py 6 2 2 0 50% 9-10
TOTAL 6 2 2 0 50%
1 file skipped due to complete coverage. ❶
❶ 这确认了完全覆盖的文件被过滤出来。
现在覆盖率报告只显示当你旨在增加测试覆盖率时需要你注意的文件。
### 5.1.3 增加测试覆盖率
你现在有一种简化的方式可以看到你的项目中哪些文件可能需要测试关注,报告可以快速让你知道你做的更改如何影响覆盖率。这是一个编写真正的测试来替换你之前写的`assert True`的好时机。
在`test_harmonic_mean.py`模块中,你需要编写一个测试来测试`harmony.py`模块中的代码。那里的代码包括`main`函数,它执行以下操作:
1. 从`sys.argv`读取参数
1. 将这些参数转换为浮点数
1. 使用`harmonic_mean`函数计算数字的调和平均值
1. 以彩色文本打印结果
你可以通过将`sys.argv`修补到受控值并断言输出是你期望的来编写一个测试,这将促进所有这些操作。这将导致`harmony.py`模块的 100%覆盖率。然而,这被称为*愉快的路径测试*。
揭示不愉快的路径
不愉快的路径测试会练习在测试代码中较少出现、容易出错的路径。当你想要使你的代码更加健壮时,你应该冒险走出愉快的路径测试,以找到可能破坏你代码的边缘情况(见图 5.2)。

图 5.2 测试可能覆盖常见的、期望的执行路径或较少见的边缘和错误情况
你可能会想知道如何编写具有 100%行和分支覆盖率的测试,同时仍然可能错过代码故障。如果你有针对每个执行路径的测试并且所有测试都通过,那么代码如何失败呢?原因通常归结于代码接受的输入,特别是如果该输入可以直接来自用户。在`harmony`控制台脚本的情况下,它直接从命令行接收用户输入并将其传递到`harmony.py`模块的`main`函数。如果该输入无效,你的代码可能会以意想不到的方式处理它。这作为一个很好的提醒,即完整的测试覆盖率仍然不是完全防止错误的完美保护。
尝试运行已安装的`harmony`命令。请注意,你需要使用`.venv/bin/harmony`来运行它,因为你没有全局安装你的包,并且`harmony`命令不在你的`$PATH`中。当你传递无法转换为数字的参数时会发生什么?当你一个参数都不传递时会发生什么?你可以产生`ZeroDivisionError`或`ValueError`。所以即使传递数字的愉快路径工作正常,仍然有可能通过精心选择的输入产生不期望的结果。在这些情况下,选择记录正确的用法、忽略边缘情况或更新代码以适应取决于你。
目前,假设任何导致除以零或无法转换为数字的输入都应该导致输出为`0.0`。在代码中实现这一点的 一种方式是使用每个潜在危险操作的`try`和相应的`catch`来处理异常(参见列表 5.5)。这可能会开始感觉像*防御性编程*,其中你防范所有可能的风险,无论它们可能多么不可能发生。但对于某些应用,你希望提供一个无错误的输出,无论是为了用户体验还是安全性。你希望 CarCorp 感到满意,并且根据你与他们已经进行的来回交流,这似乎值得覆盖你的基础。
列表 5.5 主函数的一个更安全的版本,可以处理较差的输入
def main():
result = 0.0 ❶
try:
nums = [float(num) for num in sys.argv[1:]]
except ValueError: ❷
nums = []
try:
result = harmonic_mean(nums)
except ZeroDivisionError: ❸
pass
cprint(result, 'red', 'on_cyan', attrs=['bold'])
❶ 结果将在稍后成功计算之前为零。
❷ 如果任何输入无法转换为数字,则按没有输入处理。
❸ 如果没有输入或输入仅为零,则使用默认结果继续。
这会在代码中创建更多的行和分支,因此你可以预期覆盖率会进一步下降。但现在你的覆盖率测量可以指导你编写测试,以断言更广泛输入的正确行为。更新`harmony.py`模块中的源代码以捕获`ValueError`和`ZeroDivisionError`情况。然后使用`py -m pip install .`命令将你的包重新安装到虚拟环境中。
练习 5.4
以下测试覆盖了`main`函数的愉快路径,模拟用户输入并对打印的输出进行断言:
import sys
from termcolor import colored
from imppkg.harmony import main
def test_harmony_happy_path(monkeypatch, capsys): ❶
inputs = ["1", "4", "4"] ❷
monkeypatch.setattr(sys, "argv", ["harmony"]
➥ + inputs) ❸
main() ❹
expected_value = 2.0
assert capsys.readouterr().out.strip() == colored( ❺
expected_value,
"red",
"on_cyan",
attrs=["bold"]
)
❶ pytest fixtures 用于设置状态和获取命令输出
❷ 要计算调和平均值的值,作为字符串
❸ 将值作为如果它们是提供给 harmony 的参数传递
❹ 从 sys.argv 读取并执行计算
❺ 断言输出为 2.0,并以彩色文本显示
将此测试添加到`test_harmonic_mean.py`模块中并运行 pytest。你会看到覆盖率增加。为了覆盖不愉快的路径,你会调整哪些内容进行额外的测试?你需要多少额外的测试?为代码中考虑到的每个不愉快的路径添加一个测试,以达到 100%的覆盖率。确保每个测试都有一个独特的名称,以便 pytest 可以运行它们。
在本章的早期,你配置了 Coverage.py 以跳过列出完全覆盖的文件。当你达到 100%的覆盖率时,所有文件都将从输出中消失,因为它们已经被完全覆盖。Coverage.py 的输出也显示了 100%覆盖率的指示,如下所示:
--- coverage: platform darwin, python 3.9.5-final-0 ---
Name Stmts Miss Branch BrPart Cover Missing
TOTAL 14 0 2 0 100% ❶
2 files skipped due to complete coverage. ❷
❶ 表示没有遗漏的语句或分支,并且 100%的覆盖率
❷ 表示报告中由于完全覆盖而跳过了两个文件
现在你已经达到了 100%的覆盖率,包括一些不愉快的路径,你处于非常好的状态。pytest 会告诉你代码的行为是否因为失败的测试而退化,而 Coverage.py 会告诉你是否有任何明显的额外测试机会。这让你可以自由地进入测试思维模式,发现只有你能识别的不愉快路径。现在你已经解决了这些问题,你将采取一些额外的措施来进一步减少未来的测试工作量。
## 5.2 解决测试的乏味
当你刚开始接触测试时,它可能感觉像是一个巨大的障碍,阻碍你完成任务。当你只想交付新的特性和价值时,测试可能会感觉像是一项附带的工作。减少测试的工作量是鼓励其采用的好方法,随着你的测试套件的增长,这种投资将在未来带来回报。
### 5.2.1 解决重复的、数据驱动的测试
你可能已经注意到,你为覆盖`main`函数编写的测试看起来非常相似。它们都有相同的基本形状,只是改变了一些值。pytest 有一个很好的工具来解决这种重复的、数据驱动的测试。`@pytest.mark.parametrize` 装饰器将一个值列表映射到装饰的测试函数的参数上,为每一组值创建一个单独的测试。然后你可以使用这些参数来构建一个单一的测试函数,该函数将正确断言所有不同值的行为。
`@pytest.mark.parametrize` 装饰器接受以下参数:
1. 将值映射到参数的逗号分隔字符串
1. 一个列表,其中每个项都是一个映射到参数的值的元组
装饰的测试函数必须接受与第一个`parametrize`参数对应的参数,但它可以以任何顺序接受额外的参数。将参数化的参数放在前面,将像 fixtures 这样的额外参数放在最后是一种常见的做法。
假设你已经编写了一个接受两个数值参数并返回其乘积的`mul`函数。你想要编写一些测试来确保它在输入为正数、零和负数时都能正常工作。你可以使用 pytest 的参数化来实现这一点,如下面的代码片段所示:
import pytest
from ... import mul
@pytest.mark.parametrize(
"input_one, input_two, expected", ❶
[ ❷
(2, 3, 6),
(-2, 3, -6),
(-2, -3, 6),
(0, 3, 0),
]
)
def test_mul(input_one, input_two, expected): ❸
assert mul(input_one, input_two) == expected ❹
❶ 映射到值的参数名称
❷ 一个元组的列表,每个元组都得到映射
❸ 与参数化规范匹配的参数名称
❹ 使用映射参数构建的测试
这个参数化测试函数将产生四个测试;每个测试在 pytest 输出中都有自己的状态。如果一个失败了,其他的仍然可以通过。如果你想添加更多的情况,只需在参数列表中添加一个新的元组即可。这可以使处理数据密集型测试套件中的重复测试变得更快。
练习 5.5
使用`@pytest.mark.parametrize`,将`harmony.py`模块的`main`函数的测试转换为单个参数化测试。别忘了导入`pytest`。完成之后,你应该仍然有 100%的覆盖率,以及相同数量的通过测试。
现在你已经使你的测试变得更加精简,你将更仔细地查看测试过程本身。
### 5.2.2 解决频繁安装包的问题
你现在至少已经将你的包安装到你的虚拟环境中两次了。因为你设置了你的包以确保你总是测试安装的包,所以每次你进行任何功能更改时,都需要重新安装包。这确保了你看到的内容与别人看到的一致,但这也为你创造了手动工作。到目前为止,你只对源代码做了一两个小的更改,但想象一下,当你从 CarCorp 收到第十个功能请求时,你会是什么感觉。
你还了解到,使你的包与多个依赖项和系统兼容可以帮助更多的人成功使用它。如果你想用那些不同的依赖项测试你的包,这会成倍增加你的手动工作;每个新的依赖项范围都会导致进一步的*组合增长*(见图 5.3)。组合增长发生在系统中,随着系统新增每个新维度,可能的状态数量显著增加。在你的测试系统中,只有几个依赖变量,你就可以快速达到需要测试的数十种组合。

图 5.3 在多个依赖项的多个版本范围内进行测试会迅速增长。
tox ([`tox.readthedocs.io`](https://tox.readthedocs.io)) 自动安装测试包,并为依赖项组合创建测试矩阵。这显著减少了你需要进行的手动工作,因此,它减少了你在测试中的人为错误机会。
开始使用 tox
tox 为每个测试的依赖项组合创建一个新的虚拟环境。由于这种隔离方法,你可以将 tox 设置为全局可用,并在多个项目中使用它,而不是在每个项目中单独安装。
注意:如果你还没有安装 tox,请转到附录 B,完成安装后返回本节。
从你的项目根目录运行`tox`命令。因为你还没有配置 tox,你会看到以下输出:
$ tox
ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found
现在向 setup.cfg 文件添加一个名为 `[tox:tox]` 的新部分。这部分是你将放置测试矩阵以及 tox 本身的高级配置的地方。首先添加一个 `isolated_build` 键,其值为 `True`,如下所示:
...
[tox:tox]
isolated_build = True
这告诉 tox 使用你在第三章中学到的 PEP 517 和 PEP 518 标准来构建你的包。再次运行 tox 以确认它看到了配置。tox 产生以下友好的输出:
$ tox
congratulations 😃
确认 tox 正在读取你的配置后,你就可以开始创建测试矩阵了。
tox 环境模型
tox 运作在 *环境* 的概念上。tox 环境是一个执行一组命令的隔离场所,它有自己的安装依赖和环境变量集。每个 tox 环境都包含一个包含 Python 解释器副本的虚拟环境(见图 5.4)。tox 配置语言为你提供了对所有这些内容的精细控制,其语法克服了你测试矩阵组合性质带来的大多数挑战。

图 5.4 tox 环境是构建、安装和测试你的代码的隔离场所。
你可以创建任何你想要的任意环境,但 tox 对一些环境名称有特殊处理。名称如 `py37` 或 `py310` 的环境将创建一个包含相应版本 Python 解释器的虚拟环境。tox 配置中的 `envlist` 键定义了 tox 在运行 `tox` 命令时默认创建和执行哪些环境。`envlist` 中的环境也可以通过使用 `tox` 命令的 `-e` 参数并指定环境名称来单独运行。
要开始,请在 setup.cfg 文件中的 `tox:tox` 部分添加一个 `envlist` 键,其值为 `py310`,如下所示:
[tox:tox]
...
envlist = py310 ❶
❶ tox 环境列表是你的测试矩阵。
下次你运行 tox 时,它将
1. 创建一个隔离的包构建
1. 创建一个包含 Python 3.10 复制品的虚拟环境
1. 在虚拟环境中安装你的包
1. 将 `PYTHONHASHSEED` 设置为新值以为测试创建更多随机性
再次运行 `tox` 命令。你将看到类似以下输出:
.package create: .../first-python-package/.tox/.package ❶
.package installdeps: setuptools, wheel, cython ❷
py310 create: .../first-python-package/.tox/py310 ❸
py310 inst: .../first-python-package-0.0.1.tar.gz ❹
py310 installed: first-python-package @
➥ file:/ /.../first-python-package-0.0.1.tar.gz, ❺
➥ termcolor==1.1.0 ❻
py310 run-test-pre: PYTHONHASHSEED='3663842017' ❼
____________________ summary ____________________
py310: commands succeeded ❽
congratulations 😃
❶ 使用 tox 作为构建前端进行隔离的包构建
❷ 构建后端依赖
❸ 创建虚拟环境
❹ 安装你的包
❺ 成功安装你的包
❻ 成功安装包依赖
❼ 为 Python 的随机化设置种子
❽ 环境中执行的所有命令都成功。
只需少量配置,tox 就能为你完成所有这些工作。你还没有告诉 tox 在环境中要运行哪些命令,但你的环境已经存在并准备好了。如果你想为多个 Python 版本做同样的事情呢?`envlist` 键接受一个以逗号分隔的环境列表。例如,你可以指定 `py39,py310` 来创建 Python 3.9 和 Python 3.10 的环境。
更新你的`envlist`值以包括一个额外的 Python 版本。虽然你指定了要创建的新环境,但 tox 会跳过构建你的包,因为它知道自上次构建以来源代码没有变化。类似于你已创建的`py310`环境,在`py39`环境中,tox 将
1. 创建虚拟环境
1. 将你的包安装到其中
1. 设置 `PYTHONHASHSEED`
tox 将再次执行`py310`环境。因为它已经存在,tox 不会重新创建它或重新安装依赖项,除非它检测到依赖项已更改。再次运行 tox。你将看到类似以下输出的内容:
py39 create: .../first-python-package/.tox/py39 ❶
py39 inst: .../first-python-package-0.0.1.tar.gz
py39 installed: first-python-package @
➥ file:/ /.../first-python-package-0.0.1.tar.gz,
➥ termcolor1.1.0
py39 run-test-pre: PYTHONHASHSEED='973215353'
py310 inst-nodeps: .../first-python-package-0.0.1.tar.gz ❷
py310 installed: first-python-package @
➥ file:/ /.../first-python-package-0.0.1.tar.gz,
➥ termcolor1.1.0
py310 run-test-pre: PYTHONHASHSEED='973215353'
____________________ summary ____________________
py39: commands succeeded ❸
py310: commands succeeded
congratulations 😃
❶ 添加了 py39 环境。
❷ inst-nodeps 跳过了安装依赖项。
❸ 确认每个环境都已执行
通过在 tox 配置中添加几个字符,你将测试矩阵的大小翻倍了。随着你需要测试的依赖组合的扩展,这变得越来越有价值,因为你不需要单独指定这些组合。tox 还将确保为每个组合执行测试,从而最大限度地提高发现特定组合的 bug 的机会。
因为添加新的依赖组合将使测试额外执行一次,所以你的测试套件的总体执行时间会增长。在你更改代码或测试时,使用`-e`选项在单个环境中运行测试可能会有所帮助,然后在你做出更改后不指定任何参数运行`tox`,以确保所有环境中没有出现任何问题。你还可以并行运行多个环境,这将在本章后面介绍。
现在你有两个测试环境,但它们目前都还没有做任何事情。下一步是告诉 tox 在每个环境中要做什么。
### 5.2.3 配置测试环境
到目前为止,你已经在 `[tox:tox]` 部分配置了 tox,以指示如何构建你的包以及要创建哪些环境。为了配置测试环境本身,添加一个新的 `[testenv]` 部分。这个部分默认用于任何配置的测试环境。在这个部分中,你使用 `commands` 键告诉 tox 要运行哪些命令。这个键接受一个要运行的命令列表,并提供一些特殊语法来传递参数给命令。
在每个命令中,你可以使用 `{posargs}` 占位符,它将传递任何指定给 `tox` 命令的参数到测试环境命令中。例如,如果你指定 `python -c 'print("{posargs}")'` 作为命令,运行 `tox hello world` 将在环境中执行 `python -c 'print("hello world")'`。
你也可以通过使用两个短横线(`--`)将选项与 `tox` 命令及其任何选项分开来传递测试命令的选项。例如,如果你指定 `python` 作为命令,运行 `tox -- -V` 将在环境中执行 `python -V`。
练习 5.6
你的测试环境应该能够执行 `pytest` 命令,并在运行 tox 时能够传递额外的参数。以下哪个显示了有效的测试命令和相应的 tox 命令?
A) `pytest {posargs}`, `tox`
B) `pytest {posargs}`, `tox --no-cov`
C) `pytest {posargs}`, `tox -- --no-cov`
D) `pytest --no-cov {posargs}`, `tox`
E) `pytest {posargs} --no-cov`, `tox`
F) `{posargs} pytest`, `tox -- --no-cov`
在你将 `pytest` 命令添加到 `commands` 列表后,再次运行 tox。你会看到在之前看到的步骤之后,tox 尝试执行 pytest 并失败,如下面的输出所示:
py39 run-test: commands[0] | pytest ❶
ERROR: InvocationError for command
➥ could not find executable pytest ❷
❶ 尝试执行正确的命令
❷ 命令在测试环境中找不到。
尽管你之前已经将 pytest 安装到你的项目的虚拟环境中,但请记住,tox 为每个测试环境创建并使用一个独立的虚拟环境。这意味着 tox 不会使用你一直在运行的 pytest 的副本。你没有告诉 tox 在这些环境中安装 pytest,所以它也找不到副本。你可以在 `[testenv]` 部分使用 `deps` 键指定依赖项。`deps` 的值是一个要安装的 Python 包列表,其语法类似于 `requirements.txt` 或 `install_requires`。现在,像这样将 `pytest` 和 `pytest-cov` 作为依赖项添加:
[testenv]
...
deps =
pytest
pytest-cov
再次运行 tox。这次它将安装额外的依赖项,并且 `pytest` 命令将成功运行测试和覆盖率报告,输出类似于以下内容:
...
py39 installdeps: pytest, pytest-cov
...
py39 run-test: commands[0] | pytest
...
py310 installdeps: pytest, pytest-cov
...
py310 run-test: commands[0] | pytest
____________________ summary ____________________
py39: commands succeeded
py310: commands succeeded
congratulations 😃
现在,你在两个不同的 Python 版本上成功地在隔离环境中运行了 pytest 和覆盖率,而不必手动安装你的包。每次你更改源代码、依赖项或测试时,你都可以运行 tox 来查看一切是否仍然正常工作。这种对基础设施的早期投资——特别是对于那些喜欢测试驱动开发的人来说——将在整个包的生命周期中带来回报。
在继续之前,阅读下一节以获取一些额外的测试和配置技巧。
### 5.2.4 更快、更安全的测试技巧
随着你的项目增长,你面临的风险是你在测试上花费的时间也会随之增长。为了保持你的生产力,你希望尽可能快地完成测试,并尽可能减少人为错误。以下章节讨论了一些保持你的测试套件执行受控的建议。
并行运行测试环境
你可能已经注意到,你的 Python 3.9 和 Python 3.10 环境一直在顺序执行。每个环境都需要几秒钟,所以这不是什么大问题。现在想象一下,在一个项目中,你需要测试三个 Python 版本和三个不同版本的依赖项。你有足够的耐心等待九个环境顺序运行吗?
tox 提供了并行模式([`mng.bz/z546`](http://mng.bz/z546)),可以在一次执行多个环境。要自动并行运行你的两个环境,在运行 tox 时传递 `-p` 选项,如下一个代码片段所示。此模式默认将隐藏每个单独环境的输出,只显示活动环境的进度指示器和每个环境的整体通过或失败状态:
$ tox -p
⠹ [2] py39 | py310
...
✔ OK py39 in 9.533 seconds
✔ OK py310 in 9.96 seconds
__________ summary ___________
py39: commands succeeded
py310: commands succeeded
congratulations 😃
揭露状态测试
考虑以下包含两个测试的片段,这两个测试对 Python 列的工作方式提出断言:
FRUITS = ["apple"]
def test_len():
assert len(FRUITS) == 1
def test_append():
FRUITS.append("banana")
assert FRUITS == ["apple", "banana"]
你能发现这个问题吗?它可能很微妙,但第二个测试改变了系统的状态。尽管 `FRUITS` 开始时只包含一个项目,即 `"apple"`,但测试通过添加 `"banana"` 改变了列表。这些测试按原样编写时将通过,但如果将它们按相反顺序排列(见图 5.5),它们将失败:
FRUITS = ["apple"]
def test_append():
FRUITS.append("banana")
assert FRUITS == ["apple", "banana"]
def test_len():
assert len(FRUITS) == 1

图 5.5 依赖于其他测试创建的状态的测试在重新排序或移动时可能会失败。
虽然可能很容易发现并修复这个例子,但状态测试通常是多个层次和交互的结果,你在编写代码时可能不会注意到。为了增加你发现并揭露这些情况的可能性,你应该以随机顺序运行你的测试。`pytest-randomly` 插件([`github.com/pytest-dev/pytest-randomly`](https://github.com/pytest-dev/pytest-randomly))正是这样做的。它不需要对随机顺序测试的基本行为进行配置;将其添加到 `[testenv]` 部分的 `deps` 列表,你就可以设置好了。
通过交换测试模块、类、方法和函数的顺序,`pytest-randomly` 揭示了由于依赖于早期测试中创建的状态而失败的测试(见图 5.6)。它是通过在每个运行中将随机种子更改为可重复的值来做到这一点的。此信息添加到了此处显示的 pytest 输出中:
Using --randomly-seed=1966324489

图 5.6 `pytest-randomly` 在每次运行中都以打乱顺序运行你的测试。
当测试运行产生测试失败时,你可以通过将 `--randomly-seed` 选项传递给 `pytest` 命令,并使用原始运行中输出的相同值来强制未来的运行以产生失败时的相同顺序执行。因为 pytest 是由 tox 运行的,所以你可以使用 `--` 来将 tox 选项与 pytest 选项分开,并将选项传递给底层的 `pytest` 命令,如下所示:
$ tox -- --randomly-seed=1966324489 ❶
❶ tox 将参数传递给 pytest。
安装了 `pytest-randomly` 后,每次运行 tox 时,你的测试将以不同的顺序运行。如果你注意到一个测试偶尔会没有明显原因地失败,那么测试或被测试的代码可能是状态化的。你可以使用这些提示作为寻找状态化问题的良好起点。
确保 pytest 标记有效
在本章的早期,你使用了 `@pytest.mark.parametrize` 标记来参数化一个数据驱动的测试。尽管 pytest 提供了内置的标记,如 `parametrize`,但你也可以设计自己的任意标记;从某种意义上说,你可以把它们看作是你测试的标签或标记。这是一个强大的功能,但由于你可以创建任意的标记,所以有可能记错或拼错标记的名称,这可能会在将来引起无声的问题。
默认情况下,pytest 会温和地警告你无效的标记,如下所示:
... PytestUnknownMarkWarning: Unknown pytest.mark.fake - is this a typo?
如果你想确保所有标记都是已知且有效的——也就是说,它们由插件或配置文件 `setup.cfg` 中的 `[tool:pytest]` 部分的 `markers` 键注册——请在 `setup.cfg` 文件中将 `--strict-markers` 选项添加到 `addopts` 键中。启用严格标记后,pytest 如果发现未知标记,将失败测试运行,如下面的输出所示:
'fake' not found in markers configuration option
这将确保只有在你定义了有效的标记集时,你的测试才会运行。无效的标记本身并不可怕,但它最小化了意外行为的机会。
确保预期的失败不会意外通过
pytest 提供了一个名为 `xfail` 的标记,用于标记一个测试为预期失败。一个测试可能因为各种原因而预期失败——环境问题、你正在等待的上游问题,或者简单地没有时间处理它。偶尔,一个预期失败在做出更改后可能会再次开始通过。虽然更多的通过测试听起来不错,但行为的不预期变化应该总是引起一些审查。
默认情况下,pytest 会通过将测试标记为 `XPASS` 来警告你这种情况。如果你想对此情况大声警告,以便检查为什么预期的失败开始通过,请在 `[tool:pytest]` 部分添加 `xfail_strict` 键,并将其值设置为 `True`。这将导致任何预期失败的通过测试在测试运行中失败,这样你必须在继续之前处理它们。
当你的精简、高效的测试机器已经准备好应对你抛出的任何变化时,你就可以开始添加和自动化更多代码质量流程了。
## 练习答案
**5.3**—答案:E
**5.4**—答案:添加两个新的测试,分别调整`inputs`为`[]`和例如`["foo", "bar"]`,并将`expected_value`都调整为`0.0`。
**5.5**
@pytest.mark.parametrize(
"inputs, expected",
[
(["1", "4", "4"], 2.0),
([], 0.0),
(['foo', 'bar'], 0.0),
]
)
def test_harmony_parametrized(inputs, expected, monkeypatch, capsys):
monkeypatch.setattr(sys, 'argv', ['harmony'] + inputs)
main()
assert capsys.readouterr().out.strip() == colored(
expected,
'red',
'on_cyan',
attrs=['bold']
)
**5.6**—答案:A, C, D, E
B 会将`--no-cov`选项传递给 tox 自身而不是 pytest。F 会将任何传递的参数放在命令之前。
## 摘要
+ pytest 框架有一个丰富的插件生态系统,你可以使用它比使用内置的`unittest`模块更高效地进行测试。
+ 使用测试覆盖率通过识别现有测试未执行的代码区域来指导你编写的测试。
+ 测试你代码中不常见的路径,因为覆盖率是有用的,但不足以理解你的测试如何确保代码的正确行为。
+ 测试许多依赖关系的组合既繁琐又容易出错,但 tox 通过自动化大多数涉及步骤来减少这种努力并提高安全性。
+ 为了最大化安全性,利用插件和工具选项的优势,将你的项目限制在只有有效的配置。
# 6 自动化代码质量工具
本章涵盖
+ 使用静态分析工具在开发早期阶段发现常见问题
+ 自动化依赖和命令管理以提升代码质量工具
+ 在代码提交时强制执行标准
随着你继续向各种汽车制造商推广你的工具,你开始感觉到你需要一些帮助。你需要雇佣另一位开发者来承担大部分的开发工作,这样你才能继续发展业务。你也意识到你需要找到一种方法,有效地但快速地让新伙伴加入,以便他们可以从第一天开始就变得高效。看看你最新编写的代码,你会发现它将受益于一些基本的质量标准和约定,以便继续为 CarCorp 和你的未来更多客户创造价值。
如果你注意到了上一章的内容,你可能想知道你可以自动化多少代码质量工具,而不是全部手动完成。审查代码的质量和格式问题会分散你试图交付的核心价值,并且它可能会在开发者之间造成紧张关系,尤其是当意见不同时。在最坏的情况下,关于不重要细节的争论可能会导致你在感知盲视的状态下忽视更紧迫的性能或安全问题(参见 Steven B. Most 的“如何不被看到:相似性和选择性忽视对持续注意盲的贡献”,[`doi.org/10.1111/1467-9280.00303`](https://doi.org/10.1111/1467-9280.00303))。对于不直接为你的工作创造有影响的结果的事情,对所有人来说,让机器做繁琐的工作,并达成一致,认为一致性比完美更重要,通常是更好的选择。在本章中,你将了解拥有一系列代码质量工具的价值,以及如何有效地将它们集成到你的包中。
重要提示:你可以使用代码伴侣([`mng.bz/69A5`](http://mng.bz/69A5))来检查本章中的练习。
## 6.1 tox 环境的真正力量
tox 不仅仅是一个测试工具。在本章的整个过程中,你将使用 tox 来管理几个不同的代码质量工具。
在上一章中,你学习了如何使用 `envlist` 键和 `[testenv]` 部分来配置 tox 创建和运行的多个环境列表。`envlist` 的值定义了当你运行 `tox` 命令而不指定特定环境时应默认运行的 tox 环境,而 `[testenv]` 部分定义了这些环境的默认配置。当你运行 `tox` 命令时,默认使用 `envlist` 中的环境。这些环境对于你将最常检查的事情非常有用——通常,是单元测试。如果你在谨慎开发和重构方面非常勤奋,你几乎会在每次更改后运行你的测试。由于它们的速度和使用频率,这些测试环境作为默认环境非常有意义。
你也看到了,你可以使用 `-e` 参数来指定一个单独的环境。除了 `envlist` 中的环境之外,你还可以为除了测试之外的任务配置任意环境:你可以创建一个用于构建你的项目文档、格式化代码等的环境。这些活动并不总是像单元测试那样快,而且它们也不是你在每次更改后都需要验证的事情。将它们添加到默认环境列表中可能会在尝试快速开发时减慢你的反馈周期。
你需要一个可以以统一方式配置的方法,使你能够在尽可能低的周转时间内管理你所有的不同维护活动。再次证明,tox 在这个方面是一个无价的工具。如果你还没有认识到 tox 在自动化方面的力量,以下章节将展示它可以为你节省多少时间。
### 6.1.1 创建非默认的 tox 环境
在上一章中,你在 setup.cfg 文件中添加了一个 `[testenv]` 部分,其中包含了要安装的依赖项和要运行的命令。这些是创建任何环境的基本成分。默认情况下,`[testenv]` 部分用于 `envlist` 中指定的任何环境,但你也可以为具有自己部分的特定环境进行配置。
当配置一个你不想默认运行的 tox 环境时,你需要在 setup.cfg 文件中添加一个名为 `[testenv:<name>]` 的部分,其中 `name` 是你想要的环境名称。当你运行 `tox -e <name>` 时,tox 使用该环境的 `[testenv:<name>]` 部分。此环境接受与 `[testenv]` 相同的所有选项,包括 `deps` 和 `commands` 键。你还可以为 `envlist` 中的环境提供显式的配置;在这种情况下,tox 使用 `[testenv]` 部分作为配置的基础,通过在 `[testenv:<name>]` 中找到的任何键添加到或覆盖基本配置(见图 6.1)。
练习 6.1
添加一个新的名为 `get_my_ip` 的 tox 环境,该环境执行以下操作:
1. 安装 `requests` 包作为依赖项
1. 执行一个使用 requests 获取 tox 运行所在机器的 IP 地址并打印出来的命令
你可以使用`requests.get("https:/ /canhazip.com").text`来获取 IP 地址,并且你可以使用`python -c "# some python code here"`来作为命令运行 Python 代码。当你完成时,命令`tox -e get_my_ip`应该会打印出你的 IP 地址。

图 6.1 tox 环境可以通过默认和显式配置部分以分层方式配置。
现在,你可以运行`tox`命令来使用`envlist`中列出的环境,它使用`[testenv]`部分进行配置。你也可以运行`tox -e get_my_ip`来使用`get_my_ip`环境,它使用`[testenv:get_my_ip]`部分进行配置。
你可能已经注意到,即使`get_my_ip`环境不需要你的包来执行其活动,它仍然会安装你的包。在未来,你也可能需要管理几个需要相同基础依赖集但每个需要不同附加依赖的环境。tox 为这些情况提供了一些额外的配置选项。
### 6.1.2 在 tox 环境中管理依赖关系
与单元测试执行你的真实代码并需要在环境中安装以运行不同,你进行的某些维护活动超越了代码,并且不需要安装即可成功运行。想象一下,你的维护活动之一是生成变更日志或打印有关项目的某些诊断统计信息。这些活动可能根本不依赖于你的包代码,因此安装包只会妨碍任务的完成。在这些情况下,你可以跳过将你的包安装到相关的 tox 环境中。
当你想要在给定的 tox 环境中跳过安装步骤时,你必须将`skip_install`键添加到该环境的配置部分,并设置值为`True`。你仍然可以安装该环境活动所需的任何额外依赖项,但你的包不会安装到环境中。这提供了速度提升,并且也清楚地表明哪些活动依赖于或不需要安装你的包。
虽然`skip_install`主要是关于减少已安装的依赖项,但你可能也想要在某些环境中安装额外的依赖项,而不污染其他环境。想象一下,你的维护活动之一使用一个工具来验证你的导入语句是否全部有效。用于分析导入的环境应该安装你的包,以便它可以验证导入它的代码。该环境还需要安装分析工具的包。如果你想让工具检查你的测试,它还需要安装你在测试中导入的任何包。你如何在不重复自己的情况下完成这项任务?你将使用一个特殊的 tox 语法,允许你引用其他配置部分和键。
注意:你可以在 tox 文档中找到完整的配置规范([`tox.wiki/en/latest/config.xhtml`](https://tox.wiki/en/latest/config.xhtml))。
假设导入分析工具在一个名为 `shipyard`(船坞是检查导入的地方)的包中。你可以配置 tox 来安装你的包和测试中导入的其他包,但这会导致随着项目增长而出现大量的重复。注意在列表 6.1 中显示的环境的依赖项列表中 `pytest` 和 `requests` 的重复。在具有许多依赖项的大项目中,这种重复可能会增长,导致你“以防万一”将任何新的依赖项添加到每个环境中,因为这样做比确定需要依赖项来运行的具体环境要容易。
列表 6.1 具有相当多重复的依赖项的简单配置
[testenv]
deps =
pytest ❶
pytest-cov ❷
requests ❸
commands =
pytest {posargs}
[testenv:check-imports]
deps =
pytest ❹
requests
shipyard ❺
commands =
python -m shipyard verify
❶ 这是在测试中导入的。
❷ 这没有导入,因此不需要安装来检查导入。
❸ 你可能在代码的各个部分有额外的导入依赖项。
❹ 在简单的配置中,你最终会重复依赖项。
❺ 这仅适用于检查导入的环境。
而不是在每个环境中列举所有依赖项,你可以使用 tox 提取并给依赖项的子集命名。这减少了重复并集中了依赖项列表,使得每次更新依赖项列表时,每个环境更有可能接收到它需要的所有依赖项。要使用 tox 从另一个章节引用配置,你指定完整的章节名称——包括方括号——后跟键名,所有这些都放在大括号中(见图 6.2)。

图 6.2 从 setup.cfg 中的另一个章节引用配置键的 tox 语法
你可以从 `testenv` 环境在 `testenv:check-imports` 环境中引用依赖项列表,这将安装检查代码和测试中导入所需的所有必要依赖项。但这也将安装 `pytest-cov` 包,它没有被导入任何地方,因此有点浪费。为了最大限度地提高你安装的效率,你可以将所需的最小依赖项集分离到自己的命名章节中,并在其他地方引用它。列表 6.2 展示了如何通过提取 `[testimports]` 章节来更新配置。
新的章节包含一个名为 `deps` 的键,其中列出了依赖项,就像之前的章节一样。它只列出了所有其他环境中需要的依赖项。然后,每个章节都使用 `{[testimports]deps}` 引用来引用新章节的依赖项。这使得每个环境需要 `pytest` 和 `requests`,以及每个环境需要额外的、独特的依赖项变得清晰。
列表 6.2 提取和引用命名配置以减少重复
[testimports] ❶
deps =
pytest
requests
[testenv]
deps =
{[testimports]deps} ❷
pytest-cov ❸
commands =
pytest {posargs}
[testenv:check-imports]
deps =
{[testimports]deps} ❹
shipyard
commands =
python -m shipyard verify
❶ 本节列出了在测试中导入的包。
❷ 此环境依赖于在 `testimports` 中列出的相同事项。
❸ 此环境还进一步扩展了依赖项列表。
❹ 从 `testimports` 获取的列表可以在许多地方重用。
此配置比简单方法多几行,但这并不总是正确的。如果你的测试导入了大量包,它们只会在 `[testimports]` 部分列出,其他部分不需要更改。随着你的包随着时间的推移变得更加复杂,你将继续获得节省。
练习 6.2
给定以下配置,以下哪些是正确的?
[tox:tox]
envlist = py39,py310
isolated_build = True
[testimports]
deps =
pytest
requests
[testenv]
deps =
{[testimports]deps}
pytest-cov
commands =
pytest {posargs}
[testenv:myenv]
skip_install = True
deps =
requests
commands =
python -c "print(requests.get('https:/ /canhazip.com').text)"
[testenv:check-imports]
deps =
{[testimports]deps}
shipyard
commands =
python -m shipyard verify
A) 运行 `tox` 命令使用两个环境。
B) 运行 `tox` 命令使用三个环境。
C) 运行 `tox` 命令使用四个环境。
D) `py39` 环境已安装了 `requests` 包。
E) `myenv` 环境已安装了 `requests` 包。
F) `check-imports` 环境已安装了 `requests` 包。
G) `myenv` 环境是唯一一个跳过安装你的包的环境。
H) 将包添加到 `[testimports]deps` 影响三个环境。
I) 将包添加到 `[testimports]deps` 影响四个环境。
现在你已经掌握了在几个 `tox` 环境中管理依赖项的方法,你就可以深入挖掘它用于代码质量任务的应用了。
## 6.2 分析类型安全
Python 是一种 *动态类型语言*——对象的类型在运行时评估,因此对象可能对于预期的操作是错误的类型。此外,Python 有 *鸭子类型*。如果一个对象“看起来像鸭子,叫起来像鸭子,那么它就是鸭子”——也就是说,如果一个对象可以成功执行预期的操作,那么它可以被视为作者意图的操作类型。这种灵活性使得 Python 成为当今最具生产力的语言之一。类型系统大部分时间都不会妨碍你,让你可以专注于快速完成的事情。考虑以下函数,该函数如果传入的参数太长则返回 `True`:
def too_long(some_list):
return len(some_list) > 100
此函数的作者可能希望调用者传入一个列表,但该函数在没有错误的情况下也可以在集合、字典、字符串等上正常工作。实际上,任何定义了 `__len__` 方法的对象都可以在调用 `too_long` 时传入而不会出错。
这种灵活性也带来了挑战。随着代码库的扩展,越来越有可能有人在某个地方以不期望的数据类型调用函数、方法或初始化器。在最坏的情况下,这可能在编写时工作,但后来当你以不再适应意外用例的方式更新被调用的代码时,可能会出错。当你创建一个希望其他人使用的包时,他们对向后兼容性的期望就更加重要了。
类型提示,首次在 2014 年提出,并于 Python 3.5 中添加([`www.python.org/dev/peps/pep-0484/`](https://www.python.org/dev/peps/pep-0484/)),为函数、方法等签名中的类型提供了更强烈建议的方式。有了这些提示,阅读代码库并尝试使用函数的人可以轻松地看到函数作者对其使用的期望。此外,IDE 等工具通常使用这些提示来向用户建议他们可能错误地使用了函数,当使用的类型与提供的类型提示不一致时。您的新同事将从类型提示和类型检查中受益匪浅,尤其是在他们熟悉项目早期。
`too_long`的一个更安全的版本可能明确地将输入参数定义为列表,如下所示:
def too_long(some_list: list) -> bool:
return len(some_list) > 100
或者,如果作者意识到他们确实希望函数对定义了`__len__`方法的任何对象都可用,他们可能会选择指定任何`Sized`类型的输入参数都足够,如下所示:
from typing import Sized
def too_long(some_object: Sized) -> bool:
return len(some_object) > 100
然后集合、字典和字符串将被接受,但整数或浮点数这样的标量值则不行。类型检查工具可以找到调用不匹配调用函数类型提示的情况,并向开发者显示错误以修复调用。
这类*静态分析*,或者不需要执行您的代码的分析,是有帮助的,因为您可以在开发过程中快速频繁地运行它们。它们也可以集成到您的持续集成管道中,您将在第七章中了解更多关于持续集成的内容。
Python 代码质量权威机构
代码质量,特别是无需运行代码即可进行检查的静态分析工具,是一个快速增长的领域。Python 代码质量权威机构(PyCQA)([`pycqa.org`](https://pycqa.org))采用并维护该领域的项目,以确保它们随着 Python 和其他生态系统领域的演变而保持更新。本章中建议的一些工具由 PyCQA 维护。
要检查您的代码的类型提示,您将使用`mypy`包。
### 6.2.1 为类型检查创建 tox 环境
mypy ([`github.com/python/mypy`](https://github.com/python/mypy)) 是可用于验证代码库中类型安全性的多种静态分析工具之一。mypy 可以在没有类型提示的 Python 代码中检测到常见错误,并验证所有调用是否与您或您的任何依赖项添加的类型提示一致。这使得将其添加到现有代码库中的体验更加流畅,因为您可以通过增量添加和检查类型提示来使代码更安全,而不是一次性尝试更新它。
首先,在`setup.cfg`文件中添加一个名为`typecheck`的新 tox 环境部分。此环境需要安装以下内容:
+ 您的包,这样您的代码导入可以被正确地跟踪
+ `pytest`包,以便在测试中正确导入
+ `mypy`包,以便你可以用它来检查类型安全
+ `types-termcolor`包,以便 mypy 能更好地验证你对`termcolor`包的使用
配置环境以运行以下命令:
mypy --ignore-missing-imports {posargs:src test}
这告诉 mypy 尽可能多地跟随导入,忽略它无法分析类型的导入。mypy 默认分析 src/和 test/目录中的所有代码,但如果你想检查项目的一部分,你可以将特定文件作为位置参数传递给`tox`命令。回想一下,你可以使用 tox 的`-e`标志后跟一个 tox 环境名称来仅运行该环境。现在使用以下命令运行环境:
$ tox -e typecheck
命令输出的末尾你应该看到以下类似的内容:
typecheck run-test: commands[0] | mypy --ignore-missing-imports src test
Success: no issues found in 4 source files
_________________________ summary _________________________
typecheck: commands succeeded
congratulations 😃
这确认了你的代码在类型方面目前是安全的。从测试驱动开发的角度来看,你处于“绿色”状态,你可以用它来重构你的代码。
练习 6.3
`src/imppkg/harmony.py`模块中的`main`函数有点长,处理了多个问题,如下所示:
+ 将命令行输入解析为浮点数列表
+ 如果可能,计算调和平均值,否则默认为`0.0`
+ 格式化并打印输出
目前,也没有合适的时机进行类型检查,因为`main`函数不接受任何参数也不返回任何值。将`main`函数的主体拆分为三个具有以下签名的辅助函数:
def _parse_nums(inputs: str) -> list[float]:
...
def _calculate_results(nums: list[float]) -> float:
...
def _format_output(result: float) -> str:
...
然后`main`函数应该使用这三个函数并打印最终结果。你可以使用`termcolor.colored`而不是`termcolor.cprint`来获取格式化文本作为字符串,而不打印它。
完成后,`typecheck`环境仍然应该成功运行。你的单元测试应该保持不变并继续通过。完成后,更改一些类型提示以相互矛盾,并重新运行类型检查以查看其响应;mypy 应该为每个类型矛盾引发错误。
现在你已经让 mypy 工作,你将配置它以获得额外的生产力。
### 6.2.2 配置 mypy
你可以通过在`setup.cfg`文件中添加一个`[mypy]`部分来配置 mypy。mypy 配置文档([`mng.bz/096E`](http://mng.bz/096E))涵盖了广泛的可用配置选项。以下是一些最重要的选项:
+ `python_version`—将此设置为你的包支持的最低 Python 版本。例如,如果你想支持 Python 3.8、3.9 和 3.10,则将其设置为`3.8`。
+ `warn_unused_configs`—将此设置为`True`,以便 mypy 在添加了没有效果的其它配置部分时提醒你。
+ `show_error_context`—将此设置为`True`,以便 mypy 显示它发现问题的代码周围的代码;这有助于在不反复在命令行和文件之间切换的情况下理解问题。
+ `pretty`—将此设置为 `True` 以使 mypy 输出更多易于阅读的错误信息。特别是,mypy 会显示你代码中错误发生的列,这可以帮助你更快地识别出现的问题。
+ `namespace_packages`—将此设置为 `True` 以使 mypy 能够找到更广泛的潜在包配置——即,它找到 PEP 420 中定义的隐式命名空间包([`www.python.org/dev/peps/pep-0420/`](https://www.python.org/dev/peps/pep-0420/))。随着你安装更多可能成为隐式命名空间包的包,这为你的类型检查配置提供了未来保障。
+ `check_untyped_defs`—默认情况下,mypy 只会检查你明确添加了类型注解的项的类型。它可能会错过你忘记添加类型注解并错误使用函数的情况。将此设置为 `True` 以使 mypy 在尽可能多的地方检查类型一致性。
将这些键添加到你的 setup.cfg 文件中的 `[mypy]` 部分。
类型安全案例研究
`urllib3` 包是一个广泛使用的 Python 包;GitHub 的依赖关系图表明,超过 5,000 个其他包依赖于它 ([`mng.bz/K0xg`](http://mng.bz/K0xg))。`urllib3` 团队努力在尽可能广泛和严格的项目中引入类型注解,而不破坏其消费者的代码。他们发现添加类型注解揭示了他们广泛的测试覆盖范围没有发现的错误和设计缺陷。关于他们在野外大型项目中能够识别的不同类型的改进,请参阅 [`mng.bz/82Ez`](http://mng.bz/82Ez)。
到目前为止,你已经设置好了捕捉你包中出现的任何类型相关问题的功能。你还可以帮助使用你包的人确保他们使用正确的类型。为此,在 src/imppkg/ 目录中创建一个空的 py.typed 文件。当这个文件存在于一个项目安装的包的内容中时,它会告诉 mypy 检查该包代码的使用情况,以查找类型相关问题。这扩展了你为任何在其应用程序中使用你工作的人添加的类型安全性。
注意:由于你在第四章中添加到 MANIFEST.in 文件的 `graft` 指令,py.typed 文件将自动包含在你的包中。
接下来,你将创建一个 tox 环境,以自动检查和更新你代码的格式。
替代类型检查器
mypy,最初在 Dropbox 开发,是最广泛使用的类型检查工具之一,但还有许多其他工具。如果你想进一步探索这个领域,可以查看这些各自有相应公司支持的类型检查器:
+ 来自 Microsoft 的 pyright ([`github.com/Microsoft/pyright`](https://github.com/Microsoft/pyright))
+ 来自 Facebook 的 pyre ([`github.com/facebook/pyre-check`](https://github.com/facebook/pyre-check))
+ 来自 Google 的 pytype ([`google.github.io/pytype/`](https://google.github.io/pytype/))
## 6.3 为代码格式化创建 tox 环境
代码被阅读的次数远多于被编写的次数,因此代码应该是*可读的*。通常,一个开发者认为可读的代码并不一定与另一个开发者认为的可读性一致。这可能导致在审查过程中浪费时间讨论缩进级别、换行位置、是否使用单引号或双引号等问题。尽管在某些情况下,给定代码的可读性可能是相当客观的,但即使在这种情况下,你也会在编写代码时浪费时间记住以这种方式格式化代码。PEP 8([`www.python.org/dev/peps/pep-0008/`](https://www.python.org/dev/peps/pep-0008/))为 Python 代码定义了一个风格指南。大多数 Python 代码格式化工具都遵循 PEP 8 中提出的建议,但 PEP 8 并不涵盖 Python 开发者需要做出的所有格式化决策。每个格式化器都引入了自己的附加规则,其中一些可能难以记住并手动处理。
为了减轻这些压力,让自动化过程为你进行格式化。许多开发者使用他们的集成开发环境(IDE)来执行此任务。一些开发者甚至让 IDE 在每次保存文件时自动格式化他们的代码,从而在正在工作的代码上形成一个更紧密的循环。不同的 IDE 之间或使用相同 IDE 但设置不同偏好的两个开发者之间,格式化风格可能不同。使用一致的格式化风格可以确保你的团队的拉取请求不会因为哪个开发者最近更新了代码而不断地包括来回重新格式化代码。
`black` 包([https://black.readthedocs.io/en/stable/](https://black.readthedocs.io/en/stable/))旨在以很少的配置选项对 Python 代码进行统一格式化,以便在格式化后,绝大多数 Python 项目代码都是可读的。它可以自动更新不符合风格的代码,使整个代码库和新代码的重新格式化变得快速。`black` 还更喜欢在更改时产生短差异的代码,例如在多行列表或字典中始终使用尾随逗号。考虑以下将字符串列表赋值给变量的代码:
a = [
"one",
"two",
"three" ❶
]
❶ 最后一个项目没有包含尾随逗号。
因为这段代码在列表的最后一个项目后没有使用尾随逗号,向列表中添加第四个字符串会导致以下差异:
--- before.py ...
+++ after.py ...
@@ -1,5 +1,6 @@
a = [
"one",
"two",
- "three" ❶
- "three", ❷
- "four" ❸
]
❶ 因为添加了逗号,这一行显示为已删除。
❷ 添加的行包含了逗号。
❸ 包含新字符串的新行显示为已添加。
注意到差异显示删除了一行并添加了两行,尽管变化的本质是向列表中添加一个新项目。`black` 倾向于使用尾随逗号,以便差异更忠实地反映这一点。现在考虑在第三个列表项之后包含尾随逗号的情况。当添加带有自己尾随逗号的第四个项目时,差异将看起来像这样:
--- before.py ...
+++ after.py ...
@@ -2,4 +2,5 @@
"one",
"two",
"three", ❶
- "four", ❷
]
❶ 如果存在现有的尾随逗号,则此行保持不变。
❷ 只有新的字符串显示为已添加。
由于代码的标点符号没有改变,diff 现在变得更简单。在修改代码时,这些小的简化可以大大简化团队的审查工作。
`black` 包最好的特性之一是,默认情况下,它确保格式化前后的代码具有相同的*抽象语法树* ([https://docs.python.org/3/library/ast.xhtml](https://docs.python.org/3/library/ast.xhtml))。也就是说,格式化前后的代码在功能上保持等效。你可以有很高的信心,`black` 所做的更改完全是功能性的。
练习 6.4
在你的 setup.cfg 文件中,配置一个新的 tox 环境,称为`format`,用于检查和格式化你的代码。此环境有一个依赖项——`black`包。环境应运行一个命令`black`,使用以下默认选项,并使用你之前学到的 tox 的`posargs`语法:
+ `--check`—检查代码的格式,而不实际更改格式
+ `--diff`—输出`black`格式化代码时所做的更改
+ `src test`—需要检查格式的代码区域
当你完成时,你应该能够运行`tox -e format`来检查你的包代码的格式,并运行`tox -e format src test`来重新格式化代码。运行检查以确定`black`是否发现任何可以更改你的代码格式的机会。如果发现了,请使用该环境自动进行更改。
### 6.3.1 配置 black
你可以使用新创建的`[tool.black]`部分中的 pyproject.toml 文件来配置`black`包。请注意,`black`不支持使用 setup.cfg 文件。如本章前面所述,`black`的配置选项非常少 ([`mng.bz/9V5q`](http://mng.bz/9V5q))。最值得注意的是以下两点:
+ `line-length`—允许的最大行长度,默认为`80`。如果行长度超过这个值,则尽可能将行重新格式化,使其跨越两个或更多行。
+ `target-version`—你的代码应与之兼容的 Python 版本列表。这阻止`black`使用对于你支持的 Python 版本来说太新的语法。
你应该将`line-length`设置为大多数代码中最易读的长度。例如,我更喜欢使用 100 或 120 的行长度,因为 80 的长度在大项目中会导致很多多行换行,而在这些项目中,使用了更长、更具描述性的变量和方法名。
为了保持一致性,`target-version`列表应与您支持的 Python 版本列表相匹配。在第五章中,你指定了一个包含至少两个 Python 版本的`envlist`。这些相同的 Python 版本也应反映在你的`black`配置中。例如,如果你需要支持 Python 3.8 和 3.9,你的`black`配置可能看起来如下:
[tool.black]
line-length = 120
target-version = ["py38", "py39"]
再次运行`format`环境,使用这个新的配置来查看`black`是否应该使用新配置重新格式化任何内容,如果是,则重新格式化代码。
最后,您可以通过跳过包安装步骤来加快`format`环境。因为`black`对您的代码进行静态检查,并不基于`import`语句或其他关于代码功能的知识执行任何工作,所以您可以在 tox 环境中避免安装包。在您之前创建的`[testenv:format]`部分中添加 tox 的`skip_install`键,并将其值设置为`True`以跳过包安装。
替代代码格式化工具
`black`正迅速成为最受欢迎的格式化工具之一,并且已被 Python 软件基金会采用以进行进一步开发。尽管如此,它的主观性和缺乏可配置性并不适合每个人。如果您想进一步探索,请查看这些其他流行的格式化工具:
+ autopep8 ([`github.com/hhatto/autopep8`](https://github.com/hhatto/autopep8))
+ yapf ([`github.com/google/yapf`](https://github.com/google/yapf)),来自谷歌
现在您可以使用单个命令保持代码的一致格式。在下一节中,您将学习如何配置对代码中常见错误的自动检查。
## 6.4 创建用于 linting 的 tox 环境
Python 中的一些错误和多余的代码非常常见。那些可以通过其抽象语法树或其他静态分析检测到的错误可以自动扫描。考虑以下使用空字典作为输入参数默认值的函数,该值在函数体中被更新:
def remove_params(
param_names: list[str],
all_params: dict = {"default_key":
➥ "default_value"} ❶
) -> dict:
for param in param_names:
all_params.pop(param) ❷
return all_params
❶ 如果未提供,则使用默认字典
❷ 无论提供与否,字典都会被更新并返回。
这可能看起来无害,但结果是可变默认参数值是危险的。可变默认参数值在模块导入时初始化一次,并在整个 Python 进程期间保持不变。这意味着您可以调用一次`remove_params(["default_key"])`,从默认字典参数中删除`"default_key"`键。但随后的`remove_params`函数调用将因`KeyError`而失败,因为默认参数不会重新初始化,且`"default_key"`键已经被从字典中删除。
就像代码格式化一样,在 IDE 中检查这类常见问题通常是默认行为,但根据你的 IDE,如果它们目前没有导致运行时异常或测试失败,可能会很容易忽略错误或警告。为了确保这些情况不会被人忽视,你可以在 tox 设置中集成一个进行此类扫描的工具——通常称为 *linting*。Linting 可以保持你的项目没有未使用的代码,例如未使用的导入。Linting 还可以识别那些可能不会立即引起错误或异常,但可能在以后以难以识别的方式引起,比如在长字典中使用重复键。
flake8 包 ([`flake8.pycqa.org/en/latest/`](https://flake8.pycqa.org/en/latest/)) 是 PyCQA 维护的另一个项目,它将几个较小的代码质量工具组合成一个命令行界面。flake8 因其提供强大的覆盖范围且管理起来不繁琐而受到赞誉。flake8 结合了以下工具的力量:
+ pyflakes ([`github.com/PyCQA/pyflakes`](https://github.com/PyCQA/pyflakes))
+ pycodestyle ([`github.com/PyCQA/pycodestyle`](https://github.com/PyCQA/pycodestyle))
+ mccabe ([`github.com/PyCQA/mccabe`](https://github.com/PyCQA/mccabe))
小贴士:`mccabe` 包测量代码复杂度。对我来说,自动化代码复杂度阈值在最好情况下也很棘手。当我确实想测量复杂度时,我喜欢使用 radon ([`radon.readthedocs.io/en/latest/`](https://radon.readthedocs.io/en/latest/)),因为它除了 McCabe 指标外,还测量了更广泛的指标。尽管 flake8 包含它,但你不需要处理 mccabe,除非你想要这样做。
flake8 也构建为基于插件的架构,社区提供了许多对代码行为的扩展。例如,flake8-bugbear ([`github.com/PyCQA/flake8-bugbear`](https://github.com/PyCQA/flake8-bugbear)) 捕获了一些 flake8 默认不捕获的常见问题,例如使用可变默认参数(参见“可变默认参数”,*Real Python*,[`mng.bz/jA28`](http://mng.bz/jA28))。只要安装了 flake8-bugbear,flake8 就会使用 flake8-bugbear 的功能。flake8 和其扩展一起发现了许多常见问题,并以可操作的形式列出。flake8 识别的每个问题都会打印以下信息:
+ 找到错误的文件名
+ 问题出现的文件中的行号和列号
+ 问题错误代码
+ 一个指示出了什么问题的消息,通常提供足够的信息以进行纠正操作
练习 6.5
在你的 setup.cfg 文件中,为你的项目代码配置一个新的 tox 环境,称为`lint`,用于代码风格检查。此环境有两个依赖项——`flake8`和`flake8-bugbear`包。该环境应运行一个命令`flake8`,使用你之前学到的 tox 的`posargs`语法,以下为默认选项:`src test`。这将指示 flake8 要检查哪些代码区域。
当你完成时,你应该能够运行`tox -e lint`来对你的包代码进行代码风格检查。运行检查以确定 flake8 是否发现了你的代码中的任何问题。如果你看到与行长度相关的问题,在修复之前继续到下一节。修复 flake8 识别出的任何其他问题。
### 6.4.1 配置 flake8
你可以使用 setup.cfg 文件中的新`[flake8]`部分来配置 flake8。flake8 的主要配置选项([`mng.bz/WMxl`](http://mng.bz/WMxl))包括对它执行的一些检查进行微调,以及完全忽略一些检查。你应该从运行所有检查并学习其中哪些对你来说没有价值开始。
目前,首先确保 flake8 配置为允许与`black`设置相同的最大行长度。`black`的选项是`line-length`,而 flake8 的选项是`max-line-length`。一旦你配置了行长度,再次运行 flake8 并确保它没有发现任何剩余的问题。
其他代码风格检查工具
你可能会发现 flake8 正好适合你的需求,但它可以稍作扩展以满足你的需求。如果你在寻找更加强大、不那么干扰你的工具,或者只是想尝试不同的工具,你可以查看以下代码风格检查工具:
+ pylint ([`github.com/PyCQA/pylint/`](https://github.com/PyCQA/pylint/))
+ prospector ([`github.com/PyCQA/prospector`](https://github.com/PyCQA/prospector))
+ bandit ([`github.com/PyCQA/bandit`](https://github.com/PyCQA/bandit)),它专注于安全性
+ vulture ([`github.com/jendrikseipp/vulture`](https://github.com/jendrikseipp/vulture)),它专注于清理未使用的代码
你也可以单独使用 pyflakes、pycodestyle 或 mccabe。
通过在单元测试中增加代码风格检查,你可以开发出高度自信的代码,确保你的代码按照预期运行,而不会出现任何潜伏的问题等待在后续阶段跳出来。
如果你已经到达这里,你已经成功配置了 tox 环境来检查代码的类型、格式和代码风格。尽管你已经放置了一些重要的配置,为你提供了一个执行静态分析的一致方式,但你可能会发现定期运行这些检查仍然很繁琐。每次对你的代码进行更改时,你都需要运行以下命令来完全执行你构建的所有检查:
+ 使用`tox`来测试代码
+ 使用`tox -e typecheck`来检查代码的类型
+ 使用`tox -e format`和有时使用`tox -e format src test`来格式化代码
+ 使用`tox -e lint`来检查代码中的常见错误
你可以通过使用以下命令之类的命令并行运行所有命令来加快这个过程:
$ tox -p -e py39,py310,typecheck,format,lint
但即使这样也可能很繁琐,你有时可能还是会忘记一个步骤。持续集成实践通过在每个更改上运行重要检查来帮助解决这些差距。当你准备好时,继续到下一章。
## 练习答案
**6.1**
[testenv:get_my_ip]
deps =
requests
commands =
python -c "import requests; print(requests.get('https:/ /canhazip.com').text)"
**6.2**—答案:A, D, E, F, G, I
+ (A), B, 和 C: tox 默认使用 `envlist` 中的环境。`envlist` 列出了两个环境。
+ (D): 是的,因为它在 `[testimports]deps` 中,而 `[testenv]deps` 扩展了它,并且 `py39` 使用 `[testenv]` 进行配置。
+ (E): 是的,因为它被明确列出。
+ (F): 是的,因为它在 `[testimports]deps` 中,而 `[testenv:check-imports] deps` 扩展了它。
+ (G): 是的,因为安装总是发生,除非你明确选择退出。
+ H 和 (I): 总共有四个环境——`envlist` 中的两个环境(使用 `[testenv]deps`,它扩展了 `[testimports]deps`),`myenv` 环境,以及 `check-imports` 环境。`myenv` 环境不引用 `[testimports]deps`,因此不受影响,总共影响三个环境。
**6.3**—答案:参见本章的代码伴侣。
**6.4**—答案:参见本章的代码伴侣。
**6.5**—答案:参见本章的代码伴侣。
## 摘要
+ 虽然通常用于测试,但 tox 是一个多用途的生产力任务管理工具。
+ 类型检查可以增加对代码预期接口被正确使用的信心。
+ 自动化或消除与您想要交付的核心价值无关的决策。
+ 利用代码检查工具来查找单元测试可能未发现的常见问题。
# 7 通过持续集成自动化工作
本章涵盖
+ 使用 GitHub Actions 在每次更改上自动化代码质量检查
+ 为各种平台构建发行版
+ 发布到 PyPI 的发行版
在前几章的过程中,你已经建立了一套任务库,每次更改你的包时都会执行这些任务,以便你可以保持功能性和代码质量。这是在建立对更改的信心方面迈出的巨大一步,但像你之前在与 CarCorp 团队互动时所看到的那样,在个人电脑上本地执行所有这些操作仍然是一个很大的限制。你可能难以记住验证更改所需的所有步骤,而对于刚开始参与项目的人来说,这可能更加困难。即使他们进行了尽职调查,除非你在监督他们,否则你无法直接验证他们在本地运行的命令。对于只有几个人的团队来说,这已经足够困难,但在开源世界中,你可能甚至不知道贡献代码更改的人是谁。
在本章中,你将为你的包创建一个流水线,将自动化引入包装过程的几乎所有方面——当然,除了编写代码之外。在深入设置此流水线的细节之前,你首先需要了解其高级流程。
重要 你可以使用代码伴侣([`mng.bz/69A5`](http://mng.bz/69A5))来检查本章练习中的工作。
## 7.1 持续集成工作流程
想象一下,你已经将几位新开发者纳入你的项目,以继续承担新的车辆客户。你的团队已经花了几个星期的时间为下一个版本的包做准备,你今天早上终于发布了新版本。当你的团队庆祝时,你手机的持续振动让你有一种不祥的预感。结果证明,在发布前进行最后更改的开发者忘记运行单元测试,最后一个更改破坏了核心功能的一部分。
你需要一个系统,可以在一个每个人都可以确认其状态的环境中自动运行你在每个更改上开发的宝贵检查。这些*持续集成*系统是随着项目的发展在生产力方面和对你项目的信心方面迈出的另一个重大步伐。
定义 持续集成(CI)是将更改尽可能频繁地纳入项目的主干开发流程的实践,以最大限度地减少出现与预期或期望行为不一致的行为的可能性。持续集成与大型软件项目早期实践截然相反,在合并和发布之前,开发可能持续数月或数年。持续集成鼓励小而渐进的更改,目的是更早、更频繁地交付价值。
要深入了解持续集成,请参阅 Christie Wilson 所著的*Grokking Continuous Delivery*(Manning,2022,[`mng.bz/82M5`](http://mng.bz/82M5))和 Mohamed Labouardy 所著的*Pipeline as Code*(Manning,2021,[`www.manning.com/books/pipeline-as-code`](https://www.manning.com/books/pipeline-as-code))。
大多数持续集成工作流程都包含相同的基本步骤,如图 7.1 所示。自动的*构建*和*测试*步骤是您当前流程中的空白。

图 7.1 基本的持续集成工作流程为开发者提供了一个关于他们更改的自动反馈循环。
由于自动构建和测试步骤是在共享位置执行的,因此您和您的团队能够验证给定的更改是否按预期工作,无论更改作者在本地执行了哪些测试步骤。这是一个关键的转变:本地测试现在可以专注于快速迭代中编写新测试或更新现有测试,而运行完整测试套件则成为可选的便利。开发者可以根据他们当前的能力在实施过程中有选择,而不是被迫以非常具体的方式行事。
现在您已经熟悉了持续集成的基本流程,您就可以开始使用免费工具构建一个了。
## 7.2 使用 GitHub Actions 进行持续集成
在合并任何新代码之前,您决定每个对项目的更改都应该通过共享环境中的 CI 管道进行验证、记录和发布。这消除了由于某人本地配置引起的任何变化,并防止了有人从他们的计算机发布一个永远不会被整合到代码库中的软件包版本的情况。因为您的团队一直在使用 GitHub 托管代码库并协作更改,您决定尝试 GitHub Actions ([`github.com/features/actions`](https://github.com/features/actions))。
其他持续集成解决方案
虽然我选择在本书中介绍 GitHub Actions,但它只是众多选项中的一个。大多数持续集成解决方案在概念上都有很强的重叠,因此学习不同的平台通常只是理解它们的特定术语的问题。
一些广泛使用的以云为先的 CI 解决方案如下:
+ GitLab CI/CD ([`docs.gitlab.com/ee/ci/`](https://docs.gitlab.com/ee/ci/))
+ CircleCI ([`circleci.com/`](https://circleci.com/))
+ Azure DevOps ([`azure.microsoft.com/en-us/services/devops`](https://azure.microsoft.com/en-us/services/devops))
+ Google Cloud Build ([`cloud.google.com/build`](https://cloud.google.com/build))
如果它与您在个人或组织工作中选择的现有云服务提供商相匹配,选择其中之一可能是有用的。Jenkins ([`www.jenkins.io/`](https://www.jenkins.io/)) 是一个开源解决方案,通常需要您付出更多努力,但如果您想要完全端到端控制,它可能是个不错的选择。
我强烈建议远离 Travis CI,并且在这里不会提供链接。尽管它曾经是开源项目中最受欢迎的平台之一,但自从 2019 年被收购以来,它经历了功能发展缓慢、沟通不佳、安全问题以及对付费计划的推动。
要有效地使用 GitHub Actions,您需要了解以下部分中的高级工作流程、GitHub Actions 特定的术语和配置格式。
### 7.2.1 高级 GitHub Actions 工作流程
在您的新管道中,每次您打开一个拉取请求或向 GitHub 推送新的提交时,CI 管道都会从您的分支检出代码,并并行执行以下操作:
+ 使用`black`和`format` tox 环境检查代码格式
+ 使用 flake8 和`lint` tox 环境进行代码格式检查
+ 使用 mypy 和`typecheck` tox 环境进行类型检查
+ 使用 pytest 和默认 tox 环境进行单元测试
+ 使用`build`构建源发行版
+ 使用`build`和 cibuildwheel 构建二进制 wheel 发行版(关于这一点将在本章后面详细介绍)
每次您标记一个提交时,管道还会将发行版发布到 PyPI。图 7.2 以高级别描述了此流程。

图 7.2 使用 GitHub Actions 进行 Python 打包的持续集成管道流程
您正在将所有测试和代码质量工作锁定到一个自动化管道中。在未来,如果您更改了其中一个 tox 环境的工作方式或添加了新的检查类型,您也可以将它们添加到您的管道中。随着您创建的新流程,这种投资将带来回报。
### 7.2.2 理解 GitHub Actions 术语
您需要使用以下 GitHub Actions 概念来构建您的 CI 管道:
+ *工作流程*—CI 管道的最高粒度级别。您可以根据不同的事件创建多个工作流程。
+ *作业*—您为工作流程定义的一个高级阶段,例如构建或测试某些内容。
+ *步骤*—您在作业中定义的特定任务,通常由单个 shell 命令组成。步骤还可以引用其他预定义的操作,这在构建基于常见任务(如检出您的代码)时非常有用。
+ *触发器*—导致工作流程发生的事件或活动。即使工作流程被触发,您也可以使用表达式有条件地跳过该工作流程中的作业。
+ *表达式*—一组 GitHub 特定的条件和值之一,您可以使用它来检查并控制您的 CI 管道。
目前,您只需要一个包含多个作业的工作流程,其中一些作业根据触发事件有条件地运行。每个作业都有几个类似的步骤来安装依赖项和工具,并最终运行任务。工作流程由您创建的拉取请求和标签触发。图 7.3 展示了您之前看到的相同的 CI 管道,这次指出了这些不同的移动部分如何映射到 GitHub Actions 概念。

图 7.3 连续集成管道的不同部分如何映射到 GitHub Actions 概念
深入理解 GitHub Actions
教授 GitHub Actions 提供的所有内容超出了本书的范围,但如果您想探索更多功能,可以参考 GitHub 的学习材料 ([`mng.bz/E0WX`](http://mng.bz/E0WX))。
拥有这些术语后,您就可以开始构建您的包的 GitHub Actions 工作流程了。
重要提示:如果您还没有这样做,现在是时候将您的项目纳入 Git 仓库的版本控制,并将其推送到 GitHub。如果您不熟悉 Git 或 GitHub,请在此处暂停,并花些时间熟悉它们。他们的文档 ([`mng.bz/N56v`](http://mng.bz/N56v)) 和 Mike McQuaid 的《Git in Practice》(Manning,2014,[`www.manning.com/books/git-in-practice`](https://www.manning.com/books/git-in-practice))是很好的资源。
### 7.2.3 开始配置 GitHub Actions 工作流程
您可以使用 YAML ([`yaml.org/`](https://yaml.org/)) 配置 GitHub Actions 工作流程。对于您的工作流程,您可以使用单个 YAML 文件来指定作业和步骤。首先,在您的仓库中创建一个新的分支。如果项目根目录中尚不存在 .github/ 目录,请创建一个。在 .github/ 目录内,创建一个名为 workflows/ 的新目录。GitHub 会自动在 .github/workflows/ 目录中查找具有 .yml 扩展名的文件,并期望它们是有效的流程定义。
您可以为工作流程配置文件几乎取任何您喜欢的名字,但当一个项目只有一个工作流程配置时,使用 main.yml 作为名称是一种常见的做法。您还可以使用一个表明工作流程目的的名称,例如 packaging.yml。现在在 .github/workflows/ 目录中创建一个空的配置文件。
每个 GitHub Actions 工作流程都必须至少包含以下几个字段:
+ `name`—在 GitHub 界面的一些部分显示的友好字符串
+ `on`—触发工作流程的一个或多个事件列表
+ `jobs`—要执行的一个或多个作业的映射
相应地,作业必须至少包含以下几个字段:
+ *关键字*—一个机器可读的字符串,用于在管道的其他地方引用作业。通常这是作业名称的一个版本,只使用字母和连字符。
+ `name`—在 GitHub 界面的一些部分显示的友好字符串。
+ `runs-on`—用于作业的 GitHub Actions 运行器类型。就你的用途而言,`ubuntu-latest`表现良好。你可以在`runs-on`文档中查看所有可用的运行器([`mng.bz/PnKP`](http://mng.bz/PnKP))。
+ `steps`—要执行的一个或多个步骤的列表。
最后,一个步骤可能以下列两种格式之一:
+ 对预定义操作的引用,例如 GitHub 或第三方提供的官方 checkout 操作([`github.com/actions/checkout`](https://github.com/actions/checkout))。此格式指定一个`uses`键,其值引用操作的 GitHub 存储库,以及一个可选的版本字符串,由一个`@`字符分隔。
+ 一个用于在 GitHub 界面的一些部分显示的人类友好`name`字符串,以及一个指定要运行的命令的`run`字段。
下面的列表显示了这些部分如何组合成一个示例工作流程配置。
列表 7.1 一个示例 GitHub Actions 工作流程。
name: My first workflow ❶
on: ❷
- push
jobs: ❸
say-hello: ❹
name: Say Hello ❺
runs-on: ubuntu-latest ❻
steps: ❼
- uses: actions/checkout@v3 ❽
- name: Say Hello ❾
run: echo "Hello"
❶ 工作流程的人类友好名称。
❷ 工作流程由推送的代码和标签触发。
❸ 工作流程的作业。
❹ 作业的机器可读键。
❺ 作业的人类友好名称。
❻ 作业使用最新的基于 Ubuntu 的运行器。
❼ 作业的步骤。
❽ 使用官方 checkout 操作检出代码。
❾ 运行具有自定义名称和命令的步骤。
当运行时,工作流程检出触发工作流程的分支或标签的代码,然后运行一个`echo`命令来问候。如果触发推送事件是拉取请求,GitHub Actions 在该拉取请求页面的底部报告挂起状态(见图 7.4)。

图 7.4 在拉取请求底部显示的挂起 GitHub Actions 工作流程。
工作流程完成后,GitHub Actions 在拉取请求页面上显示完成状态(见图 7.5)。

图 7.5 在拉取请求上成功完成的 GitHub Actions 工作流程。
你可以点击工作流程作业上的“详情”链接来查看单个步骤的输出(如图 7.6 所示)。你还可以在你的存储库的操作选项卡中找到所有以前的工作流程运行。GitHub Actions 在你定义的步骤之前和之后执行一些自己的步骤。

图 7.6 GitHub Actions 工作流程作业的详细步骤和输出。一些步骤是用户定义的,而另一些是 GitHub Actions 内置的。
你可以点击步骤的名称来展开并查看其输出,这有助于更好地理解 GitHub 或第三方提供的行为(如图 7.7 所示)。

图 7.7 官方 checkout 操作的输出显示了从触发分支或标签检出代码所涉及的所有步骤。
你还可以使用输出以确认或调试你创建的步骤,例如确保记录的值是你预期的(如图 7.8 所示)。

图 7.8 在工作流程作业步骤中指定的命令及其输出显示。
当工作流程失败时,浏览 GitHub Actions 界面的不同级别对于发现如何修复失败特别重要。这些区域是你将看到失败的单元测试和关于代码格式不正确或其他代码质量问题的消息,这些问题是由你的工具发现的。
练习 7.1
在你的仓库的一个新分支上,执行以下操作:
1. 如果你的项目根目录下还没有 .github/ 目录,请创建一个。
1. 在 .github/ 目录内,创建一个名为 workflows/ 的新目录。
1. 在 .github/workflows/ 目录中创建一个用于工作流程配置的 YAML 文件。你可以根据喜好命名你的工作流程配置文件,但当一个项目只有一个工作流程配置时,使用 main.yml 作为名称是一种常见的做法。你也可以使用一个表明工作流程目的的名称,例如 packaging.yml。
1. 在你的工作流程文件中,添加列表 7.1 中的示例 YAML。
1. 将你的更改提交并推送到 GitHub。
1. 提交一个 pull request。
完成这些步骤后,你应该会看到 GitHub Actions 在你的 pull request 上触发了工作流程。确认工作流程成功执行并执行了你定义的步骤。将 `echo` 命令更改为新的字符串,并推送一个新的提交。工作流程应该再次触发,并且输出应该反映你的更新字符串。
现在你已经创建了一个工作的工作流程,你可以添加你的实际任务到其中。
## 7.3 将手动任务转换为 GitHub Actions
之前你学习了 GitHub Actions 中持续集成的整体流程。稍微放大一点,你现在的重点是工作流程需要执行的具体作业和步骤。其中一些对应于你在第五章和第六章中创建的 tox 环境。大多数这些作业也可以并行运行;唯一的例外是发布作业,它应该在所有其他作业成功后继续进行,以确保只有经过验证的更改被发布。图 7.9 重复了你需要实现的内容。

图 7.9 Python 打包工作流程的作业。每个作业都有类似的步骤集。
你需要更新你的工作流程配置,为 `name` 使用更清晰的值,删除 `say-hello` 作业,并添加真实作业。将工作流程重命名为类似 `Packaging` 的名称,并现在删除 `say-hello` 作业。要添加新作业,从检查代码格式的作业开始。这个作业需要执行以下操作:
1. 使用 `actions/checkout@v3` 动作检查代码。
1. 使用 `actions/setup-python@v4.0.0` 动作设置你的包支持的最新版本的 Python。你可以使用 `with` 键指定 Python 版本,并在其下方指定 `python-version` 键的值。务必将 Python 版本用引号括起来;否则,YAML 会将版本解释为浮点数。
1. 安装 tox。通过使用 `setup-python` 动作,你请求的 Python 版本将作为步骤的 `run` 值中的 `python` 命令可用。
1. 使用 tox 运行适合该作业的环境。在这种情况下,你将使用 `format` tox 环境。
注意:你可能还会注意到代码伴侣([`mng.bz/69A5`](http://mng.bz/69A5))在某些步骤中也包括了一个 `working-directory` 键,但这仅仅是因为包目录不在 Git 仓库的根目录中。如果你的包在仓库的根目录中——如果你一直密切跟随这本书——你应该省略 `working-directory` 键。
现在添加新的作业,完成后返回这里。确保使用各自的 `name` 键给你的作业和自定义步骤提供人性化的名称。你的工作流程文件应该看起来像以下列表。
列表 7.2 用于检查 Python 代码格式的 GitHub Actions 作业
name: Packaging ❶
on:
- push
jobs:
format: ❷
name: Check formatting ❸
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4.0.0 ❹
with:
python-version: "3.10"
- name: Install tox ❺
run: python -m pip install tox
- name: Run black ❻
run: tox -e format
❶ 确保工作流程名称始终反映其目的
❷ 一个用于检查代码格式的作业
❸ 确保作业名称也反映其目的
❹ 安装所需的 Python 版本
❺ 在安装的 Python 中安装 tox
❻ 使用 tox 运行格式环境
添加了新的作业后,提交你的更改并将它们推送到你的 GitHub 分支。工作流程将再次触发,如果你的格式检查在本地通过,你应该期待它们在 GitHub 上也能通过。
练习 7.2
检查代码格式化和类型检查的作业看起来几乎与检查代码格式的作业相同;只有 tox 环境和 `name` 值应该不同。添加一个用于代码检查的作业和一个用于类型检查的作业。
记住默认情况下多个作业是并行运行的。在你推送更改以添加这些作业后,你应该看到你的三个作业并行运行。
你现在已经设置了所有的代码质量检查。你应该会感到一种平静的感觉涌过。在它完全占据之前,你需要在测试和 wheel 构建作业工作之前学习一些额外的 GitHub Actions 功能。
### 7.3.1 使用构建矩阵多次运行作业
从第五章中记住,tox 可以创建一个构建矩阵来跨多个配置运行你的测试。GitHub Actions 提供了一个相当类似的功能。你可以将 GitHub Actions 的矩阵功能与 tox 的矩阵功能结合使用,为特定的 tox 测试环境安装适当的 Python 版本。
注意:你也可以使用不同的作业来实现这一点,但与 tox 类似,使用矩阵功能可以节省你大量的重复手动配置,尤其是在你需要支持许多配置变体时。
您告诉 GitHub Actions,特定作业应该运行几个组合,使用 `strategy.matrix` 键。`strategy.matrix` 键内的每个键都可以有您选择的名称,并代表矩阵展开中选择的一组。每个键的值是一个映射列表,每个映射提供将替换到特定作业实例中的变量。
例如,如果您在 `strategy.matrix` 中定义了四个键,并且每个键都有一个包含四个变量映射的列表,那么矩阵将有 16 种组合,作业将在这些 16 个变体中运行。您可以使用 GitHub Actions 上下文中的 `matrix` 值([`mng.bz/DDgA`](http://mng.bz/DDgA))引用变量替换。下一个列表显示了定义具有矩阵的作业的语法示例。对于完整的参考,您还可以参考 GitHub 文档([`mng.bz/J2pv`](http://mng.bz/J2pv))。
列表 7.3 使用矩阵构建策略的 GitHub Actions 作业
test-color-a11y:
name: Test color accessibility
runs-on: ubuntu-latest
strategy:
matrix:
text-color: ❶
- value: "#000000" ❷
- value: "#33A5F3"
- value: "#59FFE9"
- value: "#999999"
background-color: ❸
- value: "#000000"
- value: "#336633"
- value: "#989A5F"
standard:
- name: "WCAG" ❹
level: "AA"
- name: "WCAG"
level: "AAA"
steps:
- name: Check accessibility ❺
run: |
echo Checking ${{ matrix.text-color.value }} ❻
on ${{ matrix.background-color.value }}
for ${{ matrix.standard.name }}
level ${{ matrix.standard.level }}
❶ 文本颜色是矩阵的一个因素。
❷ `text-color.value` 变量有四个选项。
❸ 背景颜色是另一个矩阵因素。
❹ 每个选项可以有多个变量。
❺ 这个步骤将为 24 种可能的组合中的每一个运行。
❻ 每个矩阵因子的值都在上下文中可用。
因为您使用 tox 在多个 Python 版本上运行单元测试,所以您需要的主要矩阵因子是 Python 版本。但是 tox 用于测试环境的字符串与 Python 版本字符串不同,因此您需要分别指定这两个值。您可以在配置中使用单个 `python` 矩阵因子来模拟这一点,其中每个选项都包含一个 `version` 和 `toxenv` 变量。然后,在您的作业中,您可以在 `actions/setup-python@v4.0.0` 动作中引用 Python 版本的 `matrix` 上下文值,并在最后一步中仅运行相关的 tox 环境。
现在将用于单元测试代码的新作业添加到您的流程文件中,完成后返回此处。它应该看起来像以下列表。
列表 7.4 运行不同 Python 版本和 tox 环境的作业
...
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
python: ❶
- version: "3.10" ❷
toxenv: "py310" ❸
- version: "3.9"
toxenv: "py39"
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4.0.0
with:
python-version: ${{ matrix.python.version }} ❹
- name: Install tox
run: python -m pip install tox
- name: Run pytest
run: tox -e ${{ matrix.python.toxenv }} ❺
❶ Python 版本和 tox 环境的因素
❷ 用于特定作业的 Python 版本
❸ 为特定作业使用的 tox 环境名称
❹ 对特定作业上下文值的引用
❺ 另一个对特定作业上下文值的引用
提交并推送您的更改。GitHub Actions 将在您的拉取请求中单独显示每个作业的状态(见图 7.10)。

图 7.10 GitHub Actions 构建矩阵中的作业拉取请求反馈
在“详细信息”视图中,您可以看到它将矩阵中的相关作业分组在一起(见图 7.11)。

图 7.11 在“操作”选项卡上的详细视图中的 GitHub Actions 构建矩阵
你可以通过点击汇总摘要(见图 7.12)来展开它们,以查看每个单独的任务。

图 7.12 展开的构建矩阵,显示了“操作”选项卡中的每个单独的任务
除了代码质量检查之外,你的单元测试也在你的持续集成管道中完全自动化。现在你可以转向构建你包发行版的作业。
### 7.3.2 为各种平台构建 Python 包发行版
你在第四章学习了如何使用除 Python 之外的语言构建扩展。你也了解到,与纯 Python 包不同,带有非 Python 扩展的包需要以源代码的形式分发,用户必须自行构建,或者作为适用于许多不同平台的二进制发行版。而构建所有这些不同的发行版既繁琐,在某些情况下在本地机器上甚至不可能完成,但对于在多种操作系统和架构上运行的 CI 解决方案,这成为了一些额外的配置问题。
为了在广泛的目标平台上构建二进制 wheel 发行版,你可以使用 PyPA 的出色工具 cibuildwheel ([`github.com/pypa/cibuildwheel`](https://github.com/pypa/cibuildwheel))。此工具旨在作为在尽可能多的平台上构建 wheels 的便捷方式。截至本文撰写时,cibuildwheel 在 GitHub Actions 上的支持比其他流行的持续集成解决方案更广泛。
你需要创建一个与之前创建的任务非常相似的作业,只有几个关键的区别,如下所示:
+ 安装`cibuildwheel`包而不是`tox`包。
+ 使用 cibuildwheel 而不是 tox 环境,运行一个命令。
+ 使用 `actions/upload-artifact@v3` 动作([`github.com/actions/upload-artifact`](https://github.com/actions/upload-artifact))在需要时存储由 cibuildwheel 创建的文件,以便发布。
你可以使用 Python 作为模块运行 cibuildwheel。你可以使用 `--output-dir` 标志传递一个目录,将构建的 wheels 放入其中。以下命令构建 wheels 并将它们放入 `wheels/` 目录:
$ python -m cibuildwheel --output-dir wheels
当你有文件想要上传到 GitHub Actions 作为后续使用的工件时,你可以使用 `with.path` 键将上传文件的 glob 模式传递给 `actions/upload-artifact@v3` 动作。以下示例上传了 wheels/ 目录中所有扩展名为 .whl 的文件:
...
- uses: actions/upload-artifact@v3
with:
path: ./wheels/*.whl
练习 7.3
在你的工作流程文件中添加一个用于构建轮子的新任务,该任务执行以下操作:
1. 使用构建矩阵和 `runs-on` 键在 `ubuntu-20.04`、`windows-2019` 和 `macOS-10.15` 上运行作业
1. 使用 cibuildwheel 在 wheels/ 目录下构建 wheels
1. 使用 `actions/upload-artifact@v3` 动作
如果您需要检查您的作业,请参考代码伴侣 ([`mng.bz/69A5`](http://mng.bz/69A5))。提交并推送您的更改以确认轮子构建成功。请注意,由于 cibuildwheel 需要执行的工作量,这些作业可能比测试和代码质量检查长得多。
尽管二进制轮分发需要大量的工作,但您只需要构建单个源分发。您可以通过使用第三章中介绍过的 `build` 工具来完成此操作。您可以使用 Python 运行 `build` 作为模块,并使用 `--sdist` 标志告诉它构建源分发,默认情况下它会将分发构建到 dist/ 目录。
练习 7.4
在您的流程文件中添加一个新的作业,用于构建源分发,该作业执行以下操作:
1. 安装 `build` 包
1. 运行 `build` 命令以在 dist/ 目录中创建源分发
1. 使用 `actions/upload-artifact@v3` 动作上传 dist/ 目录中的所有 .tar.gz 文件以供以后使用
如果您需要检查您的作业,请参考代码伴侣 ([`mng.bz/69A5`](http://mng.bz/69A5))。提交并推送您的更改以确认源分发构建成功。
您现在已经为包装工作流程中到目前为止学习的每一个活动实现了自动化。有了这些检查,您可以有信心,提交给项目的每个更改都将通过审查。您甚至可以不费吹灰之力就做到这一点,因为 GitHub Actions 将向更改的作者在拉取请求中提供反馈,让他们知道如果出现错误需要修复。您和您的团队甚至可以开始开发一个基于假设的开发模型。您可以在本地运行与您的更改直接相关的测试子集,假设完整套件将通过,然后查看完整套件检查的状态是否确认了您的预期。这是一个高度高效的位置,甚至可以带来一些信心带来的兴奋感。最后要自动化的步骤是发布包。
## 7.4 发布包
这本书的名称是《发布 Python 包》,因此您可能想知道它何时真正涉及到发布方面。尽管为此做了大量的准备工作,但这是为了学习概念,以便您能够对替代工具做出反应,在过程中调试问题,并自信地探索包装领域。我为你们走到这一步感到自豪,也希望你们自己也是如此!
重要:在您可以将包发布到 PyPI 之前,您需要一个 PyPI 用户账户。如果您还没有账户,请现在访问注册页面 ([`pypi.org/account/register/`](https://pypi.org/account/register/)) 创建一个账户。
在您能够正确自动化发布您的包之前,您首先需要通过手动上传您的包来“声明”您想在 PyPI 上使用的包名。如果您仔细遵循本书中的练习,我强烈建议您使用`pubpypack-harmony-<firstname>-<lastname>`格式为您的包命名,这样您就不会在 PyPI 上为您的练习包耗尽好的包名。现在更新您的 setup.cfg 文件中的名称字段以使用此格式。您还应该通过从 PyPI 主页([`pypi.org`](https://pypi.org))或访问项目 URL(https://pypi.org/project/pubpypack-harmony-<firstname>-<lastname>)来检查是否存在具有该名称的包,以防您与另一位读者同名。这也有助于我更容易地找到您的所有成功!我的版本位于[`pypi.org/project/pubpypack-harmony-dane-hillard`](https://pypi.org/project/pubpypack-harmony-dane-hillard)。
提示:您也可以在测试 PyPI 实例([`test.pypi.org/`](https://test.pypi.org/))上执行所有这些相同的步骤,这在尝试在实时实例上执行之前尝试新事物非常有帮助。如果您决定这样做,您需要创建一个单独的账户以及针对测试实例的任何其他凭证。
在您确定包名之后,您可以使用 twine ([`twine.readthedocs.io/en/stable/`](https://twine.readthedocs.io/en/stable/))工具发布您的包。为此,您需要准备好您的 PyPI 用户名和密码。准备好后,从您包的根目录运行以下命令以创建源分布并将其上传到 PyPI。您将被提示输入您的 PyPI 凭证:
$ pipx install twine
$ pyproject-build --sdist
$ twine upload dist/*
提示:您可以使用 twine 创建一个用于上传包的 tox 环境。这在进行调试时反复运行问题非常有帮助,如果您使用的是需要指定非标准仓库 URL 的私有包仓库(如 Artifactory),则特别有用。
在您成功上传包之后,它将与您的账户关联。这允许您为该包创建一个特定的 API 令牌,这对于自动化目的非常有用,因为您不需要直接使用您的个人用户名和密码。现在按照以下步骤创建一个针对您的包的 API 令牌,如图 7.13 所示:
1. 访问 API 令牌创建页面([`pypi.org/manage/account/token/`](https://pypi.org/manage/account/token/))。
1. 给令牌起一个您能识别的名字,例如`pubpypack`。
1. 从范围下拉菜单中选择项目:pubpypack-harmony-<firstname>-<lastname>。
1. 点击添加令牌。

图 7.13 向 PyPI 账户添加特定项目 API 令牌的界面
添加令牌后,你将看到一个包含令牌内容的页面(如图 7.14 所示)。你应该将此令牌复制到安全的地方保存,因为你可以只访问它这一次。你总是可以稍后生成一个新的,但如果丢失了令牌,你可能需要根据你使用它的地方更新它。

图 7.14 显示一次性页面,展示新添加的 PyPI API 令牌。
现在,你需要将新创建的 PyPI API 令牌作为*秘密*添加到你的 GitHub 仓库中。秘密是敏感信息,GitHub Actions 会对其进行加密存储。它们可以注入到 GitHub Actions 中,但任何人无法直接查看。按照以下步骤添加你的令牌,从你的仓库的 GitHub 页面开始:
1. 点击设置。
1. 在左下角点击秘密。确保你最终到达的是动作秘密页面,这是截至本文写作时的默认设置。
1. 在右上角点击“新建仓库秘密”。
1. 将秘密命名为`PYPI_API_TOKEN`。
1. 将从 PyPI 保存的 API 令牌的值粘贴进去。
1. 点击“添加秘密”。
添加秘密后,你应该在仓库秘密表中看到它(如图 7.15 所示)。

图 7.15 显示添加的秘密的 GitHub Actions 秘密界面
你可以从你的作业中的`secret`上下文中引用`PYPI_API_TOKEN`秘密。现在你已经设置了所有凭证,可以自动化你的包发布了。
而到目前为止,你添加到工作流程中的所有检查、测试和分发构建都是直接由推送的提交和标签触发的,但发布步骤是你希望限制其定期运行的。例如,你可能不希望每次将新提交推送到你的仓库时都发布新版本的包,尤其是当分支来自不可信的作者时。有恶意意图的人可能会提交一个包含安全漏洞的拉取请求,并利用你的管道发布该代码。表示 Git 历史中里程碑的标签是发布包版本的一个常见触发事件,它还允许你非常谨慎地选择从代码历史中哪个确切时刻发布版本。为了将发布作业限制为仅使用标签,你需要使用一个表达式来检查正确的条件。如果条件不满足,GitHub Actions 将跳过作业。
你的发布作业必须完成以下所有操作:
1. 等待所有其他作业完成。你不希望发布失败的检查。
1. 仅当触发事件的 Git 引用是一个以`v`开头的标签时才运行,使用`if`键和`startsWith`函数检查`github.event.ref`上下文变量的值。这将允许你创建像`v3.4.0`这样的标签来触发发布,但与发布无关的标签不会触发发布。
1. 使用 `actions/download-artifact@v3` 动作下载您在先前作业中构建和上传作为工件的可执行文件和源分发文件。您可以使用 `with.path` 键告诉动作将工件下载到何处。dist/ 目录是一个不错的选择,因为下一步将默认查找那里。
1. 使用 PyPA 的 `pypa/gh-action-pypi-publish@1.5.0` 动作来处理发布细节。此动作在底层使用 twine,但减少了您需要管理的配置量。
完成后,发布作业的配置应类似于以下列表。
列表 7.5 一个用于将 Python 包及其分发发布到 PyPI 的作业
...
publish:
name: Publish package
if: startsWith(github.event.ref, 'refs/tags/v') ❶
needs: ❷
- format
- lint
- typecheck
- test
- build_source_dist
- build_wheels
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3 ❸
with:
- name: artifact ❹
path: ./dist/ ❺
- uses: pypa/gh-action-pypi-publish@1.5.0 ❻
with:
user: __token__ ❼
password: ${{ secrets.PYPI_API_TOKEN }} ❽
❶ 仅在特定标签触发时运行此作业
❷ 等待所有其他作业首先完成
❸ 用于从先前作业下载工件
❹ 将所有工件下载到单个目录中
❺ 将工件放入下一步将默认使用的目录
❻ 用于将包工件发布到 PyPI
❼ 使用 API 令牌而不是用户/密码身份验证
❽ 引用您添加的仓库秘密
这次,在您提交并推送更改后,您应该预期新作业将被跳过(见图 7.16)。这是因为您推送的提交与您添加的 `if` 表达式不匹配。

图 7.16 您可以在工作流程中配置作业在指定条件下跳过。
要触发发布作业,您需要创建一个具有匹配名称的标签;否则,管道不会被触发。您还需要确保您不要尝试发布已经存在的版本;否则,您将等待管道中的所有作业,最终在发布作业期间收到错误。如果您一直密切跟随本书,您早期的 twine 上传可能已发布版本 `0.0.1`。现在,请将 setup.cfg 文件中的 `version` 值更新为下一个最高版本,例如 `0.0.2`。更新版本后,提交并推送更改。然后,按照以下步骤触发发布作业以创建 GitHub *发布*。*发布* 是 GitHub 特有的结构,与标签相关联,并允许您添加相关的说明和附件。
注意:您可以通过手动创建 Git 标签并将其推送到 GitHub 来实现与 GitHub 发布相同的效果。对于公开项目,发布工作流程很棒,因为它给您提供了一个机会,从您的更改日志中输入有用的发布说明。关于这一点,本书后面会详细介绍。
要创建发布,请点击页面右下角的“发布”。此链接可能难以找到;您始终可以访问 https://github.com/<you>/<repo>/releases(见图 7.17)。

图 7.17 导航到 GitHub 仓库的发布
点击“创建新发布”(见图 7.18)。

图 7.18 为 GitHub 仓库启动新的发布
点击“选择标签”下拉菜单,在框中输入新版本号,例如`v0.0.2`,然后点击“发布”中的“+ 创建新标签:v0.0.2”(见图 7.19)。

图 7.19 指定为 GitHub 发布创建的标签
点击“目标”下拉菜单,并选择你正在使用的 Git 分支(见图 7.20)。

图 7.20 指定从 Git 历史中创建标签的点
在“发布标题”框中输入版本号,并可选地添加描述发布的说明,描述更改。最后,点击“发布发布”(见图 7.21)。

图 7.21 填充和发布 GitHub 发布
发布发布后,访问你仓库的动作标签页。你应该会看到一个新的工作流程运行,旁边是你新的标签名称,而不是分支名称。这次运行将满足发布作业的条件,并且这次不会跳过(如图 7.22 所示)。

图 7.22 满足指定条件后作业成功
恭喜!你刚刚发布了你的第一个完全自动化的包版本。感觉如何?如果你像我一样,你可能感到疲惫和有点烦躁,但经过一夜的睡眠后,这种情绪会转变为兴奋。你所取得的成就不容小觑。你的团队摆脱了本地开发环境的束缚,你现在可以为比以往更广泛的平台提供预构建的分发。尽管 CarCorp 的人们使用各种计算机供应商,但你仍然可以有一些信心,他们能够使用你的工作。你还将最终将分发发布到 PyPI,这样其他人甚至可以使用 pip 等熟悉工具在他们的 Python 应用程序中安装它们。做得好,你。
现在你已经成为自动化领域的专家,并且有一个希望人们使用的包,你需要确保他们知道**如何**和**为什么**使用它。继续到下一章学习如何构建和维护文档。
## 摘要
+ 持续集成为你提供了一个共享环境中的频繁、可靠的反馈,从而在更改方面具有高度的信心。
+ 使用与你的版本控制系统和部署基础设施紧密集成的持续集成解决方案。
+ 通过配置你的持续集成解决方案并行运行几个高级任务来检查你做出的每个更改,每个任务由几个特定的命令组成。
+ 在触发你的发布流程时要保守,因为你想要确保代码是完美的。在建立对系统的高度信心之前,保持手动触发这一过程。
# 8 编写和维护文档
本章涵盖
+ 使用 reStructuredText 编写散文文档
+ 使用 sphinx-apidoc 和 sphinx-autodoc 自动化代码文档收集
+ 使用 Sphinx 构建 HTML 文档站点
+ 使用 Read the Docs 发布文档
在上一章的结尾,你实现了将你的包发布到 Python 包索引(PyPI)的重要里程碑,以便其他人可以使用。事实是,PyPI 已经有超过 350,000 个包,并且只会继续增长。你在功能、质量和包装物流方面的工作确保了人们*可以*使用它,但如果你想确保他们*会*使用它,你还需要做更多的工作。你已经在 CarCorp 有了一个固定的受众,但随着你尝试扩展到更多客户,他们将会期待更多。
文档是包采用的主要障碍之一。如果没有充分的理由,人们可能不理解为什么他们需要使用你的包。即使有足够的理由,人们可能也不理解如何使用该包,即使他们想这么做。在本章中,你将了解有效的包文档是什么样的,以及如何创建一个随着代码演变而支持你的项目的设置。
重要提示:你可以使用代码伴侣([`mng.bz/69A5`](http://mng.bz/69A5))来检查本章练习的工作。
## 8.1 关于文档的一些快速哲学
用户带着通常只有几个明确的目标来寻找文档:
+ 他们第一次想要了解代码。
+ 他们对代码非常了解,但想要完成他们以前没有做过的一项特定任务。
+ 他们对代码的行为非常了解,但仍需要参考诸如签名和语法之类的细节。
+ 他们想要了解导致代码达到今天这个地步的推理过程。
虽然从一种目标到另一种目标可能存在信息类型上的重叠,但信息的呈现方式必须专门针对一次一个目标,才能有效。Daniele Procida 的 Diátaxis 框架([`diataxis.fr/`](https://diataxis.fr/))详细说明了每个目标及其有助于实现每个目标的文档类型,如以下所述:
+ 指南帮助新读者通过一个已解决的问题来学习项目的一般思想和方法。
+ 指南有助于读者完成他们进入文档想要完成的特定任务。
+ 讨论有助于读者了解项目的历史和决策。
+ 参考内容有助于读者找到非常具体的信息,例如语法或允许的参数。
Diátaxis 网站上的观点和 Procida 的相应演示文稿有力地论证了将这些问题分开,最大化文档的有效性。这种方法的自然结果是,一些文档将几乎全部是散文,而另一些将几乎全部是代码,还有一些将是两者的混合。您需要一个可以轻松结合散文和代码文档,同时支持两者之间清晰区分的文档系统。
尽管教育、认知科学和教学法的全部深度超出了本书的范围(更多内容请参阅 Felienne Hermans 的《程序员的大脑》,Manning,2021,[http://mng.bz/lRxd](http://mng.bz/lRxd)),但有一些工具可以帮助您的教学蓬勃发展。Sphinx ([`www.sphinx-doc.org`](https://www.sphinx-doc.org)) 是一个文档框架,它利用 reStructuredText ([`mng.bz/BZMw`](http://mng.bz/BZMw)) 的强大功能,以各种输出格式创建分页和交叉引用的文档网站。
其他优秀的文档框架
尽管由于在 Python 文档中使用以及 reStructuredText 的强大功能,Sphinx 是 Python 文档中最受欢迎的框架之一,但它并不是这个领域唯一的参与者。
Python 提供了一个内置的 `pydoc` 模块 ([`docs.python.org/3/library/pydoc.xhtml`](https://docs.python.org/3/library/pydoc.xhtml)),可以从 Python 模块中找到的文档字符串生成文档文件。它没有很好的方法来包含散文文档,但如果你的项目只需要最基本的内容,它可能还是可行的。
MkDocs ([`www.mkdocs.org/`](https://www.mkdocs.org/)) 是另一个使用 Markdown 和 YAML 创建文档网站的第三方文档框架,并且有一个用于处理 Python 文档字符串的 mkdocstrings 插件 ([`github.com/pawamoy/mkdocstrings`](https://github.com/pawamoy/mkdocstrings))。
Sphinx 非常强大,可以通过其基于插件的架构进行扩展。它是 Python 项目的宝贵选择,甚至官方的 Python 文档 ([`docs.python.org`](https://docs.python.org)) 也使用了它。
注意:Sphinx 提供的完整功能和自定义选项超出了本书的范围,但正如您所想象的那样,Sphinx 有出色的文档。如果您喜欢这一章,您可能会喜欢探索 Sphinx 可以做什么的更多内容。
继续阅读,了解您如何使用 Sphinx 创建一个可以在 Read the Docs ([`readthedocs.org/`](https://readthedocs.org/)) 上提供的服务 HTML 文档网站,这是托管 Python 项目文档最受欢迎的地方。
## 8.2 使用 Sphinx 开始您的文档
首先为您的文档创建一个新的 tox 环境。记住,您可以通过在 setup.cfg 文件中添加一个名为`[testenv:docs]`的新部分来配置 tox 环境,这使您能够使用`tox -e docs`运行它。使用`deps`键将`sphinx`包([`pypi.org/project/Sphinx/`](https://pypi.org/project/Sphinx/))作为环境的依赖项。然后,将`commands`键添加到环境中,使用以下命令,该命令在项目根目录中创建一个 docs/目录,并填充初始文档配置和目录结构:
[testenv:docs]
...
commands =
sphinx-quickstart docs
Sphinx 快速启动生成的文件之一是 docs/index.rst。此文件是您文档的入口点。index.rst 文件的快速启动版本创建了一个带有嵌套标题和标题的目录表,如下一列表所示。
列表 8.1 Sphinx 中的基本目录表指令
.. toctree:: ❶
:maxdepth: 2 ❷
:caption: Contents: ❸
❶ 创建目录表
❷ 显示最多两层嵌套页面的链接
❸ 使用字符串“内容”来引入目录
目录表是 Sphinx 的一个重要概念,因为就像在书中一样,读者应该能够找到他们感兴趣阅读的内容。您添加的每一页文档都应该成为目录树的一部分,因此 Sphinx 的快速启动过程从遵循此模式开始。
使用`tox -e docs`命令运行您的环境。Sphinx 会提示您有关包的基本信息:
1. *目录结构*—您可以选择默认结构,将 _build/目录放置在 docs/目录中,与原始文档并列,或者您可以选择嵌套 source/和 build/目录,以将原始文档和构建文档完全分开。默认结构在这里就很好;本章的其余部分将假设这种结构。
1. *项目名称*—这应该与您发布包时使用的名称相匹配,以便人们能够识别他们正在阅读他们考虑安装或已安装的包的正确文档。
1. *作者姓名*—这是您的姓名或其他标识符,例如您公司的名称。
1. *项目发布*—目前您可以留空。在本章的后面部分,您将在文档配置中动态获取包版本。
1. *项目语言*—这是您编写文档的自然语言的两个字母 ISO 639-1 代码([`mng.bz/de2g`](http://mng.bz/de2g))。Sphinx 会根据所选语言调整其输出;默认为英语。
快速启动过程的完整输出将类似于以下列表。
列表 8.2 使用 Sphinx 驱动的文档项目的初始设置
Welcome to the Sphinx 4.4.0 quickstart utility.
Please enter values for the following settings (just press Enter to
accept a default value, if one is given in brackets).
Selected root path: docs
You have two options for placing the build directory for Sphinx output.
Either, you use a directory "_build" within the root path, or you separate
"source" and "build" directories within the root path.
Separate source and build directories (y/n) [n]: ❶
The project name will occur in several places in the built documentation.
Project name: pubpypack-harmony-dane-hillard ❷
Author name(s): Dane Hillard ❸
Project release []: ❹
If the documents are to be written in a language other than English,
you can select a language here by its language code. Sphinx will then
translate text that it generates into that language.
For a list of supported codes, see
https:/ /www.sphinx-doc.org/en/master/usage/configuration.xhtml#confval-language.
Project language [en]: ❺
Creating file /.../first-python-package/docs/conf.py.
Creating file /.../first-python-package/docs/index.rst.
Creating file /.../first-python-package/docs/Makefile.
Creating file /.../first-python-package/docs/make.bat.
Finished: An initial directory structure has been created.
You should now populate your master file
/.../first-python-package/docs/index.rst and create other documentation
source files. Use the Makefile to build the docs, like so:
make builder
where "builder" is one of the supported builders, e.g., html, latex or linkcheck.
❶ 使用默认结构
❷ 使用与已发布包相同的名称
❸ 使用您的姓名或其他适当的标识符
❹ 目前请保持为空。
❺ 选择您首选的语言
您现在应该在项目的根目录中看到`docs/`目录,并在其中看到以下文件和目录:
+ *conf.py*—此文件包含构建您的文档的 Sphinx 配置。
+ *index.rst*—当您构建文档时,这个文件作为 Sphinx 查找所有文档的主要入口点。其内容将是您文档的首页。
+ *Makefile*—您可以使用这个文件在已安装 GNU Make ([`www.gnu.org/software/make/`](https://www.gnu.org/software/make/))的 Unix 系统上手动构建文档。目前您可以删除这个文件;在这个书中您不会用到它。
+ *make.bat*—您可以使用这个文件在 Windows 系统上手动构建文档。目前您可以删除这个文件;在这个书中您不会用到它。
+ *static/*—您可以将 CSS 或图像文件添加到这个目录中,以便在文档中使用。目前您可以删除这个目录;在这个书中您不会用到它。
+ *templates/*—您可以将或覆盖 Sphinx 的默认模板添加到这个目录中,以改变您的文档展示方式。目前您可以先删除这个目录;在这个书中您不会用到它。
除非您想从零开始重新开始您的文档,否则您不需要再次使用`sphinx-quickstart`命令。如果`sphinx-quickstart`命令发现现有的文档,它将产生错误以避免覆盖您已经创建的内容。从现在开始,您希望 tox 环境构建文档。您可以使用`sphinx-build`命令从`docs/`目录构建文档,将其作为 HTML 保存在`docs/_build/`目录中。除了将`index.rst`文件转换为 HTML 外,Sphinx 还会构建索引,支持搜索文档以及允许其他基于 Sphinx 的文档站点进行交叉引用——关于这一点将在本章后面详细说明。请记住,您已经配置了 pytest 和 mypy,如果意外添加了不存在或未使用的引用,它们会明确失败。`sphinx-build`命令也有一些选项可以帮助确保您的文档没有任何损坏的引用。以下每个选项都会改变 Sphinx 的行为以及它输出到控制台的信息:
+ `-n`—挑剔模式使 Sphinx 在日志输出中将缺失的引用作为警告。
+ `-W`—此选项将构建过程中生成的所有警告转换为错误。您可能喜欢启用此选项以降低将问题引入文档的风险,这些问题会静默地允许构建成功。
+ `--keep-going`—此选项运行完整的文档构建,沿途收集所有错误,而不是在第一个错误后失败。这很有用,这样您就可以一次性看到多个错误并修复它们,而不是反复构建并每次都得到新的错误。
更新您的文档 tox 环境的`commands`值,以运行以下命令,该命令从`docs/`目录构建文档,将其作为 HTML 保存在`docs/_build/`目录中:
sphinx-build -n -W --keep-going -b html docs/ docs/_build/
再次运行 `docs` tox 环境。这次,Sphinx 应该在 conf.py 中找到现有的配置以及 index.rst 文件的内容,并使用它们来构建文档,如下一列表所示。
列表 8.3 使用 sphinx-build 构建 HTML 文档的示例输出
Running Sphinx v4.4.0
making output directory... done ❶
building [mo]: targets for 0 po files that are out of date
building [html]: targets for 1 source files that are out of date ❷
updating environment: [new config] 1 added, 0 changed, 0 removed
reading sources... [100%] index ❸
looking for now-outdated files... none found
pickling environment... done
checking consistency... done
preparing documents... done
writing output... [100%] index ❹
generating indices... genindex done ❺
writing additional pages... search done
copying static files... done
copying extra files... done
dumping search index in English (code: en)... done ❻
dumping object inventory... done ❼
build succeeded.
The HTML pages are in docs/_build. ❽
_____________ summary _____________
docs: commands succeeded
congratulations 😃
❶ 如果不存在,则创建 _build/ 目录。
❷ 每个源文件的索引.rst 源文件。
❸ 为每个源文件打印一行。
❹ 为每个源文件创建一个输出文件。
❺ 索引支持搜索和交叉引用。
❻ 用户搜索文档的索引
❼ 用于从其他 Sphinx 网站进行交叉引用。
❽ 您可以访问构建的网站
现在您已经将文档构建为 HTML,您可以在浏览器中查看它。尽管您现在只有一个 index.rst 文件,以及 docs/_build/ 目录中的相应 index.xhtml 文件,但 Sphinx 为文档搜索页面和索引构建了 HTML 文件。您可以直接在浏览器中查看 index.xhtml 文件,甚至运行搜索,但更好的方法是使用 Python 内置的 `http.server` 模块来查看具有所有功能的文档。此模块运行一个小型 HTTP 服务器,模拟文件在互联网上的服务器上如何被访问。您可以从项目的根目录运行以下命令来在 http:/ /localhost:9876 上提供文档:
$ py -m http.server -d docs/_build/ 9876
现在请在浏览器中访问 http:/ /localhost:9876。这显示了文档主页,其中包含您在快速入门过程中提供给 Sphinx 的一些信息(见图 8.1)。

图 8.1 Sphinx 为新文档项目创建的裸骨 HTML 网站。它包括包名和作者名。
现在您已经成功运行了 Sphinx,在进一步编写文档之前,下一步是为几件事情构建自动化,以加快您的文档工作。
### 8.2.1 在开发过程中自动化文档刷新
您可以在更新本章中的文档时让 Python HTTP 服务器持续运行,这样您就可以始终在浏览器中访问它们,但每次在将来工作于文档时记住这样做可能会有点困难。您可以使用 `sphinx-autobuild` 包([`pypi.org/project/sphinx-autobuild/`](https://pypi.org/project/sphinx-autobuild/)) 和它提供的 `sphinx-autobuild` 命令来管理,而不是使用 `sphinx-build` 当您在本地工作于文档时。`sphinx-autobuild` 命令也会启动一个 HTTP 服务器,但它将监视文档源文件的变化,并自动在您的浏览器中刷新文档。这在您快速迭代文档的措辞或结构时可以节省大量时间。创建一个新的 `devdocs` tox 环境,其配置与 `docs` 环境类似,但有以下更改:
+ 除了 `sphinx` 包之外,将 `sphinx-autobuild` 包作为依赖项添加。
+ 将 `sphinx-build` 命令更改为 `sphinx-autobuild`。
+ 删除 `--keep-going` 选项;即使发生错误,`sphinx-autobuild` 也会继续提供文档,并打印一条警告,说明构建的文档与源代码不同步。当你修复错误时,构建将重新同步到最新的源代码。
+ 如果需要,可以添加一个 `--port` 选项,以选择机器上可用的端口来提供文档。默认端口是 `8000`。
现在,你可以使用 `tox -e devdocs` 命令在本地提供文档,而不是同时运行文档的 tox 环境和 Python HTTP 服务器。你仍然可以运行 `docs` tox 环境来构建静态文档,这将在本章的后面用于验证文档。现在,继续完善文档本身。
### 8.2.2 自动提取代码文档
代码的低级文档最有效的方式是尽可能接近代码本身。否则,文档很可能会在你写完它的时候就已经过时了。理想情况下,代码文档应该与代码本身交织在一起,直接关联到它所文档化的代码。Python 通过其一些语言结构支持这一点。同时,当用户的目标只是阅读代码的高级内容时,深入代码并不总是对用户来说是最好的选择。你需要一个流程,这个流程允许你将代码参考文档保持在与相关代码的紧密关联中,以便于维护,同时也允许你将其提升到更高的层次,以便于读者的使用。
在第六章,你配置了 mypy 以验证代码中的类型提示。虽然类型提示对于自动化验证很有价值,但它们也有助于开发者阅读代码时理解他们可以期望传递给函数或从函数接收的数据类型。你应该将这些内容作为代码文档的一部分提取出来,因为它们作为参考信息的价值。关于这一点,稍后会有更多说明。
Python 允许你将 *docstrings*(文档字符串)或独立字符串添加到 Python 模块、类、方法和函数中,这些字符串旨在文档化代码。你可能之前见过或使用过 docstrings——可能起到与代码注释类似的作用——但它们的功能要强大得多。Python 将 docstrings 作为它们所文档化的对象结构的一部分进行解析。特别是,任何模块、类、方法或函数的 `__doc__` 属性都包含其 docstring 的值(如果存在)。以下是一个名为 `shapes.py` 的 Python 模块示例,其中展示了每个级别的 docstrings。
列表 8.4 模块、类、方法和独立函数的 docstrings
""" ❶
shapes.py
This module contains utilities for dealing with shapes.
"""
from math import pi
class Circle:
""" ❷
A class for calculating
the circumference and area of a circle.
"""
def __init__(self, radius: int = 1):
self.radius = radius
def area(self):
""" ❸
Return the area of this circle.
"""
return pi * self.radius**2
def circumference(self):
"""
Return the length of the perimeter of this circle.
"""
return 2 * pi * self.radius
❶ 模块 docstring
❷ 类 docstring
❸ 方法 docstring
你可以通过检查`shapes.py`模块中对象的`__doc__`属性来观察文档字符串的行为,包括模块本身(见下一条列表)。请注意,由于文档字符串是多行 Python 字符串,它们在`__doc__`属性中以与原始字符串相同的换行符和缩进方式忠实复制。
列表 8.5 使用 __doc__ 属性检查文档字符串
import shapes
shapes.doc ❶
'\nshapes.py\n\nThis module contains utilities for dealing with
➥ shapes.\n' ❷
shapes.Circle.doc ❸
'\n A class for calculating
➥ \n the circumference and area of a circle.\n ' ❹
❶ 模块文件开头的文档字符串
❷ 文档字符串以换行符开始和结束
❸ Circle 类内部的文档字符串
❹ 文档字符串包含更多的缩进,因为它位于类内部。
这种关联使得文档字符串在自动化系统中非常有用,因为它们可以提取函数的名称、签名和文档字符串以用于文档目的,从而将重点放在 API 和你在文档字符串中添加的叙述性文档上,而不是强迫用户浏览源代码的实现细节。Sphinx 为此类提取提供了工具,这在需要文档化大量对象的项目中通常是一项无法克服的任务。
要让 Sphinx 提取你的代码文档,你必须向你的文档 tox 环境中添加一个额外的命令。Sphinx 提供了一个`sphinx-apidoc`命令([`mng.bz/rnJx`](http://mng.bz/rnJx)),它可以发现你项目中的所有模块、类、方法和函数,并提取它们拥有的任何文档。该命令提供了大量影响最终文档渲染方式的选择,但这些通常取决于个人品味。我推荐以下选项,因为它们的效果:
+ `--force`—这将导致`sphinx-apidoc`覆盖任何现有的提取文档。由于你直接从代码中连续生成这些文件,而不是偶尔构建一次,因此你需要确保生成的文档与源代码保持同步。如果没有此选项,`sphinx-apidoc`将保守地避免写入已存在的文件。
+ `--implicit-namespaces`—在搜索模块时,你希望 Sphinx 找到任何位于隐式命名空间中的模块,如 PEP 420([`www.python.org/dev/peps/pep-0420/`](https://www.python.org/dev/peps/pep-0420/))中定义的。这支持更广泛的包配置,这样在将来你为项目添加隐式命名空间时,它不会导致问题。
+ `--module-first`—通常人们在学习时,先了解高级概念再了解低级概念效果最好。默认情况下,Sphinx 输出的文档是先从最低级代码开始进行文档化;此选项将最高级文档放在前面。
+ `--separate`—如果一页上展示的内容太多,一些读者可能会感到不知所措。默认情况下,Sphinx 将文档分组在一起;此选项将每个模块的文档拆分到其自己的页面上。
接下来的几个选项是必要的,用于告诉 `sphinx-apidoc` 命令在哪里发现和输出文档:
+ `-o` 选项表示文档的输出目录。一个像 `docs/reference/` 这样的目录是一个不错的选择,但如果您喜欢,您可以将其命名为其他名称。这个名称将出现在提取的文档的 URL 中,并且您应该将该目录添加到 .gitignore 文件中,以避免将生成的文档文件提交到版本控制中。
+ 位置参数是要开始发现代码的目录,后面跟着零个或多个在搜索过程中要忽略的文件模式。您希望 Sphinx 在您的 `src/imppkg/` 目录中查找,这是您的包源代码所在的位置,并且您希望它忽略所有 `src/imppkg/*.c` 和 `src/imppkg/*.so` 文件,因为这些是由 Cython 生成的非 Python 文件。
`sphinx-apidoc` 命令的最终用法可能有点令人畏惧,但现在您已经理解了它的各个部分。它应该看起来像这里显示的列表。
列表 8.6 使用 sphinx-apidoc 自动提取代码文档
sphinx-apidoc
--force \ ❶
--implicit-namespaces \ ❷
--module-first \ ❸
--separate \ ❹
-o docs/reference/ \ ❺
src/imppkg/ \ ❻
src/imppkg/.c \ ❼
src/imppkg/.so
❶ 每次都覆盖提取的文档
❷ 包括隐式命名空间中的模块
❸ 先展示高级文档,再展示低级文档
❹ 将每个模块拆分到自己的页面
❺ 将生成的文件输出到这里
❻ 在这里启动文档搜索
❼ 忽略由 Cython 生成的文件
将这个新的 `sphinx-apidoc` 命令作为 `docs` 和 `devdocs` tox 环境中的第一个命令添加,以便在构建和监视完整文档之前,将代码文档提取到 `docs/reference/` 目录中。您几乎准备好提取一些代码文档了,但在这样做之前,您还需要配置两件事。
警告 在 `devdocs` 环境中设置的 `sphinx-autobuild` 命令只能检测现有文档文件的变化。如果在 `devdocs` 环境运行时添加了新的 Python 模块,那么新模块中的任何代码文档都不会被 Sphinx 自动提取。当这种情况发生时,您可以停止并重新运行 `tox -e devdocs` 命令。
`sphinx-apidoc` 命令在 `docs/references/` 目录下生成几个文件,这些文件使用了 Sphinx 的 `autodoc` 扩展的指令 ([`mng.bz/VyMN`](http://mng.bz/VyMN)),而这个扩展默认是未启用的。在 `docs/conf.py` 模块中,查找空的 `extensions` 列表,并添加下一列表中显示的扩展来解释从源代码中提取的文档字符串和类型提示。
列表 8.7 使用 Sphinx 从源代码中提取文档
extensions = [
"sphinx.ext.autodoc", ❶
"sphinx.ext.autodoc.typehints", ❷
]
❶ sphinx-apidoc 输出的文件使用这个扩展。
❷ 这个扩展将在文档中渲染类型提示。
`sphinx-apidoc` 命令生成的文件之一被称为 docs/reference/modules.rst。正如 index.rst 作为所有文档的主要入口点一样,modules.rst 文件作为 `sphinx-apidoc` 命令生成的代码文档的入口点。您需要将 index.rst 文件链接到 modules.rst 文件,以便您的文档主页可以链接到代码文档。
生成文档的版本控制
根据我的经验,Sphinx 从您的代码中提取的自动文档应该从版本控制系统中忽略,因为文档在代码更改时重新生成,并将主要增加代码审查的噪音。话虽如此,如果您在一个需要仔细关注以验证其内容的文档的项目上工作,您可以将它们放入版本控制中。您需要决定这个选择对您的项目在完整性和开销之间的权衡。
当您第一次渲染文档并在浏览器中查看时,没有内容;您还没有添加任何文档。因为您即将在 docs/reference/ 目录中构建代码文档,其中 modules.rst 文件将成为该目录中所有文档的入口点,您可以将它添加到 index.rst 中的目录指令。在 reStructuredText 中,您可以使用相对链接到文件,并且如果其他文件也是 reStructuredText 格式,则可以省略扩展名。也就是说,如果您想从 docs/index.rst 引用 docs/reference/modules.rst,相对路径是 reference/modules.rst,您可以使用更短的 reference/modules,因为它是一个 reStructuredText 文件。现在将此值添加到 `toctree` 指令中,用空白行与 `:maxdepth:` 和 `:caption:` 属性分隔,如下一列表所示。
列表 8.8 在目录中链接其他文档
.. toctree::
:maxdepth: 2
:caption: Contents:
reference/modules ❶
❶ 在目录中链接此文件中找到的文档
除了为每个 Python 模块生成一个 .rst 文件外,Sphinx 还为每个导入包生成一个 .rst 文件。导入包的文件链接到该包中每个模块的 .rst 文件。反过来,顶级模块.rst 文件链接到每个导入包的 .rst 文件。这创建了一个链接图,当 Sphinx 将其构建成 HTML 站点时,它们创建了一个可浏览的页面结构,如图 8.2 所示。

图 8.2 Sphinx 将相互链接的文本文档和代码文档处理成可浏览的 HTML 页面图。
警告 Sphinx 将术语 *module* 和 *submodule* 分别与 *import packages* 和 *modules* 互换使用。如果您命名清晰并且对项目的结构非常了解,这不会成为太大的问题,但最好留意这一点。
现在您的 tox 环境已配置为运行 `sphinx-apidoc` 命令,并且 Sphinx 已配置为在 HTML 构建期间使用 `sphinx.ext.autodoc` 扩展来解释该步骤的输出,再次运行 `devdocs` tox 环境。这次您应该会看到 `sphinx-apidoc` 命令的额外输出,指示已创建新的文档文件(见以下列表)。
列表 8.9 使用 sphinx-apidoc 自动生成文档的输出
Creating file docs/reference/imppkg.rst. ❶
Creating file docs/reference/imppkg.harmonic_mean.rst. ❷
Creating file docs/reference/imppkg.harmony.rst.
Creating file docs/reference/modules.rst. ❸
❶ 整个 imppkg 包的索引
❷ imppkg 中每个模块的文档
❸ 所有包的索引——Sphinx 称它们为模块。
再次在浏览器中查看文档。现在您应该会在主页上看到链接到 `imppkg` 包(见图 8.3)。

图 8.3 使用自动的 sphinx-apidoc 代码文档提取方法构建的 Sphinx 目录表
您还可以通过这些新链接查看针对 `harmonic_mean` 或 `harmony` 模块的特定文档。请记住,文档是一个可浏览的图。
练习 8.1
虽然目前看起来并不多,但您的文档设置已经具备了构建相当丰富的文档的能力。您需要通过在 docs/ 目录中添加更多文本文档和在 Python 代码中添加文档字符串来付出艰苦的努力。
抽空练习为您的代码添加文档字符串,并看看 Sphinx 如何在文档中反映这些内容。将以下文档添加到您的项目中,并观察构建的 HTML 文档如何变化:
+ 在 `harmony.py` 模块中添加一个模块级别的文档字符串,解释如何将其用作命令行工具。
+ 在 harmonic_mean.pyx Cython 文件中的 `harmonic_mean` 函数中添加一个文档字符串,链接到一个关于谐波平均使用资源的页面。
+ 在目录表之前将关于 harmony 包的介绍添加到 index.rst 文件中。
+ 为想要添加或更改功能的开发者添加一个解释项目结构的 .rst 文件,并将其链接到目录表中。
除了在 .rst 文件中使用 reStructuredText,您还可以在文档字符串中使用 reStructuredText。如果您想了解如何在 reStructuredText 中实现特定功能,请查看 Sphinx 的 reStructuredText 入门指南([`mng.bz/xMn7`](http://mng.bz/xMn7))。在源代码中添加或更改文档字符串时,您需要重新运行 `devdocs` 或 `docs` 环境。
最后,你应该配置包版本,以便 Sphinx 和 Read the Docs 可以将特定的构建与其对应的包版本关联起来。你可以使用`importlib`模块([`docs.python.org/3/library/importlib.xhtml`](https://docs.python.org/3/library/importlib.xhtml))来检索已安装包的版本。`importlib.metadata.version`接受一个分发包的安装名称,并以字符串形式返回已安装的版本。将下一列表中的代码添加到`docs/conf.py`模块中,输入你为发布你的包所选择的名称。
列表 8.10 配置 Sphinx 以理解已安装包的版本
from importlib import metadata
PACKAGE_VERSION = metadata.version('pubpypack-harmony-
➥
version = release = PACKAGE_VERSION
注意,Sphinx 的默认主题在文档中不会显示包版本。一些其他内置主题会显示,如果你更喜欢显示版本,你可以自定义主题;关于主题和自定义配置将在稍后详细说明。当你对文档的快速初步审查感到满意时,你可以将这些更改提交到你的项目中,并将它们推送到 GitHub。下一步是将你的文档发布到 Read the Docs。
## 8.3 将文档发布到 Read the Docs
小贴士 在继续本节之前,你必须在 Read the Docs 上创建一个账户([`readthedocs.org/accounts/signup/`](https://readthedocs.org/accounts/signup/))。你可以使用你的电子邮件或 GitHub 账户;我选择 GitHub,因为它使得导入现有的 GitHub 项目变得容易得多。
你的文档已经取得了很好的开端,但如果它只存在于你的仓库中的纯文本文件中,它还没有充分发挥其帮助用户的潜力。你不想强迫试图使用你的代码的人自己构建文档,这会分散他们的注意力。Read the Docs 是一个优秀的托管平台,它将你的 Sphinx 构建的 HTML 文档在线发布。它直接支持 Sphinx,以及一些其他文档系统,并且截至本文撰写时,它对开源项目始终免费。
为私有项目发布文档
你是否有用于组织内部使用的私有包?Read the Docs 提供了一个付费的商业级解决方案([`readthedocs.com`](https://readthedocs.com)),这对于你的组织回馈他们来说是一个很好的方式。如果你的组织更可能为你支付时间和一些私有基础设施的费用,你还可以在 Docker 容器中构建 Sphinx 文档,并使用 nginx([`nginx.org`](https://nginx.org))或 Apache HTTP 服务器([`httpd.apache.org/`](https://httpd.apache.org/))自行提供服务。这是因为 Sphinx 构建最终会将所有文档转换为静态 HTML。
在 Read the Docs 上登录后,您将到达仪表板页面。如果您之前从未使用过 Read the Docs,那里可能不会有太多内容。重要的是导入项目按钮(见图 8.4)。点击那里开始导入您的项目。

图 8.4 Read the Docs 仪表板显示了您已导入的任何项目,并提示导入新项目。
在第一次导入页面,Read the Docs 会提示您选择要导入的仓库。您可以选择您或您所在组织拥有的任何公共仓库(见图 8.5)。通过点击添加(**+**)从列表中选择您的包仓库。如果您看不到您的仓库,请点击刷新或再次确认您的仓库在 GitHub 上是公开的。

图 8.5 Read the Docs 可以从您的账户或您所在的任何组织的任何公共仓库导入。
当您添加仓库时,Read the Docs 会提示您输入一些项目信息。此页面已经填充了默认值,但您应该更改以下内容:
1. 将名称字段更改为与您在 Python 包索引上发布包时使用的名称匹配。这应该是类似于`pubpypack-harmony-<firstname>-<lastname>`的东西。
1. 确保默认分支字段设置为您的仓库默认分支,对于新的 GitHub 仓库通常是`main`。如果您正在默认分支之外的分支上编写文档,请暂时将此字段设置为您的文档分支,以便 Read the Docs 可以找到您的文档代码。当您准备好合并文档分支时,可以将此设置切换回`main`。
1. 选择“编辑高级项目选项”选项。
在继续之前,您的设置应该类似于图 8.6。准备好后,点击下一步。

图 8.6 导入项目时的主要 Read the Docs 设置
下一页会提示您输入有关项目的高级设置,该页面也已填充了一些默认值。更改以下内容:
1. 如果您选择,更新描述以匹配项目 README 文件中的描述。这仅在 Read the Docs 网站上显示。
1. 确保文档类型字段设置为 Sphinx HTML。
1. 确保语言字段设置为编写文档所用的语言。
1. 确保编程语言字段设置为 Python。
1. 添加一些您选择的标签。这些标签有助于他人发现您的项目。添加一个 pubpypack 标签,这样这本书的所有读者都可以找到彼此的项目。
在继续之前,您的设置应该类似于图 8.7。准备好后,点击完成。

图 8.7 导入项目时的高级 Read the Docs 设置
当你完成项目的导入过程后,Read the Docs 会带你到项目的页面。目前还没有太多信息;它主要反映了你刚刚输入的设置。重要的是,你可以看到“最后构建”字段显示“尚未构建”,状态徽章显示未知状态(见图 8.8)。

图 8.8 在你从 GitHub 导入仓库后,Read the Docs 项目的页面。可能还没有任何构建。
Read the Docs 已经在后台开始构建你的项目,你可以通过选择构建标签来观察。你应该会看到一个状态为触发、克隆或构建(如图 8.9 所示)的构建。定期刷新此页面;大约一分钟后,状态应该变为通过。


当构建成功完成后,点击查看文档以查看你发布的文档。URL 应该类似于 https://pubpypack-harmony-<firstname>-<lastname>.readthedocs.io/<language>/latest/。
仔细查看你新文档网站的首页。注意,你如此仔细配置的 `sphinx-apidoc` 命令提取的代码文档并不在那里。这是因为 Read the Docs 不了解你的 tox 环境——它只是在运行 `sphinx-apidoc` 之前复制了在 docs/ 目录中找到的内容。为了解决这个问题,你可以创建一些额外的配置来告诉 Read the Docs 更多关于你的项目信息。
### 8.3.1 配置 Read the Docs
你当前配置有两个缺点:
1. Read the Docs 在构建你的文档之前不会运行你的 tox 环境或 `sphinx-apidoc` 命令,这导致了你之前观察到的缺失的代码文档([`mng.bz/AVye`](http://mng.bz/AVye))。
1. Sphinx 不会像你的 tox 环境那样将你的包安装到 Python 环境中。如果你的包有任何第三方依赖项,这些依赖项也不会被安装,Sphinx 在构建文档时遇到未知包导入时可能会失败。
你必须处理这两种情况以确保后续的顺利运行。
Read the Docs 将从你项目的根目录中名为 .readthedocs.yaml 的 YAML 文件中读取配置([`mng.bz/ZpAN`](http://mng.bz/ZpAN))。Read the Docs 构建使用操作系统镜像,就像你之前设置的 GitHub Actions 一样。YAML 配置文件可以更改构建过程中使用的工具的版本,等等。Read the Docs 还自动找到了你的 Sphinx 文档文件,但你也可以明确配置这些文件的位置,这样 Read the Docs 以后就不会混淆了。
现在创建一个空的 .readthedocs.yaml 文件。对于你的项目,你需要添加以下内容:
+ `version`—您使用的 Read the Docs 配置架构版本。在撰写本文时,最新版本是 `2`,这是一个必填字段。
+ `sphinx.configuration`—从项目根目录到 Sphinx conf.py 文件的相对路径。
+ `formats`—要构建的输出类型列表。Sphinx 支持除了 HTML 之外的 EPUB 和 PDF 输出。如果您只想构建 HTML,请指定 `htmlzip`。
+ `build.os`—构建的操作系统。使用最新的 Ubuntu LTS 版本,在撰写本文时是 `ubuntu-20.04`。
+ `build.tools.python`—构建的 Python 版本。在撰写本文时,默认为 Python 3.7。您应该使用您的包支持的 Python 版本之一。指定类似于 `"3.10"` 的内容,包括引号;YAML 将不带引号的 `3.10` 解释为十进制数字,结果为 `3.1`。
+ `python.install[0].method`—如何为文档构建安装依赖项。Read the Docs 支持使用 pip 或 Setuptools 来安装包;您应该使用 pip,因为 Setuptools 方法是一个遗留方法,并且您已正确配置了您的包,使其可以使用 Python 的最新构建系统方法进行安装。指定 `pip`。
+ `python.install[0].path`—到包项目目录的相对路径。您的包项目与根目录相同,因此使用点字符(`.`)指定当前目录。
当您完成时,您的 Read the Docs YAML 配置文件应如下所示。
列表 8.11 一个 Read the Docs 配置文件
version: 2 ❶
sphinx:
configuration: docs/conf.py ❷
formats:
- htmlzip ❸
build:
os: ubuntu-20.04 ❹
tools:
python: "3.10" ❺
python:
install:
- method: pip ❻
path: . ❼
❶ Read the Docs 配置架构版本
❷ Sphinx 配置的位置
❸ 仅构建 HTML 输出
❹ 如何指定非默认操作系统
❺ 如何指定非默认 Python 版本—记住引号。
❻ 在构建文档之前使用 pip 安装包
❼ 在根项目目录中安装包
在此配置就绪后,您可以稍后向项目中添加依赖项,并确信 Sphinx 不会因为未知导入而失败。接下来,您需要让 Read the Docs 运行 `sphinx-apidoc` 命令。
在 Read the Docs 上运行 sphinx-apidoc
在您的 tox 环境中,您在 `sphinx-build` 和 `sphinx-autobuild` 命令之前分别添加了 `sphinx-apidoc` 命令,以便在完整构建之前提取代码文档。当您的项目在 Read the Docs 上构建时,Read the Docs 正在运行其自己的独立命令集,这些命令与您的 tox 环境无关或未意识到。您不能直接告诉 Read the Docs 改变其过程,但您可以在每次构建之前告诉 Sphinx 做些什么。
作为其基于插件的架构的一部分,Sphinx 公开了一系列“事件”([`mng.bz/Rv4R`](http://mng.bz/Rv4R)),扩展可以通过这些事件进行交互。其中一个事件称为`builder-inited`([`mng.bz/2rro`](http://mng.bz/2rro)),它在构建发生之前触发。Sphinx 将在构建期间调用您配置中定义的`setup`函数,在那里您可以连接到您需要监听的事件。您可以利用这个架构以及 sphinx-apidoc 的编程 API 来实现与在 tox 环境中调用`sphinx-apidoc`命令相同的行为。
注意:以下内容可能因您已在 setup.cfg 文件中添加的内容而显得重复,但我建议保留这两种配置。sphinx-apidoc 步骤的持续时间与您的项目包含的 Python 模块数量呈线性增长,因此随着时间的推移,它只会越来越慢。在本地每次构建之前运行它可能会很麻烦,尤其是当您只对文档的文本部分进行更改时。
您的程序配置 sphinx-apidoc 应在构建执行在 Read the Docs 环境中时进行。您可以通过检查`READTHEDOCS`环境变量的值来确定这一点,在 Read the Docs 构建环境中该变量的值为`"True"`。当您检测到构建正在 Read the Docs 上执行时,您可以定义您的`setup`函数以连接到 sphinx-apidoc。sphinx-apidoc 的编程 API 接受与命令行界面相同的参数,因为它暴露了一个`main`函数作为控制台脚本——类似于您为`harmony`命令创建的那个。在`docs/conf.py`模块中使用它的唯一区别是,源代码路径、输出目录和忽略文件的路径需要相对于模块而不是项目根目录。您可以使用 Python 的`os.path`模块([`docs.python.org/3/library/os.path.xhtml`](https://docs.python.org/3/library/os.path.xhtml))或较新的`pathlib`模块([`docs.python.org/3/library/pathlib.xhtml`](https://docs.python.org/3/library/pathlib.xhtml))来实现这一点。
将下一列表中的代码添加到`docs/conf.py`模块的底部。
列表 8.12 使 sphinx-apidoc 作为 Read the Docs 构建过程的一部分运行
if os.environ.get("READTHEDOCS") == "True": ❶
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent ❷
PACKAGE_ROOT = PROJECT_ROOT / "src" / "imppkg"
def run_apidoc(_): ❸
from sphinx.ext import apidoc ❹
apidoc.main([ ❺
"--force",
"--implicit-namespaces",
"--module-first",
"--separate",
"-o",
str(PROJECT_ROOT / "docs" / "reference"), ❻
str(PACKAGE_ROOT),
str(PACKAGE_ROOT / "*.c"),
str(PACKAGE_ROOT / "*.so"),
])
def setup(app): ❼
app.connect('builder-inited', run_apidoc) ❽
❶ 仅在 Read the Docs 环境中运行此操作。
❷ 计算所需的项目路径
❸ 运行 sphinx-apidoc 的函数
❹ 导入 sphinx-apidoc
❺ 使用与其它地方相同的参数调用 sphinx-apidoc
❻ 路径必须是正确相对的,并且必须是字符串。
❼ 在构建过程中由 Sphinx 调用
❽ 在主要构建之前调用 run_apidoc
当您完成对`docs/conf.py`模块的更改后,您就可以提交您的更改并将它们推送到 GitHub。当这些更改到达 Read the Docs 上指定的分支时,它将触发一个新的构建。当这个构建完成后,您应该能够看到完整的文档。
注意 Read the Docs 为文档站点实施了一个有效的缓存策略。通常,您需要在浏览器中进行强制刷新才能在构建后看到新鲜内容。
在继续之前,Read the Docs 还提供了一项您应该利用的便利功能。
自动化 Read the Docs 为 GitHub 拉取请求构建
Read the Docs 可以为您打开的每个拉取请求构建文档,就像您已经设置的 GitHub Actions 工作流程一样。要添加此功能,请执行以下步骤:
1. 访问 Read the Docs 中的项目页面。
1. 点击“管理员”。
1. 点击“高级设置”。
1. 在全局设置部分的底部选择“为此项目选择构建拉取请求”选项。
1. 在页面底部点击“保存”。
下次您推送更改时,Read the Docs 将在您的拉取请求中添加一个状态,指示文档是否成功构建(如图 8.10 所示)。

图 8.10 Read the Docs 在 GitHub 拉取请求上的构建状态
您可以点击 Read the Docs 拉取请求状态上的“详情”链接,查看文档的临时版本。Read the Docs 在 HTML 中添加了一个警告,表明文档不是实时版本(如图 8.11 所示)。

图 8.11 Read the Docs 为拉取请求构建了一个临时的文档版本。
您现在拥有一个涵盖以下内容的文档系统:
+ 由 reStructuredText 和 Sphinx 驱动的丰富内联链接和样式化的文档
+ 完整的代码文档,包括类型提示,同时也支持 reStructuredText 语法
+ 通过 Read the Docs 自动构建和发布
这对您和 CarCorp 的用户以及更广泛的人来说都是有价值的,但未来的价值将在于您保持文档详尽和更新的承诺。每次您更改代码时——特别是公共 API——请考虑这种更改对文档的影响,并相应地采取行动。本章的最后几节涵盖了最佳实践和一些额外的提示。如果您对文档感到兴奋,现在就可以查看;否则,您可以转到下一章,在您准备好了解更多信息时再回头来看。
## 8.4 文档最佳实践
在本章的开头,您了解了 Diátaxis 框架,该框架旨在将文档分层为用户的不同目标。一些实践超越了这一模型,几乎适用于任何情况,以确保出色的文档。
### 8.4.1 需要记录的内容
如果您将文档的彻底性视为一个范围——不包括“完全不文档化”的情况——那么一端是记录一切,另一端是仅记录公共 API。Sphinx 提供了可以自动提取代码文档的功能,即使是未文档化或私有的函数和方法,因此从技术角度来看,它支持整个范围。在决定您希望在项目中的哪个位置时,请仔细考虑您的受众。
如果您预计受众是**最终用户**,或者只想利用您的功能来完成自己的工作的人,那么记录公共 API 是最好的选择。这有两个好处:人们不会因为不影响他们的代码而感到不知所措,他们也不会倾向于依赖它。由于 Python 没有真正的私有代码,人们不可避免地会依赖实现细节,尤其是在文档中。如果您需要优先记录某些事物,首先记录用户最想知道的事情,然后逐步处理不那么紧迫的区域。
另一方面,如果您预计受众是其他开发者,他们也会维护该项目,那么您可能需要考虑记录那些难以解决、有已知限制的实现细节等。这可以帮助他们在未来更好地开发项目。他们会有不同的优先级,通常从架构的角度与代码交互,以确定项目的良好前进路径。因为好的文档有助于人们理解代码的“是什么”,而不仅仅是“为什么”,所以您有强调的义务确保项目维护者理解项目的设计和历史。
### 8.4.2 优先选择链接而非重复
除了选择在您自己的项目代码中避免文档化的区域外,避免过度文档化其他项目的代码也同样重要。在您自己的项目中保持代码更新已经相当具有挑战性,而文档化其他项目则又增加了一层复杂性。您无法控制的项目可能会随时发生变化,因此您在周一记录的行为可能在周四发生变化,而您可能几天、几周甚至几个月后才会意识到这一点。相反,考虑提及项目的一个主要功能,并链接到该功能的页面。这还有一个额外的优点,即人们可以理解文档中的高级流程,如果需要了解更多信息,他们可以跳转到详细文档。
默认情况下,Sphinx 允许你使用像`:class:`和`:ref:`这样的角色链接到特定的类、函数等,([`mng.bz/199Q`](http://mng.bz/199Q)),但这些引用只能在你的项目内部工作。幸运的是,Sphinx 提供了一个名为 intersphinx 的扩展([`mng.bz/Poo8`](http://mng.bz/Poo8)),它允许跨其他 Sphinx 驱动的文档站点进行引用。intersphinx 的唯一要求是在构建项目文档时,任何连接到的文档站点都必须可以通过网络访问。
你可以在许多不同的 Python 项目中看到 intersphinx 在工作。例如,pytest-django ([`pytest-django.readthedocs.io`](https://pytest-django.readthedocs.io)) 对 pytest 文档 ([`docs.pytest.org`](https://docs.pytest.org)) 和 Django 文档 ([`docs.djangoproject.com`](https://docs.djangoproject.com)) 进行了交叉引用。那些项目反过来又链接到 Python 的文档 ([`docs.python.org`](https://docs.python.org))。这些相互链接的文档集确保用户始终能够直接从官方来源获取最新信息。有了结构良好的文档,这可以创造出一个几乎类似于维基百科的体验,用户可以在不同的主题之间浏览和退出。
### 8.4.3 使用一致、富有同情心的语言
文档受益于撰写论文、书籍或其他作品时的许多相同实践。时态应该一致。语法应该正确。对事物的引用应该拼写和大小写正确。任何错别字或令人困惑的段落都会给已经可能在学习材料上面临挑战的用户增加认知负担。
此外,某些语言可能会给用户带来挫败感。想象一下,有人很难理解一个概念,而且他们的代码无法正常工作。当他们访问项目的文档时,文档中提到它应该“只需几个简单步骤”就能工作,或者它“显然不适用于这个用例。”对于这个用户来说,事情并不简单或明显,看到这样的表述可能会让他们非常沮丧。始终努力坚持事实,并删除狡猾的词汇、含糊不清的表述、性别语言等等。
检查你的文档中以下单词,看看它们是否增加了价值:
+ 基础
+ 简单/容易
+ 简单/简单地
+ 明显的
+ 只是
+ 自动/自动化
+ 魔法
+ 快/慢
以下示例显示了在应用此实践前后一段文字可能的样子:
We created this easy API as a way to make international taxes simple.
We created an international taxes API that solves some problems other APIs
weren’t handling.
The developer can use his code to calculate how much manpower is needed.
The developer can use the code to calculate how large a workforce is needed.
This package magically tells you which stock to buy.
This package uses the Bloomberg API and machine learning to recommend a stock
with strong odds of increasing in value.
### 8.4.4 避免假设并创建上下文
用户通常在 Google 或其他搜索引擎进行搜索后到达文档。他们可能在没有上下文的情况下随意点击你的文档中的任何页面,甚至可能不知道他们在寻找什么。当你能的时候,尽量避免依赖其他部分或页面的上下文。如果你确实需要从另一个位置假设先前的知识,请明确提及,如果可能的话,链接到它。这将帮助用户更快地找到他们需要的信息,并在必要时进行进一步阅读,以获得对材料的深入理解。
以下示例显示了在应用此实践前后段落可能看起来如何:
Make sure to use BCNF when modeling your database.
Using BCNF <https:/ /en.wikipedia.org/wiki/Boyce%E2%80%93Codd_normal_form>_
will help ensure that your data model addresses some specific concerns,
listed below.
### 8.4.5 创建视觉兴趣和连贯结构
大量的文本墙很难吸收。如果你在 20 行段落的中间迷失或需要暂停,当你重新开始时,你也很难找到你的位置。通过添加新段落、图表等来分割信息,有助于人们找到并保持他们的方向感,减少认知负荷,使他们能够保持参与。
有些段落很难拆分——对于本书中的任何此类段落,我表示歉意——但作为一般规则,尽量将段落限制在每个段落一个专注的主题,并使用列表来列出自然适合列出的内容。使用平行结构([`mng.bz/wyyB`](http://mng.bz/wyyB))以便人们可以逻辑地分组短语和概念。
### 8.4.6 为文档供电
因为编写文档可能是一个高摩擦的活动,所以一些最好的文档创建工具专门改进了编写过程。它们可能使编写风格化文本的语法不那么繁琐,帮助检查你的文档是否过时,或者帮助你更有效地交叉引用内容。以下是一些可以进一步探索的快速提示:
+ 如果你看到一些由 Sphinx 提供支持的文档,因为其风格或功能而喜欢,你几乎总能查看生成它的源代码。许多由 Sphinx 提供支持的网站直接链接到 GitHub 中的相关文件。你还可以查看该项目的 Sphinx 配置,看看它是否使用了任何有趣的扩展或技术,你可以在自己的项目中采用。Django 的配置([`mng.bz/qooN`](http://mng.bz/qooN))有很多东西需要学习,但它有很多有价值的东西你可以探索。
+ 如果你更喜欢 Markdown 而不是 reStructuredText,或者你的项目需要两者,请查看 MyST 项目([`mng.bz/N59n`](http://mng.bz/N59n))。
+ Python 的 `doctest` 模块([`docs.python.org/3/library/doctest.xhtml`](https://docs.python.org/3/library/doctest.xhtml))可以测试代码示例中的 docstrings,以确保它们仍然有效。这是一种确保你的文档保持更新的好方法。
+ 阅读并观察大型项目的文档结构,即使它们不是 Python 项目。Vue JavaScript 框架的文档([`v3.vuejs.org/guide/introduction.xhtml`](https://v3.vuejs.org/guide/introduction.xhtml))是一个很好的参考。
+ Napoleon 扩展([`mng.bz/m22y`](http://mng.bz/m22y))允许您使用一些替代格式来编写 docstrings,这些格式仍然可以被正确解析成结构化的文档。
+ 如果您不喜欢默认的 Alabaster 主题,Sphinx 提供了其他可用的内置主题([`mng.bz/5mmZ`](http://mng.bz/5mmZ)),并且有一个完整的 Sphinx 文档主题社区([`sphinx-themes.org/`](https://sphinx-themes.org/))。您还可以从头开始自定义 Sphinx 或修改现有主题以适应项目的需求。
## 摘要
+ 文档对于项目的成功采用是必要的。
+ 不同用户可能有不同的目标,文档应该专注于一次满足一个目标。
+ 使用支持链接和交叉引用的技术来支持读者浏览文档。
+ 保持文档更新是一项挑战,因此找到自动化您能做的事情的方法。将代码参考文档与代码保持紧密,并自动提取以供高级使用。
+ Sphinx 是一个可扩展的框架,用于从文本和代码文档中构建文档。
+ Read the Docs 是一个流行的公共文档平台,支持 Sphinx。
+ 在写作时考虑读者;如何以最清晰、最诚实的方式表达需要表达的内容?
# 9 使你的包保持常青
本章涵盖
+ 为你的包发布选择版本化策略
+ 使用 GitHub 的 Dependabot 自动化依赖项更新
+ 设置测试覆盖率阈值
+ 使用 pyupgrade 升级你的 Python 语法
+ 使用 pre-commit 钩子减少返工
在前面的章节中,你成功地在本地构建了一个包,然后发布它,以便你所有客户公司的开发者都能从你的辛勤工作中受益。你可能会想象在这个时候你已经做了大部分工作,但发布一个项目对许多开发者来说通常只是开始。当人们开始使用你的包时,新的和有问题的用例开始出现。一个流行的开源项目可能变成一项持续多年的工作。
即使尘埃落定,项目达到稳定成熟水平,偶尔的更新或错误修复也会出现。如果维护者有一段时间没有打开项目,这些时刻可能会变得代价高昂。如果项目周围的依赖项和工具生态系统自上次更新以来发生了显著变化,那么可能只是一行简单的更改也可能变成更新依赖项到兼容版本并让项目再次运转的几天之旅。在最坏的情况下,这种情况发生在安全漏洞面前;高压力和高风险不会在您进行仔细更新时帮上忙。
我所描绘的这幅图景并非旨在吓唬你;相反,我希望它能给你留下持续维护和自动化的重要性印象。如果你想保持生产力,防止软件腐化,并将你的项目长期维持下去,你需要保持一个丰富的实践工具箱。本章涵盖了一系列工具和哲学,但不应该被视为全面的;目的是在项目演变过程中实践持续学习,这样它将保持*常青*——就像针叶树在整个冬天都保持常青一样。
重要提示:您可以使用代码伴侣([`mng.bz/69A5`](http://mng.bz/69A5))来检查本章练习的工作。
### 9.1 选择包版本化策略
你首先在第三章中创建了你的包及其分发元数据,包括*版本*。分发包的版本有助于将其与其他版本区分开来。你最初给你的包分配了版本 `0.0.1`,然后在第七章中,你使用这个版本将发布发布到 Python 包索引。在项目的早期阶段,版本通常只是物流的细节,简单地作为唯一标识符,以便每个发布都可以区分。但随着时间的推移,使用你的项目的人期望它传达有关发布中包含内容的更多信息。你需要为你的发布定义一个版本化策略。在直接深入了解这些细节之前,你首先需要了解 Python 生态系统中依赖项和发布版本之间的相互作用。
### 9.1.1 直接和间接依赖项
并非所有依赖项都是平等的。当您考虑您的包及其依赖项时,您可能首先想到的是在`install_requires`元数据列表中指定的那些,或者在您的 tox 环境中指定的`deps`列表中的那些。这些是您直接在代码中导入的依赖项,或者是在您项目开发过程中直接使用的依赖项。例如,您的包依赖于`termcolor`包以提供样式化的输出,当您开发您的包时,您依赖于像`mypy`和`black`这样的包。这些是*直接依赖项*,因为您通过名称明确引用了它们。
您项目的直接依赖项本身可能直接依赖于其他包,这些包可能又依赖于其他包,以此类推。比您项目的直接依赖项低一层或更多层的这些依赖项被称为*间接依赖项*。
注意:您可能会遇到使用不同术语来描述依赖项的来源。一些来源可能使用“具体/抽象”或“依赖/子依赖”来分别指代直接和间接依赖项。由于 Python 只允许在特定环境中安装一个版本的包,这些术语基本上是可以互换的。
一个依赖项可以同时是直接和间接的;您的项目可能直接依赖于包 A 和包 B,而包 B 可能直接依赖于包 A。以这种方式,一个项目的依赖项会产生一个图(如图 9.1 所示)。

图 9.1 Python 项目依赖于直接和间接依赖项的图。
每当您开始在项目中使用新的依赖项时,都要在脑海中保留这个图模型。这不是大多数工具会直接指出的概念,因此您需要将其内化到自己的理解中。当解决某些依赖项问题时,图模型会很有用,如下一节所述。
实际中的直接和间接依赖项
直接和间接依赖项的不对称性偶尔会在 Python 工具中出现。当您使用`python -m pip install`命令安装包时,您只指定直接依赖项。当您使用`python -m pip list`命令列出包时,它会列出所有已安装的包,无论是直接还是间接的。这可能会导致错误。想象一下,您之前将`package-a`作为直接依赖项添加。您有一段时间没有在您的项目上工作,当您稍后回来时,您想查看已安装的内容。当您列出已安装的包时,您看到`package-a`和`package-b`都已安装。`package-b`仅因为`package-a`依赖于它而安装;除非您仔细检查您的直接依赖项,否则您可能会错误地认为您可以在项目中安全地使用`package-b`。如果`package-a`的新版本停止依赖于`package-b`,这可能会导致 Python 在应用程序运行时产生`ImportError`,这种错误可能会在以后破坏您的项目。
区分直接和间接依赖
尽管在撰写本文时,pip 将直接和间接依赖简化为单个列表,但其他工具确实跟踪这两种类型依赖之间的区别。例如,poetry ([`python-poetry.org`](https://python-poetry.org)) 提供了一个`poetry show --tree`命令,列出已安装的依赖关系;它使用树而不是图来线性列出包。
还有其他依赖关系安装的方法,例如 pip-tools 流程([`mng.bz/xMdX`](http://mng.bz/xMdX))。这很有价值,因为你仍然需要显式地管理你的直接依赖,但你也会得到一个更可重复的构建,因为 pip-tools 生成一个静态的直接和间接依赖关系列表,而不是每次安装依赖关系时再次解析它们。尽管功能强大,但我建议在向项目中添加这种复杂性之前,先熟悉依赖关系管理的核心行为。
暂时将依赖关系视为一个 API。直接依赖是公共 API 的一部分,而间接依赖是私有 API 的一部分。你应该只依赖于公共 API,因为私有 API 可能会在未通知的情况下发生变化(见图 9.2)。

图 9.2 将依赖关系视为公共和私有行为之间的接口
总是要确保你导入到运行时应用程序中的任何包都在`install_requires`元数据中指定,并且确保你用于开发项目的任何包都在适当的 tox 环境中`deps`列表中指定。这种做法将确保你的项目不会因为间接依赖关系的变动而中断。如果你将来遇到此类问题,你对依赖关系图模型的理解将引导你检查所有导入的包是否为直接依赖。
当像 pip 这样的工具需要确定要安装的依赖关系版本集时,依赖关系图与发行包的版本有关。
依赖关系解析非同小可
在一个项目的依赖关系中满足所有约束可能很困难。需要考虑的因素比你想象的要多。尽管依赖关系解析算法不在此书的范围之内,但 pip 的依赖关系解析算法更新故事却很有趣(查看 Podcast.__init__,第 264 集,[`mng.bz/woKQ`](http://mng.bz/woKQ))。
### 9.1.2 Python 依赖指定符和依赖地狱
在第四章中,你将`termcolor`包作为依赖项添加。回想一下,你指定了允许任何大于 1.1.0 且小于 2.0.0 的版本,如下面的代码片段所示:
install_requires =
termcolor>=1.1.0,<2
PEP 440 ([`www.python.org/dev/peps/pep-0440/`](https://www.python.org/dev/peps/pep-0440/)) 涵盖了你可以对包进行版本化的各种方式,以及反过来如何指定依赖项版本。在最常见的案例中,项目以以下方式指定依赖项,从最严格到最宽松:
1. 精确匹配版本,通常称为*固定*。`termcolor==1.1.3`是版本 1.1.3 的精确匹配示例。
1. 一个下限和上限,这些可能是精确匹配或前缀匹配。`termcolor>=1.1.0,<2` 或 `termcolor~=1.1` 允许安装任何大于或等于 1.1.0 但小于 2 的版本。
1. 仅下限。`termcolor>=1.1.0` 允许安装任何大于或等于 1.1.0 的版本。
1. 没有版本。没有额外指定的`termcolor`允许安装任何版本的`termcolor`。
考虑`termcolor`包所有可用的发布版本集。它们可能从版本 0.0.1,就像你的包一样,一直到版本 5.6.2,或者 10.8.19,或者 1000.5.2。通过指定允许安装的版本范围,你可以限制安装器只解决更小版本的集合。除了你对项目直接依赖项施加的约束外,项目依赖的包也可能进一步限制允许的版本集。如图 9.3 所示,这些约束可能并不总是很好地协同工作。

图 9.3 依赖项版本指定符作为给定包所有可用发布版本集的约束。
当由于依赖项版本约束导致解析变得不可能时,通常的做法是调查一系列潜在的测试,以检查升级你的直接依赖项之一是否解决了问题。因为这种情况的图有时看起来像菱形,所以这种情况有时被称为*菱形依赖冲突*(图 9.4)。

图 9.4 依赖关系解析有时由于冲突而变得不可能,而菱形依赖冲突是最常见的类型之一。
因为这很少是件有趣的事情,而且几乎总是令人沮丧,所以它有时也被称为*依赖地狱*。
管理依赖地狱
实话实说,依赖地狱是软件开发现实的一部分。但它通常被那些不必要地将它们的依赖项约束在一个狭窄范围内的包所加剧。如果你使用了特定版本依赖项中引入的功能,那么指定该版本作为允许版本的下限是有意义的。上限则没有太多意义;它们通常只在你的库与较新版本有已知的兼容性问题时使用(参见 Henry Schreiner 的*Should You Use Upper Bound Version Constraints?*,[`mng.bz/099v`](http://mng.bz/099v),对上限危险的深入探讨)。
通过尽可能宽泛地设置直接依赖项允许的版本范围,你最大化了那些使用你的包与其他依赖项一起使用时的选项。
现在你已经对直接和间接依赖、依赖图、版本指定符以及这些可能产生的紧张关系有了坚实的理解,你就可以开始考虑你想要用于自己包版本控制的策略了。
### 9.1.3 语义版本控制和日历版本控制
到目前为止,Python 生态系统中最突出的两个包版本管理方法分别是**语义版本控制**([`semver.org/`](https://semver.org/))和**日历版本控制**([`calver.org/`](https://calver.org/))。这两种方法都与 PEP 440 规范兼容,但它们各自强调了一个发行包版本发布的不同信息。
语义版本控制旨在传达发布中行为 API 变化的程度。它关注以下方面:
+ 如果安装了此版本,是否会破坏任何现有行为?如果是,则这是一个**重大**变更。最重要的版本标识符数字应增加 1。
+ 如果保持现有行为不变,此版本是否添加了新行为?如果是,则这是一个**次要**变更。下一个最重要的版本标识符数字应增加 1。
+ 如果没有添加新行为,则更改必须修复破坏的行为,因此这是一个**补丁**变更。最不重要的版本标识符数字应增加 1。
此方案有助于你辨别版本 2.0.1 修复了版本 2.0.0 中存在的问题,或者当你从版本 2.7.3 升级到版本 3.0.0 时可能需要更新你的使用方式。在导航各种包时,这可能是一个非常有益的方案。
练习 9.1
记录以下每个之后的重大、次要和补丁发布的最终语义版本:
+ 17.8.3
+ 0.4.6
+ 1.0.19
语义版本控制的一个问题是,特定的版本可能过度承诺用户可以期待的内容,无论是由于维护者的错误还是用户对版本控制方案过度自信。如果你修复了一个错误,但修复错误也破坏了现有行为,它应该是一个补丁还是重大版本发布?严格来说,你应该发布一个重大版本。但即使是 semver 规范也说要“使用你的最佳判断”。如果你选择发布补丁版本,而用户认为你不会在没有重大版本发布的情况下破坏功能,那么就会发生沟通障碍,因此有潜在的挫败感。
语义版本控制的一个不太重要的问题是,它不能给你一个特定版本发布时间的概念。你通常可以在发布它的包仓库中查找这个信息,但当你对查看多个包或版本感兴趣时,这可能很麻烦。语义版本控制甚至可能让用户相信一个特定版本是在另一个版本之前发布的,因为版本号,但这并不保证;你可以在某一天发布版本 4.0.0,然后在第二天发布版本 2.1.0 的修复作为版本 2.1.1。时间线不可保证,甚至语义版本控制的语义也不保证,这部分是日历版本控制产生的原因。
日历版本控制是一种不太精确的规范,但一般来说,使用日历版本控制方案的项目通常以当前年份或月份开始每个版本,然后跟一个更具体的版本号。通常,使用日历版本控制的项目也会按照一定的计划发布新版本,尽可能多地包含更新和修复,直到下一次发布。这为时间线提供了可预测性,但并不保证 API 的任何变化。
单源包版本
你可能会在网上发现一些讨论单源包版本价值的文章。这是一件值得做的事情,因为如果你将版本存储在多个地方,你不可避免地会在某个时候更新一个而忘记另一个。历史上,这种讨论出现是因为项目作者有一个习惯,在包的根 `__init__.py` 模块中提供包的版本作为 `__version__` 属性。因为版本还需要在 setup.cfg 或 setup.py 等文件中指定,以便包构建工具能够识别它,所以需要一次指定版本并在两个地方使用它的方法(参见 *Python 包管理用户指南*,“单源包版本”,[`mng.bz/qYp2`](http://mng.bz/qYp2))。
`__version__` 属性仅是一种常见的做法,并没有标准化——它唯一被提及的地方是在被拒绝的 PEP 396 ([`www.python.org/dev/peps/pep-0396/`](https://www.python.org/dev/peps/pep-0396/))。未来的最佳实践是使用 `importlib.metadata.version` 函数,正如你在第八章中配置 Sphinx 文档时所做的。采用这种方法,你只需在包的静态元数据中指定版本,就可以在代码的其他部分或用户的代码中读取它。
在两种方法中,语义版本控制仍然是最常见的。使用顺序版本方案是有意义的,因此您需要决定哪个对您和您的用户来说更直观。最终最重要的是沟通,而沟通往往需要更多的努力。关于发布沟通的最好方式之一是通过一致且详尽的变更日志,您将在第十一章中了解更多关于变更日志的内容。
下一个章节将介绍 GitHub 提供的一些用于管理依赖项版本的功能。
### 9.2 充分利用 GitHub
作为软件项目变更的流行协作平台,GitHub 被定位为一个进行软件项目维护的有用场所。在过去几年中,他们开发或收购了几个用于管理软件依赖的有用工具:安全扫描、自动漏洞修复和依赖项更新,以及依赖图。在以下章节中详细了解每个功能。
注意:以下章节中提到的类似功能在不同平台上的可用性各不相同。如果您想为您的软件协作使用其他平台,您需要阅读他们的文档以了解他们提供的内容。
### 9.2.1 GitHub 依赖图
GitHub 会检查您仓库中的文件,并从中提取关于依赖项的结构化知识。这适用于多种语言,甚至适用于一些工作流和框架级别的功能,如 GitHub Actions。在所有仓库中,GitHub 使用这些结构化数据生成依赖项和依赖者的图。依赖图对所有公共仓库都是启用的,您也可以在仓库设置中将其启用为私有仓库。因为您的仓库是公开的,所以依赖图已经启用。
现在访问您仓库的 GitHub 页面。点击“洞察”标签;然后,在左侧导航中点击“依赖图”。GitHub 会根据每个文件显示它能够识别的依赖项。对于每个依赖项,它都会链接到该项目的仓库,并显示您的项目所依赖的版本。图 9.5 显示,GitHub 在一个项目的 GitHub Actions YAML 文件中找到了对 `setup-python` 动作版本 2 的依赖,并提供了一个链接到该动作的仓库。

图 9.5 使用 GitHub Actions 的项目在 GitHub 依赖图中的依赖关系
注意:截至本文写作时,GitHub 不支持在 setup.cfg 文件中定义的 `install_requires` 依赖项。[`mng.bz/7yAy`](http://mng.bz/7yAy)。请帮助项目在 GitHub 依赖图中获得更好的支持,通过点赞并加入我在此主题上的功能请求讨论。[`mng.bz/K00O`](http://mng.bz/K00O)。
你还可以点击“依赖项”选项卡来查看任何依赖你的项目。你可能没有使用你为这本书创建的包的用户,但你可以在其他流行的项目(如 requests 包)中看到这一功能的实际应用示例([`mng.bz/9VVr`](http://mng.bz/9VVr))。截至 2022 年 3 月,超过一百万个项目依赖于 requests(图 9.6)!

图 9.6 来自流行项目的 GitHub 依赖关系图中的依赖项
除了显示你项目的依赖关系外,GitHub 还可以检查它们是否存在安全漏洞。
### 9.2.2 使用 Dependabot 缓解安全漏洞
导航到你的 GitHub 仓库的设置页面。在左侧导航中,点击“代码安全和分析”。在这个页面上,你可以找到 GitHub 提供的各种依赖项安全功能,包括依赖关系图,如这里所述:
+ *Dependabot 警报*—GitHub 可以为你依赖的有漏洞的包创建自动通知,并提供缓解建议。默认情况下,此选项是开启的。
+ *Dependabot 安全更新*—当 Dependabot 发现一个有漏洞的依赖项时,启用此选项将自动打开一个拉取请求以更新到非漏洞版本(如果可用)。默认情况下,此选项是关闭的。
+ *代码扫描*—GitHub 还可以扫描你的项目代码以查找漏洞。默认情况下,此选项是关闭的。
+ *秘密扫描*—GitHub 扫描你的代码以查找可能泄露的密码、API 密钥等,以保护你免受那些刮取并使用这些信息的攻击者。此功能始终开启。
注意,为了示例,Dependabot 安全警报不容易生成,并且任何现有的漏洞都是对项目维护者敏感的私人信息。请参考 GitHub 自己的文档以查看示例并了解如何与警报本身交互([`mng.bz/mOM2`](http://mng.bz/mOM2))。
这可能看起来需要吸收很多信息,但这些功能都是自动化的,并提供可操作的警报或拉取请求,你可以根据需要做出回应。安全最好作为一个多层次的流程来执行,因为每一层都有自己的重点和不足(詹姆斯·T·雷恩,《潜在人为错误对复杂系统崩溃的贡献》,《皇家学会哲学学报》,[`mng.bz/jAAe`](http://mng.bz/jAAe))。你能在你的安全策略中引入更多多样性,那就越好。
启用 Dependabot 安全更新
在“Dependabot 安全更新”旁边点击“启用”。Dependabot 会在可能的情况下打开一个拉取请求来更新有漏洞的依赖项。Dependabot 会从`@dependabot`用户打开拉取请求,除了代码更改外,拉取请求还包括以下有用的信息:
+ 正在更新的依赖项是哪个
+ 依赖项在项目中的位置
+ 依赖项更改前后的版本
+ 发布说明、变更日志以及旧版本和新版本之间的提交记录
+ 如果已知,新版本引入破坏性更改的可能性有多大
您还可以通过评论与拉取请求互动,让 Dependabot 执行额外的操作。重要的是,Dependabot 不会在拉取请求中表明更改解决了漏洞,因为这会警告恶意行为者尝试利用该漏洞。图 9.7 展示了来自`black`包存储库的一个示例拉取请求描述。

图 9.7 Dependabot 打开拉取请求以更新易受攻击的依赖项,并提供有关评估新版本兼容性的信息。
Dependabot 打开拉取请求后,您可以通过观察测试状态和代码质量检查来评估更改的兼容性。您还可以在本地检查代码以进行任何手动验证。如果更改看起来是兼容的,您可以合并拉取请求。Dependabot 检测到更新的依赖项并移除任何相关的漏洞警报。接下来,您将配置 GitHub 的代码扫描以扫描您自己的代码中的安全问题和错误。
启用 GitHub 代码扫描
GitHub 使用一个名为 CodeQL 的系统,简称“代码查询语言”,它使开发者能够查询其代码库中的特定代码结构([`codeql.github.com/`](https://codeql.github.com/))。CodeQL 的工作方式类似于 mypy、black 和 flake8 等工具,这些工具使用 Python 的抽象语法树来查找问题。例如,您可以使用 CodeQL 查找 Django 项目中易受 SQL 注入攻击的区域,因为代码将未经验证的用户输入直接传递到数据库查询中。人们可以向社区提交 CodeQL 查询,以识别常见的安全问题和错误。您可以在几个步骤中在存储库中启用 CodeQL 扫描,如下所示:
1. 导航到您存储库的代码安全和分析设置。
1. 点击代码扫描旁边的“设置”。
1. 点击“在 CodeQL 分析中设置此工作流程”,GitHub 将带您到一个预先填充的新文件创建视图,用于.github/workflows/codeql-analysis.yml。
1. 将`on.schedule.cron`值更新为运行您所需的频率。每天一次是一个好的起点;如果您不熟悉语法,可以使用 Cron Helper([`cron.help`](https://cron.help))构建 cron 表达式。
1. 确保在 YAML 配置中的`language`字段设置为`[ 'python' ]`。GitHub 应该为您完成此操作,但如果它没有检测到或检测到不同的语言,您可以更改此值。
1. 点击“开始提交”,并填写所需的提交详细信息。您可以直接提交到主分支或创建一个新的分支以打开拉取请求。
1. 点击“提交新文件”。
然后,如果您选择创建新的分支和拉取请求,点击“创建拉取请求”,并在检查通过后合并拉取请求。
在将 CodeQL 扫描配置添加到您的仓库后,GitHub 将在每次拉取请求时以及您设置的定期计划中扫描您的仓库。GitHub Action 的结果将显示在您的拉取请求中,与您在之前章节中创建的结果并列,这样您就会知道您的更改是否引入了 CodeQL 发现的任何漏洞或错误。由于它是定期运行的,您还可以了解是否有任何新识别的漏洞存在于您的代码中,即使您最近没有打开拉取请求。这种主动扫描对于每天不更新的成熟项目尤其有帮助。
在 Dependabot 警报、自动化更新和代码扫描就绪后,您可以有信心,您的依赖项和代码更改未来不会因安全漏洞而影响您的项目。尽管如此,您的项目仍存在一个威胁模型:*衰减威胁模型*。
使用 Dependabot 自动更新依赖项
衰减威胁模型(这个短语首次由 YCombinator 用户 javajosh 提出,[`news.ycombinator.com/item?id=29474932`](https://news.ycombinator.com/item?id=29474932))表明,您最大的威胁可能不是来自外部的恶意行为者,而是由于缺乏维护,您自己的软件和生态系统在您周围崩溃。除了您的项目依赖项有漏洞外,您还应保持它们更新,以免遇到依赖地狱或让您抓狂的“大爆炸”更新。Dependabot 最初就是为了这个用例而创建的。
您可以配置 Dependabot 以自动为您提升依赖项版本,即使现有版本没有漏洞([`mng.bz/WMMW`](http://mng.bz/WMMW))。为此,您可以在 .github/dependabot.yml 文件中配置软件生态系统、项目位置、策略和更新的频率。请注意,许多这些设置相当主观;您需要根据您和您团队的工作节奏进行调整,以避免挫败感。
对于您的包的 Dependabot 更新,一个良好的最小可行起点是每天检查一次 GitHub Actions 和您的 Python 依赖项的更新。您需要使用以下字段:
+ `version`—当前 Dependabot 配置的版本。在撰写本文时,版本为 `2`。
+ `updates`—要检查可用更新的配置列表。
+ `package-ecosystem`—给定配置的生态系统。您将有一个用于 `github-actions` 和一个用于 `pip`。
+ `directory`—检查当前依赖项版本的目录。您可以使用 `"/"` 来配置您的配置。
+ `schedule.interval`、`schedule.day`、`schedule.time`、`schedule.timezone`—检查更新的频率。每周一早上进行一次检查可能是一个好的起点。
提示:要获取所有可用配置的完整列表,请参阅 GitHub 文档 ([`mng.bz/5QV1`](http://mng.bz/5QV1))。
现在在您的项目 .github 目录中创建 dependabot.yml 文件。请注意,它不应位于与您的 GitHub Actions 并排的 .github/workflows/ 目录中,因为它不是 GitHub Action。完成之后,您的配置应类似于以下列表。
列表 9.1 一个每周更新的示例配置
version: 2 ❶
updates: ❷
-
package-ecosystem: "github-actions" ❸
directory: "/" ❹
schedule:
interval: "weekly" ❺
day: "monday" ❻
time: "09:00" ❼ -
package-ecosystem: "pip" ❽
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
❶ Dependabot 配置版本
❷ 更新检查的配置列表
❸ GitHub Actions 依赖项的更新配置
❹ 从项目根目录开始检查依赖项
❺ 每周检查依赖项更新
❻ 每周一检查依赖项
❼ 在 09:00 检查依赖项
❽ Python 依赖项的更新配置
将此新文件提交并推送到您的仓库。文件添加后,Dependabot 将根据您指定的计划检查更新依赖项的机会。如果 Dependabot 找到任何符合您参数的更新,它将打开一个看起来与它为安全漏洞更新打开的请求完全相同的拉取请求。
现在您已经了解了关于依赖项的各种实践,请继续阅读,了解影响您项目持续集成状态的其他方面。
### 9.3 阈值测试覆盖率
在第五章中,您使用 pytest 和 pytest-cov 为您的包添加了单元测试和测试覆盖率测量。此配置有助于您了解您的代码中有多少未测试的部分以及哪些文件覆盖率最低。尽管这是有用的信息,但它缺乏任何强制措施。
对于许多项目来说,测试覆盖率落后是自然的。并非所有贡献者都会包含测试,反复要求人们编写测试可能会让您显得不好,尽管您只是试图保护项目。正如您可能猜到的,有一个自动化的过程告诉人们这一点会大有帮助。他们会得到通知,您不需要在大多数情况下介入。如果您的项目在覆盖率方面已经落后很多,开始强制执行测试覆盖率可能看起来是一项艰巨的任务,但事实正好相反。您的目标应该是首先停止出血,这样覆盖率就不会变得更糟,然后添加强制措施,确保覆盖率只会变得更好。
请记住,100%的覆盖率并不一定是最终目标;它可能很难实现,并且回报递减。通过专注于*单调递增*的覆盖率,您不需要如此焦虑地考虑您目前的位置和您想要达到的最终状态之间的差距。相反,您可以确保覆盖率在最坏的情况下保持不变,并随着时间的推移逐步提高。这个系统就像一个只能向一个方向紧固的棘轮。每当添加新的测试来提高覆盖率时,您都需要一种方式来说明覆盖率永远不会再次低于那个新值(见图 9.8)。

图 9.8 优先考虑单调递增的覆盖率,以实现随时间推移的持续、增量改进。
您可以使用一行代码为项目构建测试覆盖率阈值机制。打开您的 setup.cfg 文件,找到`[coverage:report]`部分。回想一下,您在第五章中使用此部分来控制在运行测试的 tox 环境时如何报告覆盖率。您可以在该部分添加一个`fail_under`键,其值为 0.0 到 100.0 之间的浮点数。如果测试覆盖率百分比低于您指定的值,测试运行后报告覆盖率的那一步将失败,并显示类似于以下片段的消息:
FAIL Required test coverage of 78.9% not reached. Total coverage: 33.33%
练习 9.2
现在运行您的测试。它们应该仍然是从前面章节中的工作达到的 100%覆盖率。将`fail_under`值设置为 100.0,然后再次运行测试。它们通过了吗?尝试暂时删除一个或两个测试。现在它们通过了吗?
每当您的覆盖率提高时,您都应该将`fail_under`值更新到新的阈值。当其他人贡献新代码而没有新测试时,测试的 GitHub Action 会因为覆盖率下降而失败,这会让他们知道需要测试他们的工作。
小贴士 确保观察测试矩阵所有组合的覆盖率百分比。不同的依赖关系可能会导致覆盖率百分比略有不同,因此您需要将这些中的最低值设置为`fail_under`,以确保它们不会因未达到阈值而失败。
在掌握安全和测试覆盖率之后,接下来关注一个不太常被考虑的方面:您使用的 Python 语法。
### 9.4 使用 pyupgrade 更新 Python 语法
使语言演变的部分原因不仅在于它提供的功能,还在于您用来编写程序的语法。随着时间的推移,语法糖被添加进来,使得某些结构更容易实现,有时使用新语言版本的内置语法比旧的手动方式更快或更正确。在某些情况下,新的语法甚至使得以前不可能的事情成为可能。
pyupgrade ([`github.com/asottile/pyupgrade`](https://github.com/asottile/pyupgrade)) 更新你的 Python 代码的语法,以利用你的项目支持的 Python 版本中可用的较新语法。就像 black 一样,pyupgrade 使用抽象语法树来确保新旧代码在功能上是等效的。同样,像 black 一样,你只需要运行 pyupgrade 命令,并告诉它需要在任何更改后继续支持哪些 Python 版本。
就像代码格式化一样,更新语法可能是在你的代码功能、测试、类型检查等完成之前你不想让它打扰你的事情。这最好通过在仓库中使用 pre-commit 钩子来处理。继续阅读下一节,以设置一个利用 pyupgrade 的钩子。
### 9.5 使用 pre-commit 钩子减少返工
*Pre-commit hooks* 是在你尝试将更改提交到版本控制系统时运行的可执行代码。Git 对版本控制生命周期的各个部分提供了原生的钩子支持,其中 pre-commit 因其能够在开发过程中较早地转移一些代码质量检查的能力而变得流行。这些钩子可以帮助阻止不适当的代码最初就进入仓库。你可以创建自己的完全定制的钩子,但 Git 不会强迫其他开发者安装这些钩子,随着时间的推移,管理许多不同的钩子可能会变得繁琐。
pre-commit ([`pre-commit.com/`](https://pre-commit.com/)) 是一个用于管理 pre-commit 钩子的框架。它提供了比原生处理 Git 钩子更好的几个改进,如以下所述:
+ 钩子可以从互联网上的仓库中安装,创建一个基于插件的架构。
+ 大多数钩子在隔离的容器中运行,这降低了它们对安装它们的仓库之外的内容产生影响的可能性。
+ 大多数钩子仅在更改的文件上运行,这对于昂贵的检查很有用。当你需要时,你仍然可以在所有文件上运行它们。
重要:在继续阅读之前,请访问附录 B 以安装本章所需的工具。
要开始配置 pre-commit,创建一个新的`.pre-commit-config.yaml`文件。在这个 YAML 文件中,你需要使用以下键:
+ `repos`—要从中获取 pre-commit 钩子的仓库列表。
+ `repo`—特定钩子的仓库,例如 URL。
+ `rev`—要使用的钩子版本。这个版本通常是特定仓库中的一些 Git 标签之一。
+ `hooks`—从指定仓库中要使用的钩子列表。
+ `id`—由指定仓库提供的钩子的唯一标识符。
+ `args`—在钩子运行时传递给钩子的额外参数。
提示:要获取所有可用配置的完整列表,请参阅 pre-commit 文档([`mng.bz/822D`](http://mng.bz/822D))。
为 pyupgrade 创建你的第一个钩子配置。在`.pre-commit-config.yaml`文件中填写以下信息:
+ pyupgrade 的仓库是 [`github.com/asottile/pyupgrade`](https://github.com/asottile/pyupgrade)。
+ 本书写时最新的版本是 `v2.31.0`。
+ 钩子的标识符为 `pyupgrade`。
+ 传递给 pyupgrade 的参数表示您想要支持的 Python 版本。例如,`--py37-plus` 支持 Python 3.7 及以上版本。`--py3-plus` 支持所有 Python 3 版本。您在这里指定的版本应与您在 pyproject.toml 中为 black 指定的版本以及您在 tox `envlist` 中指定的版本一致。
在完成您的 pyupgrade 配置后,它应该看起来类似于以下列表。
列表 9.2 使用 pyupgrade 的示例预提交配置
repos: ❶
- repo: https://github.com/asottile/pyupgrade ❷
rev: v2.31.0 ❸
hooks: ❹- id: pyupgrade ❺
args: ['--py39-plus'] ❻
- id: pyupgrade ❺
❶ 所有钩子仓库列表
❷ pyupgrade 仓库
❸ pyupgrade 钩子的版本
❹ 要使用的钩子列表
❺ pyupgrade 的主要钩子
❻ 传递给钩子命令的参数
在为您的仓库创建配置后,通过在项目根目录中运行 `pre-commit install` 命令将 pre-commit 安装到您的仓库中,以便它可以管理预提交钩子。在安装预提交钩子后,您所做的任何新提交都会触发钩子对更改的文件运行。要针对您项目中的所有文件运行预提交钩子,您可以运行 `pre-commit run --all-files` 命令。现在运行它,并观察 pyupgrade 是否进行了任何语法更改。
练习 9.3
越来越多的工具提供了预提交钩子。flake8 ([`github.com/pycqa/flake8`](https://github.com/pycqa/flake8)) 和 black ([`github.com/psf/black`](https://github.com/psf/black)) 都提供了预提交钩子。这些钩子的配置几乎相同,其中 `args` 或其他键的具体配置因工具而异。现在将 flake8 和 black 的钩子添加到您的 pre-commit 配置中。black 和 flake8 的配置包含在代码伴侣中。您应该阅读您喜欢的工具的文档,以了解是否以及如何将它们用作预提交钩子。
虽然预提交钩子可能感觉像是大幅提高生产力的工具,但重要的是要认识到,如果它们变得过于昂贵且运行缓慢,它们可能会产生不良的反效果。即使你的意图是提供对小型独立提交的更紧密的反馈循环,缓慢的提交钩子也可能鼓励人们等到他们完成所有工作后再进行提交。在安装的每个检查的价值和它们执行所需的时间之间找到一个平衡点。
你现在可以对代码及其依赖项进行安全扫描,实现单调递增的测试覆盖率,最新的 Python 语法,以及防止一些常见错误被提交到仓库的方法。这将减少项目在其生命周期中的大量噪音,并帮助你积极进化,避免衰败的威胁模型。在下一章中,你将回顾你所学的一些内容,并提取一个模板,以便你可以在创建的任何新项目中复制这种相同的体验。
### 练习题答案
**9.1**
+ 18.0.0, 17.9.0, 17.8.4
+ 1.0.0, 0.5.0, 0.4.7
+ 2.0.0, 1.1.0, 1.0.20
### 摘要
+ 软件依赖形成一个图,项目作者应尽量减少对依赖的限制,以实现与其他包的最大互操作性。
+ 依赖项通过安全漏洞和过时性影响你的项目。定期更新它们,以避免未来出现头痛问题。
+ 不要试图达到 100%的测试覆盖率,尤其是在现有项目中。相反,使用覆盖率阈值,随着时间的推移实现增量且单调递增的覆盖率。
+ 预提交钩子可以帮助你防止不适当的代码被提交,但你应谨慎使用它们,以鼓励频繁的小型提交。
# 10 规模化和巩固你的实践
本章涵盖
+ 使用 cookiecutter 提取项目模板以创建未来的包
+ 使用私有包仓库服务器发布和安装包
+ 使用命名空间包将大型项目拆分到几个包中
你在这本书的大部分时间里都在为发布一个包做准备。在这本书的整个过程中,我强调了可重复的过程和自动化的价值,但到目前为止,你关注的是单个包。现在你已经为你的包建立了一个稳固的过程,那么你接下来想要创建的下一个包呢?无论你是想维护开源项目,还是成为你组织中 Python 打包的专家,你不可避免地会创建和发布更多的包。虽然你可能希望通过从头开始构建另一个包来巩固你所学的内容,但经过四五个包之后,这个过程可能会开始显得相当单调。
在本章中,你将学习如何从现有的包中提取常见元素,以及一些在规模上处理私有和大型分布式包项目的技巧。
重要 在继续阅读之前,请访问附录 B 以安装本章所需的工具。
你可以使用代码伴侣([`mng.bz/69A5`](http://mng.bz/69A5))来检查本章练习的工作。
## 10.1 为未来的包创建项目模板
作为一般规则,你创建的每个项目都应该有一个明确的责任。这有助于你与要求新功能的人进行艰难的对话,因为你更容易确定哪些属于项目,哪些不属于。这也允许你的用户组合几个小包来实现他们的特定目标,而不是安装一个庞大的包,只使用其可用行为的一小部分。
虽然你的项目可能每个都有独特的责任,由不同的代码支持,但许多部分从包到包看起来都一样。这些常见的代码和配置,称为*样板代码*,对于项目运行是必要的,但通常不需要特别的关注,除了填写一些值。
想象你需要从头开始创建五个新的包,用于 CarCorp,遵循你在本书中学到的过程。想想所有你必须创建的不同配置文件和目录结构。你会在哪里出错?你将如何验证你做了一切正确?你认为这需要多长时间?你很可能首先想到了一些事情,在实践中,你经常会遇到一些你没有预料到的问题。由于人为错误的空间很大,包创建的可重复性不足可能会阻碍你开发新价值的速度。
而不是每次都从头开始创建包,你可以使用一个包含项目所需所有模板的模板系统,并帮助你填写所需的项目特定信息。你甚至可以将你的项目模板放入版本控制中,并随着时间的推移对其进行改进,将你最新的标准应用到每个新创建的包中。如果你注意到 CarCorp 和其他客户之间的差异足够大,以至于可以将其编码化,你可以为 CarCorp 和你的其他每个客户创建特定的模板。在接下来的章节中,你将使用 cookiecutter ([`cookiecutter.readthedocs.io`](https://cookiecutter.readthedocs.io)),这是一个基于 Python 的项目,用于创建语言无关的项目模板。
### 10.1.1 创建 cookiecutter 配置
就像真正的饼干切割器一样,cookiecutter 项目之所以这样命名,是因为你用它来创建一个模板,然后使用该模板来制作几个形状相似的东西。尽管你创建的项目的基本形状将是相似的,但你也可以将某些部分标记为动态的,并在创建新项目时填写这些值(见图 10.1)。这有点像用不同的糖霜和撒料组合来装饰饼干。

图 10.1 使用 cookiecutter 从静态模板和动态用户输入的混合中创建新项目
在接下来的章节中,你将对这个新目录进行更改,将其转变为 cookiecutter 模板项目。
将此项目与 cookiecutter 一起工作的第一步是配置必须填写的动态值。为了了解这些值是什么,考虑一下目前特定于你的项目但需要在任何新项目中更改到其他值的事情,例如以下内容:
+ 项目名称,`first-python-package`
+ 导入包名称,`imppkg`
+ 项目目的的描述
+ 项目作者的姓名和电子邮件
+ 你提供项目的许可证
这些都是你可以使用 cookiecutter 来解决的问题。其他一些事情,例如以下内容,是特定于你的项目的,没有合理的方法将它们模板化以供其他项目使用:
+ 项目所依赖的包
+ 项目提供的代码和测试,包括非 Python 扩展
由于项目模板可能被用于难以预测的广泛环境中,你通常希望省略这些内容。这样,你就不会在创建的新项目中留下未使用的代码或依赖项。如果你计划创建一个模板,许多人将基于此模板创建自己的项目,你可以考虑提供可运行的示例代码,以帮助用户在创建后验证他们的项目是否正确配置。
小贴士:最终,您可能会创建足够多的项目,以至于它们的子集具有相同类型的定制或专业化。在您注意到这些模式跨越多个项目后,如果您计划创建更多类似的项目,您可以考虑提取一个单独的特殊项目模板。
一旦您确定了所有想要模板化的值,下一步就是创建配置文件。cookiecutter 会查找一个 cookiecutter.json 文件,其键是动态变量名称,其值是在创建包时显示的默认值,如下一列表所示。
列表 10.1 包含两个变量的小型 cookiecutter JSON 配置
{
"variable_one": "green", ❶
"variable_two": "blue" ❷
}
❶ `variable_one` 默认值为 "green"。
❷ `variable_two` 默认值为 "blue"。
您可以在项目模板的任何地方引用 `variable_one` 和 `variable_two` 变量,cookiecutter 在创建项目时会用默认值或用户指定的值替换它们。
提示用户输入
您可以通过运行 `cookiecutter` 命令并传递项目模板目录的路径来从项目模板创建一个项目。当您运行该命令时,cookiecutter 将提示您接受默认值或为 cookiecutter.json 文件中的键输入自定义值,如下一列表所示。
列表 10.2 运行 cookiecutter 并带有两个变量的示例输出
$ cookiecutter python-project-template
variable_one [green]: red ❶
variable_two [blue]: ❷
❶ 变量名称和默认值,以及提供的替代值
❷ 您可以按 Enter 键接受默认值。
对于默认选项为字符串的变量,您可以输入任何任意替代输入以使用除默认值以外的值。除了字符串外,cookiecutter 还允许您为给定变量配置值列表。与字符串变量不同,列表变量在运行 cookiecutter 命令时会显示所有可用值,并默认选择第一个;您必须选择这些选项之一,不能输入任意值。这在您的项目模板需要限制可能的选项或您希望方便地从几个选项中选择时非常有用。
从前面的示例中继续,以下列表显示了如何为变量添加选项列表。
列表 10.3 为 cookiecutter 变量指定可能的选项列表
{
"variable_one": "green", ❶
"variable_two": "blue",
"variable_three": ["foo", "bar", "baz"] ❷
}
❶ 使用字符串提供默认值
❷ 使用列表提供允许选项的枚举
使用此配置运行 `cookiecutter` 命令将产生与之前相同的前两个变量的输出,并额外显示第三个变量的所有选项(如下一列表所示)。
列表 10.4 运行 cookiecutter 并带有混合变量类型的示例输出
$ cookiecutter python-project-template
variable_one [green]:
variable_two [blue]:
Select variable_three: ❶
1 - foo ❷
2 - bar
3 - baz
Choose from 1, 2, 3 [1]: ❸
❶ 表示您必须进行选择
❷ 显示所有可用选项
❸ 输入选项的索引,或默认为第一个。
字符串和列表变量选项为你提供了足够的权力来制作你包动态部分的模板,但 cookiecutter 还有一个更方便的特性你可以利用。
建立在先前值的基础上
很常见,你的项目配置中的一个值将与另一个值相似,但并不完全相同。一个突出的例子是,分发包的名称通常与导入包的名称相似,但分发名称是带连字符的,而导入名称的连字符被移除或替换为下划线。例如,一个具有类似`flask-tools`的分发包名称可能具有类似`flasktools`或`flask_tools`的导入名称。
当你需要为两个类似值(如包名称)创建模板时,请自问以下问题:
+ 这两个值中是否有一个是比另一个更“规范”的?也就是说,它是否感觉像是另一个的派生?
+ 如果答案是肯定的,那么规范值是否可以轻松地通过编程转换成另一个值?
如果这两个问题的答案都是肯定的,你可以考虑只提示规范值,并自动生成另一个值。cookiecutter 模板系统使用 Jinja2 ([`palletsprojects.com/p/jinja/`](https://palletsprojects.com/p/jinja/))来注入动态内容,Jinja2 允许你在生成内容时使用 Python 表达式;稍后将有更多介绍。你可以在一个变量的值中使用 Python 表达式来根据前面的变量计算值。
警告:请注意,由于 cookiecutter 按照 cookiecutter.json 文件中变量的出现顺序提示变量,因此任何依赖于其他变量的变量必须放在它们依赖的变量之后。
例如,你可以提示用户输入分发包名称,然后使用提供的值生成有效的导入包名称选项。以下列表显示了如何使用 Python 的`str.replace`函数将用户提供的分发包名称中的每个连字符替换为空字符串或下划线字符,并将它们作为导入包名称的选项提供。
列表 10.5:从另一个变量值生成 cookiecutter 变量值
{
"distribution_package_name": "my-python-package", ❶
"import_package_name": [
"{{ cookiecutter.distribution_package_name
➥ .lower().replace("-", "") }}", ❷
"{{ cookiecutter.distribution_package_name
➥ .lower().replace("-", "_") }}" ❸
]
}
❶ Python 分发包名称通常包含连字符。
❷ 移除连字符以获得有效的导入包名称
❸ 将连字符替换为下划线以获得有效的导入包名称
使用此配置运行`cookiecutter`命令会产生以下输出。
列表 10.6:具有依赖变量的 cookiecutter 配置的输出
$ cookiecutter python-project-template
distribution_package_name [my-python-package]: ❶
Select import_package_name: ❷
1 - mypythonpackage
2 - my_python_package
Choose from 1, 2 [1]:
❶ 这里选择的值用于生成后续值。
❷ 这些值是从第一个变量生成的。
现在,你已经拥有了所有需要的配置工具,可以将你的包副本转换为 Python 包项目模板。下一步是创建 cookiecutter 配置并更新包内容以引用配置的变量,但在深入创建模板之前,你需要更深入地理解变量的流程和 Jinja2 的语法。
### 10.1.2 从现有项目中提取 cookiecutter 模板
Jinja2 在*渲染上下文*下工作,或者是一组包含其可以注入内容的值的变量。cookiecutter 工具集向上下文中添加了一个`cookiecutter`变量,该变量反过来具有与 cookiecutter.json 文件中配置的变量相对应的属性。Jinja2 通过解析输入作为字符串,然后识别并操作以下两种特殊表达式类型来*渲染*输出:
+ *占位符表达式*被双大括号(`{{ ... }}`)包围,并包含对上下文变量的引用。占位符表达式可以进一步使用 Python 字符串操作来操纵上下文变量的值;你之前在根据分发包名提供导入包名的转换选项时已经看到了一个例子。
+ *块表达式*被大括号和百分号(`{% ... %}`)包围。块表达式可以条件性地渲染内容,或者根据渲染上下文的不同值重复渲染同一块内容。
例如,你可以根据上下文变量的值使用下一列表中的语法渲染两块不同内容中的一块。
列表 10.7 使用条件块表达式的 Jinja2 控制流
{% if variable_one == "green" %}
It's green!
{% else %}
It isn't green.
这对于你的项目模板根据你配置的选项在文件中渲染不同内容是有用的。
在 Jinja2 解析表达式并渲染内容后,cookiecutter 创建一个包含渲染内容的输出项目。图 10.2 描述了之前看到的流程,并提供了关于 cookiecutter 和 Jinja2 的更多具体信息。

图 10.2 展示了 Jinja2 将动态上下文中的值渲染到静态内容中的占位符表达式内。
cookiecutter 模板设置的一个强大方面是它还适用于文件和目录的名称。因为你的包目录名称也是动态的,并且对包的正确运行很重要,所以你需要能够将这些也模板化。
重要的是,cookiecutter 期望你的项目模板的根目录是输出项目模板的包装器。换句话说,你的模板项目必须包含一个将成为输出项目根目录的目录。就像你的`first-python-package`项目一样,这个输出目录通常与分发包同名。你可以通过以下步骤实现这一点:
1. 在您的原始包项目旁边创建一个名为 python-project-template/ 的新目录,用于您的模板项目。
1. 在 python-project-template/ 目录中添加一个空的 cookiecutter.json 文件,您将在稍后进行配置。
1. 将 first-python-package/ 目录复制到 python-project-template/ 目录中。
1. 使用 Jinja2 占位符语法将 first-python-package/ 目录重命名为 {{cookiecutter.package_distribution_name}}/,以引用包的发行名称。
下一个列表显示了您的项目模板应该具有的目录结构。
列表 10.8 project-template-tree
python-package-template ❶
├── cookiecutter.json ❷
└── {{cookiecutter.package_distribution_name}} ❸
├──
└── ...
❶ 项目模板根目录
❷ Cookiecutter 配置位于模板根目录。
❸ 包根目录位于项目模板根目录内部。
您现在已经具备完成将您的包转换为项目模板所需的知识。
练习 10.1
从您的项目其余部分制作一个模板。您需要配置 cookiecutter .json 文件,提示以下内容:
1. `package_distribution_name`。
1. `package_import_name`; 从 `package_distribution_name` 的值生成带下划线和不带下划线的选项。
1. `package_description`; 在描述中使用 `package_distribution_name` 的值。
1. `package_license`; 建议使用 OSI 批准的分类器列表中的名称,提出一个开源许可子集([`pypi.org/classifiers/`](https://pypi.org/classifiers/))。
1. `package_author_name`。
1. `package_author_email`。
在配置 cookiecutter 以提示这些值后,您需要用占位符替换以下所有硬编码的引用,以便它们可以动态渲染:
+ src/ 目录内的导入包目录需要从 cookiecutter 变量动态确定。将其重命名为依赖于 `package_import_name` 变量。
+ 用占位符变量替换以下文件中的引用:
+ setup.cfg
+ README.md
+ docs/index.rst
+ docs/conf.py
然后,使用条件块表达式更改 LICENSE 文件的内容,为所选的 `package_license` 提供适当的许可内容。最后,删除您可能不想在项目模板中包含的以下部分:
+ setup.cfg 中的 `[options.entry_points]` 部分
+ setup.cfg 中的 `install_requires` 选项
+ src/ 和 test/ 目录中除了 `__init__.py__` 之外的其他模块
+ setup.py 和 pyproject.toml 中的 Cython 机制
您可以定期运行 `cookiecutter` 命令,使用项目模板生成项目并检查您的作品。
在 cookiecutter 从您的模板生成您喜欢的项目后,您可以提交模板到版本控制,并继续在未来创建新的项目。
## 10.2 使用命名空间包
本书构建的包是一个小型、独立的函数组件。有时,一个项目变得足够大,以至于即使所有行为在广义上仍然相关,也不再适合将其保留在单个包中。作为一个例子,想想像 Django ([`www.djangoproject.com/`](https://www.djangoproject.com/)) 或 Flask ([`flask.palletsprojects.com/en/2.0.x/`](https://flask.palletsprojects.com/en/2.0.x/)) 这样的大型基于插件的框架。这些项目有核心责任提供创建网络服务器应用程序的工具,但也可以做更多的事情。
有时,你可能在组织内部进行打包工作,并希望清楚地划分出所有组织的包与第三方包。维护独立的、小型且共享相同顶级导入名称的包可能是个不错的选择,这样团队就可以确信他们正在使用组织的代码。这种模式在 Java 应用程序(见“命名包”,Java 教程,[`mng.bz/N5md`](http://mng.bz/N5md))中非常常见,但在 Python 中直到组织广泛采用 Python 和打包之前则较少见。
回想一下,包通常应该有一个明确的责任。严格遵循这一规则会有后果;你可以想象需要安装成百上千个包,每个包都有自己的分发和导入包名称,只是为了完成一个简单的任务。JavaScript 的 NPM 生态系统([`www.npmjs.com/`](https://www.npmjs.com/))遵循这一理念。你不想把所有行为都塞进一个没有明确目的的大型包中,但也许你也不想把这种行为拆分得太多,以至于人们无法记住他们需要从哪里获取所需的内容。
PEP 420 ([https://www.python.org/dev/peps/pep-0420/](https://www.python.org/dev/peps/pep-0420/)) 定义了 *隐式命名空间包* 的规范,这使得在不牺牲从公共命名空间导入行为的人体工程学的情况下提供细粒度行为成为可能。命名空间包允许你将分发包拆分为多个分发包,同时保持它们在单个命名空间下(见图 10.3)。

图 10.3 命名空间包将现有的分发包拆分为多个分发包,同时保持一个单一的顶级命名空间。
命名空间包与常规包在一点上有所不同:它们提供了一个包含一个或多个常规包的目录,但这个目录本身不是包。也就是说,如果一个目录包含 Python 包但不包含自己的 `__init__.py` 模块,那么这个目录就是一个命名空间包。通过这种机制,不同位置的多个目录可以共享一个共同名称,但包含不同的包。这些目录共享的名称充当命名空间,而包含在这些目录中的包都可以在该命名空间下导入。
命名空间包也可以嵌套,但命名空间和常规包的结构需要在不同的目录之间匹配才能兼容。如果一个目录在一个目录中是命名空间包,但在另一个目录中是常规包,Python 将优先选择常规包,而命名空间包将无法工作。例如,以下列表中显示的目录结构允许导入`geometry.lines`以及`geometry.polygons`。
列表 10.9 单个命名空间包的目录结构
├── geometry-lines
│ └── geometry ❶
│ └── lines ❷
│ └── init.py
└── geometry-polygons
└── geometry ❸
└── polygons ❹
└── init.py
❶ 在几何命名空间中提供包的命名空间包
❷ 可导入为 geometry.lines
❸ 在几何命名空间中提供包的另一个命名空间包
❹ 可导入为 geometry.polygons
另一方面,如果 geometry-lines/geometry/目录被设置为常规包,如以下列表所示,你仍然可以导入`geometry.lines`及其包含的包,但不能再导入`geometry.polygons`。
列表 10.10 常规包优先于命名空间包
├── geometry-lines
│ └── geometry
│ ├── init.py ❶
│ └── lines
│ └── init.py
└── geometry-polygons
└── geometry
└── polygons ❷
└── init.py
❶ 使几何成为常规包并具有优先权
❷ 如果路径上有两个命名空间包,则不再可导入
当你尝试导入一个包时,Python 会通过检查它所知道的路径来解析请求的导入。Python 的解析算法会优先选择常规包,因为查找命名空间包需要更多的努力,因为命名空间包可能涉及额外的嵌套。当系统路径上的一个包是常规包时,Python 会使其在系统路径上的任何其他包之上可用,即使系统路径上也有匹配的命名空间包。
现在你已经了解了命名空间包的机制,你需要通过实践来熟悉它们。
### 10.2.1 将现有包转换为命名空间包
要将现有的提供常规包的分布包转换为提供命名空间包的包,你需要采取以下两个行动:
+ 更新命名空间的目录结构:
+ 如果常规包当前的名字与命名空间相同,请从 src/<package>/目录中移除`__init__.py`模块,使其成为一个命名空间包。
+ 如果常规包是一个应该存在于命名空间内的包,创建一个空的 src/<namespace>/目录,并将 src/<package>/目录移动到其中。
+ 更新 setup.cfg 文件中的`[options]`部分:
+ 将`packages`键从`find:`更改为`find_namespace:`.
+ 添加一个`namespace_packages`键,其值等于新命名空间的名字。
通过这两个更改,你可以将一个“消耗”其原始命名空间的包转换为可以与其他在同一命名空间内提供包的分布包交互的包。
小贴士:使用本书中学习到的设置,你通常可以用`find_ namespace:`代替`find:`,而不会影响任何内容。你甚至可以将此作为项目模板的默认设置,即使你的发行版包没有提供任何命名空间包。
练习 10.2
使用本章早期创建的项目模板,创建两个新的包。在你完成创建后,将它们都转换为在同一命名空间内提供包。将它们都安装到一个虚拟环境中,并检查你是否可以使用单个命名空间从它们中导入代码。
现在你已经有了一种创建遵循你标准的大量包的方法,以及一种使用单个命名空间创建许多能够良好协作的包的方法,你可能也会好奇如何在一个私密的环境中发布所有这些新包,以便你的团队能够在你的组织内部安装它们。
## 10.3 在你的组织中扩展打包
分享代码没有一种正确的方式,组织将根据当时的需求以任何必要的方式解决跨项目代码复用的问题。一些组织,比如谷歌,最终将所有代码放入一个具有复杂构建系统的单个仓库中。其他组织则在每个项目的仓库中保持其模块化,并拥有自己的构建过程。如果你怀疑行为解耦的交付方式将成为你的一个优先事项,你应该考虑创建一个与 Python 包索引和其他工作方式相似的私有打包生态系统,使用人们已经熟悉的工具。
### 10.3.1 私有包仓库服务器
回想一下,PyPI 是一个包仓库,其主要任务是存储和提供分发包。人们可以向其发布包,并从中安装包。这个功能看似基本,但正如你在第一章中学到的,它是安装依赖项可选更新模型的基石,使得打包变得非常有价值。即使你因为正在开发专有软件或因为你的组织对外部访问有限制而无法使用 PyPI,你仍然可以考虑在你的组织内部运行一个私有包仓库。
PyPI 与 pip 一起工作,因为它们各自都遵循关于在索引中提供包路径的特定合同。你选择的任何私有包仓库都应该遵守这个相同的合同,以确保它与 pip 兼容。pypiserver 包([`github.com/pypiserver/pypiserver`](https://github.com/pypiserver/pypiserver))提供了一个即插即用的开源解决方案,用于运行兼容 PyPI 的包索引服务器。如果你预计需要其他类型的包仓库,如 JavaScript、Docker、Ruby 等,你可能想要考虑使用 Artifactory([`jfrog.com/artifactory/`](https://jfrog.com/artifactory/))之类的解决方案运行多语言包索引。
小贴士:你也可以寻找那些会自动从 PyPI 拉取之前从未请求过的包,并在之后将其缓存的解决方案。这可以加快未来安装的下载速度,如果 PyPI 服务器不可用,这也提供了一定的弹性。pypiserver 和 Artifactory 都支持这一点。
设置和运行私有包仓库服务器超出了本书的范围,但本节中提到的和链接的解决方案有关于配置和托管方面的文档,这将有助于你完成这项工作。假设你已经有了这样的服务器,你需要知道如何让你的打包工具与之通信。
配置 twine 和 pip 以使用私有仓库。
回想一下,你首先使用 twine ([`twine.readthedocs.io`](https://twine.readthedocs.io)) 在使用 GitHub Actions 之前发布你的包。GitHub Actions 无法将包发布到私有包仓库,除非你明确允许它通过你的网络访问服务器。如果你觉得这是一个限制,你可以使用 twine 来发布你的包。默认情况下,当你要求安装一个包时,twine 和 pip 将与 PyPI 通信。这两个工具都接受配置,使它们可以与你的选择的服务器通信。你可以阅读有关配置 twine ([`mng.bz/DDZV`](http://mng.bz/DDZV)) 和 pip ([`mng.bz/lRJo`](http://mng.bz/lRJo)) 的各种可能方式的说明,但以下是我个人对在你的项目中明确配置的建议,以便清楚地表明项目是否从私有服务器发布或安装包。
要使用 twine 将包发布到备用仓库服务器,你可以在你的 setup.cfg 文件中创建一个新的`publish` tox 环境,执行以下操作:
1. 安装`build`包以构建包。
1. 安装`twine`包以将构建的包上传到仓库服务器。
1. 允许使用外部的`rm`命令,以便在构建和上传包之前清理 dist/目录。
1. 运行命令以清理、构建和发布包。
你可以向`twine upload`命令传递一个带有你仓库服务器 Python 包仓库上传端点的 URL 的`--repository`标志,如果服务器需要认证,还可以传递`--username`标志和`--password`标志。以下列表展示了使用 Artifactory 的此配置示例。
列表 10.11 通过 twine 添加备用包仓库 URL。
[testenv:publish]
skip_install = True
deps =
build
twine ❶
whitelist_externals =
rm ❷
commands =
rm -rf dist/ ❸
pyproject-build . ❹
twine upload
--username=""
--password=""
--repository-url=
➥ https:/ /artifactory.mycompany.org/artifactory/
➥ api/pypi/pypi \ ❺
dist/*
❶ 安装 twine 以发布包。
❷ 允许使用外部的 rm 命令。
❸ 在继续之前清理任何现有的已构建包。
❹ 构建包。
❺ 将包上传到备用仓库服务器。
在`publish` tox 环境中,你可以从你的机器上手动发布包,或者在 Jenkins ([`www.jenkins.io/`](https://www.jenkins.io/))或 GitLab CI/CD ([`docs.gitlab.com/ee/ci/`](https://docs.gitlab.com/ee/ci/))等自托管解决方案上创建持续集成工作流程,当你在代码仓库上创建标签时,它会运行该环境。
在将包发布到你的备用仓库服务器后,你需要告诉 pip 如何从该服务器检索包。如果你在 Python 运行时应用程序中安装包,并使用 requirements.txt 文件,你可以在`pip install`命令中添加一个`--index-url`标志,将仓库服务器的 Python 包仓库下载端点添加到其中,或者直接添加到 requirements.txt 文件本身。想象一下,你将一个名为 my-private-package 的私有包发布到你的私有包仓库,并且你需要在正在工作的项目中安装它以及 Django。列表 10.12 显示了一个示例 requirements.txt 文件,该文件指示 pip 查看私有包仓库,在那里它将能够找到 my-private-package。仓库服务器可能有一个 Django 的副本,或者它可能需要首先从 PyPI 获取;这是你选择的私有仓库解决方案的实现和配置细节。
列表 10.12 通过 pip 添加备用包仓库 URL
requirements.txt
--index-url https:/ /artifactory.mycompany.org/artifactory/api/pypi/pypi/simple ❶
my-private-package2.5.1 ❷
Django3.2.12 ❸
❶ pip 在这里查找包,替代 PyPI
❷ 解决因为它已发布到私有服务器
❸ 在私有服务器上解决或从 PyPI 获取
通过将这些 URL 明确放入项目源代码中,你确保了从代码仓库检出并工作的开发者将发布和从预期的包仓库服务器获取包。如果你使用存在于项目之外的可配置方法,你必须信任开发者正确配置 twine 和 pip 等工具以使用正确的包仓库服务器。
你现在已经学会了如何在可能对使用更广泛的开源打包生态系统有限制的组织中推广包的使用。你可以使用 cookiecutter 项目模板创建新的包,并为人们创建一组命名空间包,这样他们就可以使用一致的导入前缀安装更小的软件片段,而你可以在组织内部的托管服务器上完成所有这些操作。你几乎准备好去开拓和繁荣了!但不要错过最后一章,如果你正朝着开源项目的方向发展,这将帮助你给事情添加一些最后的润色。
## 摘要
+ 不要只关注自动化单个项目;当涉及到模块化软件生态系统时,考虑使用项目模板自动化项目的创建。
+ 使用项目模板有助于他人采用该系统,并且你可以保持其更新,以确保最新的标准被纳入每个新项目中。
+ 在特定的项目中,有些内容可能不会映射到您创建的所有项目中,因此您需要随着时间的推移不断优化您的项目模板,以实现最大化的生产力。
+ 包可能变得过大,但过多的命名空间也可能令人困惑。考虑使用命名空间包来为您的用户提供一个合适的平衡点。
+ 本书中所使用的每个公开解决方案都有一个私有或自托管的对应版本,您可以使用这些版本在您的组织中构建专有的打包生态系统。
# 11 建立社区
本章涵盖
+ 创建用户到维护者渠道
+ 在你的项目中添加行为准则
+ 向用户传达项目的状态
+ 使用模板和标签来简化 GitHub 问题管理
想象一下,你刚刚创建了一个有价值的软件包,迫不及待地想要与世界分享。你在 GitHub 上公开了仓库,并向所有可能感兴趣的人发送了一波推文和电子邮件。你坐下来等待炒作的兴起,但它从未到来。尽管你通过完成项目的实施达到了一个里程碑,但结果证明这很少是最终的里程碑。如果你想让人们使用你的作品,尤其是如果你想让他们贡献新功能、错误修复或文档,你需要为项目提供指导和愿景,以便每个人都能朝同一个方向前进。这就像在构建一个产品。
这些年,我发布了一些开源项目,现在我可以告诉你,其中最成功的一些项目有一些共同之处。偶尔,一些工作可能会纯粹出于兴趣或偶然变得流行,但你可以采取一些措施来给项目最好的成功机会。无论你是热衷于向他人提供你的作品以便他们受益,还是为了获得知名度而构建一个作品集,或者两者兼而有之,建立社区都是重要的。如果你希望你的项目在同事或客户的固定受众之外增长,并且如果你想让你的项目在没有不断自己工作或指导他人的情况下长期存在,这一章节就是为你准备的。
### 11.1 你的 README 需要提出价值主张
README 通常是项目的着陆页。无论用户是在 Python 包索引、GitHub 还是 Google 搜索中找到你的项目,README 都会是他们看到的第一件事之一。许多项目没有充分利用这一点,错失了吸引人们尝试项目的机会。
在今天这个充斥着无数在线项目的绝对海洋中,仅仅陈述你的项目实现了什么已经不再足够。相反,考虑一下为什么有人会选择使用你的项目,尤其是在与竞争对手相比的情况下。如果你的项目完全新颖,那就明确说出来。就像生活中大多数其他领域一样,每个项目都在争夺用户的注意力,而你只有宝贵的几秒钟时间来给出你的“电梯演讲”,然后用户才会转向下一个项目。许多成功的团队将项目视为产品,并打造了一个可能包含多个项目的品牌。打造品牌是一项细致的工艺,需要研究用户与品牌之间的情感联系(参见凯文·L·凯勒,《品牌融合:品牌知识的多维性》,*《消费者研究杂志》*,[`www.jstor.org/stable/10.1086/346254`](https://www.jstor.org/stable/10.1086/346254))。考虑在 README 文件中融入一些你和你团队的信息,并包括项目的动机,这样人们就会感觉他们可以与你互动,而不仅仅是消费软件。
视觉辅助工具在吸引眼球方面可以大有帮助——毕竟,一图胜千言。因此,考虑一下你可以展示什么而不是仅仅告诉人们。丰富的包([`github.com/Textualize/rich`](https://github.com/Textualize/rich))出色地展示了它的能力,吸引人们继续阅读关于如何使用它构建美丽的命令行界面的内容。丰富的 README 文件随后涵盖了它支持的几个用例,以便潜在用户可以大致了解与之合作的感觉。请注意,这并不是作为或代替正式文档的意图;它的明确目的是让人们尝试丰富的功能,而完整的文档在其他地方。最后,README 文件以一些社会证明结束,以表明其他人已经是项目的满意用户(关于社会证明的更多信息,请参阅罗伯特·B·西尔迪尼,《影响力:科学与实践》[Allyn and Beacon, 2000])。
一个项目最有价值的贡献往往来自最活跃的用户,而一个人贡献得越多,他们未来再次做出贡献的可能性就越大。为了最大化人们回归项目进行贡献的渠道,你需要最大化经常使用该项目的用户数量。你可以预期漏斗的每个级别都会减少一个数量级。让你的 README 尽可能吸引人,确保潜在用户没有成为实际用户时的损失尽可能小(见图 11.1)。

图 11.1 一个项目的社区随着时间的推移会经历一个漏斗。漏斗的每个级别都比上一个级别小得多,通常是一个数量级。
当你分享最新的努力时,这种思维模型可能很重要,因为它将帮助你确定谁可能最感兴趣,谁可能能够帮助你传播使用你的解决方案来解决他们实际问题的福音。例如,CarCorp 的人可能想使用你最新的一个包,但他们可能不感兴趣告诉别人它在航空旅行行业中的有用性。通过识别即将到来的用户类型,你可以更好地分层他们的动机和期望结果,并针对对项目有意义的那些进行优化。用户漏斗的每个级别在您的项目中有不同的需求,他们需要文档来支持这些需求。
### 11.2 为不同类型的用户提供支持性文档
你在第八章中学到,文档支持几种活动模式:
+ 学习如何使用项目
+ 完成特定任务
+ 查找语法细节和其他参考资料
+ 理解项目的背景和方向
项目的早期用户最好通过前三种类型的文档来支持。由于您用户漏斗的很大一部分需要这些类型的文档,因此不要在这方面节省。确保创建如何操作、教程和自动化的代码参考文档,尽可能覆盖所有基础。特别是在项目的早期阶段,将大部分精力放在这里将产生最大的影响。
GitHub 贡献指南
GitHub 为新用户在打开问题等操作时提供了一种展示贡献指南的功能。您可以通过在项目中的 .github/ 目录下创建一个名为 CONTRIBUTING.md 的文件来利用这一功能。
如果你像在第八章中学到的那样提供你项目的大部分文档,那么最好将 CONTRIBUTING.md 的内容保持简洁,并附上指向主要文档的链接。这样,当用户打开问题时,会提示他们阅读文档,但会直接引导他们到你的主要文档,而不是你在多个地方维护信息。
随着项目的成熟,超级用户、贡献者和维护者将受益于第四种类型的文档。他们需要指导哪些功能甚至应该考虑添加到项目中,以及用户期望什么样的体验。他们还需要对导致项目当前状态的决定有更深入的理解,以便他们能够以一致的方式支持并发展系统的设计。
想象一下,有人非常喜欢你的项目,在发现一个错误后想要贡献。他们来到你的项目仓库,看到关于如何*使用*软件的出色文档,但在寻找关于*开发*软件的文档半小时后,又花了一个小时自己设置环境,最终放弃。不幸的是,他们可能太沮丧了,无法给你反馈,而你也不知道需要改进。你也就失去了宝贵的贡献。
如果你不能提供这种级别的指导,你的项目也可能以你未预料到的方式发展。当贡献者做出与你愿景不符的更改,但愿景没有记录下来时,你将陷入艰难且可能令人沮丧的对话,以使更改符合要求。通过在可能的情况下提前提供愿景,你可以减少在这些对话上花费的时间和精力。
项目愿景和状态是另一个方面,将项目视为产品可以帮助用户社区。这个项目的下一步是什么?项目的最终目标是什么?这个目标是否已经实现,接近实现,还是处于某个地平线上?在你的文档中回答这些问题将帮助合适的用户在正确的时间采取正确的行动,并尽可能减少挫败感。
记录架构决策
对于你在项目中做出的关于架构和系统设计的广泛决策,记录决策及其做出的背景可能是有价值的,这样当背景在未来不可避免地发生变化时,新的决策可以做出,而无需从头开始重建整个背景。架构决策记录(ADRs)是捕获此类信息的流行框架(参见 Michael Nygard,“记录架构决策”,[`mng.bz/BZl2`](http://mng.bz/BZl2))。
我最近在几个项目中使用 ADRs,我们的团队非常喜欢它们。与代码质量反馈与人为因素分离的代码检查器一样,ADRs 可以作为提醒,为特定原因投入额外努力,而无需过多地重复推理。
一些工具,如 adr-tools ([`github.com/npryce/adr-tools`](https://github.com/npryce/adr-tools)),有助于自动化创建、链接和随时间演进你的架构决策的过程。
尽管你可以在一开始就尽可能地减轻问题,但不可避免地,有人会对现状感到极度不满。他们甚至可能做出或说出一些不适当的事情,如果得不到妥善处理,可能会损害社区。你应该准备好应对这种不可避免的情况,制定一个包括明确执行计划的守则。
### 11.3 建立并提供执行守则
Python 的禅宗([`peps.python.org/pep-0020/`](https://peps.python.org/pep-0020/))指出“明确优于隐晦”。许多项目没有提供特定的行为准则,因为这些项目开发平台的服务条款非常慷慨,用户可以逃避很多最终会破坏项目社区的行为。
想象一下,一个新用户出于善意提出一个新功能请求,却不知道这个功能在过去已经被多次请求并被拒绝。你正准备用友好的欢迎语和关于过去拒绝的背景信息来回应,这时社区中的另一位成员突然跳出来,非常粗鲁地责备了这位新用户。这位用户可能再也不会回到这个项目了。在最理想的情况下,他们未来在请求功能时可能会感到紧张。你给那位粗鲁回应的成员提供了一些私人反馈,但你已经看到他们这样做了几次,担心这种情况会继续发生。
这些情况总是让人感到不舒服,但它们不需要是无序的。行为准则有助于明确界定社区成员的预期和不可接受的行为,以及当成员超出这些界限时可能带来的后果。这些用户可能会面临临时或永久的封禁,禁止他们进一步参与项目。将这些规则明确地列出并供社区使用,确保项目维护者有权执行并对其负责。
行为准则的一个很好的起点是贡献者公约([`www.contributor-covenant.org/`](https://www.contributor-covenant.org/)),一些最大的科技公司已经采纳了它作为他们开源项目的准则。贡献者公约提供了一个行为准则模板,你可以根据项目需求进行修改和采用,并概述了针对不当行为的执行升级政策。像 Vue([`github.com/vuejs/vue`](https://github.com/vuejs/vue))这样的大型项目也在使用贡献者公约作为他们的行为准则。
虽然讨论有时可能会变得激烈,甚至负面,但行为准则可以保护维护者和社区免受那些可能对项目社区造成长期负面影响的行为的侵害。将你的行为准则文本添加到项目仓库根目录下的名为 CODE_OF_CONDUCT.md 的文件中,可以在 GitHub 界面的某些部分显示一个链接,例如当新用户即将打开一个问题时(见图 11.2)。

图 11.2 当新用户打开问题时,GitHub 会显示一个指向你的行为准则的链接。
查看一些大型项目(如 Django [`www.djangoproject.com/conduct/`](https://www.djangoproject.com/conduct/))或 Python [`www.python.org/psf/conduct/`](https://www.python.org/psf/conduct/))的行为准则以获取灵感。尽管它们的执行过程可能比你能够处理的要复杂,但它们很可能涵盖了他们在现实生活中看到的案例,你可以提前预测而不是后来作为惊喜。坚持使用来自非营利组织的免费和开源软件的行为准则,因为它们比盈利项目更有可能拥有健全的准则。
现在你已经有一些关于创建欢迎空间和支持用户需求通过文档的作业了,你可以采取一些额外的措施来促进有效的贡献。
### 11.4 传达项目的路线图、状态和变更
你已经在文档中涵盖了项目愿景,但这应该是尽可能的常青,以支持持续的设计和决策。当涉及到项目在任何时间点如何与愿景保持一致时,拥有一个路线图和一种跟踪该路线图上活动状态的方法是有帮助的。
### 11.4.1 使用 GitHub 项目进行看板管理
如果你之前没有在敏捷软件开发环境中工作过,你可能没有听说过看板(kanban)。看板是一种精益的工作跟踪方法,最初是为丰田制造过程中的库存管理开发的(参见 Taiichi Ohno,《丰田生产系统:超越大规模生产》(Productivity Press,1988 年))。看板被应用于软件以跟踪团队内部的工作队列。今天的看板产品提供了以下属性的可见性:
+ 某个任务的状态(对于简单的项目通常是“待办”、“进行中”和“完成”)
+ 要完成的工作类别(有时称为*泳道*)
+ 谁在处理哪些任务
随着项目的进展,对这些属性的可见性可以提供以下信号:
+ 缺乏专注(一次进行太多工作)
+ 不一致(低优先级工作在完成高优先级工作之前完成)
+ 挑战(长时间进行中的任务)
+ 常见的瓶颈和阻碍
随着你处理用户期待的新版本,这些信号变得越来越重要。GitHub 在 GitHub 问题之上提供了一个项目功能([`github.com/features/issues`](https://github.com/features/issues)),为问题管理提供了一个看板风格的流程。你可以将用户故事卡片描述、问题、拉取请求和一些轻量级自动化结合起来,为你的项目提供一个丰富的跟踪和报告系统(见图 11.3)。

图 11.3 GitHub 问题
### 11.4.2 使用 GitHub 标签跟踪单个任务的状态
虽然 GitHub 问题中的看板功能对于整个项目的高层次进度很有用,但项目中的每个单独任务也会经历一个生命周期。因为维护者和贡献者都会从相关的拉取请求的角度来看待这些任务,所以 GitHub 标签可以是一个有用且可见的方式来指示拉取请求的状态。GitHub 自带一组默认标签,但你可以在存储库的标签页上创建自己的标签(https://github.com/<owner>/<repo>/labels)。你甚至可以更改或添加 GitHub 组织的默认标签,以便你创建的新存储库符合组织的需求。将<organization>替换为你的组织名称:https://github.com/organizations/<organization>/settings/repository-defaults。
你可能会认为拉取请求处于三种状态之一:打开、合并或关闭。对于需要更仔细审查和几次迭代开发的代码关键区域或较大更改,"打开"状态实际上可能是几个不同的状态,如下所示:
1. *进行中*—代码已经接近完成,但仍处于开发中。审阅者可能会查看它,但代码可能会发生变化。
1. *待审查*—代码应该像项目所需的那样进行审查。任何需要查看正在更改的代码区域的特定审阅者都应该这样做。
1. *已审查*—代码更改被认为适合发布,但可能尚未准备好实际发布。
1. *准备发布*—代码应该包含在下一个合适的发布中。
1. *待定*—无论代码处于何种状态,它都不适合进行其他活动。维护者可能需要时间来思考,或者贡献者可能已经不活跃,或者有其他原因。
这些只是可能的状态之一;你项目中的拉取请求可能经历更多或不同的状态,并且它们可能会在维护者和贡献者协作更改时在这些状态之间来回流动(见图 11.4)。

图 11.4 项目中的拉取请求可能通过许多不同的状态,可以将其视为一个状态机。
一个明确定义的状态标签系统可以帮助项目的新手和有经验的人快速了解拉取请求在其生命周期中的位置。它还可以帮助拉取请求更有效地通过这些状态,因为人们可以更容易地判断拉取请求是否可能从他们的关注中受益。
另一个人们可以从快速浏览中受益的领域是查看过去发生了什么更改。为此,请查看简单的变更日志。
### 11.4.3 在日志中跟踪高级更改
对于有规律发布节奏的项目,特别是对于有版本发布的项目,人们最想知道的任何发布信息是发生了什么变更。对于主要版本发布,这一点变得至关重要,因为消费者可能需要对这些变更做出反应,以保持他们自己的项目正常运行。
随着更多和更多的发布出去,跟踪哪些变更发生在哪个发布版本变得越来越困难,以至于回顾性地编写发布说明带来的回报越来越少。如果你能早期开发一个流程,让你能够跟踪变更的发生,你将避免在项目上进行考古学式的深夜工作。
一个有帮助的系统是名为 Keep a Changelog 的创意命名([`keepachangelog.com`](https://keepachangelog.com))。在 Keep a Changelog 系统中,你会在存储库的根目录中创建一个文件,通常命名为 CHANGELOG.md,以严格的传统格式记录关于你发布的概要笔记。此格式提供了以下内容:
+ 发布版本的版本号
+ 以 YYYY-MM-DD 格式表示的确切发布日期
+ 一个或多个变更部分,这些可能包括新增、更改、弃用、移除、错误修复或安全修复
维护一个变更日志也鼓励你记录下你已合并到项目中但尚未发布的变更,这样在发布时它们可以随时可用。下面将展示一个示例变更日志。
列表 11.1 Keep a Changelog 格式及其几个历史条目
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.
[Unreleased] ❶
Added ❷
- Half-Life 3
[2.7.1] - 2022-04-05 ❸
Fixed ❹
- Stop mining Bitcoin in the emergency phone call feature
[2.7.0] - 1914-08-15
Added
- New colors for the Model T including Dark Black and Midnight Black
❶ 下一个发布版本中的功能列表
❷ 新增功能列表
❸ 具体发布及其确切发布日期
❹ 修复列表
如果你习惯于编写 README 和其他 Markdown 文件,Keep a Changelog 的语法和格式应该会感到非常熟悉。
我个人的变更日志方法
对于个人项目,我相当喜欢使用 Keep a Changelog。我倾向于在合并功能时逐渐构建我的变更日志,当发布时间来临时,我会手动将最新变更日志的内容复制到 GitHub 发布的正文中。
如果我已经充分地向你展示了自动化的价值,你可能喜欢探索针对此问题的自动化解决方案。我愉快地使用了 Atlassian 的 changesets 项目([`github.com/changesets/changesets`](https://github.com/changesets/changesets))来处理一个组织项目,但请注意,它主要针对 JavaScript 生态系统。在 Python 生态系统中,我听说 towncrier([`github.com/twisted/towncrier`](https://github.com/twisted/towncrier))很好用。这两个工具都以类似格式生成变更日志,在功能和展示上略有不同。最重要的是选择一个你真正会使用的工具;一个不完美的变更日志通常比没有变更日志要好。
虽然保持变更日志不是一个自动化的系统,但它是一种很好的纯文本方法,可以将可读的笔记渲染成 Markdown 格式,并提供足够的提醒,让人们记得去做这件事。你可以在你的拉取请求清单中添加一个条目,提醒大家填写变更日志。
变更日志中记录多少细节是一个比较困难的挑战。重复记录个别变更的细节可能对用户来说太多,但仅仅说“有变化”也不够。你应该根据问题的变化调整你的细节程度。例如,重大变更应该包括迁移到新做法的步骤说明。包括指向实现变更的拉取请求的链接也是一个有益但繁琐的做法,这样用户如果需要可以深入了解。
到目前为止,你已经得到了很多对社区有益的家庭作业,但最后一部分也会对你有显著的帮助。
### 11.5 使用问题模板收集一致的信息
有些人有报告错误的诀窍。不经过提示,他们会提供他们操作系统的所有细节、软件版本、在遇到错误之前吃了什么,等等。其他人可能会提供没有太多上下文的错误消息,而其他人可能只是报告了一个错误,说“我用它时坏了。”
你可以采取措施确保你的项目社区在报告问题和贡献拉取请求时感到舒适,但让他们自己处理,他们提供的类型和数量的上下文信息会有很大的差异。这最终会让你在与他们进行来回对话以获取更多细节时感到沮丧。通过在 GitHub 中创建问题模板,你可以提高成功的可能性。
GitHub 支持拉取请求模板,当贡献者开始创建拉取请求的工作流程时,它会填充描述字段。你可以使用这个功能来确保贡献者被提示输入诸如
+ 与变更相关的问题描述
+ 变更如何解决问题的细节
+ 对审阅者有用的任何额外上下文
一个拉取请求模板的示例以及它在 GitHub 中的显示方式如图 11.5 所示。

图 11.5 拉取请求模板有助于从贡献者那里提取有用的信息,以促进更有效的协作。
要创建拉取请求模板,在你的仓库的 .github/ 目录中创建一个名为 PULL_REQUEST_TEMPLATE .md 的文件。将你想要在默认情况下出现在拉取请求描述中的内容作为文件的内容添加,并提交它。新的拉取请求将使用 PULL_REQUEST_TEMPLATE.md 文件的内容作为拉取请求描述。
小贴士 使用不常见的字符如`<<>>`来指示人们应该填写特定的值或部分,并保持简短,以便人们填写完整而不是部分填写或用他们选择的内容替换整个内容。
下面的列表展示了我通常作为新项目起点所偏好的拉取请求模板。它足够简短,以至于人们倾向于完整填写,但又足够详细,可以比没有提示更好地理解变更。
列表 11.2 一个包含上下文的良好拉取请求描述
Details ❶
What does this change address? ❷
A technical overview of the feature or bug being fixed should go here.
How does this change work? ❸
A detailed description of the approach you took goes here.
Help reviewers understand your thought process, the challenges you faced,
or the genesis of the existing code and how that shaped what you wrote.
Additional context: ❹
Include thoughts toward the future, how this might affect developers'
workflows, and anything else you might want to include for historical
purposes here.
❶ 一个可选的标题,用于快速视觉识别
❷ 提醒作者描述意图
❸ 提醒作者解释方法
❹ 提醒作者进行横向和长期思考
GitHub 还提供了用于报告错误、请求功能等的 issue 模板。使用 issue,你甚至可以创建限制和结构化用户提供的信息的表单。你项目需求的特点和这一领域的可能性深度使得 issue 模板的覆盖范围超出了本书的范围,但最终,这是一个为用户想要提交的每个问题类型添加和配置文件的问题。有关 issue 模板的详细说明和示例,请参阅官方 GitHub 文档([`mng.bz/deJw`](http://mng.bz/deJw))。我鼓励你在提交 issue 时考虑用户的目标,并为每个不同的目标创建一个 issue 类型。这将引导用户获得正确的体验,这在他们感到沮丧或困惑或两者兼而有之时非常有帮助。
要深入了解 GitHub 提供的所有社区功能,其中许多你在本章和本书前面的部分已经了解过,请查看官方文档([`docs.github.com/en/communities`](https://docs.github.com/en/communities))。
### 11.6 前行
如果你正在阅读这篇文章,你已经成功通过了在开发成功的 Python 包项目和社区过程中的一系列考验和磨难。或者,你可能跳过了一部分,需要回去重新阅读前面的章节。
我已经把我能想到的所有经验都倾注到这些页面上了,但这并不意味着这就是所有可以学习的内容。Python 打包生态系统继续以惊人的方式发展,我很乐意与你保持联系。如果你遇到问题,需要建议,或者只是想展示你制作的内容,请用 GitHub 问题或讨论标签我(@daneah)。我会在那里为你指明正确的道路,或者根据情况为你加油鼓劲。在此之前,祝你编码愉快!
### 摘要
+ 你的社区有一个漏斗,随着你建立社区,你希望漏斗的口尽可能宽。通过文档和问题管理为漏斗每个级别的不同用户提供支持。
+ 社区从系统和结构中受益,成员们知道可以期待什么。你也是如此。
+ 首先,你们的社区需要沟通才能正常运作。传达项目愿景、状态和需求。传达架构决策。传达进展。传达变化。
+ 你们是一群出色的听众,你们值得出去为自己做些美好的事情。
# 附录 A. 安装 asdf 和 python-launcher
在本书中,你需要管理多个 Python 版本和虚拟环境的安装,以及它们之间的切换。本附录涵盖了安装简化这一负担的工具,你将在章节中了解更多关于这些工具的内容。
重要
本附录中涵盖的工具是我为了方便那些寻找更简单方式在 macOS 上管理环境的人而推荐的,它们也是我自己使用的工具。如果你已经非常熟悉安装基础 Python 版本和管理虚拟环境,那么你可能不需要使用它们。如果你使用的是 Windows 或 Linux,你可能需要安装额外的依赖项以使这些工具正常工作,你可能还希望或需要考虑以下的一些替代方案。为了本书的简洁性和一致性,示例将使用它们,所以请确保至少阅读本附录以最好地理解章节中的示例。最后,如果你想在没有其他工具或生态系统的情况下完成本书,你可以手动安装 Python 版本,手动创建虚拟环境,并手动激活它们。
我根据个人经验推荐的 asdf 的替代方案,按照偏好顺序如下:
1. pyenv ([`github.com/pyenv/pyenv`](https://github.com/pyenv/pyenv)) 或 pyenv-win ([`pyenv-win.github.io/pyenv-win`](https://pyenv-win.github.io/pyenv-win)) 适用于 Windows 用户
1. 从源代码或为你的平台预构建的二进制文件直接安装 ([`www.python.org/downloads/`](https://www.python.org/downloads/))
1. Homebrew ([`brew.sh/`](https://brew.sh/)) 或你平台官方的系统包管理器
我根据个人经验推荐的 python-launcher 和 venv 的替代方案,按照偏好顺序如下:
1. pyenv-virtualenv ([`github.com/pyenv/pyenv-virtualenv`](https://github.com/pyenv/pyenv-virtualenv))
1. poetry ([`python-poetry.org/`](https://python-poetry.org/))
1. virtualenv ([`virtualenv.pypa.io/en/latest/`](https://virtualenv.pypa.io/en/latest/)) 和 virtualenvwrapper ([`mng.bz/rndy`](http://mng.bz/rndy)) 以增加管理便利性
1. pipenv ([`github.com/pypa/pipenv`](https://github.com/pypa/pipenv))
在本书中,任何出现 `py` 命令的地方,除非另有说明,你应该将其视为需要激活你的项目虚拟环境或确保你使用与你的项目关联的 `python` 命令。
## A.1 安装 asdf
asdf ([`github.com/asdf-vm/asdf`](https://github.com/asdf-vm/asdf)) 是一个用于安装多种语言、框架和工具的版本,并在它们之间切换的工具。虽然您可以从源代码或为您的操作系统预构建的二进制文件安装 Python 的基本版本,但 asdf 可以很好地管理已安装的版本和每个目录的配置。除了 Python 之外,它还可以跨其他语言和框架,如 NodeJS、Ruby 等。
您将使用 asdf 安装多个 Python 的基本版本,然后为您的项目创建隔离的环境。您可以在 macOS、Linux 或 Windows 子系统 for Linux 上使用 asdf。
注意:以下说明提供是为了方便您在本书的上下文中停留。您还可能想查看 asdf 的官方文档以了解入门信息 ([`asdf-vm.com/guide/getting-started.xhtml`](https://asdf-vm.com/guide/getting-started.xhtml)),看看是否有任何变化。
要安装 asdf,首先确定最新的标记版本 ([`github.com/asdf-vm/asdf/tags`](https://github.com/asdf-vm/asdf/tags))。然后,将对应版本的分支克隆到 $HOME/.asdf/ 目录中。例如,如果最新版本是 `v1.2.3`,您将运行以下命令:
$ git clone
https:/ /github.com/asdf-vm/asdf.git \ ❶
$HOME/.asdf \ ❷
--branch v1.2.3 ❸
❶ GitHub 上的 asdf 存储库
❷ 克隆代码的目的地
❸ 要使用的代码版本
在克隆代码后,您需要在 shell 的启动过程中源代码。对于 macOS,其中默认 shell 是 zsh,请将以下行添加到 `$HOME/.zshrc`。对于 bash,请将其添加到 `$HOME/.bash_profile`:
if [ -f $HOME/.asdf/asdf.sh ]; then
source $HOME/.asdf/asdf.sh
fi
保存您的启动文件后,打开一个新的 shell 会话。使用以下命令验证 asdf 是否正确安装:
$ asdf --version
您应该看到一个与您克隆存储库时签出的分支匹配的版本。现在您已经安装了 asdf,请使用以下命令安装 Python 插件:
$ asdf plugin add python
这将使插件立即可用。验证插件是否正常工作,并使用以下命令查看可用的 Python 版本:
$ asdf list all python
您应该会看到列出了数百个版本,包括 PyPy、Anaconda 等。向上滚动到只有编号而没有名称的版本——这些是标准的 CPython 实现版本。
您将在 Python 的三个最新次要版本上测试您的项目,因此接下来安装这些版本。例如,如果 Python 的最新版本是 `3.11.X`,您应该安装以下匹配的最新版本:
+ `3.11.X`
+ `3.10.Y`
+ `3.9.Z`
您可以使用以下命令安装这些版本,将版本替换为您想要安装的版本:
$ asdf install python 3.11.X
$ asdf install python 3.10.Y
$ asdf install python 3.9.Z
警告
macOS Big Sur 及更高版本可能会导致安装较旧版本的 Python 时出现问题。如果在尝试安装 Python 版本时遇到编译错误,可以使用以下方法修复 Python:
ASDF_PYTHON_PATCH_URL=\ ❶
"https:/ /github.com/python/cpython/commit/
➥ 8ea6353.patch?full_index=1" \ ❷
asdf install python 3.11.X
❶ 指示 asdf 在编译 Python 代码之前应用补丁
❷ 此特定补丁修复了 macOS Big Sur 上常见的编译问题。
如果您遇到其他问题,可以始终检查 Python 插件的文档([`github.com/danhper/asdf-python`](https://github.com/danhper/asdf-python))。
安装您所需的 Python 版本后,您可以使用以下命令列出所有版本:
$ asdf list python
您应该看到类似以下输出的内容,具体版本可能因安装的版本而异:
3.11.X
3.10.Y
3.9.Z
最后,使用以下命令将您已安装的所有 Python 版本添加到您的`$PATH`中,适当替换版本:
$ asdf global python 3.11.X 3.10.Y 3.9.Z
这将创建一个类似以下内容的$HOME/.tool-versions 文件:
python 3.11.X 3.10.X 3.9.X
指定多个版本将使它们默认在系统中的任何位置可用,这在下一节安装 python-launcher 后将会很有用。您还可以使用项目根目录中的`asdf local python`来减少给定项目中可用的版本,以创建特定于该项目的`.tool-versions`文件。
为了验证您的配置,请启动一个新的 shell 会话并调用`python`命令。这应该会启动您传递给`asdf global python`的第一个 Python 版本的解释器,因为该版本具有最高优先级。您也应该能够从安装的任何版本中启动解释器。例如,如果您已安装 Python 3.9,您应该能够调用`python3.9`命令以获取 Python 3.9 解释器。
## A.2 安装 python-launcher
使用 asdf 单独使用不同的 Python 版本并不太难,但随着您为不同的项目创建环境,这将会变得更加困难。python-launcher ([`github.com/brettcannon/python-launcher`](https://github.com/brettcannon/python-launcher)) 是一个方便的工具,可以在正确的时间启动正确的 Python。使用 python-launcher,您可以使用单个命令`py`来调用基于您当前工作目录或虚拟环境目录存在的 Python 安装。这可以为您节省大量时间,因为您不需要不断激活和关闭虚拟环境。本书中的示例将使用 python-launcher 进行大多数操作。
警告
如果您是 Windows 用户,您不需要自己安装 python-launcher。它已经包含在 Windows 上的 Python 中([`mng.bz/VypG`](http://mng.bz/VypG)),并且自 2012 年以来就是这样。Unix 系统基于的 python-launcher 最终可能会被纳入 Python 核心,但截至本文撰写时,尚未宣布具体计划。跨平台的统一将有利于使用 Windows 和 Unix 平台的用户。
要安装 python-launcher,您可能可以使用您平台上的系统包管理器([`github.com/brettcannon/python-launcher#installation`](https://github.com/brettcannon/python-launcher#installation))。
手动安装 python-launcher
如果你的平台没有提供 python-launcher 包,或者你想要更精细的控制,你可以使用 Rust 手动安装它([`www.rust-lang.org`](https://www.rust-lang.org))。截至本文撰写时,推荐使用以下命令安装 Rust(有关最新安装说明,请参阅“安装 Rust”,[`www.rust-lang.org/tools/install`](https://www.rust-lang.org/tools/install)):
$ curl --proto '=https' --tlsv1.2 -sSf https:/ /sh.rustup.rs | sh
安装 Rust 后,使用 Rust 的 `cargo` 工具通过以下命令安装 python-launcher:
$ cargo install python-launcher
现在,你可以通过启动一个新的 shell 会话并使用以下命令来列出 python-launcher 所知的所有 Python 版本,以验证你的安装:
$ py --list
你应该会看到类似以下输出,具体版本可能因操作系统和安装内容的不同而有所差异:
3.10 │ /Users/
3.9 │ /Users/
3.8 │ /Users/
3.7 │ /Users/
2.7 │ /usr/bin/python2.7
注意,这些版本中大多数提到了 asdf,这是通过 `asdf global python` 命令提供的。但最后一个版本指向了其他地方——那就是系统 Python。asdf 与你的 shell 的 `$PATH` 变量交互,这就是它能够切换 `python` 命令解析位置的方式。
python-launcher 默认会使用它找到的最高可用版本的 Python。例如,如果你安装了 Python 3.10、3.9 和 3.8,python-launcher 会默认选择使用 Python 3.10。你也可以使用 python-launcher 的版本标志来控制你想要的基础 Python 版本。例如,如果你已安装 Python 3.9,你应该能够使用 `-3.9` 标志调用 `py` 命令来获取 Python 3.9 解释器。
练习 A.1
如果你使用 asdf 安装了 Python 3.10、3.9 和 3.8,并运行了 `asdf global python 3.9 3.8`,以下命令会返回哪个版本?
$ py -V
## 练习 A.1 的答案
**A.1**—3.9\. 3.10 不在 `$PATH` 中,除非使用 asdf 配置,因此 python-launcher 不会知道它,并找到 3.9 是最高版本。
# 附录 B. 安装 pipx、build、tox、pre-commit 和 cookiecutter
在这本书中,您将频繁使用一些工具,并且最终您可能需要在多个项目中使用它们。本附录涵盖了这些工具的安装,您将在各章节中了解更多关于它们的内容。您可以从系统上的几乎任何位置运行安装命令。
## B.1 安装 pipx
一些工具作为 Python 包可用,但如果它们是通用性质的,您不希望在您使用它们的每个项目中都安装它们。尽管如此,您仍然应该单独安装这些工具,以免它们与不必要的其他已安装软件混合。pipx ([`github.com/pypa/pipx/`](https://github.com/pypa/pipx/)) 是一个受 JavaScript 世界 npx ([`docs.npmjs.com/cli/v8/commands/npx`](https://docs.npmjs.com/cli/v8/commands/npx)) 启发的在隔离环境中运行其他 Python 工具的管理工具。您将使用 pipx 安装一些通用工具,并且您可以使用它来安装您未来想要的“系统级”工具。
使用以下命令安装 pipx,可选地提供要安装它的所需基本 Python 版本:
$ py -3.10 -m pip install pipx
安装 pipx 以便 pipx 可以管理自身
pipx 专注于隔离工具,但自身并不隔离。您可以使用 pipx-in-pipx 项目 ([`github.com/mattsb42-meta/pipx-in-pipx`](https://github.com/mattsb42-meta/pipx-in-pipx)) 安装 pipx,这样甚至 pipx 本身也是隔离的。pipx 还将能够管理自身进行版本升级等。为此,安装`pipx-in-pipx`而不是`pipx`。文档中提到了一些尖锐的边缘;我自己还没有遇到任何实质性的问题。
安装完成后,启动一个新的 shell 会话,以确保包提供的`pipx`命令在您的`$PATH`上可用。
## B.2 安装构建工具
build ([`github.com/pypa/build`](https://github.com/pypa/build)) 是 Python 打包权威机构(PyPA)提供的一个用于构建 Python 包的工具。因为您可能最终会想用它来构建多个不同的包,所以使用 pipx 安装它将使其在任何可能需要的地方可用。您将使用`build`来构建您在本书过程中开发的 Python 包。使用以下命令安装 build:
$ pipx install build
您应该看到类似以下输出,表明`pyproject-build`应用程序已安装:
installed package build 0.4.0, Python 3.10.0
These apps are now globally available
- pyproject-build
done! ✨ 🌟 ✨
要验证您的配置,请运行以下命令:
$ pyproject-build --version
版本应与`pipx install`命令输出中的版本相匹配。
## B.3 安装 tox
tox ([`tox.wiki/en/latest/`](https://tox.wiki/en/latest/)) 是一个用于 Python 项目的测试和任务管理工具。使用以下命令安装 tox:
$ pipx install tox
您应该看到类似以下输出的内容:
installed package tox 3.23.1, Python 3.10.0
These apps are now globally available
- tox
- tox-quickstart
done! ✨ 🌟 ✨
要验证您的配置,请运行以下命令:
$ tox --version
版本应与`pipx install`命令输出中的版本相匹配。
## B.4 安装 pre-commit
pre-commit ([`pre-commit.com`](https://pre-commit.com)) 是一个用于管理和执行 Git 仓库的 pre-commit 钩子的工具。使用以下命令安装 pre-commit:
$ pipx install pre-commit
您应该看到类似以下输出:
installed package pre-commit 2.17.0, Python 3.10.0
These apps are now globally available
- pre-commit
- pre-commit-validate-config
- pre-commit-validate-manifest
done! ✨ 🌟 ✨
要验证您的配置,请运行以下命令:
$ pre-commit --version
版本应与 `pipx install` 命令输出中的版本匹配。
## B.5 安装 cookiecutter
cookiecutter ([`cookiecutter.readthedocs.io`](https://cookiecutter.readthedocs.io)) 是一个从项目模板创建项目的工具。使用以下命令安装 cookiecutter:
installed package cookiecutter 1.7.3, Python 3.10.0
These apps are now globally available
- cookiecutter
done! ✨ 🌟 ✨
要验证您的配置,请运行以下命令:
$ cookiecutter --version
版本应与 `pipx install` 命令输出中的版本匹配。
# 第一部分\. 基础
软件打包可能是将应用和行为带入消费者市场最重要的成就。包让我们能够在项目中重用他人的工作,在我们的手机上安装应用,等等。没有打包,我们降低的生产力仍会使我们陷入软件开发黑暗时代。
无论你已经是 Python 包的维护者还是刚开始接触打包,对打包概念有一个扎实的理解将使你在阅读本书和其他项目时保持正确的思维方式。本部分涵盖了什么是打包,你需要做什么才能开始自己的 Python 包,以及什么构成了一个最小的工作包。
# 第二部分:创建一个可行的包
你为什么要构建一个包?你可能想要与你的团队分享一些代码,以便在多个项目中使用。你可能想要创建一个其他人可以安装和运行的命令行界面。或者你可能需要将性能良好的代码(如 C 语言)抽象化,以便在 Python 中提供一个易于使用的交互层。你可能因为思考构建包的原因和实现方式而感到头晕,但不要绝望。
这一部分将帮助你将包装项目视为一系列移动部件的管道,并构建一个编排过程来管理整个过程。项目练习现实世界的工具和活动,这些工具和活动能增强你的信心和肌肉记忆,使你能够在以后更改或添加新的活动到你的管道中。尽管可能性是无限的,但你控制着方向,你应该感到掌控全局。
# 第三部分:公开
无论你包装软件的动机是什么,你很可能计划至少在一个与你的日常工作环境之外的场景中分享你的工作。你与之分享代码的人很可能会发现错误或希望添加新功能。随着你的项目需求的变化和增长,你的能力会惊人地迅速缩小。你需要一些实践来最大化生产力并将对项目的贡献商品化,这样你就可以接受任何愿意提供帮助的人的帮助。
这一部分将你的包装项目带入协作模式。你将增强你的管道,以便在您或您的团队成员对代码进行更改时进行自动化检查,提供有用的文档,以便团队和您的用户可以理解项目,并通过定期更新依赖项和语法来防止项目过时。
# 第四部分. 长期坚持
到这本书的这一部分,你已经至少操作过一个运行良好的 Python 包项目。无论你打算将你所学应用到现有的项目,还是未来创建的新项目中,都有很多信息需要记住,以及大量的配置、语法和目录结构需要正确设置。如果你几个月后回来,你可能会发现自己不得不从头开始重新学习这些材料。如果你试图同时扩大你的维护者团队和用户基础,这可能会变得有些困难。
这一部分帮助你为新的项目创建一个可重复的过程,这样你可以快速启动并运行,专注于每个项目的独特软件。你还将了解一些成功用户社区方面的内容,这些内容最大化了其他人贡献和维护你项目的可能性。


浙公网安备 33010602011771号