Skip to content

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

Merged
merged 4 commits into from
May 4, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/matplotlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1454,6 +1454,17 @@ def tk_window_focus():
except (KeyError, ValueError):
pass


# Jupyter extension paths
def _jupyter_nbextension_paths():
return [{
'section': 'notebook',
'src': 'backends/web_backend/js',
'dest': 'matplotlib',
'require': 'matplotlib/extension'
}]


default_test_modules = [
'matplotlib.tests.test_agg',
'matplotlib.tests.test_animation',
Expand Down
3 changes: 2 additions & 1 deletion lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2161,7 +2161,8 @@ def print_figure(self, filename, dpi=None, facecolor=None, edgecolor=None,
origfacecolor = self.figure.get_facecolor()
origedgecolor = self.figure.get_edgecolor()

self.figure.dpi = dpi
if dpi != 'figure':
Copy link
Member Author

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.

Copy link
Member

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).

self.figure.dpi = dpi
self.figure.set_facecolor(facecolor)
self.figure.set_edgecolor(edgecolor)

Expand Down
287 changes: 139 additions & 148 deletions lib/matplotlib/backends/backend_nbagg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +37,7 @@


class Show(ShowBase):

def __call__(self, block=None):
from matplotlib._pylab_helpers import Gcf

Expand Down Expand Up @@ -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',
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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
}

Expand All @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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});')
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still need to create the temporary directory either way, install_nbextension grabs everything in the directory. The alternative is to make a new directory with just those two files and wrap mpl.js in a define there.

Copy link
Member

Choose a reason for hiding this comment

The 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
to deal with issues like this. Never done it myself, but I sometimes find
such directories after using certain packages.
On Dec 29, 2015 7:26 PM, "Steven Silvester" notifications@github.com
wrote:

In lib/matplotlib/backends/backend_nbagg.py
#5754 (comment):

  • 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});')
    

We still need to create the temporary directory either way,
install_nbextension grabs everything in the directory. The alternative is
to make a new directory with just those two files and wrap mpl.js in a
define there.


Reply to this email directly or view it on GitHub
https://github.com/matplotlib/matplotlib/pull/5754/files#r48580693.

Copy link
Member

Choose a reason for hiding this comment

The 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.

Since the notebook server and the kernel may be in different locations, it is not possible to know where or how to install the js when installing the python package.

Certainly the notebook server is talking to the kernel though and could get Javascript content from there...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this particular widget should be special-cased and added to ipywidgets itself in the short term?

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...

Copy link
Member Author

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the Python and Javascript sides are pretty tightly coupled, by necessity

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 mpl-<minor>.<major>.<minor>.<patch>.js javascript extension to protect us on this?


install_nbextension(
tempdir,
overwrite=overwrite,
symlink=False,
destination='matplotlib',
verbose=0,
**({'user': user} if version_info >= (3, 0, 0, '') else {})
)

#nbinstall()
5 changes: 3 additions & 2 deletions lib/matplotlib/backends/backend_webagg_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import io
import json
import os
import time
import datetime
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this one!

import warnings

import numpy as np
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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}
Expand Down
Loading