Source code for ccu.fancyplots.validation

"""Classes, protocols, and functions for data validation.

In particular, this module defines the :class:`Validator` and
:class:`Serializer` protocols that are used to validate user input from GUI
widgets and serialize Python values for display in GUI widgets, respectively.
Validators return a boolean indicating the validity of an input with the
option to convert the input to the desired type.

.. admonition:: Example:
    A simple validator that only accepts the number 5

    .. code-block:: python

        from ccu.fancyplots.validation import ValidationError

        def simple_validator(value: Any, validate_only: bool = True):
            valid value == 5

            if validate_only:
                return valid

            if valid:
                return 5

            raise ValidationError("The value is not 5")

        simple_validator(5)
        True

:func:`validator_from_type` creates simple wrappers
around any callable that can accept a string and return a converted value. For
simple types (e.g., str, int, float), creating a validator is as simple
as:

.. code-block:: python

    from ccu.fancyplots.validation import validator_from_type

    validator = validator_from_type(str)

For creating Validators from type hints, this module defines the convenience
function :func:`type_hint_to_validator`:

.. code-block:: python

    from ccu.fancyplots.validation import type_hint_to_validator

    validator = type_hint_to_validator(list[str] | None, "")
    validator("1")
    False
    validator("1", validate_only=False)
    ...ValidationError: Unable to validate the value
    validator("1,2,3")
    True
    validator("1,2,3", validator_only=False)
    [1, 2, 3]
    # ``ccu.fancyplots`` interprets empty GUI fields as unset, so for
    # convenience, "" is a valid None type
    validator("", validator_only=False)
    None

Note:
    Validation is only supported for primitive values,
    tuples/lists of primitive values, and unions of either two:

    .. code-block:: python

        union_type = str | None  # okay
        union_type = tuple[str]  # okay
        union_type = int  # okay
        union_type = tuple[float] | None  # okay
        union_type = list[str]  # okay
        union_type = tuple[tuple[str]]  # not okay
        union_type = tuple[int | str]  # not okay

Note:
    **The order that types are specified in the union matters.**

    Validation is tried in left-to-right order. That is, if the type hint
    is ``str | int``, then the validation first attempts to convert the value
    into a string and then into an integer. This is especially important for
    unions involving strings, since every object can be converted into a
    string.

Custom validators can be constructed using some of the other utility methods.
For example, with :func:`string_to_sequence`, one can do:

>>> from ccu.fancyplots.validation import (
...     string_to_sequence,
...     validator_from_type,
... )
>>> sequence_from_type = string_to_sequence(
...     element_type=int, sequence_type=tuple, delimiter="-"
... )
>>> tuple_validator = validator_from_type(sequence_from_type)
>>> tuple_validator("1-2-3")
True
>>> tuple_validator("This should return False")
False
>>> tuple_validator("1-2-3", validate_only=False)
(1, 2, 3)

Simple unions can be handled as well:

>>> from ccu.fancyplots.validation import (
...     string_to_union,
...     validator_from_type,
... )
>>> union_from_type = string_to_union(int | str)
>>> union_validator = validator_from_type(union_from_type)
>>> union_validator("1")
True
>>> union_validator("Okay")
True
>>> union_validator("This should return False")
False
>>> union_validator("1", validate_only=False)
1
>>> union_validator("Okay", validate_only=False)
True

This module also provides a convenience Validator and Serializer,
:func:`no_validation_validator` and :func:`default_serializer`, respectively.

The :func:`highlight_and_warn` function is a handler for when invalid text is
entered into a :class:`.ttk.Entry`.

.. seealso:: :class:`~ccu.fancyplots._gui.frames.FancyFormatFrame`
"""

from collections.abc import Callable
import logging
import tkinter as tk
from tkinter import messagebox
from tkinter import ttk
from types import UnionType
from typing import Any
from typing import Generic
from typing import Literal
from typing import Protocol
from typing import TypeVar
from typing import Union
from typing import get_args
from typing import get_origin
from typing import overload

logger = logging.getLogger(__name__)


[docs] def highlight_and_warn(event: tk.Event) -> None: """Highlight the entry containing invalid data and warn the user. Args: event: The triggering event. """ widget: ttk.Entry = event.widget widget.configure(style="Invalid.Fancy.TEntry") msg = f"'{widget.get()}' is not a valid value" logger.warning(msg) messagebox.showwarning("Number not recognized!", message=msg)
_T = TypeVar("_T")
[docs] class Validator(Protocol, Generic[_T]): """Callables which can validate and convert values. Adherents should raise a :class:`ValidationError` if `validate_only` is False and `value` is invalid. """ @overload def __call__(self, value: str, validate_only: Literal[False]) -> _T: ... def __call__(self, value: str, validate_only: bool = True) -> bool: """Validate a value. Args: value: The value to validate. validate_only: Whether to only check the value for validity or also convert the value to a valid type. """
[docs] class ValidationError(ValueError): """Invalid value."""
@overload def no_validation_validator( value: _T, validate_only: Literal[False] ) -> _T: ...
[docs] def no_validation_validator( value: _T, validate_only: bool = True ) -> bool | _T: """Always validate the value. Args: value: The value to validate. validate_only: Whether to only check the value for validity or also convert the value to a valid type. Returns: _description_ """ return validate_only or value
[docs] def validator_from_type(from_type: Callable[[str], _T]) -> Validator[_T]: """Create a :class:`Validator` from a type (or factory function). Args: from_type: A function that can accept a string as input and return an object of the desired type. Returns: A :class:`Validator` for objects of the type produced by `from_type`. """ @overload def _validator(value: str, validate_only: bool = False) -> _T: ... def _validator(value: str, validate_only: bool = True) -> bool: valid_value = False try: value = from_type(value) valid_value = True except (TypeError, ValueError) as err: msg = "Unable to validate the value" raise ValidationError(msg) from err finally: if validate_only: # Silence error if only validating return valid_value # noqa: B012 return value return _validator
Primitive = TypeVar("Primitive", Any, str, int, float, bool, None)
[docs] def string_to_primitive( type_hint: type[Primitive], ) -> Callable[[str], Primitive]: """Return a function that parses a string into a Python primitive. Note that the empty string is parsed as None. Args: type_hint: One of ``str``, ``int``, ``float``, ``bool``, or ``None``. Returns: A :class:`Validator` capable of validating Python primitives from strings. """ if type_hint is Any: return lambda x: no_validation_validator(x, False) if type_hint in (str, int, float): return lambda x: type_hint(x) if type_hint is bool: def _bool_from_type(value: str) -> bool: v = value.lower() if v in ("true", "false"): return v == "true" msg = f"Unable to validate {value!r} as bool" raise ValueError(msg) return _bool_from_type def _none_from_type(value: str) -> None: if value == "": return None msg = f"Unable to validate {value!r} as None" raise ValueError(msg) return _none_from_type
[docs] def string_to_sequence( element_type: Validator[_T], sequence_type: type[list] | type[tuple], delimiter: str = ",", ) -> Callable[[str], tuple[_T, ...]]: """Return a function that parses a string into a sequence. Args: element_type: The type into which elements of the sequence should be cast. sequence_type: The type of sequence to be constructed. Must be either ``list`` or ``tuple``. delimiter: A character used to split the string into a sequence. Returns: A function that can be supplied to :func:`validator_from_type` to produce a :class:`Validator`. """ def _func(value: str) -> sequence_type[_T]: return sequence_type( element_type(x, validate_only=False) for x in value.split(delimiter) ) return _func
[docs] def string_to_union( validators: list[Validator[_T]], ) -> Callable[[str], _T]: """Return a function that tries to validate/convert a string into a union. Args: validators: A :class:`ccu.fancyplots.validation.Validator` instances corresponding to the union. Returns: A Callable that can be used to convert a str into a member type of the union. """ def _func(value: str) -> _T: for validator in validators: try: return validator(value, validate_only=False) except (ValueError, TypeError): logger.info( f"Unable to validate value {value!r} with {validator!r}" ) msg = f"Unable to validate value {value!r}" raise ValidationError(msg) return _func
[docs] def type_hint_to_validator(type_hint: type[_T], label: str) -> Validator[_T]: """Return a :class:`Validator` from a type hint. Args: type_hint: A bare (no annotations) type hint such as ``str``, ``int``, ``None``, ``tuple[int]``, ``int | None``. label: The name of the parameter to which the type hint belongs. Returns: A :class:`Validator` capable of validating a value against the type hint provided. Examples: >>> from ccu.fancyplots.validation import type_hint_to_validator >>> validator = type_hint_to_validator(int | str, "") >>> validator("1") True >>> validator("Okay") True >>> validator("This should return False") True >>> validator("1", validate_only=False) 1 >>> validator("Okay", validate_only=False) "Okay" """ origin = get_origin(type_hint) if origin is None: # primitive type: int, str, float, bool from_type: Validator[type_hint] = string_to_primitive(type_hint) elif origin is Union or origin is UnionType: hints = get_args(type_hint) validators = [] for hint in hints: validator = type_hint_to_validator(hint, "") validators.append(validator) from_type = string_to_union(validators) elif issubclass(origin, list | tuple): arg_type = get_args(type_hint)[0] element_type = type_hint_to_validator(arg_type, "") from_type = string_to_sequence(element_type, origin) else: msg = "Unsupported annotation type {0} for parameter {1}" raise NotImplementedError(msg.format(origin, label)) return validator_from_type(from_type)
[docs] class Serializer(Protocol, Generic[_T]): """Serialize a value.""" def __call__(self, value: _T) -> str: """Serialize a value. Args: value: The value to serialize. Returns: A string representation of the value. """
[docs] def default_serializer(value: _T, *, delimiter: str = ",") -> str: """Serialize values as strings. Args: value: The value to be serialized. delimiter: A character to be used to delimit list values. Returns: A string representation of ``value``. """ if isinstance(value, list | tuple): return delimiter.join(str(x) for x in value) if value is None: return "" return str(value)