Skip to content

Commit 0374cf8

Browse files
authored
ENH: support for PySide6 in %gui (#13864)
Addresses #13859 Changes here parallel the changes in [ipykernel](ipython/ipykernel#1054), i.e. prefer `PyQt` over `PySide` and allow requesting explicit versions (e.g. 'qt5') with one difference (see below). I believe that, eventually, the Qt importing logic should be all here, and `ipykernel` should defer to`ipython`. I chose not to do that at this time since it would mean the latest `ipykernel` would require the latest `IPython`; I'm happy to discuss further. The only difference between `IPython` and `ipykernel`, after these two pull requests, is that, in `IPython`, it's not possible to explicitly request an event loop for Qt4. This is because an alias exists [here](https://github.com/ipython/ipython/blob/5409de68d87ddd073a35111aca0cb8360ff63ca8/IPython/terminal/pt_inputhooks/__init__.py#L5) which effectively makes "qt4" be "the latest Qt available". I did not remove the alias because I don't know the history behind it.
2 parents 9663a9a + 37ec2ad commit 0374cf8

10 files changed

Lines changed: 173 additions & 50 deletions

File tree

‎IPython/core/magics/basic.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,8 +493,10 @@ def gui(self, parameter_s=''):
493493
are supported: wxPython, PyQt4, PyGTK, Tk and Cocoa (OSX)::
494494
495495
%gui wx # enable wxPython event loop integration
496-
%gui qt4|qt # enable PyQt4 event loop integration
497-
%gui qt5 # enable PyQt5 event loop integration
496+
%gui qt # enable PyQt/PySide event loop integration
497+
# with the latest version available.
498+
%gui qt6 # enable PyQt6/PySide6 event loop integration
499+
%gui qt5 # enable PyQt5/PySide2 event loop integration
498500
%gui gtk # enable PyGTK event loop integration
499501
%gui gtk3 # enable Gtk3 event loop integration
500502
%gui gtk4 # enable Gtk4 event loop integration

‎IPython/external/qt_for_kernel.py‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
else:
2424
use what QT_API says
2525
26+
Note that %gui's implementation will always set a `QT_API`, see
27+
`IPython.terminal.pt_inputhooks.get_inputhook_name_and_func`
28+
2629
"""
2730
# NOTE: This is no longer an external, third-party module, and should be
2831
# considered part of IPython. For compatibility however, it is being kept in
@@ -42,7 +45,6 @@
4245
QT_API_PYQT5,
4346
QT_API_PYSIDE2,
4447
# QT4
45-
QT_API_PYQTv1,
4648
QT_API_PYQT,
4749
QT_API_PYSIDE,
4850
# default
@@ -56,10 +58,6 @@
5658
# QT5
5759
QT_API_PYQT5,
5860
QT_API_PYSIDE2,
59-
# QT4
60-
QT_API_PYQTv1,
61-
QT_API_PYQT,
62-
QT_API_PYSIDE,
6361
# default
6462
QT_API_PYQT_DEFAULT,
6563
)
@@ -98,7 +96,7 @@ def get_options():
9896
if loaded is not None:
9997
return [loaded]
10098

101-
mpl = sys.modules.get('matplotlib', None)
99+
mpl = sys.modules.get("matplotlib", None)
102100

103101
if mpl is not None and tuple(mpl.__version__.split(".")) < ("1", "0", "2"):
104102
# 1.0.1 only supports PyQt4 v1
@@ -113,8 +111,6 @@ def get_options():
113111
QT_API_PYSIDE6,
114112
QT_API_PYQT5,
115113
QT_API_PYSIDE2,
116-
QT_API_PYQT,
117-
QT_API_PYSIDE,
118114
]
119115
elif qt_api not in _qt_apis:
120116
raise RuntimeError("Invalid Qt API %r, valid values are: %r" %

‎IPython/external/qt_loaders.py‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
QT_API_PYSIDE2 = 'pyside2'
2525

2626
# Qt4
27+
# NOTE: Here for legacy matplotlib compatibility, but not really supported on the IPython side.
2728
QT_API_PYQT = "pyqt" # Force version 2
2829
QT_API_PYQTv1 = "pyqtv1" # Force version 2
2930
QT_API_PYSIDE = "pyside"
@@ -367,23 +368,28 @@ def load_qt(api_options):
367368
commit_api(api)
368369
return result
369370
else:
370-
raise ImportError("""
371+
raise ImportError(
372+
"""
371373
Could not load requested Qt binding. Please ensure that
372-
PyQt4 >= 4.7, PyQt5, PySide >= 1.0.3 or PySide2 is available,
373-
and only one is imported per session.
374+
PyQt4 >= 4.7, PyQt5, PyQt6, PySide >= 1.0.3, PySide2, or
375+
PySide6 is available, and only one is imported per session.
374376
375377
Currently-imported Qt library: %r
376-
PyQt4 available (requires QtCore, QtGui, QtSvg): %s
377378
PyQt5 available (requires QtCore, QtGui, QtSvg, QtWidgets): %s
378-
PySide >= 1.0.3 installed: %s
379+
PyQt6 available (requires QtCore, QtGui, QtSvg, QtWidgets): %s
379380
PySide2 installed: %s
381+
PySide6 installed: %s
380382
Tried to load: %r
381-
""" % (loaded_api(),
382-
has_binding(QT_API_PYQT),
383-
has_binding(QT_API_PYQT5),
384-
has_binding(QT_API_PYSIDE),
385-
has_binding(QT_API_PYSIDE2),
386-
api_options))
383+
"""
384+
% (
385+
loaded_api(),
386+
has_binding(QT_API_PYQT5),
387+
has_binding(QT_API_PYQT6),
388+
has_binding(QT_API_PYSIDE2),
389+
has_binding(QT_API_PYSIDE6),
390+
api_options,
391+
)
392+
)
387393

388394

389395
def enum_factory(QT_API, QtCore):

‎IPython/lib/guisupport.py‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,39 +106,39 @@ def start_event_loop_wx(app=None):
106106
app._in_event_loop = True
107107

108108
#-----------------------------------------------------------------------------
109-
# qt4
109+
# Qt
110110
#-----------------------------------------------------------------------------
111111

112112
def get_app_qt4(*args, **kwargs):
113-
"""Create a new qt4 app or return an existing one."""
113+
"""Create a new Qt app or return an existing one."""
114114
from IPython.external.qt_for_kernel import QtGui
115115
app = QtGui.QApplication.instance()
116116
if app is None:
117117
if not args:
118-
args = ([''],)
118+
args = ([""],)
119119
app = QtGui.QApplication(*args, **kwargs)
120120
return app
121121

122122
def is_event_loop_running_qt4(app=None):
123-
"""Is the qt4 event loop running."""
123+
"""Is the qt event loop running."""
124124
# New way: check attribute on shell instance
125125
ip = get_ipython()
126126
if ip is not None:
127127
return ip.active_eventloop and ip.active_eventloop.startswith('qt')
128128

129129
# Old way: check attribute on QApplication singleton
130130
if app is None:
131-
app = get_app_qt4([''])
131+
app = get_app_qt4([""])
132132
if hasattr(app, '_in_event_loop'):
133133
return app._in_event_loop
134134
else:
135-
# Does qt4 provide a other way to detect this?
135+
# Does qt provide a other way to detect this?
136136
return False
137137

138138
def start_event_loop_qt4(app=None):
139-
"""Start the qt4 event loop in a consistent manner."""
139+
"""Start the qt event loop in a consistent manner."""
140140
if app is None:
141-
app = get_app_qt4([''])
141+
app = get_app_qt4([""])
142142
if not is_event_loop_running_qt4(app):
143143
app._in_event_loop = True
144144
app.exec_()

‎IPython/terminal/interactiveshell.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,7 +911,12 @@ def inputhook(self, context):
911911

912912
active_eventloop = None
913913
def enable_gui(self, gui=None):
914+
if self._inputhook is not None and gui is not None:
915+
warn(
916+
f"Shell was already running a gui event loop for {self.active_eventloop}; switching to {gui}."
917+
)
914918
if gui and (gui not in {"inline", "webagg"}):
919+
# This hook runs with each cycle of the `prompt_toolkit`'s event loop.
915920
self.active_eventloop, self._inputhook = get_inputhook_name_and_func(gui)
916921
else:
917922
self.active_eventloop = self._inputhook = None

‎IPython/terminal/pt_inputhooks/__init__.py‎

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
backends = [
1010
"qt",
11-
"qt4",
1211
"qt5",
1312
"qt6",
1413
"gtk",
@@ -40,6 +39,80 @@ def __str__(self):
4039
', '.join(backends + sorted(registered)))
4140

4241

42+
def set_qt_api(gui):
43+
"""Sets the `QT_API` environment variable if it isn't already set."""
44+
45+
qt_api = os.environ.get("QT_API", None)
46+
47+
from IPython.external.qt_loaders import (
48+
QT_API_PYQT,
49+
QT_API_PYQT5,
50+
QT_API_PYQT6,
51+
QT_API_PYSIDE,
52+
QT_API_PYSIDE2,
53+
QT_API_PYSIDE6,
54+
QT_API_PYQTv1,
55+
loaded_api,
56+
)
57+
58+
loaded = loaded_api()
59+
60+
qt_env2gui = {
61+
QT_API_PYSIDE: "qt4",
62+
QT_API_PYQTv1: "qt4",
63+
QT_API_PYQT: "qt4",
64+
QT_API_PYSIDE2: "qt5",
65+
QT_API_PYQT5: "qt5",
66+
QT_API_PYSIDE6: "qt6",
67+
QT_API_PYQT6: "qt6",
68+
}
69+
if loaded is not None and gui != "qt":
70+
if qt_env2gui[loaded] != gui:
71+
print(
72+
f"Cannot switch Qt versions for this session; must use {qt_env2gui[loaded]}."
73+
)
74+
return
75+
76+
if qt_api is not None and gui != "qt":
77+
if qt_env2gui[qt_api] != gui:
78+
print(
79+
f'Request for "{gui}" will be ignored because `QT_API` '
80+
f'environment variable is set to "{qt_api}"'
81+
)
82+
else:
83+
if gui == "qt5":
84+
try:
85+
import PyQt5 # noqa
86+
87+
os.environ["QT_API"] = "pyqt5"
88+
except ImportError:
89+
try:
90+
import PySide2 # noqa
91+
92+
os.environ["QT_API"] = "pyside2"
93+
except ImportError:
94+
os.environ["QT_API"] = "pyqt5"
95+
elif gui == "qt6":
96+
try:
97+
import PyQt6 # noqa
98+
99+
os.environ["QT_API"] = "pyqt6"
100+
except ImportError:
101+
try:
102+
import PySide6 # noqa
103+
104+
os.environ["QT_API"] = "pyside6"
105+
except ImportError:
106+
os.environ["QT_API"] = "pyqt6"
107+
elif gui == "qt":
108+
# Don't set QT_API; let IPython logic choose the version.
109+
if "QT_API" in os.environ.keys():
110+
del os.environ["QT_API"]
111+
else:
112+
print(f'Unrecognized Qt version: {gui}. Should be "qt5", "qt6", or "qt".')
113+
return
114+
115+
43116
def get_inputhook_name_and_func(gui):
44117
if gui in registered:
45118
return gui, registered[gui]
@@ -51,12 +124,9 @@ def get_inputhook_name_and_func(gui):
51124
return get_inputhook_name_and_func(aliases[gui])
52125

53126
gui_mod = gui
54-
if gui == "qt5":
55-
os.environ["QT_API"] = "pyqt5"
56-
gui_mod = "qt"
57-
elif gui == "qt6":
58-
os.environ["QT_API"] = "pyqt6"
127+
if gui.startswith("qt"):
128+
set_qt_api(gui)
59129
gui_mod = "qt"
60130

61-
mod = importlib.import_module('IPython.terminal.pt_inputhooks.'+gui_mod)
131+
mod = importlib.import_module("IPython.terminal.pt_inputhooks." + gui_mod)
62132
return gui, mod.inputhook

‎IPython/terminal/pt_inputhooks/qt.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def inputhook(context):
6363
timer = QtCore.QTimer()
6464
timer.timeout.connect(event_loop.quit)
6565
while not context.input_is_ready():
66+
# NOTE: run the event loop, and after 50 ms, call `quit` to exit it.
6667
timer.start(50) # 50 ms
6768
_exec(event_loop)
6869
timer.stop()
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import os
2+
import importlib
3+
4+
import pytest
5+
6+
from IPython.terminal.pt_inputhooks import set_qt_api, get_inputhook_name_and_func
7+
8+
9+
guis_avail = []
10+
11+
12+
def _get_qt_vers():
13+
"""If any version of Qt is available, this will populate `guis_avail` with 'qt' and 'qtx'. Due
14+
to the import mechanism, we can't import multiple versions of Qt in one session."""
15+
for gui in ["qt", "qt6", "qt5"]:
16+
print(f"Trying {gui}")
17+
try:
18+
set_qt_api(gui)
19+
importlib.import_module("IPython.terminal.pt_inputhooks.qt")
20+
guis_avail.append(gui)
21+
if "QT_API" in os.environ.keys():
22+
del os.environ["QT_API"]
23+
except ImportError:
24+
pass # that version of Qt isn't available.
25+
except RuntimeError:
26+
pass # the version of IPython doesn't know what to do with this Qt version.
27+
28+
29+
_get_qt_vers()
30+
31+
32+
@pytest.mark.skipif(
33+
len(guis_avail) == 0, reason="No viable version of PyQt or PySide installed."
34+
)
35+
def test_inputhook_qt():
36+
gui = guis_avail[0]
37+
38+
# Choose a qt version and get the input hook function. This will import Qt...
39+
get_inputhook_name_and_func(gui)
40+
41+
# ...and now we're stuck with this version of Qt for good; can't switch.
42+
for not_gui in ["qt6", "qt5"]:
43+
if not_gui not in guis_avail:
44+
break
45+
46+
with pytest.raises(ImportError):
47+
get_inputhook_name_and_func(not_gui)
48+
49+
# A gui of 'qt' means "best available", or in this case, the last one that was used.
50+
get_inputhook_name_and_func("qt")

‎docs/source/config/eventloops.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ loop, so you can use both a GUI and an interactive prompt together. IPython
77
supports a number of common GUI toolkits, but from IPython 3.0, it is possible
88
to integrate other event loops without modifying IPython itself.
99

10-
Supported event loops include ``qt4``, ``qt5``, ``gtk2``, ``gtk3``, ``gtk4``,
10+
Supported event loops include ``qt5``, ``qt6``, ``gtk2``, ``gtk3``, ``gtk4``,
1111
``wx``, ``osx`` and ``tk``. Make sure the event loop you specify matches the
1212
GUI toolkit used by your own code.
1313

‎docs/source/interactive/reference.rst‎

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ the command-line by passing the full class name and a corresponding value; type
4444
<...snip...>
4545
--matplotlib=<CaselessStrEnum> (InteractiveShellApp.matplotlib)
4646
Default: None
47-
Choices: ['auto', 'gtk', 'gtk3', 'gtk4', 'inline', 'nbagg', 'notebook', 'osx', 'qt', 'qt4', 'qt5', 'tk', 'wx']
47+
Choices: ['auto', 'gtk', 'gtk3', 'gtk4', 'inline', 'nbagg', 'notebook', 'osx', 'qt', 'qt5', 'qt6', 'tk', 'wx']
4848
Configure matplotlib for interactive use with the default matplotlib
4949
backend.
5050
<...snip...>
@@ -892,7 +892,7 @@ GUI event loop support
892892
======================
893893

894894
IPython has excellent support for working interactively with Graphical User
895-
Interface (GUI) toolkits, such as wxPython, PyQt4/PySide, PyGTK and Tk. This is
895+
Interface (GUI) toolkits, such as wxPython, PyQt/PySide, PyGTK and Tk. This is
896896
implemented by running the toolkit's event loop while IPython is waiting for
897897
input.
898898

@@ -902,7 +902,7 @@ For users, enabling GUI event loop integration is simple. You simple use the
902902
%gui [GUINAME]
903903

904904
With no arguments, ``%gui`` removes all GUI support. Valid ``GUINAME``
905-
arguments include ``wx``, ``qt``, ``qt5``, ``gtk``, ``gtk3`` ``gtk4``, and
905+
arguments include ``wx``, ``qt``, ``qt5``, ``qt6``, ``gtk``, ``gtk3`` ``gtk4``, and
906906
``tk``.
907907

908908
Thus, to use wxPython interactively and create a running :class:`wx.App`
@@ -936,16 +936,9 @@ PyQt and PySide
936936
.. attempt at explanation of the complete mess that is Qt support
937937
938938
When you use ``--gui=qt`` or ``--matplotlib=qt``, IPython can work with either
939-
PyQt4 or PySide. There are three options for configuration here, because
940-
PyQt4 has two APIs for QString and QVariant: v1, which is the default on
941-
Python 2, and the more natural v2, which is the only API supported by PySide.
942-
v2 is also the default for PyQt4 on Python 3. IPython's code for the QtConsole
943-
uses v2, but you can still use any interface in your code, since the
944-
Qt frontend is in a different process.
945-
946-
The default will be to import PyQt4 without configuration of the APIs, thus
947-
matching what most applications would expect. It will fall back to PySide if
948-
PyQt4 is unavailable.
939+
PyQt or PySide. ``qt`` implies "use the latest version available", and it favors
940+
PyQt over PySide. To request a specific version, use ``qt5`` or ``qt6``. Note that
941+
Qt4 is not supported with the ``--gui`` switch (and has not been for some time now).
949942

950943
If specified, IPython will respect the environment variable ``QT_API`` used
951944
by ETS. ETS 4.0 also works with both PyQt4 and PySide, but it requires

0 commit comments

Comments
 (0)