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

Posted

技术标签:

【中文标题】如何让 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? 【发布时间】:2022-01-20 19:41:07 【问题描述】:

在处理 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,它似乎工作正常;但在实际代码中,我在几个不同的类型提示中使用了该导入。有没有办法让注释导入不会破坏这一点?

【问题讨论】:

目前我的解决方法是取出from __future__ import annotations 行并通过引号中的类型进行类型提示,这是我没有意识到我可以做到的。不过,这并不完全是我的问题的答案。 我有答案了,我只需要写一个。虽然这是一种解决方法,而且有点 hack,但它似乎也适用于转发声明注释或使用 __future__ 导入。 【参考方案1】:

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

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

因此,所有类型注释都转换为字符串后,typing.get_type_hints 实际上所做的是解析那些 ForwardRef 类型——这本质上是 typing 库识别包装在字符串 -- 使用类或模块的 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\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].

让我们记下堆栈跟踪,因为知道哪里出了问题肯定很有用。但是,我们可能希望确切地探究为什么dataclasses.InitVar 的使用首先会导致这个奇怪且不寻常的错误,这实际上是我们将要开始研究的内容。 p>

那么dataclasses.InitVar 是怎么回事?

TL;DR 这里的下标dataclasses.InitVar 使用存在问题。不管怎样,让我们​​只看一下 Python 3.10 中如何定义 InitVar 的相关部分:

class InitVar:

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

请注意,__class_getitem__ 是当我们在注解中为类下标时调用的方法,例如 InitVar[str]。这会调用InitVar.__class_getitem__(str),它会返回InitVar(str)

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

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

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

解决方案的(拼凑)之路

如果您至少在 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 包含的引用,然后在内部调用 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(...),它似乎返回了我们遇到的错误消息。如果您检查_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.InitVar 类下定义一个__call__ 方法。这可以是一个存根方法,本质上是一个无操作,但至少该类必须定义这个方法,以便它可以被认为是可调用的,因此typing 模块可以接受它作为@ 中的有效引用类型987654369@对象。

最后,这是与 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 方法没有引发任何错误。

【讨论】:

以上是关于如何让 Python 数据类 InitVar 字段与 typing.get_type_hints 一起使用,同时还使用注释?的主要内容,如果未能解决你的问题,请参考以下文章

数据库如何设置让字段名称和数据表中的不一样

python中如何循环给对象的属性赋值???

如何在Python中创建一个递归函数来创建一个映射Odoo 8关系字段记录的字典?

Python私有类字段,如何制作? [复制]

hibernate中使用annotation映射的时候,如何指定实体类中的某些字段不映射到数据库?

如何让hibernate自动生成表