Source code for typing_validation.validation_failure

"""
    Validation failure tracking functionality.
"""

from __future__ import annotations

import sys
import typing
from typing import Any, Mapping, Optional, Type, TypeVar

if sys.version_info[1] >= 8:
    from typing import Protocol
else:
    from typing_extensions import Protocol

if sys.version_info[1] >= 9:
    from collections.abc import Sequence
else:
    from typing import Sequence

if sys.version_info[1] >= 11:
    from typing import Self
else:
    from typing_extensions import Self


def _indent_lines(lines: Sequence[str], level: int = 1) -> list[str]:
    """Indent all given blocks of text."""
    if any("\n" in line for line in lines):
        lines = [l for line in lines for l in line.split("\n")]
    ind = " " * 2 * level
    return [ind + line for line in lines]


def _type_str(t: Any) -> str:
    if isinstance(t, type):
        return t.__name__
    return str(t)


Acc = typing.TypeVar("Acc")
"""
    Type variable for the accumulator in :meth:`ValidationFailure.visit`.
"""


[docs] class FailureTreeVisitor(Protocol[Acc]): """ Structural type for visitor functions that can be passed to :meth:`ValidationFailure.visit`. """ def __call__(self, val: Any, t: Any, acc: Acc) -> Acc: """ See :meth:`ValidationFailure.visit` for usage. """
[docs] class ValidationFailure: """ Generic validation failures. """ _val: Any _t: Any _causes: typing.Tuple[ValidationFailure, ...] _type_aliases: dict[str, Any] def __new__( cls, val: Any, t: Any, *causes: ValidationFailure, type_aliases: Optional[Mapping[str, Any]] = None, ) -> Self: instance = super().__new__(cls) instance._val = val instance._t = t instance._causes = causes instance._type_aliases = {**type_aliases} if type_aliases is not None else {} return instance @property def val(self) -> Any: """The value involved in the validation failure.""" return self._val @property def t(self) -> Any: """The type involved in the validation failure.""" return self._t @property def causes(self) -> typing.Tuple[ValidationFailure, ...]: r""" Validation failure that in turn caused this failure (if any). :rtype: :obj:`~typing.Tuple`\ [:class:`ValidationFailure`, ...] """ return self._causes @property def type_aliases(self) -> Mapping[str, Any]: r""" The type aliases that were set at the time of validation. """ return self._type_aliases
[docs] def visit(self, fun: FailureTreeVisitor[Acc], acc: Acc) -> None: r""" Performs a pre-order visit of the validation failure tree: 1. applies ``fun(self.val, self.t, acc)`` to the failure, 2. saves the return value as ``new_acc`` 3. recurses on all causes using ``new_acc``. For example, this can be used to implement pretty-prenting of validation failures (see :meth:`ValidationFailure.rich_print`): >>> import rich >>> from rich.tree import Tree >>> from rich.text import Text >>> from typing import Any, Collection, Union >>> from typing_validation import validate, latest_validation_failure >>> validate([[0, 1, 2], {"hi": 0}], list[Union[Collection[int], dict[str, str]]]) TypeError: ... >>> failure_tree = Tree("Failure tree") >>> def tree_builder(val: Any, t: Any, tree_tip: Tree) -> Tree: ... label = Text(f"({repr(t)}, {repr(val)})") ... tree_tip.add(label) # see https://rich.readthedocs.io/en/latest/tree.html ... return tree_tip ... >>> latest_validation_failure().visit(tree_builder, failure_tree) >>> rich.print(failure_tree) Failure tree └── (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) :param fun: the function that will be called on each element of the failure tree during the visit :type fun: :obj:`~typing.Callable`\ [[:obj:`~typing.Any`, :obj:`~typing.Any`, ``Acc``], ``Acc``] :param acc: the initial value for the accumulator :type acc: any type ``Acc`` """ new_acc = fun(self.val, self.t, acc) for cause in self.causes: cause.visit(fun, new_acc)
[docs] def rich_print(self) -> None: r""" Pretty-prints the validation failure tree using `rich <https://github.com/willmcgugan/rich>`_: >>> from typing import Union, Collection >>> from typing_validation import validate, latest_validation_failure >>> validate([[0, 1, 2], {"hi": 0}], list[Union[Collection[int], dict[str, str]]]) TypeError: ... >>> latest_validation_failure().rich_print() Failure tree └── (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) Raises :obj:`ModuleNotFoundError` if `rich <https://github.com/willmcgugan/rich>`_ is not installed. """ # pylint: disable = import-outside-toplevel import rich from rich.tree import Tree from rich.text import Text failure_tree = Tree("Failure tree") def tree_builder(val: Any, t: Any, acc: Tree) -> Tree: label = Text(f"({repr(t)}, {repr(val)})") return acc.add(label) # see https://rich.readthedocs.io/en/latest/tree.html self.visit(tree_builder, failure_tree) rich.print(failure_tree)
def __str__(self) -> str: return "\n".join(self._str_lines(top_level=True)) def __repr__(self) -> str: causes_str = "" if self.causes: causes_str = ", " + ", ".join(repr(cause) for cause in self.causes) return f"{type(self).__name__}({repr(self.val)}, {repr(self.t)}{causes_str})" def _str_type_descr(self, type_quals: tuple[str, ...] = ()) -> str: descr = ( "type alias" if isinstance(self.t, str) else "type variable" if isinstance(self.t, TypeVar) else "type" ) if type_quals: descr = " ".join(type_quals) + " " + descr return descr def _str_main_msg(self, type_quals: tuple[str, ...] = ()) -> str: return f"For {self._str_type_descr(type_quals)} {repr(self.t)}, invalid value: {repr(self.val)}" def _str_header_lines(self, top_level: bool) -> list[str]: if top_level: lines = [ "Runtime validation error raised by validate(val, t), " "details below." ] else: lines = [] if top_level and self.type_aliases: lines.append("Validation type aliases:") lines.append("{") for alias, aliased_t in self.type_aliases.items(): lines.append(f" '{alias}': {repr(aliased_t)}") lines.append("}") return lines def _str_causes_lines(self) -> list[str]: return [ line for cause in self.causes for line in _indent_lines(cause._str_lines(top_level=False)) ] def _str_lines( self, *, top_level: bool, type_quals: tuple[str, ...] = () ) -> list[str]: # pylint: disable = too-many-branches lines = self._str_header_lines(top_level) lines.append(self._str_main_msg(type_quals)) lines.extend(self._str_causes_lines()) return lines
[docs] class UnionValidationFailure(ValidationFailure): """ Validation failures arising from union types. """ def __new__( cls, val: Any, t: Any, *causes: ValidationFailure, type_aliases: Optional[Mapping[str, Any]] = None, ) -> Self: instance = super().__new__(cls, val, t, *causes, type_aliases=type_aliases) assert all(cause.val is val for cause in causes) return instance def _str_type_descr(self, type_quals: tuple[str, ...] = ()) -> str: if not type_quals or type_quals[-1] != "union": type_quals += ("union",) return super()._str_type_descr(type_quals) def _str_causes_lines(self) -> list[str]: return [ line for cause in self.causes for line in _indent_lines( cause._str_lines(top_level=False, type_quals=("member",)) ) ]
[docs] class ValidationFailureAtIdx(ValidationFailure): """ Validation failures arising at a given index of a sequence. """ _idx: int _ordered: bool def __new__( cls, val: Any, t: Any, idx_cause: ValidationFailure, idx: int, *, ordered: bool = True, type_aliases: Optional[Mapping[str, Any]] = None, ) -> Self: # pylint: disable = too-many-arguments if ordered: assert isinstance(val, Sequence) assert idx in range(len(val)) instance = super().__new__(cls, val, t, idx_cause, type_aliases=type_aliases) instance._idx = idx instance._ordered = ordered return instance @property def idx(self) -> int: """ The of the collection item at which this failure arose. """ return self._idx @property def ordered(self) -> bool: """ Whether the collection is ordered. If not, the item :attr:`idx` might not be stable. """ return self._ordered def _str_main_msg(self, type_quals: tuple[str, ...] = ()) -> str: return ( f"For {self._str_type_descr(type_quals)} {repr(self.t)}, " f"invalid value at idx: {self.idx}" )
[docs] class ValidationFailureAtKey(ValidationFailure): """ Validation failures arising at a given key of a mapping. """ _key: Any def __new__( cls, val: Any, t: Any, key_cause: ValidationFailure, key: Any, *, type_aliases: Optional[Mapping[str, Any]] = None, ) -> Self: # pylint: disable = too-many-arguments assert isinstance(val, Mapping) assert key in val instance = super().__new__(cls, val, t, key_cause, type_aliases=type_aliases) instance._key = key return instance @property def key(self) -> Any: """ The key of the outer sequence at which this failure arose. """ return self._key def _str_main_msg(self, type_quals: tuple[str, ...] = ()) -> str: return ( f"For {self._str_type_descr(type_quals)} {repr(self.t)}, " f"invalid value at key: {self.key!r}" )
[docs] class MissingKeysValidationFailure(ValidationFailure): """ Validation failures arising because of missing required keys in a mapping. """ _missing_keys: tuple[Any, ...] def __new__( cls, val: Any, t: Any, missing_keys: Sequence[Any], *, type_aliases: Optional[Mapping[str, Any]] = None, ) -> Self: assert isinstance(val, Mapping) assert len(missing_keys) >= 0 assert all(k not in val for k in missing_keys) instance = super().__new__(cls, val, t, type_aliases=type_aliases) instance._missing_keys = tuple(missing_keys) return instance @property def missing_keys(self) -> tuple[Any, ...]: """ The required key(s) missing from the mapping. """ return self._missing_keys def _str_main_msg(self, type_quals: tuple[str, ...] = ()) -> str: missing_keys = self.missing_keys if len(missing_keys) == 1: keys_repr = f"key: {missing_keys[0]!r}" else: keys_repr = f"keys: {missing_keys!r}" return ( f"For {self._str_type_descr(type_quals)} {repr(self.t)}, " f"missing required {keys_repr}" )
[docs] class InvalidNumpyDTypeValidationFailure(ValidationFailure): """ Validation failures arising because of invalid NumPy dtype. """ def __new__( cls, val: Any, t: Any, *, type_aliases: Optional[Mapping[str, Any]] = None, ) -> Self: import numpy as np # pylint: disable = import-outside-toplevel assert isinstance(val, np.ndarray) instance = super().__new__(cls, val, t, type_aliases=type_aliases) return instance def _str_main_msg(self, type_quals: tuple[str, ...] = ()) -> str: return ( f"For {self._str_type_descr(type_quals)} {repr(self.t)}, " f"invalid array dtype {self.val.dtype}" )
[docs] class TypeVarBoundValidationFailure(ValidationFailure): """ Validation failures arising from the bound of a type variable. """ def __new__( cls, val: Any, t: Any, bound_cause: ValidationFailure, *, type_aliases: Optional[Mapping[str, Any]] = None, ) -> Self: # pylint: disable = too-many-arguments instance = super().__new__(cls, val, t, bound_cause, type_aliases=type_aliases) return instance def _str_main_msg(self, type_quals: tuple[str, ...] = ()) -> str: return ( f"For {self._str_type_descr(type_quals)} {self.t!r}, " f"value is not valid for upper bound: {self.val!r}" )
[docs] class SubtypeValidationFailure(ValidationFailure): """ Validation failures arising from ``validate(s, Type[t])`` when ``s`` is not a subtype of ``t``. """ def __new__( cls, s: Any, t: Any, *, type_aliases: Optional[Mapping[str, Any]] = None, ) -> Self: # pylint: disable = too-many-arguments instance = super().__new__(cls, s, Type[t], type_aliases=type_aliases) return instance def _str_main_msg(self, type_quals: tuple[str, ...] = ()) -> str: t = self.t bound_t = t.__args__[0] return ( f"For {self._str_type_descr(type_quals)} {t!r}, " f"type bound is not a supertype of value: {self.val!r}" )
[docs] def get_validation_failure(err: TypeError) -> ValidationFailure: """ Programmatic access to the validation failure tree for the latest validation call. >>> from typing_validation import validate, get_validation_failure >>> try: ... validate([[0, 1], [1, 2], [2, "hi"]], list[list[int]]) ... except TypeError as err: ... validation_failure = get_validation_failure(err) ... >>> validation_failure ValidationFailure([[0, 1], [1, 2], [2, 'hi']], list[list[int]], ValidationFailure([2, 'hi'], list[int], ValidationFailure('hi', <class 'int'>))) :param err: type error raised by :func:`~typing_validation.validation.validate` :type err: :obj:`TypeError` Raises :obj:`TypeError` if the given error ``err`` is a :obj:`TypeError`. Raises :obj:`ValueError` if no validation failure data is available (when ``err`` is not a validation error raised by this library). """ if not isinstance(err, TypeError): raise TypeError(f"Expected TypeError, found {type(err)}") if not hasattr(err, "validation_failure"): raise ValueError("TypeError given is not a validation error.") validation_failure = getattr(err, "validation_failure") if not isinstance(validation_failure, ValidationFailure): raise ValueError("TypeError given is not a validation error.") return validation_failure
[docs] def latest_validation_failure() -> Optional[ValidationFailure]: """ Programmatic access to the validation failure tree for the latest validation call. Uses :obj:`sys.last_value`, so it must be called immediately after the error occurred. >>> from typing_validation import validate, latest_validation_failure >>> validate([[0, 1], [1, 2], [2, "hi"]], list[list[int]]) TypeError: ... >>> latest_validation_failure() ValidationFailure([[0, 1], [1, 2], [2, 'hi']], list[list[int]], ValidationFailure([2, 'hi'], list[int], ValidationFailure('hi', <class 'int'>))) This validation failure information is also set by ``is_valid`` in case of failed validation, even though no error is raised. """ type_err: Optional[TypeError] = None try: err = sys.last_value # pylint: disable = no-member if isinstance(err, TypeError): type_err = err except AttributeError: pass latest_validation_failure = _set_latest_validation_failure(None) if type_err is not None: return get_validation_failure(type_err) return latest_validation_failure
_latest_validation_failure: Optional[ValidationFailure] = None def _set_latest_validation_failure( failure: Optional[ValidationFailure], ) -> Optional[ValidationFailure]: """ Sets a new value for ``_latest_validation_failure`` and returns the previous value. """ global _latest_validation_failure # pylint: disable = global-statement prev_failure = _latest_validation_failure _latest_validation_failure = failure return prev_failure