Skip to content

PyScript FFI

The foreign function interface (FFI) gives Python access to JavaScript, and JavaScript access to Python. As a result PyScript is able to access all the standard APIs and capabilities provided by the browser.

We provide a unified pyscript.ffi because Pyodide's FFI is only partially implemented in MicroPython and there are some fundamental differences. The pyscript.ffi namespace smooths out such differences into a uniform and consistent API.

Our pyscript.ffi offers the following utilities:

  • ffi.to_js(reference) converts a Python object into its JavaScript counterpart.
  • ffi.create_proxy(def_or_lambda) proxies a generic Python function into a JavaScript one, without destroying its reference right away.

Should you require access to Pyodide or MicroPython's specific version of the FFI you'll find them under the pyodide.ffi and micropython.ffi namespaces. Please refer to the documentation for those projects for further information.

to_js

In the Pyodide project, this utility converts Python dictionaries into JavaScript Map objects. Such Map objects reflect the obj.get(field) semantics native to Python's way of retrieving a value from a dictionary.

Unfortunately, this default conversion breaks the vast majority of native and third party JavaScript APIs. This is because the convention in idiomatic JavaScript is to use an object for such key/value data structures (not a Map instance).

A common complaint has been the repeated need to call to_js with the long winded argument dict_converter=js.Object.fromEntries. It turns out, most people most of the time simply want to map a Python dict to a JavaScript object (not a Map).

Furthermore, in MicroPython the default Python dict conversion is to the idiomatic and sensible JavaScript object, making the need to specify a dictionary converter pointless.

Therefore, if there is no reason to hold a Python reference in a JavaScript context (which is 99% of the time, for common usage of PyScript) then use the pyscript.ffi.to_js function, on both Pyodide and MicroPython, to always convert a Python dict to a JavaScript object.

to_js: pyodide.ffi VS pyscript.ffi
<!-- works on Pyodide (type py) only -->
<script type="py">
  from pyodide.ffi import to_js

  # default into JS new Map([["a", 1], ["b", 2]])
  to_js({"a": 1, "b": 2})
</script>

<!-- works on both Pyodide and MicroPython -->
<script type="py">
  from pyscript.ffi import to_js

  # always default into JS {"a": 1, "b": 2}
  to_js({"a": 1, "b": 2})
</script>

Note

It is still possible to specify a different dict_converter or use Pyodide specific features while converting Python references by simply overriding the explicit field for dict_converter.

However, we cannot guarantee all fields and features provided by Pyodide will work in the same way on MicroPython.

create_proxy

In the Pyodide project, this function ensures that a Python callable associated with an event listener, won't be garbage collected immediately after the function is assigned to the event. Therefore, in Pyodide, if you do not wrap your Python function, it is immediately garbage collected after being associated with an event listener.

This is so common a gotcha (see the FAQ for more on this) that the Pyodide project have already created many work-arounds to address this situation. For example, the create_once_callable, pyodide.ffi.wrappers.add_event_listener and pyodide.ffi.set_timeout are all methods whose goal is to automatically manage the lifetime of the passed in Python callback.

Add to this situation methods connected to the JavaScript Promise object (.then and .catch callbacks that are implicitly handled to guarantee no leaks once executed) and things start to get confusing and overwhelming with many ways to achieve a common end result.

Ultimately, user feedback suggests folks simply want to do something like this, as they write their Python code:

Define a callback without create_proxy.
import js
from pyscript import window


def callback(msg):
    """
    A Python callable that logs a message.
    """
    window.console.log(msg)


# Use the callback without having to explicitly create_proxy.
js.setTimeout(callback, 1000, 'success')

Therefore, PyScript provides an experimental configuration flag called experimental_create_proxy = "auto". When set, you should never have to care about these technical details nor use the create_proxy method and all the JavaScript callback APIs should just work.

Under the hood, the flag is strictly coupled with the JavaScript garbage collector that will eventually destroy all proxy objects created via the FinalizationRegistry built into the browser.

This flag also won't affect MicroPython because it rarely needs a create_proxy at all when Python functions are passed to JavaScript event handlers. MicroPython automatically handles this situation. However, there might still be rare and niche cases in MicroPython where such a conversion might be needed.

Hence, PyScript retains the create_proxy method, even though it does not change much in the MicroPython world, although it might be still needed with the Pyodide runtime is you don't use the experimental_create_proxy = "auto" flag.

At a more fundamental level, MicroPython doesn't provide (yet) a way to explicitly destroy a proxy reference, whereas Pyodide still expects to explicitly invoke proxy.destroy() when the function is not needed.

Warning

In MicroPython proxies might leak due to the lack of a destroy() method.

Happily, proxies are usually created explicitly for event listeners or other utilities that won't need to be destroyed in the future. So the lack of a destroy() method in MicroPython is not a problem in this specific, and most common, situation.

Until we have a destroy() in MicroPython, we suggest testing the experimental_create_proxy flag with Pyodide so both runtimes handle possible leaks automatically.

For completeness, the following examples illustrate the differences in behaviour between Pyodide and MicroPython:

A classic Pyodide gotcha VS MicroPython
<!-- Throws:
Uncaught Error: This borrowed proxy was automatically destroyed
at the end of a function call. Try using create_proxy or create_once_callable.
-->
<script type="py">
    import js
    js.setTimeout(lambda x: print(x), 1000, "fail");
</script>

<!-- logs "success" after a second -->
<script type="mpy">
    import js
    js.setTimeout(lambda x: print(x), 1000, "success");
</script>

To address the difference in Pyodide's behaviour, we can use the experimental flag:

experimental create_proxy
<py-config>
    experimental_create_proxy = "auto"
</py-config>

<!-- logs "success" after a second in both Pyodide and MicroPython -->
<script type="py">
    import js
    js.setTimeout(lambda x: print(x), 1000, "success");
</script>

Alternatively, create_proxy via the pyscript.ffi in both interpreters, but only in Pyodide can we then destroy such proxy:

pyscript.ffi.create_proxy
<!-- success in both Pyodide and MicroPython -->
<script type="py">
    from pyscript.ffi import create_proxy
    import js

    def log(x):
        try:
            proxy.destroy()
        except:
            pass  # MicroPython
        print(x)

    proxy = create_proxy(log)
    js.setTimeout(proxy, 1000, "success");
</script>