universal-wasm-loader 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of universal-wasm-loader might be problematic. Click here for more details.

@@ -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
+ }
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: universal-wasm-loader
3
+ Version: 1.0.0
4
+ Summary: Universal WASM loader for Python
5
+ Project-URL: Homepage, https://github.com/jrmarcum/universalWasmLoader-py
6
+ Project-URL: Repository, https://github.com/jrmarcum/universalWasmLoader-py
7
+ Author-email: Jon Marcum <jrmarcum.se@gmail.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: canonical-abi,loader,wasm,webassembly,wit
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: wasmtime>=26.0.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: mypy>=1.10.0; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # universalWasmLoader-py
29
+
30
+ Universal WASM loader for Python — the Python port of
31
+ [universalWasmLoader-js](https://github.com/jrmarcum/universalWasmLoader-js). Loads and
32
+ instantiates WebAssembly modules and, when a companion `.wit` file is present, applies the
33
+ Canonical ABI so you call WIT exports with idiomatic Python values (`int`/`float`, `bool`,
34
+ `str`) instead of raw i32/pointer pairs.
35
+
36
+ - **PyPI package:** `universal-wasm-loader`
37
+ - **WASM runtime:** [`wasmtime`](https://pypi.org/project/wasmtime/) ≥ 26.0.0
38
+ - **License:** MIT
39
+
40
+ ## Install
41
+
42
+ ```sh
43
+ pip install universal-wasm-loader
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from universal_wasm_loader import wasm_import, create_singleton, InstancePool
50
+
51
+ exports = await wasm_import("./math.wasm")
52
+ result = exports["calculate"](1, 2) # 3
53
+ ```
54
+
55
+ See [`CLAUDE.md`](CLAUDE.md) / [`cmem/overview.md`](cmem/overview.md) for the full API surface.
56
+
57
+ ## Develop
58
+
59
+ ```sh
60
+ pixi run test # pytest tests/ -v
61
+ pixi run lint # ruff check src/ tests/
62
+ pixi run typecheck # mypy src/
63
+ ```
64
+
65
+ ## Release / publish to PyPI
66
+
67
+ The version lives in **`pyproject.toml`** `[project] version` — the single source of truth
68
+ (`pixi.toml` mirrors it). Three scripts key off it, each shipped in **two equivalent forms**: a
69
+ Bash `.sh` and a cross-platform Nushell `.nu` (Nushell ≥ 0.113; runs on Windows/macOS/Linux
70
+ without Git Bash). Use whichever your machine has — they have identical flags and behavior.
71
+
72
+ **Tagging/releasing and publishing are deliberately decoupled:** `release` tags + pushes +
73
+ creates the GitHub Release; `publish` is a separate, auth-gated, confirmation-guarded step that
74
+ actually uploads to PyPI (irreversible). All three support `--dry-run`.
75
+
76
+ ```sh
77
+ # 1) Bump the version (commits the isolated change)
78
+ bash scripts/bump-version.sh patch # 1.0.0 -> 1.0.1 (or minor | major | X.Y.Z)
79
+ nu scripts/bump-version.nu patch # …equivalent
80
+
81
+ # 2) Tag v<version>, push branch + tag, create the GitHub Release (NO PyPI upload)
82
+ bash scripts/release.sh # --no-release / --no-build / --remote / --dry-run
83
+ nu scripts/release.nu
84
+
85
+ # 3) Publish to PyPI — separate, deliberate, irreversible
86
+ bash scripts/publish.sh # prompts; --yes / --dry-run / --allow-dirty / --skip-tag-check
87
+ nu scripts/publish.nu
88
+ ```
89
+
90
+ `publish` reads the version from `pyproject.toml`, requires the matching `v<version>` tag to
91
+ exist locally **and** on the remote, refuses to run without a PyPI token, and is idempotent (if
92
+ that version is already on PyPI it reports success and uploads nothing).
93
+
94
+ ### Required setup for `publish` (one-time)
95
+
96
+ 1. **PyPI project owned/registered.** The name `universal-wasm-loader` must exist on PyPI under
97
+ an account you control (do the first upload, or pre-register the name).
98
+ 2. **A PyPI API token in the environment** (project-scoped is best):
99
+
100
+ ```sh
101
+ export TWINE_USERNAME=__token__
102
+ export TWINE_PASSWORD=pypi-XXXXXXXX # your token; or a [pypi] entry in ~/.pypirc
103
+ ```
104
+
105
+ ### Publishing from CI instead (optional, token-free)
106
+
107
+ `.github/workflows/publish.yml` can also build + upload, but it is **manual-only**
108
+ (`workflow_dispatch`) — it does **not** fire on a tag push, so it never double-publishes
109
+ alongside `release`. Run it from the Actions tab against the release tag.
110
+
111
+ It authenticates with **PyPI Trusted Publishing over short-lived OIDC — no long-lived PyPI token
112
+ is stored in repo secrets**, so there's nothing to leak. One-time setup: register a Trusted
113
+ Publisher on PyPI (a *pending publisher* before the project's first upload) with Owner
114
+ `jrmarcum`, Repository `universalWasmLoader-py`, Workflow `publish.yml`.
115
+
116
+ > **`run:`-only workflow.** This org's GitHub Actions policy permits only `jrmarcum`-owned
117
+ > actions; any third-party `uses:` step (including `actions/checkout` and
118
+ > `pypa/gh-action-pypi-publish`) causes a `startup_failure`. The workflow therefore uses plain
119
+ > `run:` steps throughout — including the OIDC exchange (`ACTIONS_ID_TOKEN_REQUEST_*` →
120
+ > `https://pypi.org/_/oidc/mint-token` → `twine upload` with the ephemeral token), done by hand
121
+ > rather than via the pypa publish action.
@@ -0,0 +1,8 @@
1
+ universal_wasm_loader/__init__.py,sha256=q5AOLOEAXByTBokNJ7hyg_yGCCOMiWsyyAeyf6dX_zo,128
2
+ universal_wasm_loader/_abi.py,sha256=SBT1Y4gSY7PIirh9QuzBytvImo6WrI72xPoT6pvyk-M,5135
3
+ universal_wasm_loader/_loader.py,sha256=XRaKB-ekLnm0DsdICr8undl-NVQl5IQo8jtnFFpGZKw,6296
4
+ universal_wasm_loader/_wit_parser.py,sha256=MWRk9945y2hlhVvTY4pwSBumFJggIotFoZ5Ddv1L02Y,2056
5
+ universal_wasm_loader-1.0.0.dist-info/METADATA,sha256=gnS-XcB9xyhnr-VlKk58U6L-VNqtxwQOmcCeWVFlIWU,5235
6
+ universal_wasm_loader-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ universal_wasm_loader-1.0.0.dist-info/licenses/LICENSE,sha256=17R5bZy3t1u5tfIaqG1sbGOZHj8paOibgt3_eaoeSHY,1067
8
+ universal_wasm_loader-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.