Skip to content

Commit 5c8845d

Browse files
committed
Patch WebAgg backend
1 parent 8fb842c commit 5c8845d

File tree

3 files changed

+131
-349
lines changed

3 files changed

+131
-349
lines changed

lib/matplotlib/backends/backend_webagg.py

+43-242
Original file line numberDiff line numberDiff line change
@@ -20,31 +20,14 @@
2020
import signal
2121
import threading
2222

23-
try:
24-
import tornado
25-
except ImportError as err:
26-
raise RuntimeError("The WebAgg backend requires Tornado.") from err
27-
28-
import tornado.web
29-
import tornado.ioloop
30-
import tornado.websocket
23+
from js import document
24+
from pyodide.code import run_js
25+
from pyodide.ffi import create_proxy
3126

3227
import matplotlib as mpl
3328
from matplotlib.backend_bases import _Backend
3429
from matplotlib._pylab_helpers import Gcf
3530
from . import backend_webagg_core as core
36-
from .backend_webagg_core import ( # noqa: F401 # pylint: disable=W0611
37-
TimerAsyncio, TimerTornado)
38-
39-
40-
@mpl._api.deprecated("3.7")
41-
class ServerThread(threading.Thread):
42-
def run(self):
43-
tornado.ioloop.IOLoop.instance().start()
44-
45-
46-
webagg_server_thread = threading.Thread(
47-
target=lambda: tornado.ioloop.IOLoop.instance().start())
4831

4932

5033
class FigureManagerWebAgg(core.FigureManagerWebAgg):
@@ -54,278 +37,96 @@ class FigureManagerWebAgg(core.FigureManagerWebAgg):
5437
def pyplot_show(cls, *, block=None):
5538
WebAggApplication.initialize()
5639

57-
url = "http://{address}:{port}{prefix}".format(
58-
address=WebAggApplication.address,
59-
port=WebAggApplication.port,
60-
prefix=WebAggApplication.url_prefix)
40+
managers = Gcf.get_all_fig_managers()
41+
for manager in managers:
42+
manager.show()
43+
44+
def show(self):
45+
fignum = str(self.num)
6146

62-
if mpl.rcParams['webagg.open_in_browser']:
63-
import webbrowser
64-
if not webbrowser.open(url):
65-
print(f"To view figure, visit {url}")
66-
else:
67-
print(f"To view figure, visit {url}")
47+
js_code = \
48+
"""
49+
var websocket_type = mpl.get_websocket_type();
50+
var fig = new mpl.figure(fig_id, new websocket_type(fig_id), null, document.body);
51+
fig;
52+
"""
53+
js_code = f"var fig_id = '{fignum}';" + js_code
6854

69-
WebAggApplication.start()
55+
js_fig = run_js(js_code)
56+
web_socket = WebAggApplication.MockPythonWebSocket(self, js_fig.ws)
57+
web_socket.open(fignum)
7058

7159

7260
class FigureCanvasWebAgg(core.FigureCanvasWebAggCore):
7361
manager_class = FigureManagerWebAgg
7462

7563

76-
class WebAggApplication(tornado.web.Application):
64+
class WebAggApplication():
7765
initialized = False
78-
started = False
79-
80-
class FavIcon(tornado.web.RequestHandler):
81-
def get(self):
82-
self.set_header('Content-Type', 'image/png')
83-
self.write(Path(mpl.get_data_path(),
84-
'images/matplotlib.png').read_bytes())
85-
86-
class SingleFigurePage(tornado.web.RequestHandler):
87-
def __init__(self, application, request, *, url_prefix='', **kwargs):
88-
self.url_prefix = url_prefix
89-
super().__init__(application, request, **kwargs)
90-
91-
def get(self, fignum):
92-
fignum = int(fignum)
93-
manager = Gcf.get_fig_manager(fignum)
94-
95-
ws_uri = f'ws://{self.request.host}{self.url_prefix}/'
96-
self.render(
97-
"single_figure.html",
98-
prefix=self.url_prefix,
99-
ws_uri=ws_uri,
100-
fig_id=fignum,
101-
toolitems=core.NavigationToolbar2WebAgg.toolitems,
102-
canvas=manager.canvas)
10366

104-
class AllFiguresPage(tornado.web.RequestHandler):
105-
def __init__(self, application, request, *, url_prefix='', **kwargs):
106-
self.url_prefix = url_prefix
107-
super().__init__(application, request, **kwargs)
108-
109-
def get(self):
110-
ws_uri = f'ws://{self.request.host}{self.url_prefix}/'
111-
self.render(
112-
"all_figures.html",
113-
prefix=self.url_prefix,
114-
ws_uri=ws_uri,
115-
figures=sorted(Gcf.figs.items()),
116-
toolitems=core.NavigationToolbar2WebAgg.toolitems)
117-
118-
class MplJs(tornado.web.RequestHandler):
119-
def get(self):
120-
self.set_header('Content-Type', 'application/javascript')
121-
122-
js_content = core.FigureManagerWebAgg.get_javascript()
123-
124-
self.write(js_content)
125-
126-
class Download(tornado.web.RequestHandler):
127-
def get(self, fignum, fmt):
128-
fignum = int(fignum)
129-
manager = Gcf.get_fig_manager(fignum)
130-
self.set_header(
131-
'Content-Type', mimetypes.types_map.get(fmt, 'binary'))
132-
buff = BytesIO()
133-
manager.canvas.figure.savefig(buff, format=fmt)
134-
self.write(buff.getvalue())
135-
136-
class WebSocket(tornado.websocket.WebSocketHandler):
67+
class MockPythonWebSocket:
13768
supports_binary = True
13869

70+
def __init__(self, manager, js_web_socket):
71+
self.manager = manager
72+
self.js_web_socket = js_web_socket
73+
13974
def open(self, fignum):
75+
self.js_web_socket.open(create_proxy(self.on_message)) # should destroy proxy on close/exit?
14076
self.fignum = int(fignum)
141-
self.manager = Gcf.get_fig_manager(self.fignum)
14277
self.manager.add_web_socket(self)
143-
if hasattr(self, 'set_nodelay'):
144-
self.set_nodelay(True)
14578

14679
def on_close(self):
14780
self.manager.remove_web_socket(self)
14881

14982
def on_message(self, message):
150-
message = json.loads(message)
83+
message = message.as_py_json()
84+
15185
# The 'supports_binary' message is on a client-by-client
15286
# basis. The others affect the (shared) canvas as a
15387
# whole.
15488
if message['type'] == 'supports_binary':
15589
self.supports_binary = message['value']
15690
else:
157-
manager = Gcf.get_fig_manager(self.fignum)
91+
manager = self.manager
15892
# It is possible for a figure to be closed,
15993
# but a stale figure UI is still sending messages
16094
# from the browser.
16195
if manager is not None:
16296
manager.handle_json(message)
16397

16498
def send_json(self, content):
165-
self.write_message(json.dumps(content))
99+
self.js_web_socket.receive_json(json.dumps(content))
166100

167101
def send_binary(self, blob):
168102
if self.supports_binary:
169-
self.write_message(blob, binary=True)
103+
self.js_web_socket.receive_binary(blob, binary=True)
170104
else:
171105
data_uri = "data:image/png;base64,{}".format(
172106
blob.encode('base64').replace('\n', ''))
173-
self.write_message(data_uri)
174-
175-
def __init__(self, url_prefix=''):
176-
if url_prefix:
177-
assert url_prefix[0] == '/' and url_prefix[-1] != '/', \
178-
'url_prefix must start with a "/" and not end with one.'
179-
180-
super().__init__(
181-
[
182-
# Static files for the CSS and JS
183-
(url_prefix + r'/_static/(.*)',
184-
tornado.web.StaticFileHandler,
185-
{'path': core.FigureManagerWebAgg.get_static_file_path()}),
186-
187-
# Static images for the toolbar
188-
(url_prefix + r'/_images/(.*)',
189-
tornado.web.StaticFileHandler,
190-
{'path': Path(mpl.get_data_path(), 'images')}),
191-
192-
# A Matplotlib favicon
193-
(url_prefix + r'/favicon.ico', self.FavIcon),
194-
195-
# The page that contains all of the pieces
196-
(url_prefix + r'/([0-9]+)', self.SingleFigurePage,
197-
{'url_prefix': url_prefix}),
198-
199-
# The page that contains all of the figures
200-
(url_prefix + r'/?', self.AllFiguresPage,
201-
{'url_prefix': url_prefix}),
202-
203-
(url_prefix + r'/js/mpl.js', self.MplJs),
204-
205-
# Sends images and events to the browser, and receives
206-
# events from the browser
207-
(url_prefix + r'/([0-9]+)/ws', self.WebSocket),
208-
209-
# Handles the downloading (i.e., saving) of static images
210-
(url_prefix + r'/([0-9]+)/download.([a-z0-9.]+)',
211-
self.Download),
212-
],
213-
template_path=core.FigureManagerWebAgg.get_static_file_path())
107+
self.js_web_socket.receive_binary(data_uri)
214108

215109
@classmethod
216110
def initialize(cls, url_prefix='', port=None, address=None):
217111
if cls.initialized:
218112
return
219113

220-
# Create the class instance
221-
app = cls(url_prefix=url_prefix)
114+
css = (Path(__file__).parent / "web_backend/css/mpl.css").read_text(encoding="utf-8")
115+
style = document.createElement('style')
116+
style.textContent = css
117+
document.head.append(style)
222118

223-
cls.url_prefix = url_prefix
224-
225-
# This port selection algorithm is borrowed, more or less
226-
# verbatim, from IPython.
227-
def random_ports(port, n):
228-
"""
229-
Generate a list of n random ports near the given port.
230-
231-
The first 5 ports will be sequential, and the remaining n-5 will be
232-
randomly selected in the range [port-2*n, port+2*n].
233-
"""
234-
for i in range(min(5, n)):
235-
yield port + i
236-
for i in range(n - 5):
237-
yield port + random.randint(-2 * n, 2 * n)
238-
239-
if address is None:
240-
cls.address = mpl.rcParams['webagg.address']
241-
else:
242-
cls.address = address
243-
cls.port = mpl.rcParams['webagg.port']
244-
for port in random_ports(cls.port,
245-
mpl.rcParams['webagg.port_retries']):
246-
try:
247-
app.listen(port, cls.address)
248-
except OSError as e:
249-
if e.errno != errno.EADDRINUSE:
250-
raise
251-
else:
252-
cls.port = port
253-
break
254-
else:
255-
raise SystemExit(
256-
"The webagg server could not be started because an available "
257-
"port could not be found")
119+
js_content = core.FigureManagerWebAgg.get_javascript()
120+
set_toolbar_image_callback = run_js(js_content)
121+
set_toolbar_image_callback(create_proxy(WebAggApplication.get_toolbar_image))
258122

259123
cls.initialized = True
260124

261125
@classmethod
262-
def start(cls):
263-
import asyncio
264-
try:
265-
asyncio.get_running_loop()
266-
except RuntimeError:
267-
pass
268-
else:
269-
cls.started = True
270-
271-
if cls.started:
272-
return
273-
274-
"""
275-
IOLoop.running() was removed as of Tornado 2.4; see for example
276-
https://groups.google.com/forum/#!topic/python-tornado/QLMzkpQBGOY
277-
Thus there is no correct way to check if the loop has already been
278-
launched. We may end up with two concurrently running loops in that
279-
unlucky case with all the expected consequences.
280-
"""
281-
ioloop = tornado.ioloop.IOLoop.instance()
282-
283-
def shutdown():
284-
ioloop.stop()
285-
print("Server is stopped")
286-
sys.stdout.flush()
287-
cls.started = False
288-
289-
@contextmanager
290-
def catch_sigint():
291-
old_handler = signal.signal(
292-
signal.SIGINT,
293-
lambda sig, frame: ioloop.add_callback_from_signal(shutdown))
294-
try:
295-
yield
296-
finally:
297-
signal.signal(signal.SIGINT, old_handler)
298-
299-
# Set the flag to True *before* blocking on ioloop.start()
300-
cls.started = True
301-
302-
print("Press Ctrl+C to stop WebAgg server")
303-
sys.stdout.flush()
304-
with catch_sigint():
305-
ioloop.start()
306-
307-
308-
def ipython_inline_display(figure):
309-
import tornado.template
310-
311-
WebAggApplication.initialize()
312-
import asyncio
313-
try:
314-
asyncio.get_running_loop()
315-
except RuntimeError:
316-
if not webagg_server_thread.is_alive():
317-
webagg_server_thread.start()
318-
319-
fignum = figure.number
320-
tpl = Path(core.FigureManagerWebAgg.get_static_file_path(),
321-
"ipython_inline_figure.html").read_text()
322-
t = tornado.template.Template(tpl)
323-
return t.generate(
324-
prefix=WebAggApplication.url_prefix,
325-
fig_id=fignum,
326-
toolitems=core.NavigationToolbar2WebAgg.toolitems,
327-
canvas=figure.canvas,
328-
port=WebAggApplication.port).decode('utf-8')
126+
def get_toolbar_image(cls, image):
127+
filename = Path(__file__).parent.parent / f"mpl-data/images/{image}.png"
128+
png_bytes = filename.read_bytes()
129+
return png_bytes
329130

330131

331132
@_Backend.export

0 commit comments

Comments
 (0)