Skip to content

Commit

Permalink
Proof of concept of bindgen tests using componentize-py
Browse files Browse the repository at this point in the history
The basic idea is that you define a wit file along with guest code
in Python.  We can then generate a wasm component using componentize-py,
host bindings using `wasmtime.bindgen`, and then test that the generated
bindings work as expected.
  • Loading branch information
jamesls committed May 8, 2024
1 parent a0ef5dd commit 95a22ab
Show file tree
Hide file tree
Showing 12 changed files with 270 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ wasmtime/win32-*
wasmtime/include
wasmtime/bindgen/generated
tests/codegen/generated
tests/bindgen/generated
3 changes: 3 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ disallow_untyped_calls = True
disallow_untyped_defs = True
disallow_incomplete_defs = True

[mypy-componentize_py.*]
ignore_missing_imports = True

[mypy-tests.*]
check_untyped_defs = True

Expand Down
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
[pytest]
addopts = --doctest-modules --mypy
norecursedirs =
tests/bindgen/*
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
'pytest',
'pycparser',
'pytest-mypy',
'componentize-py',
],
},
classifiers=[
Expand Down
140 changes: 140 additions & 0 deletions tests/bindgen/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Test suite for testing generated code with guest code written in Python.
These tests work by allowing you to write a WIT file, implement the guest
code in Python via componentize-py, and then test the generated Python
bindings. To add a new test, first create the needed fixtures:
* Create a new sub directory.
* Within that directory create a `.wit` file.
* Create an `app.py` file in that directory implementing the guest code.
Then to write the test itself:
* Define the params of the testcase with `BindgenTestCase`.
* Generate the Python bindings using `generate_bindings()`. This will also
build the `app.wasm` file using `componentize-py`.
* `generate_bindings()` returns the store and the instantiated `Root` object
which you can then test.
## Example
Given this directory:
```
bare_funcs/
├── app.py <-- guest code implementation
├── barefuncs <-- componentize-py bindings
│ ├── __init__.py
│ └── types.py
└── component.wit <-- test .wit file
```
With a `component.wit` file of:
```wit
package component:barefuncs;
world barefuncs {
export foo: func(a: s32) -> s32;
}
```
And guest code of:
```python
class Barefuncs:
def foo(self, a: int) -> int:
return a + 1
```
You can write a testcase for this using:
```python
def test_bare_funcs():
testcase = BindgenTestCase(
guest_code_dir='bare_funcs',
world_name='barefuncs',
)
store, root = generate_bindings(testcase)
assert root.foo(store, 10) == 11
```
"""
import os
from pathlib import Path
from dataclasses import dataclass, field
from wasmtime.bindgen import generate
import wasmtime
import contextlib
import importlib
import tempfile
import subprocess
import shutil


TEST_ROOT = Path(__file__).parent
BINDGEN_DIR = TEST_ROOT / 'generated'


@contextlib.contextmanager
def chdir(dirname: Path):
original = os.getcwd()
try:
os.chdir(str(dirname))
yield
finally:
os.chdir(original)


@dataclass
class BindgenTestCase:
world_name: str
guest_code_dir: str
app_dir: Path = field(init=False)
wit_filename: str = 'component.wit'
app_name: str = 'app'

def __post_init__(self):
self.app_dir = TEST_ROOT / self.guest_code_dir

@property
def wit_full_path(self):
return self.app_dir.joinpath(self.wit_filename)

@property
def testsuite_name(self):
# The name of the directory that contains the
# guest Python code is used as the identifier for
# package names, etc.
return self.app_dir.name


def generate_bindings(testcase: BindgenTestCase):
wit_path = testcase.wit_full_path
componentize_py = shutil.which('componentize-py')
if componentize_py is None:
raise RuntimeError("Could not find componentize-py executable.")
with tempfile.NamedTemporaryFile('w') as f:
output_wasm = str(f.name + '.wasm')
with chdir(testcase.app_dir):
subprocess.run([
componentize_py, '-d', str(wit_path), '-w', testcase.world_name,
'componentize', '--stub-wasi', testcase.app_name,
'-o', output_wasm
], check=True)
# Once we've done that now generate the python bindings.
testsuite_name = testcase.testsuite_name
with open(output_wasm, 'rb') as out:
# Mapping of filename -> content_bytes
results = generate(testsuite_name, out.read())
for filename, contents in results.items():
path = BINDGEN_DIR / testsuite_name / filename
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(contents)
# Return an instantiated module for the caller to test.
pkg = importlib.import_module(f'.generated.{testsuite_name}',
package=__package__)
store = wasmtime.Store()
root = pkg.Root(store)
return store, root
3 changes: 3 additions & 0 deletions tests/bindgen/bare_funcs/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Barefuncs:
def foo(self, a):
return a + 1
5 changes: 5 additions & 0 deletions tests/bindgen/bare_funcs/component.wit
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package component:barefuncs;

world barefuncs {
export foo: func(a: s32) -> s32;
}
30 changes: 30 additions & 0 deletions tests/bindgen/export_resources/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import sys
from types import ModuleType


class MyInterfaceName:
def interface_func(self, foo: str) -> str:
return f"hello {foo}"


# componentize-py expects that resources within an interface are defined
# as a class in a separate module that matches the interface name.
#
# Normally, you'd want to go the more typical route of running
#
# componentize-py -d component.wit -w testworld bindings .
#
# to generate the types and protocols to help you write guest code,
# and then split the code into multiple files, but we're taking a
# shortcut here so we can write all the guest code in a single file.
class DemoResourceClass:
def __init__(self, name: str) -> None:
self.name = name

def greet(self, greeting: str) -> str:
return f'{greeting}, {self.name}!'


mod = ModuleType("my_interface_name")
mod.DemoResourceClass = DemoResourceClass
sys.modules['my_interface_name'] = mod
13 changes: 13 additions & 0 deletions tests/bindgen/export_resources/component.wit
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package component:basicresource;

interface my-interface-name {
interface-func: func(foo: string) -> string;
resource demo-resource-class {
constructor(name: string);
greet: func(greeting: string) -> string;
}
}

world testworld {
export my-interface-name;
}
15 changes: 15 additions & 0 deletions tests/bindgen/list_types/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from typing import List


class Lists:
def strings(self, a: str) -> str:
return a

def bytes(self, a: bytes) -> bytes:
return a

def ints(self, a: List[int]) -> List[int]:
return a

def string_list(self, a: List[str]) -> List[str]:
return a
8 changes: 8 additions & 0 deletions tests/bindgen/list_types/component.wit
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package component:lists;

world lists {
export strings: func(a: string) -> string;
export bytes: func(a: list<u8>) -> list<u8>;
export ints: func(a: list<u32>) -> list<u32>;
export string-list: func(a: list<string>) -> list<string>;
}
49 changes: 49 additions & 0 deletions tests/bindgen/test_bindgen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from . import BindgenTestCase, generate_bindings

def test_bare_funcs():
testcase = BindgenTestCase(
guest_code_dir='bare_funcs',
world_name='barefuncs',
)
store, root = generate_bindings(testcase)
assert root.foo(store, 10) == 11


# This test works, but needs wasmtime-py#232 merged to pass.
#
# def test_export_resources():
# testcase = BindgenTestCase(
# guest_code_dir='export_resources',
# world_name='testworld',
# )
# store, root = generate_bindings(testcase)
# interface = root.my_interface_name()
# instance = interface.DemoResourceClass(store, 'myname')
# result = instance.greet(store, 'Hello there')
# assert result == 'Hello there, myname!'


def test_lists():
testcase = BindgenTestCase(
guest_code_dir='list_types',
world_name='lists'
)
store, root = generate_bindings(testcase)
assert root.strings(store, '') == ''
assert root.strings(store, 'a') == 'a'
assert root.strings(store, 'hello world') == 'hello world'
assert root.strings(store, 'hello ⚑ world') == 'hello ⚑ world'

assert root.bytes(store, b'') == b''
assert root.bytes(store, b'a') == b'a'
assert root.bytes(store, b'\x01\x02') == b'\x01\x02'

assert root.ints(store, []) == []
assert root.ints(store, [1]) == [1]
assert root.ints(store, [1, 2, 100, 10000]) == [1, 2, 100, 10000]

assert root.string_list(store, []) == []
assert root.string_list(store, ['']) == ['']
assert root.string_list(
store, ['a', 'b', '', 'd', 'hello']
) == ['a', 'b', '', 'd', 'hello']

0 comments on commit 95a22ab

Please sign in to comment.