-
-
Notifications
You must be signed in to change notification settings - Fork 8.4k
Add pyodide backend based on webagg #32148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ff1183c
4ded7c8
69ba2da
42483ad
f48a20e
1bde8ee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| Pyodide backend | ||
| --------------- | ||
|
|
||
| The ``pyodide`` backend, which has been patched into pyodide builds of Matplotlib since | ||
| early 2025, is now included in the main Matplotlib repository. | ||
| It is an interactive backend based on ``webagg`` and is the default backend used on pyodide. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| """Interactive backend for pyodide running in main browser thread, based on webagg.""" | ||
|
|
||
| import base64 | ||
| from io import BytesIO | ||
| import json | ||
| import mimetypes | ||
| from pathlib import Path | ||
|
|
||
| from pyodide.code import run_js | ||
| from pyodide.ffi import create_proxy | ||
|
|
||
| from matplotlib.backend_bases import _Backend | ||
| from matplotlib._pylab_helpers import Gcf | ||
| from . import backend_webagg_core as core | ||
|
|
||
|
|
||
| class FigureManagerPyodide(core.FigureManagerWebAgg): | ||
| _toolbar2_class = core.NavigationToolbar2WebAgg | ||
|
|
||
| @classmethod | ||
| def pyplot_show(cls, *, block=None): | ||
| PyodideApplication.initialize() | ||
| managers = Gcf.get_all_fig_managers() | ||
| for manager in managers: | ||
| manager.show() | ||
|
|
||
| def show(self): | ||
| fignum = str(self.num) | ||
|
|
||
| js_code = \ | ||
| """ | ||
| var websocket_type = mpl.get_websocket_type(); | ||
| var websocket = new websocket_type(fig_id); | ||
| const parent_element = document.pyodideMplTarget ?? document.body; | ||
| const fig = new mpl.figure(fig_id, websocket, null, parent_element); | ||
| fig; | ||
| """ | ||
| js_code = f"var fig_id = '{fignum}';" + js_code | ||
|
|
||
| self.js_fig = run_js(js_code) | ||
| web_socket = PyodideApplication.MockPythonWebSocket(self, self.js_fig.ws) | ||
| web_socket.open(fignum) | ||
|
|
||
|
|
||
| class FigureCanvasPyodide(core.FigureCanvasWebAggCore): | ||
| manager_class = FigureManagerPyodide | ||
|
|
||
| def get_diff_image(self): | ||
| ret = super().get_diff_image() | ||
| self._force_full = True | ||
| return ret | ||
|
|
||
| def handle_save(self, event): | ||
| figure_id = event['figure_id'] | ||
| format = event['format'] | ||
|
|
||
| try: | ||
| from js import alert, document | ||
| except ImportError: | ||
| raise RuntimeError( | ||
| "Save not supported as cannot import js.alert and js.document") | ||
|
|
||
| mimetype = mimetypes.types_map.get(f".{format}") | ||
| if mimetype is None: | ||
| alert(f"Cannot download plot, unable to determine mimetype for '{format}'") | ||
| return | ||
|
|
||
| element = document.createElement('a') | ||
| data = BytesIO() | ||
| self.figure.savefig(data, format=format) | ||
|
|
||
| element.setAttribute( | ||
| "href", | ||
| "data:{};base64,{}".format( | ||
| mimetype, base64.b64encode(data.getvalue()).decode("ascii") | ||
| ), | ||
| ) | ||
| element.setAttribute("download", f"plot{figure_id}.{format}") | ||
| element.style.display = "none" | ||
| document.body.appendChild(element) | ||
| element.click() | ||
| document.body.removeChild(element) | ||
|
|
||
|
|
||
| class PyodideApplication(): | ||
| initialized = False | ||
|
|
||
| class MockPythonWebSocket: | ||
| supports_binary = True | ||
|
|
||
| def __init__(self, manager, js_web_socket): | ||
| self.manager = manager | ||
| self.js_web_socket = js_web_socket | ||
| self.on_message_proxy = None | ||
|
|
||
| def open(self, fignum): | ||
| self.fignum = int(fignum) | ||
| self.on_message_proxy = create_proxy(self.on_message) | ||
| self.js_web_socket.open(self.on_message_proxy) | ||
| self.manager.add_web_socket(self) | ||
|
|
||
| def on_close(self): | ||
| self.manager.remove_web_socket(self) | ||
| self.on_message_proxy.destroy() | ||
| self.on_message_proxy = None | ||
|
|
||
| def on_message(self, message): | ||
| message = json.loads(message) | ||
|
|
||
| # The 'supports_binary' message is on a client-by-client | ||
| # basis. The others affect the (shared) canvas as a | ||
| # whole. | ||
| if message['type'] == 'supports_binary': | ||
| self.supports_binary = message['value'] | ||
| else: | ||
| # It is possible for a figure to be closed, | ||
| # but a stale figure UI is still sending messages | ||
| # from the browser. | ||
| if self.manager is not None: | ||
| self.manager.handle_json(message) | ||
|
|
||
| def send_json(self, content): | ||
| self.js_web_socket.receive_json(json.dumps(content)) | ||
|
|
||
| def send_binary(self, blob): | ||
| if self.supports_binary: | ||
| self.js_web_socket.receive_binary(blob, binary=True) | ||
| else: | ||
| data_uri = "data:image/png;base64,{}".format( | ||
| blob.encode('base64').replace('\n', '')) | ||
| self.js_web_socket.receive_binary(data_uri) | ||
|
|
||
| @classmethod | ||
| def initialize(cls, url_prefix='', port=None, address=None): | ||
| if cls.initialized: | ||
| return | ||
|
|
||
| try: | ||
| from js import document | ||
|
|
||
| css = (Path(__file__).parent / "web_backend/css/mpl.css").read_text( | ||
| encoding="utf-8") | ||
| style = document.createElement('style') | ||
| style.textContent = css | ||
| document.head.append(style) | ||
| except ImportError: | ||
| # js.document not available, continue without CSS. | ||
| pass | ||
|
|
||
| js_content = core.FigureManagerWebAgg.get_javascript(pyodide=True) | ||
| set_toolbar_image_callback = run_js(js_content) | ||
| set_toolbar_image_callback(create_proxy(PyodideApplication.get_toolbar_image)) | ||
|
|
||
| cls.initialized = True | ||
|
|
||
| @classmethod | ||
| def get_toolbar_image(cls, image): | ||
| filename = Path(__file__).parent.parent / f"mpl-data/images/{image}.png" | ||
| png_bytes = filename.read_bytes() | ||
| return png_bytes | ||
|
|
||
|
|
||
| @_Backend.export | ||
| class _BackendPyodide(_Backend): | ||
| FigureCanvas = FigureCanvasPyodide | ||
| FigureManager = FigureManagerPyodide |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -509,14 +509,17 @@ def refresh_all(self): | |
| s.send_binary(diff) | ||
|
|
||
| @classmethod | ||
| def get_javascript(cls, stream=None): | ||
| def get_javascript(cls, stream=None, *, pyodide=False): | ||
| if stream is None: | ||
| output = StringIO() | ||
| else: | ||
| output = stream | ||
|
|
||
| output.write((Path(__file__).parent / "web_backend/js/mpl.js") | ||
| .read_text(encoding="utf-8")) | ||
| if pyodide: | ||
| output.write((Path(__file__).parent / "web_backend/js/mpl_pyodide.js") | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here we have already loaded the default |
||
| .read_text(encoding="utf-8")) | ||
|
|
||
| toolitems = [] | ||
| for name, tooltip, image, method in cls.ToolbarCls.toolitems: | ||
|
|
@@ -536,6 +539,11 @@ def get_javascript(cls, stream=None): | |
| output.write("mpl.default_extension = {};".format( | ||
| json.dumps(FigureCanvasWebAggCore.get_default_filetype()))) | ||
|
|
||
| if pyodide: | ||
| output.write("mpl.toolbar_image_callback = null;\n") | ||
| output.write("mpl.set_toolbar_image_callback = function(c) {\n") | ||
| output.write(" mpl.toolbar_image_callback=c;}\n") | ||
|
|
||
| if stream is None: | ||
| return output.getvalue() | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| from enum import Enum | ||
| import importlib | ||
| import sys | ||
|
|
||
|
|
||
| class BackendFilter(Enum): | ||
|
|
@@ -92,6 +93,9 @@ def __init__(self): | |
| "notebook": "nbagg", | ||
| } | ||
|
|
||
| if sys.platform == 'emscripten': | ||
| self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK["pyodide"] = "pyodide" | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here I am only advertising the existence of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know about how other backends (e.g. wegagg) are advertised, but it makes sense to me as people who try to use pyodide-backend in non-emscripten environment would get runtime error.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this makes sense to do. In the case of GUI backends it makes sense to advertise backends the user can not use due to missing dependencies so they learn they can install those dependencies to get the backend, but given that this only works in emscripten and will never work on a desktop, advertising it will just be annoying. |
||
|
|
||
| def _backend_module_name(self, backend): | ||
| if backend.startswith("module://"): | ||
| return backend[9:] | ||
|
|
||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import js from "@eslint/js"; | ||
| import globals from "globals"; | ||
| import prettier from "eslint-config-prettier/flat"; | ||
|
|
||
| export default [ | ||
| { | ||
| ignores: ["jquery-ui-*/", "node_modules/**"], | ||
| }, | ||
| js.configs.recommended, | ||
| { | ||
| languageOptions: { | ||
| globals: { | ||
| ...globals.browser, | ||
| ...globals.jquery, | ||
| IPython: "readonly", | ||
| MozWebSocket: "readonly", | ||
| mpl: "readonly", | ||
| mpl_ondownload: "readonly", | ||
| }, | ||
| }, | ||
| rules: { | ||
| indent: ["error", 2, { SwitchCase: 1 }], | ||
| "max-len": ["error", { code: 100 }], | ||
| "no-unused-vars": [ | ||
| "error", | ||
| { | ||
| argsIgnorePattern: "^_", | ||
| caughtErrors: "none", | ||
| } | ||
| ], | ||
| quotes: ["error", "double", { avoidEscape: true }], | ||
| }, | ||
| }, | ||
| { | ||
| files: ["js/**/*.js"], | ||
| rules: { | ||
| indent: ["error", 4, { SwitchCase: 1 }], | ||
| quotes: ["error", "single", { avoidEscape: true }], | ||
| }, | ||
| }, | ||
| { | ||
| files: ["js/mpl_pyodide.js"], | ||
| rules: { | ||
| "no-unused-vars": [ | ||
| "error", | ||
| { | ||
| argsIgnorePattern: "^_", | ||
| caughtErrors: "none", | ||
| args: "none", | ||
| } | ||
| ], | ||
| }, | ||
| }, | ||
| prettier, | ||
| { | ||
| rules: { | ||
| "comma-dangle": [ | ||
| "error", | ||
| { functions: "never", objects: "always-multiline" } | ||
| ], | ||
| }, | ||
| } | ||
| ]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The extra
kwarghere isn't elegant, but it keeps backward compatibility with minimal code changes.Alternatives would be to reimplement this entirely in
backend_pyodideto keep it unchanged here, but that would be quite a lot of code duplication. Or this function could call a number of other shorter functions andbackend_pyodidecould just override the 2 that it needs to.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense, I'm 👍🏻 on this approach.