如何使用Python中元类插入的方法对类进行类型检查?

Rom*_*usi 4 python metaclass type-hinting mypy python-typing

在以下代码中some_method已通过元类添加:

from abc import ABC
from abc import ABCMeta
from typing import Type


def some_method(cls, x: str) -> str:
    return f"result {x}"


class MyMeta(ABCMeta):
    def __new__(mcs, *args, **kwargs):
        cls = super().__new__(mcs, *args, **kwargs)
        cls.some_method = classmethod(some_method)
        return cls


class MyABC(ABC):
    @classmethod
    def some_method(cls, x: str) -> str:
        return x


class MyClassWithSomeMethod(metaclass=MyMeta):
    pass


def call_some_method(cls: Type[MyClassWithSomeMethod]) -> str:
    return cls.some_method("A")


if __name__ == "__main__":
    mc = MyClassWithSomeMethod()
    assert isinstance(mc, MyClassWithSomeMethod)
    assert call_some_method(MyClassWithSomeMethod) == "result A"
Run Code Online (Sandbox Code Playgroud)

然而,MyPy对此很不满意:

minimal_example.py:27: error: "Type[MyClassWithSomeMethod]" has no attribute "some_method"
Found 1 error in 1 file (checked 1 source file)
Run Code Online (Sandbox Code Playgroud)

有没有什么优雅的方法来告诉类型检查器该类型真的没问题?我所说的优雅是指我不需要到处更改这些定义:

class MyClassWithSomeMethod(metaclass=MyMeta): ...
Run Code Online (Sandbox Code Playgroud)

请注意,我不想进行子类化(就像MyABC上面的代码中一样)。也就是说,我的类将用metaclass=.

有哪些选择?

我也尝试过Protocol

from typing import Protocol

class SupportsSomeMethod(Protocol):
    @classmethod
    def some_method(cls, x: str) -> str:
        ...


class MyClassWithSomeMethod(SupportsSomeMethod, metaclass=MyMeta):
    pass


def call_some_method(cls: SupportsSomeMethod) -> str:
    return cls.some_method("A")
Run Code Online (Sandbox Code Playgroud)

但这会导致:

TypeError:元类冲突:派生类的元类必须是其所有基类的元类的(非严格)子类

Ale*_*ood 8

正如MyPy 文档中所解释的,MyPy 对元类的支持仅限于此:

Mypy 不会也不可能理解任意元类代码。

问题是,如果您将一个方法猴子修补到元类__new__方法中的类上,则可能会向类的定义中添加任何内容。这对 Mypy 来说太动态了,无法理解。

然而,一切并没有失去!您在这里有几个选择。


选项 1:将方法静态定义为元类上的实例方法


类是其元类的实例,因此元类上的实例方法与类中定义的实例方法非常相似。classmethod因此,您可以重写minimal_example.py如下,MyPy会很高兴

from abc import ABCMeta
from typing import Type


class MyMeta(ABCMeta):
    def some_method(cls, x: str) -> str:
        return f"result {x}"


class MyClassWithSomeMethod(metaclass=MyMeta):
    pass


def call_some_method(cls: Type[MyClassWithSomeMethod]) -> str:
    return cls.some_method("A")


if __name__ == "__main__":
    mc = MyClassWithSomeMethod()
    assert isinstance(mc, MyClassWithSomeMethod)
    assert call_some_method(MyClassWithSomeMethod) == "result A"
Run Code Online (Sandbox Code Playgroud)

元类实例方法和平均方法之间唯一的大区别classmethod是元类实例方法不能从使用元类的类的实例中获得:

>>> from abc import ABCMeta
>>> class MyMeta(ABCMeta):
...     def some_method(cls, x: str) -> str:
...         return f"result {x}"
...         
>>> class MyClassWithSomeMethod(metaclass=MyMeta):
...     pass
...     
>>> MyClassWithSomeMethod.some_method('foo')
'result foo'
>>> m = MyClassWithSomeMethod()
>>> m.some_method('foo')
Traceback (most recent call last):
  File "<string>", line 1, in <module>
AttributeError: 'MyClassWithSomeMethod' object has no attribute 'some_method'
>>> type(m).some_method('foo')
'result foo'
Run Code Online (Sandbox Code Playgroud)

选项 2:Promise MyPy 存在一个方法,但不实际定义它


在很多情况下,您将使用元类,因为您希望比静态定义方法更加动态。例如,您可能希望即时动态生成方法定义并将它们添加到使用元类的类中。在这些情况下,选项 1 根本不起作用。

在这些情况下,另一种选择是“承诺”MyPy 存在一个方法,而不实际定义它。您可以使用标准注释语法来执行此操作:

from abc import ABCMeta
from typing import Type, Callable


def some_method(cls, x: str) -> str:
    return f"result {x}"


class MyMeta(ABCMeta):
    some_method: Callable[['MyMeta', str], str]
    
    def __new__(mcs, *args, **kwargs):
        cls = super().__new__(mcs, *args, **kwargs)
        cls.some_method = classmethod(some_method)
        return cls


class MyClassWithSomeMethod(metaclass=MyMeta):
    pass


def call_some_method(cls: Type[MyClassWithSomeMethod]) -> str:
    return cls.some_method("A")


if __name__ == "__main__":
    mc = MyClassWithSomeMethod()
    assert isinstance(mc, MyClassWithSomeMethod)
    assert call_some_method(MyClassWithSomeMethod) == "result A"
Run Code Online (Sandbox Code Playgroud)

通过 MyPy很好,而且实际上相当干净。但是,这种方法存在局限性,因为可调用对象的全部复杂性无法使用简写typing.Callable语法来表达。

选项 3:对 MyPy 撒谎


第三种选择是对 MyPy 撒谎。有两种明显的方法可以做到这一点。

选项 3(a)。使用typing.TYPE_CHECKING常量对 MyPy 撒谎

typing.TYPE_CHECKING常量始终True用于静态类型检查器,并且始终False在运行时。因此,您可以使用此常量将类的不同定义提供给 MyPy,而不是在运行时使用的定义。

from typing import Type, TYPE_CHECKING
from abc import ABCMeta 

if not TYPE_CHECKING:
    def some_method(cls, x: str) -> str:
        return f"result {x}"


class MyMeta(ABCMeta):
    if TYPE_CHECKING:
        def some_method(cls, x: str) -> str: ...
    else:
        def __new__(mcs, *args, **kwargs):
            cls = super().__new__(mcs, *args, **kwargs)
            cls.some_method = classmethod(some_method)
            return cls

class MyClassWithSomeMethod(metaclass=MyMeta):
    pass

def call_some_method(cls: Type[MyClassWithSomeMethod]) -> str:
    return cls.some_method("A")


if __name__ == "__main__":
    mc = MyClassWithSomeMethod()
    assert isinstance(mc, MyClassWithSomeMethod)
    assert call_some_method(MyClassWithSomeMethod) == "result A"
Run Code Online (Sandbox Code Playgroud)

通过了 MyPy。这种方法的主要缺点是if TYPE_CHECKING在代码库中进行检查实在是太难看了。

选项 3(b):使用.pyi存根文件欺骗 MyPy

另一种欺骗 MyPy 的方法是使用.pyi存根文件。你可以有一个minimal_example.py这样的文件:

from abc import ABCMeta

def some_method(cls, x: str) -> str:
    return f"result {x}"


class MyMeta(ABCMeta):
    def __new__(mcs, *args, **kwargs):
        cls = super().__new__(mcs, *args, **kwargs)
        cls.some_method = classmethod(some_method)
        return cls
Run Code Online (Sandbox Code Playgroud)

您可以minimal_example.pyi在同一目录中有一个存根文件,如下所示:

from abc import ABCMeta


class MyMeta(ABCMeta):
    def some_method(cls, x: str) -> str: ...
Run Code Online (Sandbox Code Playgroud)

如果 MyPy 在同一目录中找到一个.py文件和一个.pyi文件,它将始终忽略文件中的定义.py,而使用文件中的存根.pyi。同时,在运行时,Python 执行相反的操作,完全忽略文件中的存根,而.pyi支持文件中的运行时实现.py。因此,您可以在运行时随心所欲地动态,而 MyPy 不会更明智。

(如您所见,无需在文件中复制完整的方法定义。MyPy 仅需要这些方法的签名,因此约定只是用文字省略号.pyi填充文件中的函数体。).pyi...

该解决方案比使用常量更干净TYPE_CHECKING。然而,我不会因为使用文件而得意忘形.pyi。尽可能少地使用它们。如果您的文件中有一个类.py,而您的存根文件中没有该类的副本,那么 MyPy 将完全不知道它的存在,并引发各种误报错误。请记住:如果您有一个.pyi文件,MyPy 将完全忽略.py其中包含运行时实现的文件。

在文件中复制类定义.pyi不利于 DRY,并且存在更新文件中的运行时定义.py但忘记更新.pyi文件的风险。如果可能,您应该将真正需要单独.pyi存根的代码隔离到单个短文件中。然后,您应该在项目的其余部分中正常注释类型,并very_dynamic_classes.py在代码的其余部分需要时正常导入必要的类。