Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
1.9.0
-----

- Exception capturing now happens as early/late as possible in order to catch
all possible exceptions (including fixtures)(`105`_). Thanks
`@The-Compiler`_ for the request.

- Widgets registered by ``qtbot.addWidget`` are now closed before all other
fixtures are tear down (`106`_). Thanks `@The-Compiler`_ for request.

.. _105: https://github.com/pytest-dev/pytest-qt/issues/105
.. _106: https://github.com/pytest-dev/pytest-qt/issues/106


1.8.0
-----

Expand Down Expand Up @@ -244,4 +258,4 @@ First working version.
.. _@datalyze-solutions: https://github.com/datalyze-solutions
.. _@fabioz: https://github.com/fabioz
.. _@baudren: https://github.com/baudren
.. _@itghisi: https://github.com/itghisi
.. _@itghisi: https://github.com/itghisi
63 changes: 51 additions & 12 deletions pytestqt/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from contextlib import contextmanager
import sys
import traceback
import pytest


@contextmanager
Expand All @@ -10,17 +11,54 @@ def capture_exceptions():
and returns them as a list of (type, value, traceback) after the
context ends.
"""
result = []

def hook(type_, value, tback):
result.append((type_, value, tback))

old_hook = sys.excepthook
sys.excepthook = hook
manager = _QtExceptionCaptureManager()
manager.start()
try:
yield result
yield manager.exceptions
finally:
sys.excepthook = old_hook
manager.finish()


class _QtExceptionCaptureManager(object):
"""
Manages exception capture context.
"""

def __init__(self):
self.old_hook = None
self.exceptions = []

def start(self):
"""Start exception capturing by installing a hook into sys.excepthook
that records exceptions received into ``self.exceptions``.
"""
def hook(type_, value, tback):
self.exceptions.append((type_, value, tback))

self.old_hook = sys.excepthook
sys.excepthook = hook

def finish(self):
"""Stop exception capturing, restoring the original hook.

Can be called multiple times.
"""
if self.old_hook is not None:
sys.excepthook = self.old_hook
self.old_hook = None

def fail_if_exceptions_occurred(self, when):
"""calls pytest.fail() with an informative message if exceptions
have been captured so far. Before pytest.fail() is called, also
finish capturing.
"""
if self.exceptions:
self.finish()
exceptions = self.exceptions
self.exceptions = []
prefix = '%s ERROR: ' % when
pytest.fail(prefix + format_captured_exceptions(exceptions),
pytrace=False)


def format_captured_exceptions(exceptions):
Expand All @@ -37,8 +75,9 @@ def format_captured_exceptions(exceptions):
return message


def _is_exception_capture_disabled(item):
def _is_exception_capture_enabled(item):
"""returns if exception capture is disabled for the given test item.
"""
return item.get_marker('qt_no_exception_capture') or \
item.config.getini('qt_no_exception_capture')
disabled = item.get_marker('qt_no_exception_capture') or \
item.config.getini('qt_no_exception_capture')
return not disabled
78 changes: 49 additions & 29 deletions pytestqt/plugin.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import pytest

from pytestqt.exceptions import capture_exceptions, format_captured_exceptions, \
_is_exception_capture_disabled
_is_exception_capture_enabled, _QtExceptionCaptureManager
from pytestqt.logging import QtLoggingPlugin, _QtMessageCapture, Record
from pytestqt.qt_compat import QApplication, QT_API
from pytestqt.qtbot import QtBot
from pytestqt.qtbot import QtBot, _close_widgets
from pytestqt.wait_signal import SignalBlocker, MultiSignalBlocker, SignalTimeoutError

# modules imported here just for backward compatibility before we have split the implementation
# of this file in several modules
# classes/functions imported here just for backward compatibility before we
# split the implementation of this file in several modules
assert SignalBlocker
assert MultiSignalBlocker
assert SignalTimeoutError
assert Record
assert capture_exceptions
assert format_captured_exceptions


@pytest.yield_fixture(scope='session')
Expand All @@ -34,25 +36,16 @@ def qapp():
_qapp_instance = None


@pytest.yield_fixture
@pytest.fixture
def qtbot(qapp, request):
"""
Fixture used to create a QtBot instance for using during testing.

Make sure to call addWidget for each top-level widget you create to ensure
that they are properly closed after the test ends.
"""
result = QtBot()
no_capture = _is_exception_capture_disabled(request.node)
if no_capture:
yield result # pragma: no cover
else:
with capture_exceptions() as exceptions:
yield result
if exceptions:
pytest.fail(format_captured_exceptions(exceptions), pytrace=False)

result._close()
result = QtBot(request)
return result


@pytest.fixture
Expand Down Expand Up @@ -91,31 +84,58 @@ def pytest_addoption(parser):


@pytest.mark.hookwrapper
@pytest.mark.tryfirst
def pytest_runtest_setup(item):
"""
Hook called after before test setup starts, to start capturing exceptions
as early as possible.
"""
capture_enabled = _is_exception_capture_enabled(item)
if capture_enabled:
item.qt_exception_capture_manager = _QtExceptionCaptureManager()
item.qt_exception_capture_manager.start()
yield
_process_events()
if capture_enabled:
item.qt_exception_capture_manager.fail_if_exceptions_occurred('SETUP')


@pytest.mark.hookwrapper
@pytest.mark.tryfirst
def pytest_runtest_call(item):
yield
_process_events()
capture_enabled = _is_exception_capture_enabled(item)
if capture_enabled:
item.qt_exception_capture_manager.fail_if_exceptions_occurred('CALL')


@pytest.mark.hookwrapper
@pytest.mark.trylast
def pytest_runtest_teardown(item):
"""
Hook called after each test tear down, to process any pending events and
avoiding leaking events to the next test.
avoiding leaking events to the next test. Also, if exceptions have
been captured during fixtures teardown, fail the test.
"""
_process_events(item)
_process_events()
_close_widgets(item)
_process_events()
yield
_process_events(item)
_process_events()
capture_enabled = _is_exception_capture_enabled(item)
if capture_enabled:
item.qt_exception_capture_manager.fail_if_exceptions_occurred('TEARDOWN')
item.qt_exception_capture_manager.finish()


def _process_events(item):
def _process_events():
"""Calls app.processEvents() while taking care of capturing exceptions
or not based on the given item's configuration.
"""
app = QApplication.instance()
if app is not None:
if _is_exception_capture_disabled(item):
app.processEvents()
else:
with capture_exceptions() as exceptions:
app.processEvents()
if exceptions:
pytest.fail('TEARDOWN ERROR: ' +
format_captured_exceptions(exceptions),
pytrace=False)
app.processEvents()


def pytest_configure(config):
Expand Down
51 changes: 35 additions & 16 deletions pytestqt/qtbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,19 +156,8 @@ class QtBot(object):

"""

def __init__(self):
self._widgets = [] # list of weakref to QWidget instances

def _close(self):
"""
Clear up method. Called at the end of each test that uses a ``qtbot`` fixture.
"""
for w in self._widgets:
w = w()
if w is not None:
w.close()
w.deleteLater()
self._widgets[:] = []
def __init__(self, request):
self._request = request

def addWidget(self, widget):
"""
Expand All @@ -178,7 +167,7 @@ def addWidget(self, widget):
:param QWidget widget:
Widget to keep track of.
"""
self._widgets.append(weakref.ref(widget))
_add_widget(self._request.node, widget)

add_widget = addWidget # pep-8 alias

Expand Down Expand Up @@ -216,7 +205,7 @@ def stopForInteraction(self):
.. note:: As a convenience, it is also aliased as `stop`.
"""
widget_and_visibility = []
for weak_widget in self._widgets:
for weak_widget in _iter_widgets(self._request.node):
widget = weak_widget()
if widget is not None:
widget_and_visibility.append((widget, widget.isVisible()))
Expand Down Expand Up @@ -324,4 +313,34 @@ def waitSignals(self, signals=None, timeout=1000, raising=False):


# provide easy access to SignalTimeoutError to qtbot fixtures
QtBot.SignalTimeoutError = SignalTimeoutError
QtBot.SignalTimeoutError = SignalTimeoutError


def _add_widget(item, widget):
"""
Register a widget into the given pytest item for later closing.
"""
qt_widgets = getattr(item, 'qt_widgets', [])
qt_widgets.append(weakref.ref(widget))
item.qt_widgets = qt_widgets


def _close_widgets(item):
"""
Close all widgets registered in the pytest item.
"""
widgets = getattr(item, 'qt_widgets', None)
if widgets:
for w in item.qt_widgets:
w = w()
if w is not None:
w.close()
w.deleteLater()
del item.qt_widgets


def _iter_widgets(item):
"""
Iterates over widgets registered in the given pytest item.
"""
return iter(getattr(item, 'qt_widgets', []))
33 changes: 33 additions & 0 deletions tests/test_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,39 @@ def test_public_api_backward_compatibility():
assert pytestqt.plugin.Record


def test_widgets_closed_before_fixtures(testdir):
"""
Ensure widgets added by "qtbot.add_widget" are closed before all other
fixtures are teardown. (#106).
"""
testdir.makepyfile('''
import pytest
from pytestqt.qt_compat import QWidget

class Widget(QWidget):

closed = False

def closeEvent(self, e):
e.accept()
self.closed = True

@pytest.yield_fixture
def widget(qtbot):
w = Widget()
qtbot.add_widget(w)
yield w
assert w.closed

def test_foo(widget):
pass
''')
result = testdir.runpytest()
result.stdout.fnmatch_lines([
'*= 1 passed in *'
])


class EventRecorder(QWidget):

"""
Expand Down
Loading