diff --git a/spyder/api/shellconnect/mixins.py b/spyder/api/shellconnect/mixins.py index d7e221663ed..0dba7925677 100644 --- a/spyder/api/shellconnect/mixins.py +++ b/spyder/api/shellconnect/mixins.py @@ -32,8 +32,6 @@ def on_ipython_console_available(self): ipyconsole.sig_shellwidget_changed.connect(self.set_shellwidget) ipyconsole.sig_shellwidget_created.connect(self.add_shellwidget) ipyconsole.sig_shellwidget_deleted.connect(self.remove_shellwidget) - ipyconsole.sig_external_spyder_kernel_connected.connect( - self.on_connection_to_external_spyder_kernel) @on_plugin_teardown(plugin=Plugins.IPythonConsole) def on_ipython_console_teardown(self): @@ -43,8 +41,6 @@ def on_ipython_console_teardown(self): ipyconsole.sig_shellwidget_changed.disconnect(self.set_shellwidget) ipyconsole.sig_shellwidget_created.disconnect(self.add_shellwidget) ipyconsole.sig_shellwidget_deleted.disconnect(self.remove_shellwidget) - ipyconsole.sig_external_spyder_kernel_connected.disconnect( - self.on_connection_to_external_spyder_kernel) # ---- Public API # ------------------------------------------------------------------------- @@ -110,16 +106,3 @@ def get_widget_for_shellwidget(self, shellwidget): The widget corresponding to the shellwidget, or None if not found. """ return self.get_widget().get_widget_for_shellwidget(shellwidget) - - def on_connection_to_external_spyder_kernel(self, shellwidget): - """ - Actions to take when the IPython console connects to an - external Spyder kernel. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shell widget that was connected to the external Spyder - kernel. - """ - pass diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index 3442d286f1d..6c74bc91b5a 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -58,6 +58,7 @@ DebuggerWidgetActions, DebuggerToolbarActions) from spyder.plugins.help.widgets import ObjectComboBox from spyder.plugins.help.tests.test_plugin import check_text +from spyder.plugins.ipythonconsole.utils.kernel_handler import KernelHandler from spyder.plugins.layout.layouts import DefaultLayouts from spyder.plugins.toolbar.api import ApplicationToolbars from spyder.py3compat import PY2, qbytearray_to_str, to_text_string @@ -120,6 +121,12 @@ def test_leaks(main_window, qtbot): Many other ways of leaking exist but are not covered here. """ + def wait_all_shutdown(): + objects = gc.get_objects() + for o in objects: + if isinstance(o, KernelHandler): + o.wait_shutdown_thread() + def ns_fun(main_window, qtbot): # Wait until the window is fully up shell = main_window.ipyconsole.get_current_shellwidget() @@ -129,7 +136,7 @@ def ns_fun(main_window, qtbot): # Count initial objects # Only one of each should be present, but because of many leaks, # this is most likely not the case. Here only closing is tested - shell.wait_all_shutdown() + wait_all_shutdown() gc.collect() objects = gc.get_objects() n_code_editor_init = 0 @@ -160,8 +167,7 @@ def ns_fun(main_window, qtbot): main_window.ipyconsole.restart() # Wait until the shells are closed - shell = main_window.ipyconsole.get_current_shellwidget() - shell.wait_all_shutdown() + wait_all_shutdown() return n_shell_init, n_code_editor_init n_shell_init, n_code_editor_init = ns_fun(main_window, qtbot) diff --git a/spyder/plugins/ipythonconsole/comms/kernelcomm.py b/spyder/plugins/ipythonconsole/comms/kernelcomm.py index 82f0f16e8c1..bcbbaf8dc63 100644 --- a/spyder/plugins/ipythonconsole/comms/kernelcomm.py +++ b/spyder/plugins/ipythonconsole/comms/kernelcomm.py @@ -37,6 +37,16 @@ def __init__(self): # Register handlers self.register_call_handler('_async_error', self._async_error) + def is_open(self, comm_id=None): + """Check to see if the comm is open.""" + valid_comms = [ + comm for comm in self._comms + if self._comms[comm]['status'] in ['opening', 'ready'] + ] + if comm_id is None: + return len(valid_comms) > 0 + return comm_id in valid_comms + @contextmanager def comm_channel_manager(self, comm_id, queue_message=False): """Use control_channel instead of shell_channel.""" @@ -63,7 +73,7 @@ def _set_call_return_value(self, call_dict, data, is_error=False): super(KernelComm, self)._set_call_return_value( call_dict, data, is_error) - def remove(self, comm_id=None): + def remove(self, comm_id=None, only_closing=False): """ Remove the comm without notifying the other side. @@ -71,6 +81,8 @@ def remove(self, comm_id=None): """ id_list = self.get_comm_id_list(comm_id) for comm_id in id_list: + if only_closing and self._comms[comm_id]['status'] != 'closing': + continue del self._comms[comm_id] def close(self, comm_id=None): diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index 5a9933b8640..4ce0e52d88f 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -138,16 +138,6 @@ class IPythonConsole(SpyderDockablePlugin): The shellwigdet. """ - sig_external_spyder_kernel_connected = Signal(object) - """ - This signal is emitted when we connect to an external Spyder kernel. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shellwigdet that was connected to the kernel. - """ - sig_render_plain_text_requested = Signal(str) """ This signal is emitted to request a plain text help render. @@ -216,8 +206,6 @@ def on_initialize(self): widget.sig_shellwidget_created.connect(self.sig_shellwidget_created) widget.sig_shellwidget_deleted.connect(self.sig_shellwidget_deleted) widget.sig_shellwidget_changed.connect(self.sig_shellwidget_changed) - widget.sig_external_spyder_kernel_connected.connect( - self.sig_external_spyder_kernel_connected) widget.sig_render_plain_text_requested.connect( self.sig_render_plain_text_requested) widget.sig_render_rich_text_requested.connect( diff --git a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py index c83e068e82f..b616853ca21 100644 --- a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py +++ b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py @@ -50,6 +50,7 @@ from spyder.plugins.help.tests.test_plugin import check_text from spyder.plugins.help.utils.sphinxify import CSS_PATH from spyder.plugins.ipythonconsole.plugin import IPythonConsole +from spyder.plugins.ipythonconsole.utils import stdfile from spyder.plugins.ipythonconsole.utils.style import create_style_class from spyder.plugins.ipythonconsole.widgets import ClientWidget from spyder.utils.programs import get_temp_dir @@ -159,9 +160,9 @@ def __getattr__(self, attr): # Instruct the console to not use a stderr file no_stderr_file = request.node.get_closest_marker('no_stderr_file') if no_stderr_file: - test_no_stderr = 'True' + test_no_stderr = True else: - test_no_stderr = '' + test_no_stderr = False # Use the automatic backend if requested auto_backend = request.node.get_closest_marker('auto_backend') @@ -211,8 +212,8 @@ def __getattr__(self, attr): # Create the console and a new client and set environment os.environ['IPYCONSOLE_TESTING'] = 'True' - os.environ['IPYCONSOLE_TEST_DIR'] = test_dir - os.environ['IPYCONSOLE_TEST_NO_STDERR'] = test_no_stderr + stdfile.IPYCONSOLE_TEST_DIR = test_dir + stdfile.IPYCONSOLE_TEST_NO_STDERR = test_no_stderr window = MainWindowMock() console = IPythonConsole(parent=window, configuration=configuration) @@ -288,8 +289,8 @@ def get_plugin(name): # Close console.on_close() os.environ.pop('IPYCONSOLE_TESTING') - os.environ.pop('IPYCONSOLE_TEST_DIR') - os.environ.pop('IPYCONSOLE_TEST_NO_STDERR') + stdfile.IPYCONSOLE_TEST_DIR = None + stdfile.IPYCONSOLE_TEST_NO_STDERR = False if os.name == 'nt' or known_leak: # Do not test for leaks @@ -1306,7 +1307,8 @@ def test_set_elapsed_time(ipyconsole, qtbot): # Set time to 2 minutes ago. client.t0 -= 120 with qtbot.waitSignal(client.timer.timeout, timeout=5000): - ipyconsole.get_widget().set_client_elapsed_time(client) + client.timer.timeout.connect(client.show_time) + client.timer.start(1000) assert ('00:02:00' in main_widget.time_label.text() or '00:02:01' in main_widget.time_label.text()) @@ -1401,10 +1403,9 @@ def test_kernel_crash(ipyconsole, qtbot): ipyconsole.create_new_client() # Assert that the console is showing an error - qtbot.waitUntil(lambda: ipyconsole.get_clients()[-1].is_error_shown, - timeout=6000) error_client = ipyconsole.get_clients()[-1] - assert error_client.is_error_shown + qtbot.waitUntil(lambda: bool(error_client.error_text), timeout=6000) + assert error_client.error_text # Assert the error contains the text we expect webview = error_client.infowidget @@ -1636,10 +1637,17 @@ def test_pdb_ignore_lib(ipyconsole, qtbot, show_lib): control.setFocus() # Tests assume inline backend + qtbot.wait(1000) ipyconsole.set_conf('pdb_ignore_lib', not show_lib, section="debugger") + qtbot.wait(1000) with qtbot.waitSignal(shell.executed): shell.execute('%debug print()') + with qtbot.waitSignal(shell.executed): + shell.execute( + '"value = " + str(get_ipython().pdb_session.pdb_ignore_lib)') + assert "value = " + str(not show_lib) in control.toPlainText() + qtbot.keyClicks(control, '!s') with qtbot.waitSignal(shell.executed): qtbot.keyClick(control, Qt.Key_Enter) @@ -2412,14 +2420,12 @@ def test_old_kernel_version(ipyconsole, qtbot): """ # Set a false _spyder_kernels_version in the cached kernel w = ipyconsole.get_widget() - # create new client so PYTEST_CURRENT_TEST is the same - w.create_new_client() # Wait until the window is fully up shell = ipyconsole.get_current_shellwidget() qtbot.waitUntil( lambda: shell._prompt_html is not None, timeout=SHELL_TIMEOUT) - kc = w._cached_kernel_properties[-1][2] + kc = w._cached_kernel_properties[-1].kernel_client kc.start_channels() kc.execute("get_ipython()._spyder_kernels_version = ('1.0.0', '')") # Cleanup the kernel_client so it can be used again @@ -2435,8 +2441,10 @@ def test_old_kernel_version(ipyconsole, qtbot): client = w.get_current_client() # Make sure an error is shown - qtbot.waitUntil(lambda: client.error_text is not None) - assert '1.0.0' in client.error_text + control = client.get_control() + qtbot.waitUntil( + lambda: "1.0.0" in control.toPlainText(), timeout=SHELL_TIMEOUT) + assert "conda install spyder" in control.toPlainText() def test_run_script(ipyconsole, qtbot, tmp_path): diff --git a/spyder/plugins/ipythonconsole/utils/kernel_handler.py b/spyder/plugins/ipythonconsole/utils/kernel_handler.py new file mode 100644 index 00000000000..ee2e912e29c --- /dev/null +++ b/spyder/plugins/ipythonconsole/utils/kernel_handler.py @@ -0,0 +1,404 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Kernel handler.""" + +# Standard library imports +import os +import os.path as osp +import re +from threading import Lock +import uuid + +# Third-party imports +from jupyter_core.paths import jupyter_runtime_dir +from qtpy.QtCore import QThread +from zmq.ssh import tunnel as zmqtunnel + +# Local imports +from spyder.api.translations import get_translation +from spyder.plugins.ipythonconsole.utils.manager import SpyderKernelManager +from spyder.plugins.ipythonconsole.utils.client import SpyderKernelClient +from spyder.plugins.ipythonconsole.utils.ssh import openssh_tunnel +from spyder.plugins.ipythonconsole.utils.stdfile import StdFile + + +# Localization +_ = get_translation("spyder") + +PERMISSION_ERROR_MSG = _( + "The directory {} is not writable and it is required to create IPython " + "consoles. Please make it writable." +) + +if os.name == "nt": + ssh_tunnel = zmqtunnel.paramiko_tunnel +else: + ssh_tunnel = openssh_tunnel + + +class KernelHandler: + """ + A class to handle the kernel in several ways and store kernel connection + information. + """ + + def __init__( + self, + connection_file, + kernel_manager=None, + kernel_client=None, + stderr_obj=None, + stdout_obj=None, + fault_obj=None, + known_spyder_kernel=False, + hostname=None, + sshkey=None, + password=None, + ): + # Connection Informations + self.connection_file = connection_file + self.kernel_manager = kernel_manager + self.kernel_client = kernel_client + self.stderr_obj = stderr_obj + self.stdout_obj = stdout_obj + self.fault_obj = fault_obj + self.known_spyder_kernel = known_spyder_kernel + self.hostname = hostname + self.sshkey = sshkey + self.password = password + + # Comm + self.kernel_comm = None + + # Internal + self.shutdown_thread = None + self._shutdown_lock = Lock() + + @staticmethod + def new_connection_file(): + """ + Generate a new connection file + + Taken from jupyter_client/console_app.py + Licensed under the BSD license + """ + # Check if jupyter_runtime_dir exists (Spyder addition) + if not osp.isdir(jupyter_runtime_dir()): + try: + os.makedirs(jupyter_runtime_dir()) + except (IOError, OSError): + return None + cf = "" + while not cf: + ident = str(uuid.uuid4()).split("-")[-1] + cf = os.path.join(jupyter_runtime_dir(), "kernel-%s.json" % ident) + cf = cf if not os.path.exists(cf) else "" + return cf + + @staticmethod + def tunnel_to_kernel( + connection_info, hostname, sshkey=None, password=None, timeout=10 + ): + """ + Tunnel connections to a kernel via ssh. + + Remote ports are specified in the connection info ci. + """ + lports = zmqtunnel.select_random_ports(5) + rports = ( + connection_info["shell_port"], + connection_info["iopub_port"], + connection_info["stdin_port"], + connection_info["hb_port"], + connection_info["control_port"], + ) + remote_ip = connection_info["ip"] + for lp, rp in zip(lports, rports): + ssh_tunnel(lp, rp, hostname, remote_ip, sshkey, password, timeout) + return tuple(lports) + + @classmethod + def new_from_spec(cls, kernel_spec): + """ + Create a new kernel. + + Might raise all kinds of exceptions + """ + connection_file = cls.new_connection_file() + if connection_file is None: + raise RuntimeError( + PERMISSION_ERROR_MSG.format(jupyter_runtime_dir()) + ) + + stderr_obj = StdFile(connection_file, ".stderr") + stdout_obj = StdFile(connection_file, ".stdout") + fault_obj = StdFile(connection_file, ".fault") + + # Kernel manager + kernel_manager = SpyderKernelManager( + connection_file=connection_file, + config=None, + autorestart=True, + ) + + kernel_manager._kernel_spec = kernel_spec + + kernel_manager.start_kernel( + stderr=stderr_obj.handle, + stdout=stdout_obj.handle, + env=kernel_spec.env, + ) + + # Kernel client + kernel_client = kernel_manager.client() + + # Increase time (in seconds) to detect if a kernel is alive. + # See spyder-ide/spyder#3444. + kernel_client.hb_channel.time_to_dead = 25.0 + + return cls( + connection_file=connection_file, + kernel_manager=kernel_manager, + kernel_client=kernel_client, + stderr_obj=stderr_obj, + stdout_obj=stdout_obj, + fault_obj=fault_obj, + known_spyder_kernel=True, + ) + + @classmethod + def from_connection_file( + cls, connection_file, hostname=None, sshkey=None, password=None + ): + """Create kernel for given connection file.""" + new_kernel = cls( + connection_file, + hostname=hostname, + sshkey=sshkey, + password=password, + ) + # Get new kernel_client + new_kernel.init_kernel_client() + return new_kernel + + def init_kernel_client(self): + """Create kernel client.""" + kernel_client = SpyderKernelClient( + connection_file=self.connection_file + ) + + # This is needed for issue spyder-ide/spyder#9304. + try: + kernel_client.load_connection_file() + except Exception as e: + raise RuntimeError( + _( + "An error occurred while trying to load " + "the kernel connection file. The error " + "was:\n\n" + ) + + str(e) + ) + + if self.hostname is not None: + try: + connection_info = dict( + ip=kernel_client.ip, + shell_port=kernel_client.shell_port, + iopub_port=kernel_client.iopub_port, + stdin_port=kernel_client.stdin_port, + hb_port=kernel_client.hb_port, + control_port=kernel_client.control_port, + ) + + ( + kernel_client.shell_port, + kernel_client.iopub_port, + kernel_client.stdin_port, + kernel_client.hb_port, + kernel_client.control_port, + ) = self.tunnel_to_kernel( + connection_info, self.hostname, self.sshkey, self.password + ) + except Exception as e: + raise RuntimeError( + _("Could not open ssh tunnel. The error was:\n\n") + + str(e) + ) + self.kernel_client = kernel_client + + def close(self, shutdown_kernel=True, now=False): + """Close kernel""" + if self.kernel_comm is not None: + self.kernel_comm.close() + + if shutdown_kernel and self.kernel_manager is not None: + km = self.kernel_manager + km.stop_restarter() + + if now: + km.shutdown_kernel(now=True) + self.after_shutdown() + else: + shutdown_thread = QThread(None) + shutdown_thread.run = self._thread_shutdown_kernel + shutdown_thread.start() + shutdown_thread.finished.connect(self.after_shutdown) + self.shutdown_thread = shutdown_thread + + if ( + self.kernel_client is not None + and self.kernel_client.channels_running + ): + self.kernel_client.stop_channels() + + def after_shutdown(self): + """Cleanup after shutdown""" + if self.kernel_comm is not None: + self.kernel_comm.remove(only_closing=True) + self.shutdown_thread = None + + def _thread_shutdown_kernel(self): + """Shutdown kernel.""" + with self._shutdown_lock: + # Avoid calling shutdown_kernel on the same manager twice + # from different threads to avoid crash. + if self.kernel_manager.shutting_down: + return + self.kernel_manager.shutting_down = True + try: + self.kernel_manager.shutdown_kernel() + except Exception: + # kernel was externally killed + pass + + def wait_shutdown_thread(self): + """Wait shutdown thread.""" + thread = self.shutdown_thread + if thread is None: + return + if thread.isRunning(): + try: + thread.kernel_manager._kill_kernel() + except Exception: + pass + thread.quit() + thread.wait() + + def copy(self): + """Copy kernel.""" + # Copy kernel infos + new_kernel = self.__class__( + connection_file=self.connection_file, + kernel_manager=self.kernel_manager, + known_spyder_kernel=self.known_spyder_kernel, + hostname=self.hostname, + sshkey=self.sshkey, + password=self.password, + ) + + # Copy std file + if self.stderr_obj is not None: + new_kernel.stderr_obj = self.stderr_obj.copy() + if self.stdout_obj is not None: + new_kernel.stdout_obj = self.stdout_obj.copy() + if self.fault_obj is not None: + new_kernel.fault_obj = self.fault_obj.copy() + + # Get new kernel_client + new_kernel.init_kernel_client() + return new_kernel + + def remove_files(self): + """Remove std files.""" + for obj in [self.stderr_obj, self.stderr_obj, self.fault_obj]: + if obj is not None: + obj.remove() + + def open_comm(self, kernel_comm): + """Open kernel comm""" + kernel_comm.open_comm(self.kernel_client) + self.kernel_comm = kernel_comm + + def replace_std_files(self): + """Replace std files.""" + for obj in [self.stderr_obj, self.stderr_obj, self.fault_obj]: + if obj is None: + continue + obj.remove() + fn = obj.filename + m = re.match(r"(.+_)(\d+)(.[a-z]+)", fn) + if m: + # Already a replaced file + path, n, ext = m.groups() + obj.filename = path + str(1 + int(n)) + ext + continue + m = re.match(r"(.+)(.[a-z]+)", fn) + if m: + # First replaced file + path, ext = m.groups() + obj.filename = path + "_1" + ext + continue + # No extension, should not happen + obj.filename += "_1" + + def get_fault_filename(self): + """Get faulthandler filename""" + if self.fault_obj is None: + return + return self.fault_obj.filename + + def get_fault_text(self): + """Get a fault from a previous session.""" + + if self.fault_obj is None: + return + fault = self.fault_obj.get_contents() + if not fault: + return + + thread_regex = ( + r"(Current thread|Thread) " + r"(0x[\da-f]+) \(most recent call first\):" + r"(?:.|\r\n|\r|\n)+?(?=Current thread|Thread|\Z)") + # Keep line for future improvments + # files_regex = r"File \"([^\"]+)\", line (\d+) in (\S+)" + + main_re = "Main thread id:(?:\r\n|\r|\n)(0x[0-9a-f]+)" + main_id = 0 + for match in re.finditer(main_re, fault): + main_id = int(match.group(1), base=16) + + system_re = ("System threads ids:" + "(?:\r\n|\r|\n)(0x[0-9a-f]+(?: 0x[0-9a-f]+)+)") + ignore_ids = [] + start_idx = 0 + for match in re.finditer(system_re, fault): + ignore_ids = [int(i, base=16) for i in match.group(1).split()] + start_idx = match.span()[1] + text = "" + for idx, match in enumerate(re.finditer(thread_regex, fault)): + if idx == 0: + text += fault[start_idx:match.span()[0]] + thread_id = int(match.group(2), base=16) + if thread_id != main_id: + if thread_id in ignore_ids: + continue + if "wurlitzer.py" in match.group(0): + # Wurlitzer threads are launched later + continue + text += "\n" + match.group(0) + "\n" + else: + try: + pattern = (r".*(?:/IPython/core/interactiveshell\.py|" + r"\\IPython\\core\\interactiveshell\.py).*") + match_internal = next(re.finditer(pattern, match.group(0))) + end_idx = match_internal.span()[0] + except StopIteration: + end_idx = None + text += "\nMain thread:\n" + match.group(0)[:end_idx] + "\n" + return text diff --git a/spyder/plugins/ipythonconsole/utils/ssh.py b/spyder/plugins/ipythonconsole/utils/ssh.py index dde51e4d8bb..87aef9b1285 100644 --- a/spyder/plugins/ipythonconsole/utils/ssh.py +++ b/spyder/plugins/ipythonconsole/utils/ssh.py @@ -9,9 +9,8 @@ import atexit import os -from qtpy.QtWidgets import QMessageBox -if not os.name == 'nt': - import pexpect +from qtpy.QtWidgets import QApplication, QMessageBox +import pexpect from spyder.config.base import _ @@ -20,7 +19,7 @@ def _stop_tunnel(cmd): pexpect.run(cmd) -def openssh_tunnel(self, lport, rport, server, remoteip='127.0.0.1', +def openssh_tunnel(lport, rport, server, remoteip='127.0.0.1', keyfile=None, password=None, timeout=0.4): """ We decided to replace pyzmq's openssh_tunnel method to work around @@ -68,7 +67,8 @@ def openssh_tunnel(self, lport, rport, server, remoteip='127.0.0.1', question = _("The authenticity of host %s can't be " "established. Are you sure you want to continue " "connecting?") % host - reply = QMessageBox.question(self, _('Warning'), question, + reply = QMessageBox.question(QApplication.activeWindow(), + _('Warning'), question, QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if reply == QMessageBox.Yes: diff --git a/spyder/plugins/ipythonconsole/utils/stdfile.py b/spyder/plugins/ipythonconsole/utils/stdfile.py index c0cb22fa454..01cc262d343 100644 --- a/spyder/plugins/ipythonconsole/utils/stdfile.py +++ b/spyder/plugins/ipythonconsole/utils/stdfile.py @@ -21,26 +21,29 @@ from spyder.utils.programs import get_temp_dir -def std_filename(connection_file, extension, std_dir=None): +# For testing +IPYCONSOLE_TEST_DIR = None +IPYCONSOLE_TEST_NO_STDERR = False + + +def std_filename(connection_file, extension): """Filename to save kernel output.""" json_file = osp.basename(connection_file) file = json_file.split('.json')[0] + extension - if std_dir is not None: - file = osp.join(std_dir, file) - else: - try: - file = osp.join(get_temp_dir(), file) - except (IOError, OSError): - file = None - return file + if IPYCONSOLE_TEST_DIR is not None: + return osp.join(IPYCONSOLE_TEST_DIR, file) + try: + return osp.join(get_temp_dir(), file) + except (IOError, OSError): + return None class StdFile: - def __init__(self, connection_file, extension=None, std_dir=None): + def __init__(self, connection_file, extension=None): if extension is None: self.filename = connection_file else: - self.filename = std_filename(connection_file, extension, std_dir) + self.filename = std_filename(connection_file, extension) self._mtime = 0 self._cursor = 0 self._handle = None @@ -48,6 +51,8 @@ def __init__(self, connection_file, extension=None, std_dir=None): @property def handle(self): """Get handle to file.""" + if IPYCONSOLE_TEST_NO_STDERR: + return None if self._handle is None and self.filename is not None: # Needed to prevent any error that could appear. # See spyder-ide/spyder#6267. @@ -67,10 +72,13 @@ def remove(self): if self._handle is not None: self._handle.close() os.remove(self.filename) - self._handle = None except Exception: pass + self._handle = None + self._mtime = 0 + self._cursor = 0 + def get_contents(self): """Get the contents of the std kernel file.""" try: diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 232aef08fc7..4760ab725ff 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -17,28 +17,27 @@ import logging import os import os.path as osp -import re from string import Template import time +import traceback # Third party imports (qtpy) from qtpy.QtCore import QUrl, QTimer, Signal, Slot, QThread -from qtpy.QtWidgets import (QMessageBox, QVBoxLayout, QWidget) +from qtpy.QtWidgets import QVBoxLayout, QWidget # Local imports from spyder.api.translations import get_translation from spyder.api.widgets.mixins import SpyderWidgetMixin from spyder.config.base import ( - get_home_dir, get_module_source_path, running_under_pytest) + get_home_dir, get_module_source_path, get_conf_path) from spyder.utils.icon_manager import ima from spyder.utils import sourcecode from spyder.utils.image_path_manager import get_image_path from spyder.utils.installers import InstallerIPythonKernelError -from spyder.utils.encoding import get_coding from spyder.utils.environ import RemoteEnvDialog from spyder.utils.palette import QStylePalette from spyder.utils.qthelpers import add_actions, DialogManager -from spyder.py3compat import to_text_string +from spyder.plugins.ipythonconsole import SpyderKernelError from spyder.plugins.ipythonconsole.widgets import ShellWidget from spyder.widgets.collectionseditor import CollectionsEditor from spyder.widgets.mixins import SaveHistoryMixin @@ -89,53 +88,40 @@ class ClientWidget(QWidget, SaveHistoryMixin, SpyderWidgetMixin): '# *** Spyder Python Console History Log ***', ] def __init__(self, parent, id_, - history_filename, config_options, - additional_options, interpreter_versions, - connection_file=None, hostname=None, + config_options, + additional_options, + interpreter_versions, context_menu_actions=(), menu_actions=None, - is_external_kernel=False, - is_spyder_kernel=True, given_name=None, give_focus=True, options_button=None, - show_elapsed_time=False, - reset_warning=True, - ask_before_restart=True, - ask_before_closing=False, - css_path=None, handlers={}, - stderr_obj=None, - stdout_obj=None, - fault_obj=None, initial_cwd=None): super(ClientWidget, self).__init__(parent) - SaveHistoryMixin.__init__(self, history_filename) + SaveHistoryMixin.__init__(self, get_conf_path('history.py')) # --- Init attrs self.container = parent self.id_ = id_ - self.connection_file = connection_file - self.hostname = hostname self.menu_actions = menu_actions - self.is_external_kernel = is_external_kernel self.given_name = given_name - self.show_elapsed_time = show_elapsed_time - self.reset_warning = reset_warning - self.ask_before_restart = ask_before_restart - self.ask_before_closing = ask_before_closing self.initial_cwd = initial_cwd # --- Other attrs + self.kernel_handler = None + self.hostname = None + self.show_elapsed_time = self.get_conf('show_elapsed_time') + self.reset_warning = self.get_conf('show_reset_namespace_warning') self.context_menu_actions = context_menu_actions self.options_button = options_button self.history = [] self.allow_rename = True - self.is_error_shown = False self.error_text = None self.restart_thread = None self.give_focus = give_focus + css_path = self.get_conf('css_path', section='appearance') if css_path is None: self.css_path = CSS_PATH else: @@ -147,8 +133,6 @@ def __init__(self, parent, id_, ipyclient=self, additional_options=additional_options, interpreter_versions=interpreter_versions, - is_external_kernel=is_external_kernel, - is_spyder_kernel=is_spyder_kernel, handlers=handlers, local_kernel=True ) @@ -158,7 +142,6 @@ def __init__(self, parent, id_, # To keep a reference to the page to be displayed # in infowidget self.info_page = None - self._before_prompt_is_ready() # Elapsed time self.t0 = time.monotonic() @@ -179,17 +162,7 @@ def __init__(self, parent, id_, self.dialog_manager = DialogManager() # --- Standard files handling - self.stderr_obj = stderr_obj - self.stdout_obj = stdout_obj - self.fault_obj = fault_obj self.std_poll_timer = None - if self.stderr_obj is not None or self.stdout_obj is not None: - self.std_poll_timer = QTimer(self) - self.std_poll_timer.timeout.connect(self.poll_std_file_change) - self.std_poll_timer.setInterval(1000) - self.std_poll_timer.start() - self.shellwidget.executed.connect(self.poll_std_file_change) - self.start_successful = False def __del__(self): @@ -200,10 +173,9 @@ def __del__(self): self.restart_thread.wait() # ----- Private methods --------------------------------------------------- - def _before_prompt_is_ready(self, show_loading_page=True): + def _before_prompt_is_ready(self): """Configuration before kernel is connected.""" - if show_loading_page: - self._show_loading_page() + self._show_loading_page() self.shellwidget.sig_prompt_ready.connect( self._when_prompt_is_ready) # If remote execution, the loading page should be hidden as well @@ -212,7 +184,11 @@ def _before_prompt_is_ready(self, show_loading_page=True): def _when_prompt_is_ready(self): """Configuration after the prompt is shown.""" + if self.error_text: + # an error occured during startup, but after the prompt was sent + return self.start_successful = True + # To hide the loading page self._hide_loading_page() @@ -271,30 +247,6 @@ def _hide_loading_page(self): self.set_info_page() self.shellwidget.show() - def _read_stderr(self): - """Read the stderr file of the kernel.""" - # We need to read stderr_file as bytes to be able to - # detect its encoding with chardet - f = open(self.stderr_file, 'rb') - - try: - stderr_text = f.read() - - # This is needed to avoid showing an empty error message - # when the kernel takes too much time to start. - # See spyder-ide/spyder#8581. - if not stderr_text: - return '' - - # This is needed since the stderr file could be encoded - # in something different to utf-8. - # See spyder-ide/spyder#4191. - encoding = get_coding(stderr_text) - stderr_text = to_text_string(stderr_text, encoding) - return stderr_text - finally: - f.close() - def _show_mpl_backend_errors(self): """ Show possible errors when setting the selected Matplotlib backend. @@ -407,11 +359,43 @@ def _set_initial_cwd_in_kernel(self): # ----- Public API -------------------------------------------------------- @property - def kernel_id(self): - """Get kernel id.""" - if self.connection_file is not None: - json_file = osp.basename(self.connection_file) - return json_file.split('.json')[0] + def connection_file(self): + if self.kernel_handler is None: + return None + return self.kernel_handler.connection_file + + @property + def stderr_obj(self): + if self.kernel_handler is None: + return None + return self.kernel_handler.stderr_obj + + @property + def stdout_obj(self): + if self.kernel_handler is None: + return None + return self.kernel_handler.stdout_obj + + def start_std_poll(self): + """Start polling std files""" + self.std_poll_timer = QTimer(self) + self.std_poll_timer.timeout.connect(self.poll_std_file_change) + self.std_poll_timer.setInterval(1000) + self.std_poll_timer.start() + self.shellwidget.executed.connect(self.poll_std_file_change) + + def connect_kernel(self, kernel_handler): + """Connect kernel to client using our handler.""" + self._before_prompt_is_ready() + self.kernel_handler = kernel_handler + if ( + kernel_handler.stderr_obj is not None + or kernel_handler.stdout_obj is not None + ): + self.start_std_poll() + + # Actually do the connection + self.shellwidget.connect_kernel(kernel_handler) def remove_std_files(self, is_last_client=True): """Remove stderr_file associated with the client.""" @@ -421,13 +405,8 @@ def remove_std_files(self, is_last_client=True): pass if self.std_poll_timer is not None: self.std_poll_timer.stop() - if is_last_client: - if self.stderr_obj is not None: - self.stderr_obj.remove() - if self.stdout_obj is not None: - self.stdout_obj.remove() - if self.fault_obj is not None: - self.fault_obj.remove() + if is_last_client and self.kernel_handler is not None: + self.kernel_handler.remove_files() @Slot() def poll_std_file_change(self): @@ -461,12 +440,10 @@ def poll_std_file_change(self): self.shellwidget._append_plain_text( '\n' + stdout, before_prompt=True) - def configure_shellwidget(self, give_focus=True): + def connect_shellwidget_signals(self): """Configure shellwidget after kernel is connected.""" - self.give_focus = give_focus - # Set exit callback - self.shellwidget.set_exit_callback() + self.shellwidget.exit_requested.connect(self.exit_callback) # To save history self.shellwidget.executing.connect(self.add_to_history) @@ -503,23 +480,6 @@ def configure_shellwidget(self, give_focus=True): # To sync with working directory toolbar self.shellwidget.executed.connect(self.shellwidget.update_cwd) - self.send_kernel_configuration() - - def send_kernel_configuration(self): - """Send kernel configuration to kernel.""" - - # To apply style - self.set_color_scheme(self.shellwidget.syntax_style, reset=False) - - # Enable faulthandler - if self.fault_obj is not None: - # To display faulthandler - self.shellwidget.call_kernel().enable_faulthandler( - self.fault_obj.filename) - - # Give a chance to plugins to configure the kernel - self.shellwidget.sig_config_kernel_requested.emit() - def add_to_history(self, command): """Add command to history""" if self.shellwidget.is_debugging(): @@ -541,6 +501,12 @@ def stop_button_click_handler(self): def show_kernel_error(self, error): """Show kernel initialization errors in infowidget.""" + if isinstance(error, Exception): + if isinstance(error, SpyderKernelError): + error = error.args[0] + else: + error = _("The error is:

" + "{}").format(traceback.format_exc()) self.error_text = error if self.is_benign_error(error): @@ -571,9 +537,6 @@ def show_kernel_error(self, error): self.shellwidget.hide() self.infowidget.show() - # Tell the client we're in error mode - self.is_error_shown = True - # Stop shellwidget self.shellwidget.shutdown() self.remove_std_files(is_last_client=False) @@ -630,10 +593,6 @@ def get_control(self): else: return self.shellwidget._control - def get_kernel(self): - """Get kernel associated with this client""" - return self.shellwidget.kernel_manager - def add_actions_to_context_menu(self, menu): """Add actions to IPython widget context menu""" add_actions(menu, self.context_menu_actions) @@ -682,8 +641,9 @@ def shutdown(self, is_last_client): self.restart_thread.quit() self.restart_thread.wait() shutdown_kernel = ( - is_last_client and not self.is_external_kernel - and not self.is_error_shown) + is_last_client and not self.shellwidget.is_external_kernel + and not self.error_text + ) self.shellwidget.shutdown(shutdown_kernel) self.remove_std_files(shutdown_kernel) @@ -705,58 +665,47 @@ def restart_kernel(self): Licensed under the BSD license """ sw = self.shellwidget + if sw.is_external_kernel: + sw._append_plain_text( + _('Cannot restart a kernel not started by Spyder\n'), + before_prompt=True + ) + return - if not running_under_pytest() and self.ask_before_restart: - message = _('Are you sure you want to restart the kernel?') - buttons = QMessageBox.Yes | QMessageBox.No - result = QMessageBox.question(self, _('Restart kernel?'), - message, buttons) - else: - result = None - - if (result == QMessageBox.Yes or - running_under_pytest() or - not self.ask_before_restart): - if sw.kernel_manager: - if self.infowidget is not None: - if self.infowidget.isVisible(): - self.infowidget.hide() - - if self._abort_kernel_restart(): - sw.spyder_kernel_comm.close() - return + if self.infowidget is not None: + if self.infowidget.isVisible(): + self.infowidget.hide() - self._show_loading_page() + # Close comm + sw.spyder_kernel_comm.close() - # Close comm - sw.spyder_kernel_comm.close() + if self._abort_kernel_restart(): + return - # Stop autorestart mechanism - sw.kernel_manager.stop_restarter() - sw.kernel_manager.autorestart = False + # Stop autorestart mechanism + sw.kernel_manager.stop_restarter() + sw.kernel_manager.autorestart = False - # Reconfigure client before the new kernel is connected again. - self._before_prompt_is_ready(show_loading_page=False) + # Reconfigure client before the new kernel is connected again. + self._before_prompt_is_ready() - # Create and run restarting thread - if (self.restart_thread is not None - and self.restart_thread.isRunning()): - self.restart_thread.finished.disconnect() - self.restart_thread.quit() - self.restart_thread.wait() - self.restart_thread = QThread(None) - self.restart_thread.run = self._restart_thread_main - self.restart_thread.error = None - self.restart_thread.finished.connect( - lambda: self._finalise_restart(True)) - self.restart_thread.start() + # Replace std files to avoid catching old kernel errors + self.kernel_handler.replace_std_files() - else: - sw._append_plain_text( - _('Cannot restart a kernel not started by Spyder\n'), - before_prompt=True - ) - self._hide_loading_page() + # Create and run restarting thread + if ( + self.restart_thread is not None + and self.restart_thread.isRunning() + ): + self.restart_thread.finished.disconnect() + self.restart_thread.quit() + self.restart_thread.wait() + self.restart_thread = QThread(None) + self.restart_thread.run = self._restart_thread_main + self.restart_thread.error = None + self.restart_thread.finished.connect( + lambda: self._finalise_restart(True)) + self.restart_thread.start() def _restart_thread_main(self): """Restart the kernel in a thread.""" @@ -772,7 +721,7 @@ def _finalise_restart(self, reset=False): sw = self.shellwidget if self._abort_kernel_restart(): - sw.spyder_kernel_comm.close() + sw.spyder_kernel_comm.remove() return if self.restart_thread and self.restart_thread.error is not None: @@ -781,15 +730,15 @@ def _finalise_restart(self, reset=False): before_prompt=True ) else: - if self.fault_obj is not None: - fault = self.fault_obj.get_contents() - if fault: - fault = self.filter_fault(fault) - self.shellwidget._append_plain_text( - '\n' + fault, before_prompt=True) + fault = self.kernel_handler.get_fault_text() + if fault: + self.shellwidget._append_plain_text( + '\n' + fault, before_prompt=True) # Reset Pdb state and reopen comm - sw._pdb_recursion_level = 0 + sw.reset_kernel_state() + + # Reopen comm sw.spyder_kernel_comm.remove() try: sw.spyder_kernel_comm.open_comm(sw.kernel_client) @@ -802,64 +751,17 @@ def _finalise_restart(self, reset=False): sw.kernel_manager.autorestart = True sw.kernel_manager.start_restarter() - # For spyder-ide/spyder#6235, IPython was changing the - # setting of %colors on windows by assuming it was using a - # dark background. This corrects it based on the scheme. - self.set_color_scheme(sw.syntax_style, reset=reset) + if reset: + sw.reset(clear=True) sw._append_html(_("
Restarting kernel...
"), before_prompt=True) sw.insert_horizontal_ruler() - self.send_kernel_configuration() + sw.send_spyder_kernel_configuration() - self._hide_loading_page() self.restart_thread = None self.sig_execution_state_changed.emit() - def filter_fault(self, fault): - """Get a fault from a previous session.""" - thread_regex = ( - r"(Current thread|Thread) " - r"(0x[\da-f]+) \(most recent call first\):" - r"(?:.|\r\n|\r|\n)+?(?=Current thread|Thread|\Z)") - # Keep line for future improvments - # files_regex = r"File \"([^\"]+)\", line (\d+) in (\S+)" - - main_re = "Main thread id:(?:\r\n|\r|\n)(0x[0-9a-f]+)" - main_id = 0 - for match in re.finditer(main_re, fault): - main_id = int(match.group(1), base=16) - - system_re = ("System threads ids:" - "(?:\r\n|\r|\n)(0x[0-9a-f]+(?: 0x[0-9a-f]+)+)") - ignore_ids = [] - start_idx = 0 - for match in re.finditer(system_re, fault): - ignore_ids = [int(i, base=16) for i in match.group(1).split()] - start_idx = match.span()[1] - text = "" - for idx, match in enumerate(re.finditer(thread_regex, fault)): - if idx == 0: - text += fault[start_idx:match.span()[0]] - thread_id = int(match.group(2), base=16) - if thread_id != main_id: - if thread_id in ignore_ids: - continue - if "wurlitzer.py" in match.group(0): - # Wurlitzer threads are launched later - continue - text += "\n" + match.group(0) + "\n" - else: - try: - pattern = (r".*(?:/IPython/core/interactiveshell\.py|" - r"\\IPython\\core\\interactiveshell\.py).*") - match_internal = next(re.finditer(pattern, match.group(0))) - end_idx = match_internal.span()[0] - except StopIteration: - end_idx = None - text += "\nMain thread:\n" + match.group(0)[:end_idx] + "\n" - return text - @Slot(str) def kernel_restarted_message(self, msg): """Show kernel restarted/died messages.""" diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index 611e375dc81..96e0d2e5608 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -12,19 +12,16 @@ import os import os.path as osp import sys -import traceback -import uuid # Third-party imports from jupyter_client.connect import find_connection_file -from jupyter_core.paths import jupyter_config_dir, jupyter_runtime_dir +from jupyter_core.paths import jupyter_config_dir from qtpy.QtCore import Signal, Slot from qtpy.QtGui import QColor from qtpy.QtWebEngineWidgets import WEBENGINE from qtpy.QtWidgets import ( QApplication, QHBoxLayout, QLabel, QMessageBox, QVBoxLayout, QWidget) from traitlets.config.loader import Config, load_pyconfig_files -from zmq.ssh import tunnel as zmqtunnel # Local imports from spyder.api.config.decorators import on_conf_change @@ -32,16 +29,14 @@ from spyder.api.widgets.main_widget import PluginMainWidget from spyder.api.widgets.menus import MENU_SEPARATOR from spyder.config.base import ( - get_conf_path, get_home_dir, running_under_pytest) -from spyder.plugins.ipythonconsole import SpyderKernelError + get_home_dir, running_under_pytest) +from spyder.plugins.ipythonconsole.utils.kernel_handler import KernelHandler from spyder.plugins.ipythonconsole.utils.kernelspec import SpyderKernelSpec -from spyder.plugins.ipythonconsole.utils.manager import SpyderKernelManager -from spyder.plugins.ipythonconsole.utils.client import SpyderKernelClient -from spyder.plugins.ipythonconsole.utils.ssh import openssh_tunnel from spyder.plugins.ipythonconsole.utils.style import create_qss_style from spyder.plugins.ipythonconsole.widgets import ( ClientWidget, ConsoleRestartDialog, COMPLETION_WIDGET_TYPE, - KernelConnectionDialog, PageControlWidget, ShellWidget) + KernelConnectionDialog, PageControlWidget) +from spyder.plugins.ipythonconsole.widgets.mixins import CachedKernelMixin from spyder.py3compat import PY38_OR_MORE from spyder.utils import encoding, programs, sourcecode from spyder.utils.misc import get_error_match, remove_backslashes @@ -49,7 +44,6 @@ from spyder.widgets.browser import FrameWebView from spyder.widgets.findreplace import FindReplace from spyder.widgets.tabs import Tabs -from spyder.plugins.ipythonconsole.utils.stdfile import StdFile # Localization @@ -111,7 +105,7 @@ class IPythonConsoleWidgetOptionsMenuSections: # --- Widgets # ---------------------------------------------------------------------------- -class IPythonConsoleWidget(PluginMainWidget): +class IPythonConsoleWidget(PluginMainWidget, CachedKernelMixin): """ IPython Console plugin @@ -208,15 +202,6 @@ class IPythonConsoleWidget(PluginMainWidget): The shellwigdet. """ - sig_external_spyder_kernel_connected = Signal(object) - """ - This signal is emitted when we connect to an external Spyder kernel. - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shellwigdet that was connected to the kernel. - """ - sig_render_plain_text_requested = Signal(str) """ This signal is emitted to request a plain text help render. @@ -261,11 +246,6 @@ class IPythonConsoleWidget(PluginMainWidget): The new working directory path. """ - # Error messages - PERMISSION_ERROR_MSG = _("The directory {} is not writable and it is " - "required to create IPython consoles. Please " - "make it writable.") - def __init__(self, name=None, plugin=None, parent=None): super().__init__(name, plugin, parent) @@ -276,7 +256,6 @@ def __init__(self, name=None, plugin=None, parent=None): self.mainwindow_close = False self.active_project_path = None self.create_new_client_if_empty = True - self.css_path = self.get_conf('css_path', section='appearance') self.run_cell_filename = None self.interrupt_action = None self.initial_conf_options = self.get_conf_options() @@ -291,13 +270,6 @@ def __init__(self, name=None, plugin=None, parent=None): # Attrs for testing self._testing = bool(os.environ.get('IPYCONSOLE_TESTING')) - self._test_dir = os.environ.get('IPYCONSOLE_TEST_DIR') - self._test_no_stderr = os.environ.get('IPYCONSOLE_TEST_NO_STDERR') - - # Create temp dir on testing to save kernel errors - if self._test_dir: - if not osp.isdir(osp.join(self._test_dir)): - os.makedirs(osp.join(self._test_dir)) layout = QVBoxLayout() layout.setSpacing(0) @@ -366,9 +338,6 @@ def __init__(self, name=None, plugin=None, parent=None): # See spyder-ide/spyder#11880 self._init_asyncio_patch() - # To cache kernel properties - self._cached_kernel_properties = None - # Initial value for the current working directory self._current_working_directory = get_home_dir() @@ -671,26 +640,6 @@ def change_client_reset_warning(value=value): change_client_reset_warning, value) - @on_conf_change(option='ask_before_restart') - def change_clients_ask_before_restart(self, value): - for idx, client in enumerate(self.clients): - def change_client_ask_before_restart(value=value): - client.ask_before_restart = value - self._change_client_conf( - client, - change_client_ask_before_restart, - value) - - @on_conf_change(option='ask_before_closing') - def change_clients_ask_before_closing(self, value): - for idx, client in enumerate(self.clients): - def change_client_ask_before_closing(value=value): - client.ask_before_closing = value - self._change_client_conf( - client, - change_client_ask_before_closing, - value) - @on_conf_change(option='show_calltips') def change_clients_show_calltips(self, value): for idx, client in enumerate(self.clients): @@ -875,19 +824,17 @@ def change_client_mpl_conf(o=options, c=client): else: self._change_client_mpl_conf(options, client) elif restart and restart_all: - current_ask_before_restart = client.ask_before_restart - client.ask_before_restart = False - client.restart_kernel() - client.ask_before_restart = current_ask_before_restart - - if (((pylab_restart and current_client_backend_require_restart) - or restart_needed) and restart_current and current_client): - current_client_ask_before_restart = ( - current_client.ask_before_restart) - current_client.ask_before_restart = False - current_client.restart_kernel() - current_client.ask_before_restart = ( - current_client_ask_before_restart) + self.restart_kernel(client, ask_before_restart=False) + + if ( + ( + (pylab_restart and current_client_backend_require_restart) + or restart_needed + ) + and restart_current + and current_client + ): + self.restart_kernel(current_client, ask_before_restart=False) # ---- Private methods # ------------------------------------------------------------------------- @@ -1002,35 +949,6 @@ def _init_asyncio_patch(self): asyncio.set_event_loop_policy( WindowsSelectorEventLoopPolicy()) - def _new_connection_file(self): - """ - Generate a new connection file - - Taken from jupyter_client/console_app.py - Licensed under the BSD license - """ - # Check if jupyter_runtime_dir exists (Spyder addition) - if not osp.isdir(jupyter_runtime_dir()): - try: - os.makedirs(jupyter_runtime_dir()) - except (IOError, OSError): - return None - cf = '' - while not cf: - ident = str(uuid.uuid4()).split('-')[-1] - cf = os.path.join(jupyter_runtime_dir(), 'kernel-%s.json' % ident) - cf = cf if not os.path.exists(cf) else '' - return cf - - def _shellwidget_started(self, client): - self.sig_shellwidget_created.emit(client.shellwidget) - - def _shellwidget_deleted(self, client): - try: - self.sig_shellwidget_deleted.emit(client.shellwidget) - except RuntimeError: - pass - @Slot() def _create_client_for_kernel(self): """Create a client connected to an existing kernel""" @@ -1038,9 +956,32 @@ def _create_client_for_kernel(self): (connection_file, hostname, sshkey, password, ok) = connect_output if not ok: return - else: - self.create_client_for_kernel(connection_file, hostname, sshkey, - password) + + try: + # Fix path + connection_file = self.find_connection_file(connection_file) + except (IOError, UnboundLocalError): + QMessageBox.critical(self, _('IPython'), + _("Unable to connect to " + "%s") % connection_file) + return + + self.create_client_for_kernel( + connection_file, hostname, sshkey, password) + + def find_connection_file(self, connection_file): + """Fix connection file path.""" + cf_path = osp.dirname(connection_file) + cf_filename = osp.basename(connection_file) + # To change a possible empty string to None + cf_path = cf_path if cf_path else None + connection_file = find_connection_file(filename=cf_filename, + path=cf_path) + if os.path.splitext(connection_file)[1] != ".json": + # There might be a file with the same id in the path. + connection_file = find_connection_file( + filename=cf_filename + ".json", path=cf_path) + return connection_file # ---- Public API # ------------------------------------------------------------------------- @@ -1062,7 +1003,7 @@ def refresh_container(self, give_focus=False): Refresh interface depending on the current widget client available. Refreshes corner widgets and actions as well as the info widget and - sets the shellwdiget and client signals + sets the shellwidget and client signals """ client = None if self.tabwidget.count(): @@ -1137,6 +1078,9 @@ def add_tab(self, client, name, filename='', give_focus=True): client.get_control().setFocus() self.update_tabs_text() + # Register client + self.register_client(client) + def move_tab(self, index_from, index_to): """ Move tab (tabs themselves have already been moved by the tabwidget). @@ -1329,16 +1273,6 @@ def additional_options(self, is_pylab=False, is_sympy=False): return options # ---- For client widgets - def set_client_elapsed_time(self, client): - """Set elapsed time for slave clients.""" - related_clients = self.get_related_clients(client) - for cl in related_clients: - if cl.timer is not None: - client.t0 = cl.t0 - client.timer.timeout.connect(client.show_time) - client.timer.start(1000) - break - def get_focus_client(self): """Return current client with focus, if any""" widget = QApplication.focusWidget() @@ -1371,203 +1305,119 @@ def create_new_client(self, give_focus=True, filename='', is_cython=False, self.master_clients += 1 client_id = dict(int_id=str(self.master_clients), str_id='A') - std_dir = self._test_dir if self._test_dir else None - cf, km, kc, stderr_obj, stdout_obj = self.get_new_kernel( - is_cython, is_pylab, is_sympy, std_dir=std_dir, cache=cache) - if cf is not None: - fault_obj = StdFile(cf, '.fault', std_dir) - else: - fault_obj = None - - show_elapsed_time = self.get_conf('show_elapsed_time') - reset_warning = self.get_conf('show_reset_namespace_warning') - ask_before_restart = self.get_conf('ask_before_restart') - ask_before_closing = self.get_conf('ask_before_closing') - client = ClientWidget(self, id_=client_id, - history_filename=get_conf_path('history.py'), - config_options=self.config_options(), - additional_options=self.additional_options( - is_pylab=is_pylab, - is_sympy=is_sympy), - interpreter_versions=self.interpreter_versions(), - connection_file=cf, - context_menu_actions=self.context_menu_actions, - show_elapsed_time=show_elapsed_time, - reset_warning=reset_warning, - given_name=given_name, - give_focus=give_focus, - ask_before_restart=ask_before_restart, - ask_before_closing=ask_before_closing, - css_path=self.css_path, - handlers=self.registered_spyder_kernel_handlers, - stderr_obj=stderr_obj, - stdout_obj=stdout_obj, - fault_obj=fault_obj, - initial_cwd=initial_cwd) + client = ClientWidget( + self, + id_=client_id, + config_options=self.config_options(), + additional_options=self.additional_options( + is_pylab=is_pylab, + is_sympy=is_sympy), + interpreter_versions=self.interpreter_versions(), + context_menu_actions=self.context_menu_actions, + given_name=given_name, + give_focus=give_focus, + handlers=self.registered_spyder_kernel_handlers, + initial_cwd=initial_cwd, + ) + # Add client to widget self.add_tab( client, name=client.get_name(), filename=filename, give_focus=give_focus) - if cf is None: - error_msg = self.PERMISSION_ERROR_MSG.format(jupyter_runtime_dir()) - client.show_kernel_error(error_msg) - return + # Create new kernel + kernel_spec = SpyderKernelSpec( + is_cython=is_cython, + is_pylab=is_pylab, + is_sympy=is_sympy + ) - self.connect_client_to_kernel(client, km, kc) - if client.shellwidget.kernel_manager is None: + try: + kernel_handler = self.get_cached_kernel(kernel_spec, cache=cache) + except Exception as e: + client.show_kernel_error(e) return - self.register_client(client, give_focus=give_focus) + + # Connect kernel to client + client.connect_kernel(kernel_handler) return client def create_client_for_kernel(self, connection_file, hostname, sshkey, password): """Create a client connected to an existing kernel.""" - # Verifying if the connection file exists - try: - cf_path = osp.dirname(connection_file) - cf_filename = osp.basename(connection_file) - # To change a possible empty string to None - cf_path = cf_path if cf_path else None - connection_file = find_connection_file(filename=cf_filename, - path=cf_path) - if os.path.splitext(connection_file)[1] != ".json": - # There might be a file with the same id in the path. - connection_file = find_connection_file( - filename=cf_filename + ".json", path=cf_path) - except (IOError, UnboundLocalError): - QMessageBox.critical(self, _('IPython'), - _("Unable to connect to " - "%s") % connection_file) - return - - # Getting the master id that corresponds to the client - # (i.e. the i in i/A) - master_id = None given_name = None - is_external_kernel = True - known_spyder_kernel = False - slave_ord = ord('A') - 1 - kernel_manager = None - stderr_obj = None - stdout_obj = None - fault_obj = None + master_client = None + related_clients = [] for cl in self.clients: if connection_file in cl.connection_file: - if cl.get_kernel() is not None: - kernel_manager = cl.get_kernel() - connection_file = cl.connection_file - if master_id is None: - master_id = cl.id_['int_id'] - is_external_kernel = cl.shellwidget.is_external_kernel - known_spyder_kernel = cl.shellwidget.is_spyder_kernel - if cl.stderr_obj: - stderr_obj = cl.stderr_obj.copy() - if cl.stdout_obj: - stdout_obj = cl.stdout_obj.copy() - if cl.fault_obj: - fault_obj = cl.fault_obj.copy() - given_name = cl.given_name + if ( + cl.kernel_handler is not None and + hostname == cl.kernel_handler.hostname and + sshkey == cl.kernel_handler.sshkey and + password == cl.kernel_handler.password + ): + related_clients.append(cl) + + if len(related_clients) > 0: + # Get master client + master_client = related_clients[0] + given_name = master_client.given_name + slave_ord = ord('A') - 1 + for cl in related_clients: new_slave_ord = ord(cl.id_['str_id']) if new_slave_ord > slave_ord: slave_ord = new_slave_ord - # If we couldn't find a client with the same connection file, - # it means this is a new master client - if master_id is None: + # Set full client name + client_id = dict(int_id=master_client.id_['int_id'], + str_id=chr(slave_ord + 1)) + else: + # If we couldn't find a client with the same connection file, + # it means this is a new master client self.master_clients += 1 - master_id = str(self.master_clients) - # Set full client name - client_id = dict(int_id=master_id, - str_id=chr(slave_ord + 1)) + # Set full client name + client_id = dict(int_id=str(self.master_clients), str_id='A') # Creating the client - show_elapsed_time = self.get_conf('show_elapsed_time') - reset_warning = self.get_conf('show_reset_namespace_warning') - ask_before_restart = self.get_conf('ask_before_restart') - client = ClientWidget(self, - id_=client_id, - given_name=given_name, - history_filename=get_conf_path('history.py'), - config_options=self.config_options(), - additional_options=self.additional_options(), - interpreter_versions=self.interpreter_versions(), - connection_file=connection_file, - context_menu_actions=self.context_menu_actions, - hostname=hostname, - is_external_kernel=is_external_kernel, - is_spyder_kernel=known_spyder_kernel, - show_elapsed_time=show_elapsed_time, - reset_warning=reset_warning, - ask_before_restart=ask_before_restart, - css_path=self.css_path, - handlers=self.registered_spyder_kernel_handlers, - stderr_obj=stderr_obj, - stdout_obj=stdout_obj, - fault_obj=fault_obj) - - # Create kernel client - kernel_client = SpyderKernelClient(connection_file=connection_file) - - # This is needed for issue spyder-ide/spyder#9304. - try: - kernel_client.load_connection_file() - except Exception as e: - QMessageBox.critical(self, _('Connection error'), - _("An error occurred while trying to load " - "the kernel connection file. The error " - "was:\n\n") + str(e)) - return + client = ClientWidget( + self, + id_=client_id, + given_name=given_name, + config_options=self.config_options(), + additional_options=self.additional_options(), + interpreter_versions=self.interpreter_versions(), + context_menu_actions=self.context_menu_actions, + handlers=self.registered_spyder_kernel_handlers + ) - if hostname is not None: - try: - connection_info = dict( - ip=kernel_client.ip, - shell_port=kernel_client.shell_port, - iopub_port=kernel_client.iopub_port, - stdin_port=kernel_client.stdin_port, - hb_port=kernel_client.hb_port, - control_port=kernel_client.control_port) - newports = self.tunnel_to_kernel(connection_info, hostname, - sshkey, password) - (kernel_client.shell_port, - kernel_client.iopub_port, - kernel_client.stdin_port, - kernel_client.hb_port, - kernel_client.control_port) = newports - except Exception as e: - QMessageBox.critical(self, _('Connection error'), - _("Could not open ssh tunnel. The " - "error was:\n\n") + str(e)) - return + # add hostname for get_name + client.hostname = hostname - # Assign kernel manager and client to shellwidget - kernel_client.start_channels() - shellwidget = client.shellwidget - - if not known_spyder_kernel: - shellwidget.sig_is_spykernel.connect( - self.connect_external_spyder_kernel) - shellwidget.set_kernel_client_and_manager( - kernel_client, kernel_manager) - shellwidget.sig_exception_occurred.connect( - self.sig_exception_occurred) - self.sig_shellwidget_created.emit(shellwidget) - kernel_client.stopped_channels.connect( - lambda: self.sig_shellwidget_deleted.emit(shellwidget)) + # Adding a new tab for the client + self.add_tab(client, name=client.get_name()) # Set elapsed time, if possible - if not is_external_kernel: - self.set_client_elapsed_time(client) + if master_client is not None: + client.t0 = master_client.t0 + client.timer.timeout.connect(client.show_time) + client.timer.start(1000) - # Adding a new tab for the client - self.add_tab(client, name=client.get_name()) + try: + # Get new client for kernel + if master_client is not None: + kernel_handler = master_client.kernel_handler.copy() + else: + kernel_handler = KernelHandler.from_connection_file( + connection_file, hostname, sshkey, password) + except Exception as e: + client.show_kernel_error(e) + return - # Register client - self.register_client(client) + # Connect kernel + client.connect_kernel(kernel_handler) def create_pylab_client(self): """Force creation of Pylab client""" @@ -1581,134 +1431,6 @@ def create_cython_client(self): """Force creation of Cython client""" self.create_new_client(is_cython=True, given_name="Cython") - def get_new_kernel(self, is_cython=False, is_pylab=False, - is_sympy=False, std_dir=None, cache=True): - """Get a new kernel, and cache one for next time.""" - # Cache another kernel for next time. - kernel_spec = self.create_kernel_spec( - is_cython=is_cython, - is_pylab=is_pylab, - is_sympy=is_sympy - ) - - new_kernel = self.create_new_kernel(kernel_spec, std_dir) - - if new_kernel[2] is None or not cache: - # error or remove/don't use cache if requested - self.close_cached_kernel() - return new_kernel - - # Check cached kernel has the same configuration as is being asked - cached_kernel = None - if self._cached_kernel_properties is not None: - (cached_spec, - cached_env, - cached_argv, - cached_dir, - cached_kernel) = self._cached_kernel_properties - # Call interrupt_mode so the dict will be the same - kernel_spec.interrupt_mode - cached_spec.interrupt_mode - valid = (std_dir == cached_dir - and cached_spec.__dict__ == kernel_spec.__dict__ - and kernel_spec.argv == cached_argv - and kernel_spec.env == cached_env) - if not valid: - # Close the kernel - self.close_cached_kernel() - cached_kernel = None - - # Cache the new kernel - self._cached_kernel_properties = ( - kernel_spec, - kernel_spec.env, - kernel_spec.argv, - std_dir, - new_kernel) - - if cached_kernel is None: - return self.create_new_kernel(kernel_spec, std_dir) - - return cached_kernel - - def close_cached_kernel(self): - """Close the cached kernel.""" - if self._cached_kernel_properties is None: - return - cached_kernel = self._cached_kernel_properties[-1] - _, kernel_manager, _, stderr_obj, stdout_obj = cached_kernel - kernel_manager.stop_restarter() - kernel_manager.shutdown_kernel(now=True) - self._cached_kernel_properties = None - if stderr_obj: - stderr_obj.remove() - if stdout_obj: - stdout_obj.remove() - - def create_new_kernel(self, kernel_spec, std_dir=None): - """Create a new kernel.""" - connection_file = self._new_connection_file() - if connection_file is None: - return None, None, None, None, None - - stderr_obj = None - stderr_handle = None - stdout_obj = None - stdout_handle = None - if not self._test_no_stderr: - stderr_obj = StdFile(connection_file, '.stderr', std_dir) - stderr_handle = stderr_obj.handle - stdout_obj = StdFile(connection_file, '.stdout', std_dir) - stdout_handle = stdout_obj.handle - - km, kc = self.create_kernel_manager_and_kernel_client( - connection_file, - stderr_handle, - stdout_handle, - kernel_spec, - ) - return connection_file, km, kc, stderr_obj, stdout_obj - - def connect_client_to_kernel(self, client, km, kc): - """Connect a client to its kernel.""" - # An error occurred if this is True - if isinstance(km, str) and kc is None: - client.shellwidget.kernel_manager = None - client.show_kernel_error(km) - return - - # This avoids a recurrent, spurious NameError when running our - # tests in our CIs - if not self._testing: - kc.stopped_channels.connect( - lambda c=client: self._shellwidget_deleted(c)) - - kc.start_channels(shell=True, iopub=True) - - shellwidget = client.shellwidget - shellwidget.set_kernel_client_and_manager(kc, km) - - shellwidget.sig_exception_occurred.connect( - self.sig_exception_occurred) - - # _shellwidget_started() – which emits sig_shellwidget_created() – must - # be called *after* set_kernel_client_and_manager() has been called. - # This is required so that plugins can rely on a initialized shell - # widget in their implementation of - # ShellConnectMainWidget.create_new_widget() (e.g. if they need to - # communicate with the kernel using the shell widget kernel client). - # - # NOTE kc.started_channels() signal must not be used to emit - # sig_shellwidget_created(): kc.start_channels() - # (QtKernelClientMixin.start_channels() from qtconsole module) emits the - # started_channels() signal. Slots connected to this signal are called - # directly from within start_channels() [1], i.e. before the shell - # widget’s initialization by set_kernel_client_and_manager(). - # - # [1] Assuming no threads are involved, i.e. the signal-slot connection - # type is Qt.DirectConnection. - self._shellwidget_started(client) - @Slot(str) def create_client_from_path(self, path): """Create a client with its cwd pointing to path.""" @@ -1740,9 +1462,9 @@ def get_client_for_file(self, filename): break return client - def register_client(self, client, give_focus=True): + def register_client(self, client): """Register new client""" - client.configure_shellwidget(give_focus=give_focus) + client.connect_shellwidget_signals() # Local vars shellwidget = client.shellwidget @@ -1781,6 +1503,16 @@ def register_client(self, client, give_focus=True): # Show time label client.sig_time_label.connect(self.time_label.setText) + # Exception handling + shellwidget.sig_exception_occurred.connect( + self.sig_exception_occurred) + + # Closing Shellwidget + shellwidget.sig_shellwidget_deleted.connect( + self.sig_shellwidget_deleted) + shellwidget.sig_shellwidget_created.connect( + self.sig_shellwidget_created) + def close_client(self, index=None, client=None, ask_recursive=True): """Close client tab from index or widget (or close current tab)""" if not self.tabwidget.count(): @@ -1803,7 +1535,7 @@ def close_client(self, index=None, client=None, ask_recursive=True): # and eventually ask before closing them if not self.mainwindow_close and ask_recursive: close_all = True - if client.ask_before_closing: + if self.get_conf('ask_before_closing'): close = QMessageBox.question( self, self._plugin.get_name(), @@ -1861,8 +1593,10 @@ def close_all_clients(self): client.close_client(is_last_client) open_clients.remove(client) - # Close all closing shellwidgets. - ShellWidget.wait_all_shutdown() + # Wait for all KernelHandler's to shutdown. + for client in self.clients: + if client.kernel_handler: + client.kernel_handler.wait_shutdown_thread() # Close cached kernel self.close_cached_kernel() @@ -1978,84 +1712,31 @@ def unregister_spyder_kernel_call_handler(self, handler_id): """ self.registered_spyder_kernel_handlers.pop(handler_id, None) - def ssh_tunnel(self, *args, **kwargs): - if os.name == 'nt': - return zmqtunnel.paramiko_tunnel(*args, **kwargs) - else: - return openssh_tunnel(self, *args, **kwargs) - - def tunnel_to_kernel(self, connection_info, hostname, sshkey=None, - password=None, timeout=10): - """ - Tunnel connections to a kernel via ssh. - - Remote ports are specified in the connection info ci. - """ - lports = zmqtunnel.select_random_ports(5) - rports = (connection_info['shell_port'], connection_info['iopub_port'], - connection_info['stdin_port'], connection_info['hb_port'], - connection_info['control_port']) - remote_ip = connection_info['ip'] - for lp, rp in zip(lports, rports): - self.ssh_tunnel(lp, rp, hostname, remote_ip, sshkey, password, - timeout) - return tuple(lports) - - def create_kernel_spec(self, is_cython=False, - is_pylab=False, is_sympy=False): - """Create a kernel spec for our own kernels""" - return SpyderKernelSpec(is_cython=is_cython, - is_pylab=is_pylab, - is_sympy=is_sympy) - - def create_kernel_manager_and_kernel_client(self, connection_file, - stderr_handle, - stdout_handle, - kernel_spec): - """Create kernel manager and client.""" - # Kernel manager - try: - kernel_manager = SpyderKernelManager( - connection_file=connection_file, - config=None, - autorestart=True, - ) - except SpyderKernelError as e: - return (e.args[0], None) - except Exception: - error_msg = _("The error is:

" - "{}").format(traceback.format_exc()) - return (error_msg, None) - kernel_manager._kernel_spec = kernel_spec + @Slot() + def restart_kernel(self, client=None, ask_before_restart=True): + """Restart kernel of current client.""" + if client is None: + client = self.get_current_client() + if client is None: + return - # Catch any error generated when trying to start the kernel. - # See spyder-ide/spyder#7302. - try: - kernel_manager.start_kernel(stderr=stderr_handle, - stdout=stdout_handle, - env=kernel_spec.env) - except SpyderKernelError as e: - return (e.args[0], None) - except Exception: - error_msg = _("The error is:

" - "{}").format(traceback.format_exc()) - return (error_msg, None) + self.sig_switch_to_plugin_requested.emit() - # Kernel client - kernel_client = kernel_manager.client() + ask_before_restart = ( + ask_before_restart and self.get_conf('ask_before_restart')) - # Increase time (in seconds) to detect if a kernel is alive. - # See spyder-ide/spyder#3444. - kernel_client.hb_channel.time_to_dead = 25.0 + do_restart = True + if ask_before_restart and not running_under_pytest(): + message = _('Are you sure you want to restart the kernel?') + buttons = QMessageBox.Yes | QMessageBox.No + result = QMessageBox.question( + self, _('Restart kernel?'), message, buttons) + do_restart = result == QMessageBox.Yes - return kernel_manager, kernel_client + if not do_restart: + return - def restart_kernel(self): - """Restart kernel of current client.""" - client = self.get_current_client() - if client is not None: - self.sig_switch_to_plugin_requested.emit() - client.restart_kernel() + client.restart_kernel() def reset_namespace(self): """Reset namespace of current client.""" @@ -2071,16 +1752,6 @@ def interrupt_kernel(self): self.sig_switch_to_plugin_requested.emit() client.stop_button_click_handler() - def connect_external_spyder_kernel(self, shellwidget): - """Connect to an external Spyder kernel.""" - shellwidget.is_spyder_kernel = True - shellwidget.spyder_kernel_comm.open_comm(shellwidget.kernel_client) - shellwidget.ipyclient.send_kernel_configuration() - self.sig_shellwidget_changed.emit(shellwidget) - self.sig_external_spyder_kernel_connected.emit(shellwidget) - - # ---- For running and debugging - # ------------------------------------------------------------------------- # ---- For cells def run_cell(self, code, cell_name, filename, run_cell_copy, method='runcell', focus_to_editor=False): diff --git a/spyder/plugins/ipythonconsole/widgets/mixins.py b/spyder/plugins/ipythonconsole/widgets/mixins.py new file mode 100644 index 00000000000..0eb1e6f0c30 --- /dev/null +++ b/spyder/plugins/ipythonconsole/widgets/mixins.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +IPython Console mixins. +""" + +# Local imports +from spyder.plugins.ipythonconsole.utils.kernel_handler import KernelHandler + + +class CachedKernelMixin: + """Cached kernel mixin.""" + + def __init__(self): + super().__init__() + self._cached_kernel_properties = None + + def close_cached_kernel(self): + """Close the cached kernel.""" + if self._cached_kernel_properties is None: + return + kernel = self._cached_kernel_properties[-1] + kernel.close(now=True) + kernel.remove_files() + self._cached_kernel_properties = None + + def check_cached_kernel_spec(self, kernel_spec): + """Test if kernel_spec corresponds to the cached kernel_spec.""" + if self._cached_kernel_properties is None: + return False + ( + cached_spec, + cached_env, + cached_argv, + _, + ) = self._cached_kernel_properties + + # Call interrupt_mode so the dict will be the same + kernel_spec.interrupt_mode + cached_spec.interrupt_mode + + if "PYTEST_CURRENT_TEST" in cached_env: + # Make tests faster by using cached kernels + # hopefully the kernel will never use PYTEST_CURRENT_TEST + cached_env["PYTEST_CURRENT_TEST"] = ( + kernel_spec.env["PYTEST_CURRENT_TEST"]) + return ( + cached_spec.__dict__ == kernel_spec.__dict__ + and kernel_spec.argv == cached_argv + and kernel_spec.env == cached_env + ) + + def get_cached_kernel(self, kernel_spec, cache=True): + """Get a new kernel, and cache one for next time.""" + # Cache another kernel for next time. + new_kernel_handler = KernelHandler.new_from_spec(kernel_spec) + + if not cache: + # remove/don't use cache if requested + self.close_cached_kernel() + return new_kernel_handler + + # Check cached kernel has the same configuration as is being asked + cached_kernel_handler = None + if self._cached_kernel_properties is not None: + cached_kernel_handler = self._cached_kernel_properties[-1] + if not self.check_cached_kernel_spec(kernel_spec): + # Close the kernel + self.close_cached_kernel() + cached_kernel_handler = None + + # Cache the new kernel + self._cached_kernel_properties = ( + kernel_spec, + kernel_spec.env, + kernel_spec.argv, + new_kernel_handler, + ) + + if cached_kernel_handler is None: + return KernelHandler.new_from_spec(kernel_spec) + + return cached_kernel_handler diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 8738039bab3..6ff112c9cbe 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -14,10 +14,9 @@ import os.path as osp import time from textwrap import dedent -from threading import Lock # Third party imports -from qtpy.QtCore import Signal, QThread +from qtpy.QtCore import Signal from qtpy.QtWidgets import QMessageBox from qtpy import QtCore, QtWidgets, QtGui from traitlets import observe @@ -140,7 +139,6 @@ class ShellWidget(NamepaceBrowserWidget, HelpWidget, DebuggingWidget, # For ShellWidget sig_focus_changed = Signal() new_client = Signal() - sig_is_spykernel = Signal(object) sig_kernel_restarted_message = Signal(str) # Kernel died and restarted (not user requested) @@ -154,46 +152,18 @@ class ShellWidget(NamepaceBrowserWidget, HelpWidget, DebuggingWidget, # For printing internal errors sig_exception_occurred = Signal(dict) - # Class array of shutdown threads - shutdown_thread_list = [] - # To save values and messages returned by the kernel _kernel_is_starting = True - # Kernel started or restarted - sig_kernel_started = Signal() - sig_kernel_reset = Signal() - # Request plugins to send additional configuration to the kernel sig_config_kernel_requested = Signal() - @classmethod - def prune_shutdown_thread_list(cls): - """Remove shutdown threads.""" - pruned_shutdown_thread_list = [] - for t in cls.shutdown_thread_list: - try: - if t.isRunning(): - pruned_shutdown_thread_list.append(t) - except RuntimeError: - pass - cls.shutdown_thread_list = pruned_shutdown_thread_list - - @classmethod - def wait_all_shutdown(cls): - """Wait for shutdown to finish.""" - for thread in cls.shutdown_thread_list: - if thread.isRunning(): - try: - thread.kernel_manager._kill_kernel() - except Exception: - pass - thread.quit() - thread.wait() - cls.shutdown_thread_list = [] + # To notify of kernel connection / disconnection + sig_shellwidget_created = Signal(object) + sig_shellwidget_deleted = Signal(object) def __init__(self, ipyclient, additional_options, interpreter_versions, - is_external_kernel, is_spyder_kernel, handlers, *args, **kw): + handlers, *args, **kw): # To override the Qt widget used by RichJupyterWidget self.custom_control = ControlWidget self.custom_page_control = PageControlWidget @@ -205,8 +175,7 @@ def __init__(self, ipyclient, additional_options, interpreter_versions, self.ipyclient = ipyclient self.additional_options = additional_options self.interpreter_versions = interpreter_versions - self.is_external_kernel = is_external_kernel - self.is_spyder_kernel = is_spyder_kernel + self.kernel_handler = None self._cwd = '' # Keyboard shortcuts @@ -237,51 +206,52 @@ def __init__(self, ipyclient, additional_options, interpreter_versions, # Show a message in our installers to explain users how to use # modules that don't come with them. self.show_modules_message = is_pynsist() or running_in_mac_app() - self.shutdown_lock = Lock() # ---- Public API --------------------------------------------------------- - def shutdown_kernel(self): - """Shutdown kernel.""" - with self.shutdown_lock: - # Avoid calling shutdown_kernel on the same manager twice - # from different threads to avoid crash. - if self.kernel_manager.shutting_down: - return - self.kernel_manager.shutting_down = True - try: - self.kernel_manager.shutdown_kernel() - except Exception: - # kernel was externally killed - pass + @property + def is_spyder_kernel(self): + if self.kernel_handler is None: + return False + return self.kernel_handler.known_spyder_kernel + + def connect_kernel(self, kernel_handler): + """Connect to the kernel using our handler.""" + # Kernel client + kernel_client = kernel_handler.kernel_client + kernel_client.stopped_channels.connect(self.notify_deleted) + kernel_client.start_channels() + self.kernel_client = kernel_client + + self.kernel_manager = kernel_handler.kernel_manager + self.kernel_handler = kernel_handler + + # Send message to kernel to check status + self.check_spyder_kernel() + self.sig_shellwidget_created.emit(self) + + def notify_deleted(self): + """Notify that the shellwidget was deleted.""" + self.sig_shellwidget_deleted.emit(self) def shutdown(self, shutdown_kernel=True): """Shutdown connection and kernel.""" if self.shutting_down: return self.shutting_down = True - if shutdown_kernel: - if not self.kernel_manager: - return + self.close_kernel(shutdown_kernel) + super().shutdown() - self.interrupt_kernel() - if self.kernel_manager: - self.kernel_manager.stop_restarter() - self.spyder_kernel_comm.close() - if self.kernel_client is not None: - self.kernel_client.stop_channels() - if self.kernel_manager: - shutdown_thread = QThread(None) - shutdown_thread.kernel_manager = self.kernel_manager - shutdown_thread.run = self.shutdown_kernel - self.shutdown_thread_list.append(shutdown_thread) - shutdown_thread.start() - else: - self.spyder_kernel_comm.close() - if self.kernel_client is not None: - self.kernel_client.stop_channels() + def close_kernel(self, shutdown_kernel=True): + """Close the kernel""" + self.kernel_handler.close(shutdown_kernel) + + # Reset state + self.reset_kernel_state() - self.prune_shutdown_thread_list() - super(ShellWidget, self).shutdown() + def reset_kernel_state(self): + """Reset the kernel state.""" + self._prompt_requested = False + self._pdb_recursion_level = 0 def call_kernel(self, interrupt=False, blocking=False, callback=None, timeout=None, display_error=False): @@ -315,29 +285,45 @@ def call_kernel(self, interrupt=False, blocking=False, callback=None, display_error=display_error ) - def set_kernel_client_and_manager(self, kernel_client, kernel_manager): - """Set the kernel client and manager""" - self.kernel_manager = kernel_manager - self.kernel_client = kernel_client + @property + def is_external_kernel(self): + """Check if this is an external kernel.""" + return self.kernel_manager is None - # Send message to kernel to check status - self.check_spyder_kernel() + def setup_spyder_kernel(self): + """Setup spyder kernel""" + self.kernel_handler.open_comm(self.spyder_kernel_comm) - if self.is_spyder_kernel: - # For completion - kernel_client.control_channel.message_received.connect( - self._dispatch) - self.spyder_kernel_comm.open_comm(kernel_client) + # For completions + self.kernel_client.control_channel.message_received.connect( + self._dispatch) # Redefine the complete method to work while debugging. self._redefine_complete_for_dbg(self.kernel_client) + # Send configuration + self.send_spyder_kernel_configuration() + def pop_execute_queue(self): """Pop one waiting instruction.""" if self._execute_queue: self.execute(*self._execute_queue.pop(0)) - # ---- Public API --------------------------------------------------------- + def send_spyder_kernel_configuration(self): + """Send kernel configuration to spyder kernel.""" + # To apply style + self.set_color_scheme(self.syntax_style, reset=False) + + ffn = self.kernel_handler.get_fault_filename() + + # Enable faulthandler + if ffn: + # To display faulthandler + self.call_kernel().enable_faulthandler(ffn) + + # Give a chance to plugins to configure the kernel + self.sig_config_kernel_requested.emit() + def interrupt_kernel(self): """Attempts to interrupt the running kernel.""" # Empty queue when interrupting @@ -365,10 +351,6 @@ def execute(self, source=None, hidden=False, interactive=False): return super(ShellWidget, self).execute(source, hidden, interactive) - def set_exit_callback(self): - """Set exit callback for this shell.""" - self.exit_requested.connect(self.ipyclient.exit_callback) - def is_running(self): if self.kernel_client is not None and \ self.kernel_client.channels_running: @@ -396,29 +378,25 @@ def check_spyder_kernel_callback(self, reply): if data is not None and 'text/plain' in data: spyder_kernel_info = ast.literal_eval(data['text/plain']) if not spyder_kernel_info: - # The running_under_pytest() part can be removed when - # spyder-kernels 3 is released. This is needed for - # the test_conda_env_activation test - if running_under_pytest(): - return - if self.is_spyder_kernel: # spyder-kernels version < 3.0 - self.ipyclient.show_kernel_error( + self.append_html_message( ERROR_SPYDER_KERNEL_VERSION_OLD.format( SPYDER_KERNELS_MIN_VERSION, SPYDER_KERNELS_MAX_VERSION, SPYDER_KERNELS_CONDA, SPYDER_KERNELS_PIP - ) + ), + before_prompt=True ) + self.kernel_handler.known_spyder_kernel = False return version, pyexec = spyder_kernel_info if not check_version_range(version, SPYDER_KERNELS_VERSION): + # Development versions are acceptable if "dev0" not in version: - # Development versions are acceptable - self.ipyclient.show_kernel_error( + self.append_html_message( ERROR_SPYDER_KERNEL_VERSION.format( pyexec, version, @@ -426,13 +404,14 @@ def check_spyder_kernel_callback(self, reply): SPYDER_KERNELS_MAX_VERSION, SPYDER_KERNELS_CONDA, SPYDER_KERNELS_PIP - ) + ), + before_prompt=True ) + self.kernel_handler.known_spyder_kernel = False return - if not self.is_spyder_kernel: - self.is_spyder_kernel = True - self.sig_is_spykernel.emit(self) + self.kernel_handler.known_spyder_kernel = True + self.setup_spyder_kernel() def set_cwd(self, dirname, emit_cwd_change=False): """ @@ -753,10 +732,9 @@ def _perform_reset(self, message): if kernel_env.get('SPY_RUN_CYTHON') == 'True': self.silent_execute("%reload_ext Cython") - self.sig_kernel_reset.emit() - if self.is_spyder_kernel: self.call_kernel().close_all_mpl_figures() + self.send_spyder_kernel_configuration() except AttributeError: pass @@ -1027,7 +1005,6 @@ def _handle_execute_reply(self, msg): # Notify that kernel has started exec_count = msg['content'].get('execution_count', '') if exec_count == 0 and self._kernel_is_starting: - self.sig_kernel_started.emit() self.ipyclient.t0 = time.monotonic() self._kernel_is_starting = False @@ -1058,7 +1035,6 @@ def _handle_status(self, msg): elif state == 'idle' and msg_type == 'shutdown_request': # This handles restarts asked by the user self.ipyclient.t0 = time.monotonic() - self.sig_kernel_started.emit() else: super()._handle_status(msg) diff --git a/spyder/plugins/variableexplorer/plugin.py b/spyder/plugins/variableexplorer/plugin.py index 31ec86c63e2..71c55af92ff 100644 --- a/spyder/plugins/variableexplorer/plugin.py +++ b/spyder/plugins/variableexplorer/plugin.py @@ -62,13 +62,3 @@ def on_preferences_available(self): def on_preferences_teardown(self): preferences = self.get_plugin(Plugins.Preferences) preferences.deregister_plugin_preferences(self) - - # ---- Public API - # ------------------------------------------------------------------------ - def on_connection_to_external_spyder_kernel(self, shellwidget): - """Send namespace view settings to the kernel.""" - widget = self.get_widget_for_shellwidget(shellwidget) - if widget is None: - return - widget.set_namespace_view_settings() - widget.refresh_namespacebrowser() diff --git a/spyder/plugins/variableexplorer/widgets/main_widget.py b/spyder/plugins/variableexplorer/widgets/main_widget.py index a7407afeba2..4b63418f26d 100644 --- a/spyder/plugins/variableexplorer/widgets/main_widget.py +++ b/spyder/plugins/variableexplorer/widgets/main_widget.py @@ -412,44 +412,29 @@ def switch_widget(self, nsb, old_nsb): def create_new_widget(self, shellwidget): """Create new NamespaceBrowser.""" nsb = NamespaceBrowser(self) - nsb.sig_hide_finder_requested.connect( - self.hide_finder) - nsb.sig_free_memory_requested.connect( - self.free_memory) - nsb.sig_start_spinner_requested.connect( - self.start_spinner) - nsb.sig_stop_spinner_requested.connect( - self.stop_spinner) + nsb.sig_hide_finder_requested.connect(self.hide_finder) + nsb.sig_free_memory_requested.connect(self.free_memory) + nsb.sig_start_spinner_requested.connect(self.start_spinner) + nsb.sig_stop_spinner_requested.connect(self.stop_spinner) nsb.set_shellwidget(shellwidget) nsb.setup() self._set_actions_and_menus(nsb) # To update the Variable Explorer after execution - shellwidget.executed.connect( - nsb.refresh_namespacebrowser) - shellwidget.sig_kernel_started.connect( - nsb.on_kernel_started) - shellwidget.sig_kernel_reset.connect( - nsb.on_kernel_started) - + shellwidget.executed.connect(nsb.refresh_namespacebrowser) + shellwidget.sig_config_kernel_requested.connect(nsb.setup_kernel) return nsb def close_widget(self, nsb): """Close NamespaceBrowser.""" - nsb.sig_hide_finder_requested.disconnect( - self.hide_finder) - nsb.sig_free_memory_requested.disconnect( - self.free_memory) - nsb.sig_start_spinner_requested.disconnect( - self.start_spinner) - nsb.sig_stop_spinner_requested.disconnect( - self.stop_spinner) - nsb.shellwidget.executed.disconnect( - nsb.refresh_namespacebrowser) - nsb.shellwidget.sig_kernel_started.disconnect( - nsb.on_kernel_started) - nsb.shellwidget.sig_kernel_reset.disconnect( - nsb.on_kernel_started) + nsb.sig_hide_finder_requested.disconnect(self.hide_finder) + nsb.sig_free_memory_requested.disconnect(self.free_memory) + nsb.sig_start_spinner_requested.disconnect(self.start_spinner) + nsb.sig_stop_spinner_requested.disconnect(self.stop_spinner) + nsb.shellwidget.executed.disconnect(nsb.refresh_namespacebrowser) + nsb.shellwidget.sig_config_kernel_requested.disconnect( + nsb.setup_kernel) + nsb.close() nsb.setParent(None) diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py index 19437185668..274338de046 100644 --- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py +++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py @@ -161,7 +161,7 @@ def refresh_table(self): except TypeError: pass - def refresh_namespacebrowser(self, interrupt=True): + def refresh_namespacebrowser(self, *, interrupt=True): """Refresh namespace browser""" self.shellwidget.call_kernel( interrupt=interrupt, @@ -173,15 +173,15 @@ def refresh_namespacebrowser(self, interrupt=True): callback=self.set_var_properties ).get_var_properties() - def set_namespace_view_settings(self): + def set_namespace_view_settings(self, interrupt=True): """Set the namespace view settings""" settings = self.get_view_settings() self.shellwidget.call_kernel( - interrupt=True + interrupt=interrupt ).set_namespace_view_settings(settings) - def on_kernel_started(self): - self.set_namespace_view_settings() + def setup_kernel(self): + self.set_namespace_view_settings(interrupt=False) self.refresh_namespacebrowser(interrupt=False) def process_remote_view(self, remote_view):