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