#############################################################################
|
|
# Copyright (c) 2018, Voila Contributors #
|
|
# Copyright (c) 2018, QuantStack #
|
|
# #
|
|
# Distributed under the terms of the BSD 3-Clause License. #
|
|
# #
|
|
# The full license is in the file LICENSE, distributed with this software. #
|
|
#############################################################################
|
|
import collections
|
|
import logging
|
|
try:
|
|
from time import monotonic # Py 3
|
|
except ImportError:
|
|
from time import time as monotonic # Py 2
|
|
|
|
from nbconvert.preprocessors import ClearOutputPreprocessor
|
|
from nbconvert.preprocessors.execute import CellExecutionError, ExecutePreprocessor
|
|
from nbformat.v4 import output_from_msg
|
|
import zmq
|
|
|
|
from traitlets import Unicode
|
|
from ipykernel.jsonutil import json_clean
|
|
|
|
try:
|
|
TimeoutError # Py 3
|
|
except NameError:
|
|
TimeoutError = RuntimeError # Py 2
|
|
|
|
|
|
def strip_code_cell_warnings(cell):
|
|
"""Strip any warning outputs and traceback from a code cell."""
|
|
if cell['cell_type'] != 'code':
|
|
return cell
|
|
|
|
outputs = cell['outputs']
|
|
|
|
cell['outputs'] = [
|
|
output for output in outputs
|
|
if output['output_type'] != 'stream' or output['name'] != 'stderr'
|
|
]
|
|
|
|
return cell
|
|
|
|
|
|
def should_strip_error(config):
|
|
"""Return True if errors should be stripped from the Notebook, False otherwise, depending on the current config."""
|
|
return 'Voila' not in config or 'log_level' not in config['Voila'] or config['Voila']['log_level'] != logging.DEBUG
|
|
|
|
|
|
class OutputWidget:
|
|
"""This class mimics a front end output widget"""
|
|
def __init__(self, comm_id, state, kernel_client, executor):
|
|
self.comm_id = comm_id
|
|
self.state = state
|
|
self.kernel_client = kernel_client
|
|
self.executor = executor
|
|
self.topic = ('comm-%s' % self.comm_id).encode('ascii')
|
|
self.outputs = self.state['outputs']
|
|
self.clear_before_next_output = False
|
|
|
|
def clear_output(self, outs, msg, cell_index):
|
|
self.parent_header = msg['parent_header']
|
|
content = msg['content']
|
|
if content.get('wait'):
|
|
self.clear_before_next_output = True
|
|
else:
|
|
self.outputs = []
|
|
# sync back the state to the kernel
|
|
self.sync_state()
|
|
if hasattr(self.executor, 'widget_state'):
|
|
# sync the state to the nbconvert state as well, since that is used for testing
|
|
self.executor.widget_state[self.comm_id]['outputs'] = self.outputs
|
|
|
|
def sync_state(self):
|
|
state = {'outputs': self.outputs}
|
|
msg = {'method': 'update', 'state': state, 'buffer_paths': []}
|
|
self.send(msg)
|
|
|
|
def _publish_msg(self, msg_type, data=None, metadata=None, buffers=None, **keys):
|
|
"""Helper for sending a comm message on IOPub"""
|
|
data = {} if data is None else data
|
|
metadata = {} if metadata is None else metadata
|
|
content = json_clean(dict(data=data, comm_id=self.comm_id, **keys))
|
|
msg = self.kernel_client.session.msg(msg_type, content=content, parent=self.parent_header, metadata=metadata)
|
|
self.kernel_client.shell_channel.send(msg)
|
|
|
|
def send(self, data=None, metadata=None, buffers=None):
|
|
self._publish_msg('comm_msg', data=data, metadata=metadata, buffers=buffers)
|
|
|
|
def output(self, outs, msg, display_id, cell_index):
|
|
if self.clear_before_next_output:
|
|
self.outputs = []
|
|
self.clear_before_next_output = False
|
|
self.parent_header = msg['parent_header']
|
|
output = output_from_msg(msg)
|
|
|
|
if self.outputs:
|
|
# try to coalesce/merge output text
|
|
last_output = self.outputs[-1]
|
|
if (last_output['output_type'] == 'stream' and
|
|
output['output_type'] == 'stream' and
|
|
last_output['name'] == output['name']):
|
|
last_output['text'] += output['text']
|
|
else:
|
|
self.outputs.append(output)
|
|
else:
|
|
self.outputs.append(output)
|
|
self.sync_state()
|
|
if hasattr(self.executor, 'widget_state'):
|
|
# sync the state to the nbconvert state as well, since that is used for testing
|
|
self.executor.widget_state[self.comm_id]['outputs'] = self.outputs
|
|
|
|
def set_state(self, state):
|
|
if 'msg_id' in state:
|
|
msg_id = state.get('msg_id')
|
|
if msg_id:
|
|
self.executor.register_output_hook(msg_id, self)
|
|
self.msg_id = msg_id
|
|
else:
|
|
self.executor.remove_output_hook(self.msg_id, self)
|
|
self.msg_id = msg_id
|
|
|
|
|
|
class VoilaExecutePreprocessor(ExecutePreprocessor):
|
|
"""Execute, but respect the output widget behaviour"""
|
|
cell_error_instruction = Unicode(
|
|
'Please run Voila with --debug to see the error message.',
|
|
config=True,
|
|
help=(
|
|
'instruction given to user to debug cell errors'
|
|
)
|
|
)
|
|
|
|
def __init__(self, **kwargs):
|
|
super(VoilaExecutePreprocessor, self).__init__(**kwargs)
|
|
self.output_hook_stack = collections.defaultdict(list) # maps to list of hooks, where the last is used
|
|
self.output_objects = {}
|
|
|
|
def preprocess(self, nb, resources, km=None):
|
|
try:
|
|
result = super(VoilaExecutePreprocessor, self).preprocess(nb, resources=resources, km=km)
|
|
except CellExecutionError as e:
|
|
self.log.error(e)
|
|
result = (nb, resources)
|
|
|
|
# Strip errors and traceback if not in debug mode
|
|
if should_strip_error(self.config):
|
|
self.strip_notebook_errors(nb)
|
|
|
|
return result
|
|
|
|
def preprocess_cell(self, cell, resources, cell_index, store_history=True):
|
|
try:
|
|
# TODO: pass store_history as a 5th argument when we can require nbconver >=5.6.1
|
|
# result = super(VoilaExecutePreprocessor, self).preprocess_cell(cell, resources, cell_index, store_history)
|
|
result = super(VoilaExecutePreprocessor, self).preprocess_cell(cell, resources, cell_index)
|
|
except CellExecutionError as e:
|
|
self.log.error(e)
|
|
result = (cell, resources)
|
|
|
|
# Strip errors and traceback if not in debug mode
|
|
if should_strip_error(self.config):
|
|
strip_code_cell_warnings(cell)
|
|
self.strip_code_cell_errors(cell)
|
|
|
|
return result
|
|
|
|
def register_output_hook(self, msg_id, hook):
|
|
# mimics
|
|
# https://jupyterlab.github.io/jupyterlab/services/interfaces/kernel.ikernelconnection.html#registermessagehook
|
|
self.output_hook_stack[msg_id].append(hook)
|
|
|
|
def remove_output_hook(self, msg_id, hook):
|
|
# mimics
|
|
# https://jupyterlab.github.io/jupyterlab/services/interfaces/kernel.ikernelconnection.html#removemessagehook
|
|
removed_hook = self.output_hook_stack[msg_id].pop()
|
|
assert removed_hook == hook
|
|
|
|
def output(self, outs, msg, display_id, cell_index):
|
|
parent_msg_id = msg['parent_header'].get('msg_id')
|
|
if self.output_hook_stack[parent_msg_id]:
|
|
hook = self.output_hook_stack[parent_msg_id][-1]
|
|
hook.output(outs, msg, display_id, cell_index)
|
|
return
|
|
super(VoilaExecutePreprocessor, self).output(outs, msg, display_id, cell_index)
|
|
|
|
def handle_comm_msg(self, outs, msg, cell_index):
|
|
super(VoilaExecutePreprocessor, self).handle_comm_msg(outs, msg, cell_index)
|
|
self.log.debug('comm msg: %r', msg)
|
|
if msg['msg_type'] == 'comm_open' and msg['content'].get('target_name') == 'jupyter.widget':
|
|
content = msg['content']
|
|
data = content['data']
|
|
state = data['state']
|
|
comm_id = msg['content']['comm_id']
|
|
if state['_model_module'] == '@jupyter-widgets/output' and state['_model_name'] == 'OutputModel':
|
|
self.output_objects[comm_id] = OutputWidget(comm_id, state, self.kc, self)
|
|
elif msg['msg_type'] == 'comm_msg':
|
|
content = msg['content']
|
|
data = content['data']
|
|
if 'state' in data:
|
|
state = data['state']
|
|
comm_id = msg['content']['comm_id']
|
|
if comm_id in self.output_objects:
|
|
self.output_objects[comm_id].set_state(state)
|
|
|
|
def clear_output(self, outs, msg, cell_index):
|
|
parent_msg_id = msg['parent_header'].get('msg_id')
|
|
if self.output_hook_stack[parent_msg_id]:
|
|
hook = self.output_hook_stack[parent_msg_id][-1]
|
|
hook.clear_output(outs, msg, cell_index)
|
|
return
|
|
super(VoilaExecutePreprocessor, self).clear_output(outs, msg, cell_index)
|
|
|
|
def strip_notebook_errors(self, nb):
|
|
"""Strip error messages and traceback from a Notebook."""
|
|
cells = nb['cells']
|
|
|
|
code_cells = [cell for cell in cells if cell['cell_type'] == 'code']
|
|
|
|
for cell in code_cells:
|
|
strip_code_cell_warnings(cell)
|
|
self.strip_code_cell_errors(cell)
|
|
|
|
return nb
|
|
|
|
def strip_code_cell_errors(self, cell):
|
|
"""Strip any error outputs and traceback from a code cell."""
|
|
# There is no 'outputs' key for markdown cells
|
|
if cell['cell_type'] != 'code':
|
|
return cell
|
|
|
|
outputs = cell['outputs']
|
|
|
|
error_outputs = [output for output in outputs if output['output_type'] == 'error']
|
|
|
|
error_message = 'There was an error when executing cell [{}]. {}'.format(cell['execution_count'], self.cell_error_instruction)
|
|
|
|
for output in error_outputs:
|
|
output['ename'] = 'ExecutionError'
|
|
output['evalue'] = 'Execution error'
|
|
output['traceback'] = [error_message]
|
|
|
|
return cell
|
|
|
|
# make it nbconvert 5.5 compatible
|
|
def _get_timeout(self, cell):
|
|
if self.timeout_func is not None and cell is not None:
|
|
timeout = self.timeout_func(cell)
|
|
else:
|
|
timeout = self.timeout
|
|
|
|
if not timeout or timeout < 0:
|
|
timeout = None
|
|
|
|
return timeout
|
|
|
|
# make it nbconvert 5.5 compatible
|
|
def _handle_timeout(self, timeout):
|
|
self.log.error(
|
|
"Timeout waiting for execute reply (%is)." % timeout)
|
|
if self.interrupt_on_timeout:
|
|
self.log.error("Interrupting kernel")
|
|
self.km.interrupt_kernel()
|
|
else:
|
|
raise TimeoutError("Cell execution timed out")
|
|
|
|
def run_cell(self, cell, cell_index=0, store_history=False):
|
|
parent_msg_id = self.kc.execute(cell.source, store_history=store_history, stop_on_error=not self.allow_errors)
|
|
self.log.debug("Executing cell:\n%s", cell.source)
|
|
exec_timeout = self._get_timeout(cell)
|
|
deadline = None
|
|
if exec_timeout is not None:
|
|
deadline = monotonic() + exec_timeout
|
|
|
|
cell.outputs = []
|
|
self.clear_before_next_output = False
|
|
|
|
# we need to have a reply, and return to idle before we can consider the cell executed
|
|
idle = False
|
|
execute_reply = None
|
|
|
|
deadline_passed = 0
|
|
deadline_passed_max = 5
|
|
while not idle or execute_reply is None:
|
|
# we want to timeout regularly, to see if the kernel is still alive
|
|
# this is tested in preprocessors/test/test_execute.py#test_kernel_death
|
|
# this actually fakes the kernel death, and we might be able to use the xlist
|
|
# to detect a disconnected kernel
|
|
timeout = min(1, deadline - monotonic())
|
|
# if we interrupt on timeout, we allow 1 seconds to pass till deadline
|
|
# to make sure we get the interrupt message
|
|
if timeout >= 0.0:
|
|
# we include 0, which simply is a poll to see if we have messages left
|
|
rlist = zmq.select([self.kc.iopub_channel.socket, self.kc.shell_channel.socket], [], [], timeout)[0]
|
|
if not rlist:
|
|
self._check_alive()
|
|
if monotonic() > deadline:
|
|
self._handle_timeout(exec_timeout)
|
|
deadline_passed += 1
|
|
assert self.interrupt_on_timeout
|
|
if deadline_passed <= deadline_passed_max:
|
|
# when we interrupt, we want to do this multiple times, so we give some
|
|
# extra time to handle the interrupt message
|
|
deadline += self.iopub_timeout
|
|
if self.kc.shell_channel.socket in rlist:
|
|
msg = self.kc.shell_channel.get_msg(block=False)
|
|
if msg['parent_header'].get('msg_id') == parent_msg_id:
|
|
execute_reply = msg
|
|
if self.kc.iopub_channel.socket in rlist:
|
|
msg = self.kc.iopub_channel.get_msg(block=False)
|
|
if msg['parent_header'].get('msg_id') == parent_msg_id:
|
|
if msg['msg_type'] == 'status' and msg['content']['execution_state'] == 'idle':
|
|
idle = True
|
|
else:
|
|
self.process_message(msg, cell, cell_index)
|
|
else:
|
|
self.log.debug("Received message for which we were not the parent: %s", msg)
|
|
else:
|
|
self._handle_timeout(exec_timeout)
|
|
break
|
|
|
|
return execute_reply, cell.outputs
|
|
|
|
|
|
def executenb(nb, cwd=None, km=None, **kwargs):
|
|
resources = {}
|
|
if cwd is not None:
|
|
resources['metadata'] = {'path': cwd} # pragma: no cover
|
|
# Clear any stale output, in case of exception
|
|
nb, resources = ClearOutputPreprocessor().preprocess(nb, resources)
|
|
ep = VoilaExecutePreprocessor(**kwargs)
|
|
return ep.preprocess(nb, resources, km=km)[0]
|