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

lap*_*ken 6 python python-3.x python-dataclasses python-typing python-3.10

当处理 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))
Run Code Online (Sandbox Code Playgroud)

运行它会得到以下结果:

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].
Run Code Online (Sandbox Code Playgroud)

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

rv.*_*tch 11

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

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

因此,将所有类型注释转换为字符串后,实际typing.get_type_hints所做的就是ForwardRef使用类或模块的命名空间以及可选的命名空间(如果假如。globalslocals

这是一个简单的例子,基本上可以带出上面讨论的所有内容。我在这里所做的就是不是from __future__ import annotations在模块顶部使用,而是手动进入并通过将所有注释包装在字符串中来向前声明它们。值得注意的是,这与上面问题中的显示方式本质上是相同的。

import typing
from dataclasses import dataclass, InitVar


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


print(typing.get_type_hints(Test))
Run Code Online (Sandbox Code Playgroud)

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

无论哪种情况,我们都会遇到下面相同的错误,也如上面的 OP 中所述:

Traceback (most recent call last):
    print(typing.get_type_hints(Test))
  File "C:\Users\USER\.pyenv\pyenv-win\versions\3.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\3.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\3.10.0\lib\typing.py", line 687, in _evaluate
    type_ =_type_check(
  File "C:\Users\USER\.pyenv\pyenv-win\versions\3.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].
Run Code Online (Sandbox Code Playgroud)

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

那么怎么了dataclasses.InitVar

这里的TL ;DR是专门针对下标使用的问题dataclasses.InitVar。无论如何,我们只看一下InitVarPython 3.10 中如何定义的相关部分:

class InitVar:

    def __init__(self, type):
        self.type = type
    
    def __class_getitem__(cls, type):
        return InitVar(type)
Run Code Online (Sandbox Code Playgroud)

请注意,__class_getitem__是当我们在注释中为类添加下标时调用的方法,例如 like InitVar[str]。这调用了InitVar.__class_getitem__(str)which 返回InitVar(str)

所以这里的实际问题是,下标InitVar[int]用法返回一个 InitVar 对象,而不是底层类型,即 InitVar 类本身。

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

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

解决方案的(拼凑)之路

typing.get_type_hints如果您至少查看 Python 3.10 的源代码,您会注意到它将所有字符串注释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)
Run Code Online (Sandbox Code Playgroud)

ForwardRef._evaluate方法所做的是eval使用类或模块全局变量包含的引用,然后在内部调用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}.")
Run Code Online (Sandbox Code Playgroud)

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

if not callable(arg):
Run Code Online (Sandbox Code Playgroud)

如果我们简单地浏览一下内置的文档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.
    """
Run Code Online (Sandbox Code Playgroud)

所以,简单来说,我们需要做的就是在类__call__下定义一个方法dataclasses.InitVar。这可以是一个存根方法,本质上是一个无操作,但至少类必须定义此方法,以便它可以被视为可调用,因此模块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))
Run Code Online (Sandbox Code Playgroud)

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