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.

Witchcraft! This is made possible by WebAssembly (Wasm) and the Pyodide project. Full source can be found here.

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?

Pyodide is a project from Mozilla that brings the Python runtime to the browser, along with the scientific stack including NumPy, Pandas, Matplotlib, SciPy, and others. 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 the hood, such as PyScript.

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.20.0/full/pyodide.js"></script>
<script>
  (async () => { // create anonymous async function to enable await
    let 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
2
<!-- HTML -->
<script src="https://cdn.jsdelivr.net/pyodide/v0.20.0/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
2
// JS
let pyodide = await loadPyodide()

Finally, we can run Python code

1
2
// JS
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
// JS

await pyodide.loadPackage('numpy');
// numpy is now available
pyodide.runPython('import numpy as np')
console.log(pyodide.runPython('np.ones((3, 3)))'))

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
10
// JS
let python_code = `
import numpy as np
np.ones((3,3))
`
(async () => {
  await pyodide.loadPackagesFromImports(python_code)
  let result = await pyodide.runPythonAsync(code.value)
  console.log(result)
})() // 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 use the live demo above or open the demo page in a new tab.

Just run this Python code and watch what happens.

1
2
3
4
5
6
# Python
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
11
# Python
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 use Python function as the 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.

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, we also need to convert the returned object to JS type using toJs(). 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. You can go to the demo page or try the following Python code in the live demo above

1
2
3
# Python
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
3
// JS
pyodide.globals.get('x').toJs()
// >>> [Float64Array(3), Float64Array(3), Float64Array(3)]

As you can see, the x variable was converted to JS typed array. We can also create the same array from JS:

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

The np.ones function takes a size of the array as an argument, which must be a list or a tuple. If we pass in a standard JS array, we get an error because it won’t be converted to a Python type. Therefore, we have to pass a typed array (see Pyodide [Type translations]https://pyodide.org/en/stable/usage/type-conversions.html) for more details).

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
10
11
// JS

// 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
2
# Python
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
3
# Python
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
4
# Python
import micropip

micropip.install('seaborn').then(lambda msg: print('Done. You can now import the module'))

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. More detailed information 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 on 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
6
# Python
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
15
16
# Python

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.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.generate_plot_img() as button’s onclick handler. Since we create HTML as a string, we cannot access the Python scope. We can only assign a Python function as a handler directly if we created the button programmatically using the document.createElement function.

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
20
21
# Python

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.

Wasm is a great technology that opens many possibilities. There are already a lot of interesting ports allowing to run games such as Doom 3 and Open Transport Tycoon Deluxe inside modern Web Browsers. MediaPipe allows us to process live media streams using ML on a webpage.

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