Realtime Updates in PyScript/Pyodide

Published May 24, 2023

Tags: pyscript python asyncio

When writing Python code to run in the Browser (whether in PyScript or Pyodide), one common desire is to print something out to the page as the program progresses. Maybe it's status messages from phases of execution, or warning messages, or informational updates. In their simplest form, they might look like:

button.html

1
<div id="myDiv"></div>
  • PyScript
  • Pyodide
1
2
for i in range(100):
    display(i, target="myDiv", append=False)
1
2
3
4
5
from js import document

for i in range(100):
    print(i)
    document.getElementById("myDiv").textContent = i

However, when running the code, it appears that only the final number 99 appears on the page, when we'd expect to see the number 0 to 99 appear, one after another.

The issue isn't that the textContent isn't being changed; the issue is that there's no opportunity for the screen to update to actually display the change. The solution is to use a coroutine.

Confirming that Changes Do Happen

To observe that the textContent of our targeted <div> is indeed changing, we can add a small Mutation Observer to the very top of the HTML page. A Mutation Observer is just what it sounds like - it watches for any mutations (changes) on a specified element, and runs some user-defined code in response. This particular mutatio will log the observed Element to the browser dev console whenever any change is made:

1
<script>
2
3
4
5
6
    function callback(mutationList, observer){
        mutationList.forEach(record => console.log(record.target))
    }
    const MO = new MutationObserver(callback)
    MO.observe(document.getElementById("myDiv"), {attributes: true })
7
</script>

With this added code, the console fills with 0, 1, ..., 98, 99. So the textContent of our target Div is, in fact, changing with each call to print/display/textContent=. So why can't we see that on the page?

Slowing Things Down to Human Speed

One might think that the code is simply proceeding too fast for you eyes to see the numbers change, but that's not exactly happening either. Let's slow things down by modifying the for loop:

  • PyScript
  • Pyodide
1
2
3
4
for i in range(100):
    display(i, target="myDiv", append = False)
    for j in range(1_000_000):
        _ = 1
1
2
3
4
5
for i in range(100):
    print(i)
    document.getElementById("myDiv").textContent = i
    for j in range(1_000_000):
        _ = 1

Now the loop has to "do a little useless work" before it advances to the next number. (You may need to change 1_000_000 to a larger or smaller number, depending on your system's capabilities.) Opening the dev console again , the numbers still appear, just at a more measured pace. But the text on the page doesn't update until the Python code has finished. So what gives?

The Real Issue

The issue is that while updates to the DOM are synchronous (i.e. no further code will be executed until the DOM update is complete), updates to the screen are asynchronous. What's more, the entire call to runPython() is synchronous, so no updates to the screen will occur until the runPython terminates. Essentially, the call to runPython is a blocking call, and nothing else can happen on the page - screen updates and repainting, other JavaScript calls, etc - until runPython returns.

This blog post gives a good high-level explanation of the interaction between synchronous code and visible changes on screen.

The Solution

So, if the screen can't update until our synchronous code call terminates, what can we do? Make our code asynchronous! By turning our code into a coroutine which occasionally yields back to the browser's event loop to do some work (i.e. update the screen), we can see the updates visibly as they happen.

Pyodide has a nifty utility for this in the form of the runPythonAsync function, which allows you to write async code without resorting to wrapping your code into a coroutine. Here's a description of this feature and its purpose, which is demonstrated in the final sample code below.

PyScript requires the user to be slightly more explicit about creating and scheduling coroutines and awaitables, and top-level await is not permitted. Instead, we'll write our code as a coroutine using async def, and schedule it using asyncio.ensure_future Here's an Overview of Asyncio in PyScript.

Finally, here's the required code. The "useless slowdown loop" is still present so that the results are visible, but there's no need for it to be there in production.

  • PyScript
  • Pyodide
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from js import document
from asyncio import sleep

async def count():
    for i in range(100):
        print(i)
        display(i, target="myDiv", append=False)
        await sleep(0.01)
        for j in range(1_000_000):
            _ = 1

fut = asyncio.ensure_future(count())
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
pyodide.runPythonAsync(`
    from js import document
    from asyncio import sleep

    for i in range(100):
        print(i)
        document.getElementById("myDiv").textContent = i
        await sleep(0.01) # Top level await is permitted by runPythonAsync
        for j in range(1_000_000):
            _ = 1
    `)

Now, go forth, and let all your intermediate results be visible!