Extending web applications with WebAssembly and Python
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 theplugin.py
module. plugin.py
callsreturn_result
defined in se2-mock-runtime
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:
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 orangewasm-wrapper-c
logs are indented by one tab and the filename is greenplugin.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
andreturn_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 importedreturn_result
orreturn_error
- uses libpython to embed the Python interpreter and thus forward the implementation of
run_e
to a Python script.
- mimics an SE2 plugin by exporting the
-
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.
-
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-insdk
module)
- add the
-
When the WasmHost calls
run_e
on the Wasm module, it will- lookup the
run_e
function from theplugin
module - translate the arguments via
Py_BuildValue
- call the python function with those arguments
- lookup the
-
When
run_e
inplugin.py
callssdk.return_result
, the implementation insdk_module
will- translate the argument via
Py_ParseTuple
- call the imported
env:return_result
function with these translated arguments
- translate the argument via
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.