Metadata-Version: 2.4
Name: formualizer
Version: 0.11.0
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Text Processing :: General
Requires-Dist: pytest>=9.0.3 ; extra == 'dev'
Requires-Dist: pytest-cov>=4.0 ; extra == 'dev'
Requires-Dist: pytest-timeout>=2.3 ; extra == 'dev'
Requires-Dist: ruff>=0.1.0 ; extra == 'dev'
Requires-Dist: mypy>=1.0 ; extra == 'dev'
Requires-Dist: openpyxl ; extra == 'dev'
Provides-Extra: dev
License-File: LICENSE-MIT
License-File: LICENSE-APACHE
Summary: Embeddable spreadsheet engine — parse, evaluate & mutate Excel workbooks at native speed
Keywords: spreadsheet,excel,formula,evaluation,parser,arrow
Home-Page: https://github.com/PSU3D0/formualizer
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Bug Tracker, https://github.com/PSU3D0/formualizer/issues
Project-URL: Documentation, https://docs.rs/formualizer
Project-URL: Homepage, https://github.com/PSU3D0/formualizer
Project-URL: Repository, https://github.com/PSU3D0/formualizer

<h1 align="center">Formualizer</h1>

<p align="center">
  <img alt="Arrow Powered" src="https://img.shields.io/badge/Arrow-Powered-0A66C2?logo=apache&logoColor=white" />
  <a href="https://pypi.org/project/formualizer/"><img alt="PyPI" src="https://img.shields.io/pypi/v/formualizer.svg" /></a>
  <a href="https://github.com/PSU3D0/formualizer/blob/main/LICENSE-MIT"><img alt="License: MIT/Apache-2.0" src="https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg" /></a>
  <a href="https://www.formualizer.dev/docs/quickstarts/python-quickstart"><img alt="Documentation" src="https://img.shields.io/badge/docs-formualizer.dev-blue" /></a>
</p>

<p align="center">
  <img alt="Formualizer banner" src="https://raw.githubusercontent.com/psu3d0/formualizer/main/assets/formualizer-banner.png" />
</p>

**The fastest open-source spreadsheet engine. Native speed, from Python.**

Load Excel workbooks, change inputs, recalculate and read results—in-process, with no Excel installation or office-suite service. Formualizer runs the calculation in Rust and gives you a Python API. Built for financial models, data pipelines and AI agents.

- **No compromise on speed.** Copied formulas compute as families over Arrow columns; lookups reuse indexes; only affected cells recalculate. See the [benchmarks and methodology](https://github.com/psu3d0/formualizer#how-fast).
- **Excel-compatible.** 400+ functions, dynamic arrays, `LET`, `LAMBDA`, names and cross-sheet references. Unlike a file reader, Formualizer computes formulas rather than just returning cached values.
- **Built for agents.** Inspect dependencies, track edits with undo/redo, inject a clock and random seed, and expose typed inputs and outputs through SheetPort.
- **Portable.** Native wheels for Linux, macOS and Windows, plus a separate [Pyodide build](#using-in-pyodide-browser--webassembly). The same engine also ships for [Rust and JavaScript](https://github.com/psu3d0/formualizer#bindings).

Need broader workbook editing CLI or MCP tools? Use [agent-spreadsheet](https://github.com/PSU3D0/agent-spreadsheet), built on Formualizer.

## Installation

```bash
pip install formualizer
```

Prebuilt stable-ABI (`abi3`) wheels are published for Python 3.10 and newer on Linux (glibc and musl), macOS, and Windows. No Rust toolchain required.

## Command line

Native wheels include the `formualizer` command, which recalculates the cached formula values in an `.xlsx` after another tool has edited it:

```bash
uvx formualizer recalc book.xlsx                  # run without installing
formualizer recalc book.xlsx --check --json       # after pip install formualizer
python -m formualizer recalc book.xlsx --json     # same CLI through the module
```

The CLI updates formula caches in place (or writes `-o other.xlsx`), preserving formula text and the rest of the workbook. Exit codes: 0 done, 1 error, 2 refused (unsupported workbook, nothing written), 3 `--check` found stale caches, 64 usage error, 130 interrupted. See the [CLI reference](https://github.com/psu3d0/formualizer/blob/main/docs/cli.md). The CLI is not available in Pyodide; `recalculate_xlsx_bytes` is.

The same recalculation is available in-process:

```python
import formualizer as fz

result = fz.recalculate_xlsx_file("book.xlsx")  # in place
result = fz.recalculate_xlsx_file("book.xlsx", output="calculated.xlsx")
print(result["summary"]["status"], result["cache_cells_changed"])
```

A refusal raises `RuntimeError("Unsupported feature: ... in ...")` and writes nothing. The CLI's `--json` output carries the same refusal as structured `feature`/`context` fields, which is easier for agents to branch on.

## For agents: edit with openpyxl, then recalc

openpyxl writes formulas but does not calculate them, and saving drops the cached values that `data_only=True` readers (and pandas) see. Recalc after the last save:

```python
import json, subprocess, sys
from openpyxl import load_workbook

wb = load_workbook("book.xlsx")
wb.active["A1"] = 100  # edit inputs or formulas
wb.save("book.xlsx")

p = subprocess.run(
    [sys.executable, "-m", "formualizer", "recalc", "book.xlsx", "--json"],
    capture_output=True,
    text=True,
)
r = json.loads(p.stdout)
if p.returncode == 2:
    raise SystemExit(f"refused, do not retry: {r['refusal']}")
if p.returncode != 0:
    raise SystemExit(r["message"])
print(r["status"], r["error_cells"], r["errors"])  # inspect formula errors

# Read the result; do not save again, or the caches are lost.
print(load_workbook("book.xlsx", data_only=True).active["B1"].value)
```

If `errors` lists unexpected cells, fix inputs or formulas, save and recalc again. Never save the `data_only=True` view: it would replace formulas with values. See the [agent workflow guide](https://github.com/psu3d0/formualizer/blob/main/docs/agents.md) and [portable skill](https://github.com/psu3d0/formualizer/blob/main/skills/formualizer-recalc/SKILL.md).

## Documentation

Full documentation at **[formualizer.dev](https://www.formualizer.dev/docs)**:

- [Recalc CLI](https://www.formualizer.dev/docs/recalc-cli) — recalculate `.xlsx` caches after openpyxl edits
- [Python Quickstart](https://www.formualizer.dev/docs/quickstarts/python-quickstart)
- [Python API Reference](https://www.formualizer.dev/docs/reference/python-api-map)
- [Function Reference](https://www.formualizer.dev/docs/reference/functions) — 400+ built-in functions
- [SheetPort Guide](https://www.formualizer.dev/docs/sheetport) — spreadsheets as typed APIs
- [Workbook Edits and Batching](https://www.formualizer.dev/docs/guides/workbook-edits-and-batching)

## Quick start

Rows and columns are **1-based**: row 3, column 2 is B3.

### Create and calculate a model

```python
import formualizer as fz

wb = fz.Workbook()
s = wb.sheet("Sheet1")

s.set_value(1, 1, fz.LiteralValue.number(1000.0))  # A1: principal
s.set_value(2, 1, fz.LiteralValue.number(0.05))  # A2: annual rate
s.set_value(3, 1, fz.LiteralValue.number(12.0))  # A3: periods

s.set_formula(1, 2, "=PMT(A2/12, A3, -A1)")
print(wb.evaluate_cell("Sheet1", 1, 2))  # ~85.61
```

### Load an XLSX and evaluate

```python
import formualizer as fz

# Use a workbook with Assumptions and Summary sheets.
wb = fz.load_workbook("financial_model.xlsx", strategy="eager_all")
wb.set_value("Assumptions", 3, 2, 0.07)  # B3: change an input
wb.evaluate_all()  # calculate affected formulas
print(wb.get_value("Summary", 5, 2))  # B5: read an output

# Optional native read-only mapping. The underlying file must not be
# destructively modified or truncated while it is loading.
mapped = fz.load_workbook(
    "financial_model.xlsx",
    path_source=fz.XlsxPathSource.DIRECT_MMAP,
)
```

### Load and save XLSX bytes

```python
import formualizer as fz

payload = open("financial_model.xlsx", "rb").read()
wb = fz.load_workbook_bytes(payload)
print(wb.evaluate_cell("Summary", 1, 2))

out = wb.to_xlsx_bytes()
```

Native Python builds use `calamine` by default for both path-based and byte-oriented XLSX loading. Pyodide defaults to `umya` for byte loading and accepts `backend="calamine"` explicitly; `umya` also remains available explicitly on native builds. XLSX byte export uses `umya` because Calamine is read-only.

### Recalculate XLSX cached values (writeback)

```python
import formualizer as fz

# in-place
summary = fz.recalculate_file("financial_model.xlsx")
print(summary["status"], summary["evaluated"], summary["errors"])

# write to a new file
summary = fz.recalculate_file(
    "financial_model.xlsx", output="financial_model.recalc.xlsx"
)
```

> Formula text is preserved. Cached-value typing follows the active
> `umya-spreadsheet` implementation.

### Cache-only XLSX recalculation

```python
result = fz.recalculate_xlsx_bytes(payload)
assert isinstance(result["bytes"], bytes)
print(result["summary"]["status"], result["cache_cells_changed"])

# The file API snapshots input and atomically replaces the destination on success.
result = fz.recalculate_xlsx_file("model.xlsx", output="model.recalc.xlsx")

# Reproducible TODAY/NOW and RAND, as with the CLI's --now/--tz/--seed:
from datetime import datetime, timezone

result = fz.recalculate_xlsx_file(
    "model.xlsx",
    output="model.recalc.xlsx",
    rng_seed=7,
    deterministic_timestamp_utc=datetime(2026, 1, 31, 9, tzinfo=timezone.utc),
    deterministic_timezone="+01:00",  # default UTC
)
print(result["clock"], result["seed"])  # what the run used, for replay
```

`RAND` is reproducible by default. Without `deterministic_timestamp_utc`,
`TODAY`/`NOW` use host local time; `result["clock"]["now"]` is the aware
`datetime` they saw.

These APIs use the shared cache-only Rust implementation, retaining formula text
and unrelated package members. Safe core resource limits apply;
`error_location_limit=` only caps retained error locations.

Calls to functions the engine does not implement (add-ins such as `_xll.EURO`,
VBA/macro functions) are written as `#NAME?`, not refused. The summary says why:

```python
name = result["summary"].get("error_summary", {}).get("#NAME?")
if name:
    for location, message in zip(name["locations"], name["messages"]):
        print(location, message)  # Sheet1!C4 Unknown function: _xll.EURO
print(result["summary"]["unknown_functions"])  # [{"name": "_xll.EURO", "cells": 1}]
```

`messages` is parallel to `locations` (`None` when a cell has no reason of its
own, e.g. an inherited `#NAME?`); `unknown_functions` is complete regardless of
`error_location_limit`. Numeric caches within one unit in the 15th significant
digit of the computed value are left untouched.

In Pyodide, `recalculate_xlsx_bytes` is available (not `recalculate_xlsx_file`
or the CLI). Pyodide has no system clock, so workbooks using `TODAY`/`NOW` are
refused unless `deterministic_timestamp_utc` is passed.

Supported inputs include ordinary/shared scalar formulas, supported calculation
names, new multi-cell spills from ordinary non-shared formulas, and existing
XLDAPR dynamic-array anchors with validated `cm`/array-`ref` metadata. Spills can
grow, shrink, collapse or become blocked; `A1#` and `_xlfn.ANCHORARRAY(A1)` read
the current spill. Source-declared children are generated caches, not independent
inputs: even externally edited child values are recalculated while their source
ownership remains declared. Obsolete caches are cleared, keeping styled shells.

`formula_cells` and `summary["evaluated"]` count source formulas/anchors, not
children. `cache_cells_changed` counts physical caches inserted, replaced or
cleared (including children), so it can exceed the formula count.
`worksheet_parts_changed` counts worksheets only, not metadata/relationships.
Deterministic unchanged outputs recalculate to byte-identical no-ops.

Legacy fixed-extent (CSE) arrays, elementwise `IF`, Excel tables within a
validated subset and volatile functions are recalculated too. Data tables,
external links, rich/unknown or malformed metadata, hidden-row `SUBTOTAL`/`AGGREGATE`
ranges, circular references, shared-family multi-cell spills and spill
publication across merges are refused. Configured ZIP/XML/cell/output bounds
still apply. Fresh unmarked 1x1 results have no spill identity (`A1#` returns
`#REF!`); this is not a claim of Excel equivalence and no Excel execution oracle
was used. See [the precise eligibility and publication contract](https://github.com/psu3d0/formualizer/blob/main/docs/cache-only-xlsx.md).
The file API preserves existing destination permissions and leaves it untouched
on pre-publication failure; omitted `output` recalculates in place.

### Parse and analyze formulas

```python
from formualizer import parse
from formualizer.visitor import collect_references, collect_function_names

ast = parse("=SUMIFS(Revenue,Region,A1,Year,B1)")
print(ast.pretty())  # indented AST tree
print(ast.to_formula())  # canonical Excel string
print(collect_references(ast))  # [Revenue, Region, A1, Year, B1]
print(collect_function_names(ast))  # ['SUMIFS']
```

---

## Key features

| Capability | Description |
|---|---|
| **Tokenization** | Break formulas into structured `Token` objects with byte spans and operator metadata |
| **Parsing** | Produce a rich AST with reference normalization, source tracking, and 64-bit structural fingerprints |
| **400+ built-in functions** | Math, text, lookup (XLOOKUP, VLOOKUP), date/time, financial, statistics, database, engineering |
| **Workbook evaluation** | Set values and formulas, evaluate cells/ranges, load XLSX/CSV/JSON |
| **XLSX cache writeback** | `recalculate_file(path, output=None)` recalculates formulas and writes cached values back |
| **Batch operations** | `set_values_batch` / `set_formulas_batch` for efficient bulk updates |
| **Undo / redo** | Optional changelog with automatic action grouping — single edits are individually undoable |
| **Evaluation planning** | Inspect the dependency graph and evaluation schedule before computing |
| **SheetPort** | Treat spreadsheets as typed functions with YAML manifests, schema validation, and batch scenarios |
| **Deterministic mode** | Inject clock, timezone, and RNG seed for reproducible evaluation |
| **Visitor utilities** | `walk_ast`, `collect_references`, `collect_function_names` for ergonomic tree traversal |
| **Rich errors** | Typed `TokenizerError` / `ParserError` / `ExcelEvaluationError` with position info |

---

## Workbook evaluation

```python
import formualizer as fz

wb = fz.Workbook()
s = wb.sheet("Data")

# Set values and formulas
s.set_value(1, 1, fz.LiteralValue.number(100.0))
s.set_value(2, 1, fz.LiteralValue.number(200.0))
s.set_value(3, 1, fz.LiteralValue.number(300.0))
s.set_formula(4, 1, "=SUM(A1:A3)")
s.set_formula(4, 2, "=AVERAGE(A1:A3)")

print(wb.evaluate_cell("Data", 4, 1))  # 600.0
print(wb.evaluate_cell("Data", 4, 2))  # 200.0
```

## Custom functions

Register workbook-local callbacks without forking Formualizer:

```python
import formualizer as fz

wb = fz.Workbook(mode=fz.WorkbookMode.Ephemeral)
wb.add_sheet("Sheet1")

wb.register_function(
    "py_add",
    lambda a, b: a + b,
    min_args=2,
    max_args=2,
)

wb.set_formula("Sheet1", 1, 1, "=PY_ADD(20,22)")
print(wb.evaluate_cell("Sheet1", 1, 1))  # 42
print(wb.list_functions())
wb.unregister_function("py_add")
```

Key semantics:

- Names are case-insensitive and stored canonically (`py_add` -> `PY_ADD`).
- Custom functions are workbook-local and take precedence over global built-ins.
- Built-in override is disabled by default; set `allow_override_builtin=True` to opt in.
- Args are passed by value; range inputs arrive as nested Python lists.
- Return Python primitives, datetime/date/time/timedelta, dict error objects, or nested lists for array spill output.
- Python callback exceptions are sanitized and mapped to `#VALUE!`.

Runnable [custom-function example](https://github.com/psu3d0/formualizer/blob/main/bindings/python/examples/custom_function_registration.py) (from a source checkout: `python bindings/python/examples/custom_function_registration.py`).

## Batch operations

```python
# Bulk-set values (auto-grouped as one undo step when changelog is enabled)
s.set_values_batch(
    1,
    1,
    3,
    2,
    [
        [fz.LiteralValue.number(10.0), fz.LiteralValue.number(20.0)],
        [fz.LiteralValue.number(30.0), fz.LiteralValue.number(40.0)],
        [fz.LiteralValue.number(50.0), fz.LiteralValue.number(60.0)],
    ],
)
```

## Undo / redo

The changelog is opt-in. Once enabled, every edit is tracked:

```python
wb.set_changelog_enabled(True)

s.set_value(1, 1, fz.LiteralValue.number(10.0))
s.set_value(1, 1, fz.LiteralValue.number(20.0))
wb.undo()  # back to 10
wb.redo()  # back to 20

# Batch methods are auto-grouped as one undo step.
# For manual grouping of multiple calls:
wb.begin_action("update prices")
s.set_value(1, 1, fz.LiteralValue.number(100.0))
s.set_value(2, 1, fz.LiteralValue.number(200.0))
wb.end_action()
wb.undo()  # reverts both values at once
```

## Evaluation planning

Inspect what the engine will compute before running:

```python
plan = wb.get_eval_plan([("Sheet1", 1, 2)])
print(f"Vertices to evaluate: {plan.total_vertices_to_evaluate}")
print(f"Parallel layers: {plan.estimated_parallel_layers}")
for layer in plan.layers:
    print(f"  Layer: {layer.vertex_count} vertices, parallel={layer.parallel_eligible}")

# By default this will build deferred workbook graphs if needed.
# Disable that behavior if you want planning to fail instead of mutating workbook state.
wb.get_eval_plan([("Sheet1", 1, 2)], build_graph_if_needed=False)
```

## SheetPort: spreadsheets as typed APIs

Define a YAML manifest to treat a spreadsheet as a typed function with validated inputs/outputs:

```python
from formualizer import SheetPortSession, Workbook

manifest_yaml = """
spec: fio
spec_version: "0.3.0"
manifest:
  id: pricing-model
  name: Pricing Model
  workbook:
    uri: memory://pricing.xlsx
    locale: en-US
    date_system: 1900
ports:
  - id: base_price
    dir: in
    shape: scalar
    location: { a1: Inputs!A1 }
    schema: { type: number }
  - id: final_price
    dir: out
    shape: scalar
    location: { a1: Outputs!A1 }
    schema: { type: number }
"""

wb = Workbook()
wb.add_sheet("Inputs")
wb.add_sheet("Outputs")
wb.set_formula("Outputs", 1, 1, "=Inputs!A1*1.2")

session = SheetPortSession.from_manifest_yaml(manifest_yaml, wb)
session.write_inputs({"base_price": 100.0})
result = session.evaluate_once(freeze_volatile=True)
print(result["final_price"])  # 120.0
```

---

## API reference

### Top-level functions

```python
tokenize(formula: str, dialect: FormulaDialect = None) -> Tokenizer
parse(formula: str, dialect: FormulaDialect = None) -> ASTNode
load_workbook(path: str, strategy: str = None, *, path_source: XlsxPathSource | None = None, span_evaluation: bool | None = None) -> Workbook
load_workbook_bytes(data: bytes, strategy: str = None, backend: str | None = None, *, span_evaluation: bool | None = None) -> Workbook
recalculate_file(path: str, output: str | None = None) -> dict
recalculate_xlsx_bytes(data: bytes, *, error_location_limit: int | None = None, rng_seed: int | None = None, deterministic_timestamp_utc: datetime | None = None, deterministic_timezone: str | int | None = None) -> dict
recalculate_xlsx_file(path: str, output: str | None = None, *, error_location_limit: int | None = None, rng_seed: int | None = None, deterministic_timestamp_utc: datetime | None = None, deterministic_timezone: str | int | None = None) -> dict
```

### Core classes

- **`Workbook`** — create, load, evaluate, undo/redo. Supports `from_path()`, `from_bytes()`, `load_path()`, and `to_xlsx_bytes()`.
- **`Sheet`** — per-sheet facade for `set_value`, `set_formula`, `get_cell`, batch operations.
- **`LiteralValue`** — typed values: `.int()`, `.number()`, `.text()`, `.boolean()`, `.date()`, `.time()`, `.datetime()`, `.duration()`, `.empty()`, `.error()`, `.array()`.
- **`Tokenizer`** — iterable token sequence with `.render()` and `.tokens`.
- **`ASTNode`** — `.pretty()`, `.to_formula()`, `.fingerprint()`, `.children()`, `.walk_refs()`.
- **`CellRef` / `RangeRef` / `TableRef` / `NamedRangeRef`** — typed references.
- **`SheetPortSession`** — bind manifests to workbooks, read/write typed ports, evaluate.
- **`EvaluationConfig`** — tune parallel evaluation, warmup, range limits, date systems.

### Visitor helpers (`formualizer.visitor`)

```python
walk_ast(node, visitor_fn)  # DFS with VisitControl (CONTINUE/SKIP/STOP)
collect_references(node)  # -> list[ReferenceLike]
collect_function_names(node)  # -> list[str]
collect_nodes_by_type(node, "Function")  # -> list[ASTNode]
```

Full type stubs are included in the package (`.pyi` files) for IDE autocompletion and mypy.

---

## Building from source

Requires Rust 1.99.0 (the pinned release toolchain; edition 2024) and [maturin](https://github.com/PyO3/maturin):

```bash
pip install maturin
cd bindings/python
maturin develop            # debug build
maturin develop --release  # optimized build
```

## Using in Pyodide (browser / WebAssembly)

Native wheels are published to PyPI. Pyodide wheels are built and smoke-tested in CI and release workflows, then uploaded only as the `wheels-pyodide` Actions artifact; they are not uploaded to PyPI or attached to GitHub Releases. Download and extract the artifact (or build locally), then host the compatible wheel at a browser-accessible URL with suitable CORS headers. An Actions artifact ZIP is not a wheel URL:

```python
import micropip

wheel_url = "<your-downloadable-wheel-url>"
await micropip.install(wheel_url)

import formualizer as fz

wb = fz.Workbook()
wb.add_sheet("Sheet1")
wb.set_value("Sheet1", 1, 1, 20)
wb.set_value("Sheet1", 2, 1, 22)
wb.set_formula("Sheet1", 1, 2, "=SUM(A1:A2)")
wb.evaluate_cell("Sheet1", 1, 2)  # -> 42.0
```

**Tested Pyodide target:** CI and release smoke tests use Pyodide 314.0.7 (Python 3.14) and the wheel's derived platform tag (currently `pyemscripten_2026_0_wasm32`). Wheels for this ABI do not install in Pyodide 0.29.x (`pyodide_2025_0`). Rebuild and smoke-test a wheel when targeting another runtime; no persistent public wheel URL is promised.

**Pyodide-specific behavior:**
- `EvaluationConfig()` and `Workbook()` default `enable_parallel = False` on `sys.platform == "emscripten"` (Pyodide has no threads). You can still opt in, but it falls back to single-threaded execution.
- Native XLSX byte loading (`Workbook.from_bytes`, `load_workbook_bytes`) defaults to `calamine`; Pyodide defaults to `umya` and accepts `backend="calamine"`. XLSX byte export uses `umya` on all platforms.
- Source-preserving recalculation is available as `recalculate_xlsx_bytes` (the CLI and `recalculate_xlsx_file` are not). There is no system clock: workbooks using `TODAY`/`NOW` are refused unless `deterministic_timestamp_utc` is passed, and `result["clock"]["now"]` is `None` otherwise.
- Python UDFs registered via `Workbook.register_function` work identically to native; single-cell refs arrive as scalars (Excel-native semantics).

### Building a Pyodide wheel from source

For local development or targeting a Pyodide version without a retained Actions artifact:

```bash
./scripts/build-pyodide-wheel.sh
./scripts/smoke-pyodide-wheel.sh dist/pyodide/*_wasm32.whl
```

The build script defaults to xbuildenv Pyodide 314.0.7, derives Python, ABI, Emscripten, and Rust toolchain values from `pyodide config`, installs that Rust toolchain's `wasm32-unknown-emscripten` target (or, for an xbuildenv that names one, Pyodide's custom wasm-EH sysroot), and tags the output wheel with the platform tag Pyodide's `micropip` expects. `pyodide-cli` and `pyodide-build` are resolved through `uvx` and are not pinned by the script.

## Testing

```bash
pip install formualizer[dev]
pytest bindings/python/tests
ruff check bindings/python
mypy bindings/python/formualizer
```

## Workspace layout

```
formualizer/
  crates/                    # Rust core (parse, eval, workbook, sheetport)
  bindings/python/
    formualizer/             # Python package (helpers, visitor, type stubs)
    src/                     # PyO3 bridge (Rust -> Python)
```

The Python wheel links directly against the Rust crates — there is no runtime FFI overhead beyond the initial C-to-Rust boundary.

## License

Dual-licensed under [MIT](https://github.com/PSU3D0/formualizer/blob/main/LICENSE-MIT) or [Apache-2.0](https://github.com/PSU3D0/formualizer/blob/main/LICENSE-APACHE), at your option. Both license texts ship inside the package.

