pytest-dev / pytest-qt

pytest plugin for Qt (PyQt5/PyQt6 and PySide2/PySide6) application testing
https://pytest-qt.readthedocs.io
MIT License
410 stars 70 forks source link

Throwing exception from `QShortcut` does not seem to call `sys.excepthook` with `PySide6==6.8.0` #573

Closed bersbersbers closed 1 month ago

bersbersbers commented 1 month ago

With PySide6==6.8.0, the following code does not call excepthook() when run via pytest, while it does with version 6.7.3 or when run via python:

# RuntimeError in pytest:
# uv pip install pyside6-essentials==6.8.0 pytest==8.3.3 pytest-qt==4.4.0 && python test.py & pytest test.py

# Passes:
# uv pip install pyside6-essentials==6.7.3 pytest==8.3.3 pytest-qt==4.4.0 && python test.py && pytest test.py

import sys

from PySide6.QtGui import QShortcut
from PySide6.QtWidgets import QApplication, QMainWindow

def raise_exception():
    raise RuntimeError

def excepthook(*_):
    print("EXCEPTHOOK()")

class Window(QMainWindow):
    def __init__(self):
        super().__init__()
        sys.excepthook = excepthook
        self.shortcut = QShortcut(self)
        self.shortcut.activated.connect(raise_exception)

def test_exception(qtbot):
    window = Window()
    if qtbot is not None:
        qtbot.addWidget(window)
    window.shortcut.activated.emit()

if __name__ == "__main__":
    app = QApplication()
    test_exception(None)

This is on Windows 11 with Python 3.13.0.

The-Compiler commented 1 month ago

That seems somewhat expected, see Exceptions in virtual methods — pytest-qt documentation.

Though I'm confused by:

PySide6 6.5.2+ automatically captures exceptions that happen during the Qt event loop and re-raises them when control is moved back to Python, so the functionality described here does not work with PySide6 (nor is necessary).

To me, this sounds more like a change (or bug?) in PySide 6.8 made that not happen anymore?

bersbersbers commented 1 month ago

I will investigate further. So long, I have found that

https://github.com/pytest-dev/pytest-qt/blob/cdad310e88bcabe0cd6eb21843125b43706b54f1/src/pytestqt/exceptions.py#L90-L95

always returns False because disabled is either "0" or "1" (as strings) or Mark(...), depending on whether I use pytest.mark.qt_no_exception_capture or qt_no_exception_capture = 1, all of which evaluate to True. That may explain some of the confusion regarding pytest-qt behavior.

bersbersbers commented 1 month ago

The PySide 6.8.0 changelog does not give much information on such differences: https://code.qt.io/cgit/pyside/pyside-setup.git/tree/doc/changelogs/changes-6.8.0

Also the list of commits between v6.8.0 (https://code.qt.io/cgit/pyside/pyside-setup.git/log/?h=v6.8.0) and v6.7.3 (https://code.qt.io/cgit/pyside/pyside-setup.git/log/?h=v6.7.3), less than 50, does not reveal anything special for exceptions.

Did not find anything in https://doc.qt.io/qt-6/whatsnew68.html, either.

penguinpee commented 1 month ago

In Fedora (rawhide) with PySide6 6.8.0 and Python 3.13.0 I'm seeing the following failures when running tests:

=================================== FAILURES ===================================
________________ test_catch_exceptions_in_virtual_methods[True] ________________
testdir = <Testdir local('/tmp/pytest-of-mockbuild/pytest-1/test_catch_exceptions_in_virtual_methods1')>
raise_error = True
    @pytest.mark.parametrize("raise_error", [False, True])
    def test_catch_exceptions_in_virtual_methods(testdir, raise_error):
        """
        Catch exceptions that happen inside Qt's event loop and make the
        tests fail if any.

        :type testdir: _pytest.pytester.TmpTestdir
        """
        testdir.makepyfile(
            """
            from pytestqt.qt_compat import qt_api

            class Receiver(qt_api.QtCore.QObject):

                def event(self, ev):
                    if {raise_error}:
                        try:
                            raise RuntimeError('original error')
                        except RuntimeError:
                            raise ValueError('mistakes were made')

                    return qt_api.QtCore.QObject.event(self, ev)

            def test_exceptions(qtbot):
                v = Receiver()
                app = qt_api.QtWidgets.QApplication.instance()
                app.sendEvent(v, qt_api.QtCore.QEvent(qt_api.QtCore.QEvent.Type.User))
                app.sendEvent(v, qt_api.QtCore.QEvent(qt_api.QtCore.QEvent.Type.User))
                app.processEvents()

        """.format(
                raise_error=raise_error
            )
        )
        result = testdir.runpytest()
        if raise_error:
            if qt_api.pytest_qt_api == "pyside6":
                # PySide6 automatically captures exceptions during the event loop,
                # and re-raises them when control gets back to Python.
                # This results in the exception not being captured by
                # us, and a more natural traceback which includes the app.sendEvent line.
                expected_lines = [
                    "*RuntimeError: original error",
                    "*app.sendEvent*",
                    "*ValueError: mistakes were made*",
                    "*1 failed*",
                ]
            else:
                expected_lines = [
                    "*Exceptions caught in Qt event loop:*",
                    "RuntimeError: original error",
                    "*ValueError: mistakes were made*",
                    "*1 failed*",
                ]
>           result.stdout.fnmatch_lines(expected_lines)
E           Failed: nomatch: '*RuntimeError: original error'
E               and: '============================= test session starts =============================='
E               and: 'platform linux -- Python 3.13.0, pytest-8.3.3, pluggy-1.5.0'
E               and: 'PyQtAPI 1.0 -- Qt runtime 2.5 -- Qt compiled 3.5'
E               and: 'rootdir: /tmp/pytest-of-mockbuild/pytest-1/test_catch_exceptions_in_virtual_methods1'
E               and: 'plugins: qt-4.4.0'
E               and: 'collected 1 item'
E               and: ''
E               and: 'test_catch_exceptions_in_virtual_methods.py F                            [100%]'
E               and: ''
E               and: '=================================== FAILURES ==================================='
E               and: '_______________________________ test_exceptions ________________________________'
E               and: ''
E               and: 'self = <test_catch_exceptions_in_virtual_methods.Receiver(0x564b3bc33870) at 0x7f604702f500>'
E               and: 'ev = <PySide6.QtCore.QEvent(QEvent::User)>'
E               and: ''
E               and: '    def event(self, ev):'
E               and: '        if True:'
E               and: '            try:'
E               and: ">               raise RuntimeError('original error')"
E           fnmatch: '*RuntimeError: original error'
E              with: 'E               RuntimeError: original error'
E           nomatch: '*app.sendEvent*'
E               and: ''
E               and: 'test_catch_exceptions_in_virtual_methods.py:8: RuntimeError'
E               and: ''
E               and: 'During handling of the above exception, another exception occurred:'
E               and: ''
E               and: 'qtbot = <pytestqt.qtbot.QtBot object at 0x7f60472343c0>'
E               and: ''
E               and: '    def test_exceptions(qtbot):'
E               and: '        v = Receiver()'
E               and: '        app = qt_api.QtWidgets.QApplication.instance()'
E           fnmatch: '*app.sendEvent*'
E              with: '>       app.sendEvent(v, qt_api.QtCore.QEvent(qt_api.QtCore.QEvent.Type.User))'
E           nomatch: '*ValueError: mistakes were made*'
E               and: ''
E               and: 'test_catch_exceptions_in_virtual_methods.py:18: '
E               and: '_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ '
E               and: ''
E               and: 'self = <test_catch_exceptions_in_virtual_methods.Receiver(0x564b3bc33870) at 0x7f604702f500>'
E               and: 'ev = <PySide6.QtCore.QEvent(QEvent::User)>'
E               and: ''
E               and: '    def event(self, ev):'
E               and: '        if True:'
E               and: '            try:'
E               and: "                raise RuntimeError('original error')"
E               and: '            except RuntimeError:'
E               and: ">               raise ValueError('mistakes were made')"
E               and: 'E               ValueError: Error calling Python override of QObject::event(): mistakes were made'
E               and: ''
E               and: 'test_catch_exceptions_in_virtual_methods.py:10: ValueError'
E               and: '=========================== short test summary info ============================'
E               and: 'FAILED test_catch_exceptions_in_virtual_methods.py::test_exceptions - ValueEr...'
E               and: '============================== 1 failed in 0.02s ==============================='
E           remains unmatched: '*ValueError: mistakes were made*'
/builddir/build/BUILD/python-pytest-qt-4.4.0-build/pytest-qt-4.4.0/tests/test_exceptions.py:72: Failed
----------------------------- Captured stdout call -----------------------------
============================= test session starts ==============================
platform linux -- Python 3.13.0, pytest-8.3.3, pluggy-1.5.0
PyQtAPI 1.0 -- Qt runtime 2.5 -- Qt compiled 3.5
rootdir: /tmp/pytest-of-mockbuild/pytest-1/test_catch_exceptions_in_virtual_methods1
plugins: qt-4.4.0
collected 1 item
test_catch_exceptions_in_virtual_methods.py F                            [100%]
=================================== FAILURES ===================================
_______________________________ test_exceptions ________________________________
self = <test_catch_exceptions_in_virtual_methods.Receiver(0x564b3bc33870) at 0x7f604702f500>
ev = <PySide6.QtCore.QEvent(QEvent::User)>
    def event(self, ev):
        if True:
            try:
>               raise RuntimeError('original error')
E               RuntimeError: original error
test_catch_exceptions_in_virtual_methods.py:8: RuntimeError
During handling of the above exception, another exception occurred:
qtbot = <pytestqt.qtbot.QtBot object at 0x7f60472343c0>
    def test_exceptions(qtbot):
        v = Receiver()
        app = qt_api.QtWidgets.QApplication.instance()
>       app.sendEvent(v, qt_api.QtCore.QEvent(qt_api.QtCore.QEvent.Type.User))
test_catch_exceptions_in_virtual_methods.py:18: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
self = <test_catch_exceptions_in_virtual_methods.Receiver(0x564b3bc33870) at 0x7f604702f500>
ev = <PySide6.QtCore.QEvent(QEvent::User)>
    def event(self, ev):
        if True:
            try:
                raise RuntimeError('original error')
            except RuntimeError:
>               raise ValueError('mistakes were made')
E               ValueError: Error calling Python override of QObject::event(): mistakes were made
test_catch_exceptions_in_virtual_methods.py:10: ValueError

This seems to be related. Maybe it helps in tracking it down.

bersbersbers commented 1 month ago

This is great. It doesn't help me directly, but it means the problem is not restricted to QShortcut (I had been unable before to come up with an example that does not involve QShortcut) and maybe helps pytest-qt guys explain what's going on.

bersbersbers commented 1 month ago

Since @penguinpee is also using Python 3.13, I quickly verified that I am getting the same test failures on Python 3.12, and I do:

Python 3.12/3.13 and PySide6 v6.8.0.1:

====================================================================================== short test summary info ====================================================================================== 
FAILED tests/test_exceptions.py::test_catch_exceptions_in_virtual_methods[True] - Failed: nomatch: '*RuntimeError: original error'
FAILED tests/test_exceptions.py::test_no_capture_preserves_custom_excepthook - Failed: nomatch: '*2 passed*'

On both Python versions, PySide6 v6.7.3 and v6.6.0 fails (only) test_no_capture_preserves_custom_excepthook, which means that

bersbersbers commented 1 month ago

In daily use, I find that v6.8.0(.1) behaves quite differently from 6.7.x in terms of exception handling. This is not a pytest-qt-only thing, I'd conclude by now. If anyone has a reference to a more general issue description, I'd be glad to know.

penguinpee commented 1 month ago

I must confess I hadn't looked into the issue at all. I was caught a bit by surprise when PySide 6.8.0 landed and "broke" pytest-qt.

It seems the test fails solely because of changed (more verbose?) output passed down.

Changing:

https://github.com/pytest-dev/pytest-qt/blob/cdad310e88bcabe0cd6eb21843125b43706b54f1/tests/test_exceptions.py#L62

to

"*ValueError: *mistakes were made*",

makes the test pass.

That makes it a different issue, I believe, then the one reported. I'm happy to split this off into a separate issue. The final fix might need to be a bit more sophisticated then simply extending the glob.

bersbersbers commented 1 month ago

That makes it a different issue, I believe, then the one reported. I'm happy to split this off into a separate issue.

I believe you are right. (I was, and still are, confused by the test failure message Failed: nomatch: '*RuntimeError: original error', but I tried your change and it makes the test pass.)

I'm all for fixing this in a separate issue.

bersbersbers commented 1 month ago

I think the original issue is independent of pytest-qt. I opened https://bugreports.qt.io/browse/PYSIDE-2900, and close this as a duplicate.