Skip to content

Commit fbea757

Browse files
shaperiliopre-commit-ci[bot]blink1073
authored
ENH: add %gui support for Qt6 (#1054)
* Initial notes * ENH: Support for `PyQt6` and `PySide6`. - Distinguish between specific version requests and the generic one. - Use a `QEventLoop` instance to properly keep windows open between event loop calls. This is "instrumented" with print statements to follow the flow * Move `Qt` importing to client side This way import errors show up in the client, not the kernel. * Bring in some changes by @tacaswell See d5d718b * Remove diagnostic `print` statements * Move last version check up * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove unused import * These seem to run fine in Windows. * TST: Qt event loop logic * Fix "Test Minimum Versions" CI test * Use `IPython` constants and version check. Importing a second version of Qt is not allowed. `IPython` silently ignores requests for different versions; we want `enable_gui` to raise an exception so the user can see it. * Add two Qt versions to test matrix * Improved logic * get coverage on windows back * more targeted windows skip * rename symbol in test * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Steven Silvester <[email protected]>
1 parent 9b434e9 commit fbea757

File tree

3 files changed

+262
-73
lines changed

3 files changed

+262
-73
lines changed

ipykernel/eventloops.py

Lines changed: 190 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -21,41 +21,6 @@ def _use_appnope():
2121
return sys.platform == "darwin" and V(platform.mac_ver()[0]) >= V("10.9")
2222

2323

24-
def _notify_stream_qt(kernel):
25-
26-
from IPython.external.qt_for_kernel import QtCore
27-
28-
def process_stream_events():
29-
"""fall back to main loop when there's a socket event"""
30-
# call flush to ensure that the stream doesn't lose events
31-
# due to our consuming of the edge-triggered FD
32-
# flush returns the number of events consumed.
33-
# if there were any, wake it up
34-
if kernel.shell_stream.flush(limit=1):
35-
kernel._qt_notifier.setEnabled(False)
36-
kernel.app.quit()
37-
38-
if not hasattr(kernel, "_qt_notifier"):
39-
fd = kernel.shell_stream.getsockopt(zmq.FD)
40-
kernel._qt_notifier = QtCore.QSocketNotifier(fd, QtCore.QSocketNotifier.Read, kernel.app)
41-
kernel._qt_notifier.activated.connect(process_stream_events)
42-
else:
43-
kernel._qt_notifier.setEnabled(True)
44-
45-
# there may already be unprocessed events waiting.
46-
# these events will not wake zmq's edge-triggered FD
47-
# since edge-triggered notification only occurs on new i/o activity.
48-
# process all the waiting events immediately
49-
# so we start in a clean state ensuring that any new i/o events will notify.
50-
# schedule first call on the eventloop as soon as it's running,
51-
# so we don't block here processing events
52-
if not hasattr(kernel, "_qt_timer"):
53-
kernel._qt_timer = QtCore.QTimer(kernel.app)
54-
kernel._qt_timer.setSingleShot(True)
55-
kernel._qt_timer.timeout.connect(process_stream_events)
56-
kernel._qt_timer.start(0)
57-
58-
5924
# mapping of keys to loop functions
6025
loop_map = {
6126
"inline": None,
@@ -103,54 +68,67 @@ def exit_decorator(exit_func):
10368
return decorator
10469

10570

106-
def _loop_qt(app):
107-
"""Inner-loop for running the Qt eventloop
108-
109-
Pulled from guisupport.start_event_loop in IPython < 5.2,
110-
since IPython 5.2 only checks `get_ipython().active_eventloop` is defined,
111-
rather than if the eventloop is actually running.
112-
"""
113-
app._in_event_loop = True
114-
app.exec_()
115-
app._in_event_loop = False
116-
71+
def _notify_stream_qt(kernel):
72+
import operator
73+
from functools import lru_cache
11774

118-
@register_integration("qt4")
119-
def loop_qt4(kernel):
120-
"""Start a kernel with PyQt4 event loop integration."""
75+
from IPython.external.qt_for_kernel import QtCore
12176

122-
from IPython.external.qt_for_kernel import QtGui
123-
from IPython.lib.guisupport import get_app_qt4
77+
try:
78+
from IPython.external.qt_for_kernel import enum_helper
79+
except ImportError:
12480

125-
kernel.app = get_app_qt4([" "])
126-
if isinstance(kernel.app, QtGui.QApplication):
127-
kernel.app.setQuitOnLastWindowClosed(False)
128-
_notify_stream_qt(kernel)
81+
@lru_cache(None)
82+
def enum_helper(name):
83+
return operator.attrgetter(name.rpartition(".")[0])(sys.modules[QtCore.__package__])
12984

130-
_loop_qt(kernel.app)
85+
def process_stream_events():
86+
"""fall back to main loop when there's a socket event"""
87+
# call flush to ensure that the stream doesn't lose events
88+
# due to our consuming of the edge-triggered FD
89+
# flush returns the number of events consumed.
90+
# if there were any, wake it up
91+
if kernel.shell_stream.flush(limit=1):
92+
kernel._qt_notifier.setEnabled(False)
93+
kernel.app.qt_event_loop.quit()
13194

95+
if not hasattr(kernel, "_qt_notifier"):
96+
fd = kernel.shell_stream.getsockopt(zmq.FD)
97+
kernel._qt_notifier = QtCore.QSocketNotifier(
98+
fd, enum_helper('QtCore.QSocketNotifier.Type').Read, kernel.app.qt_event_loop
99+
)
100+
kernel._qt_notifier.activated.connect(process_stream_events)
101+
else:
102+
kernel._qt_notifier.setEnabled(True)
132103

133-
@register_integration("qt", "qt5")
134-
def loop_qt5(kernel):
135-
"""Start a kernel with PyQt5 event loop integration."""
136-
if os.environ.get("QT_API", None) is None:
137-
try:
138-
import PyQt5 # noqa
104+
# there may already be unprocessed events waiting.
105+
# these events will not wake zmq's edge-triggered FD
106+
# since edge-triggered notification only occurs on new i/o activity.
107+
# process all the waiting events immediately
108+
# so we start in a clean state ensuring that any new i/o events will notify.
109+
# schedule first call on the eventloop as soon as it's running,
110+
# so we don't block here processing events
111+
if not hasattr(kernel, "_qt_timer"):
112+
kernel._qt_timer = QtCore.QTimer(kernel.app)
113+
kernel._qt_timer.setSingleShot(True)
114+
kernel._qt_timer.timeout.connect(process_stream_events)
115+
kernel._qt_timer.start(0)
139116

140-
os.environ["QT_API"] = "pyqt5"
141-
except ImportError:
142-
try:
143-
import PySide2 # noqa
144117

145-
os.environ["QT_API"] = "pyside2"
146-
except ImportError:
147-
os.environ["QT_API"] = "pyqt5"
148-
return loop_qt4(kernel)
118+
@register_integration("qt", "qt4", "qt5", "qt6")
119+
def loop_qt(kernel):
120+
"""Event loop for all versions of Qt."""
121+
_notify_stream_qt(kernel) # install hook to stop event loop.
122+
# Start the event loop.
123+
kernel.app._in_event_loop = True
124+
# `exec` blocks until there's ZMQ activity.
125+
el = kernel.app.qt_event_loop # for brevity
126+
el.exec() if hasattr(el, 'exec') else el.exec_()
127+
kernel.app._in_event_loop = False
149128

150129

151130
# exit and watch are the same for qt 4 and 5
152-
@loop_qt4.exit
153-
@loop_qt5.exit
131+
@loop_qt.exit
154132
def loop_qt_exit(kernel):
155133
kernel.app.exit()
156134

@@ -450,6 +428,135 @@ def close_loop():
450428
loop.close()
451429

452430

431+
# The user can generically request `qt` or a specific Qt version, e.g. `qt6`. For a generic Qt
432+
# request, we let the mechanism in IPython choose the best available version by leaving the `QT_API`
433+
# environment variable blank.
434+
#
435+
# For specific versions, we check to see whether the PyQt or PySide implementations are present and
436+
# set `QT_API` accordingly to indicate to IPython which version we want. If neither implementation
437+
# is present, we leave the environment variable set so IPython will generate a helpful error
438+
# message.
439+
#
440+
# NOTE: if the environment variable is already set, it will be used unchanged, regardless of what
441+
# the user requested.
442+
443+
444+
def set_qt_api_env_from_gui(gui):
445+
"""
446+
Sets the QT_API environment variable by trying to import PyQtx or PySidex.
447+
448+
If QT_API is already set, ignore the request.
449+
"""
450+
qt_api = os.environ.get("QT_API", None)
451+
452+
from IPython.external.qt_loaders import (
453+
QT_API_PYQT,
454+
QT_API_PYQT5,
455+
QT_API_PYQT6,
456+
QT_API_PYSIDE,
457+
QT_API_PYSIDE2,
458+
QT_API_PYSIDE6,
459+
QT_API_PYQTv1,
460+
loaded_api,
461+
)
462+
463+
loaded = loaded_api()
464+
465+
qt_env2gui = {
466+
QT_API_PYSIDE: 'qt4',
467+
QT_API_PYQTv1: 'qt4',
468+
QT_API_PYQT: 'qt4',
469+
QT_API_PYSIDE2: 'qt5',
470+
QT_API_PYQT5: 'qt5',
471+
QT_API_PYSIDE6: 'qt6',
472+
QT_API_PYQT6: 'qt6',
473+
}
474+
if loaded is not None and gui != 'qt':
475+
if qt_env2gui[loaded] != gui:
476+
raise ImportError(
477+
f'Cannot switch Qt versions for this session; must use {qt_env2gui[loaded]}.'
478+
)
479+
480+
if qt_api is not None and gui != 'qt':
481+
if qt_env2gui[qt_api] != gui:
482+
print(
483+
f'Request for "{gui}" will be ignored because `QT_API` '
484+
f'environment variable is set to "{qt_api}"'
485+
)
486+
else:
487+
if gui == 'qt4':
488+
try:
489+
import PyQt # noqa
490+
491+
os.environ["QT_API"] = "pyqt"
492+
except ImportError:
493+
try:
494+
import PySide # noqa
495+
496+
os.environ["QT_API"] = "pyside"
497+
except ImportError:
498+
# Neither implementation installed; set it to something so IPython gives an error
499+
os.environ["QT_API"] = "pyqt"
500+
elif gui == 'qt5':
501+
try:
502+
import PyQt5 # noqa
503+
504+
os.environ["QT_API"] = "pyqt5"
505+
except ImportError:
506+
try:
507+
import PySide2 # noqa
508+
509+
os.environ["QT_API"] = "pyside2"
510+
except ImportError:
511+
os.environ["QT_API"] = "pyqt5"
512+
elif gui == 'qt6':
513+
try:
514+
import PyQt6 # noqa
515+
516+
os.environ["QT_API"] = "pyqt6"
517+
except ImportError:
518+
try:
519+
import PySide6 # noqa
520+
521+
os.environ["QT_API"] = "pyside6"
522+
except ImportError:
523+
os.environ["QT_API"] = "pyqt6"
524+
elif gui == 'qt':
525+
# Don't set QT_API; let IPython logic choose the version.
526+
if 'QT_API' in os.environ.keys():
527+
del os.environ['QT_API']
528+
else:
529+
raise ValueError(
530+
f'Unrecognized Qt version: {gui}. Should be "qt4", "qt5", "qt6", or "qt".'
531+
)
532+
533+
# Do the actual import now that the environment variable is set to make sure it works.
534+
try:
535+
from IPython.external.qt_for_kernel import QtCore, QtGui # noqa
536+
except ImportError:
537+
# Clear the environment variable for the next attempt.
538+
if 'QT_API' in os.environ.keys():
539+
del os.environ["QT_API"]
540+
raise
541+
542+
543+
def make_qt_app_for_kernel(gui, kernel):
544+
"""Sets the `QT_API` environment variable if it isn't already set."""
545+
if hasattr(kernel, 'app'):
546+
raise RuntimeError('Kernel already running a Qt event loop.')
547+
548+
set_qt_api_env_from_gui(gui)
549+
# This import is guaranteed to work now:
550+
from IPython.external.qt_for_kernel import QtCore, QtGui
551+
from IPython.lib.guisupport import get_app_qt4
552+
553+
kernel.app = get_app_qt4([" "])
554+
if isinstance(kernel.app, QtGui.QApplication):
555+
kernel.app.setQuitOnLastWindowClosed(False)
556+
557+
kernel.app.qt_event_loop = QtCore.QEventLoop(kernel.app)
558+
559+
453560
def enable_gui(gui, kernel=None):
454561
"""Enable integration with a given GUI"""
455562
if gui not in loop_map:
@@ -463,7 +570,18 @@ def enable_gui(gui, kernel=None):
463570
"You didn't specify a kernel,"
464571
" and no IPython Application with a kernel appears to be running."
465572
)
573+
if gui is None:
574+
# User wants to turn off integration; clear any evidence if Qt was the last one.
575+
if hasattr(kernel, 'app'):
576+
delattr(kernel, 'app')
577+
else:
578+
if gui.startswith('qt'):
579+
# Prepare the kernel here so any exceptions are displayed in the client.
580+
make_qt_app_for_kernel(gui, kernel)
581+
466582
loop = loop_map[gui]
467583
if loop and kernel.eventloop is not None and kernel.eventloop is not loop:
468584
raise RuntimeError("Cannot activate multiple GUI eventloops")
469585
kernel.eventloop = loop
586+
# We set `eventloop`; the function the user chose is executed in `Kernel.enter_eventloop`, thus
587+
# any exceptions raised during the event loop will not be shown in the client.

ipykernel/tests/test_eventloop.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,39 @@
99
import pytest
1010
import tornado
1111

12-
from ipykernel.eventloops import enable_gui, loop_asyncio, loop_cocoa, loop_tk
12+
from ipykernel.eventloops import (
13+
enable_gui,
14+
loop_asyncio,
15+
loop_cocoa,
16+
loop_tk,
17+
set_qt_api_env_from_gui,
18+
)
1319

1420
from .utils import execute, flush_channels, start_new_kernel
1521

1622
KC = KM = None
1723

24+
qt_guis_avail = []
25+
26+
27+
def _get_qt_vers():
28+
"""If any version of Qt is available, this will populate `guis_avail` with 'qt' and 'qtx'. Due
29+
to the import mechanism, we can't import multiple versions of Qt in one session."""
30+
for gui in ['qt', 'qt6', 'qt5', 'qt4']:
31+
print(f'Trying {gui}')
32+
try:
33+
set_qt_api_env_from_gui(gui)
34+
qt_guis_avail.append(gui)
35+
if 'QT_API' in os.environ.keys():
36+
del os.environ['QT_API']
37+
except ImportError:
38+
pass # that version of Qt isn't available.
39+
except RuntimeError:
40+
pass # the version of IPython doesn't know what to do with this Qt version.
41+
42+
43+
_get_qt_vers()
44+
1845

1946
def setup():
2047
"""start the global kernel (if it isn't running) and return its client"""
@@ -97,3 +124,36 @@ def test_enable_gui(kernel):
97124
@pytest.mark.skipif(sys.platform != "darwin", reason="MacOS-only")
98125
def test_cocoa_loop(kernel):
99126
loop_cocoa(kernel)
127+
128+
129+
@pytest.mark.skipif(
130+
len(qt_guis_avail) == 0, reason='No viable version of PyQt or PySide installed.'
131+
)
132+
def test_qt_enable_gui(kernel):
133+
gui = qt_guis_avail[0]
134+
135+
enable_gui(gui, kernel)
136+
137+
# We store the `QApplication` instance in the kernel.
138+
assert hasattr(kernel, 'app')
139+
# And the `QEventLoop` is added to `app`:`
140+
assert hasattr(kernel.app, 'qt_event_loop')
141+
142+
# Can't start another event loop, even if `gui` is the same.
143+
with pytest.raises(RuntimeError):
144+
enable_gui(gui, kernel)
145+
146+
# Event loop intergration can be turned off.
147+
enable_gui(None, kernel)
148+
assert not hasattr(kernel, 'app')
149+
150+
# But now we're stuck with this version of Qt for good; can't switch.
151+
for not_gui in ['qt6', 'qt5', 'qt4']:
152+
if not_gui not in qt_guis_avail:
153+
break
154+
155+
with pytest.raises(ImportError):
156+
enable_gui(not_gui, kernel)
157+
158+
# A gui of 'qt' means "best available", or in this case, the last one that was used.
159+
enable_gui('qt', kernel)

0 commit comments

Comments
 (0)