如何让 Python 数据类 InitVar 字段与 typing.get_type_hints 一起使用,同时还使用注释?

How do I get Python dataclass InitVar fields to work with typing.get_type_hints while also using annotations?

在处理 Python 数据类时,我 运行 遇到了这个很容易重现的奇怪错误。

from __future__ import annotations

import dataclasses as dc
import typing

@dc.dataclass
class Test:
    foo: dc.InitVar[int]

print(typing.get_type_hints(Test))

运行 这将为您带来以下内容:

Traceback (most recent call last):
  File "test.py", line 11, in <module>
    print(typing.get_type_hints(Test))
  File "C:\Program Files\Python310\lib\typing.py", line 1804, in get_type_hints
    value = _eval_type(value, base_globals, base_locals)
  File "C:\Program Files\Python310\lib\typing.py", line 324, in _eval_type
    return t._evaluate(globalns, localns, recursive_guard)
  File "C:\Program Files\Python310\lib\typing.py", line 687, in _evaluate
    type_ =_type_check(
  File "C:\Program Files\Python310\lib\typing.py", line 173, in _type_check
    raise TypeError(f"{msg} Got {arg!r:.100}.")
TypeError: Forward references must evaluate to types. Got dataclasses.InitVar[int].

没有from __future__ import annotations,似乎工作正常;但在实际代码中,我在几个不同类型的提示中使用了该导入。有没有办法让注释导入不破坏这个?

所以我实际上能够在我的 Python 3.10 环境中复制完全相同的行为,坦率地说,我对能够这样做感到有点惊讶。至少从表面上看,这个问题似乎与 InitVar 以及 typing.get_type_hints 如何解决此类非泛型类型有关。

无论如何,在我们深入了解杂草之前,有必要澄清一下 from __future__ import annotations 的工作原理。您可以在将它引入野外的 PEP 中阅读更多相关信息,但本质上“简而言之”的故事是 __future__ 导入将使用它的模块中的所有注释转换为 forward-declared 注释,即用单引号包裹的注释 ' 以将所有类型注释呈现为字符串值。

然后将所有类型注释转换为字符串,typing.get_type_hints 实际做的是解析那些 ForwardRef 类型——本质上是 typing库识别包装在字符串中的注释的方法——使用 class 或模块的 globals 命名空间,以及可选的 locals 命名空间(如果提供)。

这里有一个简单的例子,基本上可以概括上面讨论的所有内容。我在这里所做的不是在模块顶部使用 from __future__ import annotations,而是手动进入并通过将所有注释包装在字符串中来转发声明所有注释。值得注意的是,这与上面问题中的表现基本相同

import typing
from dataclasses import dataclass, InitVar


@dataclass
class Test:
    foo: 'InitVar[int]'


print(typing.get_type_hints(Test))

如果好奇,您也可以尝试使用 __future__ 导入,而不是手动向前声明注释,然后检查 Test.__annotations__ 对象以确认最终结果与我的方式相同已经在上面定义了它。

在任何一种情况下,我们 运行 都会遇到以下相同的错误,也如上面的 OP 所述:

Traceback (most recent call last):
    print(typing.get_type_hints(Test))
  File "C:\Users\USER\.pyenv\pyenv-win\versions.10.0\lib\typing.py", line 1804, in get_type_hints
    value = _eval_type(value, base_globals, base_locals)
  File "C:\Users\USER\.pyenv\pyenv-win\versions.10.0\lib\typing.py", line 324, in _eval_type
    return t._evaluate(globalns, localns, recursive_guard)
  File "C:\Users\USER\.pyenv\pyenv-win\versions.10.0\lib\typing.py", line 687, in _evaluate
    type_ =_type_check(
  File "C:\Users\USER\.pyenv\pyenv-win\versions.10.0\lib\typing.py", line 173, in _type_check
    raise TypeError(f"{msg} Got {arg!r:.100}.")
TypeError: Forward references must evaluate to types. Got dataclasses.InitVar[int].

让我们记下堆栈跟踪,因为它肯定有助于了解哪里出了问题。然而,我们可能想要探索 为什么 dataclasses.InitVar 用法首先导致了这个奇怪和不寻常的错误,这实际上是我们将要研究的从.

开始

那么 dataclasses.InitVar 怎么了?

这里的 TL;DR 下标 dataclasses.InitVar 用法有问题。无论如何,让我们只看 InitVar 在 Python 3.10:

中如何定义的相关部分
class InitVar:

    def __init__(self, type):
        self.type = type
    
    def __class_getitem__(cls, type):
        return InitVar(type)

请注意,__class_getitem__ 是我们在注释中下标 class 时调用的方法,例如 InitVar[str]。这调用 InitVar.__class_getitem__(str) 其中 returns InitVar(str).

所以这里的实际问题是,下标 InitVar[int] 用法 return 是一个 InitVar 对象,而不是基础类型,即 InitVar class 本身。

所以 typing.get_type_hints 在这里导致错误,因为它在已解析的类型注释中看到一个 InitVar 实例,而不是 InitVar class 本身,这是一个有效类型,因为它本质上是 Python class。

嗯...但是解决这个问题最直接的方法是什么?

解决方案的(拼凑)之路

如果你至少在 Python 3.10 中查看 typing.get_type_hints 的源代码,你会注意到它将所有字符串注释显式转换为 ForwardRef 对象,然后调用ForwardRef._evaluate 每一个:

for name, value in ann.items():
    ...
    if isinstance(value, str):
        value = ForwardRef(value, is_argument=False)
>>  value = _eval_type(value, base_globals, base_locals)

ForwardRef._evaluate 方法所做的是 eval 使用 class 或模块全局变量包含的引用,然后在内部调用 typing._type_check 检查包含在ForwardRef 对象。这会做一些事情,比如验证引用是来自 typing 模块的泛型类型,这里绝对不感兴趣,因为 InitVar 被明确定义为非泛型类型,在至少在 3.10.

typing._type_check的相关位如下所示:

    if isinstance(arg, _SpecialForm) or arg in (Generic, Protocol):
        raise TypeError(f"Plain {arg} is not valid as type argument")
    if isinstance(arg, (type, TypeVar, ForwardRef, types.UnionType, ParamSpec)):
        return arg
    if not callable(arg):
>>      raise TypeError(f"{msg} Got {arg!r:.100}.")

这是上面显示的最后一行,raise TypeError(...) 这似乎是 return 我们 运行 进入的错误消息。如果您检查 _type_check 函数检查的最后一个条件,您可以猜测我们如何在我们的案例中实施最简单的解决方法:

if not callable(arg):

如果我们稍微浏览一下内置 callable 的文档,我们会得到我们可以使用的可能解决方案的第一个具体提示:

def callable(i_e_, some_kind_of_function): # real signature unknown; restored from __doc__
    """
    Return whether the object is callable (i.e., some kind of function).
    
    Note that classes are callable, as are instances of classes with a
    __call__() method.
    """

所以,简单来说,我们只需要在dataclasses.InitVarclass下定义一个__call__方法即可。这可以是一个存根方法,本质上是一个空操作,但至少 class 必须定义这个方法,以便它可以被认为是可调用的,因此 typing 模块可以接受它作为一个ForwardRef 对象中的有效引用类型。

最后,这是与 OP 中相同的示例,但稍作修改以添加一个新行,该行修补 dataclasses.InitVar 以添加必要的方法,作为存根:

from __future__ import annotations

import typing
from dataclasses import dataclass, InitVar


@dataclass
class Test:
    foo: InitVar[int]


# can also be defined as:
#   setattr(InitVar, '__call__', lambda *args: None)
InitVar.__call__ = lambda *args: None

print(typing.get_type_hints(Test))

该示例现在似乎按预期工作,在向前声明任何带下标的 InitVar 注释时,typing.get_type_hints 方法没有引发任何错误。