web-dev-qa-db-fra.com

Validation de types détaillés dans python dataclasses

Python 3.7 est au coin , et je voulais tester certaines des nouvelles fonctionnalités de frappe dataclass +. Obtenir des conseils pour bien fonctionner est assez facile, avec les types natifs et ceux du module 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 :)

Mais une autre chose que je voulais essayer était de forcer les indications de type comme conditions pendant l'exécution, c'est-à-dire qu'il ne devrait pas être possible pour un dataclass avec des types incorrects d'exister. Il peut être bien implémenté avec __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')

Ce type de fonction validate fonctionne pour les types natifs et les classes personnalisées, mais pas celles spécifiées par le module 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

Existe-t-il une meilleure approche pour valider une liste non typée avec une liste typée typing-? De préférence, qui n'inclut pas la vérification des types de tous les éléments dans un list, dict, Tuple ou set qui est un dataclass 'attribut.

15
Arne

Au lieu de vérifier l'égalité de type, vous devez utiliser isinstance. Mais vous ne pouvez pas utiliser un type générique paramétré (typing.List[int]) Pour cela, vous devez utiliser la version "générique" (typing.List). Ainsi, vous pourrez vérifier le type de conteneur mais pas les types contenus. Les types génériques paramétrés définissent un attribut __Origin__ Que vous pouvez utiliser pour cela.

Contrairement à Python 3.6, dans Python 3.7 la plupart des indices de type ont un attribut __Origin__ Utile. Comparez:

# Python 3.6
>>> import typing
>>> typing.List.__Origin__
>>> typing.List[int].__Origin__
typing.List

et

# Python 3.7
>>> import typing
>>> typing.List.__Origin__
<class 'list'>
>>> typing.List[int].__Origin__
<class 'list'>

Les exceptions notables étant typing.Any, typing.Union Et typing.ClassVar… Eh bien, tout ce qui est un typing._SpecialForm Ne définit pas __Origin__. Heureusement:

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

Mais les types paramétrés définissent un attribut __args__ Qui stocke leurs paramètres en tant que Tuple:

>>> typing.Union[int, str].__args__
(<class 'int'>, <class 'str'>)

Nous pouvons donc améliorer un peu la vérification de type:

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

Ce n'est pas parfait car il ne tiendra pas compte de typing.ClassVar[typing.Union[int, str]] Ou typing.Optional[typing.List[int]] Par exemple, mais il devrait faire avancer les choses.


Ensuite, vous pouvez appliquer cette vérification.

Au lieu d'utiliser __post_init__, J'irais dans la voie du décorateur: cela pourrait être utilisé sur n'importe quoi avec des indices de type, pas seulement 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)

Utilisation étant:

@enforce_types
@dataclasses.dataclass
class Point:
    x: float
    y: float

@enforce_types
def foo(bar: typing.Union[int, str]):
    pass

Hormis la validation de certains indices de type, comme suggéré dans la section précédente, cette approche présente encore certains inconvénients:

  • les indications de type utilisant des chaînes (class Foo: def __init__(self: 'Foo'): pass) ne sont pas prises en compte par inspect.getfullargspec: vous pouvez utiliser typing.get_type_hints et inspect.signature à la place;
  • une valeur par défaut qui n'est pas du type approprié n'est pas validée:

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

    ne soulève pas de TypeError. Vous voudrez peut-être utiliser inspect.Signature.bind en conjonction avec inspect.BoundArguments.apply_defaults si vous voulez en tenir compte (et donc vous forcer à définir def foo(bar: typing.Optional[int] = None));

  • le nombre variable d'arguments ne peut pas être validé car il faudrait définir quelque chose comme def foo(*args: typing.Sequence, **kwargs: typing.Mapping) et, comme dit au début, nous ne pouvons valider que les conteneurs et non les objets contenus.

Merci à @ Aran-Fey qui m'a aidé à améliorer cette réponse.

25
Mathias Ettinger

Je viens de trouver cette question.

pydantic peut faire une validation de type complète pour les classes de données hors de la boîte. (admission: j'ai construit pydantic)

Utilisez simplement la version pydantic du décorateur, la classe de données résultante est complètement 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')

La dernière ligne donnera:

    ...
pydantic.error_wrappers.ValidationError: 1 validation error
id
  value is not a valid integer (type=type_error.integer)
2
SColvin