译者:豌豆花下猫@Python猫

英文:Using Mypy in production at Spring (https://notes.crmarsh.com/using-mypy-in-production-at-spring)

在 Spring ,我们掩护了一个大型的 Python 单体代码库(英:monorepo),用上了 Mypy 最严格的配置项,实现了 Mypy 全覆盖。
简而言之,这意味着每个函数署名都是带表明的,并且不许可有隐式的 Any 转换。

missingargumentphpSpring 应用 Mypy 检讨 30 万行代码总结出 3 年夜痛点与 6 个技能 Java

(译注:此处的 Spring 并不是 Java 中那个著名的 Spring 框架,而是一家生物科技公司,专注于找到与年事干系的疾病的疗法,2022 年 3 月曾得到比尔&梅琳达·盖茨基金会 120 万美元的帮助。

诚然,代码行数是一个糟糕的衡量标准,但可作一个粗略的估计:我们的代码仓有超过 30 万行 Python 代码,个中大约一半构成了核心的数据平台,另一半是由数据科学家和机器学习研究员编写的终端用户代码。

我有个大胆的预测,就这个规模而言,这是最全面的加了类型的 Python 代码仓之一。

我们在 2019 年 7 月首次引入了 Mypy,大约一年后实现了全面的类型覆盖,从此成为了快乐的 Mypy 用户。

几周前,我跟 Leo Boytsov 和 Erik Bernhardsson 在 Twitter 上对 Python 类型有一次简短的谈论——然后我看到 Will McGugan 也对类型大加讴歌。
由于 Mypy 是我们在 Spring 公司发布和迭代 Python 代码的关键部分,我想写一下我们在过去几年中大规模利用它的履历。

一句话总结:虽然采取 Mypy 是有代价的(前期和持续的投入、学习曲线等),但我创造它对付掩护大型 Python 代码库有着不可估量的代价。
Mymy 可能不适宜于所有人,但它十分适宜我。

Mypy 是什么?

(如果你很熟习 Mypy,可跳过本节。

Mypy 是 Python 的一个静态类型检讨工具。
如果你写过 Python 3,你可能会把稳到 Python 支持类型表明,像这样:

def greeting(name: str) -> str: return 'Hello ' + name

Python 在 2014 年通过 PEP-484 定义了这种类型表明语法。
虽然这些表明是措辞的一部分,但 Python(以及干系的第一方工具)实际上并不拿它们来逼迫做到类型安全。

相反,类型检讨通过第三方工具来实现。
Mypy 便是这样的工具。
Facebook 的 Pyre 也是这样的工具——但就我所知,Mypy 更受欢迎(Mypy 在 GitHub 上有两倍多的星星,它是 Pants 默认利用的工具)。
IntelliJ 也有自己的类型检讨工具,支持在 PyCharm 中实现类型推断。
这些工具都声称自己“兼容 PEP-484”,由于它们利用 Python 本身定义的类型表明。

(译注:最著名的类型检讨工具还有谷歌的pytype 和微软的pyright ,关于基本情形先容与比拟,可查阅这篇文章《先容几款 Python 类型检讨工具》 )

换句话说:Python 认为自己的任务是定义类型表明的语法和语义(只管 PEP-484 本身很大程度上受到了 Mypy 现有版本的启示),但故意让第三方工具来检讨这些语义。

请把稳,当你利用像 Mypy 这样的工具时,你是在 Python 本身之外运行它的——比如,当你运行mypy path/to/file.py 后,Mypy 会把推断出的违规代码都吐出来。
Python 在运行时显露但不利用那些类型表明。

(顺便一提:在写本文时,我理解到比较于 Pypy 这样的项目,Mypy 最初有着非常不同的目标。
那时还没有 PEP-484(它的灵感来自 Mypy!
),以是 Mypy 定义了自己的语法,与 Python 不同,并实现了自己的运行时(也便是说,Mypy 代码是通过 Mypy 实行的)。
当时,Mypy 的目标之一是利用静态类型、不可变性等来提高性能——而且明确地避开了与 CPython 兼容。
Mypy 在 2013 年切换到兼容 Python 的语法,而 PEP-484 在 2015 年才推出。
(“利用静态类型加速 Python”的观点催生了 Mypyc,它仍旧是一个生动的项目,可用于编译 Mypy 本身。
))

在 Spring 集成 Mypy

我们在 2019 年 7 月将 Mypy 引入代码库(#1724)。
当首次发起发起时,我们有两个紧张的考虑:

虽然 Mypy 在 2012 年的 PyCon 芬兰大会上首次亮相,并在 2015 年初发布了兼容 PEP-484 的版本,但它仍旧是一个相称新的工具——至少对我们来说是这样。
只管我们在一些相称大的 Python 代码库上事情过(在可汗学院和其它地方),但团队中没有人利用过它。
像其它增量类型检讨工具一样(例如 Flow),随着代码库的表明越来越多,Mypy 的代价会与时俱增。
由于 Mypy 可以并且将会用最少的表明捕获 bug,以是你在代码库上投入表明的韶光越多,它就会变得越有代价。

只管有所犹豫,我们还是决定给 Mypy 一个机会。
在公司内部,我们有强烈偏好于静态类型的工程师文化(除了 Python,我们写了很多 Rust 和 TypeScript)。
以是,我们准备利用 Mypy。

我们首先类型化了一些文件。
一年后,我们完成了全部代码的类型化(#2622),并升级到最严格的 Mypy 设置(最关键的是 disallow_untyped_defs ,它哀求对所有函数署名进行表明),从那时起,我们一贯掩护着这些设置。
(Wolt 团队有一篇很好的文章,他们称之为“专业级的 Mypy 配置”,巧合的是,我们利用的正是这种配置。

Mypy 配置:https://blog.wolt.com/engineering/2021/09/30/professional-grade-mypy-configuration/

反馈

总体而言:我对 Mypy 持积极的意见。
作为核心根本举动步伐的开拓职员(跨做事和跨团队利用的公共库),我认为它极其有用。

我将在往后的任何 Python 项目中连续利用它。

好处

Zulip 早在 2016 年写了一篇俊秀的文章,内容关于利用 Mypy 的好处(这篇文章也被收入了 Mypy 官方文档 中)。

Zulip 博文:https://blog.zulip.com/2016/10/13/static-types-in-python-oh-mypy/#benefitsofusingmypy

我不想重述静态类型的所有好处(它很好),但我想简要地强调他们在帖子中提到的几个好处:

改进可读性:有了类型表明,代码趋向于自描述(与文档字符串不同,这种描述的准确性可以静态地逼迫实行)。
(英:self-documenting)捕获缺点:是真的!
Mypy 确实能找出 bug。
从始至终。
自傲地重构:这是 Mypy 最有影响力的一个好处。
有了 Mypy 的广泛覆盖,我可以自傲地发布涉及数百乃至数千个文件的变动。
当然,这与上一条好处有关——我们用 Mypy 找出的大多数 bug 都是在重构时创造的。

第三点的代价怎么强调都不为过。
绝不夸年夜地说,在 Mypy 的帮助下,我发布变动的速率快了十倍,乃至快了一百倍。

虽然这是完备主不雅观的,但在写这篇文章时,我意识到:我信赖 Mypy。
虽然程度还不及,比如说 OCaml 编译器,但它完备改变了我掩护 Python 代码的关系,我无法想象回到没有表明的天下。

痛点

Zulip 的帖子同样强调了他们在迁移 Mypy 时所经历的痛点(与静态代码剖析工具的交互,循环导入)。

坦率地说,我在 Mypy 上经历的痛点与 Zulip 文章中提到的不一样。
我把它们分成三类:

外部库缺少类型表明Mypy 学习曲线对抗类型系统

让我们来逐一回顾一下:

1. 外部库缺少类型表明

最主要的痛点是,我们引入的大多数第三方 Python 库要么是无类型的,要么不兼容 PEP-561。
在实践中,这意味着对这些外部库的引用会被解析为不兼容,这会大大削弱类型的覆盖率。

每当在环境里添加一个第三方库时,我们都会在mypy.ini 里添加一个容许条款,它见告 Mypy 要忽略那些模块的类型表明(有类型或供应类型存根的库,比较罕见):

[mypy-altair.]ignore_missing_imports = True[mypy-apache_beam.]ignore_missing_imports = True[mypy-bokeh.]ignore_missing_imports = True...

由于有了这样的安全出口,纵然是随便写的表明也不会生效。
例如,Mypy 许可这样做:

import pandas as pddef return_data_frame() -> pd.DataFrame: """Mypy interprets pd.DataFrame as Any, so returning a str is fine!""" return "Hello, world!"

除了第三方库,我们在 Python 标准库上也碰着了一些不顺。
例如,functools.lru_cache 只管在 typeshed 里有类型表明,但由于繁芜的缘故原由,它不保留底层函数的署名,以是任何用 @functools.lru_cache 装饰的函数都会被移除所有类型表明。

例如,Mypy 许可这样做:

import functools@functools.lru_cachedef add_one(x: float) -> float: return x + 1add_one("Hello, world!")

第三方库的情形正在改进。
例如,NumPy 在 1.20 版本中开始供应类型。
Pandas 也有一系列公开的类型存根 ,但它们被标记为不完全的。
(添加存根到这些库是非常主要的,这是一个巨大的造诣!
)其余值得一提的是,我最近在 Twitter 上看到了 Wolt 的 Python 项目模板 ,它也默认包括类型。

以是,类型正在变得不再罕见。
过去当我们添加一个有类型表明的依赖时,我会感到惊异。
有类型表明的库还是少数,并未成为主流。

2. Mypy 学习曲线

大多数加入 Spring 的人没有利用过 Mypy(写过 Python),只管他们基本知道并熟习 Python 的类型表明语法。

同样地,在口试中,候选人每每不熟习typing 模块。
我常日在跟候选人作广泛的技能谈论时,会展示一个利用了typing.Protocol 的代码片段,我不记得有任何候选人看到过这个特定的布局——当然,这完备没问题!
但这表示了 typing 在 Python 生态的盛行程度。

以是,当我们招募团队成员时,Mypy 每每是他们必须学习的新东西。
虽然类型表明语法的根本很大略,但我们常常听到这样的问题:“为什么 Mypy 会这样?”、“为什么 Mypy 在这里报错?”等等。

例如,这是一个常日须要阐明的例子:

if condition: value: str = "Hello, world"else: # Not ok -- we declared `value` as `str`, and this is `None`! value = None...if condition: value: str = "Hello, world"else: # Not ok -- we already declared the type of `value`. value: Optional[str] = None...# This is ok!if condition: value: Optional[str] = "Hello, world"else: value = None

其余,还有一个随意马虎稠浊的例子:

from typing import Literaldef my_func(value: Literal['a', 'b']) -> None: ...for value in ('a', 'b'): # Not ok -- `value` is `str`, not `Literal['a', 'b']`. my_func(value)

当阐明之后,这些例子的“缘故原由”是有道理的,但我不可否认的是,团队成员须要耗费韶光去熟习 Mypy。
有趣的是,我们团队中有人说 PyCharm 的类型赞助觉得还不如在同一个 IDE 中利用 TypeScript 得到的有用和完全(纵然有足够的静态类型)。
不幸的是,这只是利用 Mypy 的代价。

除了学习曲线之外,还有持续地表明函数和变量的开销。
我曾建议对某些“种类”的代码(如探索性数据剖析)放宽我们的 Mypy 规则——然而,团队的觉得是表明是值得的,这件事很酷。

3. 对抗类型系统

在编写代码时,我会只管即便避免几件事,以免导致自己与类型系统作斗争:写出我知道可行的代码,并强制 Mypy 接管。

首先是@overload ,来自typing 模块:非常强大,但很难精确利用。
当然,如果须要重载一个方法,我就会利用它——但是,就像我说的,如果可以的话,我宁肯避免它。

基本事理很大略:

@overloaddef clean(s: str) -> str: ...@overloaddef clean(s: None) -> None: ...def clean(s: Optional[str]) -> Optional[str]: if s: return s.strip().replace("\u00a0", " ") else: return None

但常日,我们想要做一些事情,比如“基于布尔值返回不同的类型,带有默认值”,这须要这样的技巧:

@overloaddef lookup( paths: Iterable[str], , strict: Literal[False]) -> Mapping[str, Optional[str]]: ...@overloaddef lookup( paths: Iterable[str], , strict: Literal[True]) -> Mapping[str, str]: ...@overloaddef lookup( paths: Iterable[str]) -> Mapping[str, Optional[str]]: ...def lookup( paths: Iterable[str], , strict: Literal[True, False] = False) -> Any: pass

纵然这是一个 hack——你不能传一个bool到 find_many_latest,你必须传一个字面量 True 或False。

同样地,我也碰着过其它问题,利用 @typing.overload 或者@overload 、在类方法中利用@overload ,等等。

其次是TypedDict ,同样来自typing 模块:可能很有用,但每每会产生笨拙的代码。

例如,你不能解构一个TypedDict ——它必须用字面量 key 布局——以是下方第二种写法是行不通的:

from typing import TypedDictclass Point(TypedDict): x: float y: floata: Point = {"x": 1, "y": 2}# error: Expected TypedDict key to be string literalb: Point = {a, "y": 3}

在实践中,很难用TypedDict工具做一些 Pythonic 的事情。
我终极方向于利用 dataclass 或 typing.NamedTuple 工具。

第三是装饰器。
Mypy 的 文档 对保留署名的装饰器和装饰器工厂有一个规范的建议。
它很前辈,但确实有效:

F = TypeVar("F", bound=Callable[..., Any])def decorator(func: F) -> F: def wrapper(args: Any, kwargs: Any): return func(args, kwargs) return cast(F, wrapper)@decoratordef f(a: int) -> str: return str(a)

但是,我创造利用装饰器做任何花哨的事情(特殊是不保留署名的情形),都会导致代码难以类型化或者充斥着逼迫类型转换。

这可能是一件好事!
Mypy 确实改变了我编写 Python 的办法:耍小聪明的代码更难被精确地类型化,因此我只管即便避免编写讨巧的代码。

(装饰器的另一个问题是我前面提过的@functools.lru_cache :由于装饰器终极定义了一个全新的函数,以是如果你禁绝确地表明代码,就可能会涌现严重而令人惊异的缺点。

我对循环导入也有类似的觉得——由于要导入类型作为表明利用,这就可能导致涌现本可避免的循环导入(这也是 Zulip 团队强调的一个痛点)。
虽然循环导入是 Mypy 的一个痛点但这常日意味着系统或代码本身存在着设计毛病,这是 Mypy 强制我们去考虑的问题。

不过,根据我的履历,纵然是履历丰富的 Mypy 用户,在类型检讨通过之前,他们也需对本来可以正常事情的代码进行一两处更正。

(顺便说一下:Python 3.10 利用ParamSpec 对装饰器的情形作了重大的改进。

提示与技巧

末了,我要先容几个在利用 Mypy 时很有用的技巧。

1. reveal_type

在代码中添加reveal_type 可以让 Mypy 在对文件进行类型检讨时,显示出变量的推断类型。
这是非常非常非常有用的。

最大略的例子是:

# No need to import anything. Just call `reveal_type`.# Your editor will flag it as an undefined reference -- just ignore that.x = 1reveal_type(x) # Revealed type is "builtins.int"

当你处理泛型时,reveal_type 特殊地有用,由于它可以帮助你理解泛型是如何被“添补”的、类型是否被缩小了,等等。

2. Mypy 作为一个库

Mypy 可以用作一个运行时库!

我们内部有一个事情流编排库,看起来有点像 Flyte 或 Prefect。
细节并不主要,但值得把稳的是,它是完备类型化的——因此我们可以静态地提升待运行任务的类型安全性,由于它们被链接在一起。

把类型弄准确是非常具有寻衅性的。
为了确保它无缺,不被意外的Any毒害,我们在一组文件上写了调用 Mypy 的单元测试,并断言 Mypy 抛出的缺点能匹配一系列预期内的非常:

def test_check_function(self) -> None: result = api.run( [ os.path.join( os.path.dirname(__file__), "type_check_examples/function.py", ), "--no-incremental", ], ) actual = result[0].splitlines() expected = [ # fmt: off 'type_check_examples/function.py:14: error: Incompatible return value type (got "str", expected "int")', # noqa: E501 'type_check_examples/function.py:19: error: Missing positional argument "x" in call to "__call__" of "FunctionPipeline"', # noqa: E501 'type_check_examples/function.py:22: error: Argument "x" to "__call__" of "FunctionPipeline" has incompatible type "str"; expected "int"', # noqa: E501 'type_check_examples/function.py:25: note: Revealed type is "builtins.int"', # noqa: E501 'type_check_examples/function.py:28: note: Revealed type is "builtins.int"', # noqa: E501 'type_check_examples/function.py:34: error: Unexpected keyword argument "notify_on" for "options" of "Expression"', # noqa: E501 'pipeline.py:307: note: "options" of "Expression" defined here', # noqa: E501 "Found 4 errors in 1 file (checked 1 source file)", # fmt: on ] self.assertEqual(actual, expected)3. GitHub 上的问题

当搜索如何办理某个类型问题时,我常常会找到 Mypy 的 GitHub Issues (比 Stack Overflow 还多)。
它可能是 Mypy 类型干系问题的办理方案和 How-To 的最佳知识源头。
你会创造其核心团队(包括 Guido)对主要问题的提示和建议。

紧张的缺陷是,GitHub Issue 中的每个评论仅仅是某个特定时刻的评论——2018 年的一个问题可能已经办理了,去年的一个变通方案可能有了新的最佳实践。
以是在查阅 issue 时,一定要把这一点牢记于心。

4. typing-extensions

typing 模块在每个 Python 版本中都有很多改进,同时,还有一些特性会通过typing-extensions 模块向后移植。

例如,虽然只利用 Python 3.8,但我们借助typing-extensions ,在前面提到的事情流编排库中利用了3.10 版本的ParamSpec。
(遗憾的是,PyCharm 彷佛不支持通过typing-extensions 引入的ParamSpec 语法,并将其标记为一个缺点,但是,还算好吧。
)当然,Python 本身语法变革而涌现的特性,不能通过typing-extensions 得到。

5. NewType

在 typing 模块中有很多有用的赞助工具,NewType 是我的最爱之一。

NewType 可让你创建出不同于现有类型的类型。
例如,你可以利用NewType 来定义合规的谷歌云存储 URL,而不仅是str 类型,比如:

from typing import NewTypeGCSUrl = NewType("GCSUrl", str)def download_blob(url: GCSUrl) -> None: ...# Incompatible type "str"; expected "GCSUrl"download_blob("gs://my_bucket/foo/bar/baz.jpg")# Ok!download_blob(GCSUrl("gs://my_bucket/foo/bar/baz.jpg"))

通过向download_blob 的调用者指出它的意图,我们使这个函数具备了自描述能力。

我创造 NewType对付将原始类型(如 str 和 int )转换为语义上故意义的类型特殊有用。

6. 性能

Mypy 的性能并不是我们的紧张问题。
Mypy 将类型检讨结果保存到缓存中,能加快重复调用的速率(据其文档称:“Mypy 增量地实行类型检讨,复用前一次运行的结果,以加快后续运行的速率”)。

在我们最大的做事中运行 mypy,冷缓存大约须要 50-60 秒,热缓存大约须要 1-2 秒。

至少有两种方法可以加速 Mypy,这两种方法都利用了以下的技能(我们内部没有利用):

Mypy 守护进程在后台持续运行 Mypy,让它在内存中保持缓存状态。
虽然 Mypy 在运行后将结果缓存到磁盘,但是守护进程确实是更快。
(我们利用了一段韶光的默认 Mypy 守护进程,但因共享状态导致一些问题后,我禁用了它——我不记得详细细节了。
)共享远程缓存。
如前所述,Mypy 在每次运行后都会将类型检讨结果缓存到磁盘——但是如果在新机器或新容器上运行 Mypy(就像在 CI 上一样),则不会有缓存的好处。
办理方案是在磁盘上预置一个最近的缓存结果(即,预热缓存)。
Mypy 文档概述了这个过程,但它相称繁芜,详细内容取决于你自己的设置。
我们终极可能会在自己的 CI 系统中启用它——暂时还没有去做。
结论

Mypy 对我们产生了很大的影响,提升了我们发布代码时的信心。
虽然采纳它须要付出一定的本钱,但我们并不后悔。

除了工具本身的代价之外,Mypy 还是一个让人印象非常深刻的项目,我非常感谢掩护者们多年来为它付出的事情。
在每一个 Mypy 和 Python 版本中,我们都看到了对 typing模块、表明语法和 Mypy 本身的显著改进。
(例如:新的联合类型语法( X|Y)、 ParamSpec 和 TypeAlias,这些都包含在 Python 3.10 中。

原文发布于 2022 年 8 月 21 日。

作者:Charlie Marsh

译者:豌豆花下猫@Python猫

英文:Using Mypy in production at Spring (https://notes.crmarsh.com/using-mypy-in-production-at-spring)

请关注我,收成更多优质的 Python 文章和资讯!