PyScript - Why Do We Need create_proxy()?

Published October 24, 2022

Tags: python pyscript pyodide proxy

The Problem

Pyodide has an almost-magical ability to proxy objects and functions between Python and JavaScript in both directions... except when sometimes it seems to mysteriously break. Consider this stumbling block that new users often hit:

1
2
3
4
5
6
7
8
9
<button id="my_button">Say Hello</button>
<py-script>
    from js import console, document
    
    def hello(*args):
        console.log("Hello!")

    document.getElementById("my_button").addEventListener("click", hello)
</py-script>

This seems like a perfectly reasonable thing to do, but upon clicking the button, an error pops up in the developer console:

Uncaught Error: This borrowed proxy was automatically destroyed at the end of a function call. Try using create_proxy or create_once_callable.
The object was of type "function" and had repr "<function hello at 0x919828>"

The usual band-aid is wrap the Python Function in create_proxy() like so:

8
9
from pyodide.ffi import create_proxy
document.getElementById("my_button").addEventListener("click", create_pyoxy(hello))

Which seems to just make things work... but why?

Why create_proxy()?

When you call something like button.addEventListener("click", hello) (without create_proxy), Pyodide needs to briefly proxy the Python function hello so the JS function addEventListener knows how to interact with it. But once addEventListener terminates, that proxy is no longer needed, it gets destroyed... and then when an event comes around to trigger your function, the proxy it should be using is gone. Which is why you'll see the error above talking about a "borrowed proxy being automatically destroyed".

The two functions that the Error mentions (create_proxy() and create_once_callable()) create a PyProxy (a JS object) of your Python object that you, the user, are supposed to manage the lifetime of, by calling PyProxy.destroy() on it when you're done with it. Or, if you use create_once_callable(), the proxy will destroy() itself after the first time it's called.

In practical terms, for something like an event listener, you may never want to destroy the proxy for the lifetime of your page, so you can just leave it hanging around. But it's worth noting that if you remove that event listener or button (maybe in a 'single-page-app' where you're manipulating what's on the page quite a bit), you should plan to track and destroy the PyProxy, otherwise it just hangs around taking up memory.

A Better Solution

Keeping track of the proxies that wrap each of our Python functions sounds like a real pain, no? Thankfully, there's a better way, thanks to some new features in the Pyodide runtime.

Since Pyodide 21.0 (PyScript 2022.09.1), there are now wrappers built into pyodide for adding event listeners: pyodide.ffi.wrappers.add_event_listener() and pyodide.ffi.wrappers.remove_event_listener() which, if you use them in conjunction, will handle proxy creation and destruction for you.

For example, here is the entirety of pyodide.ffi.wrappers.add_event_listener:

pyodide/wrappers.py (partial)

18
19
20
21
22
23
24
25
26
def add_event_listener(
    elt: JsProxy, event: str, listener: Callable[[Any], None]
) -> None:
    """Wrapper for JavaScript's addEventListener() which automatically manages the lifetime
    of a JsProxy corresponding to the listener param.
    """
    proxy = create_proxy(listener)
    EVENT_LISTENERS[(elt.js_id, event, listener)] = proxy
    elt.addEventListener(event, proxy)

You can see that this:

  • Creates a proxy of the listener function using create_proxy()
  • Adds a reference to that proxy in an internal dictionary for later reference
  • Adds the event listener using the browser's addEventListener()

remove_event_listener simply undoes this process - it removes the event listener using JavaScript's removeEventListener, looks up the appropriate proxy in the internal dictionary, and destroy()s it.

So now, our code above would look like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<button id="my_button">Say Hello</button>
<py-script>
    from js import console, document
    from pyodide.ffi.wrappers import add_event_listener
    
    def hello(*args):
        console.log("Hello!")

    btn = document.getElementById("my_button")
    add_event_listener(btn, "click", hello)
</py-script>

I personally recommend using these wrapper methods for all new code where possible, instead of using create_proxy() and addEventListener() manually.