20
20
import signal
21
21
import threading
22
22
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
31
26
32
27
import matplotlib as mpl
33
28
from matplotlib .backend_bases import _Backend
34
29
from matplotlib ._pylab_helpers import Gcf
35
30
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 ())
48
31
49
32
50
33
class FigureManagerWebAgg (core .FigureManagerWebAgg ):
@@ -54,278 +37,96 @@ class FigureManagerWebAgg(core.FigureManagerWebAgg):
54
37
def pyplot_show (cls , * , block = None ):
55
38
WebAggApplication .initialize ()
56
39
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 )
61
46
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
68
54
69
- WebAggApplication .start ()
55
+ js_fig = run_js (js_code )
56
+ web_socket = WebAggApplication .MockPythonWebSocket (self , js_fig .ws )
57
+ web_socket .open (fignum )
70
58
71
59
72
60
class FigureCanvasWebAgg (core .FigureCanvasWebAggCore ):
73
61
manager_class = FigureManagerWebAgg
74
62
75
63
76
- class WebAggApplication (tornado . web . Application ):
64
+ class WebAggApplication ():
77
65
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 )
103
66
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 :
137
68
supports_binary = True
138
69
70
+ def __init__ (self , manager , js_web_socket ):
71
+ self .manager = manager
72
+ self .js_web_socket = js_web_socket
73
+
139
74
def open (self , fignum ):
75
+ self .js_web_socket .open (create_proxy (self .on_message )) # should destroy proxy on close/exit?
140
76
self .fignum = int (fignum )
141
- self .manager = Gcf .get_fig_manager (self .fignum )
142
77
self .manager .add_web_socket (self )
143
- if hasattr (self , 'set_nodelay' ):
144
- self .set_nodelay (True )
145
78
146
79
def on_close (self ):
147
80
self .manager .remove_web_socket (self )
148
81
149
82
def on_message (self , message ):
150
- message = json .loads (message )
83
+ message = message .as_py_json ()
84
+
151
85
# The 'supports_binary' message is on a client-by-client
152
86
# basis. The others affect the (shared) canvas as a
153
87
# whole.
154
88
if message ['type' ] == 'supports_binary' :
155
89
self .supports_binary = message ['value' ]
156
90
else :
157
- manager = Gcf . get_fig_manager ( self .fignum )
91
+ manager = self .manager
158
92
# It is possible for a figure to be closed,
159
93
# but a stale figure UI is still sending messages
160
94
# from the browser.
161
95
if manager is not None :
162
96
manager .handle_json (message )
163
97
164
98
def send_json (self , content ):
165
- self .write_message (json .dumps (content ))
99
+ self .js_web_socket . receive_json (json .dumps (content ))
166
100
167
101
def send_binary (self , blob ):
168
102
if self .supports_binary :
169
- self .write_message (blob , binary = True )
103
+ self .js_web_socket . receive_binary (blob , binary = True )
170
104
else :
171
105
data_uri = "data:image/png;base64,{}" .format (
172
106
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 )
214
108
215
109
@classmethod
216
110
def initialize (cls , url_prefix = '' , port = None , address = None ):
217
111
if cls .initialized :
218
112
return
219
113
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 )
222
118
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 ))
258
122
259
123
cls .initialized = True
260
124
261
125
@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
329
130
330
131
331
132
@_Backend .export
0 commit comments