85

Python 3.7 was released a while ago, and I wanted to test some of the fancy new dataclass+typing features. Getting hints to work right is easy enough, with both native types and those from the typing module:

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

But one other thing that I wanted to try was forcing the type hints as conditions during runtime, i.e. it should not be possible for a dataclass with incorrect types to exist. It can be implemented nicely with __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')

This kind of validate function works for native types and custom classes, but not those specified by the typing module:

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

Is there a better approach to validate an untyped list with a typing-typed one? Preferably one that doesn't include checking the types of all elements in any list, dict, tuple, or set that is a dataclass' attribute.


Revisiting this question after a couple of years, I've now moved to use pydantic in cases where I want to validate classes that I'd normally just define a dataclass for. I'll leave my mark with the currently accepted answer though, since it correctly answers the original question and has outstanding educational value.

Arne
  • 17,706
  • 5
  • 83
  • 99
  • The obvious solution would be `if not isinstance(actual_type, field_def.type):`... but of course the obvious solution doesn't work: `TypeError: Parameterized generics cannot be used with class or instance checks`. – Aran-Fey May 28 '18 at 09:59
  • 1
    That made me find `ty.List.__origin__` though, which gives ``. That won't let me check the inner type, but at least it won't crash on me any more – Arne May 28 '18 at 10:03
  • 2
    I found [this](https://stackoverflow.com/q/42027923/1222951) similar question, but it doesn't really have a solution. If you're down for checking the type manually, you'll find these two links useful: [What's the correct way to check if an object is a typing.Generic?](//stackoverflow.com/q/49171189) and [How to access the type arguments of typing.Generic?](//stackoverflow.com/q/48572831) – Aran-Fey May 28 '18 at 10:04
  • @Aran-Fey Those were some really interesting reads! – Arne May 28 '18 at 10:47
  • 4
    This is a lost cause. Trying to enforce this would encur prohibative runtime costs. What is the list is a million items long? Do you want to iterate over every item checking its type? What if I do `struct.a_str_list[24] = 1` -- you'd have no way of knowing. You'd have to write a specialised subclass of list that introspects its items and only allow that class rather than `list` in your structure. This is a lot fo runtime overhead and more easily prevented by using guards at the API level, and type annotations with linting elsewhere. – Dunes May 29 '18 at 07:48
  • @Dunes That's what I was afraid of, or rather, I wouldn't have known how to work around my problem while avoiding the pitfalls you describe. Lost cause it is then, plus then bandaid of learning `__origin__` at least lets me use `typing`. – Arne May 29 '18 at 08:03
  • Related: [How do I check if a value matches a type in python?](//stackoverflow.com/q/55503673) – Aran-Fey Apr 03 '19 at 21:14
  • I'll just add for future adventurers, that you need to avoid using `from __future__ import annotations` if you want typing to work as described in accepted answer. – Greg0ry Aug 06 '21 at 20:52

4 Answers4

95

Instead of checking for type equality, you should use isinstance. But you cannot use a parametrized generic type (typing.List[int]) to do so, you must use the "generic" version (typing.List). So you will be able to check for the container type but not the contained types. Parametrized generic types define an __origin__ attribute that you can use for that.

Contrary to Python 3.6, in Python 3.7 most type hints have a useful __origin__ attribute. Compare:

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

and

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

Python 3.8 introduce even better support with the typing.get_origin() introspection function:

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

Notable exceptions being typing.Any, typing.Union and typing.ClassVar… Well, anything that is a typing._SpecialForm does not define __origin__. Fortunately:

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

But parametrized types define an __args__ attribute that store their parameters as a tuple; Python 3.8 introduce the typing.get_args() function to retrieve them:

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

So we can improve type checking a bit:

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

This is not perfect as it won't account for typing.ClassVar[typing.Union[int, str]] or typing.Optional[typing.List[int]] for instance, but it should get things started.


Next is the way to apply this check.

Instead of using __post_init__, I would go the decorator route: this could be used on anything with type hints, not only 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)

Usage being:

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

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

Appart from validating some type hints as suggested in the previous section, this approach still have some drawbacks:

  • type hints using strings (class Foo: def __init__(self: 'Foo'): pass) are not taken into account by inspect.getfullargspec: you may want to use typing.get_type_hints and inspect.signature instead;

  • a default value which is not the appropriate type is not validated:

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

    does not raise any TypeError. You may want to use inspect.Signature.bind in conjuction with inspect.BoundArguments.apply_defaults if you want to account for that (and thus forcing you to define def foo(bar: typing.Optional[int] = None));

  • variable number of arguments can't be validated as you would have to define something like def foo(*args: typing.Sequence, **kwargs: typing.Mapping) and, as said at the beginning, we can only validate containers and not contained objects.


Update

After this answer got some popularity and a library heavily inspired by it got released, the need to lift the shortcomings mentioned above is becoming a reality. So I played a bit more with the typing module and will propose a few findings and a new approach here.

For starter, typing is doing a great job in finding when an argument is optional:

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

This is pretty neat and definitely an improvement over inspect.getfullargspec, so better use that instead as it can also properly handle strings as type hints. But typing.get_type_hints will bail out for other kind of default values:

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

So you may still need extra strict checking, even though such cases feels very fishy.

Next is the case of typing hints used as arguments for typing._SpecialForm, such as typing.Optional[typing.List[str]] or typing.Final[typing.Union[typing.Sequence, typing.Mapping]]. Since the __args__ of these typing._SpecialForms is always a tuple, it is possible to recursively find the __origin__ of the hints contained in that tuple. Combined with the above checks, we will then need to filter any typing._SpecialForm left.

Proposed improvements:

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)

Thanks to @Aran-Fey that helped me improve this answer.

Alex Waygood
  • 6,304
  • 3
  • 24
  • 46
301_Moved_Permanently
  • 4,007
  • 14
  • 28
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/172211/discussion-between-mathias-ettinger-and-aran-fey). – 301_Moved_Permanently Jun 01 '18 at 08:44
  • Thanks a lot for this great answer! =) I finally got around to understand and test it, and it solved my problems as well as taught me a lot about `typing`. – Arne Jun 05 '18 at 09:35
  • 4
    There is a [library](https://pypi.org/project/enforce-typing/) inspired by this answer available. – Jundiaius Nov 22 '19 at 13:53
  • @301_Moved_Permanently the library indicated by @Jundiaius above suffers from all the shortcomings you mention in your answer. Notably, checking `Optional[List[str]]` results in an error. Would you be interested to contribute to that library to cover the corner cases? It would be much appreciated. – Philipp Apr 30 '20 at 09:50
  • 2
    @Philipp You made me contribute to SO again after I wanted to depart with it, shame on you ;) – 301_Moved_Permanently Apr 30 '20 at 22:46
  • Thanks for the awesome answer. ....helped me hugely! My question is the above will handle cases of `Optional[List[str]]` by return a type of `'list'`. But in this case a list of anything will match. ...So is there a way (module?) to also check the contents of the list (in my example, are they all strings) against the hint, with the exception of moving to pydantic or equivalent? – Richard Nov 28 '20 at 00:05
  • @Richard As stated at the beginning of the answer, I'm not focusing on validating types inside of collections. As hinted by [Dunes](https://stackoverflow.com/questions/50563546/validating-detailed-types-in-python-dataclasses/50622643#comment88167102_50563546), this is hard to do right; especially the case of generators : should we exhaust them for type-checking? How to store the values? What if we don't use evrything? What if it is infifnite? `pydantic` allows such checks for simple cases but I don't think you'll find a generic answer anywhere. – 301_Moved_Permanently Nov 29 '20 at 10:21
  • 1
    Note to future adventurers - if you have `from __future__ import annotations` then types will not work the way as it's described in this answer. – Greg0ry Aug 06 '21 at 20:54
62

Just found this question.

pydantic can do full type validation for dataclasses out of the box. (admission: I built pydantic)

Just use pydantic's version of the decorator, the resulting dataclass is completely 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')

The last line will give:

    ...
pydantic.error_wrappers.ValidationError: 1 validation error
id
  value is not a valid integer (type=type_error.integer)
SColvin
  • 11,584
  • 6
  • 57
  • 71
  • Can you please provide more details how to use `pydantic`? Maybe with code examples. – sanyassh Apr 30 '19 at 11:04
  • The question is talking typinh with generics, does pydantic support it? Does it validate `typing.List[str]` correctly? Thanks :) – Marco Acierno Apr 17 '20 at 10:25
  • @SColvin ....I'd second Marco's question above! Will it also handle `Optional[List[str]]` correctly (ie either None or a list of only strings)? – Richard Nov 28 '20 at 00:09
2

I created a tiny Python library for this purpose: https://github.com/tamuhey/dataclass_utils

This library can be applied for such dataclass that holds another dataclass (nested dataclass), and nested container type (like Tuple[List[Dict...)

tamuhey
  • 2,904
  • 3
  • 21
  • 50
  • 1
    That simple library is the only one that seems to be working for me. I think there are some cases it still doesn't get (like `x:MyEnum = ''`), but for the rest it's working fine. – Apalala Feb 06 '21 at 13:47
1

For typing aliases, you must separately check the annotation. I did like this: https://github.com/EvgeniyBurdin/validated_dc

Evgeniy_Burdin
  • 627
  • 5
  • 14