eventloops.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. """Event loop integration for the ZeroMQ-based kernels."""
  2. # Copyright (c) IPython Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. import os
  5. import platform
  6. import sys
  7. from functools import partial
  8. import zmq
  9. from packaging.version import Version as V
  10. from traitlets.config.application import Application
  11. def _use_appnope():
  12. """Should we use appnope for dealing with OS X app nap?
  13. Checks if we are on OS X 10.9 or greater.
  14. """
  15. return sys.platform == "darwin" and V(platform.mac_ver()[0]) >= V("10.9")
  16. # mapping of keys to loop functions
  17. loop_map = {
  18. "inline": None,
  19. "nbagg": None,
  20. "webagg": None,
  21. "notebook": None,
  22. "ipympl": None,
  23. "widget": None,
  24. None: None,
  25. }
  26. def register_integration(*toolkitnames):
  27. """Decorator to register an event loop to integrate with the IPython kernel
  28. The decorator takes names to register the event loop as for the %gui magic.
  29. You can provide alternative names for the same toolkit.
  30. The decorated function should take a single argument, the IPython kernel
  31. instance, arrange for the event loop to yield the asyncio loop when a
  32. message is received by the main shell zmq stream or at least every
  33. ``kernel._poll_interval`` seconds, and start the event loop.
  34. :mod:`ipykernel.eventloops` provides and registers such functions
  35. for a few common event loops.
  36. """
  37. def decorator(func):
  38. """Integration registration decorator."""
  39. for name in toolkitnames:
  40. loop_map[name] = func
  41. func.exit_hook = lambda kernel: None # noqa: ARG005
  42. def exit_decorator(exit_func):
  43. """@func.exit is now a decorator
  44. to register a function to be called on exit
  45. """
  46. func.exit_hook = exit_func
  47. return exit_func
  48. func.exit = exit_decorator
  49. return func
  50. return decorator
  51. def get_shell_stream(kernel):
  52. # Return the zmq stream that receives messages for the main shell.
  53. if kernel._supports_kernel_subshells:
  54. manager = kernel.shell_channel_thread.manager
  55. socket_pair = manager.get_shell_channel_to_subshell_pair(None)
  56. return socket_pair.to_stream
  57. return kernel.shell_stream
  58. def _notify_stream_qt(kernel):
  59. import operator
  60. from functools import lru_cache
  61. from IPython.external.qt_for_kernel import QtCore
  62. try:
  63. from IPython.external.qt_for_kernel import enum_helper
  64. except ImportError:
  65. @lru_cache(None)
  66. def enum_helper(name):
  67. return operator.attrgetter(name.rpartition(".")[0])(sys.modules[QtCore.__package__])
  68. def exit_loop():
  69. """fall back to main loop"""
  70. kernel._qt_notifier.setEnabled(False)
  71. kernel.app.qt_event_loop.quit()
  72. def process_stream_events_wrap(shell_stream, *args, **kwargs):
  73. """fall back to main loop when there's a socket event"""
  74. # call flush to ensure that the stream doesn't lose events
  75. # due to our consuming of the edge-triggered FD
  76. # flush returns the number of events consumed.
  77. # if there were any, wake it up
  78. if shell_stream.flush(limit=1):
  79. exit_loop()
  80. shell_stream = get_shell_stream(kernel)
  81. process_stream_events = partial(process_stream_events_wrap, shell_stream)
  82. if not hasattr(kernel, "_qt_notifier"):
  83. fd = shell_stream.getsockopt(zmq.FD)
  84. kernel._qt_notifier = QtCore.QSocketNotifier(
  85. fd, enum_helper("QtCore.QSocketNotifier.Type").Read, kernel.app.qt_event_loop
  86. )
  87. kernel._qt_notifier.activated.connect(process_stream_events)
  88. else:
  89. kernel._qt_notifier.setEnabled(True)
  90. # allow for scheduling exits from the loop in case a timeout needs to
  91. # be set from the kernel level
  92. def _schedule_exit(delay):
  93. """schedule fall back to main loop in [delay] seconds"""
  94. # The signatures of QtCore.QTimer.singleShot are inconsistent between PySide and PyQt
  95. # if setting the TimerType, so we create a timer explicitly and store it
  96. # to avoid a memory leak.
  97. # PreciseTimer is needed so we exit after _at least_ the specified delay, not within 5% of it
  98. if not hasattr(kernel, "_qt_timer"):
  99. kernel._qt_timer = QtCore.QTimer(kernel.app)
  100. kernel._qt_timer.setSingleShot(True)
  101. kernel._qt_timer.setTimerType(enum_helper("QtCore.Qt.TimerType").PreciseTimer)
  102. kernel._qt_timer.timeout.connect(exit_loop)
  103. kernel._qt_timer.start(int(1000 * delay))
  104. loop_qt._schedule_exit = _schedule_exit
  105. # there may already be unprocessed events waiting.
  106. # these events will not wake zmq's edge-triggered FD
  107. # since edge-triggered notification only occurs on new i/o activity.
  108. # process all the waiting events immediately
  109. # so we start in a clean state ensuring that any new i/o events will notify.
  110. # schedule first call on the eventloop as soon as it's running,
  111. # so we don't block here processing events
  112. QtCore.QTimer.singleShot(0, process_stream_events)
  113. @register_integration("qt", "qt5", "qt6")
  114. def loop_qt(kernel):
  115. """Event loop for all supported versions of Qt."""
  116. _notify_stream_qt(kernel) # install hook to stop event loop.
  117. # Start the event loop.
  118. kernel.app._in_event_loop = True
  119. # `exec` blocks until there's ZMQ activity.
  120. el = kernel.app.qt_event_loop # for brevity
  121. el.exec() if hasattr(el, "exec") else el.exec_()
  122. kernel.app._in_event_loop = False
  123. # NOTE: To be removed in version 7
  124. loop_qt5 = loop_qt
  125. # exit and watch are the same for qt 4 and 5
  126. @loop_qt.exit
  127. def loop_qt_exit(kernel):
  128. kernel.app.exit()
  129. def _loop_wx(app):
  130. """Inner-loop for running the Wx eventloop
  131. Pulled from guisupport.start_event_loop in IPython < 5.2,
  132. since IPython 5.2 only checks `get_ipython().active_eventloop` is defined,
  133. rather than if the eventloop is actually running.
  134. """
  135. app._in_event_loop = True
  136. app.MainLoop()
  137. app._in_event_loop = False
  138. @register_integration("wx")
  139. def loop_wx(kernel):
  140. """Start a kernel with wx event loop support."""
  141. import wx
  142. # We have to put the wx.Timer in a wx.Frame for it to fire properly.
  143. # We make the Frame hidden when we create it in the main app below.
  144. class TimerFrame(wx.Frame): # type:ignore[misc]
  145. def __init__(self, kernel):
  146. self.kernel = kernel
  147. self.shell_stream = get_shell_stream(kernel)
  148. wx.Frame.__init__(self, None, -1)
  149. self.timer = wx.Timer(self)
  150. self.Bind(wx.EVT_CLOSE, self.on_exit)
  151. self.Bind(wx.EVT_TIMER, self.on_timer)
  152. # Units for the timer are in milliseconds
  153. self.timer.Start(int(1000 * self.kernel._poll_interval))
  154. def wake(self):
  155. """wake from wx"""
  156. try:
  157. if self.shell_stream.flush(limit=1):
  158. self.kernel.app.ExitMainLoop()
  159. except Exception:
  160. pass
  161. def on_timer(self, event):
  162. self.wake()
  163. def on_exit(self, event):
  164. self.timer.Stop()
  165. self.wake()
  166. self.Destroy()
  167. # We need a custom wx.App to create our Frame subclass that has the
  168. # wx.Timer to defer back to the tornado event loop.
  169. class IPWxApp(wx.App): # type:ignore[misc]
  170. def OnInit(self):
  171. self.frame = TimerFrame(kernel)
  172. self.frame.Show(False)
  173. return True
  174. # The redirect=False here makes sure that wx doesn't replace
  175. # sys.stdout/stderr with its own classes.
  176. if not (getattr(kernel, "app", None) and isinstance(kernel.app, wx.App)):
  177. kernel.app = IPWxApp(redirect=False)
  178. # The import of wx on Linux sets the handler for signal.SIGINT
  179. # to 0. This is a bug in wx or gtk. We fix by just setting it
  180. # back to the Python default.
  181. import signal
  182. if not callable(signal.getsignal(signal.SIGINT)):
  183. signal.signal(signal.SIGINT, signal.default_int_handler)
  184. _loop_wx(kernel.app)
  185. @loop_wx.exit
  186. def loop_wx_exit(kernel):
  187. """Exit the wx loop."""
  188. import wx
  189. wx.Exit()
  190. @register_integration("tk")
  191. def loop_tk(kernel):
  192. """Start a kernel with the Tk event loop."""
  193. from tkinter import READABLE, Tk
  194. app = Tk()
  195. # Capability detection:
  196. # per https://docs.python.org/3/library/tkinter.html#file-handlers
  197. # file handlers are not available on Windows
  198. if hasattr(app, "createfilehandler"):
  199. # A basic wrapper for structural similarity with the Windows version
  200. class BasicAppWrapper:
  201. def __init__(self, app):
  202. self.app = app
  203. self.app.withdraw()
  204. def exit_loop():
  205. """fall back to main loop"""
  206. app.tk.deletefilehandler(shell_stream.getsockopt(zmq.FD))
  207. app.quit()
  208. app.destroy()
  209. del kernel.app_wrapper
  210. def process_stream_events_wrap(shell_stream, *a, **kw):
  211. """fall back to main loop when there's a socket event"""
  212. if shell_stream.flush(limit=1):
  213. exit_loop()
  214. # allow for scheduling exits from the loop in case a timeout needs to
  215. # be set from the kernel level
  216. def _schedule_exit(delay):
  217. """schedule fall back to main loop in [delay] seconds"""
  218. app.after(int(1000 * delay), exit_loop)
  219. loop_tk._schedule_exit = _schedule_exit
  220. # For Tkinter, we create a Tk object and call its withdraw method.
  221. kernel.app_wrapper = BasicAppWrapper(app)
  222. shell_stream = get_shell_stream(kernel)
  223. process_stream_events = partial(process_stream_events_wrap, shell_stream)
  224. app.tk.createfilehandler(shell_stream.getsockopt(zmq.FD), READABLE, process_stream_events)
  225. # schedule initial call after start
  226. app.after(0, process_stream_events)
  227. app.mainloop()
  228. else:
  229. import asyncio
  230. import nest_asyncio
  231. nest_asyncio.apply()
  232. # Tk uses milliseconds
  233. poll_interval = int(1000 * kernel._poll_interval)
  234. shell_stream = get_shell_stream(kernel)
  235. class TimedAppWrapper:
  236. def __init__(self, app, shell_stream):
  237. self.app = app
  238. self.shell_stream = shell_stream
  239. self.app.withdraw()
  240. async def func(self):
  241. self.shell_stream.flush(limit=1)
  242. def on_timer(self):
  243. loop = asyncio.get_event_loop()
  244. try:
  245. loop.run_until_complete(self.func())
  246. except Exception:
  247. kernel.log.exception("Error in message handler")
  248. self.app.after(poll_interval, self.on_timer)
  249. def start(self):
  250. self.on_timer() # Call it once to get things going.
  251. self.app.mainloop()
  252. kernel.app_wrapper = TimedAppWrapper(app, shell_stream)
  253. kernel.app_wrapper.start()
  254. @loop_tk.exit
  255. def loop_tk_exit(kernel):
  256. """Exit the tk loop."""
  257. try:
  258. kernel.app_wrapper.app.quit()
  259. kernel.app_wrapper.app.destroy()
  260. del kernel.app_wrapper
  261. kernel.eventloop = None
  262. except (RuntimeError, AttributeError):
  263. pass
  264. @register_integration("gtk")
  265. def loop_gtk(kernel):
  266. """Start the kernel, coordinating with the GTK event loop"""
  267. from .gui.gtkembed import GTKEmbed
  268. gtk_kernel = GTKEmbed(kernel)
  269. gtk_kernel.start()
  270. kernel._gtk = gtk_kernel
  271. @loop_gtk.exit
  272. def loop_gtk_exit(kernel):
  273. """Exit the gtk loop."""
  274. kernel._gtk.stop()
  275. @register_integration("gtk3")
  276. def loop_gtk3(kernel):
  277. """Start the kernel, coordinating with the GTK event loop"""
  278. from .gui.gtk3embed import GTKEmbed
  279. gtk_kernel = GTKEmbed(kernel)
  280. gtk_kernel.start()
  281. kernel._gtk = gtk_kernel
  282. @loop_gtk3.exit
  283. def loop_gtk3_exit(kernel):
  284. """Exit the gtk3 loop."""
  285. kernel._gtk.stop()
  286. @register_integration("osx", "macosx")
  287. def loop_cocoa(kernel):
  288. """Start the kernel, coordinating with the Cocoa CFRunLoop event loop
  289. via the matplotlib MacOSX backend.
  290. """
  291. from ._eventloop_macos import mainloop, stop
  292. real_excepthook = sys.excepthook
  293. shell_stream = get_shell_stream(kernel)
  294. def handle_int(etype, value, tb):
  295. """don't let KeyboardInterrupts look like crashes"""
  296. # wake the eventloop when we get a signal
  297. stop()
  298. if etype is KeyboardInterrupt:
  299. print("KeyboardInterrupt caught in CFRunLoop", file=sys.__stdout__)
  300. else:
  301. real_excepthook(etype, value, tb)
  302. while not kernel.shell.exit_now:
  303. try:
  304. # double nested try/except, to properly catch KeyboardInterrupt
  305. # due to pyzmq Issue #130
  306. try:
  307. # don't let interrupts during mainloop invoke crash_handler:
  308. sys.excepthook = handle_int
  309. mainloop(kernel._poll_interval)
  310. if shell_stream.flush(limit=1):
  311. # events to process, return control to kernel
  312. return
  313. except BaseException:
  314. raise
  315. except KeyboardInterrupt:
  316. # Ctrl-C shouldn't crash the kernel
  317. print("KeyboardInterrupt caught in kernel", file=sys.__stdout__)
  318. finally:
  319. # ensure excepthook is restored
  320. sys.excepthook = real_excepthook
  321. @loop_cocoa.exit
  322. def loop_cocoa_exit(kernel):
  323. """Exit the cocoa loop."""
  324. from ._eventloop_macos import stop
  325. stop()
  326. @register_integration("asyncio")
  327. def loop_asyncio(kernel):
  328. """Start a kernel with asyncio event loop support."""
  329. import asyncio
  330. loop = asyncio.get_event_loop()
  331. # loop is already running (e.g. tornado 5), nothing left to do
  332. if loop.is_running():
  333. return
  334. if loop.is_closed():
  335. # main loop is closed, create a new one
  336. loop = asyncio.new_event_loop()
  337. asyncio.set_event_loop(loop)
  338. loop._should_close = False # type:ignore[attr-defined]
  339. # pause eventloop when there's an event on a zmq socket
  340. def process_stream_events(shell_stream):
  341. """fall back to main loop when there's a socket event"""
  342. if shell_stream.flush(limit=1):
  343. loop.stop()
  344. shell_stream = get_shell_stream(kernel)
  345. notifier = partial(process_stream_events, shell_stream)
  346. loop.add_reader(shell_stream.getsockopt(zmq.FD), notifier)
  347. loop.call_soon(notifier)
  348. while True:
  349. error = None
  350. try:
  351. loop.run_forever()
  352. except KeyboardInterrupt:
  353. continue
  354. except Exception as e:
  355. error = e
  356. if loop._should_close: # type:ignore[attr-defined]
  357. loop.close()
  358. if error is not None:
  359. raise error
  360. break
  361. @loop_asyncio.exit
  362. def loop_asyncio_exit(kernel):
  363. """Exit hook for asyncio"""
  364. import asyncio
  365. loop = asyncio.get_event_loop()
  366. async def close_loop():
  367. if hasattr(loop, "shutdown_asyncgens"):
  368. yield loop.shutdown_asyncgens()
  369. loop._should_close = True # type:ignore[attr-defined]
  370. loop.stop()
  371. if loop.is_running():
  372. close_loop()
  373. elif not loop.is_closed():
  374. loop.run_until_complete(close_loop) # type:ignore[arg-type]
  375. loop.close()
  376. def set_qt_api_env_from_gui(gui):
  377. """
  378. Sets the QT_API environment variable by trying to import PyQtx or PySidex.
  379. The user can generically request `qt` or a specific Qt version, e.g. `qt6`.
  380. For a generic Qt request, we let the mechanism in IPython choose the best
  381. available version by leaving the `QT_API` environment variable blank.
  382. For specific versions, we check to see whether the PyQt or PySide
  383. implementations are present and set `QT_API` accordingly to indicate to
  384. IPython which version we want. If neither implementation is present, we
  385. leave the environment variable set so IPython will generate a helpful error
  386. message.
  387. Notes
  388. -----
  389. - If the environment variable is already set, it will be used unchanged,
  390. regardless of what the user requested.
  391. """
  392. qt_api = os.environ.get("QT_API", None)
  393. from IPython.external.qt_loaders import (
  394. QT_API_PYQT5,
  395. QT_API_PYQT6,
  396. QT_API_PYSIDE2,
  397. QT_API_PYSIDE6,
  398. loaded_api,
  399. )
  400. loaded = loaded_api()
  401. qt_env2gui = {
  402. QT_API_PYSIDE2: "qt5",
  403. QT_API_PYQT5: "qt5",
  404. QT_API_PYSIDE6: "qt6",
  405. QT_API_PYQT6: "qt6",
  406. }
  407. if loaded is not None and gui != "qt" and qt_env2gui[loaded] != gui:
  408. print(f"Cannot switch Qt versions for this session; you must use {qt_env2gui[loaded]}.")
  409. return
  410. if qt_api is not None and gui != "qt":
  411. if qt_env2gui[qt_api] != gui:
  412. print(
  413. f'Request for "{gui}" will be ignored because `QT_API` '
  414. f'environment variable is set to "{qt_api}"'
  415. )
  416. return
  417. else:
  418. if gui == "qt5":
  419. try:
  420. import PyQt5 # noqa: F401
  421. os.environ["QT_API"] = "pyqt5"
  422. except ImportError:
  423. try:
  424. import PySide2 # noqa: F401
  425. os.environ["QT_API"] = "pyside2"
  426. except ImportError:
  427. os.environ["QT_API"] = "pyqt5"
  428. elif gui == "qt6":
  429. try:
  430. import PyQt6 # noqa: F401
  431. os.environ["QT_API"] = "pyqt6"
  432. except ImportError:
  433. try:
  434. import PySide6 # noqa: F401
  435. os.environ["QT_API"] = "pyside6"
  436. except ImportError:
  437. os.environ["QT_API"] = "pyqt6"
  438. elif gui == "qt":
  439. # Don't set QT_API; let IPython logic choose the version.
  440. if "QT_API" in os.environ:
  441. del os.environ["QT_API"]
  442. else:
  443. print(f'Unrecognized Qt version: {gui}. Should be "qt5", "qt6", or "qt".')
  444. return
  445. # Do the actual import now that the environment variable is set to make sure it works.
  446. try:
  447. pass
  448. except Exception as e:
  449. # Clear the environment variable for the next attempt.
  450. if "QT_API" in os.environ:
  451. del os.environ["QT_API"]
  452. print(f"QT_API couldn't be set due to error {e}")
  453. return
  454. def make_qt_app_for_kernel(gui, kernel):
  455. """Sets the `QT_API` environment variable if it isn't already set."""
  456. if hasattr(kernel, "app"):
  457. # Kernel is already running a Qt event loop, so there's no need to
  458. # create another app for it.
  459. return
  460. set_qt_api_env_from_gui(gui)
  461. # This import is guaranteed to work now:
  462. from IPython.external.qt_for_kernel import QtCore
  463. from IPython.lib.guisupport import get_app_qt4
  464. kernel.app = get_app_qt4([" "])
  465. kernel.app.qt_event_loop = QtCore.QEventLoop(kernel.app)
  466. def enable_gui(gui, kernel=None):
  467. """Enable integration with a given GUI"""
  468. if gui not in loop_map:
  469. e = f"Invalid GUI request {gui!r}, valid ones are:{loop_map.keys()}"
  470. raise ValueError(e)
  471. if kernel is None:
  472. if Application.initialized():
  473. kernel = getattr(Application.instance(), "kernel", None)
  474. if kernel is None:
  475. msg = (
  476. "You didn't specify a kernel,"
  477. " and no IPython Application with a kernel appears to be running."
  478. )
  479. raise RuntimeError(msg)
  480. if gui is None:
  481. # User wants to turn off integration; clear any evidence if Qt was the last one.
  482. if hasattr(kernel, "app"):
  483. delattr(kernel, "app")
  484. if hasattr(kernel, "_qt_notifier"):
  485. delattr(kernel, "_qt_notifier")
  486. if hasattr(kernel, "_qt_timer"):
  487. delattr(kernel, "_qt_timer")
  488. else:
  489. if gui.startswith("qt"):
  490. # Prepare the kernel here so any exceptions are displayed in the client.
  491. make_qt_app_for_kernel(gui, kernel)
  492. loop = loop_map[gui]
  493. if (
  494. loop and kernel.eventloop is not None and kernel.eventloop is not loop # type:ignore[unreachable]
  495. ):
  496. msg = "Cannot activate multiple GUI eventloops" # type:ignore[unreachable]
  497. raise RuntimeError(msg)
  498. kernel.eventloop = loop
  499. # We set `eventloop`; the function the user chose is executed in `Kernel.enter_eventloop`, thus
  500. # any exceptions raised during the event loop will not be shown in the client.