| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
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
| 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 |
|---|---|---|
| Expand Up | @@ -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") | ||
|
Comment thread
Copy link
Copy Markdown
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. Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low QualityHere we have already loaded the default mpl.js JavaScript code into the browser page, and then loading mpl_pyodide.js afterwards adds some new code and replaces some of the previous functions.
Sorry, something went wrong.
All reactions
|
||
| .read_text(encoding="utf-8")) | ||
|
|
||
| toolitems = [] | ||
| for name, tooltip, image, method in cls.ToolbarCls.toolitems: | ||
| Expand All | @@ -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() | ||
|
|
||
| Expand Down | ||
| 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): | ||
| Expand Down Expand Up | @@ -92,6 +93,9 @@ def __init__(self): | |
| "notebook": "nbagg", | ||
| } | ||
|
|
||
| if sys.platform == 'emscripten': | ||
| self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK["pyodide"] = "pyodide" | ||
|
Comment thread
Copy link
Copy Markdown
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. Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low QualityHere I am only advertising the existence of the pyodide backend if we are running on emscripten. The alternative would be to always have it present in the list of available backends even when it cannot be used.
Sorry, something went wrong.
All reactions
Copy link
Copy Markdown
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. Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low QualityI 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.
Sorry, something went wrong.
agriyakhetarpal reacted with thumbs up emoji
All reactions
Copy link
Copy Markdown
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. Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low QualityI 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.
Sorry, something went wrong.
agriyakhetarpal reacted with thumbs up emoji
All reactions
|
||
|
|
||
| def _backend_module_name(self, backend): | ||
| if backend.startswith("module://"): | ||
| return backend[9:] | ||
| Expand Down | ||
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" } | ||
| ], | ||
| }, | ||
| } | ||
| ]; |
| Back | FazBrowse Home | New Git URL |
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.
Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low QualityThe extra kwarg here isn't elegant, but it keeps backward compatibility with minimal code changes.
Alternatives would be to reimplement this entirely in backend_pyodide to 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 and backend_pyodide could just override the 2 that it needs to.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
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.
Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Qualitymakes sense, I'm 👍🏻 on this approach.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.