diff --git a/requirements/windows.yml b/requirements/windows.yml index 53b91c72333..2d59be8f3f9 100644 --- a/requirements/windows.yml +++ b/requirements/windows.yml @@ -2,4 +2,8 @@ channels: - conda-forge dependencies: - - paramiko >=2.4.0 + # This is a dummy dependency for Windows because it's also declared in + # main.yml. But we have it here in case we need to add Windows-only deps + # in the future (without this we'd have to remove code from CIs and tests + # and restore it again if we were to have Windows-only deps again). + - aiohttp >=3.9.3 diff --git a/setup.py b/setup.py index abebe664d20..c8f48b677f9 100644 --- a/setup.py +++ b/setup.py @@ -220,8 +220,6 @@ def run(self): 'keyring>=17.0.0', 'nbconvert>=4.0', 'numpydoc>=0.6.0', - # Required to get SSH connections to remote kernels - 'paramiko>=2.4.0;platform_system=="Windows"', 'parso>=0.7.0,<0.9.0', 'pexpect>=4.4.0', 'pickleshare>=0.4', diff --git a/spyder/dependencies.py b/spyder/dependencies.py index 96da1dfb8f8..f148ecdac94 100644 --- a/spyder/dependencies.py +++ b/spyder/dependencies.py @@ -50,7 +50,6 @@ KEYRING_REQVER = '>=17.0.0' NBCONVERT_REQVER = '>=4.0' NUMPYDOC_REQVER = '>=0.6.0' -PARAMIKO_REQVER = '>=2.4.0' PARSO_REQVER = '>=0.7.0,<0.9.0' PEXPECT_REQVER = '>=4.4.0' PICKLESHARE_REQVER = '>=0.4' @@ -163,11 +162,6 @@ 'package_name': "numpydoc", 'features': _("Improve code completion for objects that use Numpy docstrings"), 'required_version': NUMPYDOC_REQVER}, - {'modname': "paramiko", - 'package_name': "paramiko", - 'features': _("Connect to remote kernels through SSH"), - 'required_version': PARAMIKO_REQVER, - 'display': os.name == 'nt'}, {'modname': "parso", 'package_name': "parso", 'features': _("Python parser that supports error recovery and " diff --git a/spyder/plugins/ipythonconsole/utils/client.py b/spyder/plugins/ipythonconsole/utils/client.py index 74674040bd5..f59b998252d 100644 --- a/spyder/plugins/ipythonconsole/utils/client.py +++ b/spyder/plugins/ipythonconsole/utils/client.py @@ -8,11 +8,77 @@ """Kernel Client subclass.""" +# Standard library imports +import socket + # Third party imports +import asyncssh from qtpy.QtCore import Signal from qtconsole.client import QtKernelClient, QtZMQSocketChannel from traitlets import Type +# Local imports +from spyder.api.asyncdispatcher import AsyncDispatcher +from spyder.api.translations import _ + + +class KernelClientTunneler: + """Class to handle SSH tunneling for a kernel connection.""" + + def __init__(self, ssh_connection, *, _close_conn_on_exit=False): + self.ssh_connection = ssh_connection + self._port_forwarded = {} + self._close_conn_on_exit = _close_conn_on_exit + + def __del__(self): + """Close all port forwarders and the connection if required.""" + for forwarder in self._port_forwarded.values(): + forwarder.close() + + if self._close_conn_on_exit: + self.ssh_connection.close() + + @classmethod + @AsyncDispatcher.dispatch(loop="asyncssh", early_return=False) + async def new_connection(cls, *args, **kwargs): + """Create a new SSH connection.""" + return cls( + await asyncssh.connect(*args, **kwargs, known_hosts=None), + _close_conn_on_exit=True, + ) + + @classmethod + def from_connection(cls, ssh_connection): + """Create a new KernelTunnelHandler from an existing connection.""" + return cls(ssh_connection) + + @AsyncDispatcher.dispatch(loop="asyncssh", early_return=False) + async def forward_port(self, remote_host, remote_port): + """Forward a port through the SSH connection.""" + local = self._get_free_port() + try: + self._port_forwarded[(remote_host, remote_port)] = ( + await self.ssh_connection.forward_local_port( + "", local, remote_host, remote_port + ) + ) + except asyncssh.Error as err: + raise RuntimeError( + _( + "It was not possible to open an SSH tunnel for the " + "remote kernel. Please check your credentials and the " + "server connection status." + ) + ) from err + return local + + @staticmethod + def _get_free_port(): + """Request a free port from the OS.""" + with socket.socket() as s: + s.bind(("", 0)) + return s.getsockname()[1] + class SpyderKernelClient(QtKernelClient): # Enable receiving messages on control channel. @@ -25,3 +91,41 @@ def _handle_kernel_info_reply(self, rep): super()._handle_kernel_info_reply(rep) spyder_kernels_info = rep["content"].get("spyder_kernels_info", None) self.sig_spyder_kernel_info.emit(spyder_kernels_info) + + def tunnel_to_kernel( + self, hostname=None, sshkey=None, password=None, ssh_connection=None + ): + """Tunnel to remote kernel.""" + if ssh_connection is not None: + self.__tunnel_handler = KernelClientTunneler.from_connection( + ssh_connection + ) + elif sshkey is not None: + self.__tunnel_handler = KernelClientTunneler.new_connection( + tunnel=hostname, + password=password, + client_keys=[sshkey], + ) + else: + self.__tunnel_handler = KernelClientTunneler.new_connection( + tunnel=hostname, + password=password, + ) + + ( + self.shell_port, + self.iopub_port, + self.stdin_port, + self.hb_port, + self.control_port, + ) = ( + self.__tunnel_handler.forward_port(self.ip, port) + for port in ( + self.shell_port, + self.iopub_port, + self.stdin_port, + self.hb_port, + self.control_port, + ) + ) + self.ip = "127.0.0.1" # Tunneled to localhost diff --git a/spyder/plugins/ipythonconsole/utils/kernel_handler.py b/spyder/plugins/ipythonconsole/utils/kernel_handler.py index b74fd4c9bb4..40b214fb29e 100644 --- a/spyder/plugins/ipythonconsole/utils/kernel_handler.py +++ b/spyder/plugins/ipythonconsole/utils/kernel_handler.py @@ -7,6 +7,7 @@ """Kernel handler.""" # Standard library imports +import json import os import os.path as osp from subprocess import PIPE @@ -22,19 +23,17 @@ from spyder.api.translations import _ from spyder.config.base import running_under_pytest from spyder.plugins.ipythonconsole import ( - SPYDER_KERNELS_MIN_VERSION, SPYDER_KERNELS_MAX_VERSION, - SPYDER_KERNELS_VERSION, SPYDER_KERNELS_CONDA, SPYDER_KERNELS_PIP) + SPYDER_KERNELS_MIN_VERSION, + SPYDER_KERNELS_MAX_VERSION, + SPYDER_KERNELS_VERSION, + SPYDER_KERNELS_CONDA, + SPYDER_KERNELS_PIP, +) from spyder.plugins.ipythonconsole.comms.kernelcomm import KernelComm 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.utils.programs import check_version_range -if os.name == "nt": - ssh_tunnel = zmqtunnel.paramiko_tunnel -else: - ssh_tunnel = openssh_tunnel - PERMISSION_ERROR_MSG = _( "The directory {} is not writable and it is required to create IPython " @@ -78,17 +77,18 @@ class KernelConnectionState: - SpyderKernelWaitComm = 'spyder_kernel_wait_comm' - SpyderKernelReady = 'spyder_kernel_ready' - IpykernelReady = 'ipykernel_ready' - Connecting = 'connecting' - Error = 'error' - Closed = 'closed' - Crashed = 'crashed' + SpyderKernelWaitComm = "spyder_kernel_wait_comm" + SpyderKernelReady = "spyder_kernel_ready" + IpykernelReady = "ipykernel_ready" + Connecting = "connecting" + Error = "error" + Closed = "closed" + Crashed = "crashed" class StdThread(QThread): """Poll for changes in std buffers.""" + sig_out = Signal(str) def __init__(self, parent, std_buffer): @@ -156,6 +156,7 @@ def __init__( hostname=None, sshkey=None, password=None, + ssh_connection=None, ): super().__init__() # Connection Informations @@ -166,13 +167,13 @@ def __init__( self.hostname = hostname self.sshkey = sshkey self.password = password + self.ssh_connection = ssh_connection self.kernel_error_message = None self.connection_state = KernelConnectionState.Connecting # Comm self.kernel_comm = KernelComm() - self.kernel_comm.sig_comm_ready.connect( - self.handle_comm_ready) + self.kernel_comm.sig_comm_ready.connect(self.handle_comm_ready) # Internal self._shutdown_lock = Lock() @@ -196,6 +197,13 @@ def __init__( # For ipykernels, this does nothing. self.kernel_comm.open_comm(self.kernel_client) + @property + def connection_info(self): + """Get connection info.""" + connection_info = self.kernel_client.get_connection_info() + connection_info["key"] = connection_info["key"].decode() + return connection_info + def connect_(self): """Connect to shellwidget.""" self._shellwidget_connected = True @@ -237,7 +245,7 @@ def check_spyder_kernel_info(self, spyder_kernel_info): SPYDER_KERNELS_MIN_VERSION, SPYDER_KERNELS_MAX_VERSION, SPYDER_KERNELS_CONDA, - SPYDER_KERNELS_PIP + SPYDER_KERNELS_PIP, ) ) self.connection_state = KernelConnectionState.Error @@ -253,15 +261,13 @@ def check_spyder_kernel_info(self, spyder_kernel_info): if not check_version_range(version, SPYDER_KERNELS_VERSION): # Development versions are acceptable if "dev0" not in version: - self.kernel_error_message = ( - ERROR_SPYDER_KERNEL_VERSION.format( - pyexec, - version, - SPYDER_KERNELS_MIN_VERSION, - SPYDER_KERNELS_MAX_VERSION, - SPYDER_KERNELS_CONDA, - SPYDER_KERNELS_PIP - ) + self.kernel_error_message = ERROR_SPYDER_KERNEL_VERSION.format( + pyexec, + version, + SPYDER_KERNELS_MIN_VERSION, + SPYDER_KERNELS_MAX_VERSION, + SPYDER_KERNELS_CONDA, + SPYDER_KERNELS_PIP, ) self.known_spyder_kernel = False self.connection_state = KernelConnectionState.Error @@ -278,7 +284,7 @@ def handle_comm_ready(self): self._comm_ready_received = True if self.connection_state in [ KernelConnectionState.SpyderKernelWaitComm, - KernelConnectionState.Crashed + KernelConnectionState.Crashed, ]: # This is necessary for systems in which the kernel takes too much # time to start because in that case its heartbeat is not detected @@ -363,41 +369,6 @@ def new_connection_file(): 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): - try: - ssh_tunnel( - lp, rp, hostname, remote_ip, sshkey, password, timeout - ) - except Exception: - raise RuntimeError( - _( - "It was not possible to open an SSH tunnel for the " - "remote kernel. Please check your credentials and the " - "server connection status." - ) - ) - - return tuple(lports) - @classmethod def new_from_spec(cls, kernel_spec): """ @@ -440,6 +411,31 @@ def new_from_spec(cls, kernel_spec): known_spyder_kernel=True, ) + @classmethod + def from_connection_info( + cls, + connection_info, + hostname=None, + sshkey=None, + password=None, + ssh_connection=None, + ): + """Create kernel for given connection info.""" + new_connection_file = cls.new_connection_file() + with open(new_connection_file, "w") as f: + json.dump(connection_info, f) + + return cls( + new_connection_file, + kernel_client=cls.init_kernel_client( + new_connection_file, + hostname, + sshkey, + password, + ssh_connection, + ), + ) + @classmethod def from_connection_file( cls, @@ -447,7 +443,7 @@ def from_connection_file( hostname=None, sshkey=None, password=None, - kernel_ports=None, + ssh_connection=None, ): """Create kernel for given connection file.""" return cls( @@ -460,18 +456,20 @@ def from_connection_file( hostname, sshkey, password, - kernel_ports=kernel_ports - ) + ssh_connection, + ), ) - @classmethod + @staticmethod def init_kernel_client( - cls, connection_file, hostname, sshkey, password, kernel_ports=None + connection_file, + hostname, + sshkey, + password, + ssh_connection, ): """Create kernel client.""" - kernel_client = SpyderKernelClient( - connection_file=connection_file - ) + kernel_client = SpyderKernelClient(connection_file=connection_file) # This is needed for issue spyder-ide/spyder#9304. try: @@ -486,28 +484,12 @@ def init_kernel_client( + str(e) ) - if hostname is not None: - 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, - ) = ( - kernel_ports - if kernel_ports is not None - else cls.tunnel_to_kernel( - connection_info, hostname, sshkey, password - ) + if hostname is not None or ssh_connection is not None: + kernel_client.tunnel_to_kernel( + hostname=hostname, + sshkey=sshkey, + password=password, + ssh_connection=ssh_connection, ) return kernel_client @@ -583,6 +565,7 @@ def copy(self): self.hostname, self.sshkey, self.password, + self.ssh_connection, ) return self.__class__( @@ -592,6 +575,7 @@ def copy(self): hostname=self.hostname, sshkey=self.sshkey, password=self.password, + ssh_connection=self.ssh_connection, kernel_client=kernel_client, ) diff --git a/spyder/plugins/ipythonconsole/utils/ssh.py b/spyder/plugins/ipythonconsole/utils/ssh.py deleted file mode 100644 index 87aef9b1285..00000000000 --- a/spyder/plugins/ipythonconsole/utils/ssh.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Utilities to connect to kernels through ssh.""" - -import atexit -import os - -from qtpy.QtWidgets import QApplication, QMessageBox -import pexpect - -from spyder.config.base import _ - - -def _stop_tunnel(cmd): - pexpect.run(cmd) - - -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 - issue https://github.com/zeromq/pyzmq/issues/589 which was solved - in pyzmq https://github.com/zeromq/pyzmq/pull/615 - """ - ssh = "ssh " - if keyfile: - ssh += "-i " + keyfile - - if ':' in server: - server, port = server.split(':') - ssh += " -p %s" % port - - cmd = "%s -O check %s" % (ssh, server) - (output, exitstatus) = pexpect.run(cmd, withexitstatus=True) - if not exitstatus: - pid = int(output[output.find("(pid=")+5:output.find(")")]) - cmd = "%s -O forward -L 127.0.0.1:%i:%s:%i %s" % ( - ssh, lport, remoteip, rport, server) - (output, exitstatus) = pexpect.run(cmd, withexitstatus=True) - if not exitstatus: - atexit.register(_stop_tunnel, cmd.replace("-O forward", - "-O cancel", - 1)) - return pid - cmd = "%s -f -S none -L 127.0.0.1:%i:%s:%i %s sleep %i" % ( - ssh, lport, remoteip, rport, server, timeout) - - # pop SSH_ASKPASS from env - env = os.environ.copy() - env.pop('SSH_ASKPASS', None) - - ssh_newkey = 'Are you sure you want to continue connecting' - tunnel = pexpect.spawn(cmd, env=env) - failed = False - while True: - try: - i = tunnel.expect( - [ssh_newkey, '[Pp]assword:', '[Pp]assphrase'], - timeout=.1 - ) - if i == 0: - host = server.split('@')[-1] - question = _("The authenticity of host %s can't be " - "established. Are you sure you want to continue " - "connecting?") % host - reply = QMessageBox.question(QApplication.activeWindow(), - _('Warning'), question, - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No) - if reply == QMessageBox.Yes: - tunnel.sendline('yes') - continue - else: - tunnel.sendline('no') - raise RuntimeError( - _("The authenticity of the host can't be established")) - if i == 1 and password is not None: - tunnel.sendline(password) - except pexpect.TIMEOUT: - continue - except pexpect.EOF: - if tunnel.exitstatus: - raise RuntimeError(_("Tunnel '%s' failed to start") % cmd) - else: - return tunnel.pid - else: - if failed or password is None: - raise RuntimeError(_("Could not connect to remote host")) - # TODO: Use this block when zeromq/pyzmq#620 is fixed - # # Prompt a passphrase dialog to the user for a second attempt - # password, ok = QInputDialog.getText(self, _('Password'), - # _('Enter password for: ') + server, - # echo=QLineEdit.Password) - # if ok is False: - # raise RuntimeError('Could not connect to remote host.') - tunnel.sendline(password) - failed = True diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 9432d02dab3..b8264e0cb18 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -359,7 +359,7 @@ def connect_kernel(self, kernel_handler, first_connect=True): def disconnect_kernel(self, shutdown_kernel): """Disconnect from current kernel.""" - kernel_handler = self.kernel_handler + kernel_handler = getattr(self, "kernel_handler", None) if not kernel_handler: return diff --git a/spyder/plugins/remoteclient/plugin.py b/spyder/plugins/remoteclient/plugin.py index 9884b367dbe..c7d42325e84 100644 --- a/spyder/plugins/remoteclient/plugin.py +++ b/spyder/plugins/remoteclient/plugin.py @@ -116,7 +116,10 @@ def on_first_registration(self): def on_close(self, cancellable=True): """Stops remote server and close any opened connection.""" for client in self._remote_clients.values(): - AsyncDispatcher(client.close, early_return=False)() + try: + AsyncDispatcher(client.close, early_return=False)() + except Exception: + pass @on_plugin_available(plugin=Plugins.MainMenu) def on_mainmenu_available(self): @@ -167,14 +170,14 @@ def get_remote_server(self, config_id): if config_id in self._remote_clients: return self._remote_clients[config_id] - @AsyncDispatcher.dispatch() + @AsyncDispatcher.dispatch(loop='asyncssh') async def _install_remote_server(self, config_id): """Install remote server.""" if config_id in self._remote_clients: client = self._remote_clients[config_id] await client.connect_and_install_remote_server() - @AsyncDispatcher.dispatch() + @AsyncDispatcher.dispatch(loop='asyncssh') async def start_remote_server(self, config_id): """Start remote server.""" if config_id not in self._remote_clients: @@ -183,14 +186,14 @@ async def start_remote_server(self, config_id): client = self._remote_clients[config_id] await client.connect_and_ensure_server() - @AsyncDispatcher.dispatch() + @AsyncDispatcher.dispatch(loop='asyncssh') async def stop_remote_server(self, config_id): """Stop remote server.""" if config_id in self._remote_clients: client = self._remote_clients[config_id] await client.close() - @AsyncDispatcher.dispatch() + @AsyncDispatcher.dispatch(loop='asyncssh') async def ensure_remote_server(self, config_id): """Ensure remote server is running and installed.""" if config_id in self._remote_clients: @@ -292,7 +295,7 @@ def create_ipyclient_for_server(self, config_id): # ------------------------------------------------------------------------- # --- Remote Server Kernel Methods @Slot(str) - @AsyncDispatcher.dispatch() + @AsyncDispatcher.dispatch(loop='asyncssh') async def get_kernels(self, config_id): """Get opened kernels.""" if config_id in self._remote_clients: @@ -300,7 +303,7 @@ async def get_kernels(self, config_id): kernels_list = await client.list_kernels() return kernels_list - @AsyncDispatcher.dispatch() + @AsyncDispatcher.dispatch(loop='asyncssh') async def _get_kernel_info(self, config_id, kernel_id): """Get kernel info.""" if config_id in self._remote_clients: @@ -308,14 +311,17 @@ async def _get_kernel_info(self, config_id, kernel_id): kernel_info = await client.get_kernel_info(kernel_id) return kernel_info - @AsyncDispatcher.dispatch() + @AsyncDispatcher.dispatch(loop='asyncssh') async def _shutdown_kernel(self, config_id, kernel_id): """Shutdown a running kernel.""" if config_id in self._remote_clients: client = self._remote_clients[config_id] - await client.terminate_kernel(kernel_id) + try: + await client.terminate_kernel(kernel_id) + except Exception: + pass - @AsyncDispatcher.dispatch() + @AsyncDispatcher.dispatch(loop='asyncssh') async def _start_new_kernel(self, config_id): """Start new kernel.""" if config_id not in self._remote_clients: @@ -324,14 +330,17 @@ async def _start_new_kernel(self, config_id): client = self._remote_clients[config_id] return await client.start_new_kernel_ensure_server() - @AsyncDispatcher.dispatch() + @AsyncDispatcher.dispatch(loop='asyncssh') async def _restart_kernel(self, config_id, kernel_id): """Restart kernel.""" if config_id in self._remote_clients: client = self._remote_clients[config_id] - return await client.restart_kernel(kernel_id) + try: + return await client.restart_kernel(kernel_id) + except Exception: + pass - @AsyncDispatcher.dispatch() + @AsyncDispatcher.dispatch(loop='asyncssh') async def _interrupt_kernel(self, config_id, kernel_id): """Interrupt kernel.""" if config_id in self._remote_clients: diff --git a/spyder/plugins/remoteclient/widgets/container.py b/spyder/plugins/remoteclient/widgets/container.py index 00849e1d646..fef10747c1c 100644 --- a/spyder/plugins/remoteclient/widgets/container.py +++ b/spyder/plugins/remoteclient/widgets/container.py @@ -7,7 +7,7 @@ """Remote client container.""" -import json +import functools from qtpy.QtCore import Signal @@ -20,16 +20,14 @@ RemoteConsolesMenuSections, ) from spyder.plugins.remoteclient.api.protocol import ConnectionInfo -from spyder.plugins.remoteclient.widgets import AuthenticationMethod from spyder.plugins.remoteclient.widgets.connectiondialog import ( ConnectionDialog, ) -from spyder.utils.workers import WorkerManager class RemoteClientContainer(PluginMainContainer): - _sig_kernel_restarted = Signal((object, bool), (object, dict)) + _sig_kernel_restarted = Signal(object, object) """ This private signal is used to inform that a kernel restart took place in the server. @@ -39,11 +37,9 @@ class RemoteClientContainer(PluginMainContainer): ipyclient: ClientWidget An IPython console client widget (the first parameter in both signatures). - response: bool or dict - Response returned by the server. It can a bool when the kernel is - restarted by the user (signature 1) or a dict when it's restarted - automatically after it dies while running some code or it's killed ( - signature 2). + response: bool or None + Response returned by the server. `None` can happen when the connection + to the server is lost. """ sig_start_server_requested = Signal(str) @@ -128,36 +124,24 @@ def setup(self): # Widgets self.create_action( RemoteClientActions.ManageConnections, - _('Manage remote connections...'), + _("Manage remote connections..."), icon=self._plugin.get_icon(), triggered=self._show_connection_dialog, ) self._remote_consoles_menu = self.create_menu( - RemoteClientMenus.RemoteConsoles, - _("New console in remote server") + RemoteClientMenus.RemoteConsoles, _("New console in remote server") ) # Signals self.sig_connection_status_changed.connect( self._on_connection_status_changed ) - self._sig_kernel_restarted[object, bool].connect( - self._on_kernel_restarted - ) - self._sig_kernel_restarted[object, dict].connect( - self._on_kernel_restarted_after_died - ) - - # Worker manager to open ssh tunnels in threads - self._worker_manager = WorkerManager(max_threads=5) + self._sig_kernel_restarted.connect(self._on_kernel_restarted) def update_actions(self): pass - def on_close(self): - self._worker_manager.terminate_all() - # ---- Public API # ------------------------------------------------------------------------- def setup_remote_consoles_submenu(self, render=True): @@ -167,7 +151,7 @@ def setup_remote_consoles_submenu(self, render=True): self.add_item_to_menu( self.get_action(RemoteClientActions.ManageConnections), menu=self._remote_consoles_menu, - section=RemoteConsolesMenuSections.ManagerSection + section=RemoteConsolesMenuSections.ManagerSection, ) servers = self.get_conf("servers", default={}) @@ -178,17 +162,17 @@ def setup_remote_consoles_submenu(self, render=True): action = self.create_action( name=config_id, text=f"New console in {name} server", - icon=self.create_icon('ipython_console'), - triggered=( - lambda checked, config_id=config_id: - self.sig_create_ipyclient_requested.emit(config_id) + icon=self.create_icon("ipython_console"), + triggered=functools.partial( + self.sig_create_ipyclient_requested.emit, + config_id, ), - overwrite=True + overwrite=True, ) self.add_item_to_menu( action, menu=self._remote_consoles_menu, - section=RemoteConsolesMenuSections.ConsolesSection + section=RemoteConsolesMenuSections.ConsolesSection, ) # This is necessary to reposition the menu correctly when rebuilt @@ -205,11 +189,9 @@ def on_kernel_started(self, ipyclient, kernel_info): # It's only at this point that we can allow users to close the client. ipyclient.can_close = True - # Get authentication method - auth_method = self.get_conf(f"{config_id}/auth_method") - # Handle failures to launch a kernel if not kernel_info: + auth_method = self.get_conf(f"{config_id}/auth_method") name = self.get_conf(f"{config_id}/{auth_method}/name") ipyclient.show_kernel_error( _( @@ -222,37 +204,18 @@ def on_kernel_started(self, ipyclient, kernel_info): ipyclient.kernel_id = kernel_info["id"] self._connect_ipyclient_signals(ipyclient) - # Set hostname in the format expected by KernelHandler - address = self.get_conf(f"{config_id}/{auth_method}/address") - username = self.get_conf(f"{config_id}/{auth_method}/username") - port = self.get_conf(f"{config_id}/{auth_method}/port") - hostname = f"{username}@{address}:{port}" - - # Get password or keyfile/passphrase - if auth_method == AuthenticationMethod.Password: - password = self.get_conf(f"{config_id}/password", secure=True) - sshkey = None - elif auth_method == AuthenticationMethod.KeyFile: - sshkey = self.get_conf(f"{config_id}/{auth_method}/keyfile") - passpharse = self.get_conf(f"{config_id}/passpharse", secure=True) - if passpharse: - password = passpharse - else: - password = None + try: + kernel_handler = KernelHandler.from_connection_info( + kernel_info["connection_info"], + ssh_connection=self._plugin.get_remote_server( + ipyclient.server_id + )._ssh_connection, + ) + except Exception as err: + ipyclient.show_kernel_error(err) else: - # TODO: Handle the ConfigFile method here - pass - - # Generate local connection file from kernel info - connection_file = KernelHandler.new_connection_file() - with open(connection_file, "w") as f: - json.dump(kernel_info["connection_info"], f) - - # Open tunnel to the kernel. Connecting the ipyclient to the kernel - # will be finished after that takes place. - self._open_tunnel_to_kernel( - ipyclient, connection_file, hostname, sshkey, password - ) + # Connect client to the kernel + ipyclient.connect_kernel(kernel_handler) # ---- Private API # ------------------------------------------------------------------------- @@ -268,9 +231,7 @@ def _show_connection_dialog(self): connection_dialog.sig_connections_changed.connect( self.setup_remote_consoles_submenu ) - connection_dialog.sig_server_renamed.connect( - self.sig_server_renamed - ) + connection_dialog.sig_server_renamed.connect(self.sig_server_renamed) self.sig_connection_status_changed.connect( connection_dialog.sig_connection_status_changed @@ -305,74 +266,9 @@ def _connect_ipyclient_signals(self, ipyclient): lambda: self._request_kernel_restart(ipyclient) ) ipyclient.sig_kernel_died.connect( - lambda: self._get_kernel_info(ipyclient) - ) - - def _open_tunnel_to_kernel( - self, - ipyclient, - connection_file, - hostname, - sshkey, - password, - restart=False, - clear=True, - ): - """ - Open an SSH tunnel to a remote kernel. - - Notes - ----- - * We do this in a worker to avoid blocking the UI. - """ - with open(connection_file, "r") as f: - connection_info = json.load(f) - - worker = self._worker_manager.create_python_worker( - KernelHandler.tunnel_to_kernel, - connection_info, - hostname, - sshkey, - password, - ) - - # Save variables necessary to make the connection in the worker - worker.ipyclient = ipyclient - worker.connection_file = connection_file - worker.hostname = hostname - worker.sshkey = sshkey - worker.password = password - worker.restart = restart - worker.clear = clear - - # Start worker - worker.sig_finished.connect(self._finish_kernel_connection) - worker.start() - - def _finish_kernel_connection(self, worker, output, error): - """Finish connecting an IPython console client to a remote kernel.""" - # Handle errors - if error: - worker.ipyclient.show_kernel_error(str(error)) - return - - # Create KernelHandler - kernel_handler = KernelHandler.from_connection_file( - worker.connection_file, - worker.hostname, - worker.sshkey, - worker.password, - kernel_ports=output, + lambda: self._request_kernel_restart(ipyclient) ) - # Connect client to the kernel - if not worker.restart: - worker.ipyclient.connect_kernel(kernel_handler) - else: - worker.ipyclient.replace_kernel( - kernel_handler, shutdown_kernel=False, clear=worker.clear - ) - def _request_kernel_restart(self, ipyclient): """ Request a kernel restart to the server for an IPython console client @@ -383,49 +279,33 @@ def _request_kernel_restart(self, ipyclient): ) future.add_done_callback( - lambda future: self._sig_kernel_restarted[object, bool].emit( + lambda future: self._sig_kernel_restarted.emit( ipyclient, future.result() ) ) - def _on_kernel_restarted(self, ipyclient, response, clear=True): - """Actions to take when the kernel was restarted by the server.""" - if response: - kernel_handler = ipyclient.kernel_handler - self._open_tunnel_to_kernel( - ipyclient, - kernel_handler.connection_file, - kernel_handler.hostname, - kernel_handler.sshkey, - kernel_handler.password, - restart=True, - clear=clear, - ) - else: - ipyclient.kernel_restarted_failure_message() - - def _get_kernel_info(self, ipyclient): + def _on_kernel_restarted(self, ipyclient, restarted): """ Get kernel info corresponding to an IPython console client from the server. If we get a response, it means the kernel is alive. """ - future = self._plugin._get_kernel_info( - ipyclient.server_id, ipyclient.kernel_id - ) - future.add_done_callback( - lambda future: self._sig_kernel_restarted[object, dict].emit( - ipyclient, future.result() - ) - ) - - def _on_kernel_restarted_after_died(self, ipyclient, response): - """ - Actions to take when the kernel was automatically restarted after it - died. - """ - # We don't clear the console in this case because it can contain - # important results that users would like to check - self._on_kernel_restarted(ipyclient, response, clear=False) + if restarted: + try: + kernel_handler = KernelHandler.from_connection_file( + ipyclient.kernel_handler.connection_file, + ssh_connection=self._plugin.get_remote_server( + ipyclient.server_id + )._ssh_connection, + ) + del ipyclient.kernel_handler + except Exception as err: + ipyclient.show_kernel_error(err) + else: + ipyclient.replace_kernel( + kernel_handler, shutdown_kernel=False, clear=True + ) + else: + ipyclient.kernel_restarted_failure_message()