HEX
Server: Apache/2.4.46 (Win64) OpenSSL/1.1.1j PHP/8.4.25
System: Windows NT DESKTOP-4TAV2RJ 10.0 build 19045 (Windows 10) AMD64
User: fred (0)
PHP: 8.4.25
Disabled: NONE
Upload Files
File: C:/Users/fred/anaconda3/Lib/site-packages/anaconda_navigator/api/external_apps/validation.py
# -*- coding: utf-8 -*-

"""Utilities for common validation tasks."""

from __future__ import annotations

__all__ = [
    # item checkers base
    'Base', 'Checker', 'Converter',

    # composite checkers
    'Composite', 'AllOf', 'AnyOf', 'OneOf',

    # basic checkers
    'IsAny', 'IsEmptyMapping', 'OfChoices', 'OfLength', 'OfType',

    # helper iterators
    'iterable_items', 'mapping_items',

    # checkers for collections
    'CollectionChecker', 'EachPair', 'EachValue', 'FindValue',

    # item operations
    'ItemMixin', 'ItemGetter', 'ItemPopper',

    # helper context managers
    'catch_exception', 'validation_to_warning',

    # shortcuts
    'sequence_of', 'IS_NONE', 'IS_BOOL', 'IS_INT', 'IS_FLOAT', 'IS_STR', 'IS_OPT_BOOL', 'IS_OPT_INT', 'IS_OPT_FLOAT',
    'IS_OPT_STR', 'IS_SEQ', 'IS_MAP',
]

import itertools

import abc
import contextlib
import typing

from anaconda_navigator.utils.logs import logger
from . import exceptions


T = typing.TypeVar('T')
KeyT = typing.TypeVar('KeyT', str, int)

EMPTY: typing.Any = object()


# ╠═══════════════════════════════════════════════════════════════════════════════════════════╡ item checkers base ╞═══╣

class Base(metaclass=abc.ABCMeta):
    """Base for all value checkers."""

    __slots__ = ()

    def convert(self, *args: typing.Callable[[typing.Any], typing.Any]) -> 'Converter':
        """
        Chain a conversion after the primary check passes.

        This might also be used for chaining other checks, especially for checkers which return custom value that should
        be validated further.
        """
        return Converter(self, *args)

    def _field(self) -> exceptions.Field:
        """Field, for which value is returned from current value checker."""
        return ()

    def __and__(self: 'Base', other: 'Base') -> 'AllOf':
        """Require both conditions."""
        return AllOf(self, other)

    @abc.abstractmethod
    def __call__(self, value: typing.Any, *, field: exceptions.Field = ()) -> typing.Any:
        """
        Perform check of the value.

        Returns the validated and (if applicable) converted value.
        """

    def __or__(self, other: Base) -> 'AnyOf':
        """Require at least one of conditions."""
        return AnyOf(self, other)

    def __xor__(self, other: Base) -> 'OneOf':
        """Require exactly one of conditions."""
        return OneOf(self, other)


class Checker(Base, metaclass=abc.ABCMeta):
    """Value checker used for value validation."""

    __slots__ = ()

    @abc.abstractmethod
    def _validate(self, value: typing.Any) -> None:
        """Perform validation of the `value`."""

    def __call__(self, value: typing.Any, *, field: exceptions.Field = ()) -> typing.Any:
        """Perform check of the value."""
        with exceptions.ValidationError.with_field(field):
            try:
                self._validate(value)
            except exceptions.ValidationError:
                raise
            except BaseException:
                raise exceptions.ValidationError('unable to validate value') from None
        return value


class Converter(Base):
    """
    Value checker used for execution of chained conversions.

    :param parent: Parent value checker, which value should be modified.
    :param args: Value conversion functions in order they should be executed.
    """

    __slots__ = ('__args', '__parent')

    def __init__(self, parent: Base, *args: typing.Callable[[typing.Any], typing.Any]) -> None:
        """Initialize new :class:`~Converter` instance."""
        while isinstance(parent, Converter):
            args = parent.args + args
            parent = parent.parent

        self.__args: typing.Final[typing.Tuple[typing.Callable[[typing.Any], typing.Any], ...]] = args
        self.__parent: typing.Final[Base] = parent

    @property
    def args(self) -> typing.Tuple[typing.Callable[[typing.Any], typing.Any], ...]:  # noqa: D401
        """Value conversion functions."""
        return self.__args

    @property
    def parent(self) -> Base:  # noqa: D401
        """Parent checker, which value should be converted."""
        return self.__parent

    def _field(self) -> exceptions.Field:
        """Field, for which value is returned from current value checker."""
        return self.parent._field()  # pylint: disable=protected-access

    def __call__(self, value: typing.Any, *, field: exceptions.Field = ()) -> typing.Any:
        """Perform check of the value."""
        with exceptions.ValidationError.with_field(field):
            result: typing.Any = self.parent(value)
            with exceptions.ValidationError.with_field(self.parent._field()):
                arg: typing.Callable[[typing.Any], typing.Any]
                for arg in self.args:
                    try:
                        result = arg(result)
                    except exceptions.ValidationError:
                        raise
                    except BaseException:
                        raise exceptions.ValidationError('unable to convert value') from None
            return result


# ╠═══════════════════════════════════════════════════════════════════════════════════════════╡ composite checkers ╞═══╣

class Composite(Base, metaclass=abc.ABCMeta):  # pylint: disable=too-few-public-methods
    """Base for composite value checkers."""

    __slots__ = ('__children',)

    def __init__(self, *args: Base) -> None:
        """Initialize new :class:`~Composite` instance."""
        children: typing.List[Base] = []

        arg: Base
        for arg in args:
            if type(arg) is type(self):  # pylint: disable=unidiomatic-typecheck
                children.extend(arg.__children)  # type: ignore  # pylint: disable=protected-access
            else:
                children.append(arg)

        self.__children: typing.Final[typing.Tuple[Base, ...]] = tuple(children)

    @property
    def children(self) -> typing.Tuple[Base, ...]:  # noqa: D401
        """Items to aggregate."""
        return self.__children


class AllOf(Composite):
    """
    Composite checker that requires all conditions.

    Returns the raw value.
    """

    __slots__ = ()

    def __call__(self, value: typing.Any, *, field: exceptions.Field = ()) -> typing.Any:
        """Perform check of the value."""
        child: Base
        for child in self.children:
            child(value, field=field)
        return value


class AnyOf(Composite):
    """
    Composite checker that requires at least one condition.

    Returns output from the first condition that passed the validation.
    """

    __slots__ = ()

    def __call__(self, value: typing.Any, *, field: exceptions.Field = ()) -> typing.Any:
        """Perform check of the value."""
        child: Base
        for child in self.children:
            with contextlib.suppress(exceptions.ValidationError):
                return child(value, field=field)
        raise exceptions.ValidationError('invalid value: none of the conditions are met', field=field)


class OneOf(Composite):
    """
    Composite checker that requires only one condition to pass the check.

    Returns output from the condition that passed.
    """

    __slots__ = ()

    def __call__(self, value: typing.Any, *, field: exceptions.Field = ()) -> typing.Any:
        """Perform check of the value."""
        result: typing.Any
        count: int = 0

        child: Base
        for child in self.children:
            with contextlib.suppress(exceptions.ValidationError):
                result = child(value, field=field)
                count += 1

        if count == 0:
            raise exceptions.ValidationError('none of the conditions are met', field=field)
        if count > 1:
            raise exceptions.ValidationError('multiple conditions are met instead of one', field=field)
        return result


# ╠═══════════════════════════════════════════════════════════════════════════════════════════════╡ basic checkers ╞═══╣

class IsAny(Checker):  # pylint: disable=too-few-public-methods
    """Dumb checker that allows any value."""

    __slots__ = ()

    def _validate(self, value: typing.Any) -> None:
        """Perform validation of the `value`."""


class IsEmptyMapping(Checker):  # pylint: disable=too-few-public-methods
    """Make sure that mapping has no items in it."""

    __slots__ = ()

    def _validate(self, value: typing.Any) -> None:
        """Perform validation of the `value`."""
        if value:
            raise exceptions.ValidationError(f'unexpected items: {", ".join(repr(item) for item in value)}')


class OfChoices(Checker):
    """Make sure that the value if one of the available choices."""

    __slots__ = ('__choices',)

    def __init__(self, *args: typing.Any) -> None:
        """Initialize new :class:`~OfChoices` instance."""
        self.__choices: typing.Final[typing.Tuple[typing.Any, ...]] = args

    @property
    def choices(self) -> typing.Tuple[typing.Any, ...]:  # noqa: D401
        """List of available value choices."""
        return self.__choices

    def _validate(self, value: typing.Any) -> None:
        """Perform validation of the `value`."""
        if value in self.choices:
            return

        message: str = ', '.join(repr(item) for item in self.choices)
        raise exceptions.ValidationError(f'must be one of ({message}), not {value!r}')


class OfLength(Checker):
    """Validate any sizeable structure by its length."""

    __slots__ = ('__at_least', '__at_most')

    def __init__(
            self,
            exact: typing.Optional[int] = None,
            *,
            at_least: typing.Optional[int] = None,
            at_most: typing.Optional[int] = None,
    ) -> None:
        """Initialize new :class:`~OfLength` instance."""
        if at_least is None:
            at_least = exact
        if at_most is None:
            at_most = exact
        self.__at_least: typing.Final[typing.Optional[int]] = at_least
        self.__at_most: typing.Final[typing.Optional[int]] = at_most

    @property
    def at_least(self) -> typing.Optional[int]:  # noqa: D401
        """Minimum allowed number of elements."""
        return self.__at_least

    @property
    def at_most(self) -> typing.Optional[int]:  # noqa: D401
        """Maximum allowed number of elements."""
        return self.__at_most

    def _validate(self, value: typing.Any) -> None:
        """Perform validation of the `value`."""
        if (self.at_least is not None) and (len(value) < self.at_least):
            raise exceptions.ValidationError(f'must have at least {self.at_least} items')
        if (self.at_most is not None) and (len(value) > self.at_most):
            raise exceptions.ValidationError(f'must have at most {self.at_most} items')


class OfType(Checker):
    """
    Validate value by its type.

    :code:`None` might also be used as valid value type.
    """

    __slots__ = ('__allow_none', '__targets')

    def __init__(self, *args: typing.Optional[type]) -> None:
        """Initialize new :class:`~OfType` instance."""
        self.__allow_none: typing.Final[bool] = any(arg is None for arg in args)
        self.__targets: typing.Final[typing.Tuple[type, ...]] = tuple(
            arg
            for arg in args
            if arg is not None
        )

    @property
    def allow_none(self) -> bool:  # noqa: D401
        """Value could be :code:`None`."""
        return self.__allow_none

    @property
    def targets(self) -> typing.Tuple[type, ...]:  # noqa: D401
        """List of valid target types."""
        return self.__targets

    def _validate(self, value: typing.Any) -> None:
        """Perform validation of the `value`."""
        value_type: str
        if value is None:
            if self.allow_none:
                return
            value_type = 'None'
        elif isinstance(value, self.targets):
            return
        else:
            value_type = type(value).__name__

        target_types: str = ' | '.join(itertools.chain(
            (getattr(item, '__name__', str(item)) for item in self.targets),
            (['None'] if self.allow_none else []),
        ))
        raise exceptions.ValidationError(f'must be {target_types}, not {value_type}')


# ╠═════════════════════════════════════════════════════════════════════════════════════════════╡ helper iterators ╞═══╣

def iterable_items(source: typing.Iterable[T]) -> typing.Iterator[T]:
    """
    Iterate through items of any iterable.

    For each item - it's index will be used as the name of a field being validated.

    Example:

    >>> with exceptions.ValidationError.with_field('list'):
    ...     for item in iterable_items([1, 2, 'text', 3]):
    ...         if not isinstance(item, int):
    ...             raise exceptions.ValidationError(message='wrong type', field='value')
    Traceback (most recent call last):
        ...
    anaconda_navigator.api.external_apps.exceptions.ValidationError: list.2.value: wrong type
    """
    index: int
    value: T
    for index, value in enumerate(source):
        with exceptions.ValidationError.with_field(index):
            yield value


def mapping_items(source: typing.Mapping[KeyT, T]) -> typing.Iterator[typing.Tuple[KeyT, T]]:
    """
    Iterate through item tuples of a mapping

    For each such tuple - key of the item will be used as the name of a field being validated.

    Example:

    >>> with exceptions.ValidationError.with_field('dict'):
    ...     for k, v in mapping_items({'field_a': 1, 'field_b': 2, 'field_c': 'text', 'field_d': 3}):
    ...         if not isinstance(v, int):
    ...             raise exceptions.ValidationError(message='wrong type', field='value')
    Traceback (most recent call last):
        ...
    anaconda_navigator.api.external_apps.exceptions.ValidationError: dict.field_c.value: wrong type
    """
    key: KeyT
    value: T
    for key, value in source.items():
        with exceptions.ValidationError.with_field(key):
            yield key, value


# ╠═════════════════════════════════════════════════════════════════════════════════════╡ checkers for collections ╞═══╣

class CollectionChecker(Checker, metaclass=abc.ABCMeta):
    """Common base for checkers of different data collections."""

    __slots__ = ()

    def as_converter(
            self,
            target: typing.Callable[[typing.Iterable[typing.Any]], T],
    ) -> typing.Callable[[typing.Any], T]:
        """Use this checker as a converter to new collection."""
        def result(value: typing.Any) -> T:
            return target(self._scan(value))

        return result

    @abc.abstractmethod
    def _scan(self, value: typing.Any) -> typing.Iterator[typing.Any]:
        """Validate and update items from original collections."""

    def _validate(self, value: typing.Any) -> None:
        """Perform validation of the `value`."""
        for _ in self._scan(value):
            pass


class EachPair(CollectionChecker):
    """Process mapping by checking keys and values of items."""

    __slots__ = ('__key', '__value')

    def __init__(self, key: Base, value: Base) -> None:
        """Initialize new :class:`~EachPair` instance."""
        self.__key: typing.Final[Base] = key
        self.__value: typing.Final[Base] = value

    @property
    def key(self) -> Base:  # noqa: D401
        """Validator for mapping keys."""
        return self.__key

    @property
    def value(self) -> Base:  # noqa: D401
        """Validator for mapping values."""
        return self.__value

    def _scan(self, value: typing.Any) -> typing.Iterator[typing.Any]:
        """Validate and update items from original collections."""
        v_key: typing.Any
        v_value: typing.Any
        for v_key, v_value in mapping_items(value):
            yield self.__key(v_key), self.__value(v_value)


class EachValue(CollectionChecker):
    """Process values from any iterable."""

    __slots__ = ('__value',)

    def __init__(self, value: Base) -> None:
        """Initialize new :class:`~EachValue` instance."""
        self.__value: typing.Final[Base] = value

    @property
    def value(self) -> Base:  # noqa: D401
        """Validator for iterable values."""
        return self.__value

    def _scan(self, value: typing.Any) -> typing.Iterator[typing.Any]:
        """Validate and update items from original collections."""
        item: typing.Any
        for item in iterable_items(value):
            yield self.value(item)


class FindValue(Base):
    """Look through any iterable and find first valid value."""

    __slots__ = ('__default', '__value')

    def __init__(self, value: Base, *, default: typing.Any = EMPTY) -> None:
        """Initialize new :class:`~FindValue` instance."""
        self.__default: typing.Final[typing.Any] = default
        self.__value: typing.Final[Base] = value

    @property
    def default(self) -> typing.Any:  # noqa: D401
        """Default value to use if iterable has no valid values at all."""
        return self.__default

    @property
    def value(self) -> Base:  # noqa: D401
        """Validator for iterable values."""
        return self.__value

    def __call__(self, value: typing.Any, *, field: exceptions.Field = ()) -> typing.Any:
        """Perform check of the value."""
        with exceptions.ValidationError.with_field(field):
            item: typing.Any
            for item in iterable_items(value):
                with contextlib.suppress(exceptions.ValidationError):
                    return self.value(item)

        if self.default is not EMPTY:
            return self.default

        raise exceptions.ValidationError('no valid value found', field=field)


# ╠══════════════════════════════════════════════════════════════════════════════════════════════╡ item operations ╞═══╣

class ItemMixin:
    """Common part for item accessors."""

    __slots__ = ('__default', '__item')

    def __init__(self, item: exceptions.Name, *, default: typing.Any = EMPTY) -> None:
        """Initialize new :class:`~ItemMixin` instance."""
        self.__default: typing.Final[typing.Any] = default
        self.__item: typing.Final[exceptions.Name] = item

    @property
    def default(self) -> typing.Any:  # noqa: D401
        """Value to use if the expected item is not present in the collection."""
        return self.__default

    @property
    def item(self) -> exceptions.Name:  # noqa: D401
        """Key or index of an item to retrieve from collection."""
        return self.__item

    def _field(self) -> exceptions.Name:
        """Field, for which value is returned from current value checker."""
        return self.__item


class ItemGetter(ItemMixin, Base):
    """Retrieve item from a mapping."""

    __slots__ = ()

    def __call__(self, value: typing.Any, *, field: exceptions.Field = ()) -> typing.Any:
        """Perform check of the value."""
        try:
            return value[self.item]
        except KeyError:
            if self.default is EMPTY:
                raise exceptions.ValidationError(f'{self.item!r} must be provided', field=field) from None
            return self.default


class ItemPopper(ItemMixin, Base):
    """Retrieve item from a mapping and remove it."""

    __slots__ = ()

    def __call__(self, value: typing.Any, *, field: exceptions.Field = ()) -> typing.Any:
        """Perform check of the value."""
        try:
            return value.pop(self.item)
        except KeyError:
            if self.default is EMPTY:
                raise exceptions.ValidationError(f'{self.item!r} must be provided', field=field) from None
            return self.default


# ╠══════════════════════════════════════════════════════════════════════════════════════╡ helper context managers ╞═══╣

@contextlib.contextmanager
def catch_exception(
        exception_type: typing.Union[
            typing.Type[BaseException],
            typing.Tuple[typing.Type[BaseException], ...],
        ] = BaseException,
        *,
        field: typing.Union[str, int, typing.Iterable[typing.Union[str, int]]] = (),
        message: typing.Optional[str] = None,
) -> typing.Iterator[None]:
    """
    Convert any :exc:`~Exception` to :exc:`~anaconda_navigator.api.external_apps.exceptions.ValidationError`.

    :param exception_type: Convert only this type(s) of :exc:`~BaseException` instances.
    :param message: Custom message to append.

                    If not provided - string representation of original :exc:`~BaseException` will be used.
    :param field: Report exception for a particular field.

    Examples:

    >>> with catch_exception():
    ...     _ = 1 / 0
    Traceback (most recent call last):
        ...
    anaconda_navigator.api.external_apps.exceptions.ValidationError: division by zero

    >>> with catch_exception(ValueError):
    ...     _ = 1 / 0
    Traceback (most recent call last):
        ...
    ZeroDivisionError: division by zero
    """
    try:
        yield
    except exception_type as error:
        custom_message: str
        if message is not None:
            custom_message = message
        elif isinstance(error, exceptions.ValidationError):
            custom_message = error.message
        else:
            custom_message = str(error)

        raise exceptions.ValidationError(custom_message, field=field) from error


@contextlib.contextmanager
def validation_to_warning(path: str = '*configuration*', *, silent: bool = True) -> typing.Iterator[None]:
    """Convert Validation errors into warnings."""
    try:
        yield
    except exceptions.ValidationError as exception:
        logger.warning('%s: %s', path, exception.details)
        if not silent:
            raise


# ╠════════════════════════════════════════════════════════════════════════════════════════════════════╡ shortcuts ╞═══╣

def sequence_of(*types: typing.Optional[type]) -> Base:
    """Validate and normalize a value in form of a sequence, which might be provided as a single item from it."""
    return AnyOf(
        OfType(*types).convert(lambda item: (item,)),
        OfType(typing.Sequence).convert(EachValue(OfType(*types)).as_converter(tuple)),
    )


IS_NONE: Base = OfType(None)
IS_BOOL: Base = OfType(bool)
IS_INT: Base = OfType(int)
IS_FLOAT: Base = OfType(float)
IS_STR: Base = OfType(str)

IS_OPT_BOOL: Base = OfType(bool, None)
IS_OPT_INT: Base = OfType(int, None)
IS_OPT_FLOAT: Base = OfType(float, None)
IS_OPT_STR: Base = OfType(str, None)

IS_SEQ: Base = OfType(typing.Sequence)
IS_MAP: Base = OfType(typing.Mapping)