关于python:使用-Mypy-检查-30-万行-Python-代码总结出-3-大痛点与-6-个技巧

42次阅读

共计 9900 个字符,预计需要花费 25 分钟才能阅读完成。

作者:Charlie Marsh

译者:豌豆花下猫 @Python 猫

英文:Using Mypy in production at Spring (https://notes.crmarsh.com/usi…)

在 Spring,咱们保护了一个大型的 Python 单体代码库(英:monorepo),用上了 Mypy 最严格的配置项,实现了 Mypy 全笼罩。简而言之,这意味着每个函数签名都是带注解的,并且不容许有隐式的 Any 转换。

(译注:此处的 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 认为本人的责任是定义类型注解的语法和语义(只管 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)。当首次发动提议时,咱们有两个次要的思考:

  1. 尽管 Mypy 在 2012 年的 PyCon 芬兰大会上首次亮相,并在 2015 年初公布了兼容 PEP-484 的版本,但它依然是一个相当新的工具——至多对咱们来说是这样。只管咱们在一些相当大的 Python 代码库上工作过(在可汗学院和其它中央),但团队中没有人应用过它。
  2. 像其它增量类型查看工具一样(例如 Flow),随着代码库的注解越来越多,Mypy 的价值会与时俱增。因为 Mypy 能够并且将会用起码的注解捕捉 bug,所以你在代码库上投入注解的工夫越多,它就会变得越有价值。

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

咱们首先类型化了一些文件。一年后,咱们实现了全副代码的类型化(#2622),并降级到最严格的 Mypy 设置(最要害的是 disallow_untyped_defs,它要求对所有函数签名进行注解),从那时起,咱们始终保护着这些设置。(Wolt 团队有一篇很好的文章,他们称之为“专业级的 Mypy 配置”,偶合的是,咱们应用的正是这种配置。)

Mypy 配置:https://blog.wolt.com/enginee…

反馈

总体而言:我对 Mypy 持踊跃的认识。 作为外围基础设施的开发人员(跨服务和跨团队应用的公共库),我认为它极其有用。

我将在当前的任何 Python 我的项目中持续应用它。

益处

Zulip 早在 2016 年写了一篇丑陋的文章,内容对于应用 Mypy 的益处(这篇文章也被支出了 Mypy 官网文档 中)。

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

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

  1. 改善可读性:有了类型注解,代码趋向于自描述(与文档字符串不同,这种形容的准确性能够动态地强制执行)。(英:self-documenting)
  2. 捕捉谬误:是真的!Mypy 的确能找出 bug。从始至终。
  3. 自信地重构:这是 Mypy 最有影响力的一个益处。有了 Mypy 的宽泛笼罩,我能够自信地公布波及数百甚至数千个文件的更改。当然,这与上一条益处无关——咱们用 Mypy 找出的大多数 bug 都是在重构时发现的。

第三点的价值怎么强调都不为过。毫不夸大地说,在 Mypy 的帮忙下,我公布更改的速度快了十倍,甚至快了一百倍。

尽管这是齐全主观的,但在写这篇文章时,我意识到:我信赖 Mypy。尽管水平还不迭,比如说 OCaml 编译器,但它齐全扭转了我保护 Python 代码的关系,我无奈设想回到没有注解的世界。

痛点

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

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

  1. 内部库不足类型注解
  2. Mypy 学习曲线
  3. 反抗类型零碎

让咱们来逐个回顾一下:

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 pd

def 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_cache
def add_one(x: float) -> float:
    return x + 1

add_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 Literal

def 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 模块:十分弱小,但很难正确应用。当然,如果须要重载一个办法,我就会应用它——然而,就像我说的,如果能够的话,我宁肯防止它。

基本原理很简略:

@overload
def clean(s: str) -> str:
    ...

@overload
def clean(s: None) -> None:
    ...

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

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

@overload
def lookup(paths: Iterable[str], *, strict: Literal[False]
) -> Mapping[str, Optional[str]]:
    ...


@overload
def lookup(paths: Iterable[str], *, strict: Literal[True]
) -> Mapping[str, str]:
    ...


@overload
def lookup(paths: Iterable[str]
) -> Mapping[str, Optional[str]]:
    ...


def lookup(paths: Iterable[str], *, strict: Literal[True, False] = False
) -> Any:
    pass

即便这是一个 hack——你不能传一个 boolfind_many_latest,你必须传一个字面量 TrueFalse

同样地,我也遇到过其它问题,应用 @typing.overload 或者@overload、在类办法中应用@overload,等等。

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

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

from typing import TypedDict

class Point(TypedDict):
    x: float
    y: float

a: Point = {"x": 1, "y": 2}

# error: Expected TypedDict key to be string literal
b: Point = {**a, "y": 3}

在实践中,很难用 TypedDict 对象做一些 Pythonic 的事件。我最终偏向于应用 dataclasstyping.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)

@decorator
def 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 = 1
reveal_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 NewType

GCSUrl = 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对于将原始类型(如 strint)转换为语义上有意义的类型特地有用。

6. 性能

Mypy 的性能并不是咱们的次要问题。Mypy 将类型查看后果保留到缓存中,能放慢反复调用的速度(据其文档称:“Mypy 增量地执行类型查看,复用前一次运行的后果,以放慢后续运行的速度”)。

在咱们最大的服务中运行 mypy,冷缓存大概须要 50-60 秒,热缓存大概须要 1-2 秒。

至多有两种办法能够减速 Mypy,这两种办法都利用了以下的技术(咱们外部没有应用):

  1. Mypy 守护过程在后盾继续运行 Mypy,让它在内存中放弃缓存状态。尽管 Mypy 在运行后将后果缓存到磁盘,然而守护过程的确是更快。(咱们应用了一段时间的默认 Mypy 守护过程,但因共享状态导致一些问题后,我禁用了它——我不记得具体细节了。)
  2. 共享近程缓存。如前所述,Mypy 在每次运行后都会将类型查看后果缓存到磁盘——然而如果在新机器或新容器上运行 Mypy(就像在 CI 上一样),则不会有缓存的益处。解决方案是在磁盘上预置一个最近的缓存后果(即,预热缓存)。Mypy 文档概述了这个过程,但它相当简单,具体内容取决于你本人的设置。咱们最终可能会在本人的 CI 零碎中启用它——临时还没有去做。

论断

Mypy 对咱们产生了很大的影响,晋升了咱们公布代码时的信念。尽管驳回它须要付出肯定的老本,但咱们并不悔恨。

除了工具自身的价值之外,Mypy 还是一个让人印象十分粗浅的我的项目,我非常感谢维护者们多年来为它付出的工作。在每一个 Mypy 和 Python 版本中,咱们都看到了对 typing模块、注解语法和 Mypy 自身的显著改良。(例如:新的联结类型语法(X|Y)、ParamSpecTypeAlias,这些都蕴含在 Python 3.10 中。)

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

作者:Charlie Marsh

译者:豌豆花下猫 @Python 猫

英文:Using Mypy in production at Spring (https://notes.crmarsh.com/usi…)

正文完
 0