当我们在大型 Python 代码库中引入动态类型代码时,我们将 Dagster 的公共 API 类型覆盖率提升至 100%,并从中汲取了宝贵经验。Python 是世界上最受欢迎的编程语言之一。动态类型使其易于学习且开发高效,这种流行性也促使它被用于全球最先进的 AI 系统和 Web 应用。
然而,传统的动态类型 Python 代码在大规模应用时显得力不从心。具体表现为:
重构令人头疼。重构大型动态类型代码库极具挑战性,因为动态类型代码无法像静态类型语言(如 Java、C#)那样,广泛利用静态分析和重构工具。这些工具支持自动符号重命名、引用查找、编辑时类型检查等功能。
难以确保正确性。动态类型 Python 代码的正确性很难验证。现有最佳实践是编写全面的自动化测试,但构建和维护庞大测试套件的代价高昂。此外,测试通常运行较慢,无法像静态分析工具那样在编辑时即时反馈,导致错误可能累积更久才被发现。
Python 并非唯一存在这些问题的语言。全球最流行的语言 JavaScript 也深陷其中。TypeScript——一种于 2012 年首次发布的 JavaScript 静态类型变体——迅速席卷了 JavaScript 界。它为 JavaScript 增加了丰富的类型注解,从而开启了通往重构和静态分析工具的大门,极大地促进了大型 JavaScript 项目的开发与维护。
同年,Jukka Lehtosalo 发布了 mypy,该项目承诺为 Python 带来类似的优势。两年后,受 mypy 启发的类型注解在 Python 3.5 中通过 PEP 484 成为标准,并在随后的每个主要版本中不断得到改进。到了 2022 年,市场上已涌现出多种成熟的类型检查工具(如 mypy、pyright、pyre)用于解析 Python 类型注解,且人们对库提供带类型注解的公共接口的期待也日益增强。
📚 Dagster 的故事
💻 第 1 步:配置语言服务器
✅ 第 2 步:使用 py.typed 标记已发布的包
📖 第 3 步:正式定义公共 API
🔌 第 4 步:设置 CI
🏦 第 5 步:添加注解!
🏆 回报与收获
📚 Dagster 的故事
Dagster 的开发始于 2018 年,彼时给 Python 代码添加类型注解尚未成为行业最佳实践。我们撰写了数千个文件,包含数十万行代码,但均无类型标注。虽然我们在后期开始要求新代码包含类型信息,但代码库中至关重要的早期部分仍未添加类型。随着 Dagster 规模和影响力的扩大,开发人员和使用方都开始为缺乏类型注解而头疼。开发者在代码库的不熟悉模块中难以定位信息,用户也难以掌握 Dagster 众多 API 的正确用法。
我们承诺大幅提升 Dagster 的类型质量,首要任务是将核心 dagster 包的公开接口实现 100% 类型覆盖。事实证明这是一项艰巨的工程。在本文中,我们记录了相关经验,供类似项目的参考。我们将整个流程拆解为五个步骤,用于优化和维持任意 Python 项目的类型注解质量。
💻 第一步:配置语言服务器
如果没有合适的工具链,为大型代码库添加类型注解将极其枯燥。单个模块纯粹为了注解目的可能需要导入十几个符号,你不可能在几百个文件中手动完成这项工作。
单靠类型检查器(Type checker)无法解决此问题——你需要自动导入(Auto-import)功能,这是自动补全的一种“副作用”,它会将补全的符号无缝添加至模块的导入列表中。自动导入(及更广义的自动补全)通常由语言服务器(Language Server)提供。类型检查器与语言服务器的区别何在?类型检查器(如 mypy)是一个分析源代码并标记类型不一致的程序,运行方式类似典型的 linter 或命令行工具——你传入输入和配置,程序分析代码并输出错误列表。语言服务器(如 pylance)则是在编辑期间后台持续运行的进程,通常由编辑器启动,在内存中保持代码的“活”表示,并通过 Language Server Protocol 与编辑器通信。功能齐全的语言服务器提供类型检查器功能的超集——它可以在每次击键时对目标文件进行类型检查,或将类型错误与其他类型的错误汇总。
| | 类型检查器 | 完整语言服务器 |
| :--- | :---: | :---: |
| 类型检查 | ✅ | ✅ |
| 实时反馈(自动补全等) | ❌ | ✅ |
| 交互式代码导航(跳转定义、查找引用等) | ❌ | ✅ |
| 自动化重构(重命名符号等) | ❀ | ✅ |
语言服务器还能在类型检查器范围之外的方式利用类型注解。除上述自动导入外,许多语言服务器还提供了 API,允许跳转至光标下实例的类定义、搜索代码中引用该类的全部实例等。这些功能在大型代码库中非常实用。请给自己一个机会,在开始注解代码之前,务必先配置好语言服务器。
Python 生态中主导的语言服务器是 Microsoft 的 pyright/pylance(Palantir 还有一个名为 python-language-server 的服务器,但已停止维护,请避开)。pyright/pylance 的市场定位有些令人困惑(参见此处的不完整差异列表)。
Pyright 是开源项目,被称为“Python 静态类型检查器”——这一描述准确但具有误导性。Pyright 确实包含一个功能完整的静态类型检查器(用 TypeScript 编写,与 mypy 的实现完全独立),但与 mypy 不同,它还包含一个语言服务器,提供许多强大功能,包括自动导入。
Pylance 是闭源项目,被称为“Visual Studio Code 的语言服务器扩展”。尽管 pyright 可在任何环境中运行,但 pylance 是作为 VSCode 扩展发布的,官方设计仅用于在 VSCode 中运行。该扩展的核心其实是一个语言服务器可执行文件,理论上可配合任何支持 Language Server Protocol 的编辑器使用。然而,Microsoft 实施的保护机制阻止了在 VSCode 之外的执行环境。类型检查及 pylance 的大部分其他功能实际上由开源的 pyright 提供,但还有一层专有代码提供了更强大的语言服务器特性。
除非你已有 mypy 的工作流或配置,否则我们建议完全跳过 mypy,直接使用 pyright(用于命令行类型检查和非 VSCode 编辑器的语言服务器)和 pylance(作为 VSCode 的语言服务器)。不仅限于语言服务器功能——pyright 的类型检查能力也优于 mypy。Pyright 速度更快,开发团队响应更为敏锐(bug 修复迅速;mypy 的 bug 往往在 issue tracker 中滞留数年),且比 mypy 更好地理解某些类型构造(尤其是递归类型)。
注意,配置 pyright/pylance(配合 linter 等其他工具)可能比较棘手,特别是在 monorepo 环境中。很容易误在错误的虚拟环境中运行工具,在只想要一个实例时生成多个语言服务器实例,因仓库中存在多个配置文件而意外覆盖配置,或将子库排除在类型检查之外。非常值得花时间去熟悉 pyright 的配置(pylance 也使用该配置)以及编辑器的语言服务器管理文档。如果遇到令人困惑的类型错误,最好先进行理性排查:确保 pyright/pylance 使用的是正确的配置,并运行在适当的 Python 环境中,然后再去死磕那些类型谜题。
对免费试用 Dagster Cloud 感兴趣?
企业级编排,优先考虑开发者体验。支持 Serverless 或混合部署,原生分支管理,开箱即用的 CI/CD。
免费试用 30 天
✅ 第二步:使用 py.typed 标记发布包
由于大量 Python 代码缺乏类型标注,流行的静态类型检查器(mypy, pyright)默认不使用第三方包中的类型信息。你必须在发布的包中显式标记其为“已添加类型”,方法是包含一个 py.typed 文件。PEP 561 对此有说明:
希望支持类型检查的包维护者必须在包中新增一个名为 py.typed 的标记文件以支持 typing。该标记具有递归性:如果顶级包包含此文件,其所有子包必须也支持类型检查。
如果你的包没有包含 py.typed,用户将享受不到你辛苦添加的类型注解带来的好处!即使你的包中每个函数都完成了注解,mypy 也会忽略这些注解并输出如下警告:
main.py:1: error: Skipping analyzing 'dagster': module is installed, but missing library stubs or py.typed marker
开发者很容易忽略这一点,因为它只在 mypy 通过 import 语句发现包时才会发生(这是库使用方的典型情况),而开发者通常直接通过源根目录调用 mypy,此时无论是否存在 py.typed 都会被检查。你可以在此处(针对 mypy)和此处(针对 pyright/pylance)阅读关于此行为的更多说明。
注意,尽管 PEP 561 声称“如果顶级包包含它,其所有子包必须也支持类型检查”,但你不必等到包实现 100% 类型化后才添加 py.typed。类型检查器对未添加类型的代码比较宽容,相比于等待 100% 覆盖,尽早向用户提供你已添加的类型注解访问权限是更好的做法。
最后一个陷阱:仅仅在源码仓库中添加 py.typed 是不够的——你需要确保它包含在包的发布发行版中。如果你正在使用 setuptools(大概率是),PEP 561 建议如下:
setup(
...,
package_data = {
'foopkg': ['py.typed'],
},
...,
)
📖 第三步:规范化公开 API
作为库作者,我们的建议是优先让公开 API 实现 100% 类型覆盖。为了达到并维持这一标准,有助于将公开 API 规范化,即描述为机器可读的形式。这将允许:(a) 类型覆盖率分析单独评估公开 API,区别于代码库的其余部分;(b) 其他静态分析工具为用户分析和解释代码(例如在自动补全中隐藏私有符号);(c) 生成的 API 文档可靠地仅包含你意图公开的 API。
历史上,Python 缺乏在库级别规范化公开 API 的方法。幸运的是,现在有一个新兴的 Python 标准(据我们所知尚未成为正式 PEP)。简而言之:
库的公开 API 默认是“退出制”(opt-out)——模块及定义在其中的(大多数)符号默认都是公开的。
所有希望从公开 API 中隐藏的模块/包必须以 _ 为前缀。带 _ 前缀的包也意味着其包含的所有模块均被标记为私有,因此这些内部模块本身无需再加 _ 前缀。
在公开模块内部,公开符号与私有符号之间存在区分。带 _ 前缀的符号是私有的,但库的公开 API 可以包含 _ 前缀之外的其他符号。
带 _ 前缀的模块和私有符号均不可被导入,但库的公开 API 可以重新导入这些模块的公开部分。
在 Dagster 中,我们的公开 API 此前是非正式定义的,即“除了带 _ 前缀的符号/模块之外的所有顶层符号”。我们决定采取新标准来定义公开 API。我们首先重构了所有 _ 前缀的符号/模块,使它们通过 _ 前缀明确成为私有。
#7808 重命名 check 为 _check
#8884 标记 dagster.{daemon,generate,grpc,loggers} 为私有
#8910 标记 dagster.{builtins,experimental,serdes,seven,utils} 为私有
#8981 标记 dagster.core 为私有
此外,我们在顶层 dagster 模块中冗余地别名的所有导入符号(根据上述规则,导入符号需别名以将其标记为公开):
from dagster._builtins import (
Any as Any,
Bool as Bool,
Float as Float,
Int as Int,
Nothing as Nothing,
String as String,
)
from dagster._config.config_schema import (
ConfigSchema as ConfigSchema,
)
...
完成上述步骤后,我们希望仅测量公开 API 的类型覆盖率。这可以使用 pyright 的“verify types”功能来完成。以下是针对 dagster 包运行该功能的(截断的)示例输出(注意:如果你的包没有 py.typed 文件,这将无法正常工作):
$ pyright --ignoreexternal --verifytypes dagster
dagster._core.launcher.default_run_launcher.DefaultRunLauncher.inst_data
/Users/smackesey/stm/code/elementl/oss/python_modules/dagster/dagster/_core/launcher/default_run_launcher.py: error: Return type annotation is missing
... 更多类型错误 ...
由 "dagster" 导出的符号:274
类型已知:251
类型模糊:3
类型未知:20
(忽略从其他包导入的未知类型)
无 docstring 的函数:4
无默认参数的函数:14
无 docstring 的类:0
"dagster" 引用但未导出的其他符号:5097
类型已知:3091
类型模糊:64
类型未知:1942
类型完整度得分:91.6%
3.006秒完成
上述方法允许我们在模块层级及这些模块导出的符号层级规范化公开 API。但我们希望更加具体——我们需要一种能力,使得导出的类上的“公开”方法可以从用户视角隐藏。
这种情况在大型项目中很容易出现。你拥有一个内部广泛使用的类,有时也被用户访问(例如 DagsterInstance)。你希望为内部代码和用户提供不同的类接口。Python 以 _ 前缀标记方法的惯例在此处帮不上忙——这按传统面向对象意义将方法标记为私有,意味着该方法不打算在类外部调用。但在公开(非 _ 前缀)方法的集合中,没有标准方法可以区分面向用户的方法和面向内部使用的方法。这是许多语言共有的问题(且在 Python 缺乏友元类的情况下只能部分解决)。
为了解决这个问题,我们决定要求公开类上的方法必须通过自定义的 @public 装饰器选择加入(opt in)我们的公开 API:
@public
def get_run_by_id(self, run_id: str) -> Optional[DagsterRun]:
return cast(DagsterRun, self._run_storage.get_run_by_id(run_id))
#8990 添加 public/deprecated 注解装饰器
#9065 为 NamedTuple 属性添加 PUBLIC 常量,为 @public 添加 property/staticmethod 支持
由于这不是 Python 标准,pyright --verifytypes 并不遵循它——在评估我们的公开 API 时,它包含了非 @public 的方法。不过,我们能够使用自定义 Sphinx 扩展来过滤 API 文档中包含的方法,仅保留 @public 方法:
#9632 修复 sphinx autoclass members
🔌 第四步:设置 CI
持续集成(CI)对于在大型协作代码库中维持标准至关重要。我们使用 Buildkite 作为 CI 平台,配置定义在此包中。除了各种测试和 linter 外,我们的配置还会检查类型正确性,并很快将检查类型覆盖率。
尽管我们此前推荐 pyright,但我们 CI 中的类型正确性检查几乎在所有代码库包中仍使用 mypy。这是由于惯性——我们从 mypy 开始类型化之旅,且 pyright 默认比 mypy 严格得多。我们尚未使代码库达到能通过 pyright 的程度。
mypy 在 CI 中表现良好,因为其行为保守——默认情况下,它会“忽略”未添加类型的代码。这意味着多种情况,但最重要的是未添加类型函数的返回值被赋值为 Any,这意味着它们永远不会触发类型错误。这在主要针对未添加类型的代码运行 CI 时非常方便——mypy 只会标记那些已尝试添加类型的代码中的错误。
我们将很快在 CI 中使用 pyright --ignoreexternal --verifytypes 来检查 dagster 的完整度。由于我们的许多集成库缺乏经过充分类型化的公开接口,因此我们暂不添加针对这些库的完整度检查。我们距离 dagster 的 100% 类型化公开 API 还差一步,因此该 CI 附加项仍是一个开放的 PR:
#9113 在 BK 中添加 pyright 类型检查和覆盖率步骤
⌨️ 第五步:添加注解!
至此,是时候真正添加类型注解了!这里列出了一堆我们逐步完成的 PR:
#7039 类型注解添加
#8993 公开 API 类型
#8356 杂项类型注解
#8308 IOManager 类型注解
🏆 回报与收获
那么,这一切值得吗?我们认为值得。尽管开发人员投入巨大,我们的类型注解改进在开发速度提升和用户体验改善方面都已获得回报。随着 Dagster 的演进、功能增加以及开发人员对代码库较老部分熟悉度的降低,我们预计将会获得更大的红利。
作为众多其他开源 Python 库的使用方,我们希望其他 Python 开发者开始优先实现完全类型化的公开接口。我们乐意在力所能及处做出贡献。在未来几个月内,我们将在本博客上发布至少一篇后续文章,深入探讨 Python 类型注解及相关工具链的某些细节。
如果你想支持 Dagster 开源项目,请记得 Star 我们的 Github 仓库。有反馈或问题?在 Slack 或 Github 发起讨论。想与我们共事?查看我们的职位空缺。想看更多此类内容?在 LinkedIn 上关注我们。