diff --git a/IPython/core/magics/basic.py b/IPython/core/magics/basic.py index 7dfa84c..0699994 100644 --- a/IPython/core/magics/basic.py +++ b/IPython/core/magics/basic.py @@ -493,8 +493,10 @@ Currently the magic system has the following functions:""", are supported: wxPython, PyQt4, PyGTK, Tk and Cocoa (OSX):: %gui wx # enable wxPython event loop integration - %gui qt4|qt # enable PyQt4 event loop integration - %gui qt5 # enable PyQt5 event loop integration + %gui qt # enable PyQt/PySide event loop integration + # with the latest version available. + %gui qt6 # enable PyQt6/PySide6 event loop integration + %gui qt5 # enable PyQt5/PySide2 event loop integration %gui gtk # enable PyGTK event loop integration %gui gtk3 # enable Gtk3 event loop integration %gui gtk4 # enable Gtk4 event loop integration diff --git a/IPython/external/qt_for_kernel.py b/IPython/external/qt_for_kernel.py index b3168f6..11e8862 100644 --- a/IPython/external/qt_for_kernel.py +++ b/IPython/external/qt_for_kernel.py @@ -23,6 +23,9 @@ if QT_API not set: else: use what QT_API says + Note that %gui's implementation will always set a `QT_API`, see + `IPython.terminal.pt_inputhooks.get_inputhook_name_and_func` + """ # NOTE: This is no longer an external, third-party module, and should be # considered part of IPython. For compatibility however, it is being kept in @@ -42,7 +45,6 @@ from IPython.external.qt_loaders import ( QT_API_PYQT5, QT_API_PYSIDE2, # QT4 - QT_API_PYQTv1, QT_API_PYQT, QT_API_PYSIDE, # default @@ -56,10 +58,6 @@ _qt_apis = ( # QT5 QT_API_PYQT5, QT_API_PYSIDE2, - # QT4 - QT_API_PYQTv1, - QT_API_PYQT, - QT_API_PYSIDE, # default QT_API_PYQT_DEFAULT, ) @@ -98,7 +96,7 @@ def get_options(): if loaded is not None: return [loaded] - mpl = sys.modules.get('matplotlib', None) + mpl = sys.modules.get("matplotlib", None) if mpl is not None and tuple(mpl.__version__.split(".")) < ("1", "0", "2"): # 1.0.1 only supports PyQt4 v1 @@ -113,8 +111,6 @@ def get_options(): QT_API_PYSIDE6, QT_API_PYQT5, QT_API_PYSIDE2, - QT_API_PYQT, - QT_API_PYSIDE, ] elif qt_api not in _qt_apis: raise RuntimeError("Invalid Qt API %r, valid values are: %r" % diff --git a/IPython/external/qt_loaders.py b/IPython/external/qt_loaders.py index 39ea298..c900c8f 100644 --- a/IPython/external/qt_loaders.py +++ b/IPython/external/qt_loaders.py @@ -24,6 +24,7 @@ QT_API_PYQT5 = 'pyqt5' QT_API_PYSIDE2 = 'pyside2' # Qt4 +# NOTE: Here for legacy matplotlib compatibility, but not really supported on the IPython side. QT_API_PYQT = "pyqt" # Force version 2 QT_API_PYQTv1 = "pyqtv1" # Force version 2 QT_API_PYSIDE = "pyside" @@ -367,23 +368,28 @@ def load_qt(api_options): commit_api(api) return result else: - raise ImportError(""" + raise ImportError( + """ Could not load requested Qt binding. Please ensure that - PyQt4 >= 4.7, PyQt5, PySide >= 1.0.3 or PySide2 is available, - and only one is imported per session. + PyQt4 >= 4.7, PyQt5, PyQt6, PySide >= 1.0.3, PySide2, or + PySide6 is available, and only one is imported per session. Currently-imported Qt library: %r - PyQt4 available (requires QtCore, QtGui, QtSvg): %s PyQt5 available (requires QtCore, QtGui, QtSvg, QtWidgets): %s - PySide >= 1.0.3 installed: %s + PyQt6 available (requires QtCore, QtGui, QtSvg, QtWidgets): %s PySide2 installed: %s + PySide6 installed: %s Tried to load: %r - """ % (loaded_api(), - has_binding(QT_API_PYQT), - has_binding(QT_API_PYQT5), - has_binding(QT_API_PYSIDE), - has_binding(QT_API_PYSIDE2), - api_options)) + """ + % ( + loaded_api(), + has_binding(QT_API_PYQT5), + has_binding(QT_API_PYQT6), + has_binding(QT_API_PYSIDE2), + has_binding(QT_API_PYSIDE6), + api_options, + ) + ) def enum_factory(QT_API, QtCore): diff --git a/IPython/lib/guisupport.py b/IPython/lib/guisupport.py index cfd325e..4d532d0 100644 --- a/IPython/lib/guisupport.py +++ b/IPython/lib/guisupport.py @@ -106,21 +106,21 @@ def start_event_loop_wx(app=None): app._in_event_loop = True #----------------------------------------------------------------------------- -# qt4 +# Qt #----------------------------------------------------------------------------- def get_app_qt4(*args, **kwargs): - """Create a new qt4 app or return an existing one.""" + """Create a new Qt app or return an existing one.""" from IPython.external.qt_for_kernel import QtGui app = QtGui.QApplication.instance() if app is None: if not args: - args = ([''],) + args = ([""],) app = QtGui.QApplication(*args, **kwargs) return app def is_event_loop_running_qt4(app=None): - """Is the qt4 event loop running.""" + """Is the qt event loop running.""" # New way: check attribute on shell instance ip = get_ipython() if ip is not None: @@ -128,17 +128,17 @@ def is_event_loop_running_qt4(app=None): # Old way: check attribute on QApplication singleton if app is None: - app = get_app_qt4(['']) + app = get_app_qt4([""]) if hasattr(app, '_in_event_loop'): return app._in_event_loop else: - # Does qt4 provide a other way to detect this? + # Does qt provide a other way to detect this? return False def start_event_loop_qt4(app=None): - """Start the qt4 event loop in a consistent manner.""" + """Start the qt event loop in a consistent manner.""" if app is None: - app = get_app_qt4(['']) + app = get_app_qt4([""]) if not is_event_loop_running_qt4(app): app._in_event_loop = True app.exec_() diff --git a/IPython/terminal/interactiveshell.py b/IPython/terminal/interactiveshell.py index 41a3321..9494db9 100644 --- a/IPython/terminal/interactiveshell.py +++ b/IPython/terminal/interactiveshell.py @@ -911,7 +911,12 @@ class TerminalInteractiveShell(InteractiveShell): active_eventloop = None def enable_gui(self, gui=None): + if self._inputhook is not None and gui is not None: + warn( + f"Shell was already running a gui event loop for {self.active_eventloop}; switching to {gui}." + ) if gui and (gui not in {"inline", "webagg"}): + # This hook runs with each cycle of the `prompt_toolkit`'s event loop. self.active_eventloop, self._inputhook = get_inputhook_name_and_func(gui) else: self.active_eventloop = self._inputhook = None diff --git a/IPython/terminal/pt_inputhooks/__init__.py b/IPython/terminal/pt_inputhooks/__init__.py index 69ff0ba..57960e4 100644 --- a/IPython/terminal/pt_inputhooks/__init__.py +++ b/IPython/terminal/pt_inputhooks/__init__.py @@ -8,7 +8,6 @@ aliases = { backends = [ "qt", - "qt4", "qt5", "qt6", "gtk", @@ -40,6 +39,80 @@ class UnknownBackend(KeyError): ', '.join(backends + sorted(registered))) +def set_qt_api(gui): + """Sets the `QT_API` environment variable if it isn't already set.""" + + qt_api = os.environ.get("QT_API", None) + + from IPython.external.qt_loaders import ( + QT_API_PYQT, + QT_API_PYQT5, + QT_API_PYQT6, + QT_API_PYSIDE, + QT_API_PYSIDE2, + QT_API_PYSIDE6, + QT_API_PYQTv1, + loaded_api, + ) + + loaded = loaded_api() + + qt_env2gui = { + QT_API_PYSIDE: "qt4", + QT_API_PYQTv1: "qt4", + QT_API_PYQT: "qt4", + QT_API_PYSIDE2: "qt5", + QT_API_PYQT5: "qt5", + QT_API_PYSIDE6: "qt6", + QT_API_PYQT6: "qt6", + } + if loaded is not None and gui != "qt": + if qt_env2gui[loaded] != gui: + print( + f"Cannot switch Qt versions for this session; must use {qt_env2gui[loaded]}." + ) + return + + if qt_api is not None and gui != "qt": + if qt_env2gui[qt_api] != gui: + print( + f'Request for "{gui}" will be ignored because `QT_API` ' + f'environment variable is set to "{qt_api}"' + ) + else: + if gui == "qt5": + try: + import PyQt5 # noqa + + os.environ["QT_API"] = "pyqt5" + except ImportError: + try: + import PySide2 # noqa + + os.environ["QT_API"] = "pyside2" + except ImportError: + os.environ["QT_API"] = "pyqt5" + elif gui == "qt6": + try: + import PyQt6 # noqa + + os.environ["QT_API"] = "pyqt6" + except ImportError: + try: + import PySide6 # noqa + + os.environ["QT_API"] = "pyside6" + except ImportError: + os.environ["QT_API"] = "pyqt6" + elif gui == "qt": + # Don't set QT_API; let IPython logic choose the version. + if "QT_API" in os.environ.keys(): + del os.environ["QT_API"] + else: + print(f'Unrecognized Qt version: {gui}. Should be "qt5", "qt6", or "qt".') + return + + def get_inputhook_name_and_func(gui): if gui in registered: return gui, registered[gui] @@ -51,12 +124,9 @@ def get_inputhook_name_and_func(gui): return get_inputhook_name_and_func(aliases[gui]) gui_mod = gui - if gui == "qt5": - os.environ["QT_API"] = "pyqt5" - gui_mod = "qt" - elif gui == "qt6": - os.environ["QT_API"] = "pyqt6" + if gui.startswith("qt"): + set_qt_api(gui) gui_mod = "qt" - mod = importlib.import_module('IPython.terminal.pt_inputhooks.'+gui_mod) + mod = importlib.import_module("IPython.terminal.pt_inputhooks." + gui_mod) return gui, mod.inputhook diff --git a/IPython/terminal/pt_inputhooks/qt.py b/IPython/terminal/pt_inputhooks/qt.py index f1e710a..cf6d11e 100644 --- a/IPython/terminal/pt_inputhooks/qt.py +++ b/IPython/terminal/pt_inputhooks/qt.py @@ -63,6 +63,7 @@ def inputhook(context): timer = QtCore.QTimer() timer.timeout.connect(event_loop.quit) while not context.input_is_ready(): + # NOTE: run the event loop, and after 50 ms, call `quit` to exit it. timer.start(50) # 50 ms _exec(event_loop) timer.stop() diff --git a/IPython/terminal/tests/test_pt_inputhooks.py b/IPython/terminal/tests/test_pt_inputhooks.py new file mode 100644 index 0000000..bb4baaa --- /dev/null +++ b/IPython/terminal/tests/test_pt_inputhooks.py @@ -0,0 +1,50 @@ +import os +import importlib + +import pytest + +from IPython.terminal.pt_inputhooks import set_qt_api, get_inputhook_name_and_func + + +guis_avail = [] + + +def _get_qt_vers(): + """If any version of Qt is available, this will populate `guis_avail` with 'qt' and 'qtx'. Due + to the import mechanism, we can't import multiple versions of Qt in one session.""" + for gui in ["qt", "qt6", "qt5"]: + print(f"Trying {gui}") + try: + set_qt_api(gui) + importlib.import_module("IPython.terminal.pt_inputhooks.qt") + guis_avail.append(gui) + if "QT_API" in os.environ.keys(): + del os.environ["QT_API"] + except ImportError: + pass # that version of Qt isn't available. + except RuntimeError: + pass # the version of IPython doesn't know what to do with this Qt version. + + +_get_qt_vers() + + +@pytest.mark.skipif( + len(guis_avail) == 0, reason="No viable version of PyQt or PySide installed." +) +def test_inputhook_qt(): + gui = guis_avail[0] + + # Choose a qt version and get the input hook function. This will import Qt... + get_inputhook_name_and_func(gui) + + # ...and now we're stuck with this version of Qt for good; can't switch. + for not_gui in ["qt6", "qt5"]: + if not_gui not in guis_avail: + break + + with pytest.raises(ImportError): + get_inputhook_name_and_func(not_gui) + + # A gui of 'qt' means "best available", or in this case, the last one that was used. + get_inputhook_name_and_func("qt") diff --git a/docs/source/config/eventloops.rst b/docs/source/config/eventloops.rst index dd527a6..6bf349f 100644 --- a/docs/source/config/eventloops.rst +++ b/docs/source/config/eventloops.rst @@ -7,7 +7,7 @@ loop, so you can use both a GUI and an interactive prompt together. IPython supports a number of common GUI toolkits, but from IPython 3.0, it is possible to integrate other event loops without modifying IPython itself. -Supported event loops include ``qt4``, ``qt5``, ``gtk2``, ``gtk3``, ``gtk4``, +Supported event loops include ``qt5``, ``qt6``, ``gtk2``, ``gtk3``, ``gtk4``, ``wx``, ``osx`` and ``tk``. Make sure the event loop you specify matches the GUI toolkit used by your own code. diff --git a/docs/source/interactive/reference.rst b/docs/source/interactive/reference.rst index 8eed534..1df77dc 100644 --- a/docs/source/interactive/reference.rst +++ b/docs/source/interactive/reference.rst @@ -44,7 +44,7 @@ the command-line by passing the full class name and a corresponding value; type <...snip...> --matplotlib= (InteractiveShellApp.matplotlib) Default: None - Choices: ['auto', 'gtk', 'gtk3', 'gtk4', 'inline', 'nbagg', 'notebook', 'osx', 'qt', 'qt4', 'qt5', 'tk', 'wx'] + Choices: ['auto', 'gtk', 'gtk3', 'gtk4', 'inline', 'nbagg', 'notebook', 'osx', 'qt', 'qt5', 'qt6', 'tk', 'wx'] Configure matplotlib for interactive use with the default matplotlib backend. <...snip...> @@ -892,7 +892,7 @@ GUI event loop support ====================== IPython has excellent support for working interactively with Graphical User -Interface (GUI) toolkits, such as wxPython, PyQt4/PySide, PyGTK and Tk. This is +Interface (GUI) toolkits, such as wxPython, PyQt/PySide, PyGTK and Tk. This is implemented by running the toolkit's event loop while IPython is waiting for input. @@ -902,7 +902,7 @@ For users, enabling GUI event loop integration is simple. You simple use the %gui [GUINAME] With no arguments, ``%gui`` removes all GUI support. Valid ``GUINAME`` -arguments include ``wx``, ``qt``, ``qt5``, ``gtk``, ``gtk3`` ``gtk4``, and +arguments include ``wx``, ``qt``, ``qt5``, ``qt6``, ``gtk``, ``gtk3`` ``gtk4``, and ``tk``. Thus, to use wxPython interactively and create a running :class:`wx.App` @@ -936,16 +936,9 @@ PyQt and PySide .. attempt at explanation of the complete mess that is Qt support When you use ``--gui=qt`` or ``--matplotlib=qt``, IPython can work with either -PyQt4 or PySide. There are three options for configuration here, because -PyQt4 has two APIs for QString and QVariant: v1, which is the default on -Python 2, and the more natural v2, which is the only API supported by PySide. -v2 is also the default for PyQt4 on Python 3. IPython's code for the QtConsole -uses v2, but you can still use any interface in your code, since the -Qt frontend is in a different process. - -The default will be to import PyQt4 without configuration of the APIs, thus -matching what most applications would expect. It will fall back to PySide if -PyQt4 is unavailable. +PyQt or PySide. ``qt`` implies "use the latest version available", and it favors +PyQt over PySide. To request a specific version, use ``qt5`` or ``qt6``. Note that +Qt4 is not supported with the ``--gui`` switch (and has not been for some time now). If specified, IPython will respect the environment variable ``QT_API`` used by ETS. ETS 4.0 also works with both PyQt4 and PySide, but it requires