Source code for pyfcstm.utils.validate

"""
Validation framework utilities for model validation workflows.

This module provides a lightweight validation framework for Python models. It
defines base exceptions for validation failures and a mixin-style interface
(:class:`IValidatable`) that allows classes to register validation rules and
perform aggregated validation.

The module contains the following main components:

* :class:`ValidationError` - Exception for a single validation rule failure
* :class:`ModelValidationError` - Aggregated exception for multiple failures
* :class:`IValidatable` - Mixin interface for defining and running validators

Example::

    >>> from pyfcstm.utils.validate import IValidatable, ValidationError
    >>>
    >>> class MyModel(IValidatable):
    ...     def __init__(self, value: int):
    ...         self.value = value
    ...
    ...     def _validate_positive(self) -> None:
    ...         if self.value <= 0:
    ...             raise ValidationError("Value must be positive.")
    ...
    ...     __validators__ = [_validate_positive]
    ...
    >>> model = MyModel(1)
    >>> model.validate()
    >>> bad_model = MyModel(0)
    >>> try:
    ...     bad_model.validate()
    ... except Exception as err:
    ...     print(type(err).__name__)
    ModelValidationError
"""

import os
from typing import Callable, List

from hbutils.string import plural_word


[docs] class ValidationError(Exception): """ Base exception class for validation errors. This exception should be raised when a single validation rule fails. It is designed to be caught and collected by :class:`IValidatable`. Example:: >>> raise ValidationError("Invalid value.") Traceback (most recent call last): ... ValidationError: Invalid value. """ pass
[docs] class ModelValidationError(Exception): """ Exception class for aggregating multiple validation errors. This exception contains a list of :class:`ValidationError` instances and formats them into a readable error message. :param errors: List of validation errors that occurred :type errors: List[ValidationError] :ivar errors: Stored validation errors :vartype errors: List[ValidationError] Example:: >>> err = ModelValidationError([ValidationError("A"), ValidationError("B")]) >>> "2 errors" in str(err) True """
[docs] def __init__(self, errors: List[ValidationError]) -> None: super().__init__( f"Model validation error, {plural_word(len(errors), 'error')} in total:{os.linesep}" f"{os.linesep.join(map(lambda x: f'{x[0]}. {x[1]}', enumerate(map(repr, errors), start=1)))}", ) self.errors = errors
[docs] class IValidatable: """ Interface class for implementing validatable objects. Classes inheriting from :class:`IValidatable` should define their validation rules in the :attr:`__validators__` class variable as a list of validator methods. Each validator should accept the instance as the sole parameter and raise :class:`ValidationError` if the rule fails. :cvar __validators__: List of validator functions to be applied :type __validators__: List[Callable[["IValidatable"], None]] Example:: >>> class MyModel(IValidatable): ... def _validate_non_empty(self) -> None: ... if not getattr(self, "value", None): ... raise ValidationError("Value is empty.") ... ... __validators__ = [_validate_non_empty] ... >>> model = MyModel() >>> try: ... model.validate() ... except ModelValidationError as err: ... isinstance(err.errors[0], ValidationError) True """ __validators__: List[Callable[["IValidatable"], None]] = [] def _validate_for_errors(self) -> List[ValidationError]: """ Execute all validators and collect validation errors. Each validator registered in :attr:`__validators__` is called with the current instance. Any :class:`ValidationError` raised is collected. :return: List of validation errors that occurred :rtype: List[ValidationError] """ errors: List[ValidationError] = [] for validator in self.__validators__: try: validator(self) except ValidationError as err: errors.append(err) return errors
[docs] def validate(self) -> None: """ Validate the object using all registered validators. :raises ModelValidationError: If any validation errors occur Example:: >>> class MyModel(IValidatable): ... def _validate(self) -> None: ... raise ValidationError("Always invalid.") ... ... __validators__ = [_validate] ... >>> try: ... MyModel().validate() ... except ModelValidationError as err: ... len(err.errors) 1 """ errors = self._validate_for_errors() if errors: raise ModelValidationError(errors)