Have you ever wanted to share your cool Python app with the world without deploying an entire Django server or developing a mobile app just for a small project?

Good news, you don’t have to! All you need is to add one JavaScript library to your HTML page and it will even work on mobile devices, allowing you to mix JS with Python so you can take advantage of both worlds.

Take a look at this REPL example:

Note that this may take some time and cause the page to freeze.
Note:

This guide has been updated to Pyodide v0.21.3.

Witchcraft! This is made possible by WebAssembly (Wasm) and the Pyodide project. You can also open the pyodide_repl.html (source code) example in a new tab.

So what can we actually do? Spoiler: With the power of Python and JS, we can do almost anything. But before getting into the details, let me first tell you a little story behind this writing.

I recently started a hobby project where I implemented image pixelation. I decided to write it in Python, as this language has a bunch of libraries for working with images. The problem was that I couldn’t easily share the app without developing an Android app or finding a hosting and deploying a Django or Flask server.

I’ve heard about WebAssembly before and have wanted to try it out for a long time. Searching the Internet for “webassembly python”, I immediately came across a link to an interesting article “Pyodide: Bringing the scientific Python stack to the browser”. Unfortunately, the article is mainly about the iodide project that is no longer in development and the documentation of Pyodide was sparse.

The idea to write this article came to me when I decided to contribute to the project by improving its documentation after collecting the information about the API piece by piece and a number of experiments with code.

Here I would like to share my experience. I will also give more examples and discuss some issues.

What is Pyodide?

According to the official repository,

Pyodide is a port of CPython to WebAssembly/Emscripten. It was created in 2018 by Michael Droettboom at Mozilla as part of the Iodide project. Iodide is an experimental web-based notebook environment for literate scientific computing and communication.

All of this is made possible by Wasm.

WebAssembly is a new type of code that can be run in modern web browsers and provides new features and major gains in performance. It is not primarily intended to be written by hand, rather it is designed to be an effective compilation target for source languages like C, C++, Rust, etc.

Wasm could potentially have a huge impact on the future of front-end development by extending the JS stack with numerous libraries and opening new possibilities for developers programming in languages other than JS. For example, there are already projects using it under the hood, such as PyScript by Anaconda.

So, it’s time to get your hands dirty. Let’s take a closer look at the minimal example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.21.3/full/pyodide.js"></script>
<script>
  (async () => { // create anonymous async function to enable await
    const pyodide = await loadPyodide();
    console.log(pyodide.runPython(`
import sys
sys.version
    `));
  })(); // call the async function immediately
</script>
</head>
<body>
</body>
</html>

First of all, we have to include the pyodide.js script by adding the CDN URL

1
<script src="https://cdn.jsdelivr.net/pyodide/v0.21.3/full/pyodide.js"></script>

After this, we must load the main Pyodide wasm module using loadPyodide and wait until the Python environment is bootstrapped

1
const pyodide = await loadPyodide()

Finally, we can run Python code

1
console.log(pyodide.runPython('import sys; sys.version'))

By default, the environment only includes standard Python modules such as sys, csv, etc. If we want to import a third-party package like numpy we have two options: we can either pre-load required packages manually and then import them in Python

1
2
3
4
5
6
7
8
await pyodide.loadPackage('numpy');
// numpy is now available
pyodide.runPython('import numpy as np')
// create a numpy array
np_array = pyodide.runPython('np.ones((3, 3))')
// convert Python array to JS array
np_array = np_array.toJs()
console.log(np_array)

or we can use the pyodide.loadPackagesFromImports function that will automatically download all packages that the code snippet imports

1
2
3
4
5
6
7
8
9
const python_code = `
import numpy as np
np.ones((3,3))
`;
(async () => {
  await pyodide.loadPackagesFromImports(python_code)
  const result = pyodide.runPython(python_code)
  console.log(result.toJs())
})() // call the function immediately
Note:

Since pyodide 0.18.0, pyodide.runPythonAsync does not automatically load packages, so loadPackagesFromImports should be called beforehand. It currently does not download packages from PyPI, but only downloads packages included in the Pyodide distribution (see Packages list). More information about loading packages can be found here

Okay, but how can we use all of this? In fact, we can replace JS and use Python as the main language for web development. Pyodide provides a bridge between JS and Python scopes.

Accessing JavaScript scope from Python

The JS scope can be accessed from Python through the js module. This module gives us access to the global object window and allows us to directly manipulate the DOM and access global variables and functions from Python. In other words, js is an alias for window, so we can either use window by importing it from the js import window or just use js directly.

Why not try it yourself? You can either try it out in the live demo above or open the demo in a new tab.

Just run this Python code and watch what happens.

1
2
3
4
5
from js import document

div = document.createElement('div')
div.innerHTML = '<h1>This element was created from Python</h1>'
document.getElementById('simple-example').prepend(div)

We have just created an h1 heading at the top of the example’s container using Python. Isn’t it cool?!

We first created a div element and then inserted it into the <div id='simple-example'> using the JS document interface.

Since we have full control over the window object, we can also handle all events from python. Let’s add a button at the bottom of the example that clears the output when clicked

1
2
3
4
5
6
7
8
9
10
from js import document

def handle_clear_output(event):
  output_area = document.getElementById('output')
  output_area.value = ''

clear_button = document.createElement('button')
clear_button.innerHTML = 'Clear output'
clear_button.onclick = handle_clear_output
document.getElementById('simple-example').appendChild(clear_button)

Note that we now use a Python function as an event handler.

Note:

We can only access the properties of the window object. That is, we can access only the variables directly attached to the window or defined globally with the var statement. Because let statement declares a block-scoped local variable just like the const, it does not create properties of the window object when declared globally.

HTTP requests

Python has a built-in module called requests that allows us to make HTTP requests. However, it is still not supported by Pyodide. Luckily, we can use the Fetch API to make HTTP requests from Python.

Pyodide used to support JS then/catch/finally promise functions and we could use fetch as follows:

1
2
3
4
from js import window
window.fetch('https://karay.me/assets/misc/test.json')
      .then(lambda resp: resp.json()).then(lambda data: data.msg)
      .catch(lambda err: 'there were error: '+err.message)

I personally find this example very cool. JS has the arrow function expression introduced in ES6, which is very handy if we want to create a callback inline. An alternative in Python is the lambda expression. Here we write the code in JS way and take advantage of chains of promises. The resp.json() function converts the response body into an object that we can then access from Python. This also enables us to handle rejections.

However, since v0.17, it integrates the implementation of await for JsProxy. So when JS returns a Promise, it converts it to Future in Python, which allows us to use await, but this object has no then/catch/finally attributes, and hence it is no longer possible to build chains like in older versions. This should be fixed in the future, but for now, we can use the await keyword to wait for the response:

1
2
3
4
5
6
7
8
9
import json
from js import window

resp = await window.fetch('https://karay.me/assets/misc/test.json')
data = await resp.json()
print(type(data))
# convert JsProxy to Python dict
data = data.to_py()
json.dumps(data, indent=2)
Note:

Since the code on the demo page is executed using runPythonAsync we can use await outside of a function.

As you probably noticed, we had to convert the JsProxy object to a Python dict using JsProxy.to_py. This is required when we communicate between JS and Python. However, some standard types do not need to be converted since this is done implicitly. You can find more information about this here.

Accessing Python scope from JS

We can also go in the opposite direction and get full access to the Python scope from JS through the pyodide.globals.get() function. Additionally, similar to Python’s JsProxy.to_py, we also need to convert the returned object to JS type using PyProxy.toJs (we’ve already done this in previous examples). For example, if we import numpy into the Python scope, we can immediately use it from JS. This option is for those who prefer JS but want to take advantage of Python libraries.

Let’s try it live

1
2
import numpy as np
x = np.ones([3,3])

Now, I will ask you to open the browser console and run this JS code

1
2
pyodide.globals.get('x').toJs()
// >>> [Float64Array(3), Float64Array(3), Float64Array(3)]

To access Python scope from JS, we use the pyodide.globals.get() that takes the name of the variable or class as an argument. The returned object is a PyProxy that we convert to JS using toJs().

As you can see, the x variable was converted to JS typed array. In the earlier version (prior to v0.17.0), we could directly access the Python scope:

1
2
let x = pyodide.globals.np.ones(new Int32Array([3, 3]))
// x >>> [Float64Array(3), Float64Array(3), Float64Array(3)]

Now, we have to manually convert the shape parameter into Python type using pyodide.toPy and then convert the result back to JS:

1
2
let x = pyodide.globals.get('np').ones(pyodide.toPy([3,3])).toJs()
// x >>> [Float64Array(3), Float64Array(3), Float64Array(3)]

This may change in the future and hopefully, most types will be implicitly converted.

Since we have full scope access, we can also re-assign new values or even JS functions to variables and create new ones from JS using globals.set function. Feel free to experiment with the code in the browser console.

1
2
3
4
5
6
7
8
9
// re-assign a new value to an existing Python variable
pyodide.globals.set('x', 'x is now string')
// create a new js function that will be available from Python
// this will show a browser alert if the function is called from Python and msg is not null (None in Python)
pyodide.globals.set('alert', msg => msg && alert(msg))
// this new function will also be available in Python and will return the square of the window
pyodide.globals.set('window_square', function(){
  return innerHeight*innerWidth
})

All of these variables and functions will be available in the global Python scope:

1
alert(f'Hi from Python. Windows square: {window_square()}')

Installing packages

If we want to import a module that is not in the Pyodide repository, say seaborn, we will get the following error

1
2
import seabornas sb
# => ModuleNotFoundError: No module named 'seaborn'

Pyodide currently supports a limited number of packages, but you can install the unsupported ones yourself using micropip module

1
2
3
import micropip

await micropip.install('seaborn')

But this does not guarantee that the module will work correctly. Also, note that there must be a wheel file in PyPi to install a module.

If a package is not found in the Pyodide repository it will be loaded from PyPI. Micropip can only load pure Python packages or for packages with C extensions that are built for Pyodide.

The recent major release (0.21-release) introduces improvements to the systems for building and loading packages. It is now much easier to build and use binary wheels that are not included in the distribution. It also includes a large number of popular packages, such as bitarray, opencv-python, shapely, and xgboost.

Detailed information on how to install and build packages can be found here.

Advanced example

Finally, let’s look at the last example. Here we will create a plot using matplotlib and display it a the page. You can reproduce the result by running the following code on the demo page.

First, we import all necessary modules. Since this will load a bunch of dependencies, the import will take a few minutes. The download progress can be seen in the browser console.

1
2
3
4
5
from js import document
import numpy as np
import scipy.stats as stats
import matplotlib.pyplot as plt
import io, base64

The numpy and scipy.stats modules are used to create a Probability Density Function (PDF). The io and base64 modules are used to encode the plot into a Base64 string, which we will later set as the source for an <img> tag.

Now let’s create the HTML layout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
div_container = document.createElement('div')
div_container.innerHTML = """
  <br><br>
  mu:
  <input id='mu' value='1' type="number">
  <br><br>
  sigma:
  <input id='sigma' value='1' type="number">
  <br><br>
  <button onclick='pyodide.globals.get("generate_plot_img")()'>Plot</button>
  <br>
  <img id="fig" />
"""
document.body.appendChild(div_container)

The layout is pretty simple. The only thing I want to draw your attention to is that we have set pyodide.globals.get("generate_plot_img")() as button’s onclick handler. Here, we get the generate_plot_img function from the Python scope and imminently call it.

After that, we define the handler function itself

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def generate_plot_img():
  # get values from inputs
  mu = int(document.getElementById('mu').value)
  sigma = int(document.getElementById('sigma').value)
  # generate an interval
  x = np.linspace(mu - 3*sigma, mu + 3*sigma, 100)
  # calculate PDF for each value in the x given mu and sigma and plot a line
  plt.plot(x, stats.norm.pdf(x, mu, sigma))
  # create buffer for an image
  buf = io.BytesIO()
  # copy the plot into the buffer
  plt.savefig(buf, format='png')
  buf.seek(0)
  # encode the image as Base64 string
  img_str = 'data:image/png;base64,' + base64.b64encode(buf.read()).decode('UTF-8')
  # show the image
  img_tag = document.getElementById('fig')
  img_tag.src = img_str
  buf.close()

This function will generate a plot and encode it as a Base64 string, which will then be set to the img tag.

You should get the following result:

Note that this may take some time and cause the page to freeze.

Every time we click the button the generate_plot_img is called. The function gets values from the inputs, generates a plot, and sets it to the img tag. Since the plt object is not closed, we can add more charts to the same figure by changing the mu and sigma values.

Conclusion

Thanks to Pyodide, we can mix JS and Python and use the two languages interchangeably, allowing us to get the best of both worlds and speed up prototyping.

On the one hand, it enables us to extend JS with vast numbers of libraries. On the other hand, it gives us the power of HTML and CSS to create a modern GUI. The final application can then be shared as a single HTML document or uploaded to any free hosting service such as the GitHub pages.

There are of course some limitations. Apart from some of the issues discussed earlier, the main one is multithreading. This can be partially solved using WebWorkers.

As mentioned at the beginning, the Iodide project is no longer in development. The Pyodide is a subproject of Iodide and it is still supported by its community, so I encourage everyone to contribute to the project.

As the project is being developed quickly, most of the issues mentioned in this guide will be resolved soon. On the other hand, new brake changes can also be introduced, so it’s only worth using the latest version as well as checking the changelog before starting a new project.

Wasm is a great technology that opens many possibilities and it has a great future. Since almost any existing C/C++ project can be compiled into Wasm, there are already many interesting ports allowing you to run games such as Doom 3 and Open Transport Tycoon Deluxe inside modern Web Browsers, and Goolge uses Wasm to rum mediapipe on the web.

Furthermore, WebAssembly System Interface (WASI) makes it possible to take full advantage of Wasm outside the browser:

It’s designed to be independent of browsers, so it doesn’t depend on Web APIs or JS, and isn’t limited by the need to be compatible with JS. And it has integrated capability-based security, so it extends WebAssembly’s characteristic sandboxing to include I/O.

For example, WASI enables us to import modules written in any language into Node.js or into other languages (e.g. import Rust module into Python), and a recent Pyodide release introduces support for Rust packages.

I hope this guide was helpful to you and you enjoyed playing with Pyodide as much as I did.