diff options
Diffstat (limited to 'silx/gui/utils')
-rw-r--r-- | silx/gui/utils/_image.py | 104 | ||||
-rw-r--r-- | silx/gui/utils/image.py | 143 | ||||
-rw-r--r-- | silx/gui/utils/test/test_async.py | 2 | ||||
-rw-r--r-- | silx/gui/utils/test/test_image.py | 50 | ||||
-rw-r--r-- | silx/gui/utils/testutils.py | 520 |
5 files changed, 697 insertions, 122 deletions
diff --git a/silx/gui/utils/_image.py b/silx/gui/utils/_image.py deleted file mode 100644 index 260aac3..0000000 --- a/silx/gui/utils/_image.py +++ /dev/null @@ -1,104 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides convenient functions to use with Qt objects. - -It provides: -- conversion between numpy and QImage: - :func:`convertArrayToQImage`, :func:`convertQImageToArray` -""" - -from __future__ import division - - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "16/01/2017" - - -import sys -import numpy - -from .. import qt - - -def convertArrayToQImage(image): - """Convert an array-like RGB888 image to a QImage. - - The created QImage is using a copy of the array data. - - Limitation: Only supports RGB888 format. - - :param image: Array-like image data - :type image: numpy.ndarray of uint8 of dimension HxWx3 - :return: Corresponding Qt image - :rtype: QImage - """ - # Possible extension: add a format argument to support more formats - - image = numpy.array(image, copy=False, order='C', dtype=numpy.uint8) - - height, width, depth = image.shape - assert depth == 3 - - qimage = qt.QImage( - image.data, - width, - height, - image.strides[0], # bytesPerLine - qt.QImage.Format_RGB888) - - return qimage.copy() # Making a copy of the image and its data - - -def convertQImageToArray(image): - """Convert a RGB888 QImage to a numpy array. - - Limitation: Only supports RGB888 format. - If QImage is not RGB888 it gets converted to this format. - - :param QImage: The QImage to convert. - :return: The image array - :rtype: numpy.ndarray of uint8 of shape HxWx3 - """ - # Possible extension: avoid conversion to support more formats - - if image.format() != qt.QImage.Format_RGB888: - # Convert to RGB888 if needed - image = image.convertToFormat(qt.QImage.Format_RGB888) - - ptr = image.bits() - if qt.BINDING not in ('PySide', 'PySide2'): - ptr.setsize(image.byteCount()) - if qt.BINDING == 'PyQt4' and sys.version_info[0] == 2: - ptr = ptr.asstring() - elif sys.version_info[0] == 3: # PySide with Python3 - ptr = ptr.tobytes() - - array = numpy.fromstring(ptr, dtype=numpy.uint8) - - # Lines are 32 bits aligned: remove padding bytes - array = array.reshape(image.height(), -1)[:, :image.width() * 3] - array.shape = image.height(), image.width(), 3 - return array diff --git a/silx/gui/utils/image.py b/silx/gui/utils/image.py new file mode 100644 index 0000000..3ac737f --- /dev/null +++ b/silx/gui/utils/image.py @@ -0,0 +1,143 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides conversions between numpy.ndarray and QImage + +- :func:`convertArrayToQImage` +- :func:`convertQImageToArray` +""" + +from __future__ import division + + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "04/09/2018" + + +import sys +import numpy +from numpy.lib.stride_tricks import as_strided as _as_strided + +from .. import qt + + +def convertArrayToQImage(array): + """Convert an array-like image to a QImage. + + The created QImage is using a copy of the array data. + + Limitation: Only RGB or RGBA images with 8 bits per channel are supported. + + :param array: Array-like image data of shape (height, width, channels) + Channels are expected to be either RGB or RGBA. + :type array: numpy.ndarray of uint8 + :return: Corresponding Qt image with RGB888 or ARGB32 format. + :rtype: QImage + """ + array = numpy.array(array, copy=False, order='C', dtype=numpy.uint8) + + if array.ndim != 3 or array.shape[2] not in (3, 4): + raise ValueError( + 'Image must be a 3D array with 3 or 4 channels per pixel') + + if array.shape[2] == 4: + format_ = qt.QImage.Format_ARGB32 + # RGBA -> ARGB + take care of endianness + if sys.byteorder == 'little': # RGBA -> BGRA + array = array[:, :, (2, 1, 0, 3)] + else: # big endian: RGBA -> ARGB + array = array[:, :, (3, 0, 1, 2)] + + array = numpy.array(array, order='C') # Make a contiguous array + + else: # array.shape[2] == 3 + format_ = qt.QImage.Format_RGB888 + + height, width, depth = array.shape + qimage = qt.QImage( + array.data, + width, + height, + array.strides[0], # bytesPerLine + format_) + + return qimage.copy() # Making a copy of the image and its data + + +def convertQImageToArray(image): + """Convert a QImage to a numpy array. + + If QImage format is not Format_RGB888, Format_RGBA8888 or Format_ARGB32, + it is first converted to one of this format depending on + the presence of an alpha channel. + + The created numpy array is using a copy of the QImage data. + + :param QImage image: The QImage to convert. + :return: The image array of RGB or RGBA channels of shape + (height, width, channels (3 or 4)) + :rtype: numpy.ndarray of uint8 + """ + rgba8888 = getattr(qt.QImage, 'Format_RGBA8888', None) # Only in Qt5 + + # Convert to supported format if needed + if image.format() not in (qt.QImage.Format_ARGB32, + qt.QImage.Format_RGB888, + rgba8888): + if image.hasAlphaChannel(): + image = image.convertToFormat( + rgba8888 if rgba8888 is not None else qt.QImage.Format_ARGB32) + else: + image = image.convertToFormat(qt.QImage.Format_RGB888) + + format_ = image.format() + channels = 3 if format_ == qt.QImage.Format_RGB888 else 4 + + ptr = image.bits() + if qt.BINDING not in ('PySide', 'PySide2'): + ptr.setsize(image.byteCount()) + if qt.BINDING == 'PyQt4' and sys.version_info[0] == 2: + ptr = ptr.asstring() + elif sys.version_info[0] == 3: # PySide with Python3 + ptr = ptr.tobytes() + + # Create an array view on QImage internal data + view = _as_strided( + numpy.frombuffer(ptr, dtype=numpy.uint8), + shape=(image.height(), image.width(), channels), + strides=(image.bytesPerLine(), channels, 1)) + + if format_ == qt.QImage.Format_ARGB32: + # Convert from ARGB to RGBA + # Not a byte-ordered format: do care about endianness + if sys.byteorder == 'little': # BGRA -> RGBA + view = view[:, :, (2, 1, 0, 3)] + else: # big endian: ARGB -> RGBA + view = view[:, :, (1, 2, 3, 0)] + + # Format_RGB888 and Format_RGBA8888 do not need reshuffling channels: + # They are byte-ordered and already in the right order + + return numpy.array(view, copy=True, order='C') diff --git a/silx/gui/utils/test/test_async.py b/silx/gui/utils/test/test_async.py index fd32a3f..dabfb3c 100644 --- a/silx/gui/utils/test/test_async.py +++ b/silx/gui/utils/test/test_async.py @@ -35,7 +35,7 @@ import unittest from silx.third_party.concurrent_futures import wait from silx.gui import qt -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui.utils import concurrent diff --git a/silx/gui/utils/test/test_image.py b/silx/gui/utils/test/test_image.py index 7cba1b0..cda7d95 100644 --- a/silx/gui/utils/test/test_image.py +++ b/silx/gui/utils/test/test_image.py @@ -32,35 +32,51 @@ import numpy import unittest from silx.gui import qt -from silx.gui.test.utils import TestCaseQt -from silx.gui.utils import _image +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.utils.image import convertArrayToQImage, convertQImageToArray -class TestQImageConversion(TestCaseQt): +class TestQImageConversion(TestCaseQt, ParametricTestCase): """Tests conversion of QImage to/from numpy array.""" def testConvertArrayToQImage(self): """Test conversion of numpy array to QImage""" - image = numpy.ones((3, 3, 3), dtype=numpy.uint8) - qimage = _image.convertArrayToQImage(image) + for format_, channels in [('Format_RGB888', 3), + ('Format_ARGB32', 4)]: + with self.subTest(format_): + image = numpy.arange( + 3*3*channels, dtype=numpy.uint8).reshape(3, 3, channels) + qimage = convertArrayToQImage(image) - self.assertEqual(qimage.height(), image.shape[0]) - self.assertEqual(qimage.width(), image.shape[1]) - self.assertEqual(qimage.format(), qt.QImage.Format_RGB888) + self.assertEqual(qimage.height(), image.shape[0]) + self.assertEqual(qimage.width(), image.shape[1]) + self.assertEqual(qimage.format(), getattr(qt.QImage, format_)) + + for row in range(3): + for col in range(3): + # Qrgb has no alpha channel, not compared + # Qt uses x,y while array is row,col... + self.assertEqual(qt.QColor(qimage.pixel(col, row)), + qt.QColor(*image[row, col, :3])) - color = qt.QColor(1, 1, 1).rgb() - self.assertEqual(qimage.pixel(1, 1), color) def testConvertQImageToArray(self): """Test conversion of QImage to numpy array""" - qimage = qt.QImage(3, 3, qt.QImage.Format_RGB888) - qimage.fill(0x010101) - image = _image.convertQImageToArray(qimage) + for format_, channels in [ + ('Format_RGB888', 3), # Native support + ('Format_ARGB32', 4), # Native support + ('Format_RGB32', 3)]: # Conversion to RGB + with self.subTest(format_): + color = numpy.arange(channels) # RGB(A) values + qimage = qt.QImage(3, 3, getattr(qt.QImage, format_)) + qimage.fill(qt.QColor(*color)) + image = convertQImageToArray(qimage) - self.assertEqual(qimage.height(), image.shape[0]) - self.assertEqual(qimage.width(), image.shape[1]) - self.assertEqual(image.shape[2], 3) - self.assertTrue(numpy.all(numpy.equal(image, 1))) + self.assertEqual(qimage.height(), image.shape[0]) + self.assertEqual(qimage.width(), image.shape[1]) + self.assertEqual(image.shape[2], len(color)) + self.assertTrue(numpy.all(numpy.equal(image, color))) def suite(): diff --git a/silx/gui/utils/testutils.py b/silx/gui/utils/testutils.py new file mode 100644 index 0000000..35085fc --- /dev/null +++ b/silx/gui/utils/testutils.py @@ -0,0 +1,520 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Helper class to write Qt widget unittests.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "05/10/2018" + + +import gc +import logging +import unittest +import time +import functools +import sys +import os + +_logger = logging.getLogger(__name__) + +from silx.gui import qt + +if qt.BINDING == 'PySide': + from PySide.QtTest import QTest +elif qt.BINDING == 'PySide2': + from PySide2.QtTest import QTest +elif qt.BINDING == 'PyQt5': + from PyQt5.QtTest import QTest +elif qt.BINDING == 'PyQt4': + from PyQt4.QtTest import QTest +else: + raise ImportError('Unsupported Qt bindings') + +# Qt4/Qt5 compatibility wrapper +if qt.BINDING in ('PySide', 'PyQt4'): + _logger.info("QTest.qWaitForWindowExposed not available," + + "using QTest.qWaitForWindowShown instead.") + + def qWaitForWindowExposed(window, timeout=None): + """Mimic QTest.qWaitForWindowExposed for Qt4.""" + QTest.qWaitForWindowShown(window) + return True +else: + qWaitForWindowExposed = QTest.qWaitForWindowExposed + + +def qWaitForWindowExposedAndActivate(window, timeout=None): + """Waits until the window is shown in the screen. + + It also activates the window and raises it. + + See QTest.qWaitForWindowExposed for details. + """ + if timeout is None: + result = qWaitForWindowExposed(window) + else: + result = qWaitForWindowExposed(window, timeout) + + if result: + # Makes sure window is active and on top + window.activateWindow() + window.raise_() + + return result + + +# Placeholder for QApplication +_qapp = None + + +class TestCaseQt(unittest.TestCase): + """Base class to write test for Qt stuff. + + It creates a QApplication before running the tests. + WARNING: The QApplication is shared by all tests, which might have side + effects. + + After each test, this class is checking for widgets remaining alive. + To allow some widgets to remain alive at the end of a test, set the + allowedLeakingWidgets attribute to the number of widgets that can remain + alive at the end of the test. + With PySide, this test is not run for now as it seems PySide + is leaking widgets internally. + + All keyboard and mouse event simulation methods call qWait(20) after + simulating the event (as QTest does on Mac OSX). + This was introduced to fix issues with continuous integration tests + running with Xvfb on Linux. + """ + + DEFAULT_TIMEOUT_WAIT = 100 + """Default timeout for qWait""" + + TIMEOUT_WAIT = 0 + """Extra timeout in millisecond to add to qSleep, qWait and + qWaitForWindowExposed. + + Intended purpose is for debugging, to add extra time to waits in order to + allow to view the tested widgets. + """ + + @classmethod + def exceptionHandler(cls, exceptionClass, exception, stack): + import traceback + message = (''.join(traceback.format_tb(stack))) + template = 'Traceback (most recent call last):\n{2}{0}: {1}' + message = template.format(exceptionClass.__name__, exception, message) + cls._exceptions.append(message) + + @classmethod + def setUpClass(cls): + """Makes sure Qt is inited""" + cls._oldExceptionHook = sys.excepthook + sys.excepthook = cls.exceptionHandler + + global _qapp + if _qapp is None: + # Makes sure a QApplication exists and do it once for all + _qapp = qt.QApplication.instance() or qt.QApplication([]) + + # Makes sure QDesktopWidget is init + # Otherwise it happens randomly during the tests + cls._desktopWidget = _qapp.desktop() + _qapp.processEvents() + + @classmethod + def tearDownClass(cls): + sys.excepthook = cls._oldExceptionHook + + def setUp(self): + """Get the list of existing widgets.""" + self.allowedLeakingWidgets = 0 + self.__previousWidgets = self.qapp.allWidgets() + self.__class__._exceptions = [] + + def _currentTestSucceeded(self): + if hasattr(self, '_outcome'): + # For Python >= 3.4 + result = self.defaultTestResult() # these 2 methods have no side effects + self._feedErrorsToResult(result, self._outcome.errors) + else: + # For Python < 3.4 + result = getattr(self, '_outcomeForDoCleanups', self._resultForDoCleanups) + + skipped = self.id() in [case.id() for case, _ in result.skipped] + error = self.id() in [case.id() for case, _ in result.errors] + failure = self.id() in [case.id() for case, _ in result.failures] + return not error and not failure and not skipped + + def _checkForUnreleasedWidgets(self): + """Test fixture checking that no more widgets exists.""" + gc.collect() + + widgets = [widget for widget in self.qapp.allWidgets() + if widget not in self.__previousWidgets] + del self.__previousWidgets + + if qt.BINDING in ('PySide', 'PySide2'): + return # Do not test for leaking widgets with PySide + + allowedLeakingWidgets = self.allowedLeakingWidgets + self.allowedLeakingWidgets = 0 + + if widgets and len(widgets) <= allowedLeakingWidgets: + _logger.info( + '%s: %d remaining widgets after test' % (self.id(), + len(widgets))) + + if len(widgets) > allowedLeakingWidgets: + raise RuntimeError( + "Test ended with widgets alive: %s" % str(widgets)) + + def tearDown(self): + if len(self.__class__._exceptions) > 0: + messages = "\n".join(self.__class__._exceptions) + raise AssertionError("Exception occured in Qt thread:\n" + messages) + + if self._currentTestSucceeded(): + self._checkForUnreleasedWidgets() + + @property + def qapp(self): + """The QApplication currently running.""" + return qt.QApplication.instance() + + # Proxy to QTest + + Press = QTest.Press + """Key press action code""" + + Release = QTest.Release + """Key release action code""" + + Click = QTest.Click + """Key click action code""" + + QTest = property(lambda self: QTest, + doc="""The Qt QTest class from the used Qt binding.""") + + def keyClick(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): + """Simulate clicking a key. + + See QTest.keyClick for details. + """ + QTest.keyClick(widget, key, modifier, delay) + self.qWait(20) + + def keyClicks(self, widget, sequence, modifier=qt.Qt.NoModifier, delay=-1): + """Simulate clicking a sequence of keys. + + See QTest.keyClick for details. + """ + QTest.keyClicks(widget, sequence, modifier, delay) + self.qWait(20) + + def keyEvent(self, action, widget, key, + modifier=qt.Qt.NoModifier, delay=-1): + """Sends a Qt key event. + + See QTest.keyEvent for details. + """ + QTest.keyEvent(action, widget, key, modifier, delay) + self.qWait(20) + + def keyPress(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): + """Sends a Qt key press event. + + See QTest.keyPress for details. + """ + QTest.keyPress(widget, key, modifier, delay) + self.qWait(20) + + def keyRelease(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): + """Sends a Qt key release event. + + See QTest.keyRelease for details. + """ + QTest.keyRelease(widget, key, modifier, delay) + self.qWait(20) + + def mouseClick(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate clicking a mouse button. + + See QTest.mouseClick for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() + QTest.mouseClick(widget, button, modifier, pos, delay) + self.qWait(20) + + def mouseDClick(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate double clicking a mouse button. + + See QTest.mouseDClick for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() + QTest.mouseDClick(widget, button, modifier, pos, delay) + self.qWait(20) + + def mouseMove(self, widget, pos=None, delay=-1): + """Simulate moving the mouse. + + See QTest.mouseMove for details. + """ + pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() + QTest.mouseMove(widget, pos, delay) + self.qWait(20) + + def mousePress(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate pressing a mouse button. + + See QTest.mousePress for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() + QTest.mousePress(widget, button, modifier, pos, delay) + self.qWait(20) + + def mouseRelease(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate releasing a mouse button. + + See QTest.mouseRelease for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() + QTest.mouseRelease(widget, button, modifier, pos, delay) + self.qWait(20) + + def qSleep(self, ms): + """Sleep for ms milliseconds, blocking the execution of the test. + + See QTest.qSleep for details. + """ + QTest.qSleep(ms + self.TIMEOUT_WAIT) + + @classmethod + def qWait(cls, ms=None): + """Waits for ms milliseconds, events will be processed. + + See QTest.qWait for details. + """ + if ms is None: + ms = cls.DEFAULT_TIMEOUT_WAIT + + if qt.BINDING in ('PySide', 'PySide2'): + # PySide has no qWait, provide a replacement + timeout = int(ms) + endTimeMS = int(time.time() * 1000) + timeout + while timeout > 0: + _qapp.processEvents(qt.QEventLoop.AllEvents, + maxtime=timeout) + timeout = endTimeMS - int(time.time() * 1000) + else: + QTest.qWait(ms + cls.TIMEOUT_WAIT) + + def qWaitForWindowExposed(self, window, timeout=None): + """Waits until the window is shown in the screen. + + See QTest.qWaitForWindowExposed for details. + """ + result = qWaitForWindowExposedAndActivate(window, timeout) + + if self.TIMEOUT_WAIT: + QTest.qWait(self.TIMEOUT_WAIT) + + return result + + _qobject_destroyed = False + + @classmethod + def _aboutToDestroy(cls): + cls._qobject_destroyed = True + + @classmethod + def qWaitForDestroy(cls, ref): + """ + Wait for Qt object destruction. + + Use a weakref as parameter to avoid any strong references to the + object. + + It have to be used as following. Removing the reference to the object + before calling the function looks to be expected, else + :meth:`deleteLater` will not work. + + .. code-block:: python + + ref = weakref.ref(self.obj) + self.obj = None + self.qWaitForDestroy(ref) + + :param weakref ref: A weakref to an object to avoid any reference + :return: True if the object was destroyed + :rtype: bool + """ + cls._qobject_destroyed = False + if qt.BINDING == 'PyQt4': + # Without this, QWidget will be still alive on PyQt4 + # (at least on Windows Python 2.7) + # If it is not skipped on PySide, silx.gui.dialog tests will + # segfault (at least on Windows Python 2.7) + import gc + gc.collect() + qobject = ref() + if qobject is None: + return True + qobject.destroyed.connect(cls._aboutToDestroy) + qobject.deleteLater() + qobject = None + for _ in range(10): + if cls._qobject_destroyed: + break + cls.qWait(10) + else: + _logger.debug("Object was not destroyed") + + return ref() is None + + def logScreenShot(self, level=logging.ERROR): + """Take a screenshot and log it into the logging system if the + logger is enabled for the expected level. + + The screenshot is stored in the directory "./build/test-debug", and + the logging system only log the path to this file. + + :param level: Logging level + """ + if not _logger.isEnabledFor(level): + return + basedir = os.path.abspath(os.path.join("build", "test-debug")) + if not os.path.exists(basedir): + os.makedirs(basedir) + filename = "Screenshot_%s.png" % self.id() + filename = os.path.join(basedir, filename) + + if not hasattr(self.qapp, "primaryScreen"): + # Qt4 + winId = qt.QApplication.desktop().winId() + pixmap = qt.QPixmap.grabWindow(winId) + else: + # Qt5 + screen = self.qapp.primaryScreen() + pixmap = screen.grabWindow(0) + pixmap.save(filename) + _logger.log(level, "Screenshot saved at %s", filename) + + +class SignalListener(object): + """Util to listen a Qt event and store parameters + """ + + def __init__(self): + self.__calls = [] + + def __call__(self, *args, **kargs): + self.__calls.append((args, kargs)) + + def clear(self): + """Clear stored data""" + self.__calls = [] + + def callCount(self): + """ + Returns how many times the listener was called. + + :rtype: int + """ + return len(self.__calls) + + def arguments(self, callIndex=None, argumentIndex=None): + """Returns positional arguments optionally filtered by call count id + or argument index. + + :param int callIndex: Index of the called data + :param int argumentIndex: Index of the positional argument. + """ + if callIndex is not None: + result = self.__calls[callIndex][0] + if argumentIndex is not None: + result = result[argumentIndex] + else: + result = [x[0] for x in self.__calls] + if argumentIndex is not None: + result = [x[argumentIndex] for x in result] + return result + + def karguments(self, callIndex=None, argumentName=None): + """Returns positional arguments optionally filtered by call count id + or name of the keyword argument. + + :param int callIndex: Index of the called data + :param int argumentName: Name of the keyword argument. + """ + if callIndex is not None: + result = self.__calls[callIndex][1] + if argumentName is not None: + result = result[argumentName] + else: + result = [x[1] for x in self.__calls] + if argumentName is not None: + result = [x[argumentName] for x in result] + return result + + def partial(self, *args, **kargs): + """Returns a new partial object which when called will behave like this + listener called with the positional arguments args and keyword + arguments keywords. If more arguments are supplied to the call, they + are appended to args. If additional keyword arguments are supplied, + they extend and override keywords. + """ + return functools.partial(self, *args, **kargs) + + +def getQToolButtonFromAction(action): + """Return a QToolButton corresponding to a QAction. + + :param QAction action: The QAction from which to get QToolButton. + :return: A QToolButton associated to action or None. + """ + for widget in action.associatedWidgets(): + if isinstance(widget, qt.QToolButton): + return widget + return None + + +def findChildren(parent, kind, name=None): + if qt.BINDING in ("PySide", "PySide2") and name is not None: + result = [] + for obj in parent.findChildren(kind): + if obj.objectName() == name: + result.append(obj) + return result + else: + return parent.findChildren(kind, name=name) |