web-dev-qa-db-ja.com

python dataclassesの詳細タイプの検証

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 :)

しかし、私が試したいもう1つのことは、実行時に型ヒントを条件として強制することでした。つまり、誤った型の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型付きリストで検証するより良い方法はありますか? listdictTuple、またはsetであるdataclassのすべての要素のタイプのチェックを含まないものが望ましい] _ '属性。

15
Arne

型の等価性をチェックする代わりに、isinstanceを使用する必要があります。ただし、パラメータ化されたジェネリック型(_typing.List[int]_)を使用してこれを行うことはできません。「ジェネリック」バージョン(_typing.List_)を使用する必要があります。そのため、コンテナタイプは確認できますが、含まれているタイプは確認できません。パラメータ化されたジェネリック型は、そのために使用できる___Origin___属性を定義します。

Python 3.6、in 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'>
_

注目すべき例外は_typing.Any_、_typing.Union_、および_typing.ClassVar_...です。まあ、_typing._SpecialForm_であるものは___Origin___を定義しません。幸いにも:

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

ただし、パラメーター化された型は、パラメーターをタプルとして格納する___args___属性を定義します。

_>>> typing.Union[int, str].__args__
(<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:
        actual_type = 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:
                    actual_type = 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_hints_ および _inspect.signature_ 代わりに;
  • 適切なタイプではないデフォルト値は検証されません。

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

    TypeErrorは発生しません。 _inspect.Signature.bind__inspect.BoundArguments.apply_defaults_ と組み合わせて使用​​したい場合は、したがって、def foo(bar: typing.Optional[int] = None))を定義する必要があります。

  • 可変数の引数は、def foo(*args: typing.Sequence, **kwargs: typing.Mapping)のようなものを定義する必要があるため検証できません。また、冒頭で述べたように、コンテナのみを検証し、含まれるオブジェクトは検証できません。

この回答の改善に役立った @ Aran-Fey に感謝します。

25

この質問を見つけました。

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)
2
SColvin