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 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__}")
@@ -0,0 +1,2 @@
1
+ # Auto-generated by packages/python/scripts/stage.py.
2
+ # This package only ships a single .wasm asset.
Binary file
formulon/py.typed ADDED
File without changes