import sys
import traceback
import hypothesis
from hypothesis import stateful
import sip
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.infra import qt_message_handler
[docs]class QtErrorCatchingRuleBasedStateMachine(stateful.RuleBasedStateMachine):
    """
    A RuleBasedStateMachine that fails if any Qt warnings or errors are reported
    or if any exceptions are raised in a slot.  Without this class:
      - Qt warnings and errors are ignored.
      - Exceptions that are raised in a slot cause the test to fail with a
        "Exceptions caught outside of main thread:" message, but
        Hypothesis doesn't report the steps required to reproduce the exception.
    :note: The error checkers in this class are implemented in two parts.
        `_recordException` and `_recordQtWarningsAndErrors` record any problems
        that happen during a step, and the invariant `checkErrors` makes sure
        that no problems were recorded at the end of each step.  Any exception
        raised in one of the `_record` wouldn't be recognized by Hypothesis and
        would instead go directly to the top-level error handler, so the two
        step approach is required.
    :cvar FAIL_ON_QT_MSGS: What Qt message types should trigger a test failure.
        By default warnings, criticals, and fatals lead to a test failure while
        debug and info messages are ignored.  This can be modified in subclasses
        if desired.
    :vartype FAIL_ON_QT_MSGS: tuple or list or set
    :cvar QT_MSG_NAMES: A mapping from QtMsgType values to a textual
        description.
    :vartype QT_MSG_NAMES: dict(int, str)
    :cvar IGNORE_WARN_PREFIXES: Any Qt warning message that starts with a string
        listed here will be ignored. Warning messages filtered by
        qt_message_handler.filter_qt_msg will be ignored regardless of this var.
    :vartype IGNORE_WARN_PREFIXES: iterable(str)
    """
    FAIL_ON_QT_MSGS = (QtCore.QtWarningMsg, QtCore.QtCriticalMsg,
                       QtCore.QtFatalMsg)
    QT_MSG_NAMES = {
        QtCore.QtDebugMsg: "debug",
        QtCore.QtInfoMsg: "info",
        QtCore.QtWarningMsg: "warning",
        QtCore.QtCriticalMsg: "critical",
        QtCore.QtFatalMsg: "fatal"
    }
    IGNORE_WARN_PREFIXES = tuple()
[docs]    def __init__(self, trap_errors=True):
        """
        :param trap_errors: Whether to override the top-level exception handler
            and catch all Qt warnings and errors.  Setting this to False is
            *only* useful for interactive use of this object, i.e. instantiating
            one in a REPL.  Otherwise, all REPL tracebacks would be swallowed by
            the excepthook.
        :type trap_errors: bool
        """
        super().__init__()
        self._trap_errors = trap_errors
        self._exceptions = []
        self._qt_errors = []
        if trap_errors:
            # catch exceptions that get thrown in a slot
            self._old_excepthook = sys.excepthook
            sys.excepthook = self._recordException
            # catch Qt warnings and errors
            self._old_message_handler = QtCore.qInstallMessageHandler(
                self._recordQtWarningOrError) 
[docs]    def teardown(self):
        """
        Reset the top-level exception handler and Qt's message handler once the
        test is done.
        """
        super().teardown()
        if self._trap_errors:
            sys.excepthook = self._old_excepthook
            QtCore.qInstallMessageHandler(self._old_message_handler) 
    def _recordException(self, exc_type, value, tback):
        """
        Record any unhandled Python exceptions in `self._exceptions`.
        See sys.excepthook documentation
        (https://docs.python.org/3/library/sys.html#sys.excepthook) for argument
        documentation.
        """
        self._exceptions.append((exc_type, value, tback))
    def _recordQtWarningOrError(self, msg_type, context, msg):
        """
        Record any Qt warnings and errors in self._qt_errors.  Ignore warnings
        that start with anything in `IGNORE_WARN_PREFIXES`.
        See qInstallMessageHandler documentation
        (http://doc.qt.io/qt-5/qtglobal.html#qInstallMessageHandler) for
        argument documentation.
        """
        if msg_type in self.FAIL_ON_QT_MSGS:
            if qt_message_handler.filter_qt_msg(msg):
                return
            if msg_type == QtCore.QtWarningMsg:
                stripped = msg.lstrip()
                if any(
                        stripped.startswith(prefix)
                        for prefix in self.IGNORE_WARN_PREFIXES):
                    return
            self._qt_errors.append((msg_type, context, msg))
[docs]    @stateful.invariant()
    def checkErrors(self):
        """
        Fail if the current trial raised an unhandled exception or triggered a
        Qt warning or error.
        """
        self._checkErrors(self._exceptions, "Caught %i unhandled exceptions.",
                          self._formatTraceback)
        self._checkErrors(self._qt_errors, "%i Qt warnings or errors output.",
                          self._formatQtWarningOrError) 
    def _checkErrors(self, errors, overflow_msg, error_formatter):
        """
        Raise an AssertionError if `errors` is not empty.
        :param errors: A list of errors
        :type errors: list[tuple]
        :param overflow_msg: If `errors` contains more than 10 errors, this
            message will be included in the AssertionError and only the first 10
            errors will be reported.
        :type overflow_msg: str
        :param error_formatter: A function that converts an element of `errors`
            into a text description.
        :type error_formatter: function
        """
        if not errors:
            return
        texts = []
        num_errors = len(self._qt_errors)
        if num_errors > 10:
            texts.append(overflow_msg % num_errors +
                         " Only reporting the first 10:")
            del errors[10:]
        texts.extend(error_formatter(*err) for err in errors)
        raise AssertionError("\n".join(texts))
    def _formatTraceback(self, exc_type, value, tback):
        """
        Return a text description of a traceback.
        See sys.excepthook documentation
        (https://docs.python.org/3/library/sys.html#sys.excepthook) for argument
        documentation.
        """
        msg = ''.join(traceback.format_tb(tback))
        msg += f'{exc_type.__name__}: {value}\n'
        return msg
    def _formatQtWarningOrError(self, msg_type, context, msg):
        """
        Return a text description of a Qt warning or error.
        See qInstallMessageHandler documentation
        (http://doc.qt.io/qt-5/qtglobal.html#qInstallMessageHandler) for
        argument documentation.
        """
        return "Qt {} output: {}".format(self.QT_MSG_NAMES[msg_type], msg)
    def _note(self, message: str):
        """
        Try to show the message as a note, which will only be shown as part of
        a failing recipe. If not in a test session (e.g. directly stepping
        through the stateful test), `hypothesis.note` raises an exception, so
        print the message instead.
        """
        try:
            hypothesis.note(message)
        except hypothesis.errors.InvalidArgument:
            print(message) 
[docs]class PanelRuleBasedStateMachine(QtErrorCatchingRuleBasedStateMachine):
    """
    A RuleBasedStateMachine for use with a Qt-based panel.  Subclasses must
    define PANEL_CLASS, and the panel instance will be available as
    `self.panel`. Without this class:
    - QTimers may not fire when they are supposed to
    - Errors that occur during painting will not be caught
    - Errors in stateful decorator arguments may cause pytest to segfault
    :cvar PANEL_CLASS: The class of the panel to test.
    :vartype PANEL_CLASS: QtWidgets.QWidget
    """
    PANEL_CLASS = None
[docs]    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._app = QtWidgets.QApplication.instance()
        if self.PANEL_CLASS is None:
            raise RuntimeError("%s does not define PANEL_CLASS" %
                               self.__class__.__name__)
        self.panel = self._initPanel()
        # "show" the panel without actually showing it.  This allows the panel's
        # geometry to update as if it was shown on screen.
        self.panel.setAttribute(Qt.WA_DontShowOnScreen)
        self.panel.show()
        self._app.processEvents() 
    def _initPanel(self):
        """
        Return an instance of the panel
        """
        panel = self.PANEL_CLASS()
        return panel
[docs]    def teardown(self):
        super().teardown()
        # The panel object must be completely destroyed before we start the next
        # Hypothesis run.  Otherwise, Hypothesis may not report recipes to
        # reproduce failures and may report flaky failures.  A deleteLater()
        # call followed by a processEvents() call isn't sufficient, since
        # processEvents() doesn't process DeferredDelete events (according to
        # the QCoreApplication::processEvents documentation).  Setting panel to
        # None and then calling gc.collect() is also insufficient, since that
        # doesn't guarantee destruction of the C++ objects.  To ensure that the
        # panel is really and truly dead, we use sip.delete().
        sip.delete(self.panel)
        self.panel = None 
[docs]    def check_invariants(self):
        """
        Make sure we process events after each step so that any QTimers with
        timeouts of 0 will fire when they're supposed to.
        """
        for _ in range(5):
            self._app.processEvents()
        super().check_invariants()