Проверка подробных типов в python dataclasses

Python 3.7 не за dataclass, и я хотел протестировать некоторые новые интересные функции 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 -typed? Предпочтительно тот, который не включает проверку типов всех элементов в любом list, dict, tuple или set который является атрибутом dataclass.

Ответы

Ответ 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'>

Известные исключения: 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 подсказками типов, а не только с 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.Signature.bind с inspect.BoundArguments.apply_defaults если вы хотите учесть это (и, следовательно, вынудить вас определить def foo(bar: typing.Optional[int] = None));

  • переменное число аргументов не может быть проверено, так как вам нужно определить что-то вроде def foo(*args: typing.Sequence, **kwargs: typing.Mapping) и, как было сказано в начале, мы можем проверять только контейнеры, а не содержащиеся объекты.

Спасибо @Aran-Fey, который помог мне улучшить этот ответ.

Ответ 2

Просто нашел этот вопрос.

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)

Ответ 3

модуль pydantic проверяет переменные только в init

pip install pydantic

пример

from pydantic.dataclasses import dataclass
@dataclass
class Glass:
    capacity_volume : float
    occupied_volume : float

glass = Glass(300, 100)       # OK int to float
print(glass) # Glass(capacity_volume=500.0, occupied_volume=0.0)

glass = Glass(300.0, 100.0)   # OK 
print(glass) # Glass(capacity_volume=500.0, occupied_volume=0.0)

glass = Glass('300', '100')   # OK str to float    
print(glass) # Glass(capacity_volume=500.0, occupied_volume=0.0)

glass.capacity_volume = '300' 
print(glass) # Glass(capacity_volume='300', occupied_volume=0.0)

Можно ли проверить типы переменных, если они установлены?