diff --git a/mu/modes/python3.py b/mu/modes/python3.py index 87612b6fa..9ccaa8f46 100644 --- a/mu/modes/python3.py +++ b/mu/modes/python3.py @@ -19,11 +19,14 @@ import sys import os import logging +import json +from mu.config import DATA_DIR from mu.modes.base import BaseMode from mu.modes.api import PYTHON3_APIS, SHARED_APIS, PI_APIS from mu.resources import load_icon from mu.interface.panes import CHARTS from ..virtual_environment import venv +from jupyter_client import kernelspec from qtconsole.manager import QtKernelManager from qtconsole.client import QtKernelClient from PyQt5.QtCore import QObject, QThread, pyqtSignal @@ -32,30 +35,21 @@ logger = logging.getLogger(__name__) -class MuKernelManager(QtKernelManager): - def start_kernel(self, **kw): - """Starts a kernel on this host in a separate process. - - Subclassed to allow checking that the kernel uses the same Python as - Mu itself. - """ - kernel_cmd, kw = self.pre_start_kernel(**kw) - cmd_interpreter = kernel_cmd[0] - if cmd_interpreter != venv.interpreter: - self.log.debug( - "Wrong interpreter selected to run REPL: %s", kernel_cmd - ) - self.log.debug( - "Using default interpreter to run REPL instead: %s", - cmd_interpreter, - ) - cmd_interpreter = venv.interpreter - kernel_cmd[0] = cmd_interpreter - - # launch the kernel subprocess - self.log.debug("Starting kernel: %s", kernel_cmd) - self.kernel = self._launch_kernel(kernel_cmd, **kw) - self.post_start_kernel(**kw) +def make_kernel_spec(kernel_dir): + os.makedirs(kernel_dir, exist_ok=True) + spec = { + "argv": [ + venv.interpreter, + "-m", + "ipykernel_launcher", + "-f", + "{connection_file}", + ], + "display_name": "Mu's Python 3 Kernel", + "language": "python", + } + with open(os.path.join(kernel_dir, "kernel.json"), "w") as kernel_json: + json.dump(spec, kernel_json) class KernelRunner(QObject): @@ -85,6 +79,7 @@ def __init__(self, kernel_name, cwd, envars): self.kernel_name = kernel_name self.cwd = cwd self.envars = dict(envars) + self.kernel_dir = os.path.join(DATA_DIR, "kernel") def start_kernel(self): """ @@ -104,9 +99,21 @@ def start_kernel(self): if k != "PYTHONPATH": os.environ[k] = v - self.repl_kernel_manager = MuKernelManager() + self.repl_kernel_manager = QtKernelManager() + if self.kernel_name not in kernelspec.find_kernel_specs(): + make_kernel_spec(self.kernel_dir) + kernelspec.install_kernel_spec( + self.kernel_dir, self.kernel_name, user=True + ) self.repl_kernel_manager.kernel_name = self.kernel_name self.repl_kernel_manager.start_kernel() + + if self.repl_kernel_manager.kernel_spec.argv[0] != venv.interpreter: + logger.debug( + "Wrong interpreter selected to run REPL: %s", + self.repl_kernel_manager.kernel_spec.argv[0], + ) + self.repl_kernel_client = self.repl_kernel_manager.client() self.kernel_started.emit( self.repl_kernel_manager, self.repl_kernel_client diff --git a/tests/modes/test_python3.py b/tests/modes/test_python3.py index abfe55f18..43bf726b8 100644 --- a/tests/modes/test_python3.py +++ b/tests/modes/test_python3.py @@ -4,13 +4,43 @@ """ import sys import os -from mu.modes.python3 import PythonMode, KernelRunner +from jupyter_client import kernelspec +from mu.modes.python3 import PythonMode, KernelRunner, make_kernel_spec from mu.modes.api import PYTHON3_APIS, SHARED_APIS, PI_APIS from mu.virtual_environment import venv from unittest import mock +def test_make_kernel_spec(): + """ + Test the generation of a kernel spec's kernel.json works as intended. + """ + mock_python = "/home/user/bin/python" + mock_dir = os.path.join("/home/user/config", "kernel") + with mock.patch("builtins.open", mock.mock_open()) as opener: + with mock.patch("mu.modes.python3.venv.interpreter", mock_python): + with mock.patch("mu.modes.python3.DATA_DIR", mock_dir): + with mock.patch("os.mkdir") as mkdir: + with mock.patch("json.dump") as dump: + make_kernel_spec(mock_dir) + mkdir.assert_called_with(mock_dir, 511) + json_path = os.path.join(mock_dir, "kernel.json") + opener.assert_called_once_with(json_path, "w") + expected = { + "argv": [ + mock_python, + "-m", + "ipykernel_launcher", + "-f", + "{connection_file}", + ], + "display_name": "Mu's Python 3 Kernel", + "language": "python", + } + dump.assert_called_once_with(expected, opener()) + + def test_kernel_runner_start_kernel(): """ Ensure the start_kernel method eventually emits the kernel_started signal @@ -34,8 +64,12 @@ def test_kernel_runner_start_kernel(): mock_kernel_manager_class = mock.MagicMock() mock_kernel_manager_class.return_value = mock_kernel_manager with mock.patch("mu.modes.python3.os", mock_os), mock.patch( - "mu.modes.python3.MuKernelManager", mock_kernel_manager_class - ), mock.patch("sys.platform", "darwin"): + "mu.modes.python3.QtKernelManager", mock_kernel_manager_class + ), mock.patch("sys.platform", "darwin"), mock.patch( + "mu.modes.python3.make_kernel_spec" + ), mock.patch( + "jupyter_client.kernelspec.install_kernel_spec" + ): kr.start_kernel() mock_os.chdir.assert_called_once_with("/a/path/to/mu_code") assert mock_os.environ["name"] == "value" @@ -48,6 +82,27 @@ def test_kernel_runner_start_kernel(): ) +def test_kernel_runner_kernel_spec_creation(): + """ + Test the creation and use of a sample kernel. + """ + kernel_name = "test_kernel_name" + kr = KernelRunner( + kernel_name=kernel_name, cwd="/a/path/to/mu_code", envars=[] + ) + try: + with mock.patch("mu.modes.python3.os.chdir"): + kr.start_kernel() + assert kr.kernel_started + assert kr.repl_kernel_manager.kernel_name == "test_kernel_name" + assert kr.repl_kernel_manager.kernel_spec.argv[0] == venv.interpreter + finally: + if kernel_name in kernelspec.find_kernel_specs(): + kr.repl_kernel_manager.kernel_spec_manager.remove_kernel_spec( + kernel_name + ) + + def test_kernel_runner_stop_kernel(): """ Ensure the stop_kernel method eventually emits the kernel_finished