Validación de tipos detallados en dataclasses de python

Python 3.7 está a la vuelta de la esquina , y quería probar algunas de las nuevas y dataclass funciones de tipografía + dataclass . Hacer que las sugerencias funcionen bien es bastante fácil, tanto con los tipos nativos como con los del módulo de 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 :) 

Pero otra cosa que quería probar era forzar las sugerencias de tipo como condiciones durante el tiempo de ejecución, es decir, no debería ser posible que dataclass una dataclass con tipos incorrectos. Se puede implementar muy bien con __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') 

Este tipo de función de validate funciona para tipos nativos y clases personalizadas, pero no para las especificadas por el módulo de typing :

 >>> my_struct = Structure(a_str='test', a_str_list=['t', 'e', 's', 't']) Traceback (most recent call last): a_str_list: '' instead of 'typing.List[str]' ValueError: Wrong types 

¿Existe un mejor enfoque para validar una lista sin tipo con una de typing tipeado? Preferiblemente uno que no incluya la verificación de los tipos de todos los elementos en cualquier list , dict , tuple o set que sea un atributo de dataclass de datos.

    En lugar de verificar la igualdad de tipos, debe usar isinstance . Pero no puede usar un tipo genérico parametrizado ( typing.List[int] ) para hacerlo, debe usar la versión “genérica” ​​( typing.List ). Así podrá verificar el tipo de contenedor pero no los tipos contenidos. Los tipos generics __origin__ definen un atributo __origin__ que puede usar para eso.

    Al contrario de Python 3.6, en Python 3.7, la mayoría de las sugerencias de tipo tienen un atributo __origin__ útil. Comparar:

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

    y

     # Python 3.7 >>> import typing >>> typing.List.__origin__  >>> typing.List[int].__origin__  

    Se están typing.Any excepciones notables. typing.Any , typing.Union y typing.ClassVar … Bueno, cualquier cosa que sea un typing._SpecialForm no define __origin__ . Por suerte:

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

    Pero los tipos parametrizados definen un atributo __args__ que almacena sus parámetros como una tupla:

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

    Así que podemos mejorar un poco la comprobación de tipos:

     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 

    Esto no es perfecto, ya que no se tendrá en cuenta al typing.ClassVar[typing.Union[int, str]] o typing.Optional[typing.List[int]] por ejemplo, pero debería comenzar.


    La siguiente es la forma de aplicar este cheque.

    En lugar de usar __post_init__ , me gustaría ir por la ruta del decorador: esto podría usarse en cualquier cosa con sugerencias de tipo, no solo en las 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) 

    Uso siendo:

     @enforce_types @dataclasses.dataclass class Point: x: float y: float @enforce_types def foo(bar: typing.Union[int, str]): pass 

    Además de validar algunos consejos de tipo como se sugirió en la sección anterior, este enfoque todavía tiene algunos inconvenientes:

    • class Foo: def __init__(self: 'Foo'): pass no tienen en cuenta las sugerencias de tipo que utilizan cadenas ( class Foo: def __init__(self: 'Foo'): pass ): es posible que desee utilizar typing.get_type_hints y inspect.signature en inspect.signature lugar;
    • no se valida un valor predeterminado que no sea el tipo apropiado:

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

      No eleva ningún TypeError . Es posible que desee utilizar inspect.Signature.bind en conjunto con inspect.BoundArguments.apply_defaults si desea inspect.BoundArguments.apply_defaults en cuenta (y, por lo tanto, obligarlo a definir def foo(bar: typing.Optional[int] = None) );

    • el número variable de argumentos no se puede validar, ya que tendría que definir algo como def foo(*args: typing.Sequence, **kwargs: typing.Mapping) y, como se dijo al principio, solo podemos validar contenedores y no objetos contenidos

    Gracias a @ Aran-Fey que me ayudó a mejorar esta respuesta.