universal-wasm-loader 1.0.0__tar.gz

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.

@@ -0,0 +1,219 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+
207
+ # Ruff stuff:
208
+ .ruff_cache/
209
+
210
+ # PyPI configuration file
211
+ .pypirc
212
+
213
+ # Marimo
214
+ marimo/_static/
215
+ marimo/_lsp/
216
+ __marimo__/
217
+
218
+ # Streamlit
219
+ .streamlit/secrets.toml
@@ -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.
@@ -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,94 @@
1
+ # universalWasmLoader-py
2
+
3
+ Universal WASM loader for Python — the Python port of
4
+ [universalWasmLoader-js](https://github.com/jrmarcum/universalWasmLoader-js). Loads and
5
+ instantiates WebAssembly modules and, when a companion `.wit` file is present, applies the
6
+ Canonical ABI so you call WIT exports with idiomatic Python values (`int`/`float`, `bool`,
7
+ `str`) instead of raw i32/pointer pairs.
8
+
9
+ - **PyPI package:** `universal-wasm-loader`
10
+ - **WASM runtime:** [`wasmtime`](https://pypi.org/project/wasmtime/) ≥ 26.0.0
11
+ - **License:** MIT
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pip install universal-wasm-loader
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ from universal_wasm_loader import wasm_import, create_singleton, InstancePool
23
+
24
+ exports = await wasm_import("./math.wasm")
25
+ result = exports["calculate"](1, 2) # 3
26
+ ```
27
+
28
+ See [`CLAUDE.md`](CLAUDE.md) / [`cmem/overview.md`](cmem/overview.md) for the full API surface.
29
+
30
+ ## Develop
31
+
32
+ ```sh
33
+ pixi run test # pytest tests/ -v
34
+ pixi run lint # ruff check src/ tests/
35
+ pixi run typecheck # mypy src/
36
+ ```
37
+
38
+ ## Release / publish to PyPI
39
+
40
+ The version lives in **`pyproject.toml`** `[project] version` — the single source of truth
41
+ (`pixi.toml` mirrors it). Three scripts key off it, each shipped in **two equivalent forms**: a
42
+ Bash `.sh` and a cross-platform Nushell `.nu` (Nushell ≥ 0.113; runs on Windows/macOS/Linux
43
+ without Git Bash). Use whichever your machine has — they have identical flags and behavior.
44
+
45
+ **Tagging/releasing and publishing are deliberately decoupled:** `release` tags + pushes +
46
+ creates the GitHub Release; `publish` is a separate, auth-gated, confirmation-guarded step that
47
+ actually uploads to PyPI (irreversible). All three support `--dry-run`.
48
+
49
+ ```sh
50
+ # 1) Bump the version (commits the isolated change)
51
+ bash scripts/bump-version.sh patch # 1.0.0 -> 1.0.1 (or minor | major | X.Y.Z)
52
+ nu scripts/bump-version.nu patch # …equivalent
53
+
54
+ # 2) Tag v<version>, push branch + tag, create the GitHub Release (NO PyPI upload)
55
+ bash scripts/release.sh # --no-release / --no-build / --remote / --dry-run
56
+ nu scripts/release.nu
57
+
58
+ # 3) Publish to PyPI — separate, deliberate, irreversible
59
+ bash scripts/publish.sh # prompts; --yes / --dry-run / --allow-dirty / --skip-tag-check
60
+ nu scripts/publish.nu
61
+ ```
62
+
63
+ `publish` reads the version from `pyproject.toml`, requires the matching `v<version>` tag to
64
+ exist locally **and** on the remote, refuses to run without a PyPI token, and is idempotent (if
65
+ that version is already on PyPI it reports success and uploads nothing).
66
+
67
+ ### Required setup for `publish` (one-time)
68
+
69
+ 1. **PyPI project owned/registered.** The name `universal-wasm-loader` must exist on PyPI under
70
+ an account you control (do the first upload, or pre-register the name).
71
+ 2. **A PyPI API token in the environment** (project-scoped is best):
72
+
73
+ ```sh
74
+ export TWINE_USERNAME=__token__
75
+ export TWINE_PASSWORD=pypi-XXXXXXXX # your token; or a [pypi] entry in ~/.pypirc
76
+ ```
77
+
78
+ ### Publishing from CI instead (optional, token-free)
79
+
80
+ `.github/workflows/publish.yml` can also build + upload, but it is **manual-only**
81
+ (`workflow_dispatch`) — it does **not** fire on a tag push, so it never double-publishes
82
+ alongside `release`. Run it from the Actions tab against the release tag.
83
+
84
+ It authenticates with **PyPI Trusted Publishing over short-lived OIDC — no long-lived PyPI token
85
+ is stored in repo secrets**, so there's nothing to leak. One-time setup: register a Trusted
86
+ Publisher on PyPI (a *pending publisher* before the project's first upload) with Owner
87
+ `jrmarcum`, Repository `universalWasmLoader-py`, Workflow `publish.yml`.
88
+
89
+ > **`run:`-only workflow.** This org's GitHub Actions policy permits only `jrmarcum`-owned
90
+ > actions; any third-party `uses:` step (including `actions/checkout` and
91
+ > `pypa/gh-action-pypi-publish`) causes a `startup_failure`. The workflow therefore uses plain
92
+ > `run:` steps throughout — including the OIDC exchange (`ACTIONS_ID_TOKEN_REQUEST_*` →
93
+ > `https://pypi.org/_/oidc/mint-token` → `twine upload` with the ephemeral token), done by hand
94
+ > rather than via the pypa publish action.
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "universal-wasm-loader"
7
+ version = "1.0.0"
8
+ description = "Universal WASM loader for Python"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Jon Marcum", email = "jrmarcum.se@gmail.com" }]
12
+ requires-python = ">=3.11"
13
+ keywords = ["wasm", "webassembly", "loader", "wit", "canonical-abi"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Software Development :: Libraries",
23
+ ]
24
+ dependencies = ["wasmtime>=26.0.0"]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=8.0.0",
29
+ "pytest-asyncio>=0.23.0",
30
+ "ruff>=0.9.0",
31
+ "mypy>=1.10.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/jrmarcum/universalWasmLoader-py"
36
+ Repository = "https://github.com/jrmarcum/universalWasmLoader-py"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/universal_wasm_loader"]
40
+
41
+ # Ship the MIT LICENSE (and the user-facing docs) inside the sdist so the license
42
+ # travels with the published package. Hatchling already auto-includes LICENSE* in
43
+ # the wheel's .dist-info; this makes the sdist contents explicit.
44
+ [tool.hatch.build.targets.sdist]
45
+ include = [
46
+ "src/universal_wasm_loader",
47
+ "tests",
48
+ "LICENSE",
49
+ "README.md",
50
+ "pyproject.toml",
51
+ ]
52
+
53
+ [tool.ruff]
54
+ src = ["src"]
55
+ line-length = 99
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "UP"]
59
+
60
+ [tool.mypy]
61
+ strict = true
62
+ mypy_path = "src"
63
+
64
+ [tool.pytest.ini_options]
65
+ asyncio_mode = "auto"
66
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ from ._loader import InstancePool, create_singleton, wasm_import
2
+
3
+ __all__ = ["wasm_import", "create_singleton", "InstancePool"]
@@ -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
+ }
File without changes
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ import wasmtime
7
+
8
+ FIXTURES = Path(__file__).parent / "fixtures"
9
+
10
+
11
+ def _compile_wat(name: str, tmp_dir: Path) -> Path:
12
+ wat_text = (FIXTURES / f"{name}.wat").read_text(encoding="utf-8")
13
+ engine = wasmtime.Engine()
14
+ # Module() accepts WAT text directly; serialize() gives canonical binary
15
+ module = wasmtime.Module(engine, wat_text)
16
+ wasm_bytes = module.serialize()
17
+ wasm_path = tmp_dir / f"{name}.wasm"
18
+ wasm_path.write_bytes(wasm_bytes)
19
+ return wasm_path
20
+
21
+
22
+ def _add_wit(name: str, wasm_path: Path) -> Path:
23
+ wit_text = (FIXTURES / f"{name}.wit").read_text(encoding="utf-8")
24
+ (wasm_path.parent / f"{name}.wit").write_text(wit_text, encoding="utf-8")
25
+ return wasm_path
26
+
27
+
28
+ def _stage_prebuilt(name: str, tmp_dir: Path) -> Path:
29
+ """Copy a prebuilt .wasm + companion .wit (produced by wasmtk) into a temp
30
+ dir so the loader's sibling-.wit auto-detection finds them."""
31
+ wasm_path = tmp_dir / f"{name}.wasm"
32
+ wasm_path.write_bytes((FIXTURES / f"{name}.wasm").read_bytes())
33
+ (tmp_dir / f"{name}.wit").write_text(
34
+ (FIXTURES / f"{name}.wit").read_text(encoding="utf-8"), encoding="utf-8"
35
+ )
36
+ return wasm_path
37
+
38
+
39
+ # ── math fixtures ──────────────────────────────────────────────────────────────
40
+
41
+ @pytest.fixture(scope="session")
42
+ def math_wasm_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
43
+ return _compile_wat("math", tmp_path_factory.mktemp("math_no_wit"))
44
+
45
+
46
+ @pytest.fixture(scope="session")
47
+ def math_wasm_with_wit(tmp_path_factory: pytest.TempPathFactory) -> Path:
48
+ wasm = _compile_wat("math", tmp_path_factory.mktemp("math_with_wit"))
49
+ return _add_wit("math", wasm)
50
+
51
+
52
+ # ── bool fixtures ──────────────────────────────────────────────────────────────
53
+
54
+ @pytest.fixture(scope="session")
55
+ def bool_wasm_with_wit(tmp_path_factory: pytest.TempPathFactory) -> Path:
56
+ wasm = _compile_wat("bool_mod", tmp_path_factory.mktemp("bool_with_wit"))
57
+ return _add_wit("bool_mod", wasm)
58
+
59
+
60
+ # ── imports fixtures ───────────────────────────────────────────────────────────
61
+
62
+ @pytest.fixture(scope="session")
63
+ def imports_wasm_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
64
+ return _compile_wat("imports_mod", tmp_path_factory.mktemp("imports_no_wit"))
65
+
66
+
67
+ @pytest.fixture(scope="session")
68
+ def imports_wasm_with_wit(tmp_path_factory: pytest.TempPathFactory) -> Path:
69
+ wasm = _compile_wat("imports_mod", tmp_path_factory.mktemp("imports_with_wit"))
70
+ return _add_wit("imports_mod", wasm)
71
+
72
+
73
+ # ── strings fixture (prebuilt .wasm from wasmtk, SPEC 3.0.0 string ABI) ─────────
74
+
75
+ @pytest.fixture(scope="session")
76
+ def strings_wasm_with_wit(tmp_path_factory: pytest.TempPathFactory) -> Path:
77
+ return _stage_prebuilt("strings_50", tmp_path_factory.mktemp("strings_with_wit"))
@@ -0,0 +1,11 @@
1
+ (module
2
+ (func (export "is_positive") (param i32) (result i32)
3
+ local.get 0
4
+ i32.const 0
5
+ i32.gt_s)
6
+ (func (export "is_even") (param i32) (result i32)
7
+ local.get 0
8
+ i32.const 2
9
+ i32.rem_s
10
+ i32.eqz)
11
+ )
@@ -0,0 +1,6 @@
1
+ package example:bool@1.0.0;
2
+
3
+ world bool-world {
4
+ export is-positive: func(n: s32) -> bool;
5
+ export is-even: func(n: s32) -> bool;
6
+ }
@@ -0,0 +1,12 @@
1
+ (module
2
+ (import "env" "env_mul" (func $env_mul (param f32 f32) (result f32)))
3
+ (import "env" "env_add" (func $env_add (param i32 i32) (result i32)))
4
+ (func (export "scale") (param f32 f32) (result f32)
5
+ local.get 0
6
+ local.get 1
7
+ call $env_mul)
8
+ (func (export "combine") (param i32 i32) (result i32)
9
+ local.get 0
10
+ local.get 1
11
+ call $env_add)
12
+ )
@@ -0,0 +1,8 @@
1
+ package example:imports@1.0.0;
2
+
3
+ world imports-world {
4
+ import env-mul: func(a: f32, b: f32) -> f32;
5
+ import env-add: func(a: s32, b: s32) -> s32;
6
+ export scale: func(x: f32, y: f32) -> f32;
7
+ export combine: func(x: s32, y: s32) -> s32;
8
+ }
@@ -0,0 +1,7 @@
1
+ (module
2
+ (global (export "version") i32 (i32.const 1))
3
+ (func (export "calculate") (param i32 i32) (result i32)
4
+ local.get 0
5
+ local.get 1
6
+ i32.add)
7
+ )
@@ -0,0 +1,5 @@
1
+ package example:math@1.0.0;
2
+
3
+ world math-world {
4
+ export calculate: func(a: s32, b: s32) -> s32;
5
+ }
@@ -0,0 +1,8 @@
1
+ // Generated by wasmtk wasic — do not edit manually
2
+ package local:strings-50;
3
+
4
+ world strings-50 {
5
+ export greet: func(name: string) -> string;
6
+ export shout: func(msg: string) -> string;
7
+ export str-len: func(s: string) -> s32;
8
+ }
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from universal_wasm_loader import InstancePool, create_singleton, wasm_import
9
+
10
+
11
+ # ── raw load (no WIT) ─────────────────────────────────────────────────────────
12
+
13
+ async def test_raw_load_calculate(math_wasm_path: Path) -> None:
14
+ exports = await wasm_import(math_wasm_path)
15
+ assert callable(exports["calculate"])
16
+ assert exports["calculate"](1, 2) == 3
17
+ assert exports["calculate"](10, -3) == 7
18
+
19
+
20
+ async def test_raw_load_version_global(math_wasm_path: Path) -> None:
21
+ exports = await wasm_import(math_wasm_path)
22
+ assert exports["version"] == 1
23
+
24
+
25
+ # ── version pinning ───────────────────────────────────────────────────────────
26
+
27
+ async def test_version_pinning_ok(math_wasm_path: Path) -> None:
28
+ exports = await wasm_import(f"{math_wasm_path}@1")
29
+ assert exports["calculate"](3, 4) == 7
30
+
31
+
32
+ async def test_version_pinning_mismatch(math_wasm_path: Path) -> None:
33
+ with pytest.raises(RuntimeError, match="version mismatch"):
34
+ await wasm_import(f"{math_wasm_path}@2")
35
+
36
+
37
+ # ── WIT export proxy ─────────────────────────────────────────────────────────
38
+
39
+ async def test_wit_export_proxy(math_wasm_with_wit: Path) -> None:
40
+ exports = await wasm_import(math_wasm_with_wit)
41
+ assert callable(exports["calculate"])
42
+ assert exports["calculate"](10, 5) == 15
43
+
44
+
45
+ # ── bool ABI ─────────────────────────────────────────────────────────────────
46
+
47
+ async def test_bool_export(bool_wasm_with_wit: Path) -> None:
48
+ exports = await wasm_import(bool_wasm_with_wit)
49
+ assert exports["isPositive"](5) is True
50
+ assert exports["isPositive"](-1) is False
51
+ assert exports["isPositive"](0) is False
52
+
53
+
54
+ async def test_bool_is_even(bool_wasm_with_wit: Path) -> None:
55
+ exports = await wasm_import(bool_wasm_with_wit)
56
+ assert exports["isEven"](4) is True
57
+ assert exports["isEven"](3) is False
58
+
59
+
60
+ # ── string ABI (SPEC 3.0.0: callee-allocated return + cabi_post) ──────────────
61
+
62
+ async def test_string_greet(strings_wasm_with_wit: Path) -> None:
63
+ exports = await wasm_import(strings_wasm_with_wit)
64
+ assert exports["greet"]("World") == "Hello, World!"
65
+
66
+
67
+ async def test_string_shout(strings_wasm_with_wit: Path) -> None:
68
+ exports = await wasm_import(strings_wasm_with_wit)
69
+ assert exports["shout"]("hi") == "hihi"
70
+
71
+
72
+ async def test_string_len(strings_wasm_with_wit: Path) -> None:
73
+ exports = await wasm_import(strings_wasm_with_wit)
74
+ assert exports["strLen"]("hello") == 5
75
+
76
+
77
+ # ── host import callbacks ─────────────────────────────────────────────────────
78
+
79
+ async def test_host_callbacks_raw(imports_wasm_path: Path) -> None:
80
+ callbacks = {
81
+ "env_mul": lambda a, b: a * b,
82
+ "env_add": lambda a, b: a + b,
83
+ }
84
+ exports = await wasm_import(imports_wasm_path, callbacks)
85
+ assert exports["scale"](3.0, 4.0) == pytest.approx(12.0)
86
+ assert exports["combine"](10, 7) == 17
87
+
88
+
89
+ async def test_host_callbacks_wit(imports_wasm_with_wit: Path) -> None:
90
+ callbacks = {
91
+ "envMul": lambda a, b: a * b,
92
+ "envAdd": lambda a, b: a + b,
93
+ }
94
+ exports = await wasm_import(imports_wasm_with_wit, callbacks)
95
+ assert exports["scale"](3.0, 4.0) == pytest.approx(12.0)
96
+ assert exports["combine"](10, 7) == 17
97
+
98
+
99
+ # ── create_singleton ──────────────────────────────────────────────────────────
100
+
101
+ async def test_create_singleton_same_instance(math_wasm_path: Path) -> None:
102
+ get_math = create_singleton(math_wasm_path)
103
+ a = await get_math()
104
+ b = await get_math()
105
+ assert a is b
106
+
107
+
108
+ async def test_create_singleton_works(math_wasm_path: Path) -> None:
109
+ get_math = create_singleton(math_wasm_path)
110
+ math = await get_math()
111
+ assert math["calculate"](2, 3) == 5
112
+
113
+
114
+ # ── InstancePool ──────────────────────────────────────────────────────────────
115
+
116
+ async def test_instance_pool_run(math_wasm_path: Path) -> None:
117
+ pool = InstancePool(math_wasm_path, size=2)
118
+ r1, r2 = await asyncio.gather(
119
+ pool.run(lambda m: m["calculate"](1, 1)),
120
+ pool.run(lambda m: m["calculate"](2, 2)),
121
+ )
122
+ assert r1 == 2
123
+ assert r2 == 4
124
+
125
+
126
+ async def test_instance_pool_acquire_release(math_wasm_path: Path) -> None:
127
+ pool = InstancePool(math_wasm_path, size=1)
128
+ inst = await pool.acquire()
129
+ assert inst["calculate"](5, 5) == 10
130
+ await pool.release(inst)
131
+ inst2 = await pool.acquire()
132
+ assert inst2 is inst
@@ -0,0 +1,91 @@
1
+ from universal_wasm_loader._wit_parser import (
2
+ kebab_to_camel,
3
+ kebab_to_wasm_key,
4
+ parse_wit,
5
+ parse_wit_funcs,
6
+ parse_wit_params,
7
+ parse_wit_type,
8
+ )
9
+
10
+
11
+ def test_kebab_to_camel() -> None:
12
+ assert kebab_to_camel("my-func") == "myFunc"
13
+ assert kebab_to_camel("calculate") == "calculate"
14
+ assert kebab_to_camel("is-positive") == "isPositive"
15
+ assert kebab_to_camel("env-mul") == "envMul"
16
+
17
+
18
+ def test_kebab_to_wasm_key() -> None:
19
+ assert kebab_to_wasm_key("my-func") == "my_func"
20
+ assert kebab_to_wasm_key("env-add") == "env_add"
21
+ assert kebab_to_wasm_key("calculate") == "calculate"
22
+
23
+
24
+ def test_parse_wit_type() -> None:
25
+ for t in ("s32", "s64", "f32", "f64", "bool", "string"):
26
+ assert parse_wit_type(t) == t
27
+ assert parse_wit_type("unknown") == "s32"
28
+ assert parse_wit_type(" f64 ") == "f64"
29
+
30
+
31
+ def test_parse_wit_params() -> None:
32
+ result = parse_wit_params("a: s32, b: s32")
33
+ assert result == [{"name": "a", "type": "s32"}, {"name": "b", "type": "s32"}]
34
+
35
+ result = parse_wit_params("x: bool")
36
+ assert result == [{"name": "x", "type": "bool"}]
37
+
38
+ result = parse_wit_params("my-name: string")
39
+ assert result == [{"name": "myName", "type": "string"}]
40
+
41
+ assert parse_wit_params("") == []
42
+
43
+
44
+ def test_parse_wit_funcs_exports() -> None:
45
+ body = """
46
+ export calculate: func(a: s32, b: s32) -> s32;
47
+ export is-positive: func(n: s32) -> bool;
48
+ import callback: func(x: s32);
49
+ """
50
+ exports = parse_wit_funcs(body, "export")
51
+ assert len(exports) == 2
52
+ assert exports[0]["name"] == "calculate"
53
+ assert exports[0]["camel_name"] == "calculate"
54
+ assert exports[0]["wasm_key"] == "calculate"
55
+ assert exports[0]["return_type"] == "s32"
56
+ assert exports[1]["name"] == "is-positive"
57
+ assert exports[1]["camel_name"] == "isPositive"
58
+ assert exports[1]["wasm_key"] == "is_positive"
59
+ assert exports[1]["return_type"] == "bool"
60
+
61
+
62
+ def test_parse_wit_funcs_imports() -> None:
63
+ body = "import env-mul: func(a: f32, b: f32) -> f32;"
64
+ imports = parse_wit_funcs(body, "import")
65
+ assert len(imports) == 1
66
+ assert imports[0]["name"] == "env-mul"
67
+ assert imports[0]["camel_name"] == "envMul"
68
+ assert imports[0]["wasm_key"] == "env_mul"
69
+ assert imports[0]["return_type"] == "f32"
70
+
71
+
72
+ def test_parse_wit_funcs_no_return() -> None:
73
+ body = "import log: func(msg: string);"
74
+ imports = parse_wit_funcs(body, "import")
75
+ assert imports[0]["return_type"] is None
76
+
77
+
78
+ def test_parse_wit_full() -> None:
79
+ src = """
80
+ package example:math@1.0.0;
81
+
82
+ world math-world {
83
+ export calculate: func(a: s32, b: s32) -> s32;
84
+ }
85
+ """
86
+ result = parse_wit(src)
87
+ assert result["package"] == "example:math@1.0.0"
88
+ assert result["world"] == "math-world"
89
+ assert len(result["exports"]) == 1
90
+ assert result["exports"][0]["camel_name"] == "calculate"
91
+ assert len(result["imports"]) == 0