0014 — The Python package: PyO3 bindings that mirror the WebAssembly package
Status: Accepted
Context
JSONata in Python has three implementations, checked on 2026-10-15: jsonata-python 0.7.1, a
port in pure Python; jsonata 0.2.5, a wrapper of jsonata-js; and jsonata-rs 0.1.4, a young
Rust port of jsonata-java. jlens would bring its conformance, audits and speed to Python, where
data pipelines and rules live. The package has to answer seven questions: which binding, where
its code lives and under which names, which API, how a Python value is read, how threads share
what the package returns, which interpreters its wheels serve, and what it leaves out.
The binding. PyO3 is the binding of Rust to CPython, and maturin builds its wheels. Both
are what other Rust packages on PyPI use (pydantic-core, orjson, polars). The workspace
forbids unsafe_code, and PyO3’s macros expand to code that calls CPython’s C API. A spike
(PyO3 0.30.1, maturin 1.15.0, Rust 1.99, Windows x86-64) answered that question: a module with
#[pyclass], #[pymethods] and #[pymodule] compiles under unsafe_code = "forbid" with no
warning, and an unsafe block written in the crate is still refused. The unsafe code is inside
PyO3, as it is inside wasm-bindgen for the WebAssembly package.
Reading a Python value. The WebAssembly package reads its input one level at a time through
Source (ADR 0010). The spike read Python values the same way (a dict as an object, a list
or tuple as an array, cycles detected through the chain of parents) and compared it with the
other ways to give jlens a document. Microseconds per evaluation, best of five, Python 3.14.8,
release build, on a document of items with five fields each (id, name, price, a list of
tags, a nested object):
| Expression | Python values, one level at a time | json.dumps, then the JSON text | Parsed JSON, read lazily | Document |
|---|---|---|---|---|
| 1,000 items (106 KB of JSON) | ||||
items[0].name | 222 | 3,872 | 62 | 4.1 |
$count(items) | 216 | 3,608 | 1.3 | 0.9 |
$sum(items.price) | 1,770 | 4,595 | 551 | 37 |
items[price > 98].id | 1,842 | 4,603 | 654 | 135 |
$ | 6,429 | 6,079 | 2,103 | 2,352 |
| 100,000 items (11 MB of JSON) | ||||
items[0].name | 36,199 | 450,707 | 9,961 | 2,480 |
$sum(items.price) | 235,319 | 539,915 | 90,994 | 13,225 |
$ | 846,609 | 847,078 | 359,145 | 381,154 |
json.dumps alone takes 1,604 µs on 1,000 items and 213,087 µs on 100,000. Reading levels is
2 to 17 times faster than the round trip through JSON text when the expression reads part of
the document, and costs about the same (6% more at worst) when it reads all of it, since
json.dumps visits every value too. Unlike the WebAssembly package, no level is worth reading
whole through JSON text. A result converted straight into Python objects costs 15% less than a
result written as JSON text and read back with json.loads (6,079 against 7,133 µs for $ on
1,000 items).
Python values have no JSON form for everything. The spike stopped the evaluation with U1004,
the error of a level that cannot be read, for a cycle (Circular reference detected, as
json.dumps says), a set, a date or a key that is not a str, with json.dumps’s message.
An int beyond 2^53 becomes the nearest number, as in jsonata-js. NaN and the infinities become
null, as the WebAssembly package does with JSON.stringify.
Threads. jlens_core::Expression is Send and Sync. jlens_core::Document holds
Rcs and is neither. A PyO3 class that is not Send is declared unsendable, and the spike
showed what that costs: an unsendable Document used from another thread raises
PanicException, which derives from BaseException (so except Exception misses it), and
prints a Rust panic on standard error. Building a document once and sharing it with a pool of
worker threads is a common Python pattern, so that would be a trap. Making the class Send by
hand needs unsafe impl Send, which the workspace forbids. The “parsed JSON, read lazily” column
is the other way: a serde_json::Value behind an Arc is Send and Sync, and each evaluation
reads it lazily, 4 to 15 times slower than a Document when the expression reads the items.
Free-threaded Python (PEP 703) runs threads without the GIL. A module declares that it
supports it (#[pymodule(gil_used = false)]), and its classes must then be safe without the
GIL, which Send + Sync classes are. The stable ABI (abi3) does not cover free-threaded
builds; PEP 803 adds abi3t from Python 3.15. The spike built three wheels of the same module:
cp39-abi3 for Python with the GIL, cp314-cp314t for 3.14 without it, and
cp315-abi3.abi3t (PyO3’s abi3t-py315 feature), which served Python 3.15.0 free-threaded,
with the GIL disabled. Each wheel takes 0.9 MB on Windows x86-64.
A bug the spike found. A Source input 20,000 levels deep, returned whole ($ or
$string($)), overflows the stack in jlens-core: the conversion of the result into JSON
(Value::to_json_spending) and the serialization of $string recurse without
stack::grow. $count($) on the same input works. The bug is not Python’s: a pure Rust
Source reproduces it, and the Python package would expose it to any nested list.
Decision
- PyO3 and maturin, under the workspace’s lints. The crate keeps
unsafe_code = "forbid"andmissing_docs, as every crate of the workspace does. - The crate is
crates/jlens-python, in the workspace, not published to crates.io. It is a maturin project in the mixed layout: the native modulejlens._jlensand a Python packagejlensbeside it incrates/jlens-python/python/jlens/, which re-exports the module and holds the type stubs (_jlens.pyi) andpy.typed. The PyPI name isjlens(free on 2026-10-15), with the workspace’s version, in beta (Development Status :: 4 - Beta) until 1.0, as the WebAssembly package is. - The API mirrors the WebAssembly package’s, in Python’s style (
snake_case, keyword arguments):Expression(source), withevaluate(input=None, bindings=None, **options),evaluate_json(text, bindings=None, **options)(the input and the result as JSON text),evaluate_document(document, ...),evaluate_with_audit(...),register_function(name, implementation, signature=None)andsource.Document(value)andDocument.from_json(text).- The options are those of
EvaluateOptions:timeoutin seconds (afloat, as Python’s own APIs take it),max_depth,max_string_length,max_array_length,max_result_size,now(milliseconds since the epoch, adatetimeor an ISO 8601 string) andseed. - Errors raise
jlens.JlensError, a subclass ofException, withcode,position,start,end,tokenandvalue. Its offsets count code points, the unit of Python’sstr, so thatsource[error.position]points where jsonata-js’s UTF-16 offset points. evaluatereturnsNonefor an undefined result, and fornull.evaluate_jsontells them apart:Nonefor undefined,"null"fornull.- The language service (
LanguageService,infer_schema) is left out: editors reach it through the language server and the VS Code extension. It can be added when a Python application asks for it.
evaluatereads its Python input one level at a time, throughSource, as the WebAssembly package does, with the conversions above:dict(and its subclasses) as an object,listandtupleas arrays,str,int,float,boolandNone; anything else stops the evaluation withU1004. The input must not change during the evaluation: a change can give a result mixing both states, orU1004, never a crash. Results are converted straight into Python objects: an integral number that fits in 64 bits becomes anint, asjson.loadswould give it, and other numbers afloat.Documentcan be shared between threads. It holds the parsed JSON (Arc<serde_json::Value>) and is afrozenclass. Each thread converts it into ajlens_core::Documentthe first time it evaluates against it, and keeps that copy in a thread-local cache, keyed by aWeakreference to the JSON: a later evaluation on that thread runs at the speed of aDocument. The cache drops a copy once its document is gone, at the thread’s next evaluation, or when the thread ends. Building aDocumentfrom a Python value converts it whole, with the same rules asevaluate.- The module supports free-threaded Python (
gil_used = false).evaluate_jsonandevaluate_documentdetach from the interpreter while they evaluate, so that other Python threads run meanwhile, and attach again to call a host function written in Python.evaluatestays attached, since it reads Python objects as it goes. - Wheels: for each release target (Linux x86-64 and ARM64, manylinux and musllinux; macOS
x86-64 and ARM64; Windows x86-64), one
abi3wheel for Python 3.10 and later with the GIL, and oneabi3twheel for free-threaded Python 3.15 and later; and a source distribution. Free-threaded 3.14 builds from the source distribution, which needs Rust. The oldest Python supported is 3.10: CPython stopped supporting 3.9 in 2025, and 3.10 is still the Python of current long-term distributions (Ubuntu 22.04). Withabi3, an older floor costs one more version in the test matrix, not one more wheel; it is raised in a minor release, when PyO3 drops it or few users remain. - The deep-input bug is fixed in jlens-core before the binding (roadmap 0.17 #2): the
conversion of results and
$stringgo throughstack::grow, with a robustness case on a deepSource. - Host functions written in Python (added with roadmap 0.17 #4). A callable in the
bindings, or registered with
register_function, is a host function:HostFunctionholds thePy<PyAny>(which isSend + Sync) and attaches to the interpreter to call it. Without a signature its arity counts the positional parameters before the first with a default, as JavaScript’slengthdoes. At the function boundaryNonestands for undefined, in the arguments and in the result; inside a list or adictit isnull. An exception the function raises is kept by the evaluation and raised again unchanged, with the call’spositionandtokenadded when it has none; an awaitable result fails withU1002. A JSONata function among the arguments arrives as ajlens.Function. The handle jlens-core gives borrows the call (ADR 0011) and cannot live in a Python object, so each call publishes it in a scoped thread-local (scoped-tls-hkt, whose safe API takes the place of wasm-bindgen’s borrowed closures), and theFunctionfinds its call there by number. Called after the call has returned, or from another thread, it raisesU1005. evaluate_with_auditreturns adict(typed byjlens.AuditResult) withresult,error,audit,explanationandexplanation_text;resultanderrorare always there,Nonewhen there is none. The audit tree and the explanation keep the JSON of the CLI (nodeCount,durationMs), with spans in code points; the options and the capturelimitsare insnake_case.
Consequences
- A Python user installs one wheel of under 1 MB, with nothing to compile, on the release targets. Other platforms and free-threaded 3.14 need a Rust toolchain.
- The Python and WebAssembly packages have the same objects, options and errors, so the book
can describe them together, and an application can move between them. Where Python’s style
differs (
snake_case, seconds, code-point offsets), the Python chapter says so. evaluateon Python values is the default and the fast path for a document evaluated once. A document evaluated many times belongs in aDocument, 2 to 240 times faster per evaluation (twice as fast when the result is the whole document), at the cost of one conversion per thread.- Each thread that evaluates against a
Documentholds its own converted copy until the document is dropped. A pool of many threads using one large document multiplies its memory by the number of threads. - The workspace builds
jlens-pythonwithcargo buildlike any crate; the pytest suite needs maturin and a Python, whichcargo testdoes not. Its CI and release steps are 0.17 #3 to #6. - jlens-core gains a fix for every
Sourceinput, the WebAssembly package’s included.
Amendment: mimalloc as the allocator (0.19)
Context
Roadmap 0.19, items 8 and 11. Profiled with glibc’s allocator, malloc and free took 24% to
38% of the object constructor and 30% of $count(items), where each dict of the list
allocates its object; jsonatapy uses mimalloc. The jlens command has used mimalloc since
0.15, where it made glibc builds 3 to 20% faster on ten of the twelve benchmarks, and musl and
Windows builds up to 3 and 2 times faster (the performance chapter). The module is loaded by
the interpreter at run time (dlopen on Linux), where the space for initial-exec thread-locals
is small and shared with every other module loaded so far.
Decision
- mimalloc is the global allocator of
jlens-python, on every wheel target, and of no other library crate: a Rust application chooses its own allocator. It serves the module’s own allocations; Python objects keep the interpreter’s allocator, and no allocation crosses from one to the other. - With mimalloc’s
local_dynamic_tls, which reaches its thread-local state through the dynamic model, as a library loaded at run time should, rather than counting on room for the initial-exec model. Measured against plain mimalloc, it costs the smallest cases at most 0.02 µs, and reading a list ofdicts 7 to 11% less.
Consequences
- With Python values in (
python.py speed, WSL, the median of three alternated runs against glibc’s allocator), ten cases take 4% to 19% less time,$count(items)35% less; the smallest evaluations take 0.02 to 0.07 µs more, andrecursion7% more, as natively with glibc (6%). - A wheel grows by 60 to 70 KB (1.23 to 1.30 MB for Linux x86-64, 1.13 to 1.19 MB for Windows). The 0.18.0 wheels already took 1.09 to 1.43 MB, not “under 1 MB” as the consequences above said.
- Every target builds mimalloc’s C sources with its own compiler, as the command’s release binaries already do: the manylinux and musllinux containers on Linux, MSVC on Windows.