Skip to content

Commit 35ff13d

Browse files
Merge pull request #68 from maartenbreddels/feat_mimic_output_widget
feat: implement Output widget that mimics a frontend
2 parents 6510bd9 + 6add32d commit 35ff13d

File tree

6 files changed

+1147
-2
lines changed

6 files changed

+1147
-2
lines changed

docs/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 0.4.0 (unreleased)
4+
5+
### Major Changes
6+
7+
- Mimic an Output widget at the frontend so that the Output widget behaves correctly [#68](https://github.com/jupyter/nbclient/pull/68)
8+
39
## 0.3.1
410

511
### Fixes

nbclient/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version = '0.3.1'
1+
version = '0.4.0-dev.0'

nbclient/client.py

Lines changed: 74 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():
@@ -299,6 +301,15 @@ def __init__(self, nb, km=None, **kw):
299301
self.nb = nb
300302
self.km = km
301303
self.reset_execution_trackers()
304+
self.widget_registry = {
305+
'@jupyter-widgets/output': {
306+
'OutputModel': OutputWidget
307+
}
308+
}
309+
# comm_open_handlers should return an object with a .handle_msg(msg) method or None
310+
self.comm_open_handlers = {
311+
'jupyter.widget': self.on_comm_open_jupyter_widget
312+
}
302313

303314
def reset_execution_trackers(self):
304315
"""Resets any per-execution trackers.
@@ -307,6 +318,11 @@ def reset_execution_trackers(self):
307318
self._display_id_map = {}
308319
self.widget_state = {}
309320
self.widget_buffers = {}
321+
# maps to list of hooks, where the last is used, this is used
322+
# to support nested use of output widgets.
323+
self.output_hook_stack = collections.defaultdict(list)
324+
# our front-end mimicing Output widgets
325+
self.comm_objects = {}
310326

311327
def start_kernel_manager(self):
312328
"""Creates a new kernel manager.
@@ -787,6 +803,14 @@ def process_message(self, msg, cell, cell_index):
787803
def output(self, outs, msg, display_id, cell_index):
788804
msg_type = msg['msg_type']
789805

806+
parent_msg_id = msg['parent_header'].get('msg_id')
807+
if self.output_hook_stack[parent_msg_id]:
808+
# if we have a hook registered, it will overrride our
809+
# default output behaviour (e.g. OutputWidget)
810+
hook = self.output_hook_stack[parent_msg_id][-1]
811+
hook.output(outs, msg, display_id, cell_index)
812+
return
813+
790814
try:
791815
out = output_from_msg(msg)
792816
except ValueError:
@@ -812,6 +836,15 @@ def output(self, outs, msg, display_id, cell_index):
812836

813837
def clear_output(self, outs, msg, cell_index):
814838
content = msg['content']
839+
840+
parent_msg_id = msg['parent_header'].get('msg_id')
841+
if self.output_hook_stack[parent_msg_id]:
842+
# if we have a hook registered, it will overrride our
843+
# default clear_output behaviour (e.g. OutputWidget)
844+
hook = self.output_hook_stack[parent_msg_id][-1]
845+
hook.clear_output(outs, msg, cell_index)
846+
return
847+
815848
if content.get('wait'):
816849
self.log.debug('Wait to clear output')
817850
self.clear_before_next_output = True
@@ -832,6 +865,19 @@ def handle_comm_msg(self, outs, msg, cell_index):
832865
self.widget_state.setdefault(content['comm_id'], {}).update(data['state'])
833866
if 'buffer_paths' in data and data['buffer_paths']:
834867
self.widget_buffers[content['comm_id']] = self._get_buffer_data(msg)
868+
# There are cases where we need to mimic a frontend, to get similar behaviour as
869+
# when using the Output widget from Jupyter lab/notebook
870+
if msg['msg_type'] == 'comm_open':
871+
handler = self.comm_open_handlers.get(msg['content'].get('target_name'))
872+
comm_id = msg['content']['comm_id']
873+
comm_object = handler(msg)
874+
if comm_object:
875+
self.comm_objects[comm_id] = comm_object
876+
elif msg['msg_type'] == 'comm_msg':
877+
content = msg['content']
878+
comm_id = msg['content']['comm_id']
879+
if comm_id in self.comm_objects:
880+
self.comm_objects[comm_id].handle_msg(msg)
835881

836882
def _serialize_widget_state(self, state):
837883
"""Serialize a widget state, following format in @jupyter-widgets/schema."""
@@ -856,6 +902,33 @@ def _get_buffer_data(self, msg):
856902
)
857903
return encoded_buffers
858904

905+
def register_output_hook(self, msg_id, hook):
906+
"""Registers an override object that handles output/clear_output instead.
907+
908+
Multiple hooks can be registered, where the last one will be used (stack based)
909+
"""
910+
# mimics
911+
# https://jupyterlab.github.io/jupyterlab/services/interfaces/kernel.ikernelconnection.html#registermessagehook
912+
self.output_hook_stack[msg_id].append(hook)
913+
914+
def remove_output_hook(self, msg_id, hook):
915+
"""Unregisters an override object that handles output/clear_output instead"""
916+
# mimics
917+
# https://jupyterlab.github.io/jupyterlab/services/interfaces/kernel.ikernelconnection.html#removemessagehook
918+
removed_hook = self.output_hook_stack[msg_id].pop()
919+
assert removed_hook == hook
920+
921+
def on_comm_open_jupyter_widget(self, msg):
922+
content = msg['content']
923+
data = content['data']
924+
state = data['state']
925+
comm_id = msg['content']['comm_id']
926+
module = self.widget_registry.get(state['_model_module'])
927+
if module:
928+
widget_class = module.get(state['_model_name'])
929+
if widget_class:
930+
return widget_class(comm_id, state, self.kc, self)
931+
859932

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

nbclient/jsonutil.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Utilities to manipulate JSON objects."""
2+
3+
# NOTE: this is a copy of ipykernel/jsonutils.py (+blackified)
4+
5+
# Copyright (c) IPython Development Team.
6+
# Distributed under the terms of the Modified BSD License.
7+
8+
from binascii import b2a_base64
9+
import math
10+
import re
11+
import types
12+
from datetime import datetime
13+
import numbers
14+
15+
16+
from ipython_genutils import py3compat
17+
from ipython_genutils.py3compat import unicode_type, iteritems
18+
19+
next_attr_name = '__next__' if py3compat.PY3 else 'next'
20+
21+
# -----------------------------------------------------------------------------
22+
# Globals and constants
23+
# -----------------------------------------------------------------------------
24+
25+
# timestamp formats
26+
ISO8601 = "%Y-%m-%dT%H:%M:%S.%f"
27+
ISO8601_PAT = re.compile(
28+
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{1,6})?Z?([\+\-]\d{2}:?\d{2})?$"
29+
)
30+
31+
# holy crap, strptime is not threadsafe.
32+
# Calling it once at import seems to help.
33+
datetime.strptime("1", "%d")
34+
35+
# -----------------------------------------------------------------------------
36+
# Classes and functions
37+
# -----------------------------------------------------------------------------
38+
39+
40+
# constants for identifying png/jpeg data
41+
PNG = b'\x89PNG\r\n\x1a\n'
42+
# front of PNG base64-encoded
43+
PNG64 = b'iVBORw0KG'
44+
JPEG = b'\xff\xd8'
45+
# front of JPEG base64-encoded
46+
JPEG64 = b'/9'
47+
# constants for identifying gif data
48+
GIF_64 = b'R0lGODdh'
49+
GIF89_64 = b'R0lGODlh'
50+
# front of PDF base64-encoded
51+
PDF64 = b'JVBER'
52+
53+
54+
def encode_images(format_dict):
55+
"""b64-encodes images in a displaypub format dict
56+
57+
Perhaps this should be handled in json_clean itself?
58+
59+
Parameters
60+
----------
61+
62+
format_dict : dict
63+
A dictionary of display data keyed by mime-type
64+
65+
Returns
66+
-------
67+
68+
format_dict : dict
69+
A copy of the same dictionary,
70+
but binary image data ('image/png', 'image/jpeg' or 'application/pdf')
71+
is base64-encoded.
72+
73+
"""
74+
75+
# no need for handling of ambiguous bytestrings on Python 3,
76+
# where bytes objects always represent binary data and thus
77+
# base64-encoded.
78+
if py3compat.PY3:
79+
return format_dict
80+
81+
encoded = format_dict.copy()
82+
83+
pngdata = format_dict.get('image/png')
84+
if isinstance(pngdata, bytes):
85+
# make sure we don't double-encode
86+
if not pngdata.startswith(PNG64):
87+
pngdata = b2a_base64(pngdata)
88+
encoded['image/png'] = pngdata.decode('ascii')
89+
90+
jpegdata = format_dict.get('image/jpeg')
91+
if isinstance(jpegdata, bytes):
92+
# make sure we don't double-encode
93+
if not jpegdata.startswith(JPEG64):
94+
jpegdata = b2a_base64(jpegdata)
95+
encoded['image/jpeg'] = jpegdata.decode('ascii')
96+
97+
gifdata = format_dict.get('image/gif')
98+
if isinstance(gifdata, bytes):
99+
# make sure we don't double-encode
100+
if not gifdata.startswith((GIF_64, GIF89_64)):
101+
gifdata = b2a_base64(gifdata)
102+
encoded['image/gif'] = gifdata.decode('ascii')
103+
104+
pdfdata = format_dict.get('application/pdf')
105+
if isinstance(pdfdata, bytes):
106+
# make sure we don't double-encode
107+
if not pdfdata.startswith(PDF64):
108+
pdfdata = b2a_base64(pdfdata)
109+
encoded['application/pdf'] = pdfdata.decode('ascii')
110+
111+
return encoded
112+
113+
114+
def json_clean(obj):
115+
"""Clean an object to ensure it's safe to encode in JSON.
116+
117+
Atomic, immutable objects are returned unmodified. Sets and tuples are
118+
converted to lists, lists are copied and dicts are also copied.
119+
120+
Note: dicts whose keys could cause collisions upon encoding (such as a dict
121+
with both the number 1 and the string '1' as keys) will cause a ValueError
122+
to be raised.
123+
124+
Parameters
125+
----------
126+
obj : any python object
127+
128+
Returns
129+
-------
130+
out : object
131+
132+
A version of the input which will not cause an encoding error when
133+
encoded as JSON. Note that this function does not *encode* its inputs,
134+
it simply sanitizes it so that there will be no encoding errors later.
135+
136+
"""
137+
# types that are 'atomic' and ok in json as-is.
138+
atomic_ok = (unicode_type, type(None))
139+
140+
# containers that we need to convert into lists
141+
container_to_list = (tuple, set, types.GeneratorType)
142+
143+
# Since bools are a subtype of Integrals, which are a subtype of Reals,
144+
# we have to check them in that order.
145+
146+
if isinstance(obj, bool):
147+
return obj
148+
149+
if isinstance(obj, numbers.Integral):
150+
# cast int to int, in case subclasses override __str__ (e.g. boost enum, #4598)
151+
return int(obj)
152+
153+
if isinstance(obj, numbers.Real):
154+
# cast out-of-range floats to their reprs
155+
if math.isnan(obj) or math.isinf(obj):
156+
return repr(obj)
157+
return float(obj)
158+
159+
if isinstance(obj, atomic_ok):
160+
return obj
161+
162+
if isinstance(obj, bytes):
163+
if py3compat.PY3:
164+
# unanmbiguous binary data is base64-encoded
165+
# (this probably should have happened upstream)
166+
return b2a_base64(obj).decode('ascii')
167+
else:
168+
# Python 2 bytestr is ambiguous,
169+
# needs special handling for possible binary bytestrings.
170+
# imperfect workaround: if ascii, assume text.
171+
# otherwise assume binary, base64-encode (py3 behavior).
172+
try:
173+
return obj.decode('ascii')
174+
except UnicodeDecodeError:
175+
return b2a_base64(obj).decode('ascii')
176+
177+
if isinstance(obj, container_to_list) or (
178+
hasattr(obj, '__iter__') and hasattr(obj, next_attr_name)
179+
):
180+
obj = list(obj)
181+
182+
if isinstance(obj, list):
183+
return [json_clean(x) for x in obj]
184+
185+
if isinstance(obj, dict):
186+
# First, validate that the dict won't lose data in conversion due to
187+
# key collisions after stringification. This can happen with keys like
188+
# True and 'true' or 1 and '1', which collide in JSON.
189+
nkeys = len(obj)
190+
nkeys_collapsed = len(set(map(unicode_type, obj)))
191+
if nkeys != nkeys_collapsed:
192+
raise ValueError(
193+
'dict cannot be safely converted to JSON: '
194+
'key collision would lead to dropped values'
195+
)
196+
# If all OK, proceed by making the new dict that will be json-safe
197+
out = {}
198+
for k, v in iteritems(obj):
199+
out[unicode_type(k)] = json_clean(v)
200+
return out
201+
if isinstance(obj, datetime):
202+
return obj.strftime(ISO8601)
203+
204+
# we don't understand it, it's probably an unserializable object
205+
raise ValueError("Can't clean for JSON: %r" % obj)

0 commit comments

Comments
 (0)