tl; dr - mypy很棒,但是您的代码需要为此做好准备。 python的非构想性质允许魔术发生并做出捷径(以更多的运行时错误为代价)。如果没有大规模重写,就不可能在此自由下开发的代码库中添加类型的检查器,例如Mypy。至少这是我在试图将Django应用程序(随着生产数年)迁移到Mypy的艰难方法。
。- A brief history of type hints/checks in Python
- The context
- The plan
- Some of the problems with Mypy
- Conclusion
(用 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并不是游戏中唯一的一个。引用一些:
-
Pyright(Microsoft):一种快速且类型的静态类型检查器,设计用于有效类型检查和编辑器支持。
-
Pyre(Facebook):一个静态类型的检查器,专注于精确的推理和分析。
-
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=True
和blank=True
定义的模型字段。这将映射到Optional
字段,因此,每当您需要访问其值时,即使您从逻辑中知道 be None
都需要assert
或if
语句。
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.Generic
和typing.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.ForeignKey
和BaseStorage
均未实现它,从而抛出了错误。引号清楚地表明,这只是一个类型的提示。
还要注意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用于我的个人项目!
谢谢您的阅读,让我知道您是否有其他(更成功?)经历!
用爱,德林。