Python hosts
The claim Geas makes is that any language which can load a C library can speak a contract, with no code generated for the binding. The repository proves it with Python and nothing but ctypes. interop/python/geas.py is written against the ABI document alone: no header parsed, no stubs generated, every struct layout and ownership rule taken from the documented ABI. interop/python/demo_payment.py drives the payment contract with it, and every snippet on this page comes from those two files.
The shape of the wrapper
Section titled “The shape of the wrapper”The binding follows the shape of the ABI. A Runtime owns the loaded library, the worker pool behind it, and the anchor list that keeps Python made pledge trampolines alive. A Contract is a signed instance: fulfill pledges on it, read its vows and its partial surface, break it. A Future is one fulfillment’s receipt, delivered exactly once by wait. Values cross as GeasValue and are decoded into ordinary Python data at the boundary, with Ok, Err, Some, and NONE standing in for the sum shaped types.
Strings and payloads returned by the binding are always copied into Python objects at the call, so the ordinary user never sees instance memory at all.
The five moves in Python
Section titled “The five moves in Python”Runtime is a context manager, and the same five moves every host makes read like this:
from geas import Runtime, Ok, Err
with Runtime(OUT / "libgeasrt.so") as rt: rt.load(OUT / "libpayment.geas.so") rt.bind("PaymentService.charge", charge) # a Python function is the pledge rt.freeze()
c1 = rt.sign("PaymentService", vows={"currency": "EUR"}) assert c1.vow("currency") == "EUR"The library is opened with RTLD_GLOBAL on purpose: a compiled module carries undefined references to the runtime’s exports, so whoever maps libgeasrt must put its symbols in the global scope the runtime’s dlopen resolves against. The wrapper does that for you.
Bind a plain Python function
Section titled “Bind a plain Python function”The abstract charge pledge is implemented by an ordinary Python function, called back from the runtime when a pledge fulfills. It receives the signed instance and the decoded argument list, and returns a Python value, Ok and Err included:
def charge(inst, args): """The Python implementation of the abstract PaymentService.charge, honest to its declared Result<Bool, Int>: the currency vow is read off the signed instance like any thunk would, a positive amount charges and answers Ok(True), anything else is Err(41).""" card, amount = args if amount > 0: print(f"[demo_payment] charging {inst.vow('currency')} " f"{amount:.2f} to {card}") return Ok(True) return Err(41)Behind the scenes, bind wraps the function in a ctypes trampoline in the uniform thunk frame, anchors the trampoline on the Runtime so it outlives every instance that can dispatch it, and hands the raw function pointer to geas_pledge_bind. A Python exception inside the body reports GEAS_ERR_TYPE: the thunk produced no value, no latch moves.
Freeze
Section titled “Freeze”After rt.freeze() the registration surface is shut. A late bind or module load is a clean state error, not a quiet success, while signing and fulfilling continue:
try: rt.bind("PaymentService.charge", charge) check(False, "bind after freeze did not fail")except GeasError as e: check(e.status == GEAS_ERR_STATE, "bind after freeze is ERR_STATE")Sign with vows, read them back
Section titled “Sign with vows, read them back”sign takes the vow overrides as a dict; the runtime copies every override at the call. The instance reads its vows back through vow, decoded into Python data:
c1 = rt.sign("PaymentService", vows={"currency": "EUR"})check(c1.state() == GEAS_SIGNED, "c1 signed")check(c1.vow("currency") == "EUR", "vow override landed")check(c1.hash() != 0 and c1.signed_at() > 0, "c1 carries a signature")Signing without overrides takes the declared defaults: rt.sign("PaymentService") reads c2.vow("currency") == "USD".
Fulfill: synchronous and through a future
Section titled “Fulfill: synchronous and through a future”fulfill_sync fuses fulfill and wait and returns the decoded value. fulfill starts a fulfillment and returns a Future whose wait() delivers exactly once:
check(c1.fulfill_sync("validate_card", "4111 1111") == Ok(True), "validate_card Ok")fut = c1.fulfill("validate_amount", 25.0)check(fut.wait() == Ok(True), "validate_amount Ok through a future")try: fut.wait() check(False, "second wait did not fail")except GeasError as e: check(e.status == GEAS_ERR_STATE, "second wait is ERR_STATE")The split between values and statuses holds here too: a pledge’s Err is a value the call returns, while a GeasError is raised only when the fulfillment itself did not run. Arguments are deep copied inside the call, so the host buffers are free once it returns.
The partial surface
Section titled “The partial surface”partial() returns a snapshot with the item names by state and the error payload of every broken pledge:
check(c1.state() == GEAS_PARTIAL, "Validation lands, c1 partial")p = c1.partial()check(p.fulfilled == ["Validation"] and p.pending == ["Processing", "notify_user"] and p.broken == [] and p.errors == [], "partial names after Validation")Send a bad amount instead, and the contract’s break line fires by itself, keeping the Err payload readable:
c2 = rt.sign("PaymentService")out = c2.fulfill_sync("charge", "4111 1111", -2.0)check(out == Err(41), "charge Err returns to the caller")check(c2.state() == GEAS_BROKEN, "the break line fired by itself")
p = c2.partial()check(p.broken == ["Processing"] and p.pending == ["Validation", "notify_user"], "c2 broken lists Processing")check(p.errors == [("charge", 41)], "the automatic break kept the Err payload readable")How the policy decides fulfilled, partial, and broken is the language’s own ground; see Contracts and the lifecycle.
By reference from Python
Section titled “By reference from Python”The write back protocol works from Python too. A StringRef owns host side storage; the runtime copies the value in at fulfill and writes the final slot back at delivery:
g = rt.sign("Greeter")ref = StringRef("whisper")out = g.fulfill_sync("shout", refs=[ref])check(out == Ok("WHISPER"), "shout Ok")check(ref.value == "WHISPER", "the default write back landed in host storage")IntRef and FloatRef do the same for the numeric types. Read .value after the wait delivered and before the break, per the ABI’s ownership rule.
Discovery from Python
Section titled “Discovery from Python”The iname table is fully readable: iname_count, iname_at, iname_lookup, and iname_dump walk and query the same registry the C host used, with entries decoded into plain dicts:
dump = rt.iname_dump()charge_rows = [ln for ln in dump.splitlines() if "PaymentService_charge" in ln]entry = rt.iname_lookup(charge_rows[0].split()[0])check(entry["kind"] == "pledge" and entry["contract"] == "PaymentService" and entry["nargs"] == 2, "iname lookup resolves charge")Run it
Section titled “Run it”make test-pythonRead demo_payment.py beside the C host and the point lands: both drive the same contracts, sign the same vows, bind the same abstract pledges, and read the same partial surface, and neither generated a line of binding code. The contract is the shared artifact, and every language meets at it. To run the same contract across processes, continue with the gRPC bridge.