diff --git a/glue/external/qt.py b/glue/external/qt.py index f8f0cfd25..0d9440036 100644 --- a/glue/external/qt.py +++ b/glue/external/qt.py @@ -1,13 +1,50 @@ -""" A Qt API selector that can be used to switch between PyQt and PySide. +# qt-helpers - a common front-end to various Qt modules +# +# Copyright (c) 2015, Chris Beaumont and Thomas Robitaille +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the +# distribution. +# * Neither the name of the Glue project nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# This file includes code adapted from: +# +# * IPython, which is released under the modified BSD license +# (https://github.com/ipython/ipython/blob/master/COPYING.rst) +# +# * python_qt_binding, which is released under the BSD license +# (https://pypi.python.org/pypi/python_qt_binding) +# +# See also this discussion +# +# http://qt-project.org/wiki/Differences_Between_PySide_and_PyQt -This file lovingly borrows from the IPython and python_qt_binding project -https://github.com/ipython/ipython/blob/master/IPython/external/qt.py -https://github.com/ros-visualization/python_qt_binding/ - - -See also this discussion -http://qt-project.org/wiki/Differences_Between_PySide_and_PyQt +""" +This module provides a way to import from Python Qt wrappers in a uniform +way, regardless of whether PySide or PyQt is used. Do not use this if you need PyQt with the old QString/QVariant API. """ @@ -17,28 +54,48 @@ import os import sys +__all__ = ['QtCore', 'QtGui', 'is_pyside', 'is_pyqt4', 'is_pyqt5', 'load_ui', + 'QT_API_PYQT4', 'QT_API_PYQT5', 'QT_API_PYSIDE'] # Available APIs. -QT_API_PYQT = 'pyqt' +QT_API_PYQT4 = 'pyqt' QT_API_PYSIDE = 'pyside' +QT_API_PYQT5 = 'pyqt5' QT_API = None -# import hook to protect importing of both PySide and PyQt4 +def is_pyside(): + return QT_API == QT_API_PYSIDE -class ImportDenier(object): - __forbidden = set() - def __init__(self): - self.__forbidden = None +def is_pyqt4(): + return QT_API == QT_API_PYQT4 + + +def is_pyqt5(): + return QT_API == QT_API_PYQT5 + - def forbid(self, module_name): - self.__forbidden = module_name +# Backward-compatibility +is_pyqt = is_pyqt4 +QT_API_PYQT = QT_API_PYQT4 + +_forbidden = set() + + +def deny_module(module): + _forbidden.add(module) + + +class ImportDenier(object): + """ + Import hook to protect importing of both PySide and PyQt. + """ def find_module(self, mod_name, pth): - if pth: + if pth or not mod_name in _forbidden: return - if mod_name == self.__forbidden: + else: return self def load_module(self, mod_name): @@ -51,30 +108,34 @@ def load_module(self, mod_name): def prepare_pyqt4(): - # For PySide compatibility, use the new-style string API that automatically - # converts QStrings to Unicode Python strings. Also, automatically unpack - # QVariants to their underlying objects. + # For PySide compatibility, use the new-style string API that + # automatically converts QStrings to Unicode Python strings. Also, + # automatically unpack QVariants to their underlying objects. import sip sip.setapi('QString', 2) sip.setapi('QVariant', 2) +prepare_pyqt5 = prepare_pyqt4 + def register_module(module, modlabel): - """Register an imported module into a - submodule of glue.external.qt. Enables syntax like - from glue.qt.QtGui import QMessageBox """ - sys.modules[__name__ + '.' + modlabel] = module + Register an imported module into a submodule of qt_helpers. + This enables syntax such as: -def deny_module(mod_name): - _import_hook.forbid(mod_name) + >>> from qt_helpers.QtGui import QMessageBox + """ + sys.modules[__name__ + '.' + modlabel] = module def _load_pyqt4(): + prepare_pyqt4() + from PyQt4 import QtCore, QtGui, QtTest from distutils.version import LooseVersion + if LooseVersion(QtCore.PYQT_VERSION_STR) < LooseVersion('4.8'): raise ImportError("Glue Requires PyQt4 >= 4.8") @@ -91,13 +152,45 @@ def _load_pyqt4(): register_module(QtTest, 'QtTest') global QT_API - QT_API = QT_API_PYQT + QT_API = QT_API_PYQT4 deny_module('PySide') + deny_module('PyQt5') + + +def _load_pyqt5(): + + prepare_pyqt5() + + from PyQt5 import QtCore, QtGui, QtTest, QtWidgets + from distutils.version import LooseVersion + + QtCore.Signal = QtCore.pyqtSignal + QtCore.Slot = QtCore.pyqtSlot + QtCore.Property = QtCore.pyqtProperty + + # In PyQt5, some widgets such as QMessageBox have moved from QtGui to + # QWidgets so we add backward-compatibility hooks here for now + for widget in dir(QtWidgets): + if widget.startswith('Q'): + setattr(QtGui, widget, getattr(QtWidgets, widget)) + QtGui.QItemSelectionModel = QtCore.QItemSelectionModel + + register_module(QtCore, 'QtCore') + register_module(QtGui, 'QtGui') + register_module(QtTest, 'QtTest') + + global QT_API + QT_API = QT_API_PYQT5 + + deny_module('PySide') + deny_module('PyQt4') def _load_pyside(): + from PySide import QtCore, QtGui, __version__, QtTest + if __version__ < '1.0.3': # old PySide, fallback on PyQt raise ImportError("Glue requires PySide >= 1.0.3") @@ -114,34 +207,100 @@ def setMargin(self, x): QT_API = QT_API_PYSIDE deny_module('PyQt4') + deny_module('PyQt5') -loaders = [_load_pyqt4, _load_pyside] -if os.environ.get('QT_API') == QT_API_PYSIDE: - loaders = loaders[::-1] - -msgs = [] - -# acutally do the loading -for loader in loaders: - try: - loader() - # we set this env var, since IPython also looks for it - os.environ['QT_API'] = QT_API - QtCore = sys.modules[__name__ + '.QtCore'] - QtGui = sys.modules[__name__ + '.QtGui'] - break - except ImportError as e: - msgs.append(str(e)) - pass -else: - raise ImportError("Could not find a suitable QT installation." - " Encountered the following errors: %s" % - '\n'.join(msgs)) +QtCore = None +QtGui = None -def is_pyside(): - return QT_API == QT_API_PYSIDE + +def reload_qt(): + """ + Reload the Qt bindings. + + If the QT_API environment variable has been updated, this will load the + new Qt bindings given by this variable. This should be used instead of + the build-in ``reload`` function because the latter can in some cases + cause issues with the ImportDenier (which prevents users from importing + e.g. PySide if PyQt4 is loaded). + """ + + _forbidden.clear() + + global QtCore + global QtGui + + if os.environ.get('QT_API') == QT_API_PYQT5: + loaders = [_load_pyqt5] + elif os.environ.get('QT_API') == QT_API_PYSIDE: + loaders = [_load_pyside, _load_pyqt4] + else: + loaders = [_load_pyqt4, _load_pyside] + + msgs = [] + + # acutally do the loading + for loader in loaders: + try: + loader() + # we set this env var, since IPython also looks for it + os.environ['QT_API'] = QT_API + QtCore = sys.modules[__name__ + '.QtCore'] + QtGui = sys.modules[__name__ + '.QtGui'] + break + except ImportError as e: + msgs.append(str(e)) + pass + else: + raise ImportError("Could not find a suitable QT installation." + " Encountered the following errors: %s" % + '\n'.join(msgs)) + + +def load_ui(path, parent=None, custom_widgets=None): + if is_pyside(): + return _load_ui_pyside(path, parent, custom_widgets=custom_widgets) + elif is_pyqt5(): + return _load_ui_pyqt5(path, parent) + else: + return _load_ui_pyqt4(path, parent) + + +def _load_ui_pyside(path, parent, custom_widgets=None): + + from PySide.QtUiTools import QUiLoader + + loader = QUiLoader() + + # must register custom widgets referenced in .ui files + if custom_widgets is not None: + for w in custom_widgets: + loader.registerCustomWidget(w) + + widget = loader.load(path, parent) + + return widget + + +def _load_ui_pyqt4(path, parent): + from PyQt4.uic import loadUi + return loadUi(path, parent) + + +def _load_ui_pyqt5(path, parent): + from PyQt5.uic import loadUi + return loadUi(path, parent) + + +def get_qapp(icon_path=None): + qapp = QtGui.QApplication.instance() + if qapp is None: + qapp = QtGui.QApplication(['']) + qapp.setQuitOnLastWindowClosed(True) + if icon_path is not None: + qapp.setWindowIcon(QIcon(icon_path)) + return qapp -def is_pyqt(): - return QT_API == QT_API_PYQT +# Now load default Qt +reload_qt() diff --git a/glue/external/tests/test_qt.py b/glue/external/tests/test_qt.py deleted file mode 100644 index a3326b8b3..000000000 --- a/glue/external/tests/test_qt.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import absolute_import, division, print_function - -import os -import sys - -from .. import qt - -import pytest -from mock import MagicMock - - -""" -We don't run these tests by default, since they import both PyQt4 and -PySide, and this brings all manner of sadness to subsequent tests. - -To run these tests, run `py.test --qtapi` -""" - - -@pytest.mark.skipif("'--qtapi' not in sys.argv") -class TestQT(object): - def teardown_class(cls): - for m in sys.modules.keys(): - if m.startswith('PyQt4') or m.startswith('PySide'): - sys.modules.pop(m) - - def setup_method(self, method): - qt.deny_module(None) - os.environ.pop('QT_API') - - def test_defaults_to_qt4(self): - reload(qt) - assert qt.QT_API == qt.QT_API_PYQT - - def _load_qt4(self): - os.environ['QT_API'] = qt.QT_API_PYQT - reload(qt) - - def _load_pyside(self): - os.environ['QT_API'] = qt.QT_API_PYSIDE - reload(qt) - - def test_overridden_with_env(self): - os.environ['QT_API'] = qt.QT_API_PYSIDE - reload(qt) - assert qt.QT_API == qt.QT_API_PYSIDE - - def test_main_import(self): - self._load_qt4() - from ..qt import QtCore - from ..qt import QtGui - - from PyQt4 import QtCore as core, QtGui as gui - assert QtCore is core - assert QtGui is gui - - self._load_pyside() - from ..qt import QtCore - from ..qt import QtGui - - from PySide import QtCore as core, QtGui as gui - assert QtCore is core - assert QtGui is gui - - def test_submodule_import(self): - self._load_qt4() - from ..qt.QtGui import QMessageBox - from ..qt.QtCore import Qt - from PyQt4.QtGui import QMessageBox as qmb - from PyQt4.QtCore import Qt as _qt - assert qmb is QMessageBox - assert _qt is Qt - - self._load_pyside() - from ..qt.QtGui import QMessageBox - from ..qt.QtCore import Qt - - from PySide.QtGui import QMessageBox as qmb - from PySide.QtCore import Qt as _qt - assert qmb is QMessageBox - assert _qt is Qt - - def test_signal_slot_property(self): - self._load_qt4() - from ..qt.QtCore import Signal, Slot, Property - - def test_qt4_unavailable(self): - import PyQt4 - try: - sys.modules['PyQt4'] = None - self._load_qt4() - assert qt.QT_API == qt.QT_API_PYSIDE - finally: - sys.modules['PyQt4'] = PyQt4 - - def test_pyside_unavailable(self): - import PySide - try: - sys.modules['PySide'] = None - self._load_pyside() - assert qt.QT_API == qt.QT_API_PYQT - finally: - sys.modules['PySide'] = PySide - - def test_both_unavailable(self): - import PySide - import PyQt4 - try: - sys.modules['PySide'] = None - sys.modules['PyQt4'] = None - with pytest.raises(ImportError) as e: - reload(qt) - finally: - sys.modules['PySide'] = PySide - sys.modules['PyQt4'] = PyQt4 diff --git a/glue/qt/__init__.py b/glue/qt/__init__.py index 8b2b508cf..e041e384b 100644 --- a/glue/qt/__init__.py +++ b/glue/qt/__init__.py @@ -1,16 +1,7 @@ -from ..external.qt.QtGui import QApplication, QIcon - import os -def get_qapp(): - qapp = QApplication.instance() - if qapp is None: - qapp = QApplication(['']) - qapp.setQuitOnLastWindowClosed(True) - pth = os.path.abspath(os.path.dirname(__file__)) - pth = os.path.join(pth, 'icons', 'app_icon.png') - qapp.setWindowIcon(QIcon(pth)) - return qapp +# For backward compatibility, we import get_qapp here +from ..external.qt import get_qapp def teardown(): @@ -22,5 +13,3 @@ def teardown(): _app = get_qapp() import atexit atexit.register(teardown) - -#from .glue_application import GlueApplication diff --git a/glue/qt/glue_application.py b/glue/qt/glue_application.py index ae6ec0b2c..de555c386 100644 --- a/glue/qt/glue_application.py +++ b/glue/qt/glue_application.py @@ -12,7 +12,7 @@ QToolButton, QVBoxLayout, QWidget, QPixmap, QBrush, QPainter, QLabel, QHBoxLayout, QTextEdit, QTextCursor, QPushButton, - QListWidgetItem) + QListWidgetItem, QIcon) from ..external.qt.QtCore import Qt, QSize, QSettings, Signal from ..core import command, Data @@ -186,6 +186,10 @@ def __init__(self, data_collection=None, session=None): session=session) self.app = get_qapp() + self.app.setQuitOnLastWindowClosed(True) + pth = os.path.abspath(os.path.dirname(__file__)) + pth = os.path.join(pth, 'icons', 'app_icon.png') + self.app.setWindowIcon(QIcon(pth)) self.setWindowIcon(self.app.windowIcon()) self.setAttribute(Qt.WA_DeleteOnClose) diff --git a/glue/qt/qtutil.py b/glue/qt/qtutil.py index f886b8f9f..711172674 100644 --- a/glue/qt/qtutil.py +++ b/glue/qt/qtutil.py @@ -707,24 +707,6 @@ def _custom_widgets(): yield LinkEquation -def _load_ui_pyside(path, parent): - from PySide.QtUiTools import QUiLoader - loader = QUiLoader() - - # must register custom widgets referenced in .ui files - for w in _custom_widgets(): - loader.registerCustomWidget(w) - - widget = loader.load(path, parent) - - return widget - - -def _load_ui_pyqt4(path, parent): - from PyQt4.uic import loadUi - return loadUi(path, parent) - - def load_ui(path, parent=None): """ Load a UI file, given its name. @@ -749,10 +731,8 @@ def load_ui(path, parent=None): if not os.path.exists(path): path = global_ui_path(path) - if is_pyside(): - return _load_ui_pyside(path, parent) - else: - return _load_ui_pyqt4(path, parent) + from ..external.qt import load_ui + return load_ui(path, parent, custom_widgets=_custom_widgets()) def global_ui_path(ui_name):