在Python数据类中验证详细类型

91

Python 3.7已经发布一段时间了,我想测试一些新的dataclass+typing功能。让提示正确工作非常容易,既可以使用本地类型,也可以使用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进行类型定义的列表? 最好不包括检查作为dataclass属性的任何listdicttupleset中所有元素的类型。
重新审视这个问题,经过几年的实践,我现在使用pydantic来验证我通常只会为定义数据类的类。 我仍会留下我的标记,因为目前已接受的答案正确回答了原始问题,并具有杰出的教育价值。

1
这让我找到了 ty.List.__origin__,它给出了 <class 'list'>。虽然这不能让我检查内部类型,但至少不会再崩溃了。 - Arne
2
我找到了一个类似的问题链接,但它并没有真正的解决方案。如果您愿意手动检查类型,那么下面这两个链接会很有用:什么是检查typing.Generic是否正确的方法?如何访问typing.Generic的类型参数? - Aran-Fey
4
这是一个无望的事情。试图强制执行将产生过高的运行时成本。如果列表有一百万个项目怎么办?你想迭代每个项目并检查它的类型吗?如果我做 struct.a_str_list[24] = 1 -- 你就无从知晓了。你得编写一个特定的子类来检查其项目,只允许该类而不是 list 在你的结构中。这会带来很多运行时开销,更容易通过在API层使用保护程序和类型注释进行预防,在其他地方进行代码风格检查。 - Dunes
@Dunes 这正是我担心的,或者说,我不知道如何避免你所描述的陷阱来解决我的问题。那么这就是一个无望的情况了,至少学习 __origin__ 的“绷带”让我可以使用 typing - Arne
我只是为未来的冒险家补充一点,如果你想让类型提示按照被接受的答案中描述的那样工作,就需要避免使用 from __future__ import annotations - Greg0ry
显示剩余3条评论
4个回答

99

不要检查类型相等,应该使用 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.Any, typing.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

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

  • type hints using strings (class Foo: def __init__(self: 'Foo'): pass) are not taken into account by inspect.getfullargspec: you may want to use typing.get_type_hints and inspect.signature instead;

  • a default value which is not the appropriate type is not validated:

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

    does not raise any TypeError. You may want to use inspect.Signature.bind in conjuction with inspect.BoundArguments.apply_defaults if you want to account for that (and thus forcing you to define def foo(bar: typing.Optional[int] = None));

  • variable number of arguments can't be validated as you would have to define something like def foo(*args: typing.Sequence, **kwargs: typing.Mapping) and, as said at the beginning, we can only validate containers and not contained objects.

更新

由于这个答案受到了一定的关注,并且受到了一个受其启发的的发布,需要解决上述提到的缺点。因此,我对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._SpecialForm作为参数的typing提示的情况,例如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)

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


4
有一个受这个答案启发的可用。 - Jundiaius
@301_Moved_Permanently 上面 @Jundiaius 提到的库存在你回答中提到的所有缺点。特别是,检查 Optional[List[str]] 会导致错误。你有兴趣为该库做出贡献以涵盖这些边角情况吗?这将非常感激。 - Philipp
2
@Philipp,你让我再次为SO做贡献,尽管我想离开它,真是让人羞愧啊 ;) - 301_Moved_Permanently
@Richard 如回答开头所述,我不专注于集合内部的类型验证。正如 Dunes 所示,这很难做到正确;特别是生成器的情况:我们应该耗尽它们进行类型检查吗?如何存储值?如果我们没有使用所有东西怎么办?如果它是无限的呢?pydantic 可以对简单情况进行此类检查,但我认为你不会在任何地方找到通用答案。 - 301_Moved_Permanently
1
未来的冒险家注意 - 如果您使用了 from __future__ import annotations ,那么类型将不会按照本答案所描述的方式工作。 - Greg0ry
显示剩余3条评论

64

刚发现这个问题。

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)

请问您能否提供更多关于如何使用 pydantic 的细节呢?最好附带一些代码示例。 - sanyassh
问题是关于泛型类型的,Pydantic支持吗?它能正确验证 typing.List[str] 吗?谢谢 :) - Marco Acierno
@SColvin....我也支持Marco上面的问题!它是否也能正确处理Optional[List[str]](即无或仅为字符串列表)? - Richard

2
我为此创建了一个小型的Python库:https://github.com/tamuhey/dataclass_utils
该库可用于包含其他数据类(嵌套数据类)以及嵌套容器类型(例如 Tuple[List[Dict...) 的数据类。

1
那个简单的库似乎是唯一一个对我有效的。我认为它仍然存在一些情况无法解决(例如 x:MyEnum = ''),但对于其他情况,它运行良好。 - Apalala

1

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接