Source code for dispel.data.validators

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