Skip to content

Add pyodide backend based on webagg - #32148

Open
ianthomas23 wants to merge 6 commits into
matplotlib:mainfrom
ianthomas23:pyodide-backend
Open

Add pyodide backend based on webagg#32148
ianthomas23 wants to merge 6 commits into
matplotlib:mainfrom
ianthomas23:pyodide-backend

Conversation

@ianthomas23

Copy link
Copy Markdown
Member

PR summary

This PR adds an interactive pyodide backend to the core Matplotlib code which is based on backend_webagg_core in a similar way to the existing webagg and ipympl backends. It has been patched into the Pyodide build of Matplotlib in pyodide-recipes for about a year and a half, but as we are planning to build and upload our own Pyodide wheels to PyPI the backend needs to be part of the core repo here or the functionality will no longer be available. Note that this is for use of Pyodide outside of JupyterLite, as within JupyterLite one would use the interactive ipympl backend as usual.

Because it is not trivial to build and try out Pyodide wheels I have prepared a repo at https://github.com/ianthomas23/pyodide-wheels that uses wheels built from this branch and allows you to try it out for both Python 3.13 and 3.14 in a web browser of your choice. Also here is a screencast of it in action:

pyodide-backend.mp4

Historically a backend similar to this has sat in its own matplotlib-pyodide repo but it became awkward to maintain and was likely to disappear so I stepped in as I was aware that there could be a solution mostly relying on webagg.

Alternatives to this PR:

  • Put it in its own repo. A very reasonable request, but it is likely to end up with the same fate as matplotlib-pyodide. To survive in a standalone repo it would need the solid commitment of a maintainer for say 10 years, and I have no interest in that but I am happy to maintain it as part of the core codebase here.
  • Don't have it anywhere. This would annoy existing Pyodide users.

Details

It is based on webagg_core which remains fully backward-compatible with the webagg and ipympl backends. The implementation follows webagg as much as possible. In webagg tornado is used on the Python side to serve resources (JavaScript, CSS, etc) to the JavaScript side in the browser, and they communicate via websockets. In pyodide the Python and JS code sit side-by-side and can essentially call each other directly (via pyodide converter shims really) and this code uses a mock websocket class to keep the code changes as small as possible.

There is no testing yet. When we have reliable testing of wasm wheels I can add extra tests, similar to the webagg tests, for this.

Pinging pyodide devs @agriyakhetarpal, @hoodmane and @ryanking13 for awareness.

AI Disclosure

No AI used.

PR quality check

  • Use an expressive title, e.g. "Fix title font property precedence"
  • New and changed code is tested
  • Plotting related features are demonstrated in an example
  • New features and API changes have release notes
  • Documentation complies with general and docstring guidelines

@github-actions github-actions Bot added GUI: webagg CI: Run cibuildwheel Run wheel building tests on a PR Documentation: user guide files in galleries/users_explain or doc/users labels Jul 31, 2026

@classmethod
def get_javascript(cls, stream=None):
def get_javascript(cls, stream=None, *, pyodide=False):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 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.

Copy link
Copy Markdown
Member

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.

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")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here 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.

}

if sys.platform == 'emscripten':
self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK["pyodide"] = "pyodide"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Comment thread pyproject.toml Outdated

[tool.cibuildwheel.pyodide]
config-settings.setup-args = [
"-DrcParams-backend=pyodide"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here setting pyodide to be the default backend used in pyodide wheels. This is consistent with the current patch in the pyodide matplotlib build. Alternatively we could avoid this and stick with agg as the default backend, and explain to Pyodide users about

import matplotlib as mpl
mpl.use('pyodide')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it would be great to make this a default so that users don't need to explicitly set the backend.

@agriyakhetarpal agriyakhetarpal Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If pyodide is the default backend in pyemscripten wheels, does anything on the JupyterLite side need to change (in pre-REPL or kernel code) such that the ipympl backend is always chosen there? Do they need to override this? Sorry for the naïve question; I'm just confirming that this doesn't break something.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@agriyakhetarpal JupyterLite is fine with this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also (conditionally) put it at the top of the search order for the auto backend selection.

@story645

Copy link
Copy Markdown
Member

If this is in core, could it be used as part of the doc builds to make the widgets examples interactive?

@ianthomas23

Copy link
Copy Markdown
Member Author

If this is in core, could it be used as part of the doc builds to make the widgets examples interactive?

I suppose so, but it would need some infrastructure so that each user only has a single pyodide instance running rather than one per plot.

But I think that jupyterlite-sphinx is the go-to project for this, and being JupyterLite-based the python instance runs in a separate thread which gives a better UX. Although at a quick glance I see all the examples produce static plots not interactive, so it would need some checking to see what is currently possible.

@tacaswell tacaswell added this to the v3.12.0 milestone Jul 31, 2026

@ryanking13 ryanking13 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for upstreaming this!

There is no testing yet. When we have reliable testing of wasm wheels I can add extra tests, similar to the webagg tests, for this.

Yeah, it is slightly tricky to test this as it requires a real browser to test.

I think you can test some part of the features that does not requires DOM (document object) using cibuildwheel by adding

[tool.cibuildwheel.pyodide.environment]
test-command = "python -m pytest <test files>"

similar to how scipy is doing. But I guess there will be a lot of tests that need to be skipped.

}

if sys.platform == 'emscripten':
self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK["pyodide"] = "pyodide"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

Comment thread pyproject.toml Outdated

[tool.cibuildwheel.pyodide]
config-settings.setup-args = [
"-DrcParams-backend=pyodide"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it would be great to make this a default so that users don't need to explicitly set the backend.

@agriyakhetarpal

Copy link
Copy Markdown

If this is in core, could it be used as part of the doc builds to make the widgets examples interactive?

I suppose so, but it would need some infrastructure so that each user only has a single pyodide instance running rather than one per plot.

But I think that jupyterlite-sphinx is the go-to project for this, and being JupyterLite-based the python instance runs in a separate thread which gives a better UX. Although at a quick glance I see all the examples produce static plots not interactive, so it would need some checking to see what is currently possible.

I have an old PR, #29506, that I could revive if I have some time soon. It would connect the JupyterLite deployment to the docs via iframes, making the examples interactive.

@agriyakhetarpal agriyakhetarpal left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great to me. Thanks for upstreaming it, @ianthomas23!

In the medium term, I think some method of running the tests via Playwright and capturing their screenshots to compare (perhaps via @pytest.mark.mpl_image_compare) could be devised; Matplotlib's testing infrastructure be willing.

@ianthomas23

Copy link
Copy Markdown
Member Author

I am happy to write Playwright tests to cover web-based backends, but I think it unlikely that we (Matplotlib) want to add Playwright test infrastructure that every maintainer and new contributor has to use and understand.

I absolutely want to separate that discussion from this PR.

@tacaswell

Copy link
Copy Markdown
Member

I am strongly 👍🏻 in principle and over all everything makes sense. Only comment is the right way to make this the default, but my suggestion (putting it at the top of the search order) may mess with jupyterlite.

@@ -0,0 +1,75 @@
// mpl.js is imported before this, here we override functions from that and define new functions.

class MockJsWebSocket {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like you may have to change the eslint configuration to accept a newer version of JavaScript for this file.

Also, opened #32183 to ensure that these kind of failures cause the CI job to fail.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. I've updated all the eslint and related config in commit 42483ad to the latest versions. This includes a necessary rename of the eslint config file, and a few linting changes that I have manually checked.

story645 pushed a commit that referenced this pull request Aug 9, 2026
In #32148, the eslint check posted errors, but the job did not fail.
This setting ensure that it _will_ fail when it posts errors.
@QuLogic

QuLogic commented Aug 11, 2026

Copy link
Copy Markdown
Member

I am happy to write Playwright tests to cover web-based backends, but I think it unlikely that we (Matplotlib) want to add Playwright test infrastructure that every maintainer and new contributor has to use and understand.

See #23540

@ianthomas23

Copy link
Copy Markdown
Member Author

I am strongly 👍🏻 in principle and over all everything makes sense. Only comment is the right way to make this the default, but my suggestion (putting it at the top of the search order) may mess with jupyterlite.

I am trying setting the default via the backend search order, I need to check downstream if this causes any problems and I will report back.

@ianthomas23

Copy link
Copy Markdown
Member Author

I am trying setting the default via the backend search order, I need to check downstream if this causes any problems and I will report back.

The search order approach does not affect use JupyterLite's use of ipympl and matplotlib-inline backends.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI: Run cibuildwheel Run wheel building tests on a PR Documentation: user guide files in galleries/users_explain or doc/users GUI: nbagg GUI: webagg topic: pyplot API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants