Skip to content

Commit 3ac42c4

Browse files
authored
Proof of concept of bindgen tests using componentize-py (bytecodealliance#234)
* Proof of concept of bindgen tests using componentize-py 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. * Restructure bindgen testcases * Each test case is now in the same directory with the corresponding WIT file and guest code (app.py). * The helper functions for generating wasm components and host bindings is moved to a pytest fixture to avoid relative package issues when using direct imports. * Remove the use of changing the cwd in the test process.
1 parent 7316692 commit 3ac42c4

15 files changed

+275
-1
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ wasmtime/win32-*
1414
wasmtime/include
1515
wasmtime/bindgen/generated
1616
tests/codegen/generated
17+
tests/bindgen/generated

mypy.ini

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ disallow_untyped_calls = True
1212
disallow_untyped_defs = True
1313
disallow_incomplete_defs = True
1414

15+
[mypy-componentize_py.*]
16+
ignore_missing_imports = True
17+
1518
[mypy-tests.*]
1619
check_untyped_defs = True
1720

pytest.ini

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
[pytest]
2-
addopts = --doctest-modules --mypy
2+
addopts = --doctest-modules --mypy --ignore-glob=tests/bindgen/*/app.py
3+
norecursedirs =
4+
tests/bindgen/generated/*

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
'pytest',
4343
'pycparser',
4444
'pytest-mypy',
45+
'componentize-py',
4546
],
4647
},
4748
classifiers=[

tests/bindgen/__init__.py

Whitespace-only changes.

tests/bindgen/bare_funcs/app.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class Barefuncs:
2+
def foo(self, a):
3+
return a + 1
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package component:barefuncs;
2+
3+
world barefuncs {
4+
export foo: func(a: s32) -> s32;
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from pathlib import Path
2+
3+
4+
def test_bare_funcs(bindgen_testcase):
5+
store, root = bindgen_testcase(
6+
guest_code_dir=Path(__file__).parent,
7+
world_name='barefuncs',
8+
)
9+
assert root.foo(store, 10) == 11

tests/bindgen/conftest.py

+146
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Fixtures to define test suites for generated code Python guest code.
2+
3+
These tests work by allowing you to write a WIT file, implement the guest
4+
code in Python via componentize-py, and then test the generated Python
5+
bindings. To add a new test, first create the needed fixtures:
6+
7+
* Create a new sub directory.
8+
* Within that directory create a `.wit` file.
9+
* Create an `app.py` file in that directory implementing the guest code.
10+
11+
Then to write the test itself:
12+
13+
* Create a `test_<name>.py` in the same directory.
14+
* Use the `bindgest_testcase` in your test to create the wasm component
15+
and generate python bindings for this component.
16+
17+
## Example
18+
19+
Given this directory:
20+
21+
```
22+
bare_funcs/
23+
├── app.py <-- guest code implementation
24+
├── barefuncs <-- componentize-py bindings
25+
│ ├── __init__.py
26+
│ └── types.py
27+
├── component.wit <-- test .wit file
28+
└── test_mycomp.py <-- pytest test case of bindings
29+
```
30+
31+
With a `component.wit` file of:
32+
33+
```wit
34+
package component:barefuncs;
35+
36+
world barefuncs {
37+
export foo: func(a: s32) -> s32;
38+
}
39+
```
40+
41+
And guest code of:
42+
43+
```python
44+
class Barefuncs:
45+
def foo(self, a: int) -> int:
46+
return a + 1
47+
```
48+
49+
You can write a testcase for this using:
50+
51+
```python
52+
from pathlib import Path
53+
54+
55+
def test_bare_funcs(bindgen_testcase):
56+
testcase = bindgen_testcase(
57+
guest_code_dir=Path(__file__).parent,
58+
world_name='barefuncs',
59+
)
60+
store, root = generate_bindings(testcase)
61+
assert root.foo(store, 10) == 11
62+
```
63+
64+
"""
65+
from pathlib import Path
66+
from dataclasses import dataclass, field
67+
import importlib
68+
import tempfile
69+
import subprocess
70+
import shutil
71+
72+
from pytest import fixture
73+
74+
import wasmtime
75+
from wasmtime.bindgen import generate
76+
77+
78+
TEST_ROOT = Path(__file__).parent
79+
BINDGEN_DIR = TEST_ROOT / 'generated'
80+
81+
82+
@dataclass
83+
class BindgenTestCase:
84+
guest_code_dir: Path
85+
world_name: str
86+
wit_filename: str = 'component.wit'
87+
app_dir: Path = field(init=False)
88+
app_name: str = field(init=False, default='app', repr=False)
89+
90+
def __post_init__(self):
91+
self.app_dir = Path(self.guest_code_dir).resolve()
92+
93+
@property
94+
def wit_full_path(self):
95+
return self.guest_code_dir.joinpath(self.wit_filename)
96+
97+
@property
98+
def testsuite_name(self):
99+
# The name of the directory that contains the
100+
# guest Python code is used as the identifier for
101+
# package names, etc.
102+
return self.guest_code_dir.name
103+
104+
105+
def generate_bindings(guest_code_dir: Path,
106+
world_name: str,
107+
wit_filename: str = 'component.wit'):
108+
tc = BindgenTestCase(
109+
guest_code_dir=guest_code_dir,
110+
world_name=world_name,
111+
wit_filename=wit_filename)
112+
return _generate_bindings(tc)
113+
114+
115+
def _generate_bindings(testcase: BindgenTestCase):
116+
wit_path = testcase.wit_full_path
117+
componentize_py = shutil.which('componentize-py')
118+
if componentize_py is None:
119+
raise RuntimeError("Could not find componentize-py executable.")
120+
with tempfile.NamedTemporaryFile('w') as f:
121+
output_wasm = str(f.name + '.wasm')
122+
subprocess.run([
123+
componentize_py, '-d', str(wit_path), '-w', testcase.world_name,
124+
'componentize', '--stub-wasi', testcase.app_name,
125+
'-o', output_wasm
126+
], check=True, cwd=testcase.guest_code_dir)
127+
# Once we've done that now generate the python bindings.
128+
testsuite_name = testcase.testsuite_name
129+
with open(output_wasm, 'rb') as out:
130+
# Mapping of filename -> content_bytes
131+
results = generate(testsuite_name, out.read())
132+
for filename, contents in results.items():
133+
path = BINDGEN_DIR / testsuite_name / filename
134+
path.parent.mkdir(parents=True, exist_ok=True)
135+
path.write_bytes(contents)
136+
# Return an instantiated module for the caller to test.
137+
pkg = importlib.import_module(f'.generated.{testsuite_name}',
138+
package=__package__)
139+
store = wasmtime.Store()
140+
root = pkg.Root(store)
141+
return store, root
142+
143+
144+
@fixture
145+
def bindgen_testcase():
146+
return generate_bindings

tests/bindgen/export_resources/app.py

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import sys
2+
from types import ModuleType
3+
4+
5+
class MyInterfaceName:
6+
def interface_func(self, foo: str) -> str:
7+
return f"hello {foo}"
8+
9+
10+
# componentize-py expects that resources within an interface are defined
11+
# as a class in a separate module that matches the interface name.
12+
#
13+
# Normally, you'd want to go the more typical route of running
14+
#
15+
# componentize-py -d component.wit -w testworld bindings .
16+
#
17+
# to generate the types and protocols to help you write guest code,
18+
# and then split the code into multiple files, but we're taking a
19+
# shortcut here so we can write all the guest code in a single file.
20+
class DemoResourceClass:
21+
def __init__(self, name: str) -> None:
22+
self.name = name
23+
24+
def greet(self, greeting: str) -> str:
25+
return f'{greeting}, {self.name}!'
26+
27+
28+
mod = ModuleType("my_interface_name")
29+
mod.DemoResourceClass = DemoResourceClass
30+
sys.modules['my_interface_name'] = mod
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package component:basicresource;
2+
3+
interface my-interface-name {
4+
interface-func: func(foo: string) -> string;
5+
resource demo-resource-class {
6+
constructor(name: string);
7+
greet: func(greeting: string) -> string;
8+
}
9+
}
10+
11+
world testworld {
12+
export my-interface-name;
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from pathlib import Path
2+
3+
4+
def test_bare_funcs(bindgen_testcase):
5+
store, root = bindgen_testcase(
6+
guest_code_dir=Path(__file__).parent,
7+
world_name='testworld',
8+
)
9+
interface = root.my_interface_name()
10+
instance = interface.DemoResourceClass(store, 'myname')
11+
result = instance.greet(store, 'Hello there')
12+
assert result == 'Hello there, myname!'

tests/bindgen/list_types/app.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import List
2+
3+
4+
class Lists:
5+
def strings(self, a: str) -> str:
6+
return a
7+
8+
def bytes(self, a: bytes) -> bytes:
9+
return a
10+
11+
def ints(self, a: List[int]) -> List[int]:
12+
return a
13+
14+
def string_list(self, a: List[str]) -> List[str]:
15+
return a
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package component:lists;
2+
3+
world lists {
4+
export strings: func(a: string) -> string;
5+
export bytes: func(a: list<u8>) -> list<u8>;
6+
export ints: func(a: list<u32>) -> list<u32>;
7+
export string-list: func(a: list<string>) -> list<string>;
8+
}
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from pathlib import Path
2+
3+
4+
def test_lists(bindgen_testcase):
5+
store, root = bindgen_testcase(
6+
guest_code_dir=Path(__file__).parent,
7+
world_name='lists',
8+
)
9+
assert root.strings(store, '') == ''
10+
assert root.strings(store, 'a') == 'a'
11+
assert root.strings(store, 'hello world') == 'hello world'
12+
assert root.strings(store, 'hello ⚑ world') == 'hello ⚑ world'
13+
14+
assert root.bytes(store, b'') == b''
15+
assert root.bytes(store, b'a') == b'a'
16+
assert root.bytes(store, b'\x01\x02') == b'\x01\x02'
17+
18+
assert root.ints(store, []) == []
19+
assert root.ints(store, [1]) == [1]
20+
assert root.ints(store, [1, 2, 100, 10000]) == [1, 2, 100, 10000]
21+
22+
assert root.string_list(store, []) == []
23+
assert root.string_list(store, ['']) == ['']
24+
assert root.string_list(
25+
store, ['a', 'b', '', 'd', 'hello']
26+
) == ['a', 'b', '', 'd', 'hello']

0 commit comments

Comments
 (0)