-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
IPython Widget #5754
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
IPython Widget #5754
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,23 +3,27 @@ | |
# lib/matplotlib/backends/web_backend/nbagg_uat.ipynb to help verify | ||
# that changes made maintain expected behaviour. | ||
|
||
import datetime | ||
from base64 import b64encode | ||
import json | ||
import io | ||
from tempfile import mkdtemp | ||
import shutil | ||
import os | ||
from matplotlib.externals import six | ||
from uuid import uuid4 as uuid | ||
|
||
import tornado.ioloop | ||
|
||
from IPython.display import display, Javascript, HTML | ||
from IPython.display import display, HTML | ||
from IPython import version_info | ||
try: | ||
# Jupyter/IPython 4.x or later | ||
from ipykernel.comm import Comm | ||
from ipywidgets import DOMWidget | ||
from traitlets import Unicode, Bool, Float, List, Any | ||
from notebook.nbextensions import install_nbextension, check_nbextension | ||
except ImportError: | ||
# Jupyter/IPython 3.x or earlier | ||
from IPython.kernel.comm import Comm | ||
from IPython.html.widgets import DOMWidget | ||
from IPython.utils.traitlets import Unicode, Bool, Float, List, Any | ||
from IPython.html.nbextensions import install_nbextension | ||
|
||
from matplotlib import rcParams | ||
from matplotlib.figure import Figure | ||
|
@@ -33,6 +37,7 @@ | |
|
||
|
||
class Show(ShowBase): | ||
|
||
def __call__(self, block=None): | ||
from matplotlib._pylab_helpers import Gcf | ||
|
||
|
@@ -98,6 +103,7 @@ def connection_info(): | |
'zoom_to_rect': 'fa fa-square-o icon-check-empty', | ||
'move': 'fa fa-arrows icon-move', | ||
'download': 'fa fa-floppy-o icon-save', | ||
'export': 'fa fa-file-picture-o icon-picture', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you elaborate on what "export" does? It inserts a static image into the notebook? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this preferable to the automatic replacement with a static image upon saving? Won't users often forget to push this button? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it inserts a static image into the notebook. I'm looking at this from the point of view where the canvas is one of many widgets in a gui, and you want explicit control over export behavior. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we should think about the name. Export makes me think of "save" - I guess this is a kind of "snapshot" type behaviour, right? |
||
None: None | ||
} | ||
|
||
|
@@ -109,84 +115,74 @@ class NavigationIPy(NavigationToolbar2WebAgg): | |
_FONT_AWESOME_CLASSES[image_file], name_of_method) | ||
for text, tooltip_text, image_file, name_of_method | ||
in (NavigationToolbar2.toolitems + | ||
(('Download', 'Download plot', 'download', 'download'),)) | ||
(('Download', 'Download plot', 'download', 'download'), | ||
('Export', 'Export plot', 'export', 'export'))) | ||
if image_file in _FONT_AWESOME_CLASSES] | ||
|
||
def export(self): | ||
buf = io.BytesIO() | ||
self.canvas.figure.savefig(buf, format='png', dpi='figure') | ||
data = "<img src='data:image/png;base64,{0}'/>" | ||
data = data.format(b64encode(buf.getvalue()).decode('utf-8')) | ||
display(HTML(data)) | ||
|
||
|
||
class FigureCanvasNbAgg(DOMWidget, FigureCanvasWebAggCore): | ||
_view_module = Unicode("matplotlib", sync=True) | ||
_view_name = Unicode('MPLCanvasView', sync=True) | ||
_toolbar_items = List(sync=True) | ||
_closed = Bool(True) | ||
_id = Unicode('', sync=True) | ||
|
||
# Must declare the superclass private members. | ||
_png_is_old = Bool() | ||
_force_full = Bool() | ||
_current_image_mode = Unicode() | ||
_dpi_ratio = Float(1.0) | ||
_is_idle_drawing = Bool() | ||
_is_saving = Bool() | ||
_button = Any() | ||
_key = Any() | ||
_lastx = Any() | ||
_lasty = Any() | ||
_is_idle_drawing = Bool() | ||
|
||
def __init__(self, figure, *args, **kwargs): | ||
super(FigureCanvasWebAggCore, self).__init__(figure, *args, **kwargs) | ||
super(DOMWidget, self).__init__(*args, **kwargs) | ||
self._uid = uuid().hex | ||
self.on_msg(self._handle_message) | ||
|
||
def _handle_message(self, object, message, buffers): | ||
# The 'supports_binary' message is relevant to the | ||
# websocket itself. The other messages get passed along | ||
# to matplotlib as-is. | ||
|
||
class FigureManagerNbAgg(FigureManagerWebAgg): | ||
ToolbarCls = NavigationIPy | ||
|
||
def __init__(self, canvas, num): | ||
self._shown = False | ||
FigureManagerWebAgg.__init__(self, canvas, num) | ||
|
||
def display_js(self): | ||
# XXX How to do this just once? It has to deal with multiple | ||
# browser instances using the same kernel (require.js - but the | ||
# file isn't static?). | ||
display(Javascript(FigureManagerNbAgg.get_javascript())) | ||
|
||
def show(self): | ||
if not self._shown: | ||
self.display_js() | ||
self._create_comm() | ||
else: | ||
self.canvas.draw_idle() | ||
self._shown = True | ||
|
||
def reshow(self): | ||
""" | ||
A special method to re-show the figure in the notebook. | ||
|
||
""" | ||
self._shown = False | ||
self.show() | ||
|
||
@property | ||
def connected(self): | ||
return bool(self.web_sockets) | ||
|
||
@classmethod | ||
def get_javascript(cls, stream=None): | ||
if stream is None: | ||
output = io.StringIO() | ||
# Every message has a "type" and a "figure_id". | ||
message = json.loads(message) | ||
if message['type'] == 'closing': | ||
self._closed = True | ||
elif message['type'] == 'supports_binary': | ||
self.supports_binary = message['value'] | ||
elif message['type'] == 'initialized': | ||
_, _, w, h = self.figure.bbox.bounds | ||
self.manager.resize(w, h) | ||
self.send_json('refresh') | ||
else: | ||
output = stream | ||
super(FigureManagerNbAgg, cls).get_javascript(stream=output) | ||
with io.open(os.path.join( | ||
os.path.dirname(__file__), | ||
"web_backend", | ||
"nbagg_mpl.js"), encoding='utf8') as fd: | ||
output.write(fd.read()) | ||
if stream is None: | ||
return output.getvalue() | ||
|
||
def _create_comm(self): | ||
comm = CommSocket(self) | ||
self.add_web_socket(comm) | ||
return comm | ||
|
||
def destroy(self): | ||
self._send_event('close') | ||
# need to copy comms as callbacks will modify this list | ||
for comm in list(self.web_sockets): | ||
comm.on_close() | ||
self.clearup_closed() | ||
|
||
def clearup_closed(self): | ||
"""Clear up any closed Comms.""" | ||
self.web_sockets = set([socket for socket in self.web_sockets | ||
if socket.is_open()]) | ||
|
||
if len(self.web_sockets) == 0: | ||
self.canvas.close_event() | ||
self.manager.handle_json(message) | ||
|
||
def remove_comm(self, comm_id): | ||
self.web_sockets = set([socket for socket in self.web_sockets | ||
if not socket.comm.comm_id == comm_id]) | ||
def send_json(self, content): | ||
self.send({'data': json.dumps(content)}) | ||
|
||
def send_binary(self, blob): | ||
# The comm is ascii, so we always send the image in base64 | ||
# encoded data URL form. | ||
data = b64encode(blob) | ||
if six.PY3: | ||
data = data.decode('ascii') | ||
data_uri = "data:image/png;base64,{0}".format(data) | ||
self.send({'data': data_uri}) | ||
|
||
class FigureCanvasNbAgg(FigureCanvasWebAggCore): | ||
def new_timer(self, *args, **kwargs): | ||
return TimerTornado(*args, **kwargs) | ||
|
||
|
@@ -197,6 +193,31 @@ def stop_event_loop(self): | |
FigureCanvasBase.stop_event_loop_default(self) | ||
|
||
|
||
class FigureManagerNbAgg(FigureManagerWebAgg): | ||
ToolbarCls = NavigationIPy | ||
|
||
def __init__(self, canvas, num): | ||
FigureManagerWebAgg.__init__(self, canvas, num) | ||
toolitems = [] | ||
for name, tooltip, image, method in self.ToolbarCls.toolitems: | ||
if name is None: | ||
toolitems.append(['', '', '', '']) | ||
else: | ||
toolitems.append([name, tooltip, image, method]) | ||
canvas._toolbar_items = toolitems | ||
self.web_sockets = [self.canvas] | ||
|
||
def show(self): | ||
if self.canvas._closed: | ||
self.canvas._closed = False | ||
display(self.canvas) | ||
else: | ||
self.canvas.draw_idle() | ||
|
||
def destroy(self): | ||
self._send_event('close') | ||
|
||
|
||
def new_figure_manager(num, *args, **kwargs): | ||
""" | ||
Create a new figure manager instance | ||
|
@@ -229,76 +250,46 @@ def closer(event): | |
return manager | ||
|
||
|
||
class CommSocket(object): | ||
def nbinstall(overwrite=False, user=True): | ||
""" | ||
Manages the Comm connection between IPython and the browser (client). | ||
|
||
Comms are 2 way, with the CommSocket being able to publish a message | ||
via the send_json method, and handle a message with on_message. On the | ||
JS side figure.send_message and figure.ws.onmessage do the sending and | ||
receiving respectively. | ||
|
||
Copies javascript dependencies to the '/nbextensions' folder in | ||
your IPython directory. | ||
|
||
Parameters | ||
---------- | ||
|
||
overwrite : bool | ||
If True, always install the files, regardless of what mayœ already be | ||
installed. Defaults to False. | ||
user : bool | ||
Whether to install to the user's .ipython/nbextensions directory. | ||
Otherwise do a system-wide install | ||
(e.g. /usr/local/share/jupyter/nbextensions). Defaults to False. | ||
""" | ||
def __init__(self, manager): | ||
self.supports_binary = None | ||
self.manager = manager | ||
self.uuid = str(uuid()) | ||
# Publish an output area with a unique ID. The javascript can then | ||
# hook into this area. | ||
display(HTML("<div id=%r></div>" % self.uuid)) | ||
try: | ||
self.comm = Comm('matplotlib', data={'id': self.uuid}) | ||
except AttributeError: | ||
raise RuntimeError('Unable to create an IPython notebook Comm ' | ||
'instance. Are you in the IPython notebook?') | ||
self.comm.on_msg(self.on_message) | ||
|
||
manager = self.manager | ||
self._ext_close = False | ||
|
||
def _on_close(close_message): | ||
self._ext_close = True | ||
manager.remove_comm(close_message['content']['comm_id']) | ||
manager.clearup_closed() | ||
|
||
self.comm.on_close(_on_close) | ||
|
||
def is_open(self): | ||
return not (self._ext_close or self.comm._closed) | ||
|
||
def on_close(self): | ||
# When the socket is closed, deregister the websocket with | ||
# the FigureManager. | ||
if self.is_open(): | ||
try: | ||
self.comm.close() | ||
except KeyError: | ||
# apparently already cleaned it up? | ||
pass | ||
|
||
def send_json(self, content): | ||
self.comm.send({'data': json.dumps(content)}) | ||
|
||
def send_binary(self, blob): | ||
# The comm is ascii, so we always send the image in base64 | ||
# encoded data URL form. | ||
data = b64encode(blob) | ||
if six.PY3: | ||
data = data.decode('ascii') | ||
data_uri = "data:image/png;base64,{0}".format(data) | ||
self.comm.send({'data': data_uri}) | ||
|
||
def on_message(self, message): | ||
# The 'supports_binary' message is relevant to the | ||
# websocket itself. The other messages get passed along | ||
# to matplotlib as-is. | ||
|
||
# Every message has a "type" and a "figure_id". | ||
message = json.loads(message['content']['data']) | ||
if message['type'] == 'closing': | ||
self.on_close() | ||
self.manager.clearup_closed() | ||
elif message['type'] == 'supports_binary': | ||
self.supports_binary = message['value'] | ||
else: | ||
self.manager.handle_json(message) | ||
if (check_nbextension('matplotlib') or | ||
check_nbextension('matplotlib', True)): | ||
return | ||
|
||
# Make a temporary directory so we can wrap mpl.js in a requirejs define(). | ||
tempdir = mkdtemp() | ||
path = os.path.join(os.path.dirname(__file__), "web_backend") | ||
shutil.copy2(os.path.join(path, "nbagg_mpl.js"), tempdir) | ||
|
||
with open(os.path.join(path, 'mpl.js')) as fid: | ||
contents = fid.read() | ||
|
||
with open(os.path.join(tempdir, 'mpl.js'), 'w') as fid: | ||
fid.write('define(["jquery"], function($) {\n') | ||
fid.write(contents) | ||
fid.write('\nreturn mpl;\n});') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What are the downsides with doing this in the file we ship directly (rather than editing it on the fly here)? It does need to work in a "no IPython" context as well, but we do already use JQuery there. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is no downside AFAIK, I was just shying away from making any changes to WebAgg. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's go ahead and make the change (and make whatever change it takes to make WebAgg work with a jquery module). This adds a fair bit of complexity that we otherwise don't need. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We still need to create the temporary directory either way, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think some packages take advantage of an "egg cache" concept in distutils
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry -- I have the feeling I'm walking into a discussion that's already been hashed out and settled somewhere else, so apologies for that. If there's something you want to point me to to read about this, that would be great. Just to be clear, I'm concerned about the added packaging and installation complexity becoming a burden on matplotlib which is already overstretched with such things. Anything Jupyter can do to make that easier on our users would be great, though I doubt I'm versed enough yet to offer any suggestions... Conda and other packaging helps here, obviously, but that should never be a necessity.
Certainly the notebook server is talking to the kernel though and could get Javascript content from there... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That's not ideal -- the Python and Javascript sides are pretty tightly coupled, by necessity. Very recent changes, such as optimizing the data bandwidth and handling HiDPI displays has required changes to both sides. And it's exactly this sort of "forced decoupling" from both sides that I'm hoping to avoid. Ideally, (and again, I'm not familiar with all the moving parts, so I'm speaking abstractly), Jupyter should get the Javascript content from the currently running matplotlib... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mdboom, this is a thorny issue, one under active discussion: https://groups.google.com/forum/#!topic/jupyter/NDc9ktzACF0 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the link. And sorry to be annoying ;) This PR in general is very very welcome. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In which case, we need to be particularly careful with versioning the js deployed. Are we putting ourselves into a position where we will be unable to run nbagg backends for distinct major versions of matplotlib if we install a single js component to the ipython extensions directory, or are we able to manage versioning too? Following up, should we be deploying a |
||
|
||
install_nbextension( | ||
tempdir, | ||
overwrite=overwrite, | ||
symlink=False, | ||
destination='matplotlib', | ||
verbose=0, | ||
**({'user': user} if version_info >= (3, 0, 0, '') else {}) | ||
) | ||
|
||
#nbinstall() |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,7 +18,7 @@ | |
import io | ||
import json | ||
import os | ||
import time | ||
import datetime | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for catching this one! |
||
import warnings | ||
|
||
import numpy as np | ||
|
@@ -501,6 +501,7 @@ def get_javascript(cls, stream=None): | |
with io.open(os.path.join( | ||
os.path.dirname(__file__), | ||
"web_backend", | ||
"js", | ||
"mpl.js"), encoding='utf8') as fd: | ||
output.write(fd.read()) | ||
|
||
|
@@ -530,7 +531,7 @@ def get_javascript(cls, stream=None): | |
|
||
@classmethod | ||
def get_static_file_path(cls): | ||
return os.path.join(os.path.dirname(__file__), 'web_backend') | ||
return os.path.join(os.path.dirname(__file__), 'web_backend', 'js') | ||
|
||
def _send_event(self, event_type, **kwargs): | ||
payload = {'type': event_type} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This avoids an error I saw.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW: This was fixed recently by #5720 (in the exact same way).