Skip to content

Commit 648d4e6

Browse files
feat: implement Output widget that mimics a frontend
This is a port of voila-dashboards/voila#91 and subsequent fixes.
1 parent 6510bd9 commit 648d4e6

File tree

3 files changed

+912
-1
lines changed

3 files changed

+912
-1
lines changed

nbclient/client.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import datetime
21
import base64
2+
import collections
3+
import datetime
34
from textwrap import dedent
45

56
from async_generator import asynccontextmanager
@@ -22,6 +23,7 @@
2223
CellExecutionError
2324
)
2425
from .util import run_sync, ensure_async
26+
from .output_widget import OutputWidget
2527

2628

2729
def timestamp():
@@ -307,6 +309,11 @@ def reset_execution_trackers(self):
307309
self._display_id_map = {}
308310
self.widget_state = {}
309311
self.widget_buffers = {}
312+
# maps to list of hooks, where the last is used, this is used
313+
# to support nested use of output widgets.
314+
self.output_hook_stack = collections.defaultdict(list)
315+
# our front-end mimicing Output widgets
316+
self.output_widget_objects = {}
310317

311318
def start_kernel_manager(self):
312319
"""Creates a new kernel manager.
@@ -787,6 +794,14 @@ def process_message(self, msg, cell, cell_index):
787794
def output(self, outs, msg, display_id, cell_index):
788795
msg_type = msg['msg_type']
789796

797+
parent_msg_id = msg['parent_header'].get('msg_id')
798+
if self.output_hook_stack[parent_msg_id]:
799+
# if we have a hook registered, it will overrride our
800+
# default output behaviour (e.g. OutputWidget)
801+
hook = self.output_hook_stack[parent_msg_id][-1]
802+
hook.output(outs, msg, display_id, cell_index)
803+
return
804+
790805
try:
791806
out = output_from_msg(msg)
792807
except ValueError:
@@ -812,6 +827,15 @@ def output(self, outs, msg, display_id, cell_index):
812827

813828
def clear_output(self, outs, msg, cell_index):
814829
content = msg['content']
830+
831+
parent_msg_id = msg['parent_header'].get('msg_id')
832+
if self.output_hook_stack[parent_msg_id]:
833+
# if we have a hook registered, it will overrride our
834+
# default clear_output behaviour (e.g. OutputWidget)
835+
hook = self.output_hook_stack[parent_msg_id][-1]
836+
hook.clear_output(outs, msg, cell_index)
837+
return
838+
815839
if content.get('wait'):
816840
self.log.debug('Wait to clear output')
817841
self.clear_before_next_output = True
@@ -832,6 +856,24 @@ def handle_comm_msg(self, outs, msg, cell_index):
832856
self.widget_state.setdefault(content['comm_id'], {}).update(data['state'])
833857
if 'buffer_paths' in data and data['buffer_paths']:
834858
self.widget_buffers[content['comm_id']] = self._get_buffer_data(msg)
859+
# There are cases where we need to mimic a frontend, to get similar behaviour as
860+
# when using the Output widget from Jupyter lab/notebook
861+
if msg['msg_type'] == 'comm_open' and msg['content'].get('target_name') == 'jupyter.widget':
862+
content = msg['content']
863+
data = content['data']
864+
state = data['state']
865+
comm_id = msg['content']['comm_id']
866+
if state['_model_module'] == '@jupyter-widgets/output' and\
867+
state['_model_name'] == 'OutputModel':
868+
self.output_widget_objects[comm_id] = OutputWidget(comm_id, state, self.kc, self)
869+
elif msg['msg_type'] == 'comm_msg':
870+
content = msg['content']
871+
data = content['data']
872+
if 'state' in data:
873+
state = data['state']
874+
comm_id = msg['content']['comm_id']
875+
if comm_id in self.output_widget_objects:
876+
self.output_widget_objects[comm_id].set_state(state)
835877

836878
def _serialize_widget_state(self, state):
837879
"""Serialize a widget state, following format in @jupyter-widgets/schema."""
@@ -856,6 +898,22 @@ def _get_buffer_data(self, msg):
856898
)
857899
return encoded_buffers
858900

901+
def register_output_hook(self, msg_id, hook):
902+
"""Registers an override object that handles output/clear_output instead.
903+
904+
Multiple hooks can be registered, where the last one will be used (stack based)
905+
"""
906+
# mimics
907+
# https://jupyterlab.github.io/jupyterlab/services/interfaces/kernel.ikernelconnection.html#registermessagehook
908+
self.output_hook_stack[msg_id].append(hook)
909+
910+
def remove_output_hook(self, msg_id, hook):
911+
"""Unregisters an override object that handles output/clear_output instead"""
912+
# mimics
913+
# https://jupyterlab.github.io/jupyterlab/services/interfaces/kernel.ikernelconnection.html#removemessagehook
914+
removed_hook = self.output_hook_stack[msg_id].pop()
915+
assert removed_hook == hook
916+
859917

860918
def execute(nb, cwd=None, km=None, **kwargs):
861919
"""Execute a notebook's code, updating outputs within the notebook object.

nbclient/output_widget.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from ipykernel.jsonutil import json_clean
2+
from nbformat.v4 import output_from_msg
3+
4+
5+
class OutputWidget:
6+
"""This class mimics a front end output widget"""
7+
def __init__(self, comm_id, state, kernel_client, executor):
8+
self.comm_id = comm_id
9+
self.state = state
10+
self.kernel_client = kernel_client
11+
self.executor = executor
12+
self.topic = ('comm-%s' % self.comm_id).encode('ascii')
13+
self.outputs = self.state['outputs']
14+
self.clear_before_next_output = False
15+
16+
def clear_output(self, outs, msg, cell_index):
17+
self.parent_header = msg['parent_header']
18+
content = msg['content']
19+
if content.get('wait'):
20+
self.clear_before_next_output = True
21+
else:
22+
self.outputs = []
23+
# sync back the state to the kernel
24+
self.sync_state()
25+
if hasattr(self.executor, 'widget_state'):
26+
# sync the state to the nbconvert state as well, since that is used for testing
27+
self.executor.widget_state[self.comm_id]['outputs'] = self.outputs
28+
29+
def sync_state(self):
30+
state = {'outputs': self.outputs}
31+
msg = {'method': 'update', 'state': state, 'buffer_paths': []}
32+
self.send(msg)
33+
34+
def _publish_msg(self, msg_type, data=None, metadata=None, buffers=None, **keys):
35+
"""Helper for sending a comm message on IOPub"""
36+
data = {} if data is None else data
37+
metadata = {} if metadata is None else metadata
38+
content = json_clean(dict(data=data, comm_id=self.comm_id, **keys))
39+
msg = self.kernel_client.session.msg(msg_type, content=content, parent=self.parent_header,
40+
metadata=metadata)
41+
self.kernel_client.shell_channel.send(msg)
42+
43+
def send(self, data=None, metadata=None, buffers=None):
44+
self._publish_msg('comm_msg', data=data, metadata=metadata, buffers=buffers)
45+
46+
def output(self, outs, msg, display_id, cell_index):
47+
if self.clear_before_next_output:
48+
self.outputs = []
49+
self.clear_before_next_output = False
50+
self.parent_header = msg['parent_header']
51+
output = output_from_msg(msg)
52+
53+
if self.outputs:
54+
# try to coalesce/merge output text
55+
last_output = self.outputs[-1]
56+
if (last_output['output_type'] == 'stream' and
57+
output['output_type'] == 'stream' and
58+
last_output['name'] == output['name']):
59+
last_output['text'] += output['text']
60+
else:
61+
self.outputs.append(output)
62+
else:
63+
self.outputs.append(output)
64+
self.sync_state()
65+
if hasattr(self.executor, 'widget_state'):
66+
# sync the state to the nbconvert state as well, since that is used for testing
67+
self.executor.widget_state[self.comm_id]['outputs'] = self.outputs
68+
69+
def set_state(self, state):
70+
if 'msg_id' in state:
71+
msg_id = state.get('msg_id')
72+
if msg_id:
73+
self.executor.register_output_hook(msg_id, self)
74+
self.msg_id = msg_id
75+
else:
76+
self.executor.remove_output_hook(self.msg_id, self)
77+
self.msg_id = msg_id

0 commit comments

Comments
 (0)