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.
- universal_wasm_loader-1.0.0/.gitignore +219 -0
- universal_wasm_loader-1.0.0/LICENSE +21 -0
- universal_wasm_loader-1.0.0/PKG-INFO +121 -0
- universal_wasm_loader-1.0.0/README.md +94 -0
- universal_wasm_loader-1.0.0/pyproject.toml +66 -0
- universal_wasm_loader-1.0.0/src/universal_wasm_loader/__init__.py +3 -0
- universal_wasm_loader-1.0.0/src/universal_wasm_loader/_abi.py +124 -0
- universal_wasm_loader-1.0.0/src/universal_wasm_loader/_loader.py +195 -0
- universal_wasm_loader-1.0.0/src/universal_wasm_loader/_wit_parser.py +64 -0
- universal_wasm_loader-1.0.0/tests/__init__.py +0 -0
- universal_wasm_loader-1.0.0/tests/conftest.py +77 -0
- universal_wasm_loader-1.0.0/tests/fixtures/bool_mod.wat +11 -0
- universal_wasm_loader-1.0.0/tests/fixtures/bool_mod.wit +6 -0
- universal_wasm_loader-1.0.0/tests/fixtures/imports_mod.wat +12 -0
- universal_wasm_loader-1.0.0/tests/fixtures/imports_mod.wit +8 -0
- universal_wasm_loader-1.0.0/tests/fixtures/math.wat +7 -0
- universal_wasm_loader-1.0.0/tests/fixtures/math.wit +5 -0
- universal_wasm_loader-1.0.0/tests/fixtures/strings_50.wasm +0 -0
- universal_wasm_loader-1.0.0/tests/fixtures/strings_50.wit +8 -0
- universal_wasm_loader-1.0.0/tests/test_loader.py +132 -0
- universal_wasm_loader-1.0.0/tests/test_wit_parser.py +91 -0
|
@@ -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,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,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
|
+
)
|
|
Binary file
|
|
@@ -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
|