formulon 0.9.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.
- formulon/__init__.py +84 -0
- formulon/__init__.pyi +79 -0
- formulon/_c.py +335 -0
- formulon/_wasm/__init__.py +2 -0
- formulon/_wasm/formulon_capi.wasm +0 -0
- formulon/py.typed +0 -0
- formulon/workbook.py +566 -0
- formulon-0.9.0.dist-info/METADATA +150 -0
- formulon-0.9.0.dist-info/RECORD +13 -0
- formulon-0.9.0.dist-info/WHEEL +5 -0
- formulon-0.9.0.dist-info/licenses/LICENSE +201 -0
- formulon-0.9.0.dist-info/licenses/NOTICE +10 -0
- formulon-0.9.0.dist-info/top_level.txt +1 -0
formulon/__init__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Copyright 2026 libraz. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Formulon -- Excel 365 calculation engine, Python binding.
|
|
3
|
+
|
|
4
|
+
Public API:
|
|
5
|
+
* :func:`eval_formula` -- one-shot formula evaluation.
|
|
6
|
+
* :func:`library_version` -- query the underlying ``libformulon`` build.
|
|
7
|
+
* :class:`Workbook` -- full workbook lifecycle.
|
|
8
|
+
* :class:`Value`, :class:`ValueKind` -- cell value POD.
|
|
9
|
+
* :class:`FormulonError` -- host-side failure exception.
|
|
10
|
+
|
|
11
|
+
Cell-level Excel errors (``#DIV/0!`` and friends) surface as ``Value``
|
|
12
|
+
instances with ``kind == ValueKind.ERROR``; only host-side failures
|
|
13
|
+
(NULL handle, parse errors during ``Workbook.load``, OOM) raise
|
|
14
|
+
:class:`FormulonError`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ._c import LIB, ValueKind, decode_cstr
|
|
20
|
+
from .workbook import (
|
|
21
|
+
Cell,
|
|
22
|
+
DefinedName,
|
|
23
|
+
FormulonError,
|
|
24
|
+
PassthroughPart,
|
|
25
|
+
Table,
|
|
26
|
+
Value,
|
|
27
|
+
Workbook,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# IMPORTANT: keep this in sync with the [project] version in
|
|
31
|
+
# packages/python/pyproject.toml. There is no portable way to read the
|
|
32
|
+
# wheel metadata at runtime without `importlib.metadata`, which is fine
|
|
33
|
+
# for installed packages but breaks for source-tree imports.
|
|
34
|
+
__version__ = "0.9.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Cell",
|
|
38
|
+
"DefinedName",
|
|
39
|
+
"FormulonError",
|
|
40
|
+
"PassthroughPart",
|
|
41
|
+
"Table",
|
|
42
|
+
"Value",
|
|
43
|
+
"ValueKind",
|
|
44
|
+
"Workbook",
|
|
45
|
+
"__version__",
|
|
46
|
+
"eval_formula",
|
|
47
|
+
"library_version",
|
|
48
|
+
"version_string",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def library_version() -> str:
|
|
53
|
+
"""Return the version string baked into the loaded WASM module.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The result of ``fm_version_string()``. Always non-empty.
|
|
57
|
+
"""
|
|
58
|
+
return decode_cstr(LIB.fm_version_string())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Backward-compat alias mirroring the npm binding's ``versionString`` name.
|
|
62
|
+
version_string = library_version
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def eval_formula(formula: str) -> Value:
|
|
66
|
+
"""Evaluate a single formula against a fresh, default workbook.
|
|
67
|
+
|
|
68
|
+
The formula is written to ``Sheet1!A1``, the workbook is recalculated,
|
|
69
|
+
and the resulting cell value is returned.
|
|
70
|
+
|
|
71
|
+
Cell-level Excel errors (``#DIV/0!``, ``#VALUE!``, etc.) surface as a
|
|
72
|
+
:class:`Value` with ``kind == ValueKind.ERROR``; only host-side
|
|
73
|
+
failures (parser crash, OOM, NULL handle) raise :class:`FormulonError`.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
formula: the formula text, with or without a leading ``=``.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The :class:`Value` cached at ``Sheet1!A1`` after recalc.
|
|
80
|
+
"""
|
|
81
|
+
with Workbook.create_default() as wb:
|
|
82
|
+
wb.set_formula(0, 0, 0, formula)
|
|
83
|
+
wb.recalc()
|
|
84
|
+
return wb.get_value(0, 0, 0)
|
formulon/__init__.pyi
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Copyright 2026 libraz. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
# Hand-rolled type stubs for the Formulon public surface.
|
|
3
|
+
# IDEs and type-checkers consume this rather than the runtime module.
|
|
4
|
+
|
|
5
|
+
from typing import Iterator, NamedTuple, Optional, Union
|
|
6
|
+
|
|
7
|
+
from ._c import ValueKind as ValueKind
|
|
8
|
+
|
|
9
|
+
__version__: str
|
|
10
|
+
|
|
11
|
+
class Value:
|
|
12
|
+
kind: ValueKind
|
|
13
|
+
number: Optional[float]
|
|
14
|
+
boolean: Optional[bool]
|
|
15
|
+
text: Optional[str]
|
|
16
|
+
error_code: Optional[int]
|
|
17
|
+
def to_python(self) -> Union[None, float, bool, str, "Value"]: ...
|
|
18
|
+
|
|
19
|
+
class Cell(NamedTuple):
|
|
20
|
+
row: int
|
|
21
|
+
col: int
|
|
22
|
+
formula: Optional[str]
|
|
23
|
+
value: Value
|
|
24
|
+
|
|
25
|
+
class DefinedName(NamedTuple):
|
|
26
|
+
name: str
|
|
27
|
+
formula: str
|
|
28
|
+
|
|
29
|
+
class Table(NamedTuple):
|
|
30
|
+
name: str
|
|
31
|
+
display_name: str
|
|
32
|
+
ref: str
|
|
33
|
+
sheet_index: int
|
|
34
|
+
|
|
35
|
+
class PassthroughPart(NamedTuple):
|
|
36
|
+
path: str
|
|
37
|
+
|
|
38
|
+
class FormulonError(Exception):
|
|
39
|
+
status: int
|
|
40
|
+
status_name: str
|
|
41
|
+
message: str
|
|
42
|
+
context: str
|
|
43
|
+
def __init__(self, status: int, *, op: str = ...) -> None: ...
|
|
44
|
+
|
|
45
|
+
class Workbook:
|
|
46
|
+
def __init__(self) -> None: ...
|
|
47
|
+
@classmethod
|
|
48
|
+
def create_default(cls) -> "Workbook": ...
|
|
49
|
+
@classmethod
|
|
50
|
+
def create_empty(cls) -> "Workbook": ...
|
|
51
|
+
@classmethod
|
|
52
|
+
def load(cls, data: Union[bytes, bytearray, memoryview]) -> "Workbook": ...
|
|
53
|
+
def __enter__(self) -> "Workbook": ...
|
|
54
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None: ...
|
|
55
|
+
def close(self) -> None: ...
|
|
56
|
+
@property
|
|
57
|
+
def is_valid(self) -> bool: ...
|
|
58
|
+
def sheet_count(self) -> int: ...
|
|
59
|
+
def sheet_name(self, index: int) -> str: ...
|
|
60
|
+
def add_sheet(self, name: str) -> None: ...
|
|
61
|
+
def set_number(self, sheet: int, row: int, col: int, value: float) -> None: ...
|
|
62
|
+
def set_bool(self, sheet: int, row: int, col: int, value: bool) -> None: ...
|
|
63
|
+
def set_text(self, sheet: int, row: int, col: int, value: str) -> None: ...
|
|
64
|
+
def set_blank(self, sheet: int, row: int, col: int) -> None: ...
|
|
65
|
+
def set_formula(self, sheet: int, row: int, col: int, formula: str) -> None: ...
|
|
66
|
+
def get_value(self, sheet: int, row: int, col: int) -> Value: ...
|
|
67
|
+
def recalc(self) -> None: ...
|
|
68
|
+
def set_iterative(
|
|
69
|
+
self, enabled: bool, max_iterations: int, max_change: float
|
|
70
|
+
) -> None: ...
|
|
71
|
+
def save(self) -> bytes: ...
|
|
72
|
+
def iter_cells(self, sheet: int) -> Iterator[Cell]: ...
|
|
73
|
+
def iter_defined_names(self) -> Iterator[DefinedName]: ...
|
|
74
|
+
def iter_tables(self) -> Iterator[Table]: ...
|
|
75
|
+
def iter_passthrough(self) -> Iterator[PassthroughPart]: ...
|
|
76
|
+
|
|
77
|
+
def library_version() -> str: ...
|
|
78
|
+
def version_string() -> str: ...
|
|
79
|
+
def eval_formula(formula: str) -> Value: ...
|
formulon/_c.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# Copyright 2026 libraz. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Low-level WASM binding for the Formulon C ABI.
|
|
3
|
+
|
|
4
|
+
Public users should import the ``Workbook`` / ``Value`` / ``FormulonError``
|
|
5
|
+
symbols re-exported by the top-level :mod:`formulon` package instead.
|
|
6
|
+
|
|
7
|
+
Architecture
|
|
8
|
+
------------
|
|
9
|
+
|
|
10
|
+
The binding loads ``formulon_capi.wasm`` (a standalone reactor-style
|
|
11
|
+
WebAssembly module that exports the ``fm_*`` C ABI from
|
|
12
|
+
``src/c_api/formulon_c.h``) via the ``wasmtime`` runtime. A single
|
|
13
|
+
module instance is created lazily at first use and shared across every
|
|
14
|
+
:class:`formulon.Workbook` in the process; each ``Workbook`` instance
|
|
15
|
+
owns an opaque ``fm_workbook_t*`` (i32 in WASM) handle.
|
|
16
|
+
|
|
17
|
+
Pointers are 32-bit offsets into the WASM linear memory. Strings cross
|
|
18
|
+
the boundary as ``malloc``-allocated UTF-8 NUL-terminated buffers in
|
|
19
|
+
that memory; the caller is responsible for ``free``-ing them after the
|
|
20
|
+
WASM function returns. Borrowed return pointers (e.g. text from
|
|
21
|
+
``fm_workbook_get_value``) are read directly from WASM memory and
|
|
22
|
+
decoded eagerly into Python ``str`` so the result outlives any
|
|
23
|
+
subsequent WASM mutation.
|
|
24
|
+
|
|
25
|
+
The ``fm_value_t`` POD has the wasm32 layout::
|
|
26
|
+
|
|
27
|
+
offset 0: int32 kind
|
|
28
|
+
offset 4: int32 (padding for 8-byte alignment of the union)
|
|
29
|
+
offset 8: union { double number ; int32 boolean ; int32 error_code ; ptr text }
|
|
30
|
+
total: 16 bytes
|
|
31
|
+
|
|
32
|
+
The union member is selected by ``kind``; reading any other member is
|
|
33
|
+
undefined per the C ABI contract.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import struct
|
|
39
|
+
import threading
|
|
40
|
+
from enum import IntEnum
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Optional, Tuple
|
|
43
|
+
|
|
44
|
+
import wasmtime
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"LIB",
|
|
48
|
+
"ValueKind",
|
|
49
|
+
"decode_cstr",
|
|
50
|
+
"fm_value_t_size",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# fm_value_t layout: int32 kind + 4 pad + 8 union = 16 bytes.
|
|
54
|
+
fm_value_t_size = 16
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# ValueKind enum (matches fm_value_kind_t)
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ValueKind(IntEnum):
|
|
63
|
+
"""Mirror of ``fm_value_kind_t`` in ``formulon_c.h``."""
|
|
64
|
+
|
|
65
|
+
BLANK = 0
|
|
66
|
+
NUMBER = 1
|
|
67
|
+
BOOL = 2
|
|
68
|
+
TEXT = 3
|
|
69
|
+
ERROR = 4
|
|
70
|
+
ARRAY = 5
|
|
71
|
+
REF = 6
|
|
72
|
+
LAMBDA = 7
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# WASM module location
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _locate_wasm() -> Path:
|
|
81
|
+
"""Return the on-disk path to ``formulon_capi.wasm``.
|
|
82
|
+
|
|
83
|
+
Search order:
|
|
84
|
+
1. ``packages/python/formulon/_wasm/formulon_capi.wasm`` -- the
|
|
85
|
+
package-data location populated by ``make python-package``
|
|
86
|
+
and shipped inside the wheel.
|
|
87
|
+
2. ``$FORMULON_WASM_PATH`` -- explicit override for development.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
FileNotFoundError: when the WASM is not on disk in either
|
|
91
|
+
location. The error message names both candidates.
|
|
92
|
+
"""
|
|
93
|
+
here = Path(__file__).resolve().parent
|
|
94
|
+
bundled = here / "_wasm" / "formulon_capi.wasm"
|
|
95
|
+
if bundled.is_file():
|
|
96
|
+
return bundled
|
|
97
|
+
|
|
98
|
+
import os
|
|
99
|
+
|
|
100
|
+
override = os.environ.get("FORMULON_WASM_PATH")
|
|
101
|
+
if override:
|
|
102
|
+
p = Path(override)
|
|
103
|
+
if p.is_file():
|
|
104
|
+
return p
|
|
105
|
+
|
|
106
|
+
raise FileNotFoundError(
|
|
107
|
+
"formulon: failed to locate formulon_capi.wasm. "
|
|
108
|
+
f"Tried: {bundled}. "
|
|
109
|
+
"Run `make python-package` to stage the artifact, or install a "
|
|
110
|
+
"wheel that ships it under formulon/_wasm/."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# WASM module / store wrapper
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _WasmInstance:
|
|
120
|
+
"""Owns a single ``wasmtime`` engine, store, and module instance.
|
|
121
|
+
|
|
122
|
+
The instance is created lazily on first attribute access; subsequent
|
|
123
|
+
Workbook creations reuse it. This trades cold-start latency for
|
|
124
|
+
repeat-call speed and keeps the engine cache around for the life of
|
|
125
|
+
the Python process.
|
|
126
|
+
|
|
127
|
+
The store is `not` thread-safe (per the wasmtime-py docs); a single
|
|
128
|
+
process-wide ``_call_lock`` serialises every WASM invocation. The
|
|
129
|
+
underlying calculation engine is already safe for one outstanding
|
|
130
|
+
recalc per ``Workbook`` handle, so the additional lock only prevents
|
|
131
|
+
cross-handle reentry on the wasmtime store itself.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self) -> None:
|
|
135
|
+
self._engine: Optional[wasmtime.Engine] = None
|
|
136
|
+
self._store: Optional[wasmtime.Store] = None
|
|
137
|
+
self._instance: Optional[wasmtime.Instance] = None
|
|
138
|
+
self._memory: Optional[wasmtime.Memory] = None
|
|
139
|
+
self._exports: dict = {}
|
|
140
|
+
self._init_lock = threading.Lock()
|
|
141
|
+
self._call_lock = threading.RLock()
|
|
142
|
+
|
|
143
|
+
def _ensure(self) -> None:
|
|
144
|
+
if self._instance is not None:
|
|
145
|
+
return
|
|
146
|
+
with self._init_lock:
|
|
147
|
+
if self._instance is not None:
|
|
148
|
+
return
|
|
149
|
+
engine = wasmtime.Engine()
|
|
150
|
+
store = wasmtime.Store(engine)
|
|
151
|
+
|
|
152
|
+
# WASI: provide a minimal config. The engine never reads
|
|
153
|
+
# files or stdin; stdout/stderr inherit so any diagnostic
|
|
154
|
+
# libc calls (e.g. trap reasons) surface to the host.
|
|
155
|
+
wasi = wasmtime.WasiConfig()
|
|
156
|
+
wasi.inherit_stdout()
|
|
157
|
+
wasi.inherit_stderr()
|
|
158
|
+
store.set_wasi(wasi)
|
|
159
|
+
|
|
160
|
+
wasm_path = _locate_wasm()
|
|
161
|
+
module = wasmtime.Module.from_file(engine, str(wasm_path))
|
|
162
|
+
|
|
163
|
+
linker = wasmtime.Linker(engine)
|
|
164
|
+
linker.define_wasi()
|
|
165
|
+
|
|
166
|
+
# Stub for env.emscripten_notify_memory_growth. emcc emits
|
|
167
|
+
# this import even under STANDALONE_WASM; it is called on
|
|
168
|
+
# memory.grow but the host has nothing useful to do with it.
|
|
169
|
+
ty = wasmtime.FuncType([wasmtime.ValType.i32()], [])
|
|
170
|
+
linker.define(
|
|
171
|
+
store,
|
|
172
|
+
"env",
|
|
173
|
+
"emscripten_notify_memory_growth",
|
|
174
|
+
wasmtime.Func(store, ty, lambda _i: None),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
instance = linker.instantiate(store, module)
|
|
178
|
+
exports = dict(instance.exports(store).items())
|
|
179
|
+
|
|
180
|
+
# Reactor init must run before any export is callable.
|
|
181
|
+
init_fn = exports.get("_initialize")
|
|
182
|
+
if init_fn is not None:
|
|
183
|
+
init_fn(store)
|
|
184
|
+
|
|
185
|
+
self._engine = engine
|
|
186
|
+
self._store = store
|
|
187
|
+
self._instance = instance
|
|
188
|
+
self._memory = exports["memory"]
|
|
189
|
+
self._exports = exports
|
|
190
|
+
|
|
191
|
+
# ----- raw export accessor --------------------------------------------
|
|
192
|
+
def __getattr__(self, name: str):
|
|
193
|
+
self._ensure()
|
|
194
|
+
fn = self._exports.get(name)
|
|
195
|
+
if fn is None:
|
|
196
|
+
raise AttributeError(f"WASM export '{name}' not found")
|
|
197
|
+
store = self._store
|
|
198
|
+
lock = self._call_lock
|
|
199
|
+
|
|
200
|
+
# Wrap so the caller can use a ctypes-like call syntax.
|
|
201
|
+
def _wrapped(*args):
|
|
202
|
+
with lock:
|
|
203
|
+
return fn(store, *args)
|
|
204
|
+
|
|
205
|
+
return _wrapped
|
|
206
|
+
|
|
207
|
+
# ----- memory primitives ----------------------------------------------
|
|
208
|
+
@property
|
|
209
|
+
def store(self) -> wasmtime.Store:
|
|
210
|
+
self._ensure()
|
|
211
|
+
assert self._store is not None
|
|
212
|
+
return self._store
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def memory(self) -> wasmtime.Memory:
|
|
216
|
+
self._ensure()
|
|
217
|
+
assert self._memory is not None
|
|
218
|
+
return self._memory
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def call_lock(self) -> threading.RLock:
|
|
222
|
+
return self._call_lock
|
|
223
|
+
|
|
224
|
+
def read_bytes(self, ptr: int, length: int) -> bytes:
|
|
225
|
+
"""Copy ``length`` bytes from WASM memory starting at ``ptr``."""
|
|
226
|
+
self._ensure()
|
|
227
|
+
assert self._memory is not None
|
|
228
|
+
return bytes(self._memory.read(self._store, ptr, ptr + length))
|
|
229
|
+
|
|
230
|
+
def write_bytes(self, ptr: int, data: bytes) -> None:
|
|
231
|
+
"""Write ``data`` into WASM memory starting at ``ptr``."""
|
|
232
|
+
self._ensure()
|
|
233
|
+
assert self._memory is not None
|
|
234
|
+
self._memory.write(self._store, data, ptr)
|
|
235
|
+
|
|
236
|
+
def read_u32(self, ptr: int) -> int:
|
|
237
|
+
return struct.unpack("<I", self.read_bytes(ptr, 4))[0]
|
|
238
|
+
|
|
239
|
+
def read_i32(self, ptr: int) -> int:
|
|
240
|
+
return struct.unpack("<i", self.read_bytes(ptr, 4))[0]
|
|
241
|
+
|
|
242
|
+
def read_f64(self, ptr: int) -> float:
|
|
243
|
+
return struct.unpack("<d", self.read_bytes(ptr, 8))[0]
|
|
244
|
+
|
|
245
|
+
def read_cstr(self, ptr: int) -> str:
|
|
246
|
+
"""Decode a NUL-terminated UTF-8 C string from ``ptr``.
|
|
247
|
+
|
|
248
|
+
Returns the empty string when ``ptr`` is 0.
|
|
249
|
+
"""
|
|
250
|
+
if ptr == 0:
|
|
251
|
+
return ""
|
|
252
|
+
self._ensure()
|
|
253
|
+
assert self._memory is not None
|
|
254
|
+
# Stream a chunk at a time to avoid copying the whole memory.
|
|
255
|
+
chunks: list[bytes] = []
|
|
256
|
+
offset = ptr
|
|
257
|
+
chunk_size = 256
|
|
258
|
+
mem_len = self._memory.data_len(self._store)
|
|
259
|
+
while offset < mem_len:
|
|
260
|
+
end = min(offset + chunk_size, mem_len)
|
|
261
|
+
buf = bytes(self._memory.read(self._store, offset, end))
|
|
262
|
+
nul = buf.find(b"\x00")
|
|
263
|
+
if nul >= 0:
|
|
264
|
+
chunks.append(buf[:nul])
|
|
265
|
+
break
|
|
266
|
+
chunks.append(buf)
|
|
267
|
+
offset = end
|
|
268
|
+
return b"".join(chunks).decode("utf-8", errors="replace")
|
|
269
|
+
|
|
270
|
+
def alloc(self, size: int) -> int:
|
|
271
|
+
"""Allocate ``size`` bytes in WASM memory; return the pointer.
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
MemoryError: when the WASM-side allocator returns NULL.
|
|
275
|
+
"""
|
|
276
|
+
if size <= 0:
|
|
277
|
+
return 0
|
|
278
|
+
self._ensure()
|
|
279
|
+
with self._call_lock:
|
|
280
|
+
ptr = self._exports["malloc"](self._store, size)
|
|
281
|
+
if ptr == 0:
|
|
282
|
+
raise MemoryError(f"formulon: WASM malloc({size}) returned NULL")
|
|
283
|
+
return ptr
|
|
284
|
+
|
|
285
|
+
def free(self, ptr: int) -> None:
|
|
286
|
+
if ptr == 0:
|
|
287
|
+
return
|
|
288
|
+
self._ensure()
|
|
289
|
+
with self._call_lock:
|
|
290
|
+
self._exports["free"](self._store, ptr)
|
|
291
|
+
|
|
292
|
+
def alloc_utf8(self, s: str) -> Tuple[int, int]:
|
|
293
|
+
"""Encode ``s`` as UTF-8 and copy it into WASM memory.
|
|
294
|
+
|
|
295
|
+
Returns ``(ptr, length_with_nul)``. The caller MUST free ``ptr``
|
|
296
|
+
with :meth:`free` once the call that consumed it returns.
|
|
297
|
+
"""
|
|
298
|
+
if not isinstance(s, str):
|
|
299
|
+
raise TypeError(f"expected str, got {type(s).__name__}")
|
|
300
|
+
buf = s.encode("utf-8") + b"\x00"
|
|
301
|
+
ptr = self.alloc(len(buf))
|
|
302
|
+
self.write_bytes(ptr, buf)
|
|
303
|
+
return ptr, len(buf)
|
|
304
|
+
|
|
305
|
+
def alloc_bytes(self, data: bytes) -> int:
|
|
306
|
+
"""Copy ``data`` into WASM memory; return the pointer."""
|
|
307
|
+
if len(data) == 0:
|
|
308
|
+
return 0
|
|
309
|
+
ptr = self.alloc(len(data))
|
|
310
|
+
self.write_bytes(ptr, data)
|
|
311
|
+
return ptr
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
# Module-level singleton + thin compatibility shim
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
LIB = _WasmInstance()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def decode_cstr(ptr_or_bytes) -> str:
|
|
323
|
+
"""Backwards-compat shim for :class:`formulon.workbook.FormulonError`.
|
|
324
|
+
|
|
325
|
+
Accepts either an int WASM pointer (decoded via :class:`LIB`) or a
|
|
326
|
+
bytes object (legacy ctypes path). Returns the empty string when
|
|
327
|
+
the input is ``None``, ``0``, or empty.
|
|
328
|
+
"""
|
|
329
|
+
if ptr_or_bytes is None:
|
|
330
|
+
return ""
|
|
331
|
+
if isinstance(ptr_or_bytes, bytes):
|
|
332
|
+
return ptr_or_bytes.decode("utf-8", errors="replace")
|
|
333
|
+
if isinstance(ptr_or_bytes, int):
|
|
334
|
+
return LIB.read_cstr(ptr_or_bytes)
|
|
335
|
+
raise TypeError(f"decode_cstr: unexpected type {type(ptr_or_bytes).__name__}")
|
|
Binary file
|
formulon/py.typed
ADDED
|
File without changes
|