Skip to content

Commit

Permalink
Closes #17
Browse files Browse the repository at this point in the history
  • Loading branch information
sg495 committed Feb 21, 2024
1 parent 4601fe5 commit 2a11dea
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 20 deletions.
14 changes: 14 additions & 0 deletions test/test_00_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,3 +388,17 @@ def test_typevar() -> None:
validate("Hello", IntStrSeqT)
with pytest.raises(TypeError):
validate(0, IntStrSeqT)

def test_subtype() -> None:
validate(int, type)
validate(int, typing.Type)
validate(int, typing.Type[int])
validate(int, typing.Type[typing.Any])
validate(int, typing.Type[typing.Union[float,str,typing.Any]])
validate(int, typing.Type[typing.Union[int,str]])
with pytest.raises(TypeError):
validate(int, typing.Type[typing.Union[str, float]])
with pytest.raises(TypeError):
validate(10, typing.Type[int])
with pytest.raises(TypeError):
validate(10, typing.Type[typing.Union[str, float]])
8 changes: 8 additions & 0 deletions test/test_01_can_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,11 @@ def test_typevar() -> None:
assert can_validate(IntT)
IntStrSeqT = typing.TypeVar("IntStrSeqT", bound=typing.Sequence[typing.Union[int,str]])
assert can_validate(IntStrSeqT)

def test_subtype() -> None:
assert can_validate(type)
assert can_validate(typing.Type)
assert can_validate(typing.Type[int])
assert can_validate(typing.Type[typing.Union[int,str]])
assert can_validate(typing.Type[typing.Any])
assert can_validate(typing.Type[typing.Union[typing.Any, str, int]])
5 changes: 4 additions & 1 deletion typing_validation/inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,10 @@ def _append_constructor_args(self, args: TypeConstructorArgs) -> None:
"mapping",
"collection",
"user-class",
), f"Found unexpected tag '{args_tag}' with type constructor {pending_generic_type_constr} pending."
), (
f"Found unexpected tag '{args_tag}' with "
f"type constructor {pending_generic_type_constr} pending."
)
if sys.version_info[1] >= 9:
self._recorded_constructors.append(
typing.cast(
Expand Down
131 changes: 115 additions & 16 deletions typing_validation/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from .validation_failure import (
InvalidNumpyDTypeValidationFailure,
SubtypeValidationFailure,
TypeVarBoundValidationFailure,
ValidationFailureAtIdx,
ValidationFailureAtKey,
Expand Down Expand Up @@ -199,6 +200,29 @@ def validation_aliases(**aliases: Any) -> collections.abc.Iterator[None]:
)


class UnsupportedTypeError(ValueError):
"""
Class for errors raised when attempting to validate an unsupported type.
.. warning::
Currently extends :obj:`ValueError` for backwards compatibility.
This will be changed to :obj:`NotImplementedError` in v1.3.0.
"""


def _unsupported_type_error(
t: Any, explanation: Union[str, None] = None
) -> UnsupportedTypeError:
"""
Error for unsupported types, with optional explanation.
"""
msg = "Unsupported validation for type {t!r}."
if explanation is not None:
msg += " " + explanation
return UnsupportedTypeError(msg)


def _type_error(
val: Any, t: Any, *errors: TypeError, is_union: bool = False
) -> TypeError:
Expand Down Expand Up @@ -272,6 +296,13 @@ def _missing_keys_type_error(val: Any, t: Any, *missing_keys: Any) -> TypeError:
return error


def _subtype_error(s: Any, t: Any) -> TypeError:
validation_failure = SubtypeValidationFailure(s, t)
error = TypeError(str(validation_failure))
setattr(error, "validation_failure", validation_failure)
return error


def _type_alias_error(t_alias: str, cause: TypeError) -> TypeError:
"""
Repackages a validation error as a type alias error.
Expand Down Expand Up @@ -505,21 +536,88 @@ def _validate_typed_dict(val: Any, t: type) -> None:
except TypeError as e:
raise _key_type_error(val, t, e, key=k) from None


def _validate_user_class(val: Any, t: Any) -> None:
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 isinstance(val, TypeInspector):
if t.__origin__ is type:
if len(t.__args__) != 1 or not _can_validate_subtype_of(
t.__args__[0]
):
val._record_unsupported_type(t)
return
val._record_pending_type_generic(t.__origin__)
val._record_user_class(*t.__args__)
for arg in t.__args__:
validate(val, arg)
return
_validate_type(val, t.__origin__)
# Generic type arguments cannot be validated
if t.__origin__ is type:
if len(t.__args__) != 1:
raise _unsupported_type_error(t)
_validate_subtype_of(val, t.__args__[0])
return
# TODO: Generic type arguments cannot be validated in general,
# but in a future release it will be possible for classes to define
# a dunder classmethod which can be used to validate type arguments.

def __extract_member_types(u: Any) -> tuple[Any, ...]|None:
q = collections.deque([u])
member_types: list[Any] = []
while q:
t = q.popleft()
if t is Any:
return None
elif UnionType is not None and isinstance(t, UnionType):
q.extend(t.__args__)
elif hasattr(t, "__origin__") and t.__origin__ is Union:
q.extend(t.__args__)
else:
member_types.append(t)
return tuple(member_types)

def __check_can_validate_subtypes(*subtypes: Any) -> None:
for s in subtypes:
if not isinstance(s, type):
raise ValueError(
"validate(s, Type[t]) is only supported when 's' is "
"an instance of 'type' or a union of instances of 'type'.\n"
f"Found s = {'|'.join(str(s) for s in subtypes)}"
)

def __check_can_validate_supertypes(*supertypes: Any) -> None:
for t in supertypes:
if not isinstance(t, type):
raise ValueError(
"validate(s, Type[t]) is only supported when 't' is "
"an instance of 'type' or a union of instances of 'type'.\n"
f"Found t = {'|'.join(str(t) for t in supertypes)}"
)

def _can_validate_subtype_of(t: Any) -> bool:
try:
# This is the validation part of _validate_subtype:
t_member_types = __extract_member_types(t)
if t_member_types is not None:
__check_can_validate_supertypes(*t_member_types)
return True
except ValueError:
return False

def _validate_subtype_of(s: Any, t: Any) -> None:
# 1. Validation:
__check_can_validate_subtypes(s)
t_member_types = __extract_member_types(t)
if t_member_types is None:
# An Any was found amongst the member types, all good.
return
__check_can_validate_supertypes(*t_member_types)
# 2. Subtype check:
if not issubclass(s, t_member_types):
raise _subtype_error(s, t)
# TODO: improve support for subtype checks.

def _extract_dtypes(t: Any) -> typing.Sequence[Any]:
if t is Any:
Expand Down Expand Up @@ -575,8 +673,8 @@ def _validate_numpy_array(val: Any, t: Any) -> None:
if isinstance(val, TypeInspector):
val._record_unsupported_type(t)
return
raise ValueError(
f"Unsupported validation for NumPy dtype {repr(dtype_t)}."
raise _unsupported_type_error(
t, f"Unsupported NumPy dtype {dtype_t!r}"
) from None
if isinstance(val, TypeInspector):
val._record_pending_type_generic(t.__origin__)
Expand Down Expand Up @@ -669,21 +767,24 @@ def validate(val: Any, t: Any) -> Literal[True]:
:param t: the type to type-check against
:type t: :obj:`~typing.Any`
:raises TypeError: if ``val`` is not of type ``t``
:raises ValueError: if validation for type ``t`` is not supported
:raises UnsupportedTypeError: if validation for type ``t`` is not supported
:raises AssertionError: if things go unexpectedly wrong with ``__args__`` for parametric types
"""
# pylint: disable = too-many-return-statements, too-many-branches, too-many-statements
unsupported_type_error: Optional[ValueError] = None
unsupported_type_error: Optional[UnsupportedTypeError] = None
if not isinstance(t, Hashable):
if isinstance(val, TypeInspector):
val._record_unsupported_type(t)
return True
if unsupported_type_error is None:
unsupported_type_error = ValueError(
f"Unsupported validation for type {repr(t)}. Type is not hashable."
unsupported_type_error = _unsupported_type_error(
t, "Type is not hashable."
) # pragma: nocover
raise unsupported_type_error
if t is typing.Type:
# Replace non-generic 'Type' with non-generic 'type':
t = type
if t in _basic_types:
# speed things up for the likely most common case
_validate_type(val, typing.cast(type, t))
Expand Down Expand Up @@ -765,8 +866,8 @@ def validate(val: Any, t: Any) -> Literal[True]:
if isinstance(val, TypeInspector):
val._record_unsupported_type(t)
return True
unsupported_type_error = ValueError(
f"Unsupported validation for Protocol {repr(t)}, because it is not runtime-checkable."
unsupported_type_error = _unsupported_type_error(
t, "Protocol class is not runtime-checkable."
) # pragma: nocover
elif _is_typed_dict(t):
_validate_typed_dict(val, t)
Expand All @@ -788,8 +889,8 @@ def validate(val: Any, t: Any) -> Literal[True]:
hint = f"Perhaps set it with validation_aliases({t_alias}=...)?"
else:
hint = f"Perhaps set it with validation_aliases(**{{'{t_alias}': ...}})?"
unsupported_type_error = ValueError(
f"Type alias '{t_alias}' is not known. {hint}"
unsupported_type_error = _unsupported_type_error(
t_alias, f"Type alias is not known. {hint}"
) # pragma: nocover
else:
_validate_alias(val, t_alias)
Expand All @@ -798,15 +899,13 @@ def validate(val: Any, t: Any) -> Literal[True]:
val._record_unsupported_type(t)
return True
if unsupported_type_error is None:
unsupported_type_error = ValueError(
f"Unsupported validation for type {repr(t)}."
) # pragma: nocover
unsupported_type_error = _unsupported_type_error(t) # pragma: nocover
raise unsupported_type_error


def can_validate(t: Any) -> TypeInspector:
r"""
Checks whether validation is supported for the given type ``t``: if not, :func:`validate` will raise :obj:`ValueError`.
Checks whether validation is supported for the given type ``t``: if not, :func:`validate` will raise :obj:`UnsupportedTypeError`.
The returned :class:`TypeInspector` instance can be used wherever a boolean is expected, and will indicate whether the type is supported or not:
Expand Down
32 changes: 29 additions & 3 deletions typing_validation/validation_failure.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

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

if sys.version_info[1] >= 8:
from typing import Protocol
Expand Down Expand Up @@ -452,8 +452,34 @@ def __new__(

def _str_main_msg(self, type_quals: tuple[str, ...] = ()) -> str:
return (
f"For {self._str_type_descr(type_quals)} {repr(self.t)}, "
f"value is not valid for upper bound."
f"For {self._str_type_descr(type_quals)} {self.t!r}, "
f"value is not valid for upper bound: {self.val!r}"
)


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}"
)


Expand Down

0 comments on commit 2a11dea

Please sign in to comment.