loading...
Cover image for Create Dev's offline page with Python (Pyodide) and WebAssembly ๐Ÿฆ„๐Ÿ’กโœจ

Create Dev's offline page with Python (Pyodide) and WebAssembly ๐Ÿฆ„๐Ÿ’กโœจ

sayanarijit profile image Arijit Basu ใƒป3 min read

Inspired by this post:

I have decided to give it a try using Python.

This is the first time I am writing WebAssembly which I wanted to do for a long time. So for this, I am using Pyodide.

Let's get started

First download Pyodide, extract it and copy the pyodide.js file in your current directory.

Now Let's write the server in server.py.

from http.server import BaseHTTPRequestHandler, HTTPServer


class HTTPRequestHandler(BaseHTTPRequestHandler):
    """Request handler class"""

    def do_GET(self):
        """Handler for GET requests."""

        # Send response status code
        self.send_response(200)

        # Send headers
        self.send_header("Cache-Control", "no-cache")

        if self.path.endswith(".wasm"):
            # Header to serve .wasm file
            self.send_header("Content-Type", "application/wasm")
        else:
            self.send_header("Content-Type", "text/html")

        self.end_headers()

        # Serve the file contents
        urlpath = self.path
        if urlpath == "/":
            urlpath = "/index.html"

        with open(f".{urlpath}", "rb") as f:
            content = f.read()

        self.wfile.write(content)


def main():
    print("starting server...")

    # Server settings
    server_address = ("localhost", 8080)
    httpd = HTTPServer(server_address, HTTPRequestHandler)

    print("running server...")
    httpd.serve_forever()


if __name__ == "__main__":
    main()

So we have pyodide.js, server.py.

Let's write the index.html

<!doctype html>
<html>
   <head>
      <meta charset="utf-8">
      <title>Python wasm</title>
   </head>
   <body>
      <script src="pyodide.js"></script>
      <div id="status">Initializing Python...</div>
      <canvas id="draw-here"></canvas>
      <style>
         .color {
         display: inline-block;
         width: 50px;
         height: 50px;
         border-radius: 50%;
         cursor: pointer;
         margin: 10px;
         }
      </style>
      <div id="colors" style="text-align:center"></div>
      <script>
        window.languagePluginUrl = "/pyodide.js";
        languagePluginLoader.then(() => {
          pyodide.runPython(` ! Python code goes here ! `)
        })
      </script>
   </body>
</html>

Now let's write the Python code for canvas.

from js import document as doc

# Make the "Initializing Python" status disappear
doc.getElementById("status").innerHTML = ""

canvas = doc.getElementById("draw-here")

canvas.setAttribute("width", doc.body.clientWidth)
canvas.setAttribute("height", 300)
ctx = canvas.getContext("2d")
ctx.strokeStyle = "#F4908E"
ctx.lineJoin = "round"
ctx.lineWidth = 5

# Global variables
pen = False
lastPoint = (0, 0)


def onmousemove(e):
    global lastPoint

    if pen:
        newPoint = (e.offsetX, e.offsetY)
        ctx.beginPath()
        ctx.moveTo(lastPoint[0], lastPoint[1])
        ctx.lineTo(newPoint[0], newPoint[1])
        ctx.closePath()
        ctx.stroke()
        lastPoint = newPoint


def onmousedown(e):
    global pen, lastPoint
    pen = True
    lastPoint = (e.offsetX, e.offsetY)


def onmouseup(e):
    global pen
    pen = False


canvas.addEventListener("mousemove", onmousemove)
canvas.addEventListener("mousedown", onmousedown)
canvas.addEventListener("mouseup", onmouseup)

# Colors

div = doc.getElementById("colors")
colors = ["#F4908E", "#F2F097", "#88B0DC", "#F7B5D1", "#53C4AF", "#FDE38C"]

for c in colors:
    node = doc.createElement("div")
    node.setAttribute("class", "color")
    node.setAttribute("id", c)
    node.setAttribute("style", f"background-color: {c}")

    def setColor(e):
        ctx.strokeStyle = e.target.id

    node.addEventListener("click", setColor)
    div.appendChild(node)

Now we can fetch this code as text via AJAX and execute with pyodide.runPython(code).

But for simplicity, we will directly paste the code in index.html.


So the final version looks like this:

<!doctype html>
<html>
   <head>
      <meta charset="utf-8">
      <title>Python wasm</title>
   </head>
   <body>
      <script src="pyodide.js"></script>
      <div id="status">Initializing Python...</div>
      <canvas id="draw-here"></canvas>
      <style>
         .color {
         display: inline-block;
         width: 50px;
         height: 50px;
         border-radius: 50%;
         cursor: pointer;
         margin: 10px;
         }
      </style>
      <div id="colors" style="text-align:center"></div>
      <script>
         window.languagePluginUrl = "/pyodide.js";
         languagePluginLoader.then(() => {
           pyodide.runPython(`
from js import document as doc

# Make the "Initializing Python" status disappear
doc.getElementById("status").innerHTML = ""

canvas = doc.getElementById("draw-here")

canvas.setAttribute("width", doc.body.clientWidth)
canvas.setAttribute("height", 300)
ctx = canvas.getContext("2d")
ctx.strokeStyle = "#F4908E"
ctx.lineJoin = "round"
ctx.lineWidth = 5

# Global variables
pen = False
lastPoint = (0, 0)


def onmousemove(e):
    global lastPoint

    if pen:
        newPoint = (e.offsetX, e.offsetY)
        ctx.beginPath()
        ctx.moveTo(lastPoint[0], lastPoint[1])
        ctx.lineTo(newPoint[0], newPoint[1])
        ctx.closePath()
        ctx.stroke()
        lastPoint = newPoint


def onmousedown(e):
    global pen, lastPoint
    pen = True
    lastPoint = (e.offsetX, e.offsetY)


def onmouseup(e):
    global pen
    pen = False


canvas.addEventListener("mousemove", onmousemove)
canvas.addEventListener("mousedown", onmousedown)
canvas.addEventListener("mouseup", onmouseup)

# Colors

div = doc.getElementById("colors")
colors = ["#F4908E", "#F2F097", "#88B0DC", "#F7B5D1", "#53C4AF", "#FDE38C"]

for c in colors:
    node = doc.createElement("div")
    node.setAttribute("class", "color")
    node.setAttribute("id", c)
    node.setAttribute("style", f"background-color: {c}")

    def setColor(e):
        ctx.strokeStyle = e.target.id

    node.addEventListener("click", setColor)
    div.appendChild(node)
           `)
         })
      </script>
   </body>
</html>

Now we only need to run the webserver with python server.py and open http://localhost:8080

If you are having trouble, here is the full project.

Discussion

markdown guide
 

This is a very cool demo of pyodide!

Won't this have to compile the python to webassembly on the client's browser, and then run that webassembly? I would have expected the python to be compiled to webassembly before the client loads it. It seems to me that this process will make the load time of the python script significantly worse than if was even just written in js.

Am I missing something, or is there a way of using pyodide that takes advantage of webassembly's load time and performance gains?

 

First of all, thanks asking this question. I realised I missed to address this topic in the post.

Yes... being dynamic and garbage collected python code is not easy to be compiled into web assembly, but not the Python interpreter which is written in C. Pyodide is kind of running a python interpreter (compiled into web assembly) in the browser which as you mentioned is performance heavy. So for now not suitable for web apps in production. However, efforts are going on and maybe in future there will be a way to bypass this limitation.

And this was the intention of this post. To learn and share with people where we currently are with python and web assembly.

However there are use cases where this performance loss due to the compilation time doesn't matter such as scientific research in browser (which is in pyodide's headline).

 

Ah I understand now. The python runtime is WebAssembly rather than the python script itself. Thank you much for your detailed and understanding response! I can now appreciate that this is really awesome!

 

Thank you for this demo! Do you know if there is any way to do file IO with this? Perhaps passing JS objects into the python environment, or somehow interacting with the virtual filesystem?
Specifically I want to load an Excel file into Pandas.

 

Pandas can load csv and Excel files from files, urls, strings and anything that behaves like a file (i.e. inherits from io.BaseIO). Maybe you can write an adapter between the is fileloader API and io.BaseIO

 

Sorry I haven't tried it and I'm not sure if I'll find the time to dig into this.

 

It was made with Rust, now with Python? Waiting for "Dev offline page in Brainf*ck" lol