backend_webagg.py 11 KB

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