summaryrefslogtreecommitdiff
path: root/silx/gui/utils
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/utils')
-rw-r--r--silx/gui/utils/__init__.py29
-rw-r--r--silx/gui/utils/concurrent.py103
-rw-r--r--silx/gui/utils/image.py143
-rw-r--r--silx/gui/utils/test/__init__.py48
-rw-r--r--silx/gui/utils/test/test_async.py136
-rw-r--r--silx/gui/utils/test/test_image.py90
-rw-r--r--silx/gui/utils/testutils.py520
7 files changed, 0 insertions, 1069 deletions
diff --git a/silx/gui/utils/__init__.py b/silx/gui/utils/__init__.py
deleted file mode 100644
index 51c4fac..0000000
--- a/silx/gui/utils/__init__.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 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.
-#
-# ###########################################################################*/
-"""Miscellaneous helpers for Qt"""
-
-__authors__ = ["T. Vincent"]
-__license__ = "MIT"
-__date__ = "09/03/2018"
diff --git a/silx/gui/utils/concurrent.py b/silx/gui/utils/concurrent.py
deleted file mode 100644
index 48fff91..0000000
--- a/silx/gui/utils/concurrent.py
+++ /dev/null
@@ -1,103 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 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 allows to run a function in Qt main thread from another thread
-"""
-
-__authors__ = ["T. Vincent"]
-__license__ = "MIT"
-__date__ = "09/03/2018"
-
-
-from silx.third_party.concurrent_futures import Future
-
-from .. import qt
-
-
-class _QtExecutor(qt.QObject):
- """Executor of tasks in Qt main thread"""
-
- __sigSubmit = qt.Signal(Future, object, tuple, dict)
- """Signal used to run tasks."""
-
- def __init__(self):
- super(_QtExecutor, self).__init__(parent=None)
-
- # Makes sure the executor lives in the main thread
- app = qt.QApplication.instance()
- assert app is not None
- mainThread = app.thread()
- if self.thread() != mainThread:
- self.moveToThread(mainThread)
-
- self.__sigSubmit.connect(self.__run)
-
- def submit(self, fn, *args, **kwargs):
- """Submit fn(*args, **kwargs) to Qt main thread
-
- :param callable fn: Function to call in main thread
- :return: Future object to retrieve result
- :rtype: concurrent.future.Future
- """
- future = Future()
- self.__sigSubmit.emit(future, fn, args, kwargs)
- return future
-
- def __run(self, future, fn, args, kwargs):
- """Run task in Qt main thread
-
- :param concurrent.future.Future future:
- :param callable fn: Function to run
- :param tuple args: Arguments
- :param dict kwargs: Keyword arguments
- """
- if not future.set_running_or_notify_cancel():
- return
-
- try:
- result = fn(*args, **kwargs)
- except BaseException as e:
- future.set_exception(e)
- else:
- future.set_result(result)
-
-
-_executor = None
-"""QObject running the tasks in main thread"""
-
-
-def submitToQtMainThread(fn, *args, **kwargs):
- """Run fn(args, kwargs) in Qt's main thread.
-
- If not called from the main thread, this is run asynchronously.
-
- :param callable fn: Function to call in main thread.
- :return: A future object to retrieve the result
- :rtype: concurrent.future.Future
- """
- global _executor
- if _executor is None: # Lazy-loading
- _executor = _QtExecutor()
-
- return _executor.submit(fn, *args, **kwargs)
diff --git a/silx/gui/utils/image.py b/silx/gui/utils/image.py
deleted file mode 100644
index 3ac737f..0000000
--- a/silx/gui/utils/image.py
+++ /dev/null
@@ -1,143 +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 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/__init__.py b/silx/gui/utils/test/__init__.py
deleted file mode 100644
index 9e50170..0000000
--- a/silx/gui/utils/test/__init__.py
+++ /dev/null
@@ -1,48 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 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.
-#
-# ###########################################################################*/
-"""silx.gui.utils tests"""
-
-
-__authors__ = ["T. Vincent"]
-__license__ = "MIT"
-__date__ = "24/04/2018"
-
-
-import unittest
-
-from . import test_async
-from . import test_image
-
-
-def suite():
- """Test suite for module silx.image.test"""
- test_suite = unittest.TestSuite()
- test_suite.addTest(test_async.suite())
- test_suite.addTest(test_image.suite())
- return test_suite
-
-
-if __name__ == '__main__':
- unittest.main(defaultTest='suite')
diff --git a/silx/gui/utils/test/test_async.py b/silx/gui/utils/test/test_async.py
deleted file mode 100644
index dabfb3c..0000000
--- a/silx/gui/utils/test/test_async.py
+++ /dev/null
@@ -1,136 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 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.
-#
-# ###########################################################################*/
-"""Test of async module."""
-
-__authors__ = ["T. Vincent"]
-__license__ = "MIT"
-__date__ = "09/03/2018"
-
-
-import threading
-import unittest
-
-
-from silx.third_party.concurrent_futures import wait
-from silx.gui import qt
-from silx.gui.utils.testutils import TestCaseQt
-
-from silx.gui.utils import concurrent
-
-
-class TestSubmitToQtThread(TestCaseQt):
- """Test submission of tasks to Qt main thread"""
-
- def setUp(self):
- # Reset executor to test lazy-loading in different conditions
- concurrent._executor = None
- super(TestSubmitToQtThread, self).setUp()
-
- def _task(self, value1, value2):
- return value1, value2
-
- def _taskWithException(self, *args, **kwargs):
- raise RuntimeError('task exception')
-
- def testFromMainThread(self):
- """Call submitToQtMainThread from the main thread"""
- value1, value2 = 0, 1
- future = concurrent.submitToQtMainThread(self._task, value1, value2=value2)
- self.assertTrue(future.done())
- self.assertEqual(future.result(1), (value1, value2))
- self.assertIsNone(future.exception(1))
-
- future = concurrent.submitToQtMainThread(self._taskWithException)
- self.assertTrue(future.done())
- with self.assertRaises(RuntimeError):
- future.result(1)
- self.assertIsInstance(future.exception(1), RuntimeError)
-
- def _threadedTest(self):
- """Function run in a thread for the tests"""
- value1, value2 = 0, 1
- future = concurrent.submitToQtMainThread(self._task, value1, value2=value2)
-
- wait([future], 3)
-
- self.assertTrue(future.done())
- self.assertEqual(future.result(1), (value1, value2))
- self.assertIsNone(future.exception(1))
-
- future = concurrent.submitToQtMainThread(self._taskWithException)
-
- wait([future], 3)
-
- self.assertTrue(future.done())
- with self.assertRaises(RuntimeError):
- future.result(1)
- self.assertIsInstance(future.exception(1), RuntimeError)
-
- def testFromPythonThread(self):
- """Call submitToQtMainThread from a Python thread"""
- thread = threading.Thread(target=self._threadedTest)
- thread.start()
- for i in range(100): # Loop over for 10 seconds
- self.qapp.processEvents()
- thread.join(0.1)
- if not thread.is_alive():
- break
- else:
- self.fail(('Thread task still running'))
-
- def testFromQtThread(self):
- """Call submitToQtMainThread from a Qt thread pool"""
- class Runner(qt.QRunnable):
- def __init__(self, fn):
- super(Runner, self).__init__()
- self._fn = fn
-
- def run(self):
- self._fn()
-
- def autoDelete(self):
- return True
-
- threadPool = qt.silxGlobalThreadPool()
- runner = Runner(self._threadedTest)
- threadPool.start(runner)
- for i in range(100): # Loop over for 10 seconds
- self.qapp.processEvents()
- done = threadPool.waitForDone(100)
- if done:
- break
- else:
- self.fail('Thread pool task still running')
-
-
-def suite():
- test_suite = unittest.TestSuite()
- test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
- TestSubmitToQtThread))
- return test_suite
-
-
-if __name__ == '__main__':
- unittest.main(defaultTest='suite')
diff --git a/silx/gui/utils/test/test_image.py b/silx/gui/utils/test/test_image.py
deleted file mode 100644
index cda7d95..0000000
--- a/silx/gui/utils/test/test_image.py
+++ /dev/null
@@ -1,90 +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.
-#
-# ###########################################################################*/
-"""Test of utils module."""
-
-__authors__ = ["T. Vincent"]
-__license__ = "MIT"
-__date__ = "16/01/2017"
-
-import numpy
-import unittest
-
-from silx.gui import qt
-from silx.utils.testutils import ParametricTestCase
-from silx.gui.utils.testutils import TestCaseQt
-from silx.gui.utils.image import convertArrayToQImage, convertQImageToArray
-
-
-class TestQImageConversion(TestCaseQt, ParametricTestCase):
- """Tests conversion of QImage to/from numpy array."""
-
- def testConvertArrayToQImage(self):
- """Test conversion of numpy array to QImage"""
- 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(), 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]))
-
-
- def testConvertQImageToArray(self):
- """Test conversion of QImage to numpy array"""
- 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], len(color))
- self.assertTrue(numpy.all(numpy.equal(image, color)))
-
-
-def suite():
- test_suite = unittest.TestSuite()
- test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
- TestQImageConversion))
- return test_suite
-
-
-if __name__ == '__main__':
- unittest.main(defaultTest='suite')
diff --git a/silx/gui/utils/testutils.py b/silx/gui/utils/testutils.py
deleted file mode 100644
index 35085fc..0000000
--- a/silx/gui/utils/testutils.py
+++ /dev/null
@@ -1,520 +0,0 @@
-# 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)