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)