Format: text/x-python
⬇ Download File (19.0 KB)
   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