Pyscript Intro

Tags: python pyscript

Installation is simple, as noted on the Github repository: clone the repo, cd into the pyscriptjs folder, run npm install and you're good to go. npm run dev starts a live server for playing with code or examples. You can also just include Pyscript via CDN.

Looking at the core part of the hello_world example shows us a few things:

hello_world.html

1
2
3
4
5
6
7
8
9
<body>
    Hello world! <br>
    This is the current date and time, as computed by Python:
    <py-script>
from datetime import datetime
now = datetime.now()
now.strftime("%m/%d/%Y, %H:%M:%S")
    </py-script>
  </body>
A screenshot of the pyscript Hello World app, with generated HTML source code clipped from the inspector

It seems that, REPL-like, the raw string output of... the final line? Is printed to the sceen. In our case, inside a div with what looks like a UUID:

A screenshot of the pyscript Hello World app, with generated HTML source code clipped from the inspector

It seems that this is only true for raw, literal values. That is, adding test_name = "test" to the end of the py-script tag means that nothing is output, but just adding "test" prints test to the screen.

<py-script>

1
2
3
4
from datetime import datetime
now = datetime.now()
now.strftime("%m/%d/%Y, %H:%M:%S")
"test"

Let's look at a slightly more complicated example with the simple clock:

<py-script>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<body>
    <div class="font-mono">start time: 
        <label id="outputDiv"></label>
    </div>
    <div id="outputDiv2" class="font-mono"></div>
    <div id="outputDiv3" class="font-mono"></div>
    <py-script output="outputDiv">
import utils
utils.now()
    </py-script>
    <py-script>
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from utils import now
import asyncio

async def foo():
  while True:
    await asyncio.sleep(1)
    output = now()
    pyscript.write("outputDiv2", output)
    
    out3 = Element("outputDiv3")
    if output[-1] in ["0", "4", "8"]:
      out3.write("It's espresso time!")
    else:
      out3.clear()

pyscript.run_until_complete(foo())
1
2
    </py-script>
  </body>

What's going on here? Well, the static text that is the start time of the program comes from the first py-script tag, again using that "final value is exported as a string" thing we saw before. The second py-script takes care of of the constatntly updating time, as well as printing "It's espresso time!" if the final character in the datetime string is a 0, 4, or 8. We're using asynchio.sleep to handle the timing

Out of curiousity, I replaced await asyncio.sleep(1) with import time and time.sleep(1), and not only does the program not wake up after 1 second to continue running, the entire chrome tab is frozen. I can't even right-click to inspect/view source. And if I try to close it or rfresh the page, I get a "page not responsive" error and the option to kill the process. So time.sleep, it seems, is right out.

Other things I'm noticing - the pyscript.write function, which apparently puts takes an element id and a value, and stuffs the value into a div within that element id. Let's look at the source to see what's actually happening here.

src/pyscript.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class PyScript:
    #...

    @staticmethod
    def write(element_id, value, append=False, exec_id=0):
        """Writes value to the element with id "element_id"""
        console.log(f"APPENDING: {append} ==> {element_id} --> {value}")
        if append:
            child = document.createElement('div');
            element = document.querySelector(f'#{element_id}');
            if not element:
                return
            exec_id = exec_id or element.childElementCount + 1
            element_id = child.id = f"{element_id}-{exec_id}";
            element.appendChild(child);

        element = document.getElementById(element_id)
        html, mime_type = format_mime(value)
        if mime_type in ('application/javascript', 'text/html'):
            scriptEl = document.createRange().createContextualFragment(html)
            element.appendChild(scriptEl)
        else:
            element.innerHTML = html

So the pyscript.write static method takes an element id and value, as well as two optional arguments. The append argument specifies whether to append the value as an additional div, as the final child of the given element, or simply set the innerHTML of the provided element to the value given. And the exec-id seems to be an index of which child of the given element is being modified, though it's also auto-incremented when appending, so probably one wouldn't set this manually much.

Adding append = True to the final pyscript.write statement behaves as expected:

And since this particular app is built with Svelte and includes tailwind, we can use all the familiar tailwind classes to start formatting the output, to make it a little more clear where our data is coming from. Let's make the first div red, the second green, and the 'espresso time' div blue:

simple_clock.html

1
2
3
<div class="font-mono bg-red-200">start time: <label id="outputDiv"></label></div>
<div id="outputDiv2" class="font-mono bg-green-200"></div>
<div id="outputDiv3" class="font-mono bg-blue-200"></div>

As long as we're in the source, let's see whatever methods and classes live in pyscript.py.

It looks like the PyScript class has only two methods: write and run_until_complete, i.e. loop forever.

There's also the Element class, which seems to be the internal, pythonic representation of a DOM element, with basic write, clear, and select method, as well as a clone(duplciate) method

Finally, there's quite a few functions that appear to deal with the output formatting of various objects based on their MIME types, allowing rendering of objects to the screen.

  • PyScript.write calls format_mime to get the properly formatted HTML for an object (in theory) before stuffing it into (or appending it to) the targetted element. If the object is a string, it simply returns that string with the MIME type 'text/plain'. Otherwise, the eval_formatter method is called to determine if the object has a print_method attribute.
  • In most cases, eval_formatter, just returns the objects print_method attribute, if it has one. But if the object's print_method is 'savefig', it stuffs the image into a base64-encoded png and returns that as well. Neat!
  • Once the content (possibly text, or a now-base64-encoded image) and MIME type are determined, some additional transformations on the content may be made. The MIME_RENDERERS dict maps MIME types to functions, some of which are the identity function, and some of which add additional html tags or boilerplate around the contetn so it will display properly. At this point,

pyscript.py

22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def render_image(mime, value, meta):
    data = f'data:{mime};charset=utf-8;base64,{value}'
    attrs = ' '.join(['{k}="{v}"' for k, v in meta.items()])
    return f'<img src="{data}" {attrs}</img>'

def identity(value, meta):
    return value


MIME_RENDERERS = {
    'text/plain': identity,
    'text/html' : identity,
    'image/png' : lambda value, meta: render_image('image/png', value, meta),
    'image/jpeg': lambda value, meta: render_image('image/jpeg', value, meta),
    'image/svg+xml': identity,
    'application/json': identity,
    'application/javascript': lambda value, meta: f'<script>{value}</script>'
} 

So, the flow is:

  • PyScript.write finds the element with the given element_id
  • PySript.write calls format_mime to get the appropriate html-formatted representation of the value passed to PyScript.write
  • If the value was a string, format_mime just returns it with a mime_type of 'text/plain'
  • Otherwise, format_mime calls eval_formatter to get the print_method's of the object, and possibly the base64 representation of it if it's an iamge.
  • Once format_mime has these methods, it looks up the repr names in its MIME_METHODS dict to map the presence of __repr__ methods to a probably mime type
  • Once the mime type is known, the value may optionally be transformed by the functions that are the values in the MIME_RENDERERS dictionary
  • Finally, if the type turned out to be either application/javascript or text/html, the given value is wrapped up in a next html or script element and stuffed into the desired element in the DOM. Otherwise, the content is simply overwritten/appended to the elements innerHTML.

I dug through all this as I was digging into Issue #103 on the PyScript Github, and learned a few things about Python on the way. Namely, print() is pretty much just a wrapper to sys.stdout.write() (or any other file-like object, if specified). And while print() can be called with any number of positional arguments and will send them all to stdout, it does so as individual calls to stdout.writer(). So programs (like PyScript) that interrupt that output to do other things with may get results that look off if they behave differently than the line-o'-text that a terminal would display.

- paths: - ./pathing.py from js import document from pathing import PathFollower canvas = document.querySelector("#my_canvas canvas") canvas.style.display = "block" width = canvas.width print(width) p = PathFollower(canvas, width, 250) p.start(interval = 100)

If all has gone well (and you're viewing this on a compatible browser), you should see my final experiment of the day, a line jumping around on an HTML canvas, powered entirely by Python (well, via JS too, but I didn't have to write any).

pyscript-intro.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<py-env>
    - paths:
      - ./pathing.py
    </py-env>
    <py-script>
    from js import document
    from pathing import PathFollower
    
    canvas = document.querySelector("#my_canvas canvas")
    canvas.style.display = "block"
    width = canvas.width
    print(width)
    
    p = PathFollower(canvas, width, 250)
    p.start(interval = 100)
    </py-script>

pathing.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from random import randint
from js import setInterval, document, DOMParser
from pyodide import create_proxy

class PathFollower:
    def __init__(self, canvas, width, height, numPoints = 10):
        self.numPoints = numPoints
        self.width = width
        self.height = height
        self.ctx = canvas.getContext("2d")
        
        canvas.style.width = f"{width}px"
        canvas.style.height = f"{height}px"

        canvas.width = width
        canvas.height = height
        self.randomizePath()
        print(self.pathPoints)

    def getNewPoint(self):
        return (randint(2,self.width-2), randint(2, self.height-2))
    
    def randomizePath(self):
        self.pathPoints = [self.getNewPoint() for _ in range(self.numPoints)]

    def movePath(self):
        self.pathPoints = self.pathPoints[1:]
        self.pathPoints.append(self.getNewPoint())

    def clearPath(self):
        self.ctx.clearRect(0,0,1000,1000)
    
    def drawPath(self):
        self.ctx.beginPath()    
        self.ctx.moveTo(*self.pathPoints[0])
        for i, point in enumerate(self.pathPoints[1:]):
            self.ctx.lineTo(*point)
            self.ctx.stroke()

    def remakePath(self):
        self.clearPath()
        #self.randomizePath()
        self.movePath()
        self.drawPath()

    def start(self, interval):
        setInterval(create_proxy(self.remakePath), interval)

Scroll to see full code