"""A module containing value validators to ensure conformity of values.
The following validators are implemented:
- :class:`RangeValidator` to ensure values are within a given range
- :class:`SetValidator` to ensure values are one of a set of allowed values
As some validators are very common, hence there are constants for convenience:
- :attr:`GREATER_THAN_ZERO` to allow values greater or equal than zero
"""
import operator
from typing import Any, Dict, List, Optional, Union
[docs]
class ValidationException(Exception):
    """An exception risen if a value didn't match the validator's expectations.
    Parameters
    ----------
    message
        Explanation of the error
    value
        The observed value
    validator
        The validator that rose the exception
    """
[docs]
    def __init__(self, message, value, validator):
        super().__init__(message)
        self.value = value
        self.validator = validator 
 
[docs]
class RangeValidator:
    r"""A range validator.
    The range validator can be used to ensure a measure value is within a given range.
    It is specified via the ``validator`` argument when creating
    :class:`~dispel.data.values.ValueDefinition`\ s.
    Examples
    --------
    To create a range validator that allows all values between ``-4`` and ``7`` one can
    use it the following way:
    .. testsetup:: validator
        >>> from dispel.data.validators import RangeValidator, SetValidator, \
        ...     ValidationException
    .. doctest:: validator
        >>> validator = RangeValidator(-4, 7)
        >>> validator
        <RangeValidator: [-4, 7]>
        >>> validator(5)
    When called the range validator will raise an except only if the value is outside
    its range:
    .. doctest:: validator
        >>> validator(10)
        Traceback (most recent call last):
         ...
        dispel.data.validators.ValidationException: Value violated upper bound
        [-4, 7]: 10
    The range validator can also be used with just one side by specifying only the lower
    or upper bound:
    .. doctest:: validator
        >>> RangeValidator(lower_bound=-4)
        <RangeValidator: [-4, ∞]>
        >>> RangeValidator(upper_bound=7)
        <RangeValidator: [-∞, 7]>
    To exclude the boundaries in the range, one can set ``include_lower`` or
    ``include_upper`` to ``False``:
    .. doctest:: validator
        >>> validator = RangeValidator(lower_bound=0, include_lower=False)
        >>> validator
        <RangeValidator: (0, ∞]>
        >>> validator(0)
        Traceback (most recent call last):
         ...
        dispel.data.validators.ValidationException: Value violated lower bound
        (0, ∞]: 0
    Attributes
    ----------
    lower_bound
        The lower bound of the range validator.
    upper_bound
        The upper bound of the range validator.
    include_lower
        Include the lower boundary in the range check.
    include_upper
        Include the upper boundary in the range check.
    """
[docs]
    def __init__(
        self,
        lower_bound: Optional[float] = None,
        upper_bound: Optional[float] = None,
        include_lower: bool = True,
        include_upper: bool = True,
    ):
        # Check that bounds are valid
        if lower_bound and upper_bound and lower_bound >= upper_bound:
            raise ValueError(
                "The validator range values have to be strictly increasing."
            )
        if lower_bound is None and upper_bound is None:
            raise ValueError("At least one bound needs to be specified.")
        self.lower_bound = lower_bound
        self.upper_bound = upper_bound
        self.include_lower = include_lower
        self.include_upper = include_upper 
    def _raise_exception(self, boundary: str, value: Any):
        msg = f"Value violated {boundary} bound {self.repr_boundaries()}: " f"{value}"
        raise ValidationException(msg, value, self)
    def __call__(self, value: Any):
        """Validate a value if it complies with bounds.
        Parameters
        ----------
        value
            The value to be validated
        """
        lower_op = operator.lt if self.include_lower else operator.le
        if self.lower_bound is not None and lower_op(value, self.lower_bound):
            self._raise_exception("lower", value)
        upper_op = operator.gt if self.include_upper else operator.ge
        if self.upper_bound is not None and upper_op(value, self.upper_bound):
            self._raise_exception("upper", value)
[docs]
    def repr_boundaries(self):
        """Get a representation of the boundaries."""
        lower = "[" if self.include_lower else "("
        lower += "-∞" if self.lower_bound is None else str(self.lower_bound)
        upper = "∞" if self.upper_bound is None else str(self.upper_bound)
        upper += "]" if self.include_upper else ")"
        return f"{lower}, {upper}" 
    def __repr__(self):
        return f"<RangeValidator: {self.repr_boundaries()}>"
    def __eq__(self, other):
        if isinstance(other, self.__class__):
            return (
                self.lower_bound == other.lower_bound
                and self.upper_bound == other.upper_bound
                and self.include_lower == other.include_lower
                and self.include_upper == other.include_upper
            )
        return False
    def __hash__(self) -> int:
        return hash(
            (
                self.lower_bound,
                self.upper_bound,
                self.include_lower,
                self.include_upper,
            )
        ) 
#: A validator that ensures values are greater or equal than zero
GREATER_THAN_ZERO = RangeValidator(lower_bound=0)
#: A validator that ensures values are between zero and one
BETWEEN_ZERO_AND_ONE = RangeValidator(lower_bound=0, upper_bound=1)
#: A validator that ensures values are between minus one and one
BETWEEN_MINUS_ONE_AND_ONE = RangeValidator(lower_bound=-1, upper_bound=1)
[docs]
class SetValidator:
    r"""A set validator.
    The set validator can be used to ensure that a value is within a particular set of
    values. It is specified via the ``validator`` argument when creating
    :class:`~dispel.data.values.ValueDefinition`\ s.
    Examples
    --------
    The most common application of the :class:`SetValidator` is to validate survey
    responses and to provide additional labels for numerical responses. Assuming you
    have a survey that has possible responses ranging from ``1`` to ``4`` and you want
    to provide the respective labels to those responses you can achieve this in the
    following way:
    .. doctest:: validators
        >>> validator = SetValidator({
        ...    1: 'Not at all',
        ...    2: 'A little',
        ...    3: 'Moderately',
        ...    4: 'Extremely'
        ... })
        >>> validator
        <SetValidator: {1: 'Not at all', 2: 'A little', 3: 'Moderately', ...}>
        >>> validator(1)
    When calling the validator with a value not being part of the ``allowed_values`` the
    validator will raise an exception:
    .. doctest:: validator
        >>> validator(0)
        Traceback (most recent call last):
          ...
        dispel.data.validators.ValidationException: Value must be one of: {1, ...}
    If there are no labels for values one can simply pass a list of unique values to the
    validator:
    .. doctest:: validator
        >>> validator = SetValidator([1, 2, 3, 4])
        >>> validator
        <SetValidator: {1, 2, 3, 4}>
    Attributes
    ----------
    allowed_values
        The allowed values by the validator. Any value within this set will pass the
        validator.
    labels
        The labels for the allowed values. To get a label for a specific value consider
        using :meth:`~SetValidator.get_label`. Labels for values are specified by
        providing a dictionary with allowed values as keys and labels as values, e.g.
        >>> from dispel.data.validators import SetValidator
        >>> validator = SetValidator({1: 'label for 1', 2: 'label for two'})
    """
[docs]
    def __init__(
        self,
        values: Union[List[Any], Dict[Any, str]],
    ):
        if isinstance(values, list):
            self.allowed_values = set(values)
            if len(self.allowed_values) != len(values):
                raise ValueError("Values must be unique")
            self.labels = None
        elif isinstance(values, dict):
            self.allowed_values = set(values.keys())
            self.labels = values.copy()
        else:
            raise ValueError(
                f"values must be a list of allowed values or dictionary of allowed "
                f"values as keys and values as labels. Got: {values}"
            ) 
    def __call__(self, value: Any):
        """Validate a value if it is within a set.
        Parameters
        ----------
        value
            The value to be validated
        Raises
        ------
        ValidationException
            If the value is not present in the set.
        """
        if value not in self.allowed_values:
            raise ValidationException(
                f"Value must be one of: {self.allowed_values}", value, self
            )
    def __eq__(self, other):
        if isinstance(other, self.__class__):
            return (
                self.allowed_values == other.allowed_values
                and self.labels == other.labels
            )
        return False
    def __hash__(self) -> int:
        return hash(self.__dict__.values())
[docs]
    def get_label(self, value: Any) -> Optional[str]:
        """Get the label for the specified value.
        Parameters
        ----------
        value
            The value for which to get the label for
        Returns
        -------
        str
            The label for the specified ``value``.
        Raises
        ------
        KeyError
            If the value is not part of the allowed values.
        """
        if not self.labels:
            return None
        if value not in self.allowed_values:
            raise KeyError(
                f"Value is not part of allowed values: {self.allowed_values}"
            )
        return self.labels[value] 
    def __repr__(self):
        if self.labels:
            res = repr(self.labels)
        else:
            res = repr(self.allowed_values)
        return f"<SetValidator: {res}>"