backend_webagg.py 11 KB

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