Source code for widgets.julia_repl_widget
######################################################################################################################
# Copyright (C) 2017 - 2019 Spine project consortium
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################
"""
Class for a custom RichJupyterWidget to use as julia REPL.
:author: M. Marin (KTH)
:date: 22.5.2018
"""
import logging
from PySide2.QtWidgets import QMessageBox, QAction, QApplication
from PySide2.QtCore import Slot, Signal, Qt
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtconsole.manager import QtKernelManager, QtKernelRestarter
from jupyter_client.kernelspec import find_kernel_specs, NoSuchKernel
from config import JULIA_EXECUTABLE, JL_REPL_TIME_TO_DEAD, JL_REPL_RESTART_LIMIT
from widgets.toolbars import DraggableWidget
import qsubprocess
from helpers import busy_effect
[docs]class CustomQtKernelManager(QtKernelManager):
"""A QtKernelManager with a custom restarter, and a means to override the --project argument."""
@property
[docs] def kernel_spec(self):
if self._kernel_spec is None and self.kernel_name != "":
self._kernel_spec = self.kernel_spec_manager.get_kernel_spec(self.kernel_name)
self.override_project_arg()
return self._kernel_spec
[docs] def override_project_arg(self):
if self.project_path is None:
return
ind = next((k for k, x in enumerate(self._kernel_spec.argv) if x.startswith("--project")), None)
if not ind:
return
self._kernel_spec.argv[ind] = f"--project={self.project_path}"
[docs] def start_restarter(self):
"""Start a restarter with custom time to dead and restart limit."""
if self.autorestart and self.has_kernel:
if self._restarter is None:
self._restarter = QtKernelRestarter(
time_to_dead=JL_REPL_TIME_TO_DEAD,
restart_limit=JL_REPL_RESTART_LIMIT,
kernel_manager=self,
parent=self,
log=self.log,
)
self._restarter.add_callback(self._handle_kernel_restarted, event='restart')
self._restarter.add_callback(self._handle_kernel_left_dead, event='dead')
self._restarter.start()
[docs]class JuliaREPLWidget(RichJupyterWidget):
"""Class for a custom RichJupyterWidget.
Attributes:
toolbox (ToolboxUI): QMainWindow instance
"""
def __init__(self, toolbox):
super().__init__(parent=toolbox)
self._toolbox = toolbox
self.custom_restart = True
self.kernel_name = None # The name of the Julia kernel from settings last checked
self.kernel_manager = None
self.kernel_client = None
self.running = False
self.command = None
self.kernel_execution_state = None
self.ijulia_process = None # IJulia installation/reconfiguration process (QSubProcess)
self.julia_exe = None
self.julia_project_path = None
self.execution_failed_to_start = False
self.starting = False
self.normal_cursor = self._control.viewport().cursor()
# Actions
self._copy_input_action = QAction('Copy (Only Input)', self)
self._copy_input_action.triggered.connect(lambda checked: self.copy_input())
self._copy_input_action.setEnabled(False)
self.copy_available.connect(self._copy_input_action.setEnabled)
self.start_repl_action = QAction("Start", self)
self.start_repl_action.triggered.connect(lambda checked: self.start_jupyter_kernel())
self.restart_repl_action = QAction("Restart", self)
self.restart_repl_action.triggered.connect(lambda checked: self.restart_jupyter_kernel())
# Set logging level for jupyter module loggers
traitlets_logger = logging.getLogger("traitlets")
asyncio_logger = logging.getLogger("asyncio")
traitlets_logger.setLevel(level=logging.WARNING)
asyncio_logger.setLevel(level=logging.WARNING)
@busy_effect
[docs] def julia_kernel_name(self):
"""Returns the name of the julia kernel specification, according to the
selected julia interpreter in settings. Returns None if julia version
cannot be determined.
"""
self._toolbox.msg.emit("\tInitializing Julia...")
julia_path = self._toolbox.qsettings().value("appSettings/juliaPath", defaultValue="")
if julia_path != "":
self.julia_exe = julia_path
else:
self.julia_exe = JULIA_EXECUTABLE
program = "{0}".format(self.julia_exe)
args = list()
args.append("-e")
args.append("println(VERSION)")
q_process = qsubprocess.QSubProcess(self._toolbox, program, args, silent=True)
q_process.start_process()
if not q_process.wait_for_finished(msecs=5000):
self._toolbox.msg_error.emit(
"\tCouldn't find out Julia version. Make sure that Julia is correctly installed and try again."
)
return None
julia_version = q_process.output
self._toolbox.msg.emit("\tJulia version is {0}".format(julia_version))
kernel_name = "julia-" + ".".join(julia_version.split(".")[0:2])
if self.kernel_name is not None and self.kernel_name != kernel_name:
self._toolbox.msg_warning.emit(
"\tJulia version has changed in settings. New kernel specification is {0}".format(kernel_name)
)
return kernel_name
[docs] def start_jupyter_kernel(self):
"""Start a Julia Jupyter kernel if available.
Returns:
True if the kernel is started, or in process of being started (installing/reconfiguring IJulia)
False if the kernel cannot be started and the user chooses not to install/reconfigure IJulia
"""
kernel_name = self.julia_kernel_name()
if not kernel_name:
return False
julia_project_path = self._toolbox.qsettings().value("appSettings/juliaProjectPath", defaultValue="")
if julia_project_path == "":
julia_project_path = "@."
if self.kernel_manager and kernel_name == self.kernel_name and julia_project_path == self.julia_project_path:
self._toolbox.msg.emit("*** Using previously started Julia Console ***")
return True
self.kernel_name = kernel_name
self.julia_project_path = julia_project_path
self.kernel_execution_state = None
kernel_specs = find_kernel_specs()
# logging.debug("kernel_specs:{0}".format(kernel_specs))
julia_kernel_names = [x for x in kernel_specs if x.startswith('julia')]
if self.kernel_name in julia_kernel_names:
return self.start_available_jupyter_kernel()
self._toolbox.msg_error.emit("\tCouldn't find kernel specification {0}".format(self.kernel_name))
return self.handle_repl_failed_to_start()
[docs] def start_available_jupyter_kernel(self):
"""Start a Jupyter kernel which is available (from the attribute `kernel_name`)
Returns:
True if the kernel is started, or in process of being started (reconfiguring IJulia)
False if the kernel cannot be started and the user chooses not to reconfigure IJulia
"""
self.starting = True
self._toolbox.msg.emit("*** Starting Julia Console ***")
kernel_manager = CustomQtKernelManager(kernel_name=self.kernel_name, project_path=self.julia_project_path)
try:
kernel_manager.start_kernel()
self.kernel_manager = kernel_manager
self.kernel_manager.kernel_left_dead.connect(self._handle_kernel_left_dead)
self.setup_client()
return True
except FileNotFoundError:
self._toolbox.msg_error.emit("\tCouldn't find Julia executable specified by Jupyter kernel.")
return self.handle_repl_failed_to_start()
except NoSuchKernel: # TODO: in which case this exactly happens?
self._toolbox.msg_error.emit("\t[NoSuchKernel] Couldn't find Julia Jupyter kernel.")
return self.handle_repl_failed_to_start()
[docs] def check_ijulia(self):
"""Check if IJulia is installed, returns True, False, or None if unable to determine."""
self._toolbox.msg.emit("\tChecking whether IJulia is installed or not...")
program = "{0}".format(self.julia_exe)
args = list()
args.append("-e")
args.append("try using Pkg catch; end; try using IJulia; println(ARGS[1]) catch; println(ARGS[2]) end")
args.append("True")
args.append("False")
q_process = qsubprocess.QSubProcess(self._toolbox, program, args, silent=True)
q_process.start_process()
if not q_process.wait_for_finished(msecs=5000):
self._toolbox.msg_error.emit(
"\tCouldn't start Julia to check IJulia status. "
"Please make sure that Julia is correctly installed and try again."
)
return None
if q_process.output == "True":
self._toolbox.msg.emit("\tIJulia is installed")
return True
self._toolbox.msg.emit("\tIJulia is not installed")
return False
[docs] def handle_repl_failed_to_start(self):
"""Prompt user to install IJulia if missing, or rebuild it otherwise.
Returns:
Boolean value depending on whether or not the problem is being handled.
"""
check_ijulia = self.check_ijulia()
if check_ijulia is None:
# Unable to determine
return False
if not check_ijulia:
# IJulia is not installed, try installing it
# First find out active project to ask user's permission to change it
program = "{0}".format(self.julia_exe)
args = list()
args.append(f"--project={self.julia_project_path}")
args.append("-e")
args.append("println(Base.active_project())")
q_process = qsubprocess.QSubProcess(self._toolbox, program, args, silent=True)
q_process.start_process()
if not q_process.wait_for_finished(msecs=5000):
self._toolbox.msg_error.emit(
"\tCouldn't find out Julia active project. "
"Make sure that Julia is correctly installed and try again."
)
return False
julia_active_project = q_process.output
msg = QMessageBox()
msg.setIcon(QMessageBox.Question)
msg.setWindowTitle("IJulia installation needed")
msg.setText(
"Spine Toolbox needs to do the following modifications to the Julia project at <b>{0}</b>:"
"<p>Install the IJulia package.".format(julia_active_project)
)
allow_button = msg.addButton("Allow", QMessageBox.YesRole)
msg.addButton("Cancel", QMessageBox.RejectRole)
msg.exec_() # Show message box
if msg.clickedButton() != allow_button:
self.starting = False
self._control.viewport().setCursor(self.normal_cursor)
return False
command = "{0}".format(self.julia_exe)
args = list()
args.append(f"--project={self.julia_project_path}")
args.append("-e")
args.append("try using Pkg catch; end; Pkg.add(ARGS[1])")
args.append("IJulia")
self.ijulia_process = qsubprocess.QSubProcess(self._toolbox, command, args)
self.ijulia_process.subprocess_finished_signal.connect(self.handle_ijulia_installation_finished)
self.ijulia_process.start_process()
self._toolbox.msg.emit("*** Installing <b>IJulia</b> ***")
self._toolbox.msg_warning.emit("<b>Depending on your system, this process can take a few minutes...</b>")
else:
# IJulia is installed but repl still failed to start, try rebuilding IJulia
# TODO: what happens if this doesn't solve the problem? Is this some sort of infinite loop?
command = "{0}".format(self.julia_exe)
args = list()
args.append("-e")
args.append("try using Pkg catch; end; Pkg.build(ARGS[1])")
args.append("IJulia")
self.ijulia_process = qsubprocess.QSubProcess(self._toolbox, command, args)
self.ijulia_process.subprocess_finished_signal.connect(self.handle_ijulia_rebuild_finished)
self.ijulia_process.start_process()
self._toolbox.msg.emit("*** Re-building <b>IJulia</b> ***")
self._toolbox.msg_warning.emit("<b>Depending on your system, this process can take a few minutes...</b>")
return True
[docs] def restart_jupyter_kernel(self):
"""Restart the julia jupyter kernel if it's already started. Otherwise, or if the julia version
has changed in settings, start a new jupyter kernel.
"""
kernel_name = self.julia_kernel_name()
if not kernel_name:
return
julia_project_path = self._toolbox.qsettings().value("appSettings/juliaProjectPath", defaultValue="")
if julia_project_path == "":
julia_project_path = "@."
if self.kernel_manager and kernel_name == self.kernel_name and julia_project_path == self.julia_project_path:
# Restart current kernel
self.starting = True
self._toolbox.msg.emit("*** Restarting Julia REPL ***")
self.kernel_client.stop_channels()
self.kernel_manager.restart_kernel(now=True)
self.setup_client()
else:
# No kernel to restart (!) or julia has changed in settings. Start a new kernel
self.kernel_name = kernel_name
self.julia_project_path = julia_project_path
kernel_specs = find_kernel_specs()
julia_kernel_names = [x for x in kernel_specs if x.startswith('julia')]
if self.kernel_name in julia_kernel_names:
self.start_available_jupyter_kernel()
else:
self._toolbox.msg_error.emit(
"\tCouldn't find Jupyter kernel specification for {}".format(self.kernel_name)
)
self.handle_repl_failed_to_start()
[docs] def setup_client(self):
if not self.kernel_manager:
return
kernel_client = self.kernel_manager.client()
kernel_client.hb_channel.time_to_dead = JL_REPL_TIME_TO_DEAD # Not crucial, but nicer to keep the same as mngr
kernel_client.start_channels()
self.kernel_client = kernel_client
@Slot(name="_handle_kernel_restarted")
[docs] def _handle_kernel_restarted(self, died=True):
"""Called when the kernel is restarted, i.e., when time to dead has elapsed."""
super()._handle_kernel_restarted(died=died)
if not died:
return
restart_count = self.kernel_manager._restarter._restart_count
restart_limit = self.kernel_manager._restarter.restart_limit
self._toolbox.msg_warning.emit(
"\tFailed to start Julia Jupyter kernel (attempt {0} of {1})".format(restart_count, restart_limit)
)
@Slot(name="_handle_kernel_left_dead")
[docs] def _handle_kernel_left_dead(self):
"""Called when the kernel is finally declared dead, i.e., the restart limit has been reached."""
restart_limit = self.kernel_manager._restarter.restart_limit
self._toolbox.msg_error.emit(
"\tFailed to start Julia Jupyter kernel (attempt {0} of {0})".format(restart_limit)
)
self.kernel_manager = None
self.kernel_client = None # TODO: needed?
self.handle_repl_failed_to_start()
@Slot(int, name="handle_ijulia_installation_finished")
[docs] def handle_ijulia_installation_finished(self, ret):
"""Run when IJulia installation process finishes"""
if self.check_ijulia_process(ret):
self._toolbox.msg.emit("IJulia successfully installed.")
# Try to start jupyter kernel again now IJulia is installed/reconfigured
self.start_available_jupyter_kernel()
self.ijulia_process.deleteLater()
self.ijulia_process = None
@Slot(int, name="handle_ijulia_rebuild_finished")
[docs] def handle_ijulia_rebuild_finished(self, ret):
"""Run when IJulia rebuild process finishes"""
if self.check_ijulia_process(ret):
self._toolbox.msg.emit("IJulia successfully rebuild.")
# Try to start jupyter kernel again now IJulia is installed/reconfigured
self.start_available_jupyter_kernel()
self.ijulia_process.deleteLater()
self.ijulia_process = None
[docs] def check_ijulia_process(self, ret):
"""Check whether or not the IJulia process finished successfully"""
if self.ijulia_process.process_failed:
if self.ijulia_process.process_failed_to_start:
self._toolbox.msg_error.emit(
"Process failed to start. Please make sure that Julia is properly installed and try again."
)
else:
self._toolbox.msg_error.emit("Process failed [exit code:{0}]".format(ret))
if self.command:
self.execution_failed_to_start = True
self.execution_finished_signal.emit(-9999)
self.command = None
return False
return True
@Slot("dict", name="_handle_execute_reply")
[docs] def _handle_execute_reply(self, msg):
super()._handle_execute_reply(msg)
if self.running:
content = msg['content']
if content['execution_count'] == 0:
return # This is not the instance, this is just the kernel saying hello
if content['status'] == 'ok':
self.execution_finished_signal.emit(0) # success code
else:
self.execution_finished_signal.emit(-9999) # any error code
self.running = False
self.command = None
@Slot("dict", name="_handle_status")
[docs] def _handle_status(self, msg):
"""Handle status message. If we have a command in line
and the kernel reports status 'idle', execute that command.
"""
super()._handle_status(msg)
self.kernel_execution_state = msg['content'].get('execution_state', '')
if self.kernel_execution_state == 'idle':
if self.starting:
self.starting = False
self._toolbox.msg_success.emit(
"\tJulia REPL successfully started using kernel specification {}".format(self.kernel_name)
)
self._control.viewport().setCursor(self.normal_cursor)
elif self.command and not self.running:
self._toolbox.msg_warning.emit("\tExecution in progress. See <b>Julia Console</b> for messages.")
self.running = True
self.execute(self.command)
@Slot("dict", name="_handle_error")
[docs] def _handle_error(self, msg):
"""Handle error messages."""
super()._handle_error(msg)
if self.running:
self.execution_finished_signal.emit(-9999) # any error code
self.running = False
[docs] def execute_instance(self, command):
"""Try and start the jupyter kernel.
Execute command immediately if kernel is idle.
If not, it will be executed as soon as the kernel
becomes idle (see `_handle_status` method).
"""
if not command:
return
self.command = command
if not self.start_jupyter_kernel():
self.execution_failed_to_start = True
self.execution_finished_signal.emit(-9999)
return
# Kernel is started or in process of being started
if self.kernel_execution_state == 'idle' and not self.running:
self._toolbox.msg_warning.emit("\tExecution in progress. See <b>Julia Console</b> for messages.")
self.running = True
self.execute(self.command)
[docs] def terminate_process(self):
"""Send interrupt signal to kernel."""
# logging.debug("interrupt exec")
self.kernel_manager.interrupt_kernel()
# TODO: Block execution until kernel has been interrupted and then emit the signal
self._toolbox.project().execution_instance.project_item_execution_finished_signal.emit(-2)
[docs] def shutdown_jupyter_kernel(self):
"""Shut down the jupyter kernel."""
if not self.kernel_client:
return
self._toolbox.msg.emit("Shutting down Julia Console...")
self.kernel_client.stop_channels()
self.kernel_manager.shutdown_kernel(now=True)
[docs] def enterEvent(self, event):
"""Set busy cursor during REPL (re)starts."""
if self.starting:
self._control.viewport().setCursor(Qt.BusyCursor)
[docs] def dragEnterEvent(self, e):
"""Don't accept drops from Add Item Toolbar."""
source = e.source()
if isinstance(source, DraggableWidget):
e.ignore()
else:
super().dragEnterEvent(e)
[docs] def copy_input(self):
"""Copy only input."""
if not self._control.hasFocus():
return
text = self._control.textCursor().selection().toPlainText()
if not text:
return
# Remove prompts.
lines = text.splitlines()
useful_lines = []
for line in lines:
m = self._highlighter._classic_prompt_re.match(line)
if m:
useful_lines.append(line[len(m.group(0)) :])
continue
m = self._highlighter._ipy_prompt_re.match(line)
if m:
useful_lines.append(line[len(m.group(0)) :])
continue
text = '\n'.join(useful_lines)
try:
was_newline = text[-1] == '\n'
except IndexError:
was_newline = False
if was_newline: # user doesn't need newline
text = text[:-1]
QApplication.clipboard().setText(text)