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
- Registries
- Manifests
- Rulesets
- Runtime EDB
- Solving
- Querying and explanations
- Errors
- Complete examples
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):
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:
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:
# check_import.py — save anywhere, does not need to live in bindings/python/
import maelys_datalog
print("import OK")# 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:
# The actual way — only PYTHONPATH, no LD_LIBRARY_PATH, from anywhere
PYTHONPATH=/path/to/bindings/python python check_import.py
# import OKSession lifecycle
Engine()Load the native library and read the active build profile capacities. One per process is typical; multiple engines may coexist.
engine.register_domain(...)Register the predicate vocabulary once — process-wide, not per Engine. Safe to call again with an identical table.
engine.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.
ruleset.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.
engine.close() - or a with blockClosing 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
| Method | Description |
|---|---|
| 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. |
| Method | Description |
|---|---|
| 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. |
| Method | Description |
|---|---|
| edb.add_fact(predicate: str, terms: Sequence[InputTerm]) -> None | Insert 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. |
| Method | Description |
|---|---|
| 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. |