Repository: spyder-ide/spyder

Issue:
Detect external Spyder kernels
Detect that a kernel is from Spyder in order to be able to connect it to the Variable Explorer and Help.


Discussion:

Resolving patch:
diff --git a/spyder/plugins/ipythonconsole.py b/spyder/plugins/ipythonconsole.py
--- a/spyder/plugins/ipythonconsole.py
+++ b/spyder/plugins/ipythonconsole.py
@@ -1390,12 +1390,30 @@ def process_finished(self, client):
         if self.variableexplorer is not None:
             self.variableexplorer.remove_shellwidget(id(client.shellwidget))
 
+    def connect_external_kernel(self, shellwidget):
+        """
+        Connect an external kernel to the Variable Explorer and Help, if
+        it is a Spyder kernel.
+        """
+        sw = shellwidget
+        kc = shellwidget.kernel_client
+        if self.help is not None:
+            self.help.set_shell(sw)
+        if self.variableexplorer is not None:
+            self.variableexplorer.add_shellwidget(sw)
+            sw.set_namespace_view_settings()
+            sw.refresh_namespacebrowser()
+            kc.stopped_channels.connect(lambda :
+                self.variableexplorer.remove_shellwidget(id(sw)))
+
     def _create_client_for_kernel(self, connection_file, hostname, sshkey,
                                   password):
         # 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)
         except (IOError, UnboundLocalError):
@@ -1464,11 +1482,15 @@ def _create_client_for_kernel(self, connection_file, hostname, sshkey,
                                    _("Could not open ssh tunnel. The "
                                      "error was:\n\n") + to_text_string(e))
                 return
-        kernel_client.start_channels()
 
         # Assign kernel manager and client to shellwidget
         client.shellwidget.kernel_client = kernel_client
         client.shellwidget.kernel_manager = kernel_manager
+        kernel_client.start_channels()
+        if external_kernel:
+            client.shellwidget.sig_is_spykernel.connect(
+                    self.connect_external_kernel)
+            client.shellwidget.is_spyder_kernel()
 
         # Adding a new tab for the client
         self.add_tab(client, name=client.get_name())
diff --git a/spyder/widgets/ipythonconsole/shell.py b/spyder/widgets/ipythonconsole/shell.py
--- a/spyder/widgets/ipythonconsole/shell.py
+++ b/spyder/widgets/ipythonconsole/shell.py
@@ -45,6 +45,7 @@ class ShellWidget(NamepaceBrowserWidget, HelpWidget, DebuggingWidget):
     focus_changed = Signal()
     new_client = Signal()
     sig_got_reply = Signal()
+    sig_is_spykernel = Signal(object)
     sig_kernel_restarted = Signal(str)
 
     def __init__(self, ipyclient, additional_options, interpreter_versions,
@@ -77,6 +78,14 @@ def is_running(self):
         else:
             return False
 
+    def is_spyder_kernel(self):
+        """Determine if the kernel is from Spyder."""
+        code = u"getattr(get_ipython().kernel, 'set_value', False)"
+        if self._reading:
+            return
+        else:
+            self.silent_exec_method(code)
+
     def set_cwd(self, dirname):
         """Set shell current working directory."""
         code = u"get_ipython().kernel.set_cwd(r'{}')".format(dirname)
@@ -301,6 +310,11 @@ def handle_exec_method(self, msg):
                     else:
                         env = None
                     self.sig_show_env.emit(env)
+                elif 'getattr' in method:
+                    if data is not None and 'text/plain' in data:
+                        is_spyder_kernel = data['text/plain']
+                        if 'SpyderKernel' in is_spyder_kernel:
+                            self.sig_is_spykernel.emit(self)
                 else:
                     if data is not None and 'text/plain' in data:
                         self._kernel_reply = ast.literal_eval(data['text/plain'])


Test patch:
diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py
--- a/spyder/app/tests/test_mainwindow.py
+++ b/spyder/app/tests/test_mainwindow.py
@@ -14,6 +14,7 @@
 import tempfile
 
 from flaky import flaky
+from jupyter_client.manager import KernelManager
 import numpy as np
 from numpy.testing import assert_array_equal
 import pytest
@@ -25,6 +26,7 @@
 from spyder.app.cli_options import get_options
 from spyder.app.mainwindow import initialize, run_spyder
 from spyder.py3compat import PY2
+from spyder.utils.ipython.kernelspec import SpyderKernelSpec
 from spyder.utils.programs import is_module_installed
 from spyder.utils.test import close_save_message_box
 
@@ -74,6 +76,25 @@ def reset_run_code(qtbot, shell, code_editor, nsb):
     qtbot.keyClick(code_editor, Qt.Key_Home, modifier=Qt.ControlModifier)
 
 
+def start_new_kernel(startup_timeout=60, kernel_name='python', spykernel=False,
+                     **kwargs):
+    """Start a new kernel, and return its Manager and Client"""
+    km = KernelManager(kernel_name=kernel_name)
+    if spykernel:
+        km._kernel_spec = SpyderKernelSpec()
+    km.start_kernel(**kwargs)
+    kc = km.client()
+    kc.start_channels()
+    try:
+        kc.wait_for_ready(timeout=startup_timeout)
+    except RuntimeError:
+        kc.stop_channels()
+        km.shutdown_kernel()
+        raise
+
+    return km, kc
+
+
 #==============================================================================
 # Fixtures
 #==============================================================================
@@ -104,6 +125,80 @@ def close_window():
 #==============================================================================
 # Tests
 #==============================================================================
+# IMPORTANT NOTE: Please leave this test to be the first one here to
+# avoid possible timeouts in Appyevor
+@flaky(max_runs=3)
+@pytest.mark.skipif(os.name != 'nt' or not PY2,
+                    reason="It times out on Linux and Python 3")
+@pytest.mark.timeout(timeout=60, method='thread')
+@pytest.mark.use_introspection
+def test_calltip(main_window, qtbot):
+    """Hide the calltip in the editor when a matching ')' is found."""
+    # Load test file
+    text = 'a = [1,2,3]\n(max'
+    main_window.editor.new(fname="test.py", text=text)
+    code_editor = main_window.editor.get_focus_widget()
+
+    # Set text to start
+    code_editor.set_text(text)
+    code_editor.go_to_line(2)
+    code_editor.move_cursor(5)
+    calltip = code_editor.calltip_widget
+    assert not calltip.isVisible()
+
+    qtbot.keyPress(code_editor, Qt.Key_ParenLeft, delay=3000)
+    qtbot.keyPress(code_editor, Qt.Key_A, delay=1000)
+    qtbot.waitUntil(lambda: calltip.isVisible(), timeout=1000)
+
+    qtbot.keyPress(code_editor, Qt.Key_ParenRight, delay=1000)
+    qtbot.keyPress(code_editor, Qt.Key_Space)
+    assert not calltip.isVisible()
+    qtbot.keyPress(code_editor, Qt.Key_ParenRight, delay=1000)
+    qtbot.keyPress(code_editor, Qt.Key_Enter, delay=1000)
+
+    QTimer.singleShot(1000, lambda: close_save_message_box(qtbot))
+    main_window.editor.close_file()
+
+
+@flaky(max_runs=3)
+def test_connection_to_external_kernel(main_window, qtbot):
+    """Test that only Spyder kernels are connected to the Variable Explorer."""
+    # Test with a generic kernel
+    km, kc = start_new_kernel()
+
+    main_window.ipyconsole._create_client_for_kernel(kc.connection_file, None,
+                                                     None, None)
+    shell = main_window.ipyconsole.get_current_shellwidget()
+    qtbot.waitUntil(lambda: shell._prompt_html is not None, timeout=SHELL_TIMEOUT)
+    with qtbot.waitSignal(shell.executed):
+        shell.execute('a = 10')
+
+    # Assert that there are no variables in the variable explorer
+    main_window.variableexplorer.visibility_changed(True)
+    nsb = main_window.variableexplorer.get_focus_widget()
+    qtbot.wait(500)
+    assert nsb.editor.model.rowCount() == 0
+
+    # Test with a kernel from Spyder
+    spykm, spykc = start_new_kernel(spykernel=True)
+    main_window.ipyconsole._create_client_for_kernel(spykc.connection_file, None,
+                                                     None, None)
+    shell = main_window.ipyconsole.get_current_shellwidget()
+    qtbot.waitUntil(lambda: shell._prompt_html is not None, timeout=SHELL_TIMEOUT)
+    with qtbot.waitSignal(shell.executed):
+        shell.execute('a = 10')
+
+    # Assert that a variable is visible in the variable explorer
+    main_window.variableexplorer.visibility_changed(True)
+    nsb = main_window.variableexplorer.get_focus_widget()
+    qtbot.wait(500)
+    assert nsb.editor.model.rowCount() == 1
+
+    # Shutdown the kernels
+    spykm.shutdown_kernel(now=True)
+    km.shutdown_kernel(now=True)
+
+
 @flaky(max_runs=3)
 @pytest.mark.skipif(os.name == 'nt', reason="It times out sometimes on Windows")
 def test_np_threshold(main_window, qtbot):
@@ -156,39 +251,6 @@ def test_change_types_in_varexp(main_window, qtbot):
     assert shell.get_value('a') == 10
 
 
-@flaky(max_runs=3)
-@pytest.mark.skipif(os.name != 'nt' or not PY2,
-                    reason="It times out on Linux and Python 3")
-@pytest.mark.timeout(timeout=60, method='thread')
-@pytest.mark.use_introspection
-def test_calltip(main_window, qtbot):
-    """Hide the calltip in the editor when a matching ')' is found."""
-    # Load test file
-    text = 'a = [1,2,3]\n(max'
-    main_window.editor.new(fname="test.py", text=text)
-    code_editor = main_window.editor.get_focus_widget()
-    
-    # Set text to start
-    code_editor.set_text(text)
-    code_editor.go_to_line(2)
-    code_editor.move_cursor(5)
-    calltip = code_editor.calltip_widget
-    assert not calltip.isVisible()
-
-    qtbot.keyPress(code_editor, Qt.Key_ParenLeft, delay=3000)
-    qtbot.keyPress(code_editor, Qt.Key_A, delay=1000)
-    qtbot.waitUntil(lambda: calltip.isVisible(), timeout=1000)
-
-    qtbot.keyPress(code_editor, Qt.Key_ParenRight, delay=1000)
-    qtbot.keyPress(code_editor, Qt.Key_Space)
-    assert not calltip.isVisible()
-    qtbot.keyPress(code_editor, Qt.Key_ParenRight, delay=1000)
-    qtbot.keyPress(code_editor, Qt.Key_Enter, delay=1000)
-        
-    QTimer.singleShot(1000, lambda: close_save_message_box(qtbot))
-    main_window.editor.close_file()
-
-
 @flaky(max_runs=3)
 @pytest.mark.skipif(os.name == 'nt' or not is_module_installed('Cython'),
                     reason="It times out sometimes on Windows and Cython is needed")