Source code for schrodinger.ui.qt.appframework2.validation

from unittest import mock

#=========================================================================
# Decorator functions
#=========================================================================


def cast_validation_result(result):
    """
    Casts the result of a validation method into a ValidationResult instance

    :param result: The result of a validation check
    :type result: bool or (bool, str) or `schrodinger.ui.qt.appframework2.validation.ValidationResult`

    :return: A ValidationResult instance
    :rtype: `schrodinger.ui.qt.appframework2.validation.ValidationResult`
    """
    if isinstance(result, ValidationResult):
        return result

    if isinstance(result, bool):
        return ValidationResult(passed=result)

    if isinstance(result, tuple):
        passed, message = result
        return ValidationResult(passed=passed, message=message)

    return ValidationResult(bool(result))


def validator(validation_order=100):
    """
    Decorator function to mark a method as a validation test and define the
    order in which is should be called. Validation order is optional and
    relative only to the other validation methods within a class instance.

    When the decorated method is called, the original method's result is cast
    to a ValidationResult. This makes it a bit more natural to test validation
    objects. A ValidationResult object evaluates to True or False depending on
    whether the validation succeeded or not.
    """

    def setOrder(to_func):

        def inner(*args, **kwargs):
            result = to_func(*args, **kwargs)
            return cast_validation_result(result)

        inner.validation_order = validation_order
        inner.is_multi_validator = False
        return inner

    return setOrder


def multi_validator(validation_order=100):
    """
    Use this decorator to mark methods that need to return multiple validation
    results. This may be a list of validator return values (e.g. bool or (bool,
    str)) or may be yielded from a generator.
    """

    def setOrder(to_func):

        def inner(*args, **kwargs):
            for result in to_func(*args, **kwargs):
                result = cast_validation_result(result)
                yield result

        inner.validation_order = validation_order
        inner.is_multi_validator = True
        return inner

    return setOrder


def add_validator(obj, validator_func, validation_order=100):
    """
    Function that allows validators to be added dynamically at runtime.
    See the `validator` decorator for more information.

    .. NOTE::
        The `validator_func` is not bound to `obj`. If you want it to behave
        like a method (ie take in `obj` as its first argument), then the
        `validator_func` should be cast using `types.MethodType(obj, validator_func)`.

    .. WARNING::
        The validator is added as an attribute to `obj` using the name of
        the validator function. This means that any attributes or methods with
        the same name will be overwritten.

    :param obj: An instance of a class that subclasses ValidationMixin.
    :type  obj: object

    :param validator_func: A function to use as a validator. The function
        should return a bool or a tuple consisting of a bool and a string.
    :type  validator_func: callable

    :param validation_order: The order to call `validator_func`. This number
        is used relative to other validators' validation_order values.
    :type  validation_order: int
    """
    wrapped_validator = validator(
        validation_order=validation_order)(validator_func)
    setattr(obj, validator_func.__name__, wrapped_validator)


def remove_validator(obj, validator_func):
    """
    This function is the inverse of `add_validator`. Note that this should only
    be used with validators that were added wtih `add_validator`, not validators
    that were built into a class using the @validator decorator.

    :param obj: An instance of a class that subclasses ValidationMixin.
    :type  obj: object

    :param validator_func: A function that's been added as a validator to `obj`.
    :type  validator_func: callable

    """
    delattr(obj, validator_func.__name__)


#=========================================================================
# Main Validation Class
#=========================================================================


class ValidationMixin(object):
    """
    This mix-in provides validation functionality to other classes, including
    the ability to designate methods as validation methods, which will be called
    when the validate method is invoked. These methods can be designated using
    the `validator` decorator.

    To enable validation functionality in a class, this mix-in can be inherited
    as an additional parent class. It expects to be inherited by a class that
    has defined `error` and `question` methods, e.g. a class that also
    inherits from `widgetmixins.MessageBoxMixin`.
    """

    def runValidation(self,
                      silent=False,
                      validate_children=True,
                      stop_on_fail=True):
        """
        Runs validation and reports the results (unless run silently).

        :param silent: run without any reporting (i.e. error messages to the
            user). This is useful if we want to programmatically test validity.
            Changes return value of this method from `ValidationResults` to a
            boolean.
        :type silent: bool

        :param validate_children: run validation on all child objects. See
                `_validateChildren` for documentation on what this entails.
        :type validate_children: bool

        :param stop_on_fail: stop validation when first failure is encountered
        :type stop_on_fail: bool

        :return: if silent is False, returns the validation results. If silent
            is True, returns a boolean generated by `reportValidation`.
        :rtype: `ValidationResults` or bool
        """

        results = self._validate(validate_children, stop_on_fail)
        if silent:
            return results
        return self.reportValidation(results)

    def reportValidation(self, results):
        """
        Present validation messages to the user. This is an implmentation of
        the `ValidationMixin` interface and does not need to be called
        directly.

        This method assumes that `error` and `question` methods have been
        defined in the subclass, as in e.g. `widget_mixins.MessageBoxMixin`.

        :param results: Set of validation results generated by `validate`
        :type results: `validation.ValidationResults`

        :return: if True, there were no validation errors and the user decided
            to continue despite any warnings. If False, there was at least one
            validation error or the user decided to abort when faced with a warning.
        """

        abort = False
        for result in results:
            if not result:
                abort = True
                message = result.message
                if not message:
                    message = ('Validation failed. Check settings and try'
                               ' again.')
                self.error(message)
                break
            else:
                if result.message:
                    cont = self.question(
                        result.message, button1='Continue', title='Warning')
                    if not cont:
                        abort = True
                        break
        return not abort

    def _validate(self, validate_children=True, stop_on_fail=True):
        """
        Run all validators defined as methods of self. Validation methods are
        designated by the `validator` decorator.

        :param validate_children: run validation on all child objects. See
                `_validateChildren` for documentation on what this entails.
        :type validate_children: bool

        :param stop_on_fail: If True, stops validation on first failure.
        :type stop_on_fail: bool

        :param results: Set of validation results
        :type results: `validation.ValidationResults`
        """

        results = ValidationResults()

        if validate_children:
            results.add(self._validateChildren(stop_on_fail))
            if not results and stop_on_fail:
                return results

        results.extend(validate_obj(self, stop_on_fail=stop_on_fail))

        return results

    def _validateChildren(self, stop_on_fail=True):
        """
        Sequentially validates each of the children of self by attempting to
        call child._validate() on all objects returned by a call to
        self.children().

        :param stop_on_fail: If True, stops validation on first failure.
        :type stop_on_fail: bool
        """

        results = ValidationResults()

        try:
            children = self.children()
        except AttributeError:
            children = []

        for child in children:
            try:
                results.add(child._validate(stop_on_fail))
            except AttributeError:
                pass
            if not results and stop_on_fail:
                break

        return results


def find_validators(obj):
    """
    Searches through the methods on an object and finds methods that have been
    decorated with the @validator decorator.

    :param obj: the object containing validator methods
    :type obj: object

    :return: the validator methods, sorted by validation_order
    :rtype: list of callable
    """
    validators = []
    for attribute in dir(obj):
        method = getattr(obj, attribute)
        # Mock objects must be explicitly ignored
        if isinstance(method, mock.Mock):
            continue
        if hasattr(method, 'validation_order'):
            validators.append(method)

    validators.sort(key=lambda method: method.validation_order)
    return validators


def validate_obj(obj, stop_on_fail=False):
    """
    Runs validation on an object containing validator methods. Will not
    recursively validate child objects.

    :param obj: the object to be validated.
    :type obj: object

    :param stop_on_fail: whether to stop validation at the first failure
    :type stop_on_fail: bool

    :return: the validation results
    :rtype: ValidationResults
    """
    results = ValidationResults()
    validators = find_validators(obj)
    abort = False
    for validate_method in validators:
        if validate_method.is_multi_validator:
            method_results = validate_method()
        else:
            result = validate_method()
            method_results = [result]
        for result in method_results:
            results.add(result)
            if not result and stop_on_fail:
                abort = True
                break
        if abort:
            break
    return results


#=========================================================================
# Validation Result Handling
#=========================================================================


class ValidationResult(object):
    """
    A class to store a single validation result.
    """

    def __init__(self, passed=True, message=None):
        """
        If passed is True and there is a message, this is generally interpreted
        as a warning.

        :param passed: Whether validation passed
        :type passed: bool
        :param message: Message to present to user, if applicable
        :type message: str
        """

        self.passed = passed
        self.message = message

    def __bool__(self):
        """
        :return: Whether the validation passed
        :rtype: bool
        """
        return self.passed

    def __str__(self):
        if self.passed:
            if not self.message:
                return 'Passed'
            else:
                return 'WARNING: %s' % self.message
        else:
            if not self.message:
                return 'FAILED'
            else:
                return 'FAILED: %s' % self.message

    def __repr__(self):
        return self.__str__()

    def __iter__(self):
        """
        Iterate through the contents of the ValidationResult instance

        Allows us to treat a ValidationResult instance in the same way as a
        tuple
        """
        return iter([self.passed, self.message])

    def __getitem__(self, index):
        """
        Return the item at the specified index: 0 for passed and 1 for message

        Allows us to treat a ValidationResult instance in the same way as a
        tuple

        :param index: The index of the item (either 0 or 1)
        :type index: int
        """
        return [self.passed, self.message][index]


class ValidationResults(list, object):
    """
    A class to store validation results. This class can store multiple results,
    and has methods for iterating through the results.

    Inherits from object in order to fix issues from python 3 transition
    """

    def add(self, result):
        """
        Adds another result or list of validation results to the list. A list
        of results must be of the ValidationResults type. Single results to add
        can be given in several ways.

        A ValidationResult can be added.

        A tuple consisting of a (bool, message) will be converted into a
        ValidationResult.

        Any other value that has a bool value will be converted into a
        ValidationResult with no message.

        :param result: The result(s) to be added
        :type result: ValidationResult, ValidationResults, tuple, or any type
            with a truth value.
        """
        if isinstance(result, ValidationResults):
            self.extend(result)
            return

        validation_result = cast_validation_result(result)
        self.append(validation_result)

    def __bool__(self):
        """
        Truth-value of a ValidationResults instance. Note that an empty list
        evaluates True. If the list of results contain any validation failures,
        the list evaluates False.
        """
        return all(self)

    def __str__(self):
        return '\n'.join(str(result) for result in self)

    def __repr__(self):
        return self.__str__()