Skip to content
79 changes: 35 additions & 44 deletions ipykernel/eventloops.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,24 @@ def process_stream_events():
kernel._qt_timer.start(0)


@register_integration("qt", "qt4", "qt5", "qt6")
@register_integration("qt", "qt5", "qt6")
def loop_qt(kernel):
"""Event loop for all versions of Qt."""
"""Event loop for all supported versions of Qt."""
_notify_stream_qt(kernel) # install hook to stop event loop.

# Start the event loop.
kernel.app._in_event_loop = True

# `exec` blocks until there's ZMQ activity.
el = kernel.app.qt_event_loop # for brevity
el.exec() if hasattr(el, 'exec') else el.exec_()
kernel.app._in_event_loop = False


# NOTE: To be removed in version 7
loop_qt5 = loop_qt


# exit and watch are the same for qt 4 and 5
@loop_qt.exit
def loop_qt_exit(kernel):
Expand Down Expand Up @@ -428,75 +434,57 @@ def close_loop():
loop.close()


# The user can generically request `qt` or a specific Qt version, e.g. `qt6`. For a generic Qt
# request, we let the mechanism in IPython choose the best available version by leaving the `QT_API`
# environment variable blank.
#
# For specific versions, we check to see whether the PyQt or PySide implementations are present and
# set `QT_API` accordingly to indicate to IPython which version we want. If neither implementation
# is present, we leave the environment variable set so IPython will generate a helpful error
# message.
#
# NOTE: if the environment variable is already set, it will be used unchanged, regardless of what
# the user requested.


def set_qt_api_env_from_gui(gui):
"""
Sets the QT_API environment variable by trying to import PyQtx or PySidex.

If QT_API is already set, ignore the request.
The user can generically request `qt` or a specific Qt version, e.g. `qt6`.
For a generic Qt request, we let the mechanism in IPython choose the best
available version by leaving the `QT_API` environment variable blank.

For specific versions, we check to see whether the PyQt or PySide
implementations are present and set `QT_API` accordingly to indicate to
IPython which version we want. If neither implementation is present, we
leave the environment variable set so IPython will generate a helpful error
message.

Notes
-----
- If the environment variable is already set, it will be used unchanged,
regardless of what the user requested.
"""
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:
msg = f'Cannot switch Qt versions for this session; must use {qt_env2gui[loaded]}.'
raise ImportError(msg)
print(f'Cannot switch Qt versions for this session; you 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}"'
)
return
else:
if gui == 'qt4':
try:
import PyQt # noqa

os.environ["QT_API"] = "pyqt"
except ImportError:
try:
import PySide # noqa

os.environ["QT_API"] = "pyside"
except ImportError:
# Neither implementation installed; set it to something so IPython gives an error
os.environ["QT_API"] = "pyqt"
elif gui == 'qt5':
if gui == 'qt5':
try:
import PyQt5 # noqa

Expand Down Expand Up @@ -525,26 +513,29 @@ def set_qt_api_env_from_gui(gui):
if 'QT_API' in os.environ.keys():
del os.environ['QT_API']
else:
msg = f'Unrecognized Qt version: {gui}. Should be "qt4", "qt5", "qt6", or "qt".'
raise ValueError(msg)
print(f'Unrecognized Qt version: {gui}. Should be "qt5", "qt6", or "qt".')
return

# Do the actual import now that the environment variable is set to make sure it works.
try:
from IPython.external.qt_for_kernel import QtCore, QtGui # noqa
except ImportError:
except Exception as e:
# Clear the environment variable for the next attempt.
if 'QT_API' in os.environ.keys():
del os.environ["QT_API"]
raise
print(f"QT_API couldn't be set due to error {e}")
return


def make_qt_app_for_kernel(gui, kernel):
"""Sets the `QT_API` environment variable if it isn't already set."""
if hasattr(kernel, 'app'):
msg = 'Kernel already running a Qt event loop.'
raise RuntimeError(msg)
# Kernel is already running a Qt event loop, so there's no need to
# create another app for it.
return

set_qt_api_env_from_gui(gui)

# This import is guaranteed to work now:
from IPython.external.qt_for_kernel import QtCore, QtGui
from IPython.lib.guisupport import get_app_qt4
Expand Down
30 changes: 17 additions & 13 deletions ipykernel/tests/test_eventloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
loop_asyncio,
loop_cocoa,
loop_tk,
set_qt_api_env_from_gui,
)

from .utils import execute, flush_channels, start_new_kernel
Expand All @@ -23,21 +22,21 @@

qt_guis_avail = []

gui_to_module = {'qt6': 'PySide6', 'qt5': 'PyQt5'}


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', 'qt4']:
for gui in ['qt6', 'qt5']:
print(f'Trying {gui}')
try:
set_qt_api_env_from_gui(gui)
__import__(gui_to_module[gui])
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()
Expand Down Expand Up @@ -129,31 +128,36 @@ def test_cocoa_loop(kernel):
@pytest.mark.skipif(
len(qt_guis_avail) == 0, reason='No viable version of PyQt or PySide installed.'
)
def test_qt_enable_gui(kernel):
def test_qt_enable_gui(kernel, capsys):
gui = qt_guis_avail[0]

enable_gui(gui, kernel)

# We store the `QApplication` instance in the kernel.
assert hasattr(kernel, 'app')

# And the `QEventLoop` is added to `app`:`
assert hasattr(kernel.app, 'qt_event_loop')

# Can't start another event loop, even if `gui` is the same.
with pytest.raises(RuntimeError):
enable_gui(gui, kernel)
# Don't create another app even if `gui` is the same.
app = kernel.app
enable_gui(gui, kernel)
assert app == kernel.app

# Event loop intergration can be turned off.
enable_gui(None, kernel)
assert not hasattr(kernel, 'app')

# But now we're stuck with this version of Qt for good; can't switch.
for not_gui in ['qt6', 'qt5', 'qt4']:
for not_gui in ['qt6', 'qt5']:
if not_gui not in qt_guis_avail:
break

with pytest.raises(ImportError):
enable_gui(not_gui, kernel)
enable_gui(not_gui, kernel)
captured = capsys.readouterr()
assert captured.out == f'Cannot switch Qt versions for this session; you must use {gui}.\n'

# A gui of 'qt' means "best available", or in this case, the last one that was used.
# Check 'qt' gui, which means "the best available"
enable_gui(None, kernel)
enable_gui('qt', kernel)
assert gui_to_module[gui] in str(kernel.app)