Wasm Labs

Extending web applications with WebAssembly and Python

By Asen Alexandrov
At 2023 / 05 10 mins reading

This article shows how you can run a Python program within another application that uses a Wasm runtime (host) and have the Python program talk to the host and vice versa.

A couple of months ago we added Python to the WebAssembly Language Runtimes. We published a pre-built python.wasm binary, which can be used to run Python scripts with WebAssembly to get better security and portability.

After that release we received a lot of feedback on how to make it even more useful for developers. One of the recurring topics was around the need for bi-directional communication between the Wasm host and the Python code that runs on python.wasm.

We worked on this together with the Suborbital team and implemented an application that showcases bi-directional communication by implementing the SE2 Plugin ABI. This work was later incorporated in Suborbital's SE2 Python offering.

The sample application can be found at WLR/python/examples/bindings/se2-bindings. It is easy to run and can guide you on how to embed Python in a Wasm application and implement bindings for bi-directional communication.

Background

WebAssembly is a great technology for extending applications. On the one hand, it provides a sandboxed environment, where extensions can run securely within the same process as the main application. On the other hand, it allows people to write extensions in any language that can be built into a Wasm module, or can be interpreted by one, like python.wasm.

There is an emerging trend for web applications and platforms to offer extensibility on top of WebAssembly. It is used by serverless platforms such as Cosmonic's wasmCloud, CloudFlare's Workers, Fastly's Compute, Fermyon's Cloud, our own Wasm Workers Server, and also by solutions for extending existing applications such as Dylibso's Extism, LoopholeLabs's Scale and Suborbital's Extension Engine.

Extending applications

The ability to extend applications with external code provides great flexibility to the software development processes. One can have a community of developers writing extensions for the same application, or a platform may offer great basic functionality that developers can reuse by building extensions on top.

Traditionally, this is done via so-called plugins, which get loaded into the main application and implement custom functionality. However, this usually limits plugin implementers to using a specific programming language (or set of languages) and also brings in risks to the stability and security of the main application.

With web applications developers have the option to use WebHooks, which allows several web apps to work as one and each can be implemented in a different language. However, this approach implies slower communication between the different web apps and a more complex deployment setup.

Extending with WebAssembly

To extend an application with WebAssembly you need to embed a Wasm runtime in it, so it can execute code from a Wasm module. We call such an application a Wasm host. Communication between the Wasm host and the Wasm module is well-defined by the exported and imported functions as declared by the Wasm module. Exported functions are implemented by the module and can be invoked by the host, while imported functions (also called host functions) are implemented by the host and can be invoked by the module.

However, when a Wasm module embeds the Python runtime in order to run a Python script we don't have a well defined way for the Wasm host to call a function from the Python script or the other way around. To facilitate that we need to add some code in the Wasm module that would expose host functions to the Python script and Python functions to the host. We refer to that code with the term bindings as it translates a Wasm module API to Python.

When we were doing our initial experiments at creating such bindings we had a discussion with Suborbital, who were just starting to work on adding Python support to their SE2 engine. We decided to collaborate. After experimenting with libpython and the Python C API we came to the working solution explained in this article.

The Suborbital team picked this up and pretty quickly rolled SE2 support for Python plugins.

Why work on this

The main drive to make WasmHost-to-Python communication easier is reusability. There is a lot of existing Python developers and code out there. Even though you can run Python scripts on python.wasm, you still don't have a paved way to communicate with the Wasm host. When necessary, developers will need to find their way through trial and error.

Accelerating WebAssembly adoption is one of our core goals at Wasm Labs, so we decided that working on a showcase of how to do this bi-directional communication can help developers bridge the gap between Wasm and their existing Python apps and knowledge.

We decided to partner up with Suborbital, who had a need for this functionality as part of their platform.

Previous work

This is all based on the python.wasm work done by the CPython team. They already provide a wasm32-wasi build target, which we previously used to publish reusable Python binaries. We only needed to add libpython (which they also build) to the released assets.

The Suborbital team already has a well-defined ABI for the communication between a Wasm host and plugins defined in JavaScript that gets interpreted by a Wasm module. We could easily build on top of something that works and just implement it for another language.

Quick overview

Our app consists of three separate components:

  • se2-mock-runtime: a WebAssembly host.
  • py-plugin: a Python app.
  • wasm-wrapper-c: a Wasm app that provides the bindings for communication between the other two.

The diagram below shows the app's flow:

  • se2-mock-runtime calls run_e defined in the plugin.py module.
  • plugin.py calls return_result defined in se2-mock-runtime
se2-bindings showcase overview

Running it

You only need to have Docker. Then running this example boils down to

export TMP_WORKDIR=$(mktemp -d)
git clone --depth 1 https://github.com/vmware-labs/webassembly-language-runtimes ${TMP_WORKDIR}/wlr
(cd ${TMP_WORKDIR}/wlr/python/examples/bindings/se2-bindings; ./run_me.sh)

and you will get an output with extensive logging that looks like this:

run_me.sh console output

You will notice a few extra logs around methods used for explicit memory management. They are explained in the README.md companion to the example, and we will not discuss them here for simplicity.

For easier reading the logs are organized like this:

  • se2-mock-runtime logs start at the beginning of the line and the filename is dark orange
  • wasm-wrapper-c logs are indented by one tab and the filename is green
  • plugin.py logs are indented by two tabs and the filename is violet

Deep dive

To get a better feel at the code and the whole showcase application, take a look at it on GitHub. There, you will find instructions on how to run it as well as more detailed explanation of its components.

Overview

Let's take a closer look at each of the components

  • se2-mock-runtime - a Node JS app that mimics Suborbital's SE2 runtime

    • loads a Wasm module (equivalent to an SE2 plugin)
    • calls its run_e method to execute the plugin logic with a sample script
    • provides return_result and return_error, which are used by the plugin to return execution result
  • wasm-wrapper-c - a Wasm module (written in C), which

    • mimics an SE2 plugin by exporting the run_e method and using imported return_result or return_error
    • uses libpython to embed the Python interpreter and thus forward the implementation of run_e to a Python script.
  • py-plugin - a Python app

    • executed by the wasm-wrapper-c app
    • provides the actual implementation of run_e in pure Python - "string reversal for words that contain only characters (without ', ., etc.)"

Using libpython.a and the Python C API

Embedding the Python interpreter is pretty straightforward. The Python C API is well documented and one can find multiple examples in Open Source software.

The challenge with WASI comes from the lack of support for dynamic libraries. Because of this we have to use libpython as a static library and link it into out Wasm module. For convenience, we added a libpython tarball to our Python release. It contains all the necessary headers, and the libpython.a file is a fat library that has all other dependencies (like zlib, libuuid, sqlite3, etc.) incorporated.

Additionally, complex Python applications are likely to require a bigger stack size. To make sure we have enough and it's configured correctly, we added linker options to the configuration file in the above in the above tarball.

// Linker configuration in lib/wasm32-wasi/pkg-config/libpython3.11.pc.
-Wl,-z,stack-size=524288 -Wl,--stack-first -Wl

This ensures a big enough stack of half a MB. Additionally, it places the stack before the global data thus ensuring that any stack overflow will lead to immediate Wasm trap, instead of silent global data corruptions (in some cases).

For more details on how to link a C app with libpython from WebAssembly Language Runtimes you can check out the build-wasm.sh and CMakeLists.tst files in WLR/python/examples/bindings/se2-bindings/wasm-wrapper-c/

Calling a Python function from the host

Let's say we have this function in plugin.py. So how do we call it from the Wasm host?

def run_e(payload, id):
"""Processes UTF-8 encoded `payload`. Execution is identified by an `id`.
"""

log(f'Received payload "{payload}"', id)

To get to call anything from the Wasm host we need to export it first from the Wasm module, which embeds the Python interpreter. As we better use simple types we represent the string as a pointer and length.

__attribute__((export_name("run_e"))) void run_e(u8 *ptr, i32 len, i32 id);

Then, translating this method to the Python one is straightforward with the Python C API. Skipping the error and memory handling it boils down to something like this:

void run_e(u8 *ptr, i32 len, i32 id) {
PyObject *module_name = PyUnicode_DecodeFSDefault("plugin");
PyObject *plugin_module = PyImport_Import(module_name);
PyObject *run_e = PyObject_GetAttrString(plugin_module, "run_e");
PyObject *run_e_args = Py_BuildValue("s#i", ptr, len, id);
PyObject *result = PyObject_CallObject(run_e, run_e_args);
}

The major method to examine here is Py_BuildValue and the Python docs about Building values.

Calling a host function from Python code

Let's say we have this host function.

/** Can be called to return UTF-8 encoded result for an execution `id`
@param ptr Pointer to the returned result
@param len Length of the returned result.
@param id Execution id
*/

void env_return_result(u8 *ptr, i32 len, i32 id)
__attribute__((__import_module__("env"),
__import_name__("return_result")));

To allow the Python code in plugin.py to access it we will need to create a Python module in C, which will translate from something like def return_result(result, id) to the function above.

A sample implementation (skipping error handling) of an SDK module with such function would look like:

static PyObject *sdk_return_result(PyObject *self, PyObject *args) {
char *ptr;
Py_ssize_t len;
i32 id;
PyArg_ParseTuple(args, "s#i", &ptr, &len, &id);
env_return_result((u8 *)result, result_len, ident);
Py_RETURN_NONE;
}

static PyMethodDef SdkMethods[] = {
{"return_result", sdk_return_result, METH_VARARGS, "Returns result"},
{NULL, NULL, 0, NULL}};

static PyModuleDef SdkModule = {
PyModuleDef_HEAD_INIT, "sdk", NULL, -1, SdkMethods,
NULL, NULL, NULL, NULL};

static PyObject *PyInit_SdkModule(void) {
return PyModule_Create(&SdkModule);
}

Again, the core of this is in PyArg_ParseTuple, which is well documented in the Python docs about Parsing arguments.

Finally, before we initialize the Python interpreter we just need to add the 'sdk' module to the list of built-in modules. This will make it available via import sdk in the interpreted Python modules.

PyImport_AppendInittab("sdk", &PyInit_SdkModule);
Py_Initialize();

Putting it all together

You can see how this all fits together with our showcase application on the picture below.

  1. When the WasmHost calls _start on the Wasm module, it will call _initialize internally to

    • add the sdk plugin as a built-in Python module
    • initialize the Python interpreter
    • load the plugin module (which will import the built-in sdk module)
  2. When the WasmHost calls run_e on the Wasm module, it will

    • lookup the run_e function from the plugin module
    • translate the arguments via Py_BuildValue
    • call the python function with those arguments
  3. When run_e in plugin.py calls sdk.return_result, the implementation in sdk_module will

    • translate the argument via Py_ParseTuple
    • call the imported env:return_result function with these translated arguments
se2-bindings recap

Future work

Our showcase application includes a lot of manual work. In an ideal scenario, you will provide a .wit file declaring an API and can have the bindings code generated automatically.

There is already developers from multiple companies working on a more generic approach for using Python interchangeably with server-side Wasm. You can track the progress in the Python guest runtime and bindings stream on the ByteCodeAlliance's Zulip space.

[5 min] Try it out

Give this showcase app a try here.

If you want to build something from scratch, you can find a pre-built libpython as part of our recent Python release. Don't forget the linker options mentioned earlier on.

Let us know what you think! If you find our work meaningful give us a star in GitHub and follow us on Twitter.

Do you want to stay up to date with WebAssembly and our projects?