diff --git a/RELEASE.md b/RELEASE.md index 44d0f73e7b7..1e1bda686cd 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -123,6 +123,7 @@ To release a new version of Spyder you need to follow these steps: * Update our subrepos with the following commands, but only if new versions are available! git subrepo pull external-deps/spyder-kernels + git subrepo pull external-deps/spyder-kernels-server git subrepo pull external-deps/python-lsp-server git subrepo pull external-deps/qtconsole diff --git a/external-deps/qtconsole/qtconsole/comms.py b/external-deps/qtconsole/qtconsole/comms.py index 7280e60db68..1f0e390ba2d 100644 --- a/external-deps/qtconsole/qtconsole/comms.py +++ b/external-deps/qtconsole/qtconsole/comms.py @@ -114,9 +114,9 @@ def get_comm(self, comm_id, closing=False): except KeyError: if closing: return - self.log.warning("No such comm: %s", comm_id) # don't create the list of keys if debug messages aren't enabled if self.log.isEnabledFor(logging.DEBUG): + self.log.debug("No such comm: %s", comm_id) self.log.debug("Current comms: %s", list(self.comms.keys())) # comm message handlers diff --git a/external-deps/qtconsole/qtconsole/jupyter_widget.py b/external-deps/qtconsole/qtconsole/jupyter_widget.py index cc9de8770b7..8922635db47 100644 --- a/external-deps/qtconsole/qtconsole/jupyter_widget.py +++ b/external-deps/qtconsole/qtconsole/jupyter_widget.py @@ -369,6 +369,9 @@ def _show_interpreter_prompt(self, number=None): """ # If a number was not specified, make a prompt number request. if number is None: + if self.kernel_client is None: + # Not connected yet + return if self._prompt_requested: # Already asked for prompt, avoid multiple prompts. return diff --git a/external-deps/spyder-kernels-server/.gitignore b/external-deps/spyder-kernels-server/.gitignore new file mode 100644 index 00000000000..570961b3a3a --- /dev/null +++ b/external-deps/spyder-kernels-server/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.egg-info +__pycache__ diff --git a/external-deps/spyder-kernels-server/README.md b/external-deps/spyder-kernels-server/README.md new file mode 100644 index 00000000000..30404ce4c54 --- /dev/null +++ b/external-deps/spyder-kernels-server/README.md @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/external-deps/spyder-kernels-server/setup.py b/external-deps/spyder-kernels-server/setup.py new file mode 100644 index 00000000000..916e66fba76 --- /dev/null +++ b/external-deps/spyder-kernels-server/setup.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Kernels Contributors +# +# Licensed under the terms of the MIT License +# (see spyder_kernels/__init__.py for details) +# ----------------------------------------------------------------------------- + +"""Jupyter Kernels for the Spyder consoles.""" + +# Standard library imports +import ast +import io +import os + +# Third party imports +from setuptools import find_packages, setup + +HERE = os.path.abspath(os.path.dirname(__file__)) + +with io.open('README.md', encoding='utf-8') as f: + LONG_DESCRIPTION = f.read() + + +def get_version(module='spyder_kernels_server'): + """Get version.""" + with open(os.path.join(HERE, module, '_version.py'), 'r') as f: + data = f.read() + lines = data.split('\n') + for line in lines: + if line.startswith('VERSION_INFO'): + version_tuple = ast.literal_eval(line.split('=')[-1].strip()) + version = '.'.join(map(str, version_tuple)) + break + return version + + +REQUIREMENTS = [ + 'spyder-kernels', +] + +setup( + name='spyder-kernels-server', + version=get_version(), + keywords='spyder jupyter kernel ipython console', + url='https://github.com/spyder-ide/spyder-kernels', + download_url="https://www.spyder-ide.org/#fh5co-download", + license='MIT', + author='Spyder Development Team', + author_email="spyderlib@googlegroups.com", + description="Jupyter kernels for Spyder's console", + long_description=LONG_DESCRIPTION, + long_description_content_type='text/markdown', + packages=find_packages(exclude=['docs', '*tests']), + install_requires=REQUIREMENTS, + # extras_require={'test': TEST_REQUIREMENTS}, + include_package_data=True, + python_requires='>=3.7', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Framework :: Jupyter', + 'Framework :: IPython', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Topic :: Software Development :: Interpreters', + ] +) diff --git a/external-deps/spyder-kernels-server/spyder_kernels_server/__init__.py b/external-deps/spyder-kernels-server/spyder_kernels_server/__init__.py new file mode 100644 index 00000000000..40a96afc6ff --- /dev/null +++ b/external-deps/spyder-kernels-server/spyder_kernels_server/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/external-deps/spyder-kernels-server/spyder_kernels_server/__main__.py b/external-deps/spyder-kernels-server/spyder_kernels_server/__main__.py new file mode 100644 index 00000000000..1ab04d18cef --- /dev/null +++ b/external-deps/spyder-kernels-server/spyder_kernels_server/__main__.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Kernels Contributors +# +# Licensed under the terms of the MIT License +# (see spyder_kernels/__init__.py for details) +# ----------------------------------------------------------------------------- +import sys +import zmq +import json +import argparse +import traceback +from spyder_kernels_server.kernel_server import KernelServer +from zmq.ssh import tunnel as zmqtunnel +from qtpy.QtWidgets import QApplication +from qtpy.QtCore import QSocketNotifier, QObject, QCoreApplication, Slot +from spyder_kernels_server.kernel_spec import get_kernel_spec + + +class Server(QObject): + def __init__(self, main_port=None, pub_port=None): + super().__init__() + + if main_port is None: + main_port = str(zmqtunnel.select_random_ports(1)[0]) + + context = zmq.Context() + self.socket = context.socket(zmq.REP) + self.socket.bind("tcp://*:%s" % main_port) + print(f"Server running on port {main_port}") + self.kernel_server = KernelServer() + + self._notifier = QSocketNotifier( + self.socket.getsockopt(zmq.FD), QSocketNotifier.Read, self + ) + self._notifier.activated.connect(self._socket_activity) + + self.port_pub = pub_port + if pub_port is None: + self.port_pub = str(zmqtunnel.select_random_ports(1)[0]) + self.socket_pub = context.socket(zmq.PUB) + self.socket_pub.bind("tcp://*:%s" % self.port_pub) + + self.kernel_server.sig_kernel_restarted.connect( + self._handle_kernel_restarted + ) + self.kernel_server.sig_stderr.connect( + self._handle_stderr + ) + self.kernel_server.sig_stdout.connect( + self._handle_stdout + ) + + def _socket_activity(self): + self._notifier.setEnabled(False) + # Wait for next request from client + message = self.socket.recv_pyobj() + cmd = message[0] + if cmd == "get_port_pub": + self.socket.send_pyobj(["set_port_pub", self.port_pub]) + elif cmd == "shutdown": + self.socket.send_pyobj(["shutting_down"]) + self.kernel_server.shutdown() + QCoreApplication.instance().quit() + + elif cmd == "open_kernel": + try: + kernel_spec = get_kernel_spec(message[1]) + cf = self.kernel_server.open_kernel(kernel_spec) + with open(cf, "br") as f: + cf = (cf, json.load(f)) + except Exception: + cf = ("error", traceback.format_exc()) + self.socket.send_pyobj(["new_kernel", *cf]) + + elif cmd == "close_kernel": + self.socket.send_pyobj(["closing_kernel"]) + try: + self.kernel_server.close_kernel(message[1]) + except Exception: + pass + + self._notifier.setEnabled(True) + + # This is necessary for some reason. + # Otherwise the socket only works twice ! + self.socket.getsockopt(zmq.EVENTS) + + @Slot(str) + def _handle_kernel_restarted(self, connection_file): + self.socket_pub.send_pyobj(["kernel_restarted", connection_file]) + + @Slot(str, str) + def _handle_stderr(self, connection_file, txt): + self.socket_pub.send_pyobj(["stderr", connection_file, txt]) + + @Slot(str, str) + def _handle_stdout(self, connection_file, txt): + self.socket_pub.send_pyobj(["stdout", connection_file, txt]) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + prog="Spyder Kernels Server", + description="Server to start and manage spyder kernels", + ) + + parser.add_argument("port", default=None, nargs="?") + parser.add_argument("port_pub", default=None, nargs="?") + parser.add_argument("-i", "--interactive", action="store_true") + args = parser.parse_args() + if args.interactive: + app = QApplication(sys.argv) + else: + app = QCoreApplication(sys.argv) + w = Server(args.port, args.port_pub) + sys.exit(app.exec_()) diff --git a/external-deps/spyder-kernels-server/spyder_kernels_server/_version.py b/external-deps/spyder-kernels-server/spyder_kernels_server/_version.py new file mode 100644 index 00000000000..da01d2e7afa --- /dev/null +++ b/external-deps/spyder-kernels-server/spyder_kernels_server/_version.py @@ -0,0 +1,12 @@ +# +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Kernels Contributors +# +# Licensed under the terms of the MIT License +# (see spyder_kernels/__init__.py for details) +# ----------------------------------------------------------------------------- + +"""Version File.""" + +VERSION_INFO = (1, 0, 0, "dev0") +__version__ = ".".join(map(str, VERSION_INFO)) diff --git a/external-deps/spyder-kernels-server/spyder_kernels_server/conda_utils.py b/external-deps/spyder-kernels-server/spyder_kernels_server/conda_utils.py new file mode 100644 index 00000000000..a7869df4e25 --- /dev/null +++ b/external-deps/spyder-kernels-server/spyder_kernels_server/conda_utils.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- + +import sys +import os +import os.path as osp +from glob import glob +import itertools +import locale + +WINDOWS = os.name == 'nt' +PREFERRED_ENCODING = locale.getpreferredencoding() + + +def is_different_interpreter(pyexec): + """Check that pyexec is a different interpreter from sys.executable.""" + # Paths may be symlinks + real_pyexe = osp.realpath(pyexec) + real_sys_exe = osp.realpath(sys.executable) + executable_validation = osp.basename(real_pyexe).startswith('python') + directory_validation = osp.dirname(real_pyexe) != osp.dirname(real_sys_exe) + return directory_validation and executable_validation + + +def add_quotes(path): + """Return quotes if needed for spaces on path.""" + quotes = '"' if ' ' in path and '"' not in path else '' + return '{quotes}{path}{quotes}'.format(quotes=quotes, path=path) + + +def get_conda_env_path(pyexec, quote=False): + """ + Return the full path to the conda environment from give python executable. + + If `quote` is True, then quotes are added if spaces are found in the path. + """ + pyexec = pyexec.replace('\\', '/') + if os.name == 'nt': + conda_env = os.path.dirname(pyexec) + else: + conda_env = os.path.dirname(os.path.dirname(pyexec)) + + if quote: + conda_env = add_quotes(conda_env) + + return conda_env + + +def is_conda_env(prefix=None, pyexec=None): + """Check if prefix or python executable are in a conda environment.""" + if pyexec is not None: + pyexec = pyexec.replace('\\', '/') + + if (prefix is None and pyexec is None) or (prefix and pyexec): + raise ValueError('Only `prefix` or `pyexec` should be provided!') + + if pyexec and prefix is None: + prefix = get_conda_env_path(pyexec).replace('\\', '/') + + return os.path.exists(os.path.join(prefix, 'conda-meta')) + + +def is_conda_based_app(pyexec=sys.executable): + """ + Check if Spyder is running from the conda-based installer by looking for + the `spyder-menu.json` file. + + If a Python executable is provided, checks if it is in a conda-based + installer environment or the root environment thereof. + """ + real_pyexec = osp.realpath(pyexec) # pyexec may be symlink + if os.name == 'nt': + env_path = osp.dirname(real_pyexec) + else: + env_path = osp.dirname(osp.dirname(real_pyexec)) + + menu_rel_path = '/Menu/spyder-menu.json' + if ( + osp.exists(env_path + menu_rel_path) + or glob(env_path + '/envs/*' + menu_rel_path) + ): + return True + else: + return False + + +def is_text_string(obj): + """Return True if `obj` is a text string, False if it is anything else, + like binary data (Python 3) or QString (PyQt API #1)""" + return isinstance(obj, str) + + +def is_binary_string(obj): + """Return True if `obj` is a binary string, False if it is anything else""" + return isinstance(obj, bytes) + + +def is_string(obj): + """Return True if `obj` is a text or binary Python string object, + False if it is anything else, like a QString (PyQt API #1)""" + return is_text_string(obj) or is_binary_string(obj) + + +def to_text_string(obj, encoding=None): + """Convert `obj` to (unicode) text string""" + if encoding is None: + return str(obj) + elif isinstance(obj, str): + # In case this function is not used properly, this could happen + return obj + else: + return str(obj, encoding) + + +# The default encoding for file paths and environment variables should be set +# to match the default encoding that the OS is using. +def getfilesystemencoding(): + """ + Query the filesystem for the encoding used to encode filenames + and environment variables. + """ + encoding = sys.getfilesystemencoding() + if encoding is None: + # Must be Linux or Unix and nl_langinfo(CODESET) failed. + encoding = PREFERRED_ENCODING + return encoding + + +FS_ENCODING = getfilesystemencoding() + + +def to_unicode_from_fs(string): + """ + Return a unicode version of string decoded using the file system encoding. + """ + if not is_string(string): # string is a QString + string = to_text_string(string.toUtf8(), 'utf-8') + else: + if is_binary_string(string): + try: + unic = string.decode(FS_ENCODING) + except (UnicodeError, TypeError): + pass + else: + return unic + return string + + +def get_home_dir(): + """Return user home directory.""" + try: + # expanduser() returns a raw byte string which needs to be + # decoded with the codec that the OS is using to represent + # file paths. + path = to_unicode_from_fs(osp.expanduser('~')) + except Exception: + path = '' + + if osp.isdir(path): + return path + else: + # Get home from alternative locations + for env_var in ('HOME', 'USERPROFILE', 'TMP'): + # os.environ.get() returns a raw byte string which needs to be + # decoded with the codec that the OS is using to represent + # environment variables. + path = to_unicode_from_fs(os.environ.get(env_var, '')) + if osp.isdir(path): + return path + else: + path = '' + + if not path: + raise RuntimeError('Please set the environment variable HOME to ' + 'your user/home directory path so Spyder can ' + 'start properly.') + + +def is_program_installed(basename): + """ + Return program absolute path if installed in PATH. + Otherwise, return None. + + Also searches specific platform dependent paths that are not already in + PATH. This permits general use without assuming user profiles are + sourced (e.g. .bash_Profile), such as when login shells are not used to + launch Spyder. + + On macOS systems, a .app is considered installed if it exists. + """ + home = get_home_dir() + req_paths = [] + if ( + sys.platform == 'darwin' + and basename.endswith('.app') + and osp.exists(basename) + ): + return basename + + if os.name == 'posix': + pyenv = [ + osp.join(home, '.pyenv', 'bin'), + osp.join('/usr', 'local', 'bin'), + ] + + a = [home, osp.join(home, 'opt'), '/opt'] + b = ['mambaforge', 'miniforge3', 'miniforge', + 'miniconda3', 'anaconda3', 'miniconda', 'anaconda'] + else: + pyenv = [osp.join(home, '.pyenv', 'pyenv-win', 'bin')] + + a = [home, osp.join(home, 'AppData', 'Local'), + 'C:\\', osp.join('C:\\', 'ProgramData')] + b = ['Mambaforge', 'Miniforge3', 'Miniforge', + 'Miniconda3', 'Anaconda3', 'Miniconda', 'Anaconda'] + + conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] + req_paths.extend(pyenv + conda) + + for path in os.environ['PATH'].split(os.pathsep) + req_paths: + abspath = osp.join(path, basename) + if osp.isfile(abspath): + return abspath + + +def find_program(basename): + """ + Find program in PATH and return absolute path + + Try adding .exe or .bat to basename on Windows platforms + (return None if not found) + """ + names = [basename] + if os.name == 'nt': + # Windows platforms + extensions = ('.exe', '.bat', '.cmd') + if not basename.endswith(extensions): + names = [basename + ext for ext in extensions] + [basename] + for name in names: + path = is_program_installed(name) + if path: + return path + + +def find_conda(): + """Find conda executable.""" + conda = None + + # First try Spyder's conda executable + if is_conda_based_app(): + root = osp.dirname(os.environ['CONDA_EXE']) + conda = osp.join(root, 'mamba.exe' if WINDOWS else 'mamba') + + # Next try the environment variables + if conda is None: + conda = os.environ.get('CONDA_EXE') or os.environ.get('MAMBA_EXE') + + # Next try searching for the executable + if conda is None: + conda_exec = 'conda.bat' if WINDOWS else 'conda' + conda = find_program(conda_exec) + + return conda diff --git a/spyder/plugins/ipythonconsole/utils/client.py b/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_client.py similarity index 81% rename from spyder/plugins/ipythonconsole/utils/client.py rename to external-deps/spyder-kernels-server/spyder_kernels_server/kernel_client.py index 74674040bd5..8cee02ccb34 100644 --- a/spyder/plugins/ipythonconsole/utils/client.py +++ b/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_client.py @@ -18,10 +18,9 @@ class SpyderKernelClient(QtKernelClient): # Enable receiving messages on control channel. # Useful for pdb completion control_channel_class = Type(QtZMQSocketChannel) - sig_spyder_kernel_info = Signal(object) + sig_kernel_info = Signal(object) def _handle_kernel_info_reply(self, rep): """Check spyder-kernels version.""" 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) + self.sig_kernel_info.emit(rep) diff --git a/spyder/plugins/ipythonconsole/comms/kernelcomm.py b/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_comm.py similarity index 71% rename from spyder/plugins/ipythonconsole/comms/kernelcomm.py rename to external-deps/spyder-kernels-server/spyder_kernels_server/kernel_comm.py index a229d83b763..85c46fa21d0 100644 --- a/spyder/plugins/ipythonconsole/comms/kernelcomm.py +++ b/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_comm.py @@ -10,19 +10,28 @@ """ from contextlib import contextmanager import logging +import os import pickle from qtpy.QtCore import QEventLoop, QObject, QTimer, Signal from spyder_kernels.comms.commbase import CommBase -from spyder.config.base import ( - get_debug_level, running_under_pytest) - logger = logging.getLogger(__name__) TIMEOUT_KERNEL_START = 30 +def get_debug_level(): + debug_env = os.environ.get("SPYDER_DEBUG", "") + if not debug_env.isdigit(): + debug_env = bool(debug_env) + return int(debug_env) + + +def running_under_pytest(): + return bool(os.environ.get("SPYDER_PYTEST")) + + class KernelComm(CommBase, QObject): """ Class with the necessary attributes and methods to handle @@ -38,8 +47,8 @@ def __init__(self): self.kernel_client = None # Register handlers - self.register_call_handler('_async_error', self._async_error) - self.register_call_handler('_comm_ready', self._comm_ready) + self.register_call_handler("_async_error", self._async_error) + self.register_call_handler("_comm_ready", self._comm_ready) def is_open(self, comm_id=None): """ @@ -48,7 +57,7 @@ def is_open(self, comm_id=None): id_list = self.get_comm_id_list(comm_id) if len(id_list) == 0: return False - return all([self._comms[cid]['status'] == 'ready' for cid in id_list]) + return all([self._comms[cid]["status"] == "ready" for cid in id_list]) @contextmanager def comm_channel_manager(self, comm_id, queue_message=False): @@ -60,24 +69,27 @@ def comm_channel_manager(self, comm_id, queue_message=False): id_list = self.get_comm_id_list(comm_id) for comm_id in id_list: - self._comms[comm_id]['comm']._send_channel = ( - self.kernel_client.control_channel) + self._comms[comm_id][ + "comm" + ]._send_channel = self.kernel_client.control_channel try: yield finally: id_list = self.get_comm_id_list(comm_id) for comm_id in id_list: - self._comms[comm_id]['comm']._send_channel = ( - self.kernel_client.shell_channel) + self._comms[comm_id][ + "comm" + ]._send_channel = self.kernel_client.shell_channel def _set_call_return_value(self, call_dict, data, is_error=False): """Override to use the comm_channel for all replies.""" with self.comm_channel_manager(self.calling_comm_id, False): if is_error and (get_debug_level() or running_under_pytest()): # Disable error muting when debugging or testing - call_dict['settings']['display_error'] = True + call_dict["settings"]["display_error"] = True super(KernelComm, self)._set_call_return_value( - call_dict, data, is_error) + call_dict, data, is_error + ) def remove(self, comm_id=None, only_closing=False): """ @@ -87,7 +99,7 @@ def remove(self, comm_id=None, only_closing=False): """ 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': + if only_closing and self._comms[comm_id]["status"] != "closing": continue del self._comms[comm_id] @@ -96,9 +108,10 @@ def close(self, comm_id=None): id_list = self.get_comm_id_list(comm_id) for comm_id in id_list: # Send comm_close directly to avoid really closing the comm - self._comms[comm_id]['comm']._send_msg( - 'comm_close', {}, None, None, None) - self._comms[comm_id]['status'] = 'closing' + self._comms[comm_id]["comm"]._send_msg( + "comm_close", {}, None, None, None + ) + self._comms[comm_id]["status"] = "closing" def open_comm(self, kernel_client): """Open comm through the kernel client.""" @@ -106,20 +119,36 @@ def open_comm(self, kernel_client): try: self._register_comm( # Create new comm and send the highest protocol - kernel_client.comm_manager.new_comm(self._comm_name, data={ - 'pickle_highest_protocol': pickle.HIGHEST_PROTOCOL})) + kernel_client.comm_manager.new_comm( + self._comm_name, + data={"pickle_highest_protocol": pickle.HIGHEST_PROTOCOL}, + ) + ) except AttributeError: logger.info( - "Unable to open comm due to unexistent comm manager: " + - "kernel_client.comm_manager=" + str(kernel_client.comm_manager) + "Unable to open comm due to unexistent comm manager: " + + "kernel_client.comm_manager=" + + str(kernel_client.comm_manager) ) - def remote_call(self, interrupt=False, blocking=False, callback=None, - comm_id=None, timeout=None, display_error=False): + def remote_call( + self, + interrupt=False, + blocking=False, + callback=None, + comm_id=None, + timeout=None, + display_error=False, + ): """Get a handler for remote calls.""" return super(KernelComm, self).remote_call( - interrupt=interrupt, blocking=blocking, callback=callback, - comm_id=comm_id, timeout=timeout, display_error=display_error) + interrupt=interrupt, + blocking=blocking, + callback=callback, + comm_id=comm_id, + timeout=timeout, + display_error=display_error, + ) def on_incoming_call(self, call_dict): """A call was received""" @@ -130,15 +159,15 @@ def on_incoming_call(self, call_dict): # ---- Private ----- def _comm_ready(self): """If this function is called, the comm is ready""" - if self._comms[self.calling_comm_id]['status'] != 'ready': - self._comms[self.calling_comm_id]['status'] = 'ready' + if self._comms[self.calling_comm_id]["status"] != "ready": + self._comms[self.calling_comm_id]["status"] = "ready" self.sig_comm_ready.emit() def _send_call(self, call_dict, call_data, comm_id): """Send call and interupt the kernel if needed.""" - settings = call_dict['settings'] - blocking = 'blocking' in settings and settings['blocking'] - interrupt = 'interrupt' in settings and settings['interrupt'] + settings = call_dict["settings"] + blocking = "blocking" in settings and settings["blocking"] + interrupt = "interrupt" in settings and settings["interrupt"] queue_message = not interrupt and not blocking if not self.kernel_client.is_alive(): @@ -148,14 +177,14 @@ def _send_call(self, call_dict, call_data, comm_id): # The user has other problems logger.info( "Dropping message because kernel is dead: %s", - str(call_dict) + str(call_dict), ) return - with self.comm_channel_manager( - comm_id, queue_message=queue_message): + with self.comm_channel_manager(comm_id, queue_message=queue_message): return super(KernelComm, self)._send_call( - call_dict, call_data, comm_id) + call_dict, call_data, comm_id + ) def _get_call_return_value(self, call_dict, comm_id): """ @@ -163,10 +192,11 @@ def _get_call_return_value(self, call_dict, comm_id): """ try: return super(KernelComm, self)._get_call_return_value( - call_dict, comm_id) + call_dict, comm_id + ) except RuntimeError as e: - settings = call_dict['settings'] - blocking = 'blocking' in settings and settings['blocking'] + settings = call_dict["settings"] + blocking = "blocking" in settings and settings["blocking"] if blocking: raise else: @@ -174,7 +204,7 @@ def _get_call_return_value(self, call_dict, comm_id): logger.info( "Dropping message because of exception: ", str(e), - str(call_dict) + str(call_dict), ) return @@ -185,7 +215,8 @@ def got_reply(): return call_id in self._reply_inbox timeout_msg = "Timeout while waiting for {}".format( - self._reply_waitlist) + self._reply_waitlist + ) self._wait(got_reply, self._sig_got_reply, timeout_msg, timeout) def _wait(self, condition, signal, timeout_msg, timeout): @@ -218,7 +249,8 @@ def _wait(self, condition, signal, timeout_msg, timeout): if not wait_timeout.isActive(): signal.disconnect(wait_loop.quit) self.kernel_client.hb_channel.kernel_died.disconnect( - wait_loop.quit) + wait_loop.quit + ) if condition(): return if not self.kernel_client.is_alive(): @@ -228,15 +260,13 @@ def _wait(self, condition, signal, timeout_msg, timeout): wait_timeout.stop() signal.disconnect(wait_loop.quit) - self.kernel_client.hb_channel.kernel_died.disconnect( - wait_loop.quit) + self.kernel_client.hb_channel.kernel_died.disconnect(wait_loop.quit) def _handle_remote_call_reply(self, msg_dict, buffer): """ A blocking call received a reply. """ - super(KernelComm, self)._handle_remote_call_reply( - msg_dict, buffer) + super(KernelComm, self)._handle_remote_call_reply(msg_dict, buffer) self._sig_got_reply.emit() def _async_error(self, error_wrapper): diff --git a/spyder/plugins/ipythonconsole/utils/manager.py b/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_manager.py similarity index 91% rename from spyder/plugins/ipythonconsole/utils/manager.py rename to external-deps/spyder-kernels-server/spyder_kernels_server/kernel_manager.py index a1604a6418e..929a4a0161a 100644 --- a/spyder/plugins/ipythonconsole/utils/manager.py +++ b/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_manager.py @@ -30,15 +30,21 @@ class SpyderKernelManager(QtKernelManager): """ client_class = DottedObjectName( - 'spyder.plugins.ipythonconsole.utils.client.SpyderKernelClient') + "spyder_kernels_server.kernel_client.SpyderKernelClient" + ) def __init__(self, *args, **kwargs): self.shutting_down = False return QtKernelManager.__init__(self, *args, **kwargs) @staticmethod - async def kill_proc_tree(pid, sig=signal.SIGTERM, include_parent=True, - timeout=None, on_terminate=None): + async def kill_proc_tree( + pid, + sig=signal.SIGTERM, + include_parent=True, + timeout=None, + on_terminate=None, + ): """ Kill a process tree (including grandchildren) with sig and return a (gone, still_alive) tuple. @@ -101,13 +107,16 @@ async def _async_kill_kernel(self, restart: bool = False) -> None: # Wait until the kernel terminates. import asyncio + try: await asyncio.wait_for(self._async_wait(), timeout=5.0) except asyncio.TimeoutError: # Wait timed out, just log warning but continue # - not much more we can do. - self.log.warning("Wait for final termination of kernel timed" - " out - continuing...") + self.log.warning( + "Wait for final termination of kernel timed" + " out - continuing..." + ) pass else: # Process is no longer alive, wait and clear diff --git a/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_server.py b/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_server.py new file mode 100644 index 00000000000..0326c0044c5 --- /dev/null +++ b/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_server.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Kernels Contributors +# +# Licensed under the terms of the MIT License +# (see spyder_kernels/__init__.py for details) +# ----------------------------------------------------------------------------- + +"""Jupyter Kernels for the Spyder consoles.""" + +# Standard library imports +import os +import os.path as osp +from subprocess import PIPE +import uuid + +from threading import Thread, Event + + +# Third-party imports +from jupyter_core.paths import jupyter_runtime_dir +from qtpy.QtCore import QObject, Signal, QThread + +from spyder_kernels_server.kernel_manager import SpyderKernelManager + + +PERMISSION_ERROR_MSG = ( + "The directory {} is not writable and it is required to create IPython " + "consoles. Please make it writable." +) + + +# kernel_comm needs a qthread +class StdThread(QThread): + """Poll for changes in std buffers.""" + + sig_text = Signal(str) + + def __init__(self, std_buffer): + self._std_buffer = std_buffer + self.closing = Event() + super().__init__() + + def run(self): + txt = True + while txt: + txt = self._std_buffer.read1() + if self.closing.is_set(): + return + if txt: + try: + txt = txt.decode() + except UnicodeDecodeError: + txt = str(txt) + self.sig_text.emit(txt) + + +class ShutdownThread(Thread): + def __init__(self, kernel_dict): + self.kernel_dict = kernel_dict + super().__init__() + + def run(self): + """Shutdown kernel.""" + kernel_manager = self.kernel_dict["kernel"] + + if "stdout" in self.kernel_dict: + self.kernel_dict["stdout"].closing.set() + if "stderr" in self.kernel_dict: + self.kernel_dict["stderr"].closing.set() + + if not kernel_manager.shutting_down: + kernel_manager.shutting_down = True + try: + kernel_manager.shutdown_kernel() + except Exception: + # kernel was externally killed + pass + if "stdout" in self.kernel_dict: + self.kernel_dict["stdout"].wait() + if "stderr" in self.kernel_dict: + self.kernel_dict["stderr"].wait() + + +class KernelServer(QObject): + + sig_kernel_restarted = Signal(str) + sig_stdout = Signal(str, str) + sig_stderr = Signal(str, str) + + def __init__(self): + super().__init__() + self._kernel_list = {} + + @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 + + def open_kernel(self, kernel_spec): + """ + Create a new kernel. + + Might raise all kinds of exceptions + """ + connection_file = self.new_connection_file() + if connection_file is None: + raise RuntimeError( + PERMISSION_ERROR_MSG.format(jupyter_runtime_dir()) + ) + + # Kernel manager + kernel_manager = SpyderKernelManager( + connection_file=connection_file, + config=None, + autorestart=True, + ) + + kernel_manager._kernel_spec = kernel_spec + + kernel_manager.start_kernel( + stderr=PIPE, + stdout=PIPE, + env=kernel_spec.env, + ) + + kernel_key = connection_file + self._kernel_list[kernel_key] = { + "kernel": kernel_manager, + } + self.connect_std_pipes(kernel_key) + + kernel_manager.kernel_restarted.connect( + lambda connection_file=connection_file: + self.sig_kernel_restarted.emit(connection_file) + ) + + return connection_file + + def connect_std_pipes(self, kernel_key): + """Connect to std pipes.""" + + kernel_manager = self._kernel_list[kernel_key]["kernel"] + stdout = kernel_manager.provisioner.process.stdout + stderr = kernel_manager.provisioner.process.stderr + + if stdout: + stdout_thread = StdThread(stdout) + stdout_thread.sig_text.connect( + lambda txt, connection_file=kernel_key: self.sig_stdout.emit( + connection_file, txt + )) + stdout_thread.start() + self._kernel_list[kernel_key]["stdout"] = stdout_thread + if stderr: + stderr_thread = StdThread(stderr) + stderr_thread.sig_text.connect( + lambda txt, connection_file=kernel_key: self.sig_stderr.emit( + connection_file, txt + )) + stderr_thread.start() + self._kernel_list[kernel_key]["stderr"] = stderr_thread + + def close_kernel(self, kernel_key): + """Close kernel""" + kernel_manager = self._kernel_list[kernel_key]["kernel"] + kernel_manager.stop_restarter() + shutdown_thread = ShutdownThread(self._kernel_list.pop(kernel_key)) + shutdown_thread.start() + + def shutdown(self): + kernel_key_list = list(self._kernel_list) + for kernel_key in kernel_key_list: + self.close_kernel(kernel_key) diff --git a/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_spec.py b/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_spec.py new file mode 100644 index 00000000000..22ce98ddafc --- /dev/null +++ b/external-deps/spyder-kernels-server/spyder_kernels_server/kernel_spec.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +import sys + +from jupyter_client.kernelspec import KernelSpec + +from .conda_utils import ( + is_conda_env, get_conda_env_path, find_conda, is_different_interpreter +) + + +def get_python_executable(): + """Return path to Spyder Python executable""" + executable = sys.executable.replace("pythonw.exe", "python.exe") + if executable.endswith("spyder.exe"): + # py2exe distribution + executable = "python.exe" + return executable + + +def get_kernel_spec(kernel_spec_dict): + + kernel_spec = KernelSpec() + for key in kernel_spec_dict: + setattr(kernel_spec, key, kernel_spec_dict[key]) + + # Python interpreter used to start kernels + if (kernel_spec.pyexec is None): + pyexec = get_python_executable() + else: + pyexec = kernel_spec.pyexec + + # Command used to start kernels + kernel_cmd = [ + pyexec, + # This is necessary to avoid a spurious message on Windows. + # Fixes spyder-ide/spyder#20800. + '-Xfrozen_modules=off', + '-m', 'spyder_kernels.console', + '-f', '{connection_file}' + ] + + # Part of spyder-ide/spyder#11819 + is_different = is_different_interpreter(pyexec) + if is_different and is_conda_env(pyexec=pyexec): + # If executable is a conda environment and different from Spyder's + # runtime environment, we need to activate the environment to run + # spyder-kernels + kernel_cmd[:0] = [ + find_conda(), 'run', + '-p', get_conda_env_path(pyexec), + ] + kernel_spec.argv = kernel_cmd + + return kernel_spec diff --git a/external-deps/spyder-kernels/.gitrepo b/external-deps/spyder-kernels/.gitrepo index c8748bc0500..8bd3e1960b4 100644 --- a/external-deps/spyder-kernels/.gitrepo +++ b/external-deps/spyder-kernels/.gitrepo @@ -5,8 +5,8 @@ ; [subrepo] remote = https://github.com/spyder-ide/spyder-kernels.git - branch = master - commit = d28282944b5fc5186fa88fc63230a50d18bfeea5 - parent = 78d244292a5ffe1c79152a0b5e51ba10d25509e3 + branch = print_remote + commit = a33dfc86975704607247c7f7fdbecfe77426f750 + parent = 912a1da04c51ff6c860a24ea06c45eaa851fa849 method = merge cmdver = 0.4.3 diff --git a/spyder/app/tests/conftest.py b/spyder/app/tests/conftest.py index b1d1232cd19..2185982a109 100755 --- a/spyder/app/tests/conftest.py +++ b/spyder/app/tests/conftest.py @@ -27,14 +27,13 @@ from spyder.app import start from spyder.config.base import get_home_dir, running_in_ci from spyder.config.manager import CONF -from spyder.plugins.ipythonconsole.utils.kernelspec import SpyderKernelSpec from spyder.plugins.projects.api import EmptyProject from spyder.plugins.run.api import RunActions, StoredRunConfigurationExecutor from spyder.plugins.toolbar.api import ApplicationToolbars from spyder.utils import encoding from spyder.utils.environ import (get_user_env, set_user_env, amend_user_shell_init) - +from spyder_kernels_server.kernel_spec import get_kernel_spec # ============================================================================= # ---- Constants @@ -84,12 +83,14 @@ 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): +def start_new_kernel(ipyconsole, 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._kernel_spec = get_kernel_spec( + ipyconsole.get_widget().get_kernel_spec_dict() + ) km.start_kernel(**kwargs) kc = km.client() kc.start_channels() diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index 216e3c333c3..22dde27d56c 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -144,7 +144,6 @@ 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 - KernelHandler.wait_all_shutdown_threads() gc.collect() objects = gc.get_objects() n_code_editor_init = 0 @@ -179,7 +178,6 @@ def ns_fun(main_window, qtbot): main_window.ipyconsole.restart() # Wait until the shells are closed - KernelHandler.wait_all_shutdown_threads() return n_shell_init, n_code_editor_init n_shell_init, n_code_editor_init = ns_fun(main_window, qtbot) @@ -823,10 +821,10 @@ def test_dedicated_consoles(main_window, qtbot): qtbot.waitUntil(lambda: nsb.editor.source_model.rowCount() == 4) assert nsb.editor.source_model.rowCount() == 4 - # --- Assert only runfile text is present and there's no banner text --- + # --- Assert only runfile text is present --- # See spyder-ide/spyder#5301. text = control.toPlainText() - assert ('runfile' in text) and not ('Python' in text or 'IPython' in text) + assert ('runfile' in text) # --- Check namespace retention after re-execution --- with qtbot.waitSignal(shell.executed): @@ -937,7 +935,7 @@ def test_shell_execution(main_window, qtbot, tmpdir): 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() + km, kc = start_new_kernel(main_window.ipyconsole) main_window.ipyconsole.create_client_for_kernel(kc.connection_file) shell = main_window.ipyconsole.get_current_shellwidget() @@ -956,7 +954,7 @@ def test_connection_to_external_kernel(main_window, qtbot): python_shell = shell # Test with a kernel from Spyder - spykm, spykc = start_new_kernel(spykernel=True) + spykm, spykc = start_new_kernel(main_window.ipyconsole, spykernel=True) main_window.ipyconsole.create_client_for_kernel(spykc.connection_file) shell = main_window.ipyconsole.get_current_shellwidget() qtbot.waitUntil( @@ -1954,8 +1952,6 @@ def test_varexp_edit_inline(main_window, qtbot): reason="It times out sometimes on Windows and macOS") def test_c_and_n_pdb_commands(main_window, qtbot): """Test that c and n Pdb commands update the Variable Explorer.""" - nsb = main_window.variableexplorer.current_widget() - # Wait until the window is fully up shell = main_window.ipyconsole.get_current_shellwidget() control = shell._control @@ -1963,6 +1959,8 @@ def test_c_and_n_pdb_commands(main_window, qtbot): lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, timeout=SHELL_TIMEOUT) + nsb = main_window.variableexplorer.current_widget() + # Clear all breakpoints main_window.debugger.clear_all_breakpoints() @@ -2112,13 +2110,13 @@ def test_change_cwd_dbg(main_window, qtbot): @pytest.mark.skipif(os.name == 'nt', reason="Times out sometimes") def test_varexp_magic_dbg(main_window, qtbot): """Test that %varexp is working while debugging.""" - nsb = main_window.variableexplorer.current_widget() # Wait until the window is fully up shell = main_window.ipyconsole.get_current_shellwidget() qtbot.waitUntil( lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, timeout=SHELL_TIMEOUT) + nsb = main_window.variableexplorer.current_widget() # Load test file to be able to enter in debugging mode test_file = osp.join(LOCATION, 'script.py') @@ -2160,12 +2158,12 @@ def test_plots_plugin(main_window, qtbot, tmpdir, mocker): """ assert CONF.get('plots', 'mute_inline_plotting') is False shell = main_window.ipyconsole.get_current_shellwidget() - figbrowser = main_window.plots.current_widget() # Wait until the window is fully up. qtbot.waitUntil( lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, timeout=SHELL_TIMEOUT) + figbrowser = main_window.plots.current_widget() # Generate a plot inline. with qtbot.waitSignal(shell.executed): @@ -2199,12 +2197,12 @@ def test_plots_scroll(main_window, qtbot): """Test plots plugin scrolling""" CONF.set('plots', 'mute_inline_plotting', True) shell = main_window.ipyconsole.get_current_shellwidget() - figbrowser = main_window.plots.current_widget() # Wait until the window is fully up. qtbot.waitUntil( lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, timeout=SHELL_TIMEOUT) + figbrowser = main_window.plots.current_widget() # Generate a plot inline. with qtbot.waitSignal(shell.executed, timeout=SHELL_TIMEOUT): @@ -5168,7 +5166,7 @@ def test_outline_no_init(main_window, qtbot): def test_pdb_ipykernel(main_window, qtbot): """Check if pdb works without spyder kernel.""" # Test with a generic kernel - km, kc = start_new_kernel() + km, kc = start_new_kernel(main_window.ipyconsole) main_window.ipyconsole.create_client_for_kernel(kc.connection_file) ipyconsole = main_window.ipyconsole diff --git a/spyder/config/main.py b/spyder/config/main.py index 1458e45564c..bd6a2b29f9f 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -131,6 +131,16 @@ 'umr/namelist': [], 'custom_interpreters_list': [], 'custom_interpreter': '', + 'kernel_server/external_server': False, + 'kernel_server/use_ssh': False, + 'kernel_server/host': "127.0.0.1", + 'kernel_server/port': 5556, + 'kernel_server/username': "", + 'kernel_server/password_auth': True, + 'kernel_server/keyfile_auth': False, + 'kernel_server/password': "", + 'kernel_server/keyfile': "", + 'kernel_server/passphrase': "", }), ('ipython_console', { diff --git a/spyder/plugins/debugger/widgets/framesbrowser.py b/spyder/plugins/debugger/widgets/framesbrowser.py index 8e49f90ea7d..2be89221f95 100644 --- a/spyder/plugins/debugger/widgets/framesbrowser.py +++ b/spyder/plugins/debugger/widgets/framesbrowser.py @@ -294,6 +294,9 @@ def set_current_item(self, top_idx, sub_index): def on_config_kernel(self): """Ask shellwidget to send Pdb configuration to kernel.""" + self.shellwidget.kernel_handler.kernel_comm.register_call_handler( + "show_traceback", self.show_exception) + self.shellwidget.set_kernel_configuration("pdb", { 'breakpoints': self.get_conf("breakpoints", default={}), 'pdb_ignore_lib': self.get_conf('pdb_ignore_lib'), @@ -306,8 +309,6 @@ def on_config_kernel(self): def on_unconfig_kernel(self): """Ask shellwidget to stop sending stack.""" - if not self.shellwidget.spyder_kernel_ready: - return self.shellwidget.set_kernel_configuration( "pdb", {'pdb_publish_stack': False} ) diff --git a/spyder/plugins/debugger/widgets/main_widget.py b/spyder/plugins/debugger/widgets/main_widget.py index b3228564db1..13551ed2ecd 100644 --- a/spyder/plugins/debugger/widgets/main_widget.py +++ b/spyder/plugins/debugger/widgets/main_widget.py @@ -493,8 +493,6 @@ def create_new_widget(self, shellwidget): shellwidget.sig_pdb_prompt_ready.connect(self.update_actions) shellwidget.executing.connect(self.update_actions) - shellwidget.kernel_handler.kernel_comm.register_call_handler( - "show_traceback", widget.show_exception) shellwidget.sig_pdb_stack.connect(widget.set_from_pdb) shellwidget.sig_config_spyder_kernel.connect( widget.on_config_kernel) diff --git a/spyder/plugins/ipythonconsole/comms/tests/test_comms.py b/spyder/plugins/ipythonconsole/comms/tests/test_comms.py index 5cbfa44be80..8b751df8ccf 100644 --- a/spyder/plugins/ipythonconsole/comms/tests/test_comms.py +++ b/spyder/plugins/ipythonconsole/comms/tests/test_comms.py @@ -20,7 +20,7 @@ # Local imports from spyder_kernels.utils.test_utils import get_kernel from spyder_kernels.comms.frontendcomm import FrontendComm -from spyder.plugins.ipythonconsole.comms.kernelcomm import KernelComm +from spyder_kernels_server.kernel_comm import KernelComm # ============================================================================= diff --git a/spyder/plugins/ipythonconsole/tests/conftest.py b/spyder/plugins/ipythonconsole/tests/conftest.py index 13927b6f94a..593fde08917 100644 --- a/spyder/plugins/ipythonconsole/tests/conftest.py +++ b/spyder/plugins/ipythonconsole/tests/conftest.py @@ -323,9 +323,8 @@ def threads_condition(): raise try: - # -1 from closed client qtbot.waitUntil(lambda: ( - len(init_subprocesses) - 1 >= len(proc.children())), + len(init_subprocesses) >= len(proc.children())), timeout=SHELL_TIMEOUT) except Exception: subprocesses = [repr(f) for f in proc.children()] diff --git a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py index de62590f99a..f62916a619a 100644 --- a/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py +++ b/spyder/plugins/ipythonconsole/tests/test_ipythonconsole.py @@ -889,8 +889,6 @@ def test_restart_kernel(ipyconsole, mocker, qtbot): """ Test that kernel is restarted correctly """ - # Mock method we want to check - mocker.patch.object(ShellWidget, "send_spyder_kernel_configuration") ipyconsole.create_new_client() @@ -899,6 +897,10 @@ def test_restart_kernel(ipyconsole, mocker, qtbot): lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, timeout=SHELL_TIMEOUT) + # Check kernel is configured + faulthandler_args = shell.kernel_handler._fault_args + assert faulthandler_args is not None + # Do an assignment to verify that it's not there after restarting with qtbot.waitSignal(shell.executed): shell.execute('a = 10') @@ -922,8 +924,8 @@ def test_restart_kernel(ipyconsole, mocker, qtbot): assert not shell.is_defined('a') # Check that we send configuration at the beginning and after the restart. - qtbot.waitUntil( - lambda: ShellWidget.send_spyder_kernel_configuration.call_count == 2) + assert shell.kernel_handler._fault_args is not None + assert shell.kernel_handler._fault_args != faulthandler_args @flaky(max_runs=3) @@ -2076,7 +2078,7 @@ def test_old_kernel_version(ipyconsole, qtbot): w = ipyconsole.get_widget() kernel_handler = w._cached_kernel_properties[-1] - kernel_handler.kernel_client.sig_spyder_kernel_info.disconnect() + kernel_handler.kernel_client.sig_kernel_info.disconnect() # Wait until it is launched qtbot.waitUntil( @@ -2086,7 +2088,9 @@ def test_old_kernel_version(ipyconsole, qtbot): timeout=SHELL_TIMEOUT) # Set wrong version - kernel_handler.check_spyder_kernel_info(('1.0.0', '')) + kernel_handler.check_spyder_kernels_info( + {"content": {"spyder_kernels_info": ('1.0.0', '')}} + ) # Create new client w.create_new_client() diff --git a/spyder/plugins/ipythonconsole/utils/kernel_handler.py b/spyder/plugins/ipythonconsole/utils/kernel_handler.py index 986efddc756..595b2d27d9e 100644 --- a/spyder/plugins/ipythonconsole/utils/kernel_handler.py +++ b/spyder/plugins/ipythonconsole/utils/kernel_handler.py @@ -8,24 +8,20 @@ # Standard library imports import os -import os.path as osp -from subprocess import PIPE -from threading import Lock import uuid # Third-party imports -from jupyter_core.paths import jupyter_runtime_dir -from qtpy.QtCore import QObject, QThread, Signal, Slot +from qtpy.QtCore import QObject, Signal, QSocketNotifier, Slot from zmq.ssh import tunnel as zmqtunnel +import zmq # Local imports from spyder.api.translations import _ from spyder.plugins.ipythonconsole import ( 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_kernels_server.kernel_comm import KernelComm +from spyder_kernels_server.kernel_client import SpyderKernelClient from spyder.plugins.ipythonconsole.utils.ssh import openssh_tunnel from spyder.utils.programs import check_version_range @@ -86,33 +82,11 @@ class KernelConnectionState: Crashed = 'crashed' -class StdThread(QThread): - """Poll for changes in std buffers.""" - sig_out = Signal(str) - - def __init__(self, parent, std_buffer): - super().__init__(parent) - self._std_buffer = std_buffer - self._closing = False - - def run(self): - txt = True - while txt: - txt = self._std_buffer.read1() - if txt: - try: - txt = txt.decode() - except UnicodeDecodeError: - txt = str(txt) - self.sig_out.emit(txt) - - class KernelHandler(QObject): """ A class to handle the kernel in several ways and store kernel connection information. """ - sig_stdout = Signal(str) """ A stdout message was received on the process stdout. @@ -123,6 +97,11 @@ class KernelHandler(QObject): A stderr message was received on the process stderr. """ + sig_error = Signal(str) + """ + The process crashed with a message. + """ + sig_fault = Signal(str) """ A fault message was received. @@ -138,18 +117,20 @@ class KernelHandler(QObject): The kernel raised an error while connecting. """ - _shutdown_thread_list = [] - """List of running shutdown threads""" + sig_remote_close = Signal(str) + """ + Signal to request the kernel to be shut down + """ - _shutdown_thread_list_lock = Lock() + sig_kernel_restarted = Signal() """ - Lock to add threads to _shutdown_thread_list or clear that list. + The kernel has restarted """ def __init__( self, - connection_file, - kernel_manager=None, + connection_file=None, + kernel_spec_dict=None, kernel_client=None, known_spyder_kernel=False, hostname=None, @@ -159,7 +140,7 @@ def __init__( super().__init__() # Connection Informations self.connection_file = connection_file - self.kernel_manager = kernel_manager + self.kernel_spec_dict = kernel_spec_dict self.kernel_client = kernel_client self.known_spyder_kernel = known_spyder_kernel self.hostname = hostname @@ -174,28 +155,43 @@ def __init__( self.handle_comm_ready) # Internal - self._shutdown_lock = Lock() - self._stdout_thread = None - self._stderr_thread = None self._fault_args = None + self._init_error = "" self._init_stderr = "" self._init_stdout = "" self._shellwidget_connected = False self._comm_ready_received = False + self._kernel_info_msg = None # Start kernel - self.kernel_client.sig_spyder_kernel_info.connect( - self.check_spyder_kernel_info - ) - self.connect_std_pipes() - self.kernel_client.start_channels() + if self.kernel_client: + self.start_channels() - # Open comm and wait for comm ready reply. - # It only works for spyder-kernels, but this is the majority of cases. - # For ipykernels, this does nothing. - self.kernel_comm.open_comm(self.kernel_client) + @Slot(str, str) + def handle_error(self, err): + """Handle crash""" + if self._shellwidget_connected: + self.sig_error.emit(err) + else: + self._init_error += err - def connect_(self): + @Slot(str, str) + def handle_stderr(self, err): + """Handle stderr""" + if self._shellwidget_connected: + self.sig_stderr.emit(err) + else: + self._init_stderr += err + + @Slot(str, str) + def handle_stdout(self, out): + """Handle stdout""" + if self._shellwidget_connected: + self.sig_stdout.emit(out) + else: + self._init_stdout += out + + def connect(self): """Connect to shellwidget.""" self._shellwidget_connected = True # Emit signal in case the connection is already made @@ -207,6 +203,9 @@ def connect_(self): self.sig_kernel_connection_error.emit() # Show initial io + if self._init_error: + self.sig_error.emit(self._init_error) + self._init_error = None if self._init_stderr: self.sig_stderr.emit(self._init_stderr) self._init_stderr = None @@ -214,15 +213,16 @@ def connect_(self): self.sig_stdout.emit(self._init_stdout) self._init_stdout = None - def check_spyder_kernel_info(self, spyder_kernel_info): + def check_spyder_kernels_info(self, msg): """ Check if the Spyder-kernels version is the right one after receiving it from the kernel. If the kernel is non-locally managed, check if it is a spyder-kernel. """ - - if not spyder_kernel_info: + self._kernel_info_msg = msg + spyder_kernels_info = msg["content"].get("spyder_kernels_info", None) + if not spyder_kernels_info: if self.known_spyder_kernel: # spyder-kernels version < 3.0 self.kernel_error_message = ( @@ -242,7 +242,7 @@ def check_spyder_kernel_info(self, spyder_kernel_info): self.sig_kernel_is_ready.emit() return - version, pyexec = spyder_kernel_info + version, pyexec = spyder_kernels_info if not check_version_range(version, SPYDER_KERNELS_VERSION): # Development versions are acceptable if "dev0" not in version: @@ -276,81 +276,6 @@ def handle_comm_ready(self): self.connection_state = KernelConnectionState.SpyderKernelReady self.sig_kernel_is_ready.emit() - def connect_std_pipes(self): - """Connect to std pipes.""" - self.close_std_threads() - - # Connect new threads - if self.kernel_manager is None: - return - - stdout = self.kernel_manager.provisioner.process.stdout - stderr = self.kernel_manager.provisioner.process.stderr - - if stdout: - self._stdout_thread = StdThread(self, stdout) - self._stdout_thread.sig_out.connect(self.handle_stdout) - self._stdout_thread.start() - if stderr: - self._stderr_thread = StdThread(self, stderr) - self._stderr_thread.sig_out.connect(self.handle_stderr) - self._stderr_thread.start() - - def disconnect_std_pipes(self): - """Disconnect old std pipes.""" - if self._stdout_thread and not self._stdout_thread._closing: - self._stdout_thread.sig_out.disconnect(self.handle_stdout) - self._stdout_thread._closing = True - if self._stderr_thread and not self._stderr_thread._closing: - self._stderr_thread.sig_out.disconnect(self.handle_stderr) - self._stderr_thread._closing = True - - def close_std_threads(self): - """Close std threads.""" - if self._stdout_thread is not None: - self._stdout_thread.wait() - self._stdout_thread = None - if self._stderr_thread is not None: - self._stderr_thread.wait() - self._stderr_thread = None - - @Slot(str) - def handle_stderr(self, err): - """Handle stderr""" - if self._shellwidget_connected: - self.sig_stderr.emit(err) - else: - self._init_stderr += err - - @Slot(str) - def handle_stdout(self, out): - """Handle stdout""" - if self._shellwidget_connected: - self.sig_stdout.emit(out) - else: - self._init_stdout += out - - @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 @@ -374,45 +299,18 @@ def tunnel_to_kernel( return tuple(lports) @classmethod - def new_from_spec(cls, kernel_spec): + def new_from_spec( + cls, kernel_spec_dict, hostname=None, sshkey=None, password=None + ): """ 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()) - ) - - # Kernel manager - kernel_manager = SpyderKernelManager( - connection_file=connection_file, - config=None, - autorestart=True, - ) - - kernel_manager._kernel_spec = kernel_spec - - kernel_manager.start_kernel( - stderr=PIPE, - stdout=PIPE, - 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, + kernel_spec_dict=kernel_spec_dict, known_spyder_kernel=True, + hostname=hostname, + sshkey=sshkey, + password=password ) @classmethod @@ -420,22 +318,6 @@ def from_connection_file( cls, connection_file, hostname=None, sshkey=None, password=None ): """Create kernel for given connection file.""" - return cls( - connection_file, - hostname=hostname, - sshkey=sshkey, - password=password, - kernel_client=cls.init_kernel_client( - connection_file, - hostname, - sshkey, - password - ) - ) - - @classmethod - def init_kernel_client(cls, connection_file, hostname, sshkey, password): - """Create kernel client.""" kernel_client = SpyderKernelClient( connection_file=connection_file ) @@ -453,6 +335,24 @@ def init_kernel_client(cls, connection_file, hostname, sshkey, password): + str(e) ) + kernel_client = cls.tunnel_kernel_client( + kernel_client, + hostname, + sshkey, + password + ) + + return cls( + connection_file, + hostname=hostname, + sshkey=sshkey, + password=password, + kernel_client=kernel_client + ) + + @classmethod + def tunnel_kernel_client(cls, kernel_client, hostname, sshkey, password): + """Create kernel client.""" if hostname is not None: try: connection_info = dict( @@ -483,21 +383,9 @@ def init_kernel_client(cls, connection_file, hostname, sshkey, password): def close(self, shutdown_kernel=True, now=False): """Close kernel""" self.close_comm() - if shutdown_kernel and self.kernel_manager is not None: - km = self.kernel_manager - km.stop_restarter() - self.disconnect_std_pipes() - - 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) - with self._shutdown_thread_list_lock: - self._shutdown_thread_list.append(shutdown_thread) + + if shutdown_kernel and self.kernel_spec_dict is not None: + self.sig_remote_close.emit(self.connection_file) if ( self.kernel_client is not None @@ -510,55 +398,17 @@ def after_shutdown(self): self.close_std_threads() self.kernel_comm.remove(only_closing=True) - 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 - - @classmethod - def wait_all_shutdown_threads(cls): - """Wait shutdown thread.""" - with cls._shutdown_thread_list_lock: - 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 = [] - def copy(self): """Copy kernel.""" - # Copy kernel infos - - # Get new kernel_client - kernel_client = self.init_kernel_client( + copy_handler = self.from_connection_file( self.connection_file, self.hostname, self.sshkey, self.password, - ) - - return 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, - kernel_client=kernel_client, - ) + ) + copy_handler.kernel_spec_dict = self.kernel_spec_dict + copy_handler.known_spyder_kernel = self.known_spyder_kernel + return copy_handler def faulthandler_setup(self, args): """Setup faulthandler""" @@ -595,3 +445,39 @@ def reopen_comm(self): self.kernel_comm.remove() self.connection_state = KernelConnectionState.Crashed self.kernel_comm.open_comm(self.kernel_client) + + def set_connection(self, connection_file, connection_info, + hostname, sshkey, password): + """Set connection file.""" + if self.connection_file: + raise RuntimeError("Connection file already set") + self.connection_file = connection_file + self.connection_info = connection_info + self.hostname = hostname + self.sshkey = sshkey + self.password = password + + self.kernel_client = SpyderKernelClient() + self.kernel_client.load_connection_info(self.connection_info) + self.kernel_client = self.tunnel_kernel_client( + self.kernel_client, + self.hostname, + self.sshkey, + self.password + ) + + # Increase time (in seconds) to detect if a kernel is alive. + # See spyder-ide/spyder#3444. + self.kernel_client.hb_channel.time_to_dead = 25.0 + + self.start_channels() + + def start_channels(self): + """Start channels""" + # Start kernel + self.kernel_client.sig_kernel_info.connect( + self.check_spyder_kernels_info + ) + self.kernel_client.start_channels() + self.kernel_client.kernel_info() + self.kernel_comm.open_comm(self.kernel_client) diff --git a/spyder/plugins/ipythonconsole/utils/kernelspec.py b/spyder/plugins/ipythonconsole/utils/kernelspec.py deleted file mode 100644 index 9d14fd3f4fa..00000000000 --- a/spyder/plugins/ipythonconsole/utils/kernelspec.py +++ /dev/null @@ -1,217 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Kernel spec for Spyder kernels -""" - -# Standard library imports -import logging -import os -import os.path as osp -import sys - -# Third party imports -from jupyter_client.kernelspec import KernelSpec - -# Local imports -from spyder.api.config.mixins import SpyderConfigurationAccessor -from spyder.api.translations import _ -from spyder.config.base import (get_safe_mode, is_conda_based_app, - running_under_pytest) -from spyder.plugins.ipythonconsole import ( - SPYDER_KERNELS_CONDA, SPYDER_KERNELS_PIP, SPYDER_KERNELS_VERSION, - SpyderKernelError) -from spyder.utils.conda import get_conda_env_path, is_conda_env, find_conda -from spyder.utils.environ import clean_env, get_user_environment_variables -from spyder.utils.misc import get_python_executable -from spyder.utils.programs import ( - is_python_interpreter, - is_module_installed, - get_module_version -) - -# Constants -HERE = os.path.abspath(os.path.dirname(__file__)) -logger = logging.getLogger(__name__) -ERROR_SPYDER_KERNEL_INSTALLED = _( - "The Python environment or installation whose interpreter is located at" - "
"
-    "    {0}"
-    "
" - "doesn't have spyder-kernels version {1} installed. " - "Without this module and specific version is not possible for Spyder to " - "create a console for you.

" - "You can install it by activating your environment (if necessary) and " - "then running in a system terminal:" - "
"
-    "    {2}"
-    "
" - "or" - "
"
-    "    {3}"
-    "
") - - -def is_different_interpreter(pyexec): - """Check that pyexec is a different interpreter from sys.executable.""" - # Paths may be symlinks - real_pyexe = osp.realpath(pyexec) - real_sys_exe = osp.realpath(sys.executable) - executable_validation = osp.basename(real_pyexe).startswith('python') - directory_validation = osp.dirname(real_pyexe) != osp.dirname(real_sys_exe) - return directory_validation and executable_validation - - -def has_spyder_kernels(pyexec): - """Check if env has spyder kernels.""" - if is_module_installed( - 'spyder_kernels', - version=SPYDER_KERNELS_VERSION, - interpreter=pyexec - ): - return True - - # Dev versions of Spyder-kernels are acceptable - try: - return "dev0" in get_module_version('spyder_kernels', pyexec) - except Exception: - return False - - -HERE = osp.dirname(os.path.realpath(__file__)) - - -class SpyderKernelSpec(KernelSpec, SpyderConfigurationAccessor): - """Kernel spec for Spyder kernels""" - - CONF_SECTION = 'ipython_console' - - def __init__(self, path_to_custom_interpreter=None, - **kwargs): - super(SpyderKernelSpec, self).__init__(**kwargs) - self.path_to_custom_interpreter = path_to_custom_interpreter - self.display_name = 'Python 3 (Spyder)' - self.language = 'python3' - self.resource_dir = '' - - @property - def argv(self): - """Command to start kernels""" - # Python interpreter used to start kernels - if ( - self.get_conf('default', section='main_interpreter') - and not self.path_to_custom_interpreter - ): - pyexec = get_python_executable() - else: - pyexec = self.get_conf('executable', section='main_interpreter') - if self.path_to_custom_interpreter: - pyexec = self.path_to_custom_interpreter - if not has_spyder_kernels(pyexec): - raise SpyderKernelError( - ERROR_SPYDER_KERNEL_INSTALLED.format( - pyexec, - SPYDER_KERNELS_VERSION, - SPYDER_KERNELS_CONDA, - SPYDER_KERNELS_PIP - ) - ) - return - if not is_python_interpreter(pyexec): - pyexec = get_python_executable() - self.set_conf('executable', '', section='main_interpreter') - self.set_conf('default', True, section='main_interpreter') - self.set_conf('custom', False, section='main_interpreter') - - # Part of spyder-ide/spyder#11819 - is_different = is_different_interpreter(pyexec) - - # Command used to start kernels - kernel_cmd = [ - pyexec, - # This is necessary to avoid a spurious message on Windows. - # Fixes spyder-ide/spyder#20800. - '-Xfrozen_modules=off', - '-m', 'spyder_kernels.console', - '-f', '{connection_file}' - ] - - if is_different and is_conda_env(pyexec=pyexec): - # If executable is a conda environment and different from Spyder's - # runtime environment, we need to activate the environment to run - # spyder-kernels - kernel_cmd[:0] = [ - find_conda(), 'run', - '-p', get_conda_env_path(pyexec), - ] - - logger.info('Kernel command: {}'.format(kernel_cmd)) - - return kernel_cmd - - @property - def env(self): - """Env vars for kernels""" - default_interpreter = self.get_conf( - 'default', section='main_interpreter') - - # Ensure that user environment variables are included, but don't - # override existing environ values - env_vars = get_user_environment_variables() - env_vars.update(os.environ) - - # Avoid IPython adding the virtualenv on which Spyder is running - # to the kernel sys.path - env_vars.pop('VIRTUAL_ENV', None) - - # Do not pass PYTHONPATH to kernels directly, spyder-ide/spyder#13519 - env_vars.pop('PYTHONPATH', None) - - # List of paths declared by the user, plus project's path, to - # add to PYTHONPATH - pathlist = self.get_conf( - 'spyder_pythonpath', default=[], section='pythonpath_manager') - pypath = os.pathsep.join(pathlist) - - # List of modules to exclude from our UMR - umr_namelist = self.get_conf( - 'umr/namelist', section='main_interpreter') - - # Environment variables that we need to pass to the kernel - env_vars.update({ - 'SPY_EXTERNAL_INTERPRETER': (not default_interpreter - or self.path_to_custom_interpreter), - 'SPY_UMR_ENABLED': self.get_conf( - 'umr/enabled', section='main_interpreter'), - 'SPY_UMR_VERBOSE': self.get_conf( - 'umr/verbose', section='main_interpreter'), - 'SPY_UMR_NAMELIST': ','.join(umr_namelist), - 'SPY_AUTOCALL_O': self.get_conf('autocall'), - 'SPY_GREEDY_O': self.get_conf('greedy_completer'), - 'SPY_JEDI_O': self.get_conf('jedi_completer'), - 'SPY_TESTING': running_under_pytest() or get_safe_mode(), - 'SPY_HIDE_CMD': self.get_conf('hide_cmd_windows'), - 'SPY_PYTHONPATH': pypath, - }) - - # App considerations - # ??? Do we need this? - if is_conda_based_app() and default_interpreter: - # See spyder-ide/spyder#16927 - # See spyder-ide/spyder#16828 - # See spyder-ide/spyder#17552 - env_vars['PYDEVD_DISABLE_FILE_VALIDATION'] = 1 - - # Remove this variable because it prevents starting kernels for - # external interpreters when present. - # Fixes spyder-ide/spyder#13252 - env_vars.pop('PYTHONEXECUTABLE', None) - - # Making all env_vars strings - clean_env_vars = clean_env(env_vars) - - return clean_env_vars diff --git a/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py b/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py index 0503af6a6a0..f86205b98ad 100644 --- a/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py +++ b/spyder/plugins/ipythonconsole/utils/tests/test_spyder_kernel.py @@ -12,12 +12,13 @@ import pytest from spyder.config.manager import CONF -from spyder.plugins.ipythonconsole.utils.kernelspec import SpyderKernelSpec from spyder.py3compat import to_text_string +from spyder_kernels_server.kernel_spec import get_kernel_spec +from spyder.plugins.ipythonconsole.tests.conftest import ipyconsole @pytest.mark.parametrize('default_interpreter', [True, False]) -def test_kernel_pypath(tmpdir, default_interpreter): +def test_kernel_pypath(ipyconsole, tmpdir, default_interpreter): """ Test that PYTHONPATH and spyder_pythonpath option are properly handled when an external interpreter is used or not. @@ -33,7 +34,8 @@ def test_kernel_pypath(tmpdir, default_interpreter): os.environ['PYTHONPATH'] = pypath CONF.set('pythonpath_manager', 'spyder_pythonpath', [pypath]) - kernel_spec = SpyderKernelSpec() + kernel_spec = get_kernel_spec( + ipyconsole.get_widget().get_kernel_spec_dict()) # Check that PYTHONPATH is not in our kernelspec # and pypath is in SPY_PYTHONPATH @@ -46,7 +48,7 @@ def test_kernel_pypath(tmpdir, default_interpreter): del os.environ['PYTHONPATH'] -def test_python_interpreter(tmpdir): +def test_python_interpreter(ipyconsole, tmpdir): """Test the validation of the python interpreter.""" # Set a non existing python interpreter interpreter = str(tmpdir.mkdir('interpreter').join('python')) @@ -55,7 +57,8 @@ def test_python_interpreter(tmpdir): CONF.set('main_interpreter', 'executable', interpreter) # Create a kernel spec - kernel_spec = SpyderKernelSpec() + kernel_spec = get_kernel_spec( + ipyconsole.get_widget().get_kernel_spec_dict()) # Assert that the python interprerter is the default one assert interpreter not in kernel_spec.argv diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 31bdf35c794..f9114e1ba72 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -323,11 +323,12 @@ def connection_file(self): return None return self.kernel_handler.connection_file - def connect_kernel(self, kernel_handler, first_connect=True): + def connect_kernel(self, kernel_handler): """Connect kernel to client using our handler.""" self.kernel_handler = kernel_handler # Connect standard streams. + kernel_handler.sig_error.connect(self.print_error) kernel_handler.sig_stderr.connect(self.print_stderr) kernel_handler.sig_stdout.connect(self.print_stdout) kernel_handler.sig_fault.connect(self.print_fault) @@ -336,7 +337,7 @@ def connect_kernel(self, kernel_handler, first_connect=True): self._show_loading_page() # Actually do the connection - self.shellwidget.connect_kernel(kernel_handler, first_connect) + self.shellwidget.connect_kernel(kernel_handler) def disconnect_kernel(self, shutdown_kernel): """Disconnect from current kernel.""" @@ -344,6 +345,7 @@ def disconnect_kernel(self, shutdown_kernel): if not kernel_handler: return + kernel_handler.sig_error.disconnect(self.print_error) kernel_handler.sig_stderr.disconnect(self.print_stderr) kernel_handler.sig_stdout.disconnect(self.print_stdout) kernel_handler.sig_fault.disconnect(self.print_fault) @@ -352,8 +354,8 @@ def disconnect_kernel(self, shutdown_kernel): self.kernel_handler = None @Slot(str) - def print_stderr(self, stderr): - """Print stderr written in PIPE.""" + def print_error(self, stderr): + """Print crash infos.""" if not stderr: return @@ -369,12 +371,16 @@ def print_stderr(self, stderr): error_text = self.error_text + error_text self.show_kernel_error(error_text) - if self.shellwidget._starting: - self.shellwidget.banner = ( - stderr + '\n' + self.shellwidget.banner) - else: - self.shellwidget._append_plain_text( - stderr, before_prompt=True) + self.shellwidget._append_plain_text(stderr, before_prompt=True) + + @Slot(str) + def print_stderr(self, stderr): + """Print stderr written in PIPE.""" + if not stderr: + return + + self.shellwidget._append_plain_text(stderr, before_prompt=True) + self._hide_loading_page() @Slot(str) def print_stdout(self, stdout): @@ -382,12 +388,8 @@ def print_stdout(self, stdout): if not stdout: return - if self.shellwidget._starting: - self.shellwidget.banner = ( - stdout + '\n' + self.shellwidget.banner) - else: - self.shellwidget._append_plain_text( - stdout, before_prompt=True) + self.shellwidget._append_plain_text(stdout, before_prompt=True) + self._hide_loading_page() def connect_shellwidget_signals(self): """Configure shellwidget after kernel is connected.""" @@ -609,11 +611,10 @@ def replace_kernel(self, kernel_handler, shutdown_kernel): """ # Connect kernel to client self.disconnect_kernel(shutdown_kernel) - self.connect_kernel(kernel_handler, first_connect=False) - # Reset shellwidget and print restart message - self.shellwidget.reset(clear=True) - self.shellwidget._kernel_restarted_message(died=False) + self.shellwidget._shellwidget_state = "user_restart" + + self.connect_kernel(kernel_handler) def print_fault(self, fault): """Print fault text.""" diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index 6e1bc063174..9de7c76aa16 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -35,12 +35,12 @@ from spyder.config.base import ( 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.style import create_qss_style from spyder.plugins.ipythonconsole.widgets import ( ClientWidget, ConsoleRestartDialog, COMPLETION_WIDGET_TYPE, KernelConnectionDialog, PageControlWidget, MatplotlibStatus) -from spyder.plugins.ipythonconsole.widgets.mixins import CachedKernelMixin +from spyder.plugins.ipythonconsole.widgets.mixins import ( + CachedKernelMixin, KernelConnectorMixin) from spyder.utils import encoding, programs, sourcecode from spyder.utils.envs import get_list_envs from spyder.utils.misc import get_error_match, remove_backslashes @@ -50,6 +50,8 @@ from spyder.widgets.browser import FrameWebView from spyder.widgets.findreplace import FindReplace from spyder.widgets.tabs import Tabs +from spyder.utils.environ import clean_env, get_user_environment_variables +from spyder.config.base import get_safe_mode, is_conda_based_app # Logging @@ -119,14 +121,15 @@ class IPythonConsoleWidgetTabsContextMenuSections: # --- Widgets # ---------------------------------------------------------------------------- -class IPythonConsoleWidget(PluginMainWidget, CachedKernelMixin): +class IPythonConsoleWidget( + PluginMainWidget, CachedKernelMixin, KernelConnectorMixin +): """ IPython Console plugin This is a widget with tabs where each one is a ClientWidget. """ - # Signals sig_append_to_history_requested = Signal(str, str) """ This signal is emitted when the plugin requires to add commands to a @@ -920,7 +923,7 @@ def change_possible_restart_and_mpl_conf(self, option, value): # Need to know the interactive state sw = client.shellwidget - if sw._starting: + if sw._shellwidget_state != "started": # If the kernel didn't start and no backend was requested, # the backend is inline interactive_backend = inline_backend @@ -1454,10 +1457,11 @@ def create_new_client(self, give_focus=True, filename='', special=None, str_id='A') # Find what kind of kernel we want - if self.get_conf('pylab/autoload'): - special = "pylab" - elif self.get_conf('symbolic_math'): - special = "sympy" + if special is None: + if self.get_conf('pylab/autoload'): + special = "pylab" + elif self.get_conf('symbolic_math'): + special = "sympy" client = ClientWidget( self, @@ -1482,10 +1486,13 @@ def create_new_client(self, give_focus=True, filename='', special=None, try: # Create new kernel - kernel_spec = SpyderKernelSpec( - path_to_custom_interpreter=path_to_custom_interpreter + kernel_spec_dict = self.get_kernel_spec_dict( + path_to_custom_interpreter + ) + kernel_handler = self.get_cached_kernel( + kernel_spec_dict, + cache=cache, ) - kernel_handler = self.get_cached_kernel(kernel_spec, cache=cache) except Exception as e: client.show_kernel_error(e) return @@ -1494,6 +1501,86 @@ def create_new_client(self, give_focus=True, filename='', special=None, client.connect_kernel(kernel_handler) return client + def get_kernel_spec_dict(self, pyexec=None): + """Create a kernel spec dict""" + kernel_spec = { + "display_name": 'Python 3 (Spyder)', + "language": 'python3', + "resource_dir": '', + } + if ( + pyexec is None + and not self.get_conf('default', section='main_interpreter') + ): + pyexec = self.get_conf( + 'executable', section='main_interpreter' + ) + kernel_spec["pyexec"] = pyexec + kernel_spec["env"] = self.get_kernel_spec_env(pyexec) + return kernel_spec + + def get_kernel_spec_env(self, pyexec): + """Env vars for kernels""" + default_interpreter = self.get_conf( + 'default', section='main_interpreter') + + # Ensure that user environment variables are included, but don't + # override existing environ values + env_vars = get_user_environment_variables() + env_vars.update(os.environ) + + # Avoid IPython adding the virtualenv on which Spyder is running + # to the kernel sys.path + env_vars.pop('VIRTUAL_ENV', None) + + # Do not pass PYTHONPATH to kernels directly, spyder-ide/spyder#13519 + env_vars.pop('PYTHONPATH', None) + + # List of paths declared by the user, plus project's path, to + # add to PYTHONPATH + pathlist = self.get_conf( + 'spyder_pythonpath', default=[], section='pythonpath_manager') + pypath = os.pathsep.join(pathlist) + + # List of modules to exclude from our UMR + umr_namelist = self.get_conf( + 'umr/namelist', section='main_interpreter') + + # Environment variables that we need to pass to the kernel + env_vars.update({ + 'SPY_EXTERNAL_INTERPRETER': (not default_interpreter + or pyexec), + 'SPY_UMR_ENABLED': self.get_conf( + 'umr/enabled', section='main_interpreter'), + 'SPY_UMR_VERBOSE': self.get_conf( + 'umr/verbose', section='main_interpreter'), + 'SPY_UMR_NAMELIST': ','.join(umr_namelist), + 'SPY_AUTOCALL_O': self.get_conf('autocall'), + 'SPY_GREEDY_O': self.get_conf('greedy_completer'), + 'SPY_JEDI_O': self.get_conf('jedi_completer'), + 'SPY_TESTING': running_under_pytest() or get_safe_mode(), + 'SPY_HIDE_CMD': self.get_conf('hide_cmd_windows'), + 'SPY_PYTHONPATH': pypath, + }) + + # App considerations + # ??? Do we need this? + if is_conda_based_app() and default_interpreter: + # See spyder-ide/spyder#16927 + # See spyder-ide/spyder#16828 + # See spyder-ide/spyder#17552 + env_vars['PYDEVD_DISABLE_FILE_VALIDATION'] = 1 + + # Remove this variable because it prevents starting kernels for + # external interpreters when present. + # Fixes spyder-ide/spyder#13252 + env_vars.pop('PYTHONEXECUTABLE', None) + + # Making all env_vars strings + clean_env_vars = clean_env(env_vars) + + return clean_env_vars + def create_client_for_kernel(self, connection_file, hostname, sshkey, password): """Create a client connected to an existing kernel.""" @@ -1697,6 +1784,9 @@ def close_client(self, index=None, client=None, ask_recursive=True): index = self.tabwidget.currentIndex() if index is not None: client = self.tabwidget.widget(index) + if client is None: + # Nothing to do? + return # Check if related clients or kernels are opened # and eventually ask before closing them @@ -1760,12 +1850,12 @@ def close_all_clients(self): client.close_client(is_last_client) open_clients.remove(client) - # Wait for all KernelHandler threads to shutdown. - KernelHandler.wait_all_shutdown_threads() - # Close cached kernel self.close_cached_kernel() self.filenames = [] + + # Close local server + self.stop_local_server(wait=True) return True def get_client_index_from_id(self, client_id): @@ -1885,8 +1975,8 @@ def restart_kernel(self, client=None, ask_before_restart=True): if client is None: return - km = client.kernel_handler.kernel_manager - if km is None: + ks_dict = client.kernel_handler.kernel_spec_dict + if ks_dict is None: client.shellwidget._append_plain_text( _('Cannot restart a kernel not started by Spyder\n'), before_prompt=True @@ -1911,7 +2001,9 @@ def restart_kernel(self, client=None, ask_before_restart=True): # Get new kernel try: - kernel_handler = self.get_cached_kernel(km._kernel_spec) + # Update the kernel because settings might have changed + ks_dict = self.get_kernel_spec_dict(ks_dict["pyexec"]) + kernel_handler = self.get_cached_kernel(ks_dict) except Exception as e: client.show_kernel_error(e) return diff --git a/spyder/plugins/ipythonconsole/widgets/mixins.py b/spyder/plugins/ipythonconsole/widgets/mixins.py index 7c5b829d77c..86c0038df43 100644 --- a/spyder/plugins/ipythonconsole/widgets/mixins.py +++ b/spyder/plugins/ipythonconsole/widgets/mixins.py @@ -7,9 +7,315 @@ """ IPython Console mixins. """ +import zmq +import sys +import os +import queue + +from qtpy.QtCore import QProcess, QSocketNotifier, Slot # Local imports +from spyder.api.config.mixins import SpyderConfigurationObserver from spyder.plugins.ipythonconsole.utils.kernel_handler import KernelHandler +from spyder.api.config.decorators import on_conf_change +from zmq.ssh import tunnel as zmqtunnel +from spyder.plugins.ipythonconsole.utils.ssh import openssh_tunnel + +if os.name == "nt": + ssh_tunnel = zmqtunnel.paramiko_tunnel +else: + ssh_tunnel = openssh_tunnel + + +KERNEL_SERVER_OPTIONS = [ + "kernel_server/external_server", + "kernel_server/use_ssh", + "kernel_server/host", + "kernel_server/port", + "kernel_server/username", + "kernel_server/password_auth", + "kernel_server/keyfile_auth", + "kernel_server/password", + "kernel_server/keyfile", + "kernel_server/passphrase", +] + + +class KernelConnectorMixin(SpyderConfigurationObserver): + """Needs https://github.com/jupyter/jupyter_client/pull/835""" + + def __init__(self): + super().__init__() + self.options = None + self.server = None + self.kernel_handler_waitlist = [] + self._alive_kernel_handlers = {} + self.request_queue = queue.Queue() + self.context = zmq.Context() + self.on_kernel_server_conf_changed() + + @on_conf_change(option=KERNEL_SERVER_OPTIONS, section="main_interpreter") + def on_kernel_server_conf_changed(self, option=None, value=None): + """Start server""" + options = { + option: self.get_conf(option=option, section="main_interpreter") + for option in KERNEL_SERVER_OPTIONS + } + if self.options == options: + return + + if self.server is not None: + self.stop_local_server() + + if self.options is not None: + # Close cached kernel + self.close_cached_kernel() + # Reset request_queue + self.request_queue = queue.Queue() + # Send new kernel request for waiting kernels + for kernel_handler in self.kernel_handler_waitlist: + self.send_request( + ["open_kernel", kernel_handler.kernel_spec_dict] + ) + + self.options = options + self.ssh_remote_hostname = None + self.ssh_key = None + self.ssh_password = None + + is_remote = options["kernel_server/external_server"] + + if not is_remote: + self.start_local_server() + return + + # Remote server + + remote_port = int(options["kernel_server/port"]) + if not remote_port: + remote_port = 22 + remote_ip = options["kernel_server/host"] + + is_ssh = options["kernel_server/use_ssh"] + + if not is_ssh: + self.connect_socket(remote_ip, remote_port) + return + + username = options["kernel_server/username"] + + self.ssh_remote_hostname = f"{username}@{remote_ip}:{remote_port}" + + # Now we deal with ssh + uses_password = options["kernel_server/password_auth"] + uses_keyfile = options["kernel_server/keyfile_auth"] + + if uses_password: + self.ssh_password = options["kernel_server/password"] + self.ssh_key = None + elif uses_keyfile: + self.ssh_password = options["kernel_server/passphrase"] + self.ssh_key = options["kernel_server/keyfile"] + else: + raise NotImplementedError("This should not be possible.") + + self.connect_socket(remote_ip, remote_port) + + def start_local_server(self): + """Start a server with the current interpreter.""" + port = str(zmqtunnel.select_random_ports(1)[0]) + self.server = QProcess(self) + self.server.start( + sys.executable, ["-m", "spyder_kernels_server", port] + ) + self.server.readyReadStandardError.connect(self.print_server_stderr) + self.server.readyReadStandardOutput.connect(self.print_server_stdout) + self.connect_socket("localhost", port) + + def stop_local_server(self, wait=False): + """Stop local server.""" + if self.server is None: + return + self.server.readyReadStandardError.disconnect(self.print_server_stderr) + self.server.readyReadStandardOutput.disconnect( + self.print_server_stdout + ) + self.send_request(["shutdown"]) + if wait: + self.server.waitForFinished() + self.server = None + + @Slot() + def print_server_stderr(self): + sys.stderr.write(self.server.readAllStandardError().data().decode()) + + @Slot() + def print_server_stdout(self): + sys.stdout.write(self.server.readAllStandardOutput().data().decode()) + + def connect_socket(self, hostname, port): + self.hostname = hostname + + hostname, port = self.tunnel_ssh(hostname, port) + + self.socket = self.context.socket(zmq.REQ) + self.socket.connect(f"tcp://{hostname}:{port}") + self._notifier = QSocketNotifier( + self.socket.getsockopt(zmq.FD), QSocketNotifier.Read, self + ) + self._notifier.activated.connect(self._socket_activity) + self.send_request(["get_port_pub"]) + + def tunnel_ssh(self, hostname, port): + if self.ssh_remote_hostname is None: + return hostname, port + remote_hostname = hostname + remote_port = port + port = zmqtunnel.select_random_ports(1) + hostname = "localhost" + timeout = 10 + ssh_tunnel( + port, + remote_port, + hostname, + remote_hostname, + self.ssh_key, + self.ssh_password, + timeout, + ) + return hostname, port + + def new_kernel(self, kernel_spec_dict): + """Get a new kernel""" + + kernel_handler = KernelHandler.new_from_spec( + kernel_spec_dict=kernel_spec_dict, + hostname=self.ssh_remote_hostname, + sshkey=self.ssh_key, + password=self.ssh_password, + ) + + kernel_handler.sig_remote_close.connect(self.request_close) + self.kernel_handler_waitlist.append(kernel_handler) + + self.send_request(["open_kernel", kernel_spec_dict]) + + return kernel_handler + + def request_close(self, connection_file): + self.send_request(["close_kernel", connection_file]) + # Remove kernel from active kernels + self._alive_kernel_handlers.pop(connection_file, None) + + def send_request(self, request): + # Check socket state + socket_state = self.socket.getsockopt(zmq.EVENTS) + if socket_state & zmq.POLLOUT: + self.socket.send_pyobj(request) + else: + self.request_queue.put(request) + # Checking the socket state interferes with the notifier. + # If the socket is ready, read. + if socket_state & zmq.POLLIN: + self._socket_activity() + + @Slot() + def _socket_activity(self): + if not self.socket.getsockopt(zmq.EVENTS) & zmq.POLLIN: + # Valid, see http://api.zeromq.org/master:zmq-getsockopt + return + self._notifier.setEnabled(False) + # Wait for next request from client + message = self.socket.recv_pyobj() + cmd = message[0] + + if cmd == "new_kernel": + cmd, connection_file, connection_info = message + + if len(self.kernel_handler_waitlist) == 0: + # This should not happen :/ + self._notifier.setEnabled(True) + self.socket.getsockopt(zmq.EVENTS) + return + + kernel_handler = self.kernel_handler_waitlist.pop(0) + if connection_file == "error": + kernel_handler.handle_error(str(connection_info)) + else: + kernel_handler.set_connection( + connection_file, + connection_info, + self.ssh_remote_hostname, + self.ssh_key, + self.ssh_password, + ) + # keep ref to signal kernel handler + self._alive_kernel_handlers[connection_file] = kernel_handler + + elif cmd == "set_port_pub": + port_pub = message[1] + self.socket_sub = self.context.socket(zmq.SUB) + # To recieve everything + self.socket_sub.setsockopt(zmq.SUBSCRIBE, b"") + hostname = self.hostname + hostname, port_pub = self.tunnel_ssh(hostname, port_pub) + + self.socket_sub.connect(f"tcp://{hostname}:{port_pub}") + self._notifier_sub = QSocketNotifier( + self.socket_sub.getsockopt(zmq.FD), QSocketNotifier.Read, self + ) + self._notifier_sub.activated.connect(self._socket_sub_activity) + + self._notifier.setEnabled(True) + # This is necessary for some reason. + # Otherwise the notifer is not really enabled + self.socket.getsockopt(zmq.EVENTS) + + try: + request = self.request_queue.get_nowait() + self.send_request(request) + except queue.Empty: + pass + + @Slot() + def _socket_sub_activity(self): + if not self.socket_sub.getsockopt(zmq.EVENTS) & zmq.POLLIN: + return + self._notifier_sub.setEnabled(False) + # Wait for next request from client + message = self.socket_sub.recv_pyobj() + cmd = message[0] + if cmd == "kernel_restarted": + connection_file = message[1] + kernel_handler = self._alive_kernel_handlers.get( + connection_file, None + ) + if kernel_handler is not None: + kernel_handler.sig_kernel_restarted.emit() + + elif cmd == "stderr": + connection_file = message[1] + err = message[2] + kernel_handler = self._alive_kernel_handlers.get( + connection_file, None + ) + if kernel_handler is not None: + kernel_handler.handle_stderr(err) + + elif cmd == "stdout": + connection_file = message[1] + out = message[2] + kernel_handler = self._alive_kernel_handlers.get( + connection_file, None + ) + if kernel_handler is not None: + kernel_handler.handle_stdout(out) + + self._notifier_sub.setEnabled(True) + # This is necessary for some reason. + # Otherwise the socket only works twice ! + if self.socket_sub.getsockopt(zmq.EVENTS) & zmq.POLLIN: + self._socket_sub_activity() class CachedKernelMixin: @@ -27,36 +333,30 @@ def close_cached_kernel(self): kernel.close(now=True) self._cached_kernel_properties = None - def check_cached_kernel_spec(self, kernel_spec): + def check_cached_kernel_spec(self, kernel_spec_dict): """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, + cached_spec_dict, _, ) = 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: + if "PYTEST_CURRENT_TEST" in cached_spec_dict["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"]) + cached_spec_dict["env"][ + "PYTEST_CURRENT_TEST"] = kernel_spec_dict["env"][ + "PYTEST_CURRENT_TEST" + ] return ( - cached_spec.__dict__ == kernel_spec.__dict__ - and kernel_spec.argv == cached_argv - and kernel_spec.env == cached_env + kernel_spec_dict == cached_spec_dict ) - def get_cached_kernel(self, kernel_spec, cache=True): + def get_cached_kernel(self, kernel_spec_dict, 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) + new_kernel_handler = self.new_kernel(kernel_spec_dict) if not cache: # remove/don't use cache if requested @@ -67,20 +367,18 @@ def get_cached_kernel(self, kernel_spec, cache=True): 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): + if not self.check_cached_kernel_spec(kernel_spec_dict): # 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, + kernel_spec_dict, new_kernel_handler, ) if cached_kernel_handler is None: - return KernelHandler.new_from_spec(kernel_spec) + return self.new_kernel(kernel_spec_dict) return cached_kernel_handler diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index e7179fd76e2..ef6ec358b26 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -158,12 +158,12 @@ def __init__(self, ipyclient, additional_options, interpreter_versions, self.set_bracket_matcher_color_scheme(self.syntax_style) self.shutting_down = False - self.kernel_manager = None self.kernel_client = None self.kernel_handler = None self._kernel_configuration = {} self.is_kernel_configured = False self._init_kernel_setup = False + self._shellwidget_state = "starting" if handlers is None: handlers = {} @@ -176,9 +176,11 @@ def __init__(self, ipyclient, additional_options, interpreter_versions, 'update_state': self.update_state, }) self.kernel_comm_handlers = handlers - - self._execute_queue = [] - self.executed.connect(self.pop_execute_queue) + # To queue all messages during startup + self._execute_startup_queue = [] + # To queue user messages if the shell is already executing + self._execute_user_queue = [] + self.executed.connect(self.pop_execute_user_queue) # Show a message in our installers to explain users how to use # modules that don't come with them. @@ -202,26 +204,18 @@ def spyder_kernel_ready(self): """ if self.kernel_handler is None: return False - return ( + return (self.is_kernel_configured and self.kernel_handler.connection_state == KernelConnectionState.SpyderKernelReady) - def connect_kernel(self, kernel_handler, first_connect=True): + 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) - self.kernel_client = kernel_client - self.kernel_manager = kernel_handler.kernel_manager - self.kernel_handler = kernel_handler - - if first_connect: - # Let plugins know that a new kernel is connected + if self._shellwidget_state == "starting": self.sig_shellwidget_created.emit(self) - else: - # Set _starting to False to avoid reset at first prompt - self._starting = False + + # Kernel client + self.kernel_handler = kernel_handler # Connect signals kernel_handler.sig_kernel_is_ready.connect( @@ -229,7 +223,7 @@ def connect_kernel(self, kernel_handler, first_connect=True): kernel_handler.sig_kernel_connection_error.connect( self.handle_kernel_connection_error) - kernel_handler.connect_() + kernel_handler.connect() def disconnect_kernel(self, shutdown_kernel=True, will_reconnect=True): """ @@ -252,12 +246,14 @@ def disconnect_kernel(self, shutdown_kernel=True, will_reconnect=True): self.handle_kernel_is_ready) kernel_handler.sig_kernel_connection_error.disconnect( self.handle_kernel_connection_error) - kernel_handler.kernel_client.stopped_channels.disconnect( - self.notify_deleted) if self._init_kernel_setup: self._init_kernel_setup = False + kernel_client.stopped_channels.disconnect(self.notify_deleted) + kernel_handler.sig_kernel_restarted.disconnect( + self._handle_kernel_restarted) + kernel_handler.kernel_comm.sig_exception_occurred.disconnect( self.sig_exception_occurred) kernel_client.control_channel.message_received.disconnect( @@ -270,16 +266,63 @@ def disconnect_kernel(self, shutdown_kernel=True, will_reconnect=True): self.reset_kernel_state() self.kernel_client = None - self.kernel_manager = None self.kernel_handler = None def handle_kernel_is_ready(self): - """The kernel is ready""" + """ + The kernel is ready + + Note: + qtconsole still want to have an extra round trip message when + setting self.kernel_client, so the setup can wait until + _handle_kernel_info_reply + """ + if self.kernel_handler.connection_state in [ + KernelConnectionState.IpykernelReady, + KernelConnectionState.SpyderKernelReady + ]: + if self.kernel_client != self.kernel_handler.kernel_client: + # If the kernel crashed, the right client is already connected + self.kernel_client = self.kernel_handler.kernel_client + # kernel_info must have already been recieved for + # handle_kernel_is_ready to be called + self._handle_kernel_info_reply( + self.kernel_handler._kernel_info_msg + ) + # If the user asked for a restart, print the restart message + if self._shellwidget_state == "user_restart": + self._control.clear() + self._kernel_restarted_message(died=False) + # Print The banner + if self._display_banner: + self._append_plain_text(self.banner) + if self.kernel_banner: + self._append_plain_text(self.kernel_banner) + # Show first prompt + self.reset() if ( self.kernel_handler.connection_state == KernelConnectionState.SpyderKernelReady ): - self.setup_spyder_kernel() + self.kernel_connect_sig() + self.send_spyder_kernel_configuration() + + def _started_channels(self): + """Make a history request""" + # Disable the _starting mechanism + self._starting = False + # Request history + self.kernel_client.history(hist_access_type='tail', n=1000) + + def _prompt_started_hook(self): + """Emit a signal when the prompt is ready.""" + if not self._reading: + self._highlighter.highlighting_on = True + self.sig_prompt_ready.emit() + if self._shellwidget_state != "started": + self._shellwidget_state = "started" + # The kernel is ready, send all messages + self.pop_execute_startup_queue() def handle_kernel_connection_error(self): """An error occurred when connecting to the kernel.""" @@ -342,14 +385,20 @@ def call_kernel(self, interrupt=False, blocking=False, callback=None, @property def is_external_kernel(self): """Check if this is an external kernel.""" - return self.kernel_manager is None + if self.kernel_handler is None: + return False + return self.kernel_handler.kernel_spec_dict is None - def setup_spyder_kernel(self): - """Setup spyder kernel""" + def kernel_connect_sig(self): + """Connect signals for kernel.""" if not self._init_kernel_setup: # Only do this setup once self._init_kernel_setup = True + self.kernel_client.stopped_channels.connect(self.notify_deleted) + self.kernel_handler.sig_kernel_restarted.connect( + self._handle_kernel_restarted) + # For errors self.kernel_handler.kernel_comm.sig_exception_occurred.connect( self.sig_exception_occurred) @@ -364,21 +413,9 @@ def setup_spyder_kernel(self): for request_id, handler in self.kernel_comm_handlers.items(): self.kernel_handler.kernel_comm.register_call_handler( request_id, handler) - - # Setup to do after restart - # Check for fault and send config - self.kernel_handler.poll_fault_text() - - self.send_spyder_kernel_configuration() - - run_lines = self.get_conf('startup/run_lines') - if run_lines: - self.execute(run_lines, hidden=True) - - if self.get_conf('startup/use_run_file'): - run_file = self.get_conf('startup/run_file') - if run_file: - self.call_kernel().safe_exec(run_file) + else: + # kernel might have restarted + self.kernel_handler.poll_fault_text() def send_spyder_kernel_configuration(self): """Send kernel configuration to spyder kernel.""" @@ -417,6 +454,15 @@ def send_spyder_kernel_configuration(self): callback=self.kernel_configure_callback ).set_configuration(self._kernel_configuration) + run_lines = self.get_conf('startup/run_lines') + if run_lines: + self.execute(run_lines, hidden=True) + + if self.get_conf('startup/use_run_file'): + run_file = self.get_conf('startup/run_file') + if run_file: + self.call_kernel().safe_exec(run_file) + self.is_kernel_configured = True def set_kernel_configuration(self, key, value): @@ -442,31 +488,26 @@ def kernel_configure_callback(self, dic): elif key == "special_kernel_error": self.ipyclient._show_special_console_error(value) - def pop_execute_queue(self): + def pop_execute_startup_queue(self): + for item in self._execute_startup_queue: + self.execute(*item) + self._execute_startup_queue = [] + + def pop_execute_user_queue(self): """Pop one waiting instruction.""" - if self._execute_queue: - self.execute(*self._execute_queue.pop(0)) + if self._execute_user_queue: + self.execute(*self._execute_user_queue.pop(0)) def interrupt_kernel(self): """Attempts to interrupt the running kernel.""" # Empty queue when interrupting # Fixes spyder-ide/spyder#7293. - self._execute_queue = [] + self._execute_user_queue = [] if self.spyder_kernel_ready: self._reading = False + self.call_kernel(interrupt=True).raise_interrupt_signal() - # Check if there is a kernel that can be interrupted before trying - # to do it. - # Fixes spyder-ide/spyder#20212 - if self.kernel_manager and self.kernel_manager.has_kernel: - self.call_kernel(interrupt=True).raise_interrupt_signal() - else: - self._append_html( - _("

The kernel appears to be dead, so it can't be " - "interrupted. Please open a new console to keep " - "working.
") - ) else: self._append_html( _("

It is not possible to interrupt a non-Spyder " @@ -481,10 +522,12 @@ def execute(self, source=None, hidden=False, interactive=False): # Needed for cases where there is no kernel initialized but # an execution is triggered like when setting initial configs. # See spyder-ide/spyder#16896 - if self.kernel_client is None: + if self._shellwidget_state != "started": + self._execute_startup_queue.append((source, hidden, interactive)) return - if self._executing: - self._execute_queue.append((source, hidden, interactive)) + # Avoid multiple execution + if not hidden and self._executing: + self._execute_user_queue.append((source, hidden, interactive)) return super(ShellWidget, self).execute(source, hidden, interactive) @@ -530,10 +573,6 @@ def send_mpl_backend(self, option=None): If `option` is not None only send the related options. """ - if not self.spyder_kernel_ready: - # will be sent later - return - # Set Matplotlib backend with Spyder options pylab_n = 'pylab' pylab_o = self.get_conf(pylab_n) @@ -633,16 +672,14 @@ def set_bracket_matcher_color_scheme(self, color_scheme): def set_color_scheme(self, color_scheme, reset=True): """Set color scheme of the shell.""" + color_changed = self.syntax_style != color_scheme self.set_bracket_matcher_color_scheme(color_scheme) self.style_sheet, dark_color = create_qss_style(color_scheme) self.syntax_style = color_scheme self._style_sheet_changed() self._syntax_style_changed() - if reset: - self.reset(clear=True) - if not self.spyder_kernel_ready: - # Will be sent later - return + if reset and color_changed: + self.reset() self.set_kernel_configuration( "color scheme", "dark" if not dark_color else "light" ) @@ -1220,7 +1257,7 @@ def _kernel_restarted_message(self, died=True): else _("Restarting kernel...") ) - if died and self.kernel_manager is None: + if died and self.is_external_kernel: # The kernel might never restart, show position of fault file msg += ( "\n" + _("Its crash file is located at:") + " " @@ -1266,12 +1303,6 @@ def _get_color(self, color): select=color_scheme['background'], fgcolor=color_scheme['normal'][0])[color] - def _prompt_started_hook(self): - """Emit a signal when the prompt is ready.""" - if not self._reading: - self._highlighter.highlighting_on = True - self.sig_prompt_ready.emit() - def _handle_execute_input(self, msg): """Handle an execute_input message""" super(ShellWidget, self)._handle_execute_input(msg) diff --git a/spyder/plugins/maininterpreter/confpage.py b/spyder/plugins/maininterpreter/confpage.py index 1e2c362b293..ee24a57bb2e 100644 --- a/spyder/plugins/maininterpreter/confpage.py +++ b/spyder/plugins/maininterpreter/confpage.py @@ -12,8 +12,12 @@ import sys # Third party imports -from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QInputDialog, QLabel, - QLineEdit, QMessageBox, QPushButton, QVBoxLayout) +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QButtonGroup, QGroupBox, QInputDialog, QLabel, + QLineEdit, QMessageBox, QPushButton, QVBoxLayout, QRadioButton, + QHBoxLayout, QGridLayout, QSpacerItem) +from qtpy.compat import getopenfilename # Local imports from spyder.api.translations import _ @@ -24,6 +28,7 @@ from spyder.utils.conda import get_list_conda_envs_cache from spyder.utils.misc import get_python_executable from spyder.utils.pyenv import get_list_pyenv_envs_cache +from spyder.config.base import get_home_dir class MainInterpreterConfigPage(PluginConfigPage): @@ -64,6 +69,104 @@ def initialize(self): def setup_page(self): newcb = self.create_checkbox + # Remote kernel groupbox + rm_group = self.create_checkable_groupbox( + _("Use an external kernel server"), + 'kernel_server/external_server', + ) + + # Hostname Layout + hostname = self.create_lineedit( + _("Hostnane:"), + 'kernel_server/host', + alignment=Qt.Horizontal, + word_wrap=False + ) + port = self.create_spinbox( + ":", "", 'kernel_server/port', min_=1, max_=65535, step=1 + ) + + hostname_layout = QHBoxLayout() + hostname_layout.addWidget(hostname) + hostname_layout.addWidget(port) + + # SSH authentication + auth_group = self.create_checkable_groupbox( + _("Authentication method (via SSH):"), + 'kernel_server/use_ssh', + ) + + username = self.create_lineedit( + _("Username:"), + 'kernel_server/username', + alignment=Qt.Horizontal, + word_wrap=False + ) + + auth_bg = QButtonGroup(auth_group) + password_radio = self.create_radiobutton( + _("Password:"), + 'kernel_server/password_auth', + button_group=auth_bg, + ) + keyfile_radio = self.create_radiobutton( + _('SSH keyfile:'), + 'kernel_server/keyfile_auth', + button_group=auth_bg, + ) + + password = self.create_lineedit( + "", + 'kernel_server/password', + alignment=Qt.Horizontal, + word_wrap=False + ) + password.textbox.setEchoMode(QLineEdit.Password) + password_radio.radiobutton.toggled.connect(password.setEnabled) + keyfile_radio.radiobutton.toggled.connect(password.setDisabled) + + keyfile = self.create_file_combobox( + _('SSH Keyfile'), + self.get_option('custom_interpreters_list'), + 'kernel_server/keyfile', + default_line_edit=True, + adjust_to_contents=True, + ) + passphrase = self.create_lineedit( + _('Passphase:'), + 'kernel_server/passphrase', + alignment=Qt.Horizontal, + word_wrap=False + ) + + passphrase.textbox.setPlaceholderText(_('Optional')) + passphrase.textbox.setEchoMode(QLineEdit.Password) + + keyfile_radio.radiobutton.toggled.connect(keyfile.setEnabled) + keyfile_radio.radiobutton.toggled.connect(passphrase.setEnabled) + password_radio.radiobutton.toggled.connect(keyfile.setDisabled) + password_radio.radiobutton.toggled.connect(passphrase.setDisabled) + + # SSH authentication layout + auth_layout = QGridLayout() + auth_layout.addWidget(username, 0, 0, 1, 2) + auth_layout.addWidget(password_radio, 1, 0) + auth_layout.addWidget(password, 1, 1) + auth_layout.addWidget(keyfile_radio, 2, 0) + auth_layout.addWidget(keyfile, 2, 1) + auth_layout.addWidget(passphrase, 3, 0, 1, 2) + auth_group.setLayout(auth_layout) + + # Remote kernel layout + rm_layout = QVBoxLayout() + rm_layout.addLayout(hostname_layout) + rm_layout.addSpacerItem(QSpacerItem(0, 8)) + rm_layout.addWidget(auth_group) + rm_group.setLayout(rm_layout) + auth_group.setCheckable(True) + auth_group.toggled.connect(password_radio.radiobutton.setChecked) + rm_group.setCheckable(True) + # Python executable Group pyexec_group = QGroupBox(_("Python interpreter")) pyexec_bg = QButtonGroup(pyexec_group) @@ -104,6 +207,7 @@ def setup_page(self): self.cus_exec_radio.radiobutton.toggled.connect( self.cus_exec_combo.setEnabled) pyexec_layout.addWidget(self.cus_exec_combo) + pyexec_layout.addWidget(rm_group) pyexec_group.setLayout(pyexec_layout) self.pyexec_edit = self.cus_exec_combo.combobox.lineEdit() diff --git a/spyder/plugins/preferences/widgets/config_widgets.py b/spyder/plugins/preferences/widgets/config_widgets.py index 77335122af0..40138c4a707 100644 --- a/spyder/plugins/preferences/widgets/config_widgets.py +++ b/spyder/plugins/preferences/widgets/config_widgets.py @@ -497,6 +497,31 @@ def show_message(is_checked=False): widget.setLayout(layout) return widget + def create_checkable_groupbox( + self, text, option, default=NoDefault, + tip=None, msg_warning=None, msg_info=None, + msg_if_enabled=False, section=None, restart=False + ): + checkbox = QGroupBox(text) + checkbox.setCheckable(True) + self.checkboxes[checkbox] = (section, option, default) + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + if tip is not None: + checkbox.setToolTip(tip) + if msg_warning is not None or msg_info is not None: + def show_message(is_checked=False): + if is_checked or not msg_if_enabled: + if msg_warning is not None: + QMessageBox.warning(self, self.get_name(), + msg_warning, QMessageBox.Ok) + if msg_info is not None: + QMessageBox.information(self, self.get_name(), + msg_info, QMessageBox.Ok) + checkbox.clicked.connect(show_message) + checkbox.restart_required = restart + return checkbox + def create_radiobutton(self, text, option, default=NoDefault, tip=None, msg_warning=None, msg_info=None, msg_if_enabled=False, button_group=None, diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py index 0e2d0695bf6..4c2903391ff 100644 --- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py +++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py @@ -231,8 +231,6 @@ def refresh_namespacebrowser(self, *, interrupt=True): def set_namespace_view_settings(self): """Set the namespace view settings""" - if not self.shellwidget.spyder_kernel_ready: - return settings = self.get_view_settings() self.shellwidget.set_kernel_configuration( "namespace_view_settings", settings diff --git a/spyder/plugins/workingdirectory/plugin.py b/spyder/plugins/workingdirectory/plugin.py index 4a7f11dd1fe..c4a795cdd5a 100644 --- a/spyder/plugins/workingdirectory/plugin.py +++ b/spyder/plugins/workingdirectory/plugin.py @@ -239,28 +239,33 @@ def get_workdir(self): # -------------------------- Private API ---------------------------------- def _editor_change_dir(self, path): editor = self.get_plugin(Plugins.Editor) - self.chdir(path, editor) + if editor is not None: + self.chdir(path, editor) def _explorer_change_dir(self, path): explorer = self.get_plugin(Plugins.Explorer) - if explorer: + if explorer is not None: explorer.chdir(path, emit=False) def _explorer_dir_opened(self, path): explorer = self.get_plugin(Plugins.Explorer) - self.chdir(path, explorer) + if explorer is not None: + self.chdir(path, explorer) def _ipyconsole_change_dir(self, path): ipyconsole = self.get_plugin(Plugins.IPythonConsole) - self.chdir(path, ipyconsole) + if ipyconsole is not None: + self.chdir(path, ipyconsole) def _project_loaded(self, path): projects = self.get_plugin(Plugins.Projects) - self.chdir(directory=path, sender_plugin=projects) + if projects is not None: + self.chdir(directory=path, sender_plugin=projects) def _project_closed(self, path): projects = self.get_plugin(Plugins.Projects) - self.chdir( - directory=projects.get_last_working_dir(), - sender_plugin=projects - ) + if projects is not None: + self.chdir( + directory=projects.get_last_working_dir(), + sender_plugin=projects + ) diff --git a/spyder/utils/conda.py b/spyder/utils/conda.py index b34f2ff1f6a..b63591e8a4d 100644 --- a/spyder/utils/conda.py +++ b/spyder/utils/conda.py @@ -13,8 +13,10 @@ import os.path as osp import sys -from spyder.utils.programs import find_program, run_program, run_shell_command -from spyder.config.base import is_conda_based_app +from spyder_kernels_server.conda_utils import ( + get_conda_env_path, add_quotes, find_conda, is_conda_env +) +from spyder.utils.programs import run_program, run_shell_command WINDOWS = os.name == 'nt' CONDA_ENV_LIST_CACHE = {} @@ -83,45 +85,6 @@ def get_conda_root_prefix(pyexec=None, quote=False): return root_prefix -def get_conda_env_path(pyexec, quote=False): - """ - Return the full path to the conda environment from give python executable. - - If `quote` is True, then quotes are added if spaces are found in the path. - """ - pyexec = pyexec.replace('\\', '/') - if os.name == 'nt': - conda_env = os.path.dirname(pyexec) - else: - conda_env = os.path.dirname(os.path.dirname(pyexec)) - - if quote: - conda_env = add_quotes(conda_env) - - return conda_env - - -def find_conda(): - """Find conda executable.""" - conda = None - - # First try Spyder's conda executable - if is_conda_based_app(): - root = osp.dirname(os.environ['CONDA_EXE']) - conda = osp.join(root, 'mamba.exe' if WINDOWS else 'mamba') - - # Next try the environment variables - if conda is None: - conda = os.environ.get('CONDA_EXE') or os.environ.get('MAMBA_EXE') - - # Next try searching for the executable - if conda is None: - conda_exec = 'conda.bat' if WINDOWS else 'conda' - conda = find_program(conda_exec) - - return conda - - def get_list_conda_envs(): """Return the list of all conda envs found in the system.""" global CONDA_ENV_LIST_CACHE