universal-wasm-loader 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of universal-wasm-loader might be problematic. Click here for more details.
- universal_wasm_loader/__init__.py +3 -0
- universal_wasm_loader/_abi.py +124 -0
- universal_wasm_loader/_loader.py +195 -0
- universal_wasm_loader/_wit_parser.py +64 -0
- universal_wasm_loader-1.0.0.dist-info/METADATA +121 -0
- universal_wasm_loader-1.0.0.dist-info/RECORD +8 -0
- universal_wasm_loader-1.0.0.dist-info/WHEEL +4 -0
- universal_wasm_loader-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
import wasmtime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _decode_string(memory: wasmtime.Memory, store: wasmtime.Store, ptr: int, length: int) -> str:
|
|
9
|
+
return bytes(memory.read(store, ptr, ptr + length)).decode("utf-8")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read_i32_le(memory: wasmtime.Memory, store: wasmtime.Store, addr: int) -> int:
|
|
13
|
+
return int.from_bytes(bytes(memory.read(store, addr, addr + 4)), "little", signed=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _encode_string(
|
|
17
|
+
memory: wasmtime.Memory,
|
|
18
|
+
store: wasmtime.Store,
|
|
19
|
+
realloc: wasmtime.Func,
|
|
20
|
+
text: str,
|
|
21
|
+
) -> tuple[int, int]:
|
|
22
|
+
data = text.encode("utf-8")
|
|
23
|
+
ptr: int = realloc(store, 0, 0, 1, len(data)) # type: ignore[assignment]
|
|
24
|
+
memory.write(store, data, ptr)
|
|
25
|
+
return ptr, len(data)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_component_import_env(
|
|
29
|
+
imports: list[dict], # type: ignore[type-arg]
|
|
30
|
+
callbacks: dict[str, Callable] | None, # type: ignore[type-arg]
|
|
31
|
+
memory_ref: list[wasmtime.Memory | None],
|
|
32
|
+
store: wasmtime.Store,
|
|
33
|
+
) -> dict[str, Callable]: # type: ignore[type-arg]
|
|
34
|
+
callbacks = callbacks or {}
|
|
35
|
+
|
|
36
|
+
def make_wrapper(fd: dict, cb: Callable | None) -> Callable: # type: ignore[type-arg]
|
|
37
|
+
has_str = any(p["type"] == "string" for p in fd["params"])
|
|
38
|
+
|
|
39
|
+
def wrapper(*raw_args: Any) -> Any:
|
|
40
|
+
memory = memory_ref[0]
|
|
41
|
+
converted: list[Any] = []
|
|
42
|
+
idx = 0
|
|
43
|
+
for p in fd["params"]:
|
|
44
|
+
val = raw_args[idx]
|
|
45
|
+
idx += 1
|
|
46
|
+
if p["type"] == "bool":
|
|
47
|
+
converted.append(bool(val))
|
|
48
|
+
elif p["type"] == "string" and has_str and memory is not None:
|
|
49
|
+
length = int(raw_args[idx])
|
|
50
|
+
idx += 1
|
|
51
|
+
converted.append(_decode_string(memory, store, int(val), length))
|
|
52
|
+
else:
|
|
53
|
+
converted.append(val)
|
|
54
|
+
if cb is None:
|
|
55
|
+
return None if not fd["return_type"] else 0
|
|
56
|
+
result = cb(*converted)
|
|
57
|
+
if result is None:
|
|
58
|
+
return 0
|
|
59
|
+
return int(result) if isinstance(result, bool) else result
|
|
60
|
+
|
|
61
|
+
return wrapper
|
|
62
|
+
|
|
63
|
+
env: dict[str, Callable] = {} # type: ignore[type-arg]
|
|
64
|
+
for func_def in imports:
|
|
65
|
+
cb = callbacks.get(func_def["camel_name"]) or callbacks.get(func_def["name"])
|
|
66
|
+
env[func_def["wasm_key"]] = make_wrapper(func_def, cb)
|
|
67
|
+
return env
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_component_export_proxy(
|
|
71
|
+
raw_exports: dict[str, Any],
|
|
72
|
+
wit_exports: list[dict], # type: ignore[type-arg]
|
|
73
|
+
memory_ref: list[wasmtime.Memory | None],
|
|
74
|
+
store: wasmtime.Store,
|
|
75
|
+
cabi_realloc_ref: list[wasmtime.Func | None],
|
|
76
|
+
) -> dict[str, Callable]: # type: ignore[type-arg]
|
|
77
|
+
def make_proxy(fd: dict, raw_fn: wasmtime.Func) -> Callable: # type: ignore[type-arg]
|
|
78
|
+
# SPEC 3.0.0: a string/aggregate-returning export returns a single i32
|
|
79
|
+
# pointer to a callee-allocated [ptr, len] pair. After reading it, the
|
|
80
|
+
# host calls the paired cabi_post_<name>(retArea) export (if present) to
|
|
81
|
+
# let the callee free that memory. <name> is the camelCase export name.
|
|
82
|
+
cabi_post = raw_exports.get(f"cabi_post_{fd['camel_name']}")
|
|
83
|
+
if not isinstance(cabi_post, wasmtime.Func):
|
|
84
|
+
cabi_post = None
|
|
85
|
+
|
|
86
|
+
def call(*args: Any) -> Any:
|
|
87
|
+
memory = memory_ref[0]
|
|
88
|
+
realloc = cabi_realloc_ref[0]
|
|
89
|
+
wasm_args: list[Any] = []
|
|
90
|
+
for p, val in zip(fd["params"], args):
|
|
91
|
+
if p["type"] == "bool":
|
|
92
|
+
wasm_args.append(int(bool(val)))
|
|
93
|
+
elif p["type"] == "string" and memory is not None and realloc is not None:
|
|
94
|
+
ptr, length = _encode_string(memory, store, realloc, str(val))
|
|
95
|
+
wasm_args.extend([ptr, length])
|
|
96
|
+
else:
|
|
97
|
+
wasm_args.append(val)
|
|
98
|
+
result = raw_fn(store, *wasm_args)
|
|
99
|
+
rt = fd.get("return_type")
|
|
100
|
+
if rt == "bool":
|
|
101
|
+
return bool(result)
|
|
102
|
+
if rt == "string" and memory is not None:
|
|
103
|
+
# The export returns a single i32 pointing at the 8-byte
|
|
104
|
+
# [ptr, len] return area (callee-allocated).
|
|
105
|
+
ret_area = int(result)
|
|
106
|
+
str_ptr = _read_i32_le(memory, store, ret_area)
|
|
107
|
+
str_len = _read_i32_le(memory, store, ret_area + 4)
|
|
108
|
+
decoded = _decode_string(memory, store, str_ptr, str_len)
|
|
109
|
+
if cabi_post is not None:
|
|
110
|
+
cabi_post(store, ret_area)
|
|
111
|
+
return decoded
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
return call
|
|
115
|
+
|
|
116
|
+
proxy: dict[str, Callable] = {} # type: ignore[type-arg]
|
|
117
|
+
for func_def in wit_exports:
|
|
118
|
+
# wasmtk emits camelCase export names (e.g. WIT `str-len` -> `strLen`),
|
|
119
|
+
# matching the reference loader's lookup by camelCase tsName. Fall back
|
|
120
|
+
# to the underscore form for tolerance.
|
|
121
|
+
raw_fn = raw_exports.get(func_def["camel_name"]) or raw_exports.get(func_def["wasm_key"])
|
|
122
|
+
if raw_fn is not None and isinstance(raw_fn, wasmtime.Func):
|
|
123
|
+
proxy[func_def["camel_name"]] = make_proxy(func_def, raw_fn)
|
|
124
|
+
return proxy
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import wasmtime
|
|
9
|
+
from wasmtime import Engine, FuncType, Linker, Module, Store
|
|
10
|
+
|
|
11
|
+
from ._abi import build_component_export_proxy, build_component_import_env
|
|
12
|
+
from ._wit_parser import parse_wit
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_version_suffix(path: str) -> tuple[str, int | None]:
|
|
16
|
+
m = re.search(r"@(\d+)$", path)
|
|
17
|
+
return (path[: m.start()], int(m.group(1))) if m else (path, None)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _collect_raw_exports(
|
|
21
|
+
module: Module, instance: wasmtime.Instance, store: Store
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
exports_obj = instance.exports(store)
|
|
24
|
+
result: dict[str, Any] = {}
|
|
25
|
+
for exp in module.exports:
|
|
26
|
+
val = exports_obj[exp.name]
|
|
27
|
+
if val is not None:
|
|
28
|
+
result[exp.name] = val
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _make_noop(n_results: int) -> Callable[..., Any]:
|
|
33
|
+
if n_results == 0:
|
|
34
|
+
return lambda *_: None
|
|
35
|
+
if n_results == 1:
|
|
36
|
+
return lambda *_: 0
|
|
37
|
+
return lambda *_, n=n_results: [0] * n
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _assert_version(raw: dict[str, Any], store: Store, expected: int, path: str) -> None:
|
|
41
|
+
g = raw.get("version")
|
|
42
|
+
if not isinstance(g, wasmtime.Global):
|
|
43
|
+
raise RuntimeError(f"{path!r}: no exported 'version' global")
|
|
44
|
+
actual = g.value(store)
|
|
45
|
+
if actual != expected:
|
|
46
|
+
raise RuntimeError(f"{path!r}: version mismatch — expected {expected}, got {actual}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _wrap_for_user(raw: dict[str, Any], store: Store) -> dict[str, Any]:
|
|
50
|
+
result: dict[str, Any] = {}
|
|
51
|
+
for name, val in raw.items():
|
|
52
|
+
if isinstance(val, wasmtime.Func):
|
|
53
|
+
fn = val
|
|
54
|
+
result[name] = lambda *args, f=fn: f(store, *args)
|
|
55
|
+
elif isinstance(val, wasmtime.Global):
|
|
56
|
+
result[name] = val.value(store)
|
|
57
|
+
else:
|
|
58
|
+
result[name] = val
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def wasm_import(
|
|
63
|
+
wasm_path: str | Path,
|
|
64
|
+
host_callbacks: dict[str, Callable[..., Any]] | None = None,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
path_str = str(wasm_path)
|
|
67
|
+
cleaned, requested_version = _parse_version_suffix(path_str)
|
|
68
|
+
path = Path(cleaned)
|
|
69
|
+
|
|
70
|
+
wit: dict[str, Any] | None = None
|
|
71
|
+
wit_path = path.with_suffix(".wit")
|
|
72
|
+
if wit_path.exists():
|
|
73
|
+
try:
|
|
74
|
+
wit = parse_wit(wit_path.read_text(encoding="utf-8"))
|
|
75
|
+
except Exception:
|
|
76
|
+
wit = None
|
|
77
|
+
|
|
78
|
+
wasm_bytes = await asyncio.to_thread(path.read_bytes)
|
|
79
|
+
engine = Engine()
|
|
80
|
+
module = Module(engine, wasm_bytes)
|
|
81
|
+
store = Store(engine)
|
|
82
|
+
|
|
83
|
+
memory_ref: list[wasmtime.Memory | None] = [None]
|
|
84
|
+
cabi_realloc_ref: list[wasmtime.Func | None] = [None]
|
|
85
|
+
|
|
86
|
+
import_env: dict[str, Callable[..., Any]] = {}
|
|
87
|
+
if wit:
|
|
88
|
+
import_env = build_component_import_env(wit["imports"], host_callbacks, memory_ref, store)
|
|
89
|
+
|
|
90
|
+
linker = Linker(engine)
|
|
91
|
+
for imp in module.imports:
|
|
92
|
+
if not isinstance(imp.type, FuncType):
|
|
93
|
+
continue
|
|
94
|
+
ft: FuncType = imp.type
|
|
95
|
+
imp_name = imp.name
|
|
96
|
+
if imp_name in import_env:
|
|
97
|
+
linker.define_func(imp.module, imp_name, ft, import_env[imp_name])
|
|
98
|
+
elif host_callbacks and imp_name in host_callbacks:
|
|
99
|
+
cb = host_callbacks[imp_name]
|
|
100
|
+
linker.define_func(imp.module, imp_name, ft, lambda *a, fn=cb: fn(*a))
|
|
101
|
+
else:
|
|
102
|
+
linker.define_func(imp.module, imp_name, ft, _make_noop(len(ft.results)))
|
|
103
|
+
|
|
104
|
+
instance = linker.instantiate(store, module)
|
|
105
|
+
raw = _collect_raw_exports(module, instance, store)
|
|
106
|
+
|
|
107
|
+
mem = raw.get("memory")
|
|
108
|
+
if isinstance(mem, wasmtime.Memory):
|
|
109
|
+
memory_ref[0] = mem
|
|
110
|
+
realloc = raw.get("cabi_realloc")
|
|
111
|
+
if isinstance(realloc, wasmtime.Func):
|
|
112
|
+
cabi_realloc_ref[0] = realloc
|
|
113
|
+
|
|
114
|
+
if requested_version is not None:
|
|
115
|
+
_assert_version(raw, store, requested_version, cleaned)
|
|
116
|
+
|
|
117
|
+
if wit:
|
|
118
|
+
proxy = build_component_export_proxy(
|
|
119
|
+
raw, wit["exports"], memory_ref, store, cabi_realloc_ref
|
|
120
|
+
)
|
|
121
|
+
for name, val in raw.items():
|
|
122
|
+
if name not in proxy:
|
|
123
|
+
if isinstance(val, wasmtime.Func):
|
|
124
|
+
fn = val
|
|
125
|
+
proxy[name] = lambda *args, f=fn: f(store, *args)
|
|
126
|
+
elif isinstance(val, wasmtime.Global):
|
|
127
|
+
proxy[name] = val.value(store)
|
|
128
|
+
return proxy
|
|
129
|
+
|
|
130
|
+
return _wrap_for_user(raw, store)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def create_singleton(
|
|
134
|
+
wasm_path: str | Path,
|
|
135
|
+
host_callbacks: dict[str, Callable[..., Any]] | None = None,
|
|
136
|
+
) -> Callable[[], Any]:
|
|
137
|
+
_cache: dict[str, Any] | None = None
|
|
138
|
+
_lock: asyncio.Lock | None = None
|
|
139
|
+
|
|
140
|
+
async def get() -> dict[str, Any]:
|
|
141
|
+
nonlocal _cache, _lock
|
|
142
|
+
if _lock is None:
|
|
143
|
+
_lock = asyncio.Lock()
|
|
144
|
+
async with _lock:
|
|
145
|
+
if _cache is None:
|
|
146
|
+
_cache = await wasm_import(wasm_path, host_callbacks)
|
|
147
|
+
return _cache
|
|
148
|
+
|
|
149
|
+
return get
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class InstancePool:
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
wasm_path: str | Path,
|
|
156
|
+
host_callbacks: dict[str, Callable[..., Any]] | None = None,
|
|
157
|
+
size: int = 2,
|
|
158
|
+
) -> None:
|
|
159
|
+
self._path = wasm_path
|
|
160
|
+
self._callbacks = host_callbacks
|
|
161
|
+
self._size = size
|
|
162
|
+
self._pool: asyncio.Queue[dict[str, Any]] | None = None
|
|
163
|
+
self._lock: asyncio.Lock | None = None
|
|
164
|
+
|
|
165
|
+
async def _ensure_ready(self) -> None:
|
|
166
|
+
if self._lock is None:
|
|
167
|
+
self._lock = asyncio.Lock()
|
|
168
|
+
async with self._lock:
|
|
169
|
+
if self._pool is not None:
|
|
170
|
+
return
|
|
171
|
+
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
172
|
+
for inst in await asyncio.gather(
|
|
173
|
+
*[wasm_import(self._path, self._callbacks) for _ in range(self._size)]
|
|
174
|
+
):
|
|
175
|
+
q.put_nowait(inst)
|
|
176
|
+
self._pool = q
|
|
177
|
+
|
|
178
|
+
async def acquire(self) -> dict[str, Any]:
|
|
179
|
+
await self._ensure_ready()
|
|
180
|
+
assert self._pool is not None
|
|
181
|
+
return await self._pool.get()
|
|
182
|
+
|
|
183
|
+
async def release(self, instance: dict[str, Any]) -> None:
|
|
184
|
+
if self._pool is not None:
|
|
185
|
+
await self._pool.put(instance)
|
|
186
|
+
|
|
187
|
+
async def run(self, fn: Callable[[dict[str, Any]], Any]) -> Any:
|
|
188
|
+
instance = await self.acquire()
|
|
189
|
+
try:
|
|
190
|
+
result = fn(instance)
|
|
191
|
+
if asyncio.iscoroutine(result):
|
|
192
|
+
return await result
|
|
193
|
+
return result
|
|
194
|
+
finally:
|
|
195
|
+
await self.release(instance)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_PRIMITIVE_TYPES = frozenset({"s32", "s64", "f32", "f64", "bool", "string"})
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def kebab_to_camel(name: str) -> str:
|
|
9
|
+
parts = name.split("-")
|
|
10
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def kebab_to_wasm_key(name: str) -> str:
|
|
14
|
+
return name.replace("-", "_")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_wit_type(raw: str) -> str:
|
|
18
|
+
t = raw.strip()
|
|
19
|
+
return t if t in _PRIMITIVE_TYPES else "s32"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_wit_params(raw: str) -> list[dict[str, str]]:
|
|
23
|
+
if not raw.strip():
|
|
24
|
+
return []
|
|
25
|
+
result: list[dict[str, str]] = []
|
|
26
|
+
for part in raw.split(","):
|
|
27
|
+
part = part.strip()
|
|
28
|
+
if ":" not in part:
|
|
29
|
+
continue
|
|
30
|
+
name_raw, type_raw = part.split(":", 1)
|
|
31
|
+
result.append({"name": kebab_to_camel(name_raw.strip()), "type": parse_wit_type(type_raw)})
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_wit_funcs(body: str, keyword: str) -> list[dict]: # type: ignore[type-arg]
|
|
36
|
+
pattern = re.compile(
|
|
37
|
+
rf"\b{keyword}\s+([\w-]+)\s*:\s*func\s*\(([^)]*)\)\s*(?:->\s*([\w-]+))?",
|
|
38
|
+
re.MULTILINE,
|
|
39
|
+
)
|
|
40
|
+
funcs: list[dict] = [] # type: ignore[type-arg]
|
|
41
|
+
for m in pattern.finditer(body):
|
|
42
|
+
name = m.group(1)
|
|
43
|
+
funcs.append(
|
|
44
|
+
{
|
|
45
|
+
"name": name,
|
|
46
|
+
"camel_name": kebab_to_camel(name),
|
|
47
|
+
"wasm_key": kebab_to_wasm_key(name),
|
|
48
|
+
"params": parse_wit_params(m.group(2) or ""),
|
|
49
|
+
"return_type": parse_wit_type(m.group(3)) if m.group(3) else None,
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
return funcs
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_wit(src: str) -> dict: # type: ignore[type-arg]
|
|
56
|
+
pkg_match = re.search(r"package\s+([\w:@.\-/]+)\s*;", src)
|
|
57
|
+
world_match = re.search(r"world\s+([\w-]+)\s*\{([^}]*)\}", src, re.DOTALL)
|
|
58
|
+
body = world_match.group(2) if world_match else src
|
|
59
|
+
return {
|
|
60
|
+
"package": pkg_match.group(1) if pkg_match else "",
|
|
61
|
+
"world": world_match.group(1) if world_match else "",
|
|
62
|
+
"imports": parse_wit_funcs(body, "import"),
|
|
63
|
+
"exports": parse_wit_funcs(body, "export"),
|
|
64
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: universal-wasm-loader
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Universal WASM loader for Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/jrmarcum/universalWasmLoader-py
|
|
6
|
+
Project-URL: Repository, https://github.com/jrmarcum/universalWasmLoader-py
|
|
7
|
+
Author-email: Jon Marcum <jrmarcum.se@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: canonical-abi,loader,wasm,webassembly,wit
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: wasmtime>=26.0.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# universalWasmLoader-py
|
|
29
|
+
|
|
30
|
+
Universal WASM loader for Python — the Python port of
|
|
31
|
+
[universalWasmLoader-js](https://github.com/jrmarcum/universalWasmLoader-js). Loads and
|
|
32
|
+
instantiates WebAssembly modules and, when a companion `.wit` file is present, applies the
|
|
33
|
+
Canonical ABI so you call WIT exports with idiomatic Python values (`int`/`float`, `bool`,
|
|
34
|
+
`str`) instead of raw i32/pointer pairs.
|
|
35
|
+
|
|
36
|
+
- **PyPI package:** `universal-wasm-loader`
|
|
37
|
+
- **WASM runtime:** [`wasmtime`](https://pypi.org/project/wasmtime/) ≥ 26.0.0
|
|
38
|
+
- **License:** MIT
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pip install universal-wasm-loader
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from universal_wasm_loader import wasm_import, create_singleton, InstancePool
|
|
50
|
+
|
|
51
|
+
exports = await wasm_import("./math.wasm")
|
|
52
|
+
result = exports["calculate"](1, 2) # 3
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
See [`CLAUDE.md`](CLAUDE.md) / [`cmem/overview.md`](cmem/overview.md) for the full API surface.
|
|
56
|
+
|
|
57
|
+
## Develop
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
pixi run test # pytest tests/ -v
|
|
61
|
+
pixi run lint # ruff check src/ tests/
|
|
62
|
+
pixi run typecheck # mypy src/
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Release / publish to PyPI
|
|
66
|
+
|
|
67
|
+
The version lives in **`pyproject.toml`** `[project] version` — the single source of truth
|
|
68
|
+
(`pixi.toml` mirrors it). Three scripts key off it, each shipped in **two equivalent forms**: a
|
|
69
|
+
Bash `.sh` and a cross-platform Nushell `.nu` (Nushell ≥ 0.113; runs on Windows/macOS/Linux
|
|
70
|
+
without Git Bash). Use whichever your machine has — they have identical flags and behavior.
|
|
71
|
+
|
|
72
|
+
**Tagging/releasing and publishing are deliberately decoupled:** `release` tags + pushes +
|
|
73
|
+
creates the GitHub Release; `publish` is a separate, auth-gated, confirmation-guarded step that
|
|
74
|
+
actually uploads to PyPI (irreversible). All three support `--dry-run`.
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
# 1) Bump the version (commits the isolated change)
|
|
78
|
+
bash scripts/bump-version.sh patch # 1.0.0 -> 1.0.1 (or minor | major | X.Y.Z)
|
|
79
|
+
nu scripts/bump-version.nu patch # …equivalent
|
|
80
|
+
|
|
81
|
+
# 2) Tag v<version>, push branch + tag, create the GitHub Release (NO PyPI upload)
|
|
82
|
+
bash scripts/release.sh # --no-release / --no-build / --remote / --dry-run
|
|
83
|
+
nu scripts/release.nu
|
|
84
|
+
|
|
85
|
+
# 3) Publish to PyPI — separate, deliberate, irreversible
|
|
86
|
+
bash scripts/publish.sh # prompts; --yes / --dry-run / --allow-dirty / --skip-tag-check
|
|
87
|
+
nu scripts/publish.nu
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`publish` reads the version from `pyproject.toml`, requires the matching `v<version>` tag to
|
|
91
|
+
exist locally **and** on the remote, refuses to run without a PyPI token, and is idempotent (if
|
|
92
|
+
that version is already on PyPI it reports success and uploads nothing).
|
|
93
|
+
|
|
94
|
+
### Required setup for `publish` (one-time)
|
|
95
|
+
|
|
96
|
+
1. **PyPI project owned/registered.** The name `universal-wasm-loader` must exist on PyPI under
|
|
97
|
+
an account you control (do the first upload, or pre-register the name).
|
|
98
|
+
2. **A PyPI API token in the environment** (project-scoped is best):
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
export TWINE_USERNAME=__token__
|
|
102
|
+
export TWINE_PASSWORD=pypi-XXXXXXXX # your token; or a [pypi] entry in ~/.pypirc
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Publishing from CI instead (optional, token-free)
|
|
106
|
+
|
|
107
|
+
`.github/workflows/publish.yml` can also build + upload, but it is **manual-only**
|
|
108
|
+
(`workflow_dispatch`) — it does **not** fire on a tag push, so it never double-publishes
|
|
109
|
+
alongside `release`. Run it from the Actions tab against the release tag.
|
|
110
|
+
|
|
111
|
+
It authenticates with **PyPI Trusted Publishing over short-lived OIDC — no long-lived PyPI token
|
|
112
|
+
is stored in repo secrets**, so there's nothing to leak. One-time setup: register a Trusted
|
|
113
|
+
Publisher on PyPI (a *pending publisher* before the project's first upload) with Owner
|
|
114
|
+
`jrmarcum`, Repository `universalWasmLoader-py`, Workflow `publish.yml`.
|
|
115
|
+
|
|
116
|
+
> **`run:`-only workflow.** This org's GitHub Actions policy permits only `jrmarcum`-owned
|
|
117
|
+
> actions; any third-party `uses:` step (including `actions/checkout` and
|
|
118
|
+
> `pypa/gh-action-pypi-publish`) causes a `startup_failure`. The workflow therefore uses plain
|
|
119
|
+
> `run:` steps throughout — including the OIDC exchange (`ACTIONS_ID_TOKEN_REQUEST_*` →
|
|
120
|
+
> `https://pypi.org/_/oidc/mint-token` → `twine upload` with the ephemeral token), done by hand
|
|
121
|
+
> rather than via the pypa publish action.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
universal_wasm_loader/__init__.py,sha256=q5AOLOEAXByTBokNJ7hyg_yGCCOMiWsyyAeyf6dX_zo,128
|
|
2
|
+
universal_wasm_loader/_abi.py,sha256=SBT1Y4gSY7PIirh9QuzBytvImo6WrI72xPoT6pvyk-M,5135
|
|
3
|
+
universal_wasm_loader/_loader.py,sha256=XRaKB-ekLnm0DsdICr8undl-NVQl5IQo8jtnFFpGZKw,6296
|
|
4
|
+
universal_wasm_loader/_wit_parser.py,sha256=MWRk9945y2hlhVvTY4pwSBumFJggIotFoZ5Ddv1L02Y,2056
|
|
5
|
+
universal_wasm_loader-1.0.0.dist-info/METADATA,sha256=gnS-XcB9xyhnr-VlKk58U6L-VNqtxwQOmcCeWVFlIWU,5235
|
|
6
|
+
universal_wasm_loader-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
universal_wasm_loader-1.0.0.dist-info/licenses/LICENSE,sha256=17R5bZy3t1u5tfIaqG1sbGOZHj8paOibgt3_eaoeSHY,1067
|
|
8
|
+
universal_wasm_loader-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jon Marcum
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|