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