Repository: spyder-ide/spyder

Issue:
How to remember the configurations of connecting to remote kernel?
Hello,
       I can update that remote kernel josn manually,but how to remember the configurations(remote kernel json file location,host,ssh key file location,password) of connectiong to remote kernel?Then I don't need to configure it manually every time.


Discussion:
There's no option to do that right now, but it's an interesting suggestion. We'll work on it for a future Spyder version :-)

Second that; it's a hassle typing everything again each time since I am connecting through SSH. Maybe add a "save password" option, too.

Resolving patch:
diff --git a/spyder/plugins/ipythonconsole/widgets/kernelconnect.py b/spyder/plugins/ipythonconsole/widgets/kernelconnect.py
--- a/spyder/plugins/ipythonconsole/widgets/kernelconnect.py
+++ b/spyder/plugins/ipythonconsole/widgets/kernelconnect.py
@@ -15,12 +15,14 @@
 from jupyter_core.paths import jupyter_runtime_dir
 from qtpy.compat import getopenfilename
 from qtpy.QtCore import Qt
-from qtpy.QtWidgets import (QDialog, QDialogButtonBox, QGridLayout, QGroupBox,
-                            QHBoxLayout, QLabel, QLineEdit, QPushButton,
-                            QRadioButton, QSpacerItem, QVBoxLayout)
+from qtpy.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, QGridLayout,
+                            QGroupBox, QHBoxLayout, QLabel, QLineEdit,
+                            QPushButton, QRadioButton, QSpacerItem,
+                            QVBoxLayout)
 
 # Local imports
 from spyder.config.base import _, get_home_dir
+from spyder.config.main import CONF
 
 
 class KernelConnectionDialog(QDialog):
@@ -64,7 +66,7 @@ def __init__(self, parent=None):
         pn_label = QLabel(_('Port:'))
         self.pn = QLineEdit()
         self.pn.setMaximumWidth(75)
-        self.pn.setText('22')
+
         un_label = QLabel(_('Username:'))
         self.un = QLineEdit()
 
@@ -129,7 +131,6 @@ def __init__(self, parent=None):
         rm_layout.addWidget(auth_group)
         self.rm_group.setLayout(rm_layout)
         self.rm_group.setCheckable(True)
-        self.rm_group.setChecked(False)
         self.rm_group.toggled.connect(self.pw_radio.setChecked)
 
         # Ok and Cancel buttons
@@ -137,9 +138,18 @@ def __init__(self, parent=None):
             QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
             Qt.Horizontal, self)
 
+        self.accept_btns.accepted.connect(self.save_connection_settings)
         self.accept_btns.accepted.connect(self.accept)
         self.accept_btns.rejected.connect(self.reject)
 
+        # Save connection settings checkbox
+        self.save_layout = QCheckBox(self)
+        self.save_layout.setText(_("Save connection settings"))
+
+        btns_layout = QHBoxLayout()
+        btns_layout.addWidget(self.save_layout)
+        btns_layout.addWidget(self.accept_btns)
+
         # Dialog layout
         layout = QVBoxLayout(self)
         layout.addWidget(main_label)
@@ -147,7 +157,78 @@ def __init__(self, parent=None):
         layout.addLayout(cf_layout)
         layout.addSpacerItem(QSpacerItem(QSpacerItem(0, 12)))
         layout.addWidget(self.rm_group)
-        layout.addWidget(self.accept_btns)
+        layout.addLayout(btns_layout)
+
+        self.load_connection_settings()
+
+    def load_connection_settings(self):
+        """Load the user's previously-saved kernel connection settings."""
+        existing_kernel = CONF.get("existing-kernel", "settings", {})
+
+        connection_file_path = existing_kernel.get("json_file_path", "")
+        is_remote = existing_kernel.get("is_remote", False)
+        username = existing_kernel.get("username", "")
+        hostname = existing_kernel.get("hostname", "")
+        port = str(existing_kernel.get("port", 22))
+        is_ssh_kf = existing_kernel.get("is_ssh_keyfile", False)
+        ssh_kf = existing_kernel.get("ssh_key_file_path", "")
+
+        if connection_file_path != "":
+            self.cf.setText(connection_file_path)
+        if username != "":
+            self.un.setText(username)
+        if hostname != "":
+            self.hn.setText(hostname)
+        if ssh_kf != "":
+            self.kf.setText(ssh_kf)
+        self.rm_group.setChecked(is_remote)
+        self.pn.setText(port)
+        self.kf_radio.setChecked(is_ssh_kf)
+        self.pw_radio.setChecked(not is_ssh_kf)
+
+        try:
+            import keyring
+            ssh_passphrase = keyring.get_password("spyder_remote_kernel",
+                                                  "ssh_key_passphrase")
+            ssh_password = keyring.get_password("spyder_remote_kernel",
+                                                "ssh_password")
+            if ssh_passphrase:
+                self.kfp.setText(ssh_passphrase)
+            if ssh_password:
+                self.pw.setText(ssh_password)
+        except Exception:
+            pass
+
+    def save_connection_settings(self):
+        """Save user's kernel connection settings."""
+
+        if not self.save_layout.isChecked():
+            return
+
+        is_ssh_key = bool(self.kf_radio.isChecked())
+        connection_settings = {
+            "json_file_path": self.cf.text(),
+            "is_remote": self.rm_group.isChecked(),
+            "username": self.un.text(),
+            "hostname": self.hn.text(),
+            "port": self.pn.text(),
+            "is_ssh_keyfile": is_ssh_key,
+            "ssh_key_file_path": self.kf.text()
+        }
+        CONF.set("existing-kernel", "settings", connection_settings)
+
+        try:
+            import keyring
+            if is_ssh_key:
+                keyring.set_password("spyder_remote_kernel",
+                                     "ssh_key_passphrase",
+                                     self.kfp.text())
+            else:
+                keyring.set_password("spyder_remote_kernel",
+                                     "ssh_password",
+                                     self.pw.text())
+        except Exception:
+            pass
 
     def select_connection_file(self):
         cf = getopenfilename(self, _('Select kernel connection file'),
@@ -166,6 +247,7 @@ def get_connection_parameters(parent=None, dialog=None):
         result = dialog.exec_()
         is_remote = bool(dialog.rm_group.isChecked())
         accepted = result == QDialog.Accepted
+
         if is_remote:
             def falsy_to_none(arg):
                 return arg if arg else None


Test patch:
diff --git a/spyder/plugins/ipythonconsole/widgets/tests/test_kernelconnect.py b/spyder/plugins/ipythonconsole/widgets/tests/test_kernelconnect.py
new file mode 100644
--- /dev/null
+++ b/spyder/plugins/ipythonconsole/widgets/tests/test_kernelconnect.py
@@ -0,0 +1,220 @@
+# -*- coding: utf-8 -*-
+# -----------------------------------------------------------------------------
+# Copyright © Spyder Project Contributors
+#
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+# -----------------------------------------------------------------------------
+
+"""Tests for the Existing Kernel Connection widget."""
+
+# Third party imports
+import sys
+import os
+import pytest
+from qtpy.QtCore import Qt
+from qtpy.QtWidgets import QDialogButtonBox
+import time
+
+# Local imports
+from spyder.plugins.ipythonconsole.widgets.kernelconnect import (
+    KernelConnectionDialog)
+from spyder.config.main import CONF
+
+
+# =============================================================================
+# Fixtures
+# =============================================================================
+@pytest.fixture
+def connection_dialog_factory(qtbot, request):
+    """Set up kernel connection dialog."""
+    class DialogFactory(object):
+        def get_default_dialog(self):
+            dialog = KernelConnectionDialog()
+            request.addfinalizer(dialog.close)
+            return dialog
+
+        def submit_filled_dialog(self, use_keyfile, save_settings):
+            dlg = self.get_default_dialog()
+
+            # fill out cf path
+            dlg.cf.clear()
+            qtbot.keyClicks(dlg.cf, pytest.cf_path)
+            # check the remote kernel box
+            dlg.rm_group.setChecked(True)
+            # fill out hostname
+            dlg.hn.clear()
+            qtbot.keyClicks(dlg.hn, pytest.hn)
+            # fill out port
+            dlg.pn.clear()
+            qtbot.keyClicks(dlg.pn, str(pytest.pn))
+            # fill out username
+            dlg.un.clear()
+            qtbot.keyClicks(dlg.un, pytest.un)
+
+            if use_keyfile:
+                # select ssh keyfile radio
+                dlg.kf_radio.setChecked(True)
+                assert dlg.kf.isEnabled()
+
+                # fill out ssh keyfile
+                dlg.kf.clear()
+                qtbot.keyClicks(dlg.kf, pytest.kf)
+
+                # fill out passphrase
+                dlg.kfp.clear()
+                qtbot.keyClicks(dlg.kfp, pytest.kfp)
+            else:
+                # select password radio
+                dlg.pw_radio.setChecked(True)
+                assert dlg.pw.isEnabled()
+
+                # fill out password
+                dlg.pw.clear()
+                qtbot.keyClicks(dlg.pw, pytest.pw)
+
+            # check save connection settings
+            dlg.save_layout.setChecked(save_settings)
+
+            return dlg
+
+    def teardown():
+        """Clear existing-kernel config and keyring passwords."""
+        CONF.remove_section("existing-kernel")
+
+        try:
+            import keyring
+            keyring.set_password("spyder_remote_kernel",
+                                 "ssh_key_passphrase", "")
+            keyring.set_password("spyder_remote_kernel",
+                                 "ssh_password", "")
+        except Exception:
+            pass
+
+    # test form values
+    pytest.cf_path = "cf_path"
+    pytest.un = "test_username"
+    pytest.hn = "test_hostname"
+    pytest.pn = 123
+    pytest.kf = "test_kf"
+    pytest.kfp = "test_kfp"
+    pytest.pw = "test_pw"
+
+    request.addfinalizer(teardown)
+    return DialogFactory()
+
+
+# =============================================================================
+# Tests
+# =============================================================================
+def test_connection_dialog_remembers_input_with_ssh_passphrase(
+        qtbot, connection_dialog_factory):
+    """
+    Test that the dialog remembers the user's kernel connection
+    settings and ssh key passphrase when the user checks the
+    save checkbox.
+    """
+
+    dlg = connection_dialog_factory.submit_filled_dialog(use_keyfile=True,
+                                                         save_settings=True)
+
+    # Press ok and save connection settings
+    qtbot.mouseClick(dlg.accept_btns.button(QDialogButtonBox.Ok),
+                     Qt.LeftButton)
+
+    # create new dialog and check fields
+    new_dlg = connection_dialog_factory.get_default_dialog()
+    assert new_dlg.cf.text() == pytest.cf_path
+    assert new_dlg.rm_group.isChecked()
+    assert new_dlg.hn.text() == pytest.hn
+    assert new_dlg.un.text() == pytest.un
+    assert new_dlg.pn.text() == str(pytest.pn)
+    assert new_dlg.kf.text() == pytest.kf
+    if (not sys.platform.startswith('linux') or
+            not os.environ.get('CI') is not None):
+        assert new_dlg.kfp.text() == pytest.kfp
+
+
+def test_connection_dialog_doesnt_remember_input_with_ssh_passphrase(
+        qtbot, connection_dialog_factory):
+    """
+    Test that the dialog doesn't remember the user's kernel
+    connection settings and ssh key passphrase when the user doesn't
+    check the save checkbox.
+    """
+
+    dlg = connection_dialog_factory.submit_filled_dialog(use_keyfile=True,
+                                                         save_settings=False)
+
+    # Press ok and save connection settings
+    qtbot.mouseClick(dlg.accept_btns.button(QDialogButtonBox.Ok),
+                     Qt.LeftButton)
+
+    # create new dialog and check fields
+    new_dlg = connection_dialog_factory.get_default_dialog()
+    assert new_dlg.cf.text() == ""
+    assert not new_dlg.rm_group.isChecked()
+    assert new_dlg.hn.text() == ""
+    assert new_dlg.un.text() == ""
+    assert new_dlg.pn.text() == "22"
+    assert new_dlg.kf.text() == ""
+    if (not sys.platform.startswith('linux') or
+            not os.environ.get('CI') is not None):
+        assert new_dlg.kfp.text() == ""
+
+
+def test_connection_dialog_remembers_input_with_password(
+        qtbot, connection_dialog_factory):
+    """
+    Test that the dialog remembers the user's kernel connection
+    settings and ssh password when the user checks the save checkbox.
+    """
+
+    dlg = connection_dialog_factory.submit_filled_dialog(use_keyfile=False,
+                                                         save_settings=True)
+
+    # Press ok and save connection settings
+    qtbot.mouseClick(dlg.accept_btns.button(QDialogButtonBox.Ok),
+                     Qt.LeftButton)
+
+    # create new dialog and check fields
+    new_dlg = connection_dialog_factory.get_default_dialog()
+    assert new_dlg.cf.text() == pytest.cf_path
+    assert new_dlg.rm_group.isChecked()
+    assert new_dlg.hn.text() == pytest.hn
+    assert new_dlg.un.text() == pytest.un
+    assert new_dlg.pn.text() == str(pytest.pn)
+    if (not sys.platform.startswith('linux') or
+            not os.environ.get('CI') is not None):
+        assert new_dlg.pw.text() == pytest.pw
+
+
+def test_connection_dialog_doesnt_remember_input_with_password(
+        qtbot, connection_dialog_factory):
+    """
+    Test that the dialog doesn't remember the user's kernel
+    connection settings and ssh password when the user doesn't
+    check the save checkbox.
+    """
+
+    dlg = connection_dialog_factory.submit_filled_dialog(use_keyfile=False,
+                                                         save_settings=False)
+
+    # Press ok and save connection settings
+    qtbot.mouseClick(dlg.accept_btns.button(QDialogButtonBox.Ok),
+                     Qt.LeftButton)
+
+    # create new dialog and check fields
+    new_dlg = connection_dialog_factory.get_default_dialog()
+    assert new_dlg.cf.text() == ""
+    assert not new_dlg.rm_group.isChecked()
+    assert new_dlg.hn.text() == ""
+    assert new_dlg.un.text() == ""
+    assert new_dlg.pn.text() == "22"
+    if (not sys.platform.startswith('linux') or
+            not os.environ.get('CI') is not None):
+        assert new_dlg.pw.text() == ""
+
+
+if __name__ == "__main__":
+    pytest.main()