Python V1 · Deprecated

Python API V1

Use the historical Python binding backed by the native C shim.

Maelys DL exposes a native Python binding for in-process use — no browser, no WASM runtime. It targets tooling that wants direct, native-speed access to the engine from a research pipeline, an offline analysis script, or a long-running service: real Python exceptions instead of sentinel return codes, real object handles instead of a single implicit session per module instance, and the full native int64 range for integer terms.

The underlying concepts — EDB facts, predicate kinds, solving, querying — are the same as in C and WASM, documented once in the Concepts pages. See Bindings overview for the concept-to-page map, how this binding compares to WASM, and where each fits relative to the shared engine.

These topic pages cover the Python surface only: installation, domain registration, object lifecycle, term encoding, and the API reference.

Explore by topic

Installation

Python 3.10 or later is required. The binding uses the public X | Y union syntax and built-in generic types throughout its annotations; older Python versions are not supported.

The binding lives in bindings/python/ inside the engine repository — it is not published as a package yet. Build the native shared libraries with CMake, then build the cffi extension, then verify the import:

Run these commands from the engine repository root. Use a virtual environment and install the CFFI build dependencies first (a C compiler and CMake are also required):

CODE
python3 -m venv .venv
. .venv/bin/activate
python -m pip install cffi setuptools

cmake -S . -B build/python-small -DMAELYS_DATALOG_BUILD_PYTHON_BINDING=ON
cmake --build build/python-small --target maelys_py_bind

python bindings/python/build_cffi.py

PYTHONPATH=bindings/python python -c "import maelys_datalog; print(maelys_datalog.Engine().limits)"
# BuildLimits(max_symbols=..., ...)

build_cffi.py takes no build-directory argument — it always links against whatever .so/.dylib files CMake's build step already copied into bindings/python/maelys_datalog/ (see Native library discovery below). There is no flag or environment variable to tell it which CMake profile to use; if you switch profiles, force a rebuild of maelys_py_bind with --clean-first before re-running build_cffi.py: the library copy is a CMake post-build step and an already up-to-date target will not run it again. A stale SMALL/LARGE mix is not caught by the cffi build step itself, only by Engine().limits disagreeing with what you expect at runtime.

Native library discovery

A compiled Python extension is itself a dynamic library, and it depends on two others: libmaelys_datalog_shared (the engine) and libmaelys_py_bind (the shim). At import time, the OS has to find those two files on disk — and by default it only looks in a handful of system locations, not wherever you happen to have built them.

The build solves this by copying the two .so/.dylib files into the same folder as the Python package:

CODE
bindings/python/maelys_datalog/
├── __init__.py
├── _maelys_cffi....so          ← the compiled extension
├── libmaelys_datalog_shared.so ← copied here by the build
└── libmaelys_py_bind.so        ← copied here by the build

Without anything else, the extension still would not find its neighbors — you would have to tell the OS where to look, every time, with an environment variable. This is a separate problem from Python finding the maelys_datalog package in the first place — that part is ordinary Python package discovery (PYTHONPATH, or running from inside bindings/python/), nothing to do with $ORIGIN. $ORIGIN only kicks in after Python has already found and loaded the compiled extension, to help that file find its two native dependencies.

To see the $ORIGIN difference specifically, keep the Python-package-discovery part constant (PYTHONPATH, works from any directory) and vary only the native-library part:

CODE
# check_import.py — save anywhere, does not need to live in bindings/python/
import maelys_datalog

print("import OK")
CODE
# The annoying way, without $ORIGIN/@loader_path: PYTHONPATH lets Python find
# the package, but the compiled extension still can't find its two native
# libraries on its own — fails regardless of which directory you run from
PYTHONPATH=/path/to/bindings/python python check_import.py
# ImportError: libmaelys_datalog_shared.so: cannot open shared object file

# ...only works once you ALSO set LD_LIBRARY_PATH, by hand, every time
PYTHONPATH=/path/to/bindings/python \
LD_LIBRARY_PATH=/path/to/bindings/python/maelys_datalog \
python check_import.py
# import OK — but only because both variables were set on this exact command

$ORIGIN (Linux) and @loader_path (macOS) remove the second variable entirely: they are special tokens baked into the compiled .so itself at build time, meaning "look in my own folder" — not a fixed path, whichever folder the file happens to be in right now. Because all three files were copied together, each one's "own folder" is the same folder, so they always find each other as long as they stay together. PYTHONPATH is still needed (that is ordinary Python, unrelated to this mechanism) — LD_LIBRARY_PATH is not, from any directory:

CODE
# The actual way — only PYTHONPATH, no LD_LIBRARY_PATH, from anywhere
PYTHONPATH=/path/to/bindings/python python check_import.py
# import OK

Session lifecycle

1. EngineEngine()

Load the native library and read the active build profile capacities. One per process is typical; multiple engines may coexist.

2. Domainengine.register_domain(...)

Register the predicate vocabulary once — process-wide, not per Engine. Safe to call again with an identical table.

3. Rulesetengine.load_inline_ruleset(...)

Parse the policy source against the registered domain. Owns its own symbol table — independent rulesets can be loaded and closed without affecting each other.

4. Evaluateruleset.edb() -> edb.add_fact(...) -> ruleset.solve(edb)

Open an EDB, add facts, solve, read results. Repeat for every request against the same ruleset — this is the step that runs per evaluation, not per process.

repeated per request
5. Closeengine.close() - or a with block

Closing a parent closes its children in order: Engine closes its Rulesets, a Ruleset closes its Edbs and SolveResults. Using a child after its parent closed raises RuntimeError in Python, before reaching C.

Ruleset owns its own policy_set and symbol table — not Engine, which only holds the build limits and the last load diagnostic. This is why multiple rulesets can be loaded and closed independently within one process, unlike the domain registry below, which is shared.

Python API walkthrough

Four classes wrap the shim. Engine holds the build limits and the process-wide domain registry; Ruleset owns a loaded policy and its symbol table; Edb holds one evaluation's input facts; SolveResult holds one solve's derived facts. This section walks through them in the order you use them.

Threading model

cffi releases the GIL during every lib.foo(...) call — confirmed against the official cffi documentation, not assumed. That means two Python threads can genuinely be inside the C shim at the same time, which is exactly why register_domain and load_inline_ruleset take a process-wide Python lock around the native domain registry: that registry is a single, unsynchronized C array shared by the whole process, so without the lock two threads registering domains concurrently could corrupt it.

Symbol-table access uses a separate threading.RLock per Ruleset. The wrapper serializes intern_symbol, symbol_text, the complete Edb.add_fact operation, and the symbol-resolution loop of enumerate_predicate_facts. Two distinct Edb objects belonging to the same Ruleset therefore cannot run those symbol-table paths simultaneously. Different rulesets keep independent locks; this is not a process-wide lock.

enumerate_predicate_facts_raw does not acquire the symbol lock: it copies already-derived term kinds and values without reading the ruleset's symbol table.

These locks do not make Ruleset, Edb, or SolveResult generally thread-safe. Concurrent solves on one shared Ruleset are not a documented guarantee. Neither are sharing one Edb or SolveResult between threads, nor calling close() while another thread uses that object or one of its children. The locks establish a narrow wrapper guarantee around symbol interning/resolution and add_fact; they do not establish general thread-safety for the C engine.

One pattern that avoids the question rather than managing it: give each thread — or each request — its own Ruleset (load_inline_ruleset can be called repeatedly against the same already-registered domain, and each resulting Ruleset has its own, unshared symbol table).

API reference

MethodDescription
Engine()Create a native engine handle and read build limits into .limits. The shared libraries and ABI self-check already ran at import time.
engine.register_domain(name, predicates)Register a predicate vocabulary in the process-wide domain registry. Safe no-op if already registered identically; raises otherwise.
engine.load_inline_ruleset(domain, ruleset_id, source)Parse .dl source against a registered domain. Returns a Ruleset. Raises MaelysDatalogError with the engine diagnostic on parse/validation failure.
engine.close()Close every open Ruleset (and everything under it), then free the engine. Safe to call more than once.
MethodDescription
ruleset.intern_symbol(text)Intern a string, return its symbol id. Idempotent.
ruleset.symbol_text(symbol_id)Resolve a symbol id back to its string. Raises MaelysDatalogError on an unknown id.
ruleset.edb()Open a new Edb bound to this ruleset.
ruleset.solve(edb)Finalize the edb (once) and solve. Returns a SolveResult. Re-solving the same finalized Edb is allowed.
ruleset.close()Close every open Edb and SolveResult under this ruleset, then free it.
MethodDescription
edb.add_fact(predicate: str, terms: Sequence[InputTerm]) -> NoneInsert one fact. Terms accept str / int / bool / Term, mixed freely. Validates input shape and the build arity ceiling before the native call. Raises RuntimeError if called after solve().
edb.close()Free the EDB. Called automatically when its parent Ruleset closes.
MethodDescription
result.contains_fact(predicate, terms)Check a ground fact under the predicate query permissions; returns bool.
result.explain_fact_text(predicate, terms)Return bounded Why-true text for a derived fact, or None when no derived witness applies; truncation is explicit.
result.derived_fact_count()Total IDB facts derived by this solve.
result.enumerate_predicate_facts(predicate, arity) -> list[Fact]Every already-derived IDB fact of an IDB | QUERY-flagged predicate, with symbols resolved to strings under the ruleset symbol lock.
result.enumerate_predicate_facts_raw(predicate, arity) -> list[RawFact]The same facts as public Term values, preserving symbol IDs and skipping symbol-text resolution.
result.close()Free the solve result. Called automatically when its parent Ruleset closes.