Source code for action_completer.validator

# -*- encoding: utf-8 -*-
# Copyright (c) 2020 Stephen Bunn <stephen@bunn.io>
# ISC License <https://choosealicense.com/licenses/isc>

"""Contains the validator used to validate data produced by the action completer.

To retreive this validator, it is highly recommend that you utilize the
:meth:`~.completer.ActionCompleter.get_validator` method.
If your completer instance is dynamic, you probably want to fetch this validator
instance for every call to :func:`~prompt_toolkit.shortcuts.prompt`.

For example:

.. code-block:: python

    from prompt_toolkit.shortcuts import prompt
    from action_completer import ActionCompleter
    completer = ActionCompleter()

    # ... register completer actions

    while True:
        # note the call to `get_validator` for every call to prompt
        prompt_result = prompt(
            ">>> ",
            completer=completer,
            validator=completer.get_validator()
        )


You could also initialize the :class:`~ActionValidator` yourself by passing
through the ``root`` group of the :class:`~.completer.ActionCompleter`:

.. code-block:: python

    from prompt_toolkit.shortcuts import prompt
    from action_completer import ActionCompleter, ActionValidator
    completer = ActionCompleter()

    # ... register completer actions

    while True:
        validator = ActionValidator(completer.root)
        prompt_result = prompt(
            ">>> ",
            completer=completer,
            validator=validator
        )
"""

import operator
import warnings
from typing import Iterable, List, Optional, Tuple, Union

import attr
from fuzzywuzzy import process as fuzzy_process
from fuzzywuzzy import utils as fuzzy_utils
from prompt_toolkit.completion import Completer
from prompt_toolkit.document import Document
from prompt_toolkit.validation import ValidationError, Validator

from .types import Action, ActionGroup, ActionParam
from .utils import decode_completion, extract_context, get_best_choice, get_fragments


[docs]@attr.s class ActionValidator(Validator): """Custom validator for a :class:`~.completer.ActionCompleter`. Most of the time you should get this instance from :meth:`~.completer.ActionCompleter.get_validator` however if you need to build an instance of it yourself you can use the following logic: .. code-block:: python from action_completer.completer import ActionCompleter from action_completer.validator import ActionValidator completer = ActionCompleter() validator = ActionValidator(completer.root) """ root: ActionGroup = attr.ib() def _validate_choices( self, choices: Union[List[str], Tuple[str]], text: str, cursor_position: int = 0 ): """Validate the given text falls within the given choices. Args: choices (Union[List[str], Tuple[str]]): The choices to validate against text (str): The text to validate against the given choices cursor_position (int, optional): The current cursor position in the prompt buffer. Defaults to 0. Raises: ValidationError: When the given text does not match the supplied choice, if a single choice is given ValidationError: When the given text does not fall within the given choices, if multiple choices are provided """ if len(choices) <= 0 or len(text) <= 0: return if len(choices) == 1 and text != choices[0]: raise ValidationError( message=f"Invalid value {text!r}, expected {choices[0]!r}", cursor_position=cursor_position, ) if text not in choices: message = f"Invalid value {text!r}" best_guess = get_best_choice(choices, text) if best_guess: message += f", did you mean {best_guess!r}" raise ValidationError(message=message, cursor_position=cursor_position) def _validate_group( self, action_group: ActionGroup, fragments: List[str], parent_name: Optional[str] = None, cursor_position: int = 0, ): """Validate the prompt buffer fragments are valid against an action group. Args: action_group (ActionGroup): The action group to base validation off of fragments (List[str]): The current prompt buffer fragments to validate parent_name (Optional[str], optional): The name of the parent task that triggered the current action group. Defaults to None. cursor_position (int, optional): The current cursor position in the prompt buffer. Defaults to 0. Raises: ValidationError: When the given text fragments are not valid against the provided action group """ available_choices = [ name for name, child in action_group.children.items() if child.active is None or child.active() ] # XXX: I'm a little confused about when this state is reached. I haven't yet # tracked down the exact cases when fragments are not available (perhaps # very-early state trying to validate the root action group?). In any case, this # is just a safe guard that shouldn't break anything that I am aware of if len(fragments) <= 0 or len(available_choices) <= 0: return self._validate_choices( available_choices, fragments[-1], cursor_position=cursor_position, ) def _validate_basic_param( self, action: Action, action_param: ActionParam, param_value: str, cursor_position: int = 0, ): """Validate a given basic (string) parameter for the parameter value. Args: action (Action): The action the given action parameter applies to action_param (ActionParam): The action parameter to base validation off of param_value (str): The value to validate against the given action parameter cursor_position (int, optional): The current cursor position in the prompt buffer. Defaults to 0. Raises: ValidationError: When the given parameter value is not valid for the current action parameter """ assert isinstance( action_param.source, str ), f"basic param validation can only handle a string source, {action_param!r}" self._validate_choices( [action_param.source], param_value, cursor_position=cursor_position ) def _validate_iterable_param( self, action: Action, action_param: ActionParam, param_value: str, cursor_position: int = 0, ): """Validate a given iterable (Iterable[str]) parameter for the parameter value. Args: action (Action): The action the given action parameter applies to action_param (ActionParam): The action parameter to base validation off of param_value (str): The value to validate against the given action parameter cursor_position (int, optional): The current cursor position in the prompt buffer. Defaults to 0. Raises: ValidationError: When the given parameter value is not valid for the current action parameter """ assert isinstance(action_param.source, (list, tuple,)), ( "iterable param validation can only handle hashable iterables, " f"{action_param.source!r}" ) validation_choices = [choice for choice in action_param.source] assert all(isinstance(param_value, str) for value in validation_choices), ( "iterable param validation can only handle hashable iterables of strings, " f"{validation_choices!r}" ) self._validate_choices( validation_choices, param_value, cursor_position=cursor_position ) def _validate_callable_param( self, action: Action, action_param: ActionParam, param_value: str, cursor_position: int = 0, ): """Validate a given callable parameter for the parameter value. Args: action (Action): The action the given parameter applies to action_param (ActionParam): The action parameter to base validation off of param_value (str): The value to validate against the given action parameter cursor_position (int, optional): The current cursor position in the prompt buffer. Defaults to 0. Raises: ValidationError: When the given parameter value is not valid for the current action parameter """ assert callable(action_param.source), ( "callable param validation can only handle callables that return " f"iterables of strings, {action_param.source!r}" ) self._validate_choices( list(action_param.source(action)), # type: ignore param_value, cursor_position=cursor_position, ) def _validate_custom_validators( self, action: Action, action_param: ActionParam, param_value: str, previous_fragments: List[str], cursor_position: int = 0, ): """Validate any custom parameter validators for the parameter value. .. important:: This validator is somewhat different than the other available private ``_validate_*`` methods as this requires the previous fragments that have appeared prior to the given action parameter's ``value``. This is necessary for the call to a custom callable validator (not one created through :meth:`~prompt_toolkit.validation.Validator.from_callable`). This function validates the current action parameter against custom validators such as those created through :meth:`~prompt_toolkit.validation.Validator.from_callable` or a custom callable that follows the following signature: .. code-block:: python def _custom_validator( param: ActionParam, param_value: str, previous_fragments: List[str] ) -> Any: # ... some validation logic ... # on failed validation a similar validation error to the following # should be raised raise prompt_toolkit.validation.ValidationError( "validation error message", cursor_position=0 ) Note that the ``param_value`` to this custom completer will **always** be the raw string value. We do not automatically apply whatever :var:`~types.ActionParam.cast` you have specified on the parameter for validation. We do however, pass the parameter to you in case you want to cast the string to a custom type yourself. .. note:: The ``cursor_position`` for the raised :class:`~prompt_toolkit.validation.ValidationError` from a custom callable does not matter. Whatever validation error you raise will be caught and re-raised with the same error message you provide but using with the appropriate cursor position. Args: action (Action): The action the given parameter applies to action_param (ActionParam): The action parameter to base validation off of param_value (str): The value to validate agains the given action parameter previous_fragments (List[str]): The list of fragments that have appeared before the current given ``value`` fragment cursor_position (int, optional): The current cursor position in the prompt buffer. Defaults to 0. Raises: ValidationError: When any of the provided parameter validator callables raises :class:`~prompt_toolkit.validation.ValidationError` """ assert action_param.validators and len(action_param.validators) > 0, ( "Custom validation handler can only be used with action parameters using a " "non-empty list of validators" ) for custom_validator in action_param.validators: if isinstance(custom_validator, Validator): # NOTE: we are purposefully re-raising ValidationError here so # we can adjust the cursor_position to properly fit the current # context rather than the one the custom validator from the # ActionParam requires try: custom_validator.validate( Document( text=decode_completion(param_value), cursor_position=len(param_value), ) ) except ValidationError as exc: raise ValidationError( message=exc.message, cursor_position=cursor_position ) elif callable(custom_validator): # Custom callable validator for utilizing the fragment history # in validation (since Validator can't take extra parameters and # that is really not its responsibility). # # Signature looks something like the following: # # custom_validator( # param: ActionParam, # param_value: str, # previous_fragments: List[str] # ) # # This custom validator callable should raise an instance of # prompt_toolkit.validation.ValidatorError on failed validation. # You can ignore the cursor_position parameter of the ValidationError # as it will always be overwritten with the proper value try: custom_validator( action_param, param_value, previous_fragments, ) except ValidationError as exc: raise ValidationError( message=exc.message, cursor_position=cursor_position ) else: warnings.warn( f"Unsure how to handle validator {custom_validator!r}, " "no validation will be performed", UserWarning, ) def _validate_default_validators( self, action: Action, action_param: ActionParam, param_value: str, cursor_position: int = 0, ): """Validate default validators for the current parameter value. Args: action (Action): The action the given parameter applies to action_param (ActionParam): The action parameter to base validation off of param_value (str): The value to validate agains the given action parameter cursor_position (int, optional): The current cursor position in the prompt buffer. Defaults to 0. Raises: ValidationError: When any of the provided parameter validator callables raises :class:`~prompt_toolkit.validation.ValidationError` """ param_validator = None if isinstance(action_param.source, str): param_validator = self._validate_basic_param elif isinstance( action_param.source, ( list, tuple, ), ): param_validator = self._validate_iterable_param elif callable(action_param.source): param_validator = self._validate_callable_param if param_validator: param_validator( action, action_param, decode_completion(param_value), cursor_position=cursor_position, ) def _validate_action( self, action: Action, fragments: List[str], parent_name: str, cursor_position: int = 0, ): """Validate the prompt buffer fragments are valid against the given action. .. note:: This validation will also raise a :class:`~prompt_toolkit.validation.ValidationError` when the given action has too few or extra values for parameters than it specifies. The only time this is not true is when the boolean flag ``capture_all`` set to ``True``. In this case, any **extra** (not missing) parameters will not fail validation and will instead be passed as positional arguments in the building of the partial action callable through the completer. Args: action (Action): The action to base validation off of fragments (List[str]): The current prompt buffer fragments parent_name (str): The name of the task that triggered the given action. cursor_position (int, optional): The current cursor position of the prompt buffer. Defaults to 0. Raises: ValidationError: When the given text fragments are not valid against the provided action group ValidationError: When there are missing or extra parameters than the action specifies it requires """ assert ( parent_name and len(parent_name) > 0 ), f"parent name for action {action!r} was not given" if action.params is None or len(action.params) <= 0: return for param_index, (action_param, param_value) in enumerate( zip(action.params, fragments) ): try: if action_param.validators and len(action_param.validators) > 0: self._validate_custom_validators( action, action_param, decode_completion(param_value), fragments[: (len(fragments) - 1) + (param_index - 1)], cursor_position=cursor_position, ) else: # NOTE: we don't assume any type of validation for parameters using # their own completers, it is up to the user to define a custom # validator for the ActionParam in this case self._validate_default_validators( action, action_param, param_value, cursor_position=cursor_position, ) except ValidationError as exc: raise ValidationError( cursor_position=cursor_position, message=f"[arg: {param_index + 1!s}] {exc.message!s}", ) non_empty_fragments = list(filter(None, fragments)) compare_operator = operator.lt if action.capture_all else operator.ne if compare_operator(len(non_empty_fragments), len(action.params)): raise ValidationError( message=( f"Invalid number of parameters for {parent_name!r}, " f"expected {len(action.params)} received {len(non_empty_fragments)}" ), cursor_position=cursor_position, )
[docs] def validate(self, document: Document): """Validate the current document from the :class:`~.completer.ActionCompleter`. Args: document (~prompt_toolkit.document.Document): The document to validate Raises: ~prompt_toolkit.validation.ValidationError: When validation of the given document fails. """ parent, parent_name, completable, fragments = extract_context( self.root, get_fragments(document.text) ) if isinstance(completable, ActionGroup): self._validate_group( completable, fragments, parent_name, cursor_position=document.cursor_position, ) elif isinstance(completable, Action): # Validate against the current action's parent group's if an action is # extracted from the current document if completable.active is not None and not completable.active() and parent: self._validate_group( parent, fragments, parent_name, cursor_position=document.cursor_position, ) return # If we don't have the action's parent name at this point, we can't really # safely perform validation. This should never occur, but I'm never 100% # confident about it if not parent_name: return self._validate_action( completable, fragments, parent_name, cursor_position=document.cursor_position, )