Saltar al contenido

Validación de tipos detallados en clases de datos de Python

Solución:

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 utilizar la versión “genérica” ​​(typing.List). Por lo tanto, podrá verificar el tipo de contenedor, pero no los tipos contenidos. Los tipos genéricos parametrizados definen un __origin__ atributo que puede usar para eso.

A diferencia de Python 3.6, en Python 3.7 la mayoría de las sugerencias de tipo tienen una utilidad __origin__ atributo. Comparar:

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

y

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

Python 3.8 presenta un soporte aún mejor con el typing.get_origin() función de introspección:

# Python 3.8
>>> import typing
>>> typing.get_origin(typing.List)
<class 'list'>
>>> typing.get_origin(typing.List[int])
<class 'list'>

Las excepciones notables son typing.Any, typing.Union y typing.ClassVar… Bueno, cualquier cosa que sea typing._SpecialForm no define __origin__. Afortunadamente:

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

Pero los tipos parametrizados definen un __args__ atributo que almacena sus parámetros como una tupla; Python 3.8 presenta el typing.get_args() función para recuperarlos:

# 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'>)

Entonces podemos mejorar un poco la verificació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:
        # 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

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


La siguiente es la forma de aplicar esta verificación.

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

El uso es:

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

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

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

  • escriba sugerencias usando cadenas (class Foo: def __init__(self: 'Foo'): pass) no son tenidos en cuenta por inspect.getfullargspec: es posible que desee utilizar typing.get_type_hints y inspect.signature en lugar de;

  • un valor predeterminado que no es del tipo apropiado no se valida:

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

    no levanta ninguna TypeError. Es posible que desee utilizar inspect.Signature.bind en conjunción con inspect.BoundArguments.apply_defaults si quiere dar cuenta de eso (y así obligarle 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.


###Actualizar

Después de que esta respuesta obtuvo cierta popularidad y se lanzó una biblioteca muy inspirada en ella, la necesidad de eliminar las deficiencias mencionadas anteriormente se está convirtiendo en una realidad. Así que jugué un poco más con el typing módulo y propondrá algunos hallazgos y un nuevo enfoque aquí.

Para empezar, typing está haciendo un gran trabajo para encontrar cuándo un argumento es opcional:

>>> 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]}

Esto es bastante bueno y definitivamente una mejora con respecto a inspect.getfullargspec, así que es mejor usarlo en su lugar, ya que también puede manejar cadenas como sugerencias de tipo. Pero typing.get_type_hints saldrá de apuros para otro tipo de valores predeterminados:

>>> 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]}

Por lo tanto, es posible que aún necesite un control más estricto, aunque estos casos se sientan muy sospechosos.

El siguiente es el caso de typing sugerencias utilizadas como argumentos para typing._SpecialForm, tal como typing.Optional[typing.List[str]] o typing.Final[typing.Union[typing.Sequence, typing.Mapping]]. Desde el __args__ de estos typing._SpecialForms es siempre una tupla, es posible encontrar recursivamente el __origin__ de las pistas contenidas en esa tupla. Combinado con las comprobaciones anteriores, necesitaremos filtrar cualquier typing._SpecialForm izquierda.

Mejoras propuestas:

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)

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

Acabo de encontrar esta pregunta.

pydantic puede realizar una validación de tipo completo para clases de datos de forma inmediata. (admisión: construí pydantic)

Simplemente use la versión de Pydantic del decorador, la clase de datos resultante es completamente vainilla.

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 última línea dará:

    ...
pydantic.error_wrappers.ValidationError: 1 validation error
id
  value is not a valid integer (type=type_error.integer)
¡Haz clic para puntuar esta entrada!
(Votos: 0 Promedio: 0)



Utiliza Nuestro Buscador

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *