验证 python 数据类中的详细类型

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。我会留下我的印记与目前接受的答案,因为它正确地回答了原来的问题,并具有突出的教育价值。

54316 次浏览

应该使用 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

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

  • 使用字符串(class Foo: def __init__(self: 'Foo'): pass)的类型提示在 inspect.getfullargspec中没有被考虑到: 你可能想要使用 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)的东西,正如开头所说的,我们只能验证容器,而不能验证包含的对象。


更新

这个答案得到了一些流行和 图书馆得到了很大的启发发布后,需要消除上面提到的缺点正在成为现实。因此,我发挥了一点与 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._SpecialForm__args__始终是一个元组,因此可以递归地找到该元组中包含的提示的 __origin__。结合以上检查,我们将需要过滤任何 typing._SpecialForm左。

拟议改进措施:

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)

感谢 @ 阿兰-菲帮助我改进了这个答案。

刚找到这个问题。

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

只要使用 pydantic 版本的装饰器,生成的数据类就完全是普通的。

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)

为了键入别名,必须单独检查注释。 我喜欢这样: Https://github.com/evgeniyburdin/validated_dc

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

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