当计划误入歧途时:我将大型Django项目迁移到Mypy的旅程失败
#教程 #python #django #开发日志

tl; dr - mypy很棒,但是您的代码需要为此做好准备。 python的非构想性质允许魔术发生并做出捷径(以更多的运行时错误为代价)。如果没有大规模重写,就不可能在此自由下开发的代码库中添加类型的检查器,例如Mypy。至少这是我在试图将Django应用程序(随着生产数年)迁移到Mypy的艰难方法。


(用 bitdowntoc 生成的toc使用devto预设)


python中的简短历史提示/检查

Python型提示在Python 3.5中引入了PEP 484,该提示提出了一种语法,用于将类型注释添加到功能签名和可变声明中。这标志着Python中静态打字支持的开始。但是,这些注释从未检查过:在构建时间和运行时都没有检查。

为了解决这个问题,jukka lehtosalo释放 mypy ,一种外部静态型检查器,根据PEP 484注释提供静态分析和类型推理。

随后的PEPS,例如PEP 526(可变注释),PEP 563(类型注释的推迟评估)和PEP 585(内置类型的行为)进一步完善了类型的Hinting功能。总的来说,这些PEP有助于Python的类型提示的增长和采用,Mypy用作广泛使用的静态检查工具。

mypy并不是游戏中唯一的一个。引用一些:

  1. Pyright(Microsoft):一种快速且类型的静态类型检查器,设计用于有效类型检查和编辑器支持。

  2. Pyre(Facebook):一个静态类型的检查器,专注于精确的推理和分析。

  3. PyType(Google):大型代码库的静态分析仪,应该比mypy快。

上下文

类型提示和类型Checkers的组合是Python World的一场革命,具有早期错误检测,改进的代码质量,增强的IDE支持,更好的协作,更强的代码库弹性等等。谁不想要那个?

自2015年我的介绍以来,我一直在使用类型提示,当时我的娱乐代码为娱乐,但从未过渡到类型检查(我一次尝试了一个个人项目,发现我的许多提示实际上是错误的ρ)。

上个月,我的公司决定给Mypy射击。目的是从我们最小的Django应用程序之一(在生产中多年)开始,以在移动其余代码库之前验证该过程。

本文概述了我们采取的步骤,并解释了为什么我们最终认为实验失败了,至少暂时是失败的。

计划

设置Mypy

要从mypy开始,我们必须安装它。而且,由于Django使用了一些Python“魔术”,使某些代码模式具有问题(例如ORM类)具有精确的类型,因此我们还需要django-stubs,这是一个第三方软件包,“ 提供提供 type stubs2626 and>自定义Mypy插件,可为Django Framework提供更精确的静态类型和类型推理“:

pip install mypy django-stubs[compatible-mypy]

mypy可以通过多种方式进行配置,其中之一是setup.cfg。让我们添加相关部分:

[mypy]
# ↓ For mypy
python_version = 3.9
mypy_path = .

# ↓ For Django-stubs
plugins =
    mypy_django_plugin.main

[mypy.plugins.django-stubs]
django_settings_module = settings

这样,我们现在可以运行:

# at the root of the project
mypy . --strict

正如预期的那样,由于没有代码包含类型提示。

忽略旧代码:mypy-clean-slate

来自mypy-clean-slate的读数:

可能很难将一个大型项目运行到可以在其上运行的地步。 mypy_clean_slate可以忽略所有以前的错误,以便几乎可以立即使用mypy --strict(或类似)。这使得从那时起写入的所有代码都可以使用mypy --strict(或任何标志是首选)检查,从那时起逐渐删除type: ignore注释。

运行mypy-clean-slate在每行添加了type: ignore[<rule>]评论,都会丢下错误,以免Mypy抱怨。这使我们能够将类型逐渐添加到现有代码中,并且仍在新代码上执行类型。这是结果的摘录:

from rest_framework import serializers  # type: ignore[import]

class FooSerializer(serializers.Serializer):  # type: ignore[misc]
   def get_queryset(self):  # type: ignore[no-untyped-def]
        return construct_qs(self)  # type: ignore[no-untyped-call]

添加类型

现在已经设置了项目,我们可以开始删除类型忽略评论,并在新功能上使用类型(请注意,Mypy足够聪明,可以告诉我们何时忽略评论是过时的ð)。这就是事情变得复杂的地方。


Mypy的一些问题

初始设置后,我尝试遵循严格的类型检查以获取新功能。这是我面临的一些困难。

没有暴露PYI类型的库(和其他特权)

tl; dr 一些库使用非暴露的PYI类型,使得很难(有时不可能?)正确键入其用法。 *args, **kwargs 的广泛使用也有问题。

采用此代码:

import tempfile

def temporary_file(*args, named=False, **kwargs):
    cls = tempfile.NamedTemporaryFile if named else tempfile.TemporaryFile
    return cls(*args, **kwargs)

首先,如果不重写它,这是不可能输入的,以使返回的类很明确:

import tempfile

def temporary_file(*args, named=False, **kwargs):
    if named:
       return tempfile.NamedTemporaryFile(*args, **kwargs)
    return tempfile.TemporaryFile(*args, **kwargs)

tempfile库是用c编写的,并具有带有以下签名的pyi文件(定义类型):

def TemporaryFile(...) -> _TemporaryFileWrapper[str]:
   ...
def NamedTemporaryFile(...) -> _TemporaryFileWrapper[str]:
   ...

_TemporaryFileWrapper[str]并未暴露!

我不得不使用typing.IO类型,这是通用的。鉴于签名,我自然去了IO[str],但发现使用字节对代码无效:

from typing import IO

def temporary_file(...) -> typing.IO[str]: # ...

with temporary_file("foo") as f:
   f.write("string") # <- OK
   f.write(b"bytes") # <- error!

我终于解决了一个联合类型:IO[Union[str,bytes]]

签名中的*args, *kwargs出现了另一个问题。但是我不想打开它们,因为我不知道(并且不在乎)它们是什么类型!我可以将忽略评论或使用Any。 (注意:有关打字夸尔格斯的PEP 692)。

打字限制

tl; dr 键入值只能使用字符串文字访问。

使用或返回dict时,一个好的做法是使用TypedDict正确键入它:

FooDict = TypedDict(
    "FooDict",
    {
        "ok": bool,
        "started_at": str,
        "ended_at": str,
        "size": int,
    },
)

然后可以像这样使用此FooType

def some_func() -> FooDict:
   return {
       "ok": True,
       "started_at": "2023-06-04T10:00",
       "ended_at": "2023-06-04T10:01",
       "size": 123,
   }

都很好。但是,您是否知道只能使用 string literals (即普通字符串)访问TypedDict值?这意味着拥有类似的东西是不可能的:

import pytest

def test_some_funct():
    result = some_func()
    assert result["size"] == 123 # ok
    for key in ["started_at", "ended_at"]:
       assert result[key] # <- FAILS!

最后一行产生:

foo_test.py:7: error: TypedDict key must be a string literal;
    expected one of ("ok", "started_at", "taken_at", "ended_at", "size")
    [literal-required]

这是一个令人沮丧的。如果在某些情况下以键作为论点收到键该怎么办? 忽略将需要评论。

选择和断言

tl; dr 可选类型需要明确检查,添加了许多(不必要的?)断言。

如文档中所述(请参见Optional types and the None type),Optional变量需要明确的None检查。那就是:

def wrong(x: Optional[int]):
    return x + 1 # <- this is NOT allowed

def ok_if(x: Optional[int]):
    if x is not None:
        return x + 1 # <- ok

def ok_assert(x: Optional[int]):
    assert x is not None
    return x + 1 # <- ok

使用django时,您可能具有使用null=Trueblank=True定义的模型字段。这将映射到Optional字段,因此,每当您需要访问其值时,即使您从逻辑中知道 be None都需要assertif语句。

from django.db import models

class Student(models.Model):
    created_at = models.DateTimeField(null=True, blank=True)
    updated_at = models.DateTimeField(null=True, blank=True)
    nickname = models.CharField(null=True, blank=True)
    # ...

   def do_something_when_nicknamed(self) -> None:
       assert self.created_at
       assert self.updated_at
       assert self.nickname

       # ... finally do something ...

在迁移的代码上,此限制在各处增加了许多线条!

Mixin类:需要的协议

tl; dr 正确使用mixin类的唯一方法是为每个类别定义协议,将所使用的类倍增。最好完全不要使用Mixin,如果仅使用一次。

我们的旧代码严重依赖Mixin类。

混合类是提供属性但不包含在标准继承树中的小类,而不是作为当前类的“添加”,而不是作为适当的祖先。 Mixins起源于LISP编程语言。

换句话说,所谓的Mixin类是从object继承的小型类,可以将组合在一起。它们对于将大型类分成较小,更易于管理的块非常有用 - 或在类(组成)中添加功能。

python mixins不是专用的语言功能,它们只是劫持了多个继承机制。因此,虽然理论上应该是正交的(彼此独立),但它们通常依赖于增强类的其他先前存在的特征和属性。

(如果您不熟悉Mixin类,我建议您的文章 Multiple inheritance and mixin classes in Python 来自Digital Cat ð)。

这是Mixin模式的简单示例:

class Graphic:
    def __init__(self, pos_x, pos_y, size_x, size_y):
        self.pos_x = pos_x
        self.pos_y = pos_y
        self.size_x = size_x
        self.size_y = size_y

class ResizableMixin:
    def resize(self, sx, sy):
        self.size_x = sx # <- UNKNOWN !
        self.size_y = sy # <- UNKNOWN !

class ResizableGraphic(Graphic, ResizableMixin):
    pass

问题是,Mypy不知道应该如何使用每种混合物,并且始终将其组成。上面标记为UNKNOWN的每一行都会丢弃错误。

有一个解决方案:实施protocols。协议是定义Mixin收件人应具有的属性的另一个类:

from typing import Protocol 

class Sizeable(Protocol):
    @property
    def size_x(self) -> int: ... # the dots are part of the syntax
    @property
    def size_y(self) -> int: ...

class ResizableMixin:
    def resize(self: Sizeable, sx: int, sy: int) -> None:
        self.size_x = sx
        self.size_y = sy

class ResizableGraphic(Graphic, ResizableMixin):
    pass

它有效,但要为已经获得太多的代码添加数十个课程!换句话说,给定初始代码库,添加协议太冗长且效率低下:最好完全摆脱Mixins模式。

Django模型 - 不支持仿制药

tl; dr django不允许模型类扩展 Generic 。正确键入通用模型类的唯一方法是使用一个非常冗长且丑陋的“ hack”。

我们的代码库在很大程度上依赖仿制药。例如,假设我将文件存储在不同的后端中。我可以有这样的东西:

# -- In base.models
from django.db import models

class BaseStorage(models.Model):
    class Meta:
        abstract = True

    name = models.CharField()
    backend = None # Foreign key to a BackendBase model
    file_model = None # File class, subclassing BaseFile
    # ...

# -- In postgres.models
class PostgresBackend(BaseBackend):
    pass
class PostgresFile(BaseFile):
    pass

class PostgresStorage(BaseStorage):
    backend = models.ForeignKey(PostgresBackend)
    file_model = PostgresFile

要在此模式中添加类型,我可以使用typing.Generictyping.TypeVar。这与Java非常相似:

# -- In base.models
from django.db import models
from typing import Type, TypeVar, Generic

# bound reads "B must be a subclass of BaseBackend"
B = TypeVar("B", bound=BaseBackend)
F = TypeVar("F", bound=BaseFile)

class StorageBase(models.Model, Generic[B, F]):
    # ...
    backend: "models.ForeignKey[Union[B, Combinable],B]"
    file_model: Type[F]

# -- In postgres.models
class PostgresStorage("BaseStorage[PostgresBackend, PostgresFile]"):
    pass

注意一些类型的引号。当未引用时,Python试图调用与操作员“ []”相关的方法,该方法在类对象上为__class_getitem__models.ForeignKeyBaseStorage均未实现它,从而抛出了错误。引号清楚地表明,这只是一个类型的提示。

还要注意backend属性的奇怪类型。我花了一段时间才弄清楚它!如果外国钥匙领域是无效的,那将是:

models.ForeignKey[Union[B, Combinable, None],Optional[B]]

与原始代码相比,此代码已经很复杂,但是它替换了解释每种类型的注释。所以还不错。虽然有问题。如果您尝试运行django迁移(manage.py migrate),则冗长的stacktrace等待着您:

Traceback (most recent call last):
  File "...django/core/management/__init__.py", line 419, in execute_from_command_line
    utility.execute()
  [...]
  File "/usr/lib/python3.9/typing.py", line 1010, in __init_subclass__
    raise TypeError("Cannot inherit from plain Generic")
TypeError: Cannot inherit from plain Generic

您正确阅读, django不支持模型类!从2021年10月开始有一个空旷的问题,但被标记为wontfix

mypy提供了一个解决方案:使用typing.TYPE_CHECKING-一个特殊的常数为静态类型检查器假定为True,但在运行时始终将False始终从模型类中从Generic继承。

这就是基础类别的外观:

from django.db import models
from typing import TYPE_CHECKING, Type, TypeVar, Generic

B = TypeVar("B", bound=BaseBackend)
F = TypeVar("F", bound=BaseFile)

if TYPE_CHECKING:
    class _Parent(Generic[B, F]):
        pass
else:
    class _Parent:
        # Implementing this method is necessary to be able to use
        # the _Parent[...] (brackets) syntax at runtime
        def __class_getitem__(cls, _):
            return cls

class StorageBase(models.Model, _Parent[B, F]):
    # ...
    backend: "models.ForeignKey[Union[B, Combinable],B]"
    file_model: Type[F]

请注意__class_getitem__。这是必要的,因为当类型打开时,_Parent也将变为a generic 类,因此需要用[]键入。要使用括号符号,父类需要实现它。

子类更容易:

# In postgres.models
if TYPE_CHECKING:
    class _Parent("BaseStorage[PostgresBackend, PostgresFile]"):
        pass
else:
    class _Parent():
        pass

class PostgresStorage(_Parent):
    # ...

此版本将通过MyPy检查,并且不会在迁移过程中引起Django抱怨。但是天哪,这是冗长的!我们的最初代码库具有基于通用性的三十多个类,这使很多_Parent类都构成了。 PEP 8定义了每个类声明周围的两条空白行,使IF/else在Span 12行之上!


结论

由于上面概述的局限性,我们决定至少暂时将迁移恢复为Mypy。虽然类型可以增强代码理解和错误检测,但引入我们已经大的代码库引入的其他复杂性超过了这些好处。我们本可以待在部分移民的情况下(即保持无数忽略评论),但它会击败目的。

我了解到的关键教训是,应该从开始时牢记的类型检查开发一个Python项目。迁移用未经类型的Python编写的代码库引入了许多边缘案例和复杂性,这些案例和复杂性很难处理而无需重构。我忍不住觉得革命类型将大大改变我们在Python中的编码方式。

重要的是要强调,我面临的部分挑战可能归因于相对较新的python类型检查。我仍然希望未来的发展将包括对Django仿制药的支持,并为打字的DICES和MIXINS提供改进的替代方案。同时,我将继续将Mypy用于我的个人项目!

谢谢您的阅读,让我知道您是否有其他(更成功?)经历!

用爱,德林。