backend_webagg.py 11 KB

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