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

2024-04-19 09:37:40 发布

您现在位置:Python中文网/ 问答频道 /正文

Python 3.7 is around the corner,我想测试一些新的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不应该存在。它可以通过^{}很好地实现:

>>> @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属性)中的所有元素的类型。


Tags: selftyping类型fieldmydeftypestructure
2条回答

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

与Python3.6相反,在Python3.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引入了对^{}内省函数的更好支持:

# 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引入了^{}函数来检索它们:

# 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__,而是使用decorator路径:这可以用于任何具有类型提示的对象,而不仅仅是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考虑:您可能需要使用^{}^{}
  • 未验证不是适当类型的默认值:

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

    不会引发任何TypeError。如果您想解释这一点(从而迫使您定义def foo(bar: typing.Optional[int] = None)),您可能需要将^{}^{}结合使用;

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

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

刚找到这个问题。

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

只要使用pydantic版本的decorator,得到的数据类就完全是普通的。

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)

相关问题 更多 >