backend_webagg.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. """
  2. Displays Agg images in the browser, with interactivity
  3. """
  4. # The WebAgg backend is divided into two modules:
  5. #
  6. # - `backend_webagg_core.py` contains code necessary to embed a WebAgg
  7. # plot inside of a web application, and communicate in an abstract
  8. # way over a web socket.
  9. #
  10. # - `backend_webagg.py` contains a concrete implementation of a basic
  11. # application, implemented with tornado.
  12. from contextlib import contextmanager
  13. import errno
  14. from io import BytesIO
  15. import json
  16. import mimetypes
  17. from pathlib import Path
  18. import random
  19. import sys
  20. import signal
  21. import socket
  22. import threading
  23. try:
  24. import tornado
  25. except ImportError:
  26. raise RuntimeError("The WebAgg backend requires Tornado.")
  27. import tornado.web
  28. import tornado.ioloop
  29. import tornado.websocket
  30. from matplotlib import cbook, rcParams
  31. from matplotlib.backend_bases import _Backend
  32. from matplotlib._pylab_helpers import Gcf
  33. from . import backend_webagg_core as core
  34. from .backend_webagg_core import TimerTornado
  35. class ServerThread(threading.Thread):
  36. def run(self):
  37. tornado.ioloop.IOLoop.instance().start()
  38. webagg_server_thread = ServerThread()
  39. class FigureCanvasWebAgg(core.FigureCanvasWebAggCore):
  40. def show(self):
  41. # show the figure window
  42. global show # placates pyflakes: created by @_Backend.export below
  43. show()
  44. def new_timer(self, *args, **kwargs):
  45. # docstring inherited
  46. return TimerTornado(*args, **kwargs)
  47. class WebAggApplication(tornado.web.Application):
  48. initialized = False
  49. started = False
  50. class FavIcon(tornado.web.RequestHandler):
  51. def get(self):
  52. self.set_header('Content-Type', 'image/png')
  53. self.write(
  54. cbook._get_data_path('images/matplotlib.png').read_bytes())
  55. class SingleFigurePage(tornado.web.RequestHandler):
  56. def __init__(self, application, request, *, url_prefix='', **kwargs):
  57. self.url_prefix = url_prefix
  58. super().__init__(application, request, **kwargs)
  59. def get(self, fignum):
  60. fignum = int(fignum)
  61. manager = Gcf.get_fig_manager(fignum)
  62. ws_uri = 'ws://{req.host}{prefix}/'.format(req=self.request,
  63. prefix=self.url_prefix)
  64. self.render(
  65. "single_figure.html",
  66. prefix=self.url_prefix,
  67. ws_uri=ws_uri,
  68. fig_id=fignum,
  69. toolitems=core.NavigationToolbar2WebAgg.toolitems,
  70. canvas=manager.canvas)
  71. class AllFiguresPage(tornado.web.RequestHandler):
  72. def __init__(self, application, request, *, url_prefix='', **kwargs):
  73. self.url_prefix = url_prefix
  74. super().__init__(application, request, **kwargs)
  75. def get(self):
  76. ws_uri = 'ws://{req.host}{prefix}/'.format(req=self.request,
  77. prefix=self.url_prefix)
  78. self.render(
  79. "all_figures.html",
  80. prefix=self.url_prefix,
  81. ws_uri=ws_uri,
  82. figures=sorted(Gcf.figs.items()),
  83. toolitems=core.NavigationToolbar2WebAgg.toolitems)
  84. class MplJs(tornado.web.RequestHandler):
  85. def get(self):
  86. self.set_header('Content-Type', 'application/javascript')
  87. js_content = core.FigureManagerWebAgg.get_javascript()
  88. self.write(js_content)
  89. class Download(tornado.web.RequestHandler):
  90. def get(self, fignum, fmt):
  91. fignum = int(fignum)
  92. manager = Gcf.get_fig_manager(fignum)
  93. self.set_header(
  94. 'Content-Type', mimetypes.types_map.get(fmt, 'binary'))
  95. buff = BytesIO()
  96. manager.canvas.figure.savefig(buff, format=fmt)
  97. self.write(buff.getvalue())
  98. class WebSocket(tornado.websocket.WebSocketHandler):
  99. supports_binary = True
  100. def open(self, fignum):
  101. self.fignum = int(fignum)
  102. self.manager = Gcf.get_fig_manager(self.fignum)
  103. self.manager.add_web_socket(self)
  104. if hasattr(self, 'set_nodelay'):
  105. self.set_nodelay(True)
  106. def on_close(self):
  107. self.manager.remove_web_socket(self)
  108. def on_message(self, message):
  109. message = json.loads(message)
  110. # The 'supports_binary' message is on a client-by-client
  111. # basis. The others affect the (shared) canvas as a
  112. # whole.
  113. if message['type'] == 'supports_binary':
  114. self.supports_binary = message['value']
  115. else:
  116. manager = Gcf.get_fig_manager(self.fignum)
  117. # It is possible for a figure to be closed,
  118. # but a stale figure UI is still sending messages
  119. # from the browser.
  120. if manager is not None:
  121. manager.handle_json(message)
  122. def send_json(self, content):
  123. self.write_message(json.dumps(content))
  124. def send_binary(self, blob):
  125. if self.supports_binary:
  126. self.write_message(blob, binary=True)
  127. else:
  128. data_uri = "data:image/png;base64,{0}".format(
  129. blob.encode('base64').replace('\n', ''))
  130. self.write_message(data_uri)
  131. def __init__(self, url_prefix=''):
  132. if url_prefix:
  133. assert url_prefix[0] == '/' and url_prefix[-1] != '/', \
  134. 'url_prefix must start with a "/" and not end with one.'
  135. super().__init__(
  136. [
  137. # Static files for the CSS and JS
  138. (url_prefix + r'/_static/(.*)',
  139. tornado.web.StaticFileHandler,
  140. {'path': core.FigureManagerWebAgg.get_static_file_path()}),
  141. # A Matplotlib favicon
  142. (url_prefix + r'/favicon.ico', self.FavIcon),
  143. # The page that contains all of the pieces
  144. (url_prefix + r'/([0-9]+)', self.SingleFigurePage,
  145. {'url_prefix': url_prefix}),
  146. # The page that contains all of the figures
  147. (url_prefix + r'/?', self.AllFiguresPage,
  148. {'url_prefix': url_prefix}),
  149. (url_prefix + r'/js/mpl.js', self.MplJs),
  150. # Sends images and events to the browser, and receives
  151. # events from the browser
  152. (url_prefix + r'/([0-9]+)/ws', self.WebSocket),
  153. # Handles the downloading (i.e., saving) of static images
  154. (url_prefix + r'/([0-9]+)/download.([a-z0-9.]+)',
  155. self.Download),
  156. ],
  157. template_path=core.FigureManagerWebAgg.get_static_file_path())
  158. @classmethod
  159. def initialize(cls, url_prefix='', port=None, address=None):
  160. if cls.initialized:
  161. return
  162. # Create the class instance
  163. app = cls(url_prefix=url_prefix)
  164. cls.url_prefix = url_prefix
  165. # This port selection algorithm is borrowed, more or less
  166. # verbatim, from IPython.
  167. def random_ports(port, n):
  168. """
  169. Generate a list of n random ports near the given port.
  170. The first 5 ports will be sequential, and the remaining n-5 will be
  171. randomly selected in the range [port-2*n, port+2*n].
  172. """
  173. for i in range(min(5, n)):
  174. yield port + i
  175. for i in range(n - 5):
  176. yield port + random.randint(-2 * n, 2 * n)
  177. if address is None:
  178. cls.address = rcParams['webagg.address']
  179. else:
  180. cls.address = address
  181. cls.port = rcParams['webagg.port']
  182. for port in random_ports(cls.port, rcParams['webagg.port_retries']):
  183. try:
  184. app.listen(port, cls.address)
  185. except socket.error as e:
  186. if e.errno != errno.EADDRINUSE:
  187. raise
  188. else:
  189. cls.port = port
  190. break
  191. else:
  192. raise SystemExit(
  193. "The webagg server could not be started because an available "
  194. "port could not be found")
  195. cls.initialized = True
  196. @classmethod
  197. def start(cls):
  198. if cls.started:
  199. return
  200. """
  201. IOLoop.running() was removed as of Tornado 2.4; see for example
  202. https://groups.google.com/forum/#!topic/python-tornado/QLMzkpQBGOY
  203. Thus there is no correct way to check if the loop has already been
  204. launched. We may end up with two concurrently running loops in that
  205. unlucky case with all the expected consequences.
  206. """
  207. ioloop = tornado.ioloop.IOLoop.instance()
  208. def shutdown():
  209. ioloop.stop()
  210. print("Server is stopped")
  211. sys.stdout.flush()
  212. cls.started = False
  213. @contextmanager
  214. def catch_sigint():
  215. old_handler = signal.signal(
  216. signal.SIGINT,
  217. lambda sig, frame: ioloop.add_callback_from_signal(shutdown))
  218. try:
  219. yield
  220. finally:
  221. signal.signal(signal.SIGINT, old_handler)
  222. # Set the flag to True *before* blocking on ioloop.start()
  223. cls.started = True
  224. print("Press Ctrl+C to stop WebAgg server")
  225. sys.stdout.flush()
  226. with catch_sigint():
  227. ioloop.start()
  228. def ipython_inline_display(figure):
  229. import tornado.template
  230. WebAggApplication.initialize()
  231. if not webagg_server_thread.is_alive():
  232. webagg_server_thread.start()
  233. fignum = figure.number
  234. tpl = Path(core.FigureManagerWebAgg.get_static_file_path(),
  235. "ipython_inline_figure.html").read_text()
  236. t = tornado.template.Template(tpl)
  237. return t.generate(
  238. prefix=WebAggApplication.url_prefix,
  239. fig_id=fignum,
  240. toolitems=core.NavigationToolbar2WebAgg.toolitems,
  241. canvas=figure.canvas,
  242. port=WebAggApplication.port).decode('utf-8')
  243. @_Backend.export
  244. class _BackendWebAgg(_Backend):
  245. FigureCanvas = FigureCanvasWebAgg
  246. FigureManager = core.FigureManagerWebAgg
  247. @staticmethod
  248. def trigger_manager_draw(manager):
  249. manager.canvas.draw_idle()
  250. @staticmethod
  251. def show():
  252. WebAggApplication.initialize()
  253. url = "http://{address}:{port}{prefix}".format(
  254. address=WebAggApplication.address,
  255. port=WebAggApplication.port,
  256. prefix=WebAggApplication.url_prefix)
  257. if rcParams['webagg.open_in_browser']:
  258. import webbrowser
  259. webbrowser.open(url)
  260. else:
  261. print("To view figure, visit {0}".format(url))
  262. WebAggApplication.start()