Module typing_validation.validate

Runtime typing validation.

Expand source code
"""
    Runtime typing validation.
"""

import collections
import collections.abc as collections_abc
import typing

# constant for the type of None
_NoneType = type(None)

# collection types (parametric on item type)
_collection_pseudotypes_dict = {
    typing.Collection: collections_abc.Collection,
    typing.AbstractSet: collections_abc.Set,
    typing.MutableSet: collections_abc.MutableSet,
    typing.Sequence: collections_abc.Sequence,
    typing.MutableSequence: collections_abc.MutableSequence,
    typing.Deque: collections.deque,
    typing.List: list,
    typing.Set: set,
    typing.FrozenSet: frozenset,
}
_collection_pseudotypes = frozenset(_collection_pseudotypes_dict.keys())|frozenset(_collection_pseudotypes_dict.values())
_collection_origins = frozenset(_collection_pseudotypes_dict.values())

# types that could might be validated as collections (parametric on item type)
_maybe_collection_pseudotypes_dict = {
    typing.Iterable: collections_abc.Iterable,
    typing.Container: collections_abc.Container,
}
_maybe_collection_pseudotypes = frozenset(_maybe_collection_pseudotypes_dict.keys())|frozenset(_maybe_collection_pseudotypes_dict.values())
_maybe_collection_origins = frozenset(_maybe_collection_pseudotypes_dict.values())

# mapping types (parametric on both key type and value type)
_mapping_pseudotypes_dict = {
    typing.Mapping: collections_abc.Mapping,
    typing.MutableMapping: collections_abc.MutableMapping,
    typing.Dict: dict,
    typing.DefaultDict: collections.defaultdict,
}
_mapping_pseudotypes = frozenset(_mapping_pseudotypes_dict.keys())|frozenset(_mapping_pseudotypes_dict.values())
_mapping_origins = frozenset(_mapping_pseudotypes_dict.values())

# tuple and namedtuples
_tuple_pseudotypes = frozenset({typing.Tuple, tuple, typing.NamedTuple, collections.namedtuple})
_tuple_origins = frozenset({tuple, collections.namedtuple})

# other types
_other_pseudotypes_dict = {
    typing.Iterator: collections_abc.Iterator,
    typing.Hashable: collections_abc.Hashable,
    typing.Sized: collections_abc.Sized,
    typing.ByteString: collections_abc.ByteString,
}
_other_pseudotypes = frozenset(_other_pseudotypes_dict.keys())|frozenset(_other_pseudotypes_dict.values())
_other_origins = frozenset(_other_pseudotypes_dict.values())

# all types together
_pseudotypes_dict: typing.Mapping[typing.Any, typing.Any] = {
    **_collection_pseudotypes_dict,
    **_maybe_collection_pseudotypes_dict,
    **_mapping_pseudotypes_dict,
    **_other_pseudotypes_dict
}
_pseudotypes = (_collection_pseudotypes|_maybe_collection_pseudotypes|_mapping_pseudotypes|_tuple_pseudotypes|_other_pseudotypes)
_origins = (_collection_origins|_maybe_collection_origins|_mapping_origins|_tuple_origins|_other_origins)

def _indent(msg: str) -> str:
    """ Indent a block of text (possibly with newlines) """
    ind = " "*2
    return ind+msg.replace("\n", "\n"+ind)

_T = typing.TypeVar("_T", bound="ValidationFailure")

class ValidationFailure:
    """
        Simple container class for validation failures.
    """

    _val: typing.Any
    _t: typing.Any
    _causes: typing.Tuple["ValidationFailure", ...]
    _is_union: bool

    def __new__(cls: typing.Type[_T],
                val: typing.Any, t: typing.Any,
                *causes: "ValidationFailure",
                is_union: bool = False) -> _T:
        instance: _T = super().__new__(cls)
        instance._val = val
        instance._t = t
        instance._causes = causes
        instance._is_union = is_union
        if is_union:
            assert all(cause.val == val for cause in causes)
        return instance

    @property
    def val(self) -> typing.Any:
        """ The value involved in the validation failure. """
        return self._val

    @property
    def t(self) -> typing.Any:
        """ The type involved in the validation failure. """
        return self._t

    @property
    def causes(self) -> typing.Tuple["ValidationFailure", ...]:
        """ Validation failure that in turn caused this failure (if any). """
        return self._causes

    @property
    def is_union(self) -> bool:
        """ Whether this validation failure concerns a union type. """
        return self._is_union

    def _build_rich_tree(self, tree: typing.Any=None) -> typing.Any:
        # pylint: disable = import-outside-toplevel
        try:
            from rich.tree import Tree
            from rich.text import Text
        except ModuleNotFoundError as e:
            raise ModuleNotFoundError("The rich library must be installed.") from e
        label = Text(f"({repr(self.t)}, {repr(self.val)})")
        if tree is None:
            tree = Tree(label)
        else:
            tree = tree.add(label)
        for cause in self.causes:
            cause._build_rich_tree(tree)
        return tree

    def tree_view(self) -> None:
        """
            Prints a tree view of the validation vailure and its tree of causes:

            ```py
            >>> from typing import *
            >>> from typing_validation import validate, latest_validation_failure
            >>> try:
            ...     validate([[0, 1, 2], {"hi": 0}], list[Union[Collection[int], dict[str, str]]])
            ... except TypeError:
            ...     failure = latest_validation_failure()
            ...
            >>> failure.tree_view()
            (list[typing.Union[typing.Collection[int], dict[str, str]]], [[0, 1, 2], {'hi': 0}])
            └── (typing.Union[typing.Collection[int], dict[str, str]], {'hi': 0})
                ├── (typing.Collection[int], {'hi': 0})
                │   └── (<class 'int'>, 'hi')
                └── (dict[str, str], {'hi': 0})
                    └── (<class 'str'>, 0)
            ```

            The [`rich` library](https://github.com/willmcgugan/rich) must be installed to use this method.

        """
        # pylint: disable = import-outside-toplevel
        try:
            import rich
        except ModuleNotFoundError as e:
            raise ModuleNotFoundError("The rich library must be installed to use ValidationFailure.tree_view.") from e
        tree = self._build_rich_tree()
        rich.print(tree)

    def __str__(self) -> str:
        msg = f"For type {repr(self.t)}, invalid value: {repr(self.val)}"
        if self._is_union:
            for cause in (cause for cause in self.causes if cause.causes):
                msg += "\n"+_indent(f"Detailed failures for member type {repr(cause.t)}:")
                for sub_cause in cause.causes:
                    msg += "\n"+_indent(_indent(str(sub_cause)))
        else:
            for cause in self.causes:
                msg += "\n"+_indent(str(cause))
        return msg

    def __repr__(self) -> str:
        causes_str = ""
        if self.causes:
            causes_str = ", "+", ".join(repr(cause) for cause in self.causes)
        is_union_str = ""
        if self._is_union:
            is_union_str = ", is_union=True"
        return f"ValidationFailure({repr(self.val)}, {repr(self.t)}{causes_str}{is_union_str})"

def _type_error(val: typing.Any, t: typing.Any, *causes: TypeError, is_union: bool = False) -> TypeError:
    """
        Type error arising from `val` not being an instance of type `t`.
        If other type errors are passed as causes, their error messages are indented and included.
        A `validation_failure` attribute of type `ValidationFailure` is set for the error,
        including full information about the chain of validation failures.
    """
    _causes: typing.Tuple[ValidationFailure, ...] = tuple(
        getattr(error, "validation_failure") for error in causes
        if hasattr(error, "validation_failure")
    )
    assert all(isinstance(cause, ValidationFailure) for cause in _causes)
    validation_failure = ValidationFailure(val, t, *_causes, is_union=is_union)
    error = TypeError(str(validation_failure))
    setattr(error, "validation_failure", validation_failure)
    return error

def _missing_args_msg(t: typing.Any) -> str:
    """ Error message for missing `__args__` attribute on a type `t`. """
    return f"For type {repr(t)}, expected '__args__' attribute." # pragma: nocover

def _wrong_args_num_msg(t: typing.Any, num_args: int) -> str:
    """ Error message for incorrect number of `__args__` on a type `t`. """
    return f"For type {repr(t)}, expected '__args__' to be tuple with {num_args} elements." # pragma: nocover

def _validate_type(val: typing.Any, t: type) -> None:
    """ Basic validation using `isinstance` """
    if not isinstance(val, t):
        raise _type_error(val, t)

def _validate_collection(val: typing.Any, t: typing.Any) -> None:
    """ Parametric collection validation (i.e. recursive validation of all items). """
    assert hasattr(t, "__args__"), _missing_args_msg(t)
    assert isinstance(t.__args__, tuple) and len(t.__args__) == 1, _wrong_args_num_msg(t, 1)
    item_t = t.__args__[0]
    item_error: typing.Optional[TypeError] = None
    for item in val:
        try:
            validate(item, item_t)
        except TypeError as e:
            item_error = e
            break
    if item_error:
        raise _type_error(val, t, item_error)

def _validate_mapping(val: typing.Any, t: typing.Any) -> None:
    """ Parametric mapping validation (i.e. recursive validation of all keys and values). """
    assert hasattr(t, "__args__"), _missing_args_msg(t)
    assert isinstance(t.__args__, tuple) and len(t.__args__) == 2, _wrong_args_num_msg(t, 2)
    key_t, value_t = t.__args__
    item_error: typing.Optional[TypeError] = None
    for key, value in val.items():
        try:
            validate(key, key_t)
            validate(value, value_t)
        except TypeError as e:
            item_error = e
            break
    if item_error:
        raise _type_error(val, t, item_error)

def _validate_tuple(val: typing.Any, t: typing.Any) -> None:
    """
        Parametric tuple validation (i.e. recursive validation of all items).
        Two cases:

        - variadic tuple types: arbitrary number of items, all of same type
        - fixed-length tuple types: fixed number of items, each with its individual type
    """
    assert hasattr(t, "__args__"), _missing_args_msg(t)
    assert isinstance(t.__args__, tuple), f"For type {repr(t)}, expected '__args__' to be a tuple."
    item_error: typing.Optional[TypeError] = None
    if ... in t.__args__: # variadic tuple
        assert len(t.__args__) == 2, _wrong_args_num_msg(t, 2)
        item_t = t.__args__[0]
        for item in val:
            try:
                validate(item, item_t)
            except TypeError as e:
                item_error = e
                break
    else: # fixed-length tuple
        if len(val) != len(t.__args__):
            raise _type_error(val, t)
            # raise TypeError(f"For tuple type {repr(t)}, the following tuple value has incorrect length "
            #                 f"(found {len(val)}, expected {len(t.__args__)}): {repr(val)}.")
        for item_t, item in zip(t.__args__, val):
            try:
                validate(item, item_t)
            except TypeError as e:
                item_error = e
                break
    if item_error:
        raise _type_error(val, t, item_error)

def _validate_union(val: typing.Any, t: typing.Any) -> None:
    """
        Union type validation. Each type `u` listed in the union type `t` is checked:

        - if `val` is an instance of `t`, returns immediately without error
        - otherwise, moves to the next `u`

        If `val` is not an instance of any of the types listed in the union, type error is raised.
    """
    assert hasattr(t, "__args__"), _missing_args_msg(t)
    assert isinstance(t.__args__, tuple), f"For type {repr(t)}, expected '__args__' to be a tuple."
    if not t.__args__:
        return
    member_errors: typing.List[TypeError] = []
    for member_t in t.__args__:
        try:
            validate(val, member_t)
            return
        except TypeError as e:
            member_errors.append(e)
    raise _type_error(val, t, *member_errors, is_union=True)

def _validate_literal(val: typing.Any, t: typing.Any) -> None:
    """
        Literal type validation.
    """
    assert hasattr(t, "__args__"), _missing_args_msg(t)
    assert isinstance(t.__args__, tuple), f"For type {repr(t)}, expected '__args__' to be a tuple."
    if val not in t.__args__:
        raise _type_error(val, t)

def _validate(val: typing.Any, t: typing.Any) -> None:
    """
        Selects the appropriate validation code based on the type.
    """
    # pylint: disable = too-many-return-statements, too-many-branches
    if t is typing.Any:
        return
    if t is None or t is _NoneType:
        if val is not None:
            raise _type_error(val, t)
        return
    if t in _pseudotypes:
        _validate_type(val, t)
        return
    if hasattr(t, "__origin__"): # parametric types
        if t.__origin__ is typing.Union:
            _validate_union(val, t)
            return
        if t.__origin__ is typing.Literal:
            _validate_literal(val, t)
            return
        if t.__origin__ in _origins:
            _validate_type(val, t.__origin__)
        if t.__origin__ in _collection_origins:
            _validate_collection(val, t)
            return
        if t.__origin__ in _mapping_origins:
            _validate_mapping(val, t)
            return
        if t.__origin__ == tuple:
            _validate_tuple(val, t)
            return
        if t.__origin__ in _maybe_collection_origins and isinstance(val, typing.Collection):
            _validate_collection(val, t)
            return
    # The `isinstance(t, type)` case goes after the `hasattr(t, "__origin__")` case:
    # e.g. `isinstance(list[int], type)` in 3.10, but we want to validate `list[int]`
    # as a parametric type, not merely as `list` (which is what `_validate_type` does).
    if isinstance(t, type):
        _validate_type(val, t)
        return
    raise ValueError(f"Unsupported validation for type {repr(t)}") # pragma: nocover

_validation_failure: typing.Optional[ValidationFailure] = None

def latest_validation_failure() -> typing.Optional[ValidationFailure]:
    """
        Programmatic access to the validation failure tree for the latest validation.
        This is `None` if the latest call to `validate` succeeded without error,
        or if the error was not a validation error (i.e. not a `TypeError`).

        ```py
        >>> from typing_validation import validate, latest_validation_failure
        >>> try:
        ...     validate([[0, 1], [1, 2], [2, "hi"]], list[list[int]])
        ... except TypeError:
        ...     failure = latest_validation_failure()
        ...
        >>> failure
        ValidationFailure([[0, 1], [1, 2], [2, 'hi']], list[list[int]],
                          ValidationFailure([2, 'hi'], list[int],
                                            ValidationFailure('hi', <class 'int'>)))
        ```
    """
    return _validation_failure

def validate(val: typing.Any, t: typing.Any) -> None:
    """
        Performs runtime type-checking for the value `val` against type `t`.
        Raises `TypeError` if:

        - `val` is not of type `t`
        - validation for type `t` is not supported

        In cases, such as typed collections/mappings, where items are recursively
        validated, collection exceptions are raised from item exceptions, keeping
        track of the chain of validation failure.
    """
    global _validation_failure # pylint: disable=global-statement
    _validation_failure = None
    try:
        _validate(val, t)
    except TypeError as e:
        if hasattr(e, "validation_failure"):
            _validation_failure = getattr(e, "validation_failure")
            assert isinstance(_validation_failure, ValidationFailure)
        raise e

Functions

def latest_validation_failure() ‑> Optional[ValidationFailure]

Programmatic access to the validation failure tree for the latest validation. This is None if the latest call to validate() succeeded without error, or if the error was not a validation error (i.e. not a TypeError).

>>> from typing_validation import validate, latest_validation_failure
>>> try:
...     validate([[0, 1], [1, 2], [2, "hi"]], list[list[int]])
... except TypeError:
...     failure = latest_validation_failure()
...
>>> failure
ValidationFailure([[0, 1], [1, 2], [2, 'hi']], list[list[int]],
                  ValidationFailure([2, 'hi'], list[int],
                                    ValidationFailure('hi', <class 'int'>)))
Expand source code
def latest_validation_failure() -> typing.Optional[ValidationFailure]:
    """
        Programmatic access to the validation failure tree for the latest validation.
        This is `None` if the latest call to `validate` succeeded without error,
        or if the error was not a validation error (i.e. not a `TypeError`).

        ```py
        >>> from typing_validation import validate, latest_validation_failure
        >>> try:
        ...     validate([[0, 1], [1, 2], [2, "hi"]], list[list[int]])
        ... except TypeError:
        ...     failure = latest_validation_failure()
        ...
        >>> failure
        ValidationFailure([[0, 1], [1, 2], [2, 'hi']], list[list[int]],
                          ValidationFailure([2, 'hi'], list[int],
                                            ValidationFailure('hi', <class 'int'>)))
        ```
    """
    return _validation_failure
def validate(val: Any, t: Any) ‑> None

Performs runtime type-checking for the value val against type t. Raises TypeError if:

  • val is not of type t
  • validation for type t is not supported

In cases, such as typed collections/mappings, where items are recursively validated, collection exceptions are raised from item exceptions, keeping track of the chain of validation failure.

Expand source code
def validate(val: typing.Any, t: typing.Any) -> None:
    """
        Performs runtime type-checking for the value `val` against type `t`.
        Raises `TypeError` if:

        - `val` is not of type `t`
        - validation for type `t` is not supported

        In cases, such as typed collections/mappings, where items are recursively
        validated, collection exceptions are raised from item exceptions, keeping
        track of the chain of validation failure.
    """
    global _validation_failure # pylint: disable=global-statement
    _validation_failure = None
    try:
        _validate(val, t)
    except TypeError as e:
        if hasattr(e, "validation_failure"):
            _validation_failure = getattr(e, "validation_failure")
            assert isinstance(_validation_failure, ValidationFailure)
        raise e

Classes

class ValidationFailure (val: Any, t: Any, *causes: ValidationFailure, is_union: bool = False)

Simple container class for validation failures.

Expand source code
class ValidationFailure:
    """
        Simple container class for validation failures.
    """

    _val: typing.Any
    _t: typing.Any
    _causes: typing.Tuple["ValidationFailure", ...]
    _is_union: bool

    def __new__(cls: typing.Type[_T],
                val: typing.Any, t: typing.Any,
                *causes: "ValidationFailure",
                is_union: bool = False) -> _T:
        instance: _T = super().__new__(cls)
        instance._val = val
        instance._t = t
        instance._causes = causes
        instance._is_union = is_union
        if is_union:
            assert all(cause.val == val for cause in causes)
        return instance

    @property
    def val(self) -> typing.Any:
        """ The value involved in the validation failure. """
        return self._val

    @property
    def t(self) -> typing.Any:
        """ The type involved in the validation failure. """
        return self._t

    @property
    def causes(self) -> typing.Tuple["ValidationFailure", ...]:
        """ Validation failure that in turn caused this failure (if any). """
        return self._causes

    @property
    def is_union(self) -> bool:
        """ Whether this validation failure concerns a union type. """
        return self._is_union

    def _build_rich_tree(self, tree: typing.Any=None) -> typing.Any:
        # pylint: disable = import-outside-toplevel
        try:
            from rich.tree import Tree
            from rich.text import Text
        except ModuleNotFoundError as e:
            raise ModuleNotFoundError("The rich library must be installed.") from e
        label = Text(f"({repr(self.t)}, {repr(self.val)})")
        if tree is None:
            tree = Tree(label)
        else:
            tree = tree.add(label)
        for cause in self.causes:
            cause._build_rich_tree(tree)
        return tree

    def tree_view(self) -> None:
        """
            Prints a tree view of the validation vailure and its tree of causes:

            ```py
            >>> from typing import *
            >>> from typing_validation import validate, latest_validation_failure
            >>> try:
            ...     validate([[0, 1, 2], {"hi": 0}], list[Union[Collection[int], dict[str, str]]])
            ... except TypeError:
            ...     failure = latest_validation_failure()
            ...
            >>> failure.tree_view()
            (list[typing.Union[typing.Collection[int], dict[str, str]]], [[0, 1, 2], {'hi': 0}])
            └── (typing.Union[typing.Collection[int], dict[str, str]], {'hi': 0})
                ├── (typing.Collection[int], {'hi': 0})
                │   └── (<class 'int'>, 'hi')
                └── (dict[str, str], {'hi': 0})
                    └── (<class 'str'>, 0)
            ```

            The [`rich` library](https://github.com/willmcgugan/rich) must be installed to use this method.

        """
        # pylint: disable = import-outside-toplevel
        try:
            import rich
        except ModuleNotFoundError as e:
            raise ModuleNotFoundError("The rich library must be installed to use ValidationFailure.tree_view.") from e
        tree = self._build_rich_tree()
        rich.print(tree)

    def __str__(self) -> str:
        msg = f"For type {repr(self.t)}, invalid value: {repr(self.val)}"
        if self._is_union:
            for cause in (cause for cause in self.causes if cause.causes):
                msg += "\n"+_indent(f"Detailed failures for member type {repr(cause.t)}:")
                for sub_cause in cause.causes:
                    msg += "\n"+_indent(_indent(str(sub_cause)))
        else:
            for cause in self.causes:
                msg += "\n"+_indent(str(cause))
        return msg

    def __repr__(self) -> str:
        causes_str = ""
        if self.causes:
            causes_str = ", "+", ".join(repr(cause) for cause in self.causes)
        is_union_str = ""
        if self._is_union:
            is_union_str = ", is_union=True"
        return f"ValidationFailure({repr(self.val)}, {repr(self.t)}{causes_str}{is_union_str})"

Instance variables

var causes : Tuple[ValidationFailure, ...]

Validation failure that in turn caused this failure (if any).

Expand source code
@property
def causes(self) -> typing.Tuple["ValidationFailure", ...]:
    """ Validation failure that in turn caused this failure (if any). """
    return self._causes
var is_union : bool

Whether this validation failure concerns a union type.

Expand source code
@property
def is_union(self) -> bool:
    """ Whether this validation failure concerns a union type. """
    return self._is_union
var t : Any

The type involved in the validation failure.

Expand source code
@property
def t(self) -> typing.Any:
    """ The type involved in the validation failure. """
    return self._t
var val : Any

The value involved in the validation failure.

Expand source code
@property
def val(self) -> typing.Any:
    """ The value involved in the validation failure. """
    return self._val

Methods

def tree_view(self) ‑> None

Prints a tree view of the validation vailure and its tree of causes:

>>> from typing import *
>>> from typing_validation import validate, latest_validation_failure
>>> try:
...     validate([[0, 1, 2], {"hi": 0}], list[Union[Collection[int], dict[str, str]]])
... except TypeError:
...     failure = latest_validation_failure()
...
>>> failure.tree_view()
(list[typing.Union[typing.Collection[int], dict[str, str]]], [[0, 1, 2], {'hi': 0}])
└── (typing.Union[typing.Collection[int], dict[str, str]], {'hi': 0})
    ├── (typing.Collection[int], {'hi': 0})
    │   └── (<class 'int'>, 'hi')
    └── (dict[str, str], {'hi': 0})
        └── (<class 'str'>, 0)

The rich library must be installed to use this method.

Expand source code
def tree_view(self) -> None:
    """
        Prints a tree view of the validation vailure and its tree of causes:

        ```py
        >>> from typing import *
        >>> from typing_validation import validate, latest_validation_failure
        >>> try:
        ...     validate([[0, 1, 2], {"hi": 0}], list[Union[Collection[int], dict[str, str]]])
        ... except TypeError:
        ...     failure = latest_validation_failure()
        ...
        >>> failure.tree_view()
        (list[typing.Union[typing.Collection[int], dict[str, str]]], [[0, 1, 2], {'hi': 0}])
        └── (typing.Union[typing.Collection[int], dict[str, str]], {'hi': 0})
            ├── (typing.Collection[int], {'hi': 0})
            │   └── (<class 'int'>, 'hi')
            └── (dict[str, str], {'hi': 0})
                └── (<class 'str'>, 0)
        ```

        The [`rich` library](https://github.com/willmcgugan/rich) must be installed to use this method.

    """
    # pylint: disable = import-outside-toplevel
    try:
        import rich
    except ModuleNotFoundError as e:
        raise ModuleNotFoundError("The rich library must be installed to use ValidationFailure.tree_view.") from e
    tree = self._build_rich_tree()
    rich.print(tree)