【问题标题】:Validating detailed types in python dataclasses验证 python 数据类中的详细类型
【发布时间】:2018-11-06 21:08:12
【问题描述】:

Python 3.7 不久前发布了,我想测试一些新奇的dataclass+打字功能。使用原生类型和 typing 模块中的类型,让提示正常工作很容易:

>>> import dataclasses
>>> import typing as ty
>>> 
... @dataclasses.dataclass
... class Structure:
...     a_str: str
...     a_str_list: ty.List[str]
...
>>> my_struct = Structure(a_str='test', a_str_list=['t', 'e', 's', 't'])
>>> my_struct.a_str_list[0].  # IDE suggests all the string methods :)

但我想尝试的另一件事是在运行时强制类型提示作为条件,即不应该存在类型不正确的 dataclass。可以用__post_init__很好地实现:

>>> @dataclasses.dataclass
... class Structure:
...     a_str: str
...     a_str_list: ty.List[str]
...     
...     def validate(self):
...         ret = True
...         for field_name, field_def in self.__dataclass_fields__.items():
...             actual_type = type(getattr(self, field_name))
...             if actual_type != field_def.type:
...                 print(f"\t{field_name}: '{actual_type}' instead of '{field_def.type}'")
...                 ret = False
...         return ret
...     
...     def __post_init__(self):
...         if not self.validate():
...             raise ValueError('Wrong types')

这种validate 函数适用于本机类型和自定义类,但不适用于typing 模块指定的那些:

>>> my_struct = Structure(a_str='test', a_str_list=['t', 'e', 's', 't'])
Traceback (most recent call last):
  a_str_list: '<class 'list'>' instead of 'typing.List[str]'
  ValueError: Wrong types

有没有更好的方法来验证具有typing 类型的无类型列表?最好不包括检查任何listdicttupleset 中的所有元素的类型,即dataclass' 属性。


几年后重新审视这个问题,我现在开始使用pydantic 来验证我通常只为其定义数据类的类。不过,我会在当前接受的答案上留下我的印记,因为它正确回答了原始问题并且具有出色的教育价值。

【问题讨论】:

  • 显而易见的解决方案是 if not isinstance(actual_type, field_def.type):... 但显而易见的解决方案当然不起作用:TypeError: Parameterized generics cannot be used with class or instance checks
  • 这让我找到了ty.List.__origin__,这给了&lt;class 'list'&gt;。这不会让我检查内部类型,但至少它不会再崩溃了
  • 我发现this类似的问题,但它并没有真正的解决方案。如果您不想手动检查类型,您会发现这两个链接很有用:What's the correct way to check if an object is a typing.Generic?How to access the type arguments of typing.Generic?
  • @Aran-Fey 这些读物真的很有趣!
  • 这是一个失败的原因。试图强制执行此操作会产生高昂的运行时成本。一百万个项目长的列表是什么?你想遍历每个项目来检查它的类型吗?如果我这样做struct.a_str_list[24] = 1 会怎样——你无法知道。您必须编写一个专门的 list 子类来内省其项目,并且只允许该类而不是 list 在您的结构中。这对于运行时开销来说是很大的,并且通过在 API 级别使用防护和在其他地方使用 linting 类型注释更容易避免。

标签: python type-hinting python-typing python-dataclasses


【解决方案1】:

您应该使用isinstance,而不是检查类型是否相等。但是您不能使用参数化的泛型类型 (typing.List[int]) 来执行此操作,您必须使用“泛型”版本 (typing.List)。因此,您将能够检查容器类型,但不能检查包含的类型。参数化的泛型类型定义了一个 __origin__ 属性,您可以使用它。

与 Python 3.6 不同,在 Python 3.7 中,大多数类型提示都有一个有用的 __origin__ 属性。比较:

# Python 3.6
>>> import typing
>>> typing.List.__origin__
>>> typing.List[int].__origin__
typing.List

# Python 3.7
>>> import typing
>>> typing.List.__origin__
<class 'list'>
>>> typing.List[int].__origin__
<class 'list'>

Python 3.8 通过 typing.get_origin() 内省函数引入了更好的支持:

# Python 3.8
>>> import typing
>>> typing.get_origin(typing.List)
<class 'list'>
>>> typing.get_origin(typing.List[int])
<class 'list'>

值得注意的例外是typing.Anytyping.Uniontyping.ClassVar...好吧,任何typing._SpecialForm 都不能定义__origin__。幸运的是:

>>> isinstance(typing.Union, typing._SpecialForm)
True
>>> isinstance(typing.Union[int, str], typing._SpecialForm)
False
>>> typing.get_origin(typing.Union[int, str])
typing.Union

但是参数化类型定义了一个__args__ 属性,将它们的参数存储为一个元组; Python 3.8 引入了typing.get_args() 函数来检索它们:

# Python 3.7
>>> typing.Union[int, str].__args__
(<class 'int'>, <class 'str'>)

# Python 3.8
>>> typing.get_args(typing.Union[int, str])
(<class 'int'>, <class 'str'>)

所以我们可以稍微改进一下类型检查:

for field_name, field_def in self.__dataclass_fields__.items():
    if isinstance(field_def.type, typing._SpecialForm):
        # No check for typing.Any, typing.Union, typing.ClassVar (without parameters)
        continue
    try:
        actual_type = field_def.type.__origin__
    except AttributeError:
        # In case of non-typing types (such as <class 'int'>, for instance)
        actual_type = field_def.type
    # In Python 3.8 one would replace the try/except with
    # actual_type = typing.get_origin(field_def.type) or field_def.type
    if isinstance(actual_type, typing._SpecialForm):
        # case of typing.Union[…] or typing.ClassVar[…]
        actual_type = field_def.type.__args__

    actual_value = getattr(self, field_name)
    if not isinstance(actual_value, actual_type):
        print(f"\t{field_name}: '{type(actual_value)}' instead of '{field_def.type}'")
        ret = False

这并不完美,因为它不会考虑 typing.ClassVar[typing.Union[int, str]]typing.Optional[typing.List[int]] 之类的问题,但它应该可以开始了。


接下来是应用此检查的方法。

我不会使用__post_init__,而是使用装饰器路线:这可以用于任何带有类型提示的东西,而不仅仅是dataclasses

import inspect
import typing
from contextlib import suppress
from functools import wraps


def enforce_types(callable):
    spec = inspect.getfullargspec(callable)

    def check_types(*args, **kwargs):
        parameters = dict(zip(spec.args, args))
        parameters.update(kwargs)
        for name, value in parameters.items():
            with suppress(KeyError):  # Assume un-annotated parameters can be any type
                type_hint = spec.annotations[name]
                if isinstance(type_hint, typing._SpecialForm):
                    # No check for typing.Any, typing.Union, typing.ClassVar (without parameters)
                    continue
                try:
                    actual_type = type_hint.__origin__
                except AttributeError:
                    # In case of non-typing types (such as <class 'int'>, for instance)
                    actual_type = type_hint
                # In Python 3.8 one would replace the try/except with
                # actual_type = typing.get_origin(type_hint) or type_hint
                if isinstance(actual_type, typing._SpecialForm):
                    # case of typing.Union[…] or typing.ClassVar[…]
                    actual_type = type_hint.__args__

                if not isinstance(value, actual_type):
                    raise TypeError('Unexpected type for \'{}\' (expected {} but found {})'.format(name, type_hint, type(value)))

    def decorate(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            check_types(*args, **kwargs)
            return func(*args, **kwargs)
        return wrapper

    if inspect.isclass(callable):
        callable.__init__ = decorate(callable.__init__)
        return callable

    return decorate(callable)

用法是:

@enforce_types
@dataclasses.dataclass
class Point:
    x: float
    y: float

@enforce_types
def foo(bar: typing.Union[int, str]):
    pass

除了验证上一节中建议的一些类型提示外,这种方法仍然有一些缺点:

  • inspect.getfullargspec 不考虑使用字符串 (class Foo: def __init__(self: 'Foo'): pass) 的类型提示:您可能希望改用 typing.get_type_hintsinspect.signature

  • 不验证不是适当类型的默认值:

     @enforce_type
     def foo(bar: int = None):
         pass
    
     foo()
    

    不会引发任何TypeError。如果您想说明这一点,您可能希望将inspect.Signature.bindinspect.BoundArguments.apply_defaults 结合使用(从而迫使您定义def foo(bar: typing.Optional[int] = None));

  • 无法验证可变数量的参数,因为您必须定义类似 def foo(*args: typing.Sequence, **kwargs: typing.Mapping) 的内容,并且正如开头所说,我们只能验证容器而不是包含的对象。


更新

在这个答案获得一定的人气并发布了一个深受其启发的library 之后,消除上述缺点的需求正在成为现实。因此,我对typing 模块进行了更多尝试,并将在这里提出一些发现和新方法。

首先,typing 在查找参数何时是可选的方面做得很好:

>>> def foo(a: int, b: str, c: typing.List[str] = None):
...   pass
... 
>>> typing.get_type_hints(foo)
{'a': <class 'int'>, 'b': <class 'str'>, 'c': typing.Union[typing.List[str], NoneType]}

这非常简洁,绝对是对inspect.getfullargspec 的改进,所以最好使用它,因为它也可以正确处理字符串作为类型提示。但是typing.get_type_hints 将放弃其他类型的默认值:

>>> def foo(a: int, b: str, c: typing.List[str] = 3):
...   pass
... 
>>> typing.get_type_hints(foo)
{'a': <class 'int'>, 'b': <class 'str'>, 'c': typing.List[str]}

所以你可能仍然需要额外的严格检查,即使这样的情况感觉很可疑。

接下来是typing 提示用作typing._SpecialForm 的参数的情况,例如typing.Optional[typing.List[str]]typing.Final[typing.Union[typing.Sequence, typing.Mapping]]。由于这些typing._SpecialForms 中的__args__ 始终是一个元组,因此可以递归地找到该元组中包含的提示的__origin__。结合上述检查,我们将需要过滤任何typing._SpecialForm left。

建议的改进:

import inspect
import typing
from functools import wraps


def _find_type_origin(type_hint):
    if isinstance(type_hint, typing._SpecialForm):
        # case of typing.Any, typing.ClassVar, typing.Final, typing.Literal,
        # typing.NoReturn, typing.Optional, or typing.Union without parameters
        return

    actual_type = typing.get_origin(type_hint) or type_hint  # requires Python 3.8
    if isinstance(actual_type, typing._SpecialForm):
        # case of typing.Union[…] or typing.ClassVar[…] or …
        for origins in map(_find_type_origin, typing.get_args(type_hint)):
            yield from origins
    else:
        yield actual_type


def _check_types(parameters, hints):
    for name, value in parameters.items():
        type_hint = hints.get(name, typing.Any)
        actual_types = tuple(_find_type_origin(type_hint))
        if actual_types and not isinstance(value, actual_types):
            raise TypeError(
                    f"Expected type '{type_hint}' for argument '{name}'"
                    f" but received type '{type(value)}' instead"
            )


def enforce_types(callable):
    def decorate(func):
        hints = typing.get_type_hints(func)
        signature = inspect.signature(func)

        @wraps(func)
        def wrapper(*args, **kwargs):
            parameters = dict(zip(signature.parameters, args))
            parameters.update(kwargs)
            _check_types(parameters, hints)

            return func(*args, **kwargs)
        return wrapper

    if inspect.isclass(callable):
        callable.__init__ = decorate(callable.__init__)
        return callable

    return decorate(callable)


def enforce_strict_types(callable):
    def decorate(func):
        hints = typing.get_type_hints(func)
        signature = inspect.signature(func)

        @wraps(func)
        def wrapper(*args, **kwargs):
            bound = signature.bind(*args, **kwargs)
            bound.apply_defaults()
            parameters = dict(zip(signature.parameters, bound.args))
            parameters.update(bound.kwargs)
            _check_types(parameters, hints)

            return func(*args, **kwargs)
        return wrapper

    if inspect.isclass(callable):
        callable.__init__ = decorate(callable.__init__)
        return callable

    return decorate(callable)

感谢 @Aran-Fey 帮助我改进了这个答案。

【讨论】:

  • 非常感谢这个很棒的答案! =) 我终于开始理解和测试它,它解决了我的问题并教会了我很多关于typing的知识。
  • 有一个受此答案启发的library
  • @301_Moved_Permanently 上面@Jundiaius 指出的库存在您在回答中提到的所有缺点。值得注意的是,检查Optional[List[str]] 会导致错误。您是否有兴趣为该库做出贡献以涵盖极端案例?将不胜感激。
  • @Philipp 在我想离开之后,你让我再次为 SO 做出贡献,真丢脸 ;)
【解决方案2】:

刚发现这个问题。

pydantic 可以开箱即用地对数据类进行完整的类型验证。 (承认:我构建了 pydantic)

只需使用 pydantic 的装饰器版本,生成的数据类完全是 vanilla。

from datetime import datetime
from pydantic.dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str = 'John Doe'
    signup_ts: datetime = None

print(User(id=42, signup_ts='2032-06-21T12:00'))
"""
User(id=42, name='John Doe', signup_ts=datetime.datetime(2032, 6, 21, 12, 0))
"""

User(id='not int', signup_ts='2032-06-21T12:00')

最后一行将给出:

    ...
pydantic.error_wrappers.ValidationError: 1 validation error
id
  value is not a valid integer (type=type_error.integer)

【讨论】:

  • 您能否提供更多详细信息如何使用pydantic?也许有代码示例。
  • 问题是 typinh 和泛型,pydantic 支持吗?它是否正确验证typing.List[str]?谢谢:)
  • @SColvin ....我想第二个 Marco 的问题!它还会正确处理Optional[List[str]](即无或仅包含字符串的列表)吗?
【解决方案3】:

对于键入别名,您必须单独检查注释。 我确实喜欢这样: https://github.com/EvgeniyBurdin/validated_dc

【讨论】:

    【解决方案4】:

    为此我创建了一个小型 Python 库:https://github.com/tamuhey/dataclass_utils

    这个库可以应用于包含另一个数据类(嵌套数据类)和嵌套容器类型(如Tuple[List[Dict...)的数据类

    【讨论】:

    • 那个简单的库是唯一一个似乎对我有用的库。我认为在某些情况下它仍然没有得到(如x:MyEnum = ''),但在其他情况下它工作正常。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2017-10-16
    • 1970-01-01
    • 2018-08-10
    • 2019-01-23
    • 2012-04-29
    • 1970-01-01
    相关资源
    最近更新 更多