Source code for argclz.validator

from __future__ import annotations

import re
from collections.abc import Callable
from pathlib import Path
from types import EllipsisType
from typing import Any, TypeVar, Generic, final, overload, Collection, cast

from typing_extensions import Self

T = TypeVar('T')


[docs] class ValidatorFailError(ValueError): """ A special ValueError used in this module. """ pass
[docs] class ValidatorFailOnTypeError(ValidatorFailError): """ A special ValidatorFailError that is raised when type validation failure. It is used for :meth:`~argclz.validator.ValidatorBuilder.any()` to exclude some error message. """ pass
[docs] class Validator:
[docs] def __call__(self, value: Any) -> bool: """ :param value: type-casted value. :return: True if *value* pass the validation. :raise ValueError: when *value* does not pass the validation. """ return True
[docs] def freeze(self) -> Self: """(internal use) return a copy of itself.""" return self
# TODO add feature? auto-casting, for example: (str|Path)->Path
[docs] class LambdaValidator(Validator, Generic[T]): """ A simple validator that carries a failure message. """
[docs] def __init__(self, validator: Callable[[T], bool], message: str | Callable[[T], str] | None = None): """ :param validator: callable :param message: failure message. It could be a str message that contains one %-formating expression (for example: '%s'), or a callable ``(T)->str``. """ if isinstance(message, str): message = message.__mod__ self._validator = validator self._message = message
[docs] def __call__(self, value: T) -> bool: message = self._message try: success = self._validator(value) except ValidatorFailError: raise except BaseException as e: if message is None: raise ValidatorFailError('validate failure') from e else: raise ValidatorFailError(message(value)) from e else: if success is None or success: return True elif message is None: return False else: raise ValidatorFailError(message(value))
def __and__(self, validator: Callable[[Any], bool]) -> AndValidatorBuilder: """``validator & validator``""" return AndValidatorBuilder(self) & validator def __or__(self, validator: Callable[[Any], bool]) -> OrValidatorBuilder: """``validator | validator``""" return OrValidatorBuilder(self) | validator
[docs] def freeze(self) -> Self: if isinstance(validator := self._validator, Validator): validator = validator.freeze() return cast(Self, LambdaValidator(validator, self._message))
[docs] @final class ValidatorBuilder: @property def str(self) -> StrValidatorBuilder: """a str validator""" return StrValidatorBuilder() @property def int(self) -> IntValidatorBuilder: """a int validator""" return IntValidatorBuilder() @property def float(self) -> FloatValidatorBuilder: """a float validator""" return FloatValidatorBuilder() @overload def tuple(self, *element_type: int | EllipsisType) -> TupleValidatorBuilder: pass @overload def tuple(self, *element_type: type[T] | EllipsisType | None) -> TupleValidatorBuilder: pass # noinspection PyMethodMayBeStatic
[docs] def tuple(self, *element_type) -> TupleValidatorBuilder: """a tuple validator overloading element_type example: * ``2``: 2-length tuple * ``type1, type2``: 2-length tuple with type1 at pos 0 and type2 at pos 1. * ``type1, None``: 2-length tuple with type1 at pos 0 and any type at pos 1. * ``type1, ...``: at-least-1-length tuple with type1 from pos 0 to remaining pos. """ return TupleValidatorBuilder(element_type)
# noinspection PyMethodMayBeStatic
[docs] def list(self, element_type: type[T] | Validator | None = None) -> ListValidatorBuilder: """ a list validator :param element_type: element type. :return: """ return ListValidatorBuilder(element_type)
@property def path(self): """a path validator""" return PathValidatorBuilder()
[docs] @classmethod def all(cls, *validator: Callable[[T], bool]) -> AndValidatorBuilder: """ return a validator that ensure all combined *validator* are satisfied. return an always-true validator if *validator* is empty. """ return AndValidatorBuilder(*validator)
[docs] @classmethod def any(cls, *validator: Callable[[T], bool]) -> OrValidatorBuilder: """ return a validator that ensure at least one combined validator is satisfied. return an always-true validator if *validator* is empty. """ return OrValidatorBuilder(*validator)
[docs] @classmethod def optional(cls) -> Validator: """ return a validator that pass the validation when the value is ``None``. """ return LambdaValidator(lambda it: it is None)
[docs] @classmethod def non_none(cls) -> Validator: return LambdaValidator(lambda it: it is not None)
[docs] def __call__(self, validator: Callable[[Any], bool], message: str | Callable[[Any], str] | None = None) -> LambdaValidator: """ Create a validator with a failure message. :param validator: callable :param message: failure message. It could be a str message that contains one %-formating expression (for example: '%s'), or a callable ``(T)->str``. :return: a validator """ return LambdaValidator(validator, message)
[docs] class AbstractTypeValidatorBuilder(Validator, Generic[T]):
[docs] def __init__(self, value_type: type[T] | tuple[type[T], ...] | None = None): self._value_type = value_type self._validators: list[LambdaValidator[T]] = [] self._allow_none = False
def __call__(self, value: Any) -> bool: if value is None: if self._allow_none: return True else: raise ValidatorFailError('None') # noinspection PyTypeHints if self._value_type is not None and not isinstance(value, self._value_type): vt = self._value_type vt_name = vt.__name__ if isinstance(vt, type) else str(vt) raise ValidatorFailOnTypeError(f'not instance of {vt_name} : {value}') for validator in self._validators: if not validator(value): return False return True
[docs] def freeze(self, *args, **kwargs) -> Self: ret = type(self)(*args, **kwargs) ret._validators = [it.freeze() for it in self._validators] ret._allow_none = self._allow_none return ret
@overload def _add(self, validator: LambdaValidator[T]) -> None: pass @overload def _add(self, validator: Callable[[T], bool], message: str | Callable[[T], str] | None = None) -> None: pass def _add(self, validator, message=None): if not isinstance(validator, LambdaValidator): validator = LambdaValidator(validator, message) self._validators.append(validator)
[docs] def optional(self) -> Self: self._allow_none = True return self
[docs] def __and__(self, validator: Callable[[Any], bool]) -> AndValidatorBuilder: """``validator & validator``""" return AndValidatorBuilder(self) & validator
[docs] def __or__(self, validator: Callable[[Any], bool]) -> OrValidatorBuilder: """``validator | validator``""" return OrValidatorBuilder(self) | validator
[docs] class StrValidatorBuilder(AbstractTypeValidatorBuilder[str]): """a str validator"""
[docs] def __init__(self): super().__init__(str)
[docs] def length_in_range(self, a: int | None, b: int | None, /) -> StrValidatorBuilder: """Enforce a string length range""" match (a, b): case (int(a), None): self._add(lambda it: a <= len(it), f'str length less than {a}: "%s"') case (None, int(b)): self._add(lambda it: len(it) <= b, f'str length over {b}: "%s"') case (int(a), int(b)): self._add(lambda it: a <= len(it) <= b, f'str length out of range [{a}, {b}]: "%s"') case _: raise TypeError() return self
[docs] def match(self, r: str | re.Pattern) -> StrValidatorBuilder: """Check if string matches a regular expression""" if isinstance(r, str): r = re.compile(r) self._add(lambda it: r.match(it) is not None, f'str does not match to {r.pattern} : "%s"') return self
[docs] def starts_with(self, prefix: str) -> StrValidatorBuilder: """Check if string values start with a substring""" self._add(lambda it: it.startswith(prefix), f'str does not start with {prefix}: "%s"') return self
[docs] def ends_with(self, suffix: str) -> StrValidatorBuilder: """Check if string values end with a substring""" self._add(lambda it: it.endswith(suffix), f'str does not end with {suffix}: "%s"') return self
[docs] def contains(self, *texts: str) -> StrValidatorBuilder: """Check if string values contain a substring""" if len(texts) == 0: raise ValueError('empty text list') self._add(lambda it: any([text in it for text in texts]), f'str does not contain one of {texts}: "%s"') return self
[docs] def one_of(self, options: Collection[str]) -> StrValidatorBuilder: """Check if string is one of the allow options""" self._add(lambda it: it in options, f'str not in allowed set {options}: "%s"') return self
[docs] class IntValidatorBuilder(AbstractTypeValidatorBuilder[int]): """a int validator"""
[docs] def __init__(self): super().__init__(int)
[docs] def in_range(self, a: int | None, b: int | None, /) -> IntValidatorBuilder: """Enforce a numeric range for int values""" match (a, b): case (int(a), None): self._add(lambda it: a <= it, f'value less than {a}: %d') case (None, int(b)): self._add(lambda it: it <= b, f'value over {b}: %d') case (int(a), int(b)): self._add(lambda it: a <= it <= b, f'value out of range [{a}, {b}]: %d') case _: raise TypeError() return self
[docs] def positive(self, include_zero=True): """Check if an int value is positive or non-negative""" if include_zero: self._add(lambda it: it >= 0, 'not a non-negative value : %d') else: self._add(lambda it: it > 0, 'not a positive value : %d') return self
[docs] def negative(self, include_zero=True): """Check if an int value is negative or non-positive.""" if include_zero: self._add(lambda it: it <= 0, 'not a non-positive value : %d') else: self._add(lambda it: it < 0, 'not a negative value : %d') return self
[docs] class FloatValidatorBuilder(AbstractTypeValidatorBuilder[float]): """a float validator"""
[docs] def __init__(self): super().__init__((int, float)) self.__allow_nan = False
[docs] def freeze(self) -> Self: ret = super().freeze() ret.__allow_nan = self.__allow_nan return ret
[docs] def in_range(self, a: float | None, b: float | None, /) -> Self: """Enforce an open-interval numeric range (a < value < b)""" a = None if a is None else float(a) if isinstance(a, (int, float)) else a b = None if b is None else float(b) if isinstance(b, (int, float)) else b match (a, b): case (float(a), None): self._add(lambda it: a < it, f'value less than {a}: %f') case (None, float(b)): self._add(lambda it: it < b, f'value over {b}: %f') case (float(a), float(b)): self._add(lambda it: a < it < b, f'value out of range ({a}, {b}): %f') case _: raise TypeError() return self
[docs] def in_range_closed(self, a: float | None, b: float | None, /) -> Self: """ Enforce a closed-interval numeric range (a <= value <= b)""" a = None if a is None else float(a) if isinstance(a, (int, float)) else a b = None if b is None else float(b) if isinstance(b, (int, float)) else b match (a, b): case (float(a), None): self._add(lambda it: a <= it, f'value less than {a}: %f') case (None, float(b)): self._add(lambda it: it <= b, f'value over {b}: %f') case (float(a), float(b)): self._add(lambda it: a <= it <= b, f'value out of range [{a}, {b}]: %f') case _: raise TypeError() return self
[docs] def allow_nan(self, allow: bool = True) -> Self: """Allow or disallow NaN (not a number) as a valid float""" self.__allow_nan = allow return self
[docs] def positive(self, include_zero=True) -> Self: """Check if a float value is positive or non-negative""" if include_zero: self._add(lambda it: it >= 0, 'not a non-negative value: %f') else: self._add(lambda it: it > 0, 'not a positive value: %f') return self
[docs] def negative(self, include_zero=True) -> Self: """Check if a float value is negative or non-positive""" if include_zero: self._add(lambda it: it <= 0, 'not a non-positive value : %f') else: self._add(lambda it: it < 0, 'not a negative value : %f') return self
[docs] def __call__(self, value: Any) -> bool: if value != value: # is NaN if self.__allow_nan: return True else: raise ValidatorFailError('NaN') return super().__call__(value)
[docs] class ListValidatorBuilder(AbstractTypeValidatorBuilder[list[T]]): """a list validator"""
[docs] def __init__(self, element_type: type[T] | Validator | None = None): super().__init__() self._element_type = element_type self._allow_empty = True
[docs] def freeze(self) -> Self: ret = super().freeze() ret._element_type = self._element_type ret._allow_empty = self._allow_empty return ret
[docs] def length_in_range(self, a: int | None, b: int | None, /) -> Self: """Enforce a length range for lists""" match (a, b): case (int(a), None): self._add(lambda it: a <= len(it), lambda it: f'list length less than {a}: {len(it)}') case (None, int(b)): self._add(lambda it: len(it) <= b, lambda it: f'list length over {b}: {len(it)}') case (int(a), int(b)): self._add(lambda it: a <= len(it) <= b, lambda it: f'list length out of range [{a}, {b}]: {len(it)}') case _: raise TypeError() return self
[docs] def allow_empty(self, allow: bool = True) -> Self: """Allow or disallow empty lists""" self._allow_empty = allow return self
[docs] def on_item(self, validator: Callable[[Any], bool]) -> Self: """Apply an additional validator to each item in the list :param validator: A callable that validates each item """ self._add(ListItemValidatorBuilder(validator)) return self
[docs] def __call__(self, value: Any) -> bool: if not isinstance(value, (tuple, list)): raise ValidatorFailOnTypeError(f'not a list : {value}') if not self._allow_empty and len(value) == 0: raise ValidatorFailError(f'empty list : {value}') if (element_type := self._element_type) is not None: for i, element in enumerate(value): if not element_isinstance(element, element_type): raise ValidatorFailError(f'wrong element type at {i} : {element}') return super().__call__(value)
[docs] class TupleValidatorBuilder(AbstractTypeValidatorBuilder[tuple]): """a tuple validator"""
[docs] def __init__(self, element_type: tuple[Any, ...]): super().__init__() _element_type: tuple[Any, ...] match element_type: case (): # XXX does validate for empty tuple meaningful? # No, so it will be interpreted as ... _element_type = (...,) case (int(length), ): _element_type = (None,) * length case (int(length), e) if e is ...: _element_type = (None,) * length + (...,) case _: _element_type = element_type self._element_type: tuple[Any, ...] = _element_type
[docs] def freeze(self) -> Self: return super().freeze(self._element_type)
[docs] def on_item(self, item: int | list[int] | None, validator: Callable[[Any], bool]) -> Self: """Apply a validator to specific tuple positions :param item: A single index, a list of indices, or None for all indices :param validator: The validation callable to apply """ self._add(TupleItemValidatorBuilder(item, validator)) return self
[docs] def __call__(self, value: Any) -> bool: if not isinstance(value, tuple): raise ValidatorFailOnTypeError(f'not a tuple : {value}') if len(element_type := self._element_type) > 0: if element_type[-1] is ...: at_least_length = len(element_type) - 1 if len(value) < at_least_length: raise ValidatorFailError(f'length less than {at_least_length} : {value}') for i, e, t in zip(range(at_least_length), value, element_type): if t is not None and not element_isinstance(e, t): raise ValidatorFailError(f'wrong element type at {i} : {e}') if at_least_length > 0: if (last_element_type := element_type[at_least_length - 1]) is not None: for i, e in zip(range(at_least_length, len(value)), value[at_least_length:]): if not element_isinstance(e, last_element_type): raise ValidatorFailError(f'wrong element type at {i} : {e}') else: if len(value) != len(element_type): raise ValidatorFailError(f'length not match to {len(element_type)} : {value}') for i, e, t in zip(range(len(element_type)), value, element_type): if t is not None and not element_isinstance(e, t): raise ValidatorFailError(f'wrong element type at {i} : {e}') return super().__call__(value)
[docs] class PathValidatorBuilder(AbstractTypeValidatorBuilder[Path]): """a path validator"""
[docs] def __init__(self): super().__init__(Path)
[docs] def is_suffix(self, suffix: str | list[str] | tuple[str, ...]) -> Self: """Check path suffix or in a list of suffixes""" if isinstance(suffix, str): self._add(lambda it: it.suffix == suffix, f'suffix != {suffix}: %s') elif isinstance(suffix, (list, tuple)): self._add(lambda it: it.suffix in suffix, f'suffix not in {suffix}: %s') else: raise TypeError('') return self
[docs] def is_exists(self) -> Self: """Check if path exists""" self._add(lambda it: it.exists(), f'path does not exist: %s') return self
[docs] def is_file(self) -> Self: """Check if path is a file""" self._add(lambda it: it.is_file(), f'path is not a file: %s') return self
[docs] def is_dir(self) -> Self: """Check if path is a directory""" self._add(lambda it: it.is_dir(), f'path is not a directory: %s') return self
[docs] class ListItemValidatorBuilder(LambdaValidator):
[docs] def __call__(self, value: Any) -> bool: for i, element in enumerate(value): try: fail = not super().__call__(element) except BaseException as e: raise ValidatorFailError(f'at index {i}, ' + e.args[0]) from e else: if fail: raise ValidatorFailError(f'at index {i}, validate fail : {value}') return True
def _on_element(self, value: Any) -> bool: return super().__call__(value)
[docs] def freeze(self) -> Self: if isinstance(validator := self._validator, Validator): validator = validator.freeze() return cast(Self, ListItemValidatorBuilder(validator, self._message))
[docs] class TupleItemValidatorBuilder(LambdaValidator):
[docs] def __init__(self, item: int | list[int] | None, validator: Callable[[Any], bool]): super().__init__(validator) self._item = item
[docs] def __call__(self, value: Any) -> bool: if self._item is None: for index in range(len(value)): if not self.__call_on_index__(index, value): return False return True elif isinstance(self._item, int): return self.__call_on_index__(self._item, value) else: for index in self._item: if not self.__call_on_index__(index, value): return False return True
def __call_on_index__(self, index: int, value: Any) -> bool: try: element = value[index] except IndexError as e: raise ValidatorFailError(f'index {index} out of size {len(value)}') from e try: return super().__call__(element) except BaseException as e: raise ValidatorFailError(f'at index {index}, ' + e.args[0]) from e
[docs] def freeze(self) -> Self: if isinstance(validator := self._validator, Validator): validator = validator.freeze() return cast(Self, TupleItemValidatorBuilder(self._item, validator))
[docs] class OrValidatorBuilder(Validator):
[docs] def __init__(self, *validator: Callable[[Any], bool]): self.__validators = [it.freeze() if isinstance(it, Validator) else it for it in validator]
[docs] def __call__(self, value: Any) -> bool: if len(self.__validators) == 0: return True coll = [] for validator in self.__validators: try: if validator(value): return True except ValidatorFailOnTypeError: pass except BaseException as e: if len(e.args): coll.append(e.args[0]) raise ValidatorFailError('; '.join(coll))
[docs] def freeze(self) -> Self: return cast(Self, OrValidatorBuilder(*self.__validators))
def __and__(self, validator: Callable[[Any], bool]) -> AndValidatorBuilder: return AndValidatorBuilder(self, validator) def __or__(self, validator: Callable[[Any], bool]) -> OrValidatorBuilder: if isinstance(validator, OrValidatorBuilder): self.__validators.extend(validator.__validators) else: self.__validators.append(validator) return self
[docs] class AndValidatorBuilder(Validator):
[docs] def __init__(self, *validator: Callable[[Any], bool]): self.__validators = [it.freeze() if isinstance(it, Validator) else it for it in validator]
[docs] def __call__(self, value: Any) -> bool: if len(self.__validators) == 0: return True for validator in self.__validators: if not validator(value): raise ValidatorFailError() return True
[docs] def freeze(self) -> Self: return cast(Self, AndValidatorBuilder(*self.__validators))
def __and__(self, validator: Callable[[Any], bool]) -> AndValidatorBuilder: if isinstance(validator, AndValidatorBuilder): self.__validators.extend(validator.__validators) else: self.__validators.append(validator) return self def __or__(self, validator: Callable[[Any], bool]) -> OrValidatorBuilder: return OrValidatorBuilder(self, validator)
def element_isinstance(e, t) -> bool: if isinstance(t, type): return isinstance(e, t) if t is Any: return True if callable(t): try: return True if t(e) else False except TypeError: return False print(f'NotImplementedError(element_isinstance(..., {t}))') return False