Vertep

Python

The same actor system as the Rust runtime and @vertep/web, as a Python package. This page shows how to install it, create actors from SCXML, send events, subscribe to snapshots, read journals and travel in time, keep actors in a file, and write guards and actions in Python.

Install

pip install vertep

CPython 3.9 or later. The wheel is built once per platform against the stable ABI (abi3), so it works on every CPython from 3.9 on; it holds the Vertep core, the CEL and rust datamodels and tiramemsu, and needs no Rust toolchain. To build from this repository instead, see Building from source.

Quick start

An order actor is addressed by its chart type and key. Paying it schedules a delayed ship event, a durable timer in the journal. A virtual clock makes the run deterministic: time moves only when advance says so.

from vertep import ActorSystem

ORDER = """<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="open">
  <state id="open">
    <transition event="pay" target="paid"><send event="ship" delay="200ms"/></transition>
  </state>
  <state id="paid"><transition event="ship" target="shipped"/></state>
  <state id="shipped"/>
</scxml>"""

ORDER_TYPE = "https://example.org/charts/order"

with ActorSystem(storage="memory", clock="virtual") as system:
    # A virtual actor exists for every key: no create call, the first message activates it.
    order = system.spawn(ORDER, type=ORDER_TYPE, key="42")
    print("actor", order.address)
    print("start:", order.snapshot().configuration)

    order.send("pay")
    print("after pay:", order.snapshot().configuration)
    print("pending timers:", [(t["event"], t["due"]) for t in system.pending_timers()])

    system.advance(0.1)
    print("after 100 ms:", order.snapshot().configuration)
    system.advance(0.1)
    print("after 200 ms:", order.snapshot().configuration)

    print("journal:")
    for entry in order.journal():
        taken = [t.split("#")[1] for t in entry["transitions"]]
        print(f"  {entry['position']} {entry['event']['name']:<5} -> {taken}")

    # Time travel: the state after entry 1, rebuilt from the journal.
    print("as of entry 1:", order.as_of(position=1).configuration)

It prints the real output recorded in examples/python/quickstart.expected-output.txt, and the test suite runs every example and compares.

Systems, charts and actors

CallWhat it does
ActorSystem(storage, path=, clock=, start=, dev=, idle_timeout=, guards=, actions=)Opens a system. storage is "memory" or "file" and has no default fallback. Use it as a context manager, or call close().
system.register(source, type=, format=)Validates and registers a chart (SCXML, YAML or an RDF chart graph; the format is sniffed when absent). Returns {"type", "version"}; the version is the content-addressed chart IRI.
system.spawn(source, type=, key=)Registers source and returns the Actor of that chart type and key. Without source, the type must be registered already; without key, one is generated.
system.actor(type, key)The actor of a registered type and key. A virtual actor exists for every key: the first message activates it.
system.actors()The addresses of every actor that has a journal in the store.

An event is its name plus its payload's fields: actor.send("add", qty=2) and actor.send({"type": "add", "qty": 2}) are the same event. send runs everything that falls due and returns the message id; a repeated message_id= is a duplicate and is applied once. actor.can("checkout") reports whether an event would take a transition, without sending it. It is False before the actor's first message, because nothing is activated yet.

Snapshots and subscriptions

actor.snapshot() is a frozen Snapshot: address, position (the journal position it is after, 0 before the first entry), version, configuration (active state ids), context (the datamodel as plain Python values), terminated, and matches(state). A terminated session has an empty configuration.

actor.subscribe(callback, transitions=) calls callback(snapshot) once per macrostep that changed the configuration or the context, in journal order; an event that no transition takes is journaled and tells nobody. With transitions= a second callback hears every macrostep that took a transition, with its position, event and transitions. Callbacks run on the thread that drives the system, during send, wait or pump, and may send events themselves. subscription.cancel() stops delivery; so does leaving a with subscription: block.

Journal and time travel

Every accepted event is one journal entry, with the transitions it took. actor.journal(start=0) lists them as dictionaries (position, kind, event, transitions, timers, sends, timestamp, digest). actor.as_of(position=1), or as_of(time=seconds_since_epoch), rebuilds the state by replaying the journal and leaves the live actor alone. system.query(sparql, at=) runs SPARQL on the state graph; with at= it sees the graph as it was then, which needs storage="file". Rows are dictionaries of N-Triples terms.

Time is explicit

Wall-clock time enters an actor only as a journaled timestamp, so the step never reads a clock and replay gives the same states at any later time. You choose how the system's own clock moves:

ClockTimers fireUse it for
clock="system" (default)While you call wait(seconds), send or pump(). wait sleeps with the interpreter free for other threads and Ctrl-C interrupting.Services and scripts that run in real time.
clock="virtual", start=When you call advance(seconds), in due order, all at once.Tests, simulations, replays; no sleeping.

Timers are durable: a timer scheduled by one system and overdue when another opens the same file fires once, as that system starts. SCXML delays are CSS2 times, so delay="200ms" and delay="3600s" are valid and delay="1h" is not.

Durable file storage

ActorSystem(storage="file", path="orders.db") keeps the journal and the state graph in a tiramemsu file, the same format the Rust runtime and @vertep/web use. The file is exclusive: a second system on it raises StoreLocked until the first is closed. system.export_journal() returns the file's bytes. storage="memory" keeps everything in the process and has no history to query.

import tempfile
from pathlib import Path

from vertep import ActorSystem

ORDER = """<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="open">
  <state id="open">
    <transition event="pay" target="paid"><send event="ship" delay="3600s"/></transition>
  </state>
  <state id="paid"><transition event="ship" target="shipped"/></state>
  <state id="shipped"/>
</scxml>"""

ORDER_TYPE = "https://example.org/charts/order"

with tempfile.TemporaryDirectory() as directory:
    journal = Path(directory) / "orders.db"

    with ActorSystem(storage="file", path=journal, clock="virtual", start=0) as system:
        order = system.spawn(ORDER, type=ORDER_TYPE, key="7")
        order.send("pay")
        print("first run:", order.snapshot().configuration, "timers:", len(system.pending_timers()))
    # The system is closed: the file is released and the process holds nothing of the actor.

    # A day later, by a different clock, in a fresh system on the same file. The durable timer
    # was journaled and is overdue, so it fires once, as the system starts.
    with ActorSystem(storage="file", path=journal, clock="virtual", start=86_400) as system:
        system.register(ORDER, type=ORDER_TYPE)
        order = system.actor(ORDER_TYPE, "7")
        snapshot = order.snapshot()
        print("reopened:", snapshot.configuration, "at position", snapshot.position)
        print("timers left:", len(system.pending_timers()))

        # The state graph, as SPARQL sees it now.
        rows = system.query(
            "SELECT (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } }"
        )["rows"]
        print("graph holds triples:", int(rows[0]["n"].split('"')[1]) > 0)
        print("journal file is SQLite:", system.export_journal()[:15].decode())

Guards and actions in Python

A chart on the rust datamodel names its guards and actions by IRI. Python callables registered under those IRIs run inside the step. A guard receives (context, event) and returns a bool. An action receives the same and returns what it changes: {"context": {...}, "raise": [...], "send": [...]}, or None for nothing. The event is a dictionary {"type": name, ...payload fields, "_meta": {...}}, as in @vertep/web; an emitted event is written the same way, {"type": "receipt", "amount": 5}, and a send may add target and delay (milliseconds).

Both must be pure: the journal is replayed, and a guard or action that answers differently the second time breaks replay. dev=True replays every macrostep from the journal and compares it with the live result, and a difference becomes an ImpurityWarning.

import warnings

from vertep import ActorSystem, ImpurityWarning

CHART = """<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="idle" datamodel="rust">
  <datamodel><data id="total" expr="0"/></datamodel>
  <state id="idle">
    <transition event="pay" cond="vt:guard/order/positive" target="paid">
      <foreach array="vt:action/order/record" item="_vt_emit">
        <send eventexpr="_vt_emit.event" targetexpr="_vt_emit.target" typeexpr="_vt_emit.type" delayexpr="_vt_emit.delay"><content expr="_vt_emit.data"/></send>
      </foreach>
    </transition>
  </state>
  <state id="paid"><transition event="receipt" target="done"/></state>
  <final id="done"/>
</scxml>"""

TYPE = "https://example.org/charts/payment"


def positive(context, event):
    return event["amount"] > 0


def record(context, event):
    # What the action changes, and the event it raises (its fields are the payload).
    return {
        "context": {"total": context["total"] + event["amount"]},
        "raise": [{"type": "receipt", "amount": event["amount"]}],
    }


with ActorSystem(
    storage="memory",
    clock="virtual",
    guards={"vt:guard/order/positive": positive},
    actions={"vt:action/order/record": record},
) as system:
    payment = system.spawn(CHART, type=TYPE, key="1")
    payment.send("pay", amount=0)
    print("pay 0 :", payment.snapshot().configuration, "(the guard refused)")
    payment.send("pay", amount=25)
    snapshot = payment.snapshot()
    print("pay 25:", snapshot.configuration, "terminated:", snapshot.terminated, "total:", snapshot.context["total"])

# An impure guard answers differently when the journal is replayed; development mode says so.
calls = []


def flaky(context, event):
    calls.append(event)
    return len(calls) == 1


with ActorSystem(
    storage="memory",
    clock="virtual",
    dev=True,
    guards={"vt:guard/order/positive": flaky},
    actions={"vt:action/order/record": lambda context, event: None},
) as system:
    payment = system.spawn(CHART, type=TYPE, key="2")
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        payment.send("pay", amount=1)
    for w in caught:
        if issubclass(w.category, ImpurityWarning):
            print("dev mode:", str(w.message).split(":")[0])

Errors

Every error is a vertep.VertepError with a stable code, the same codes @vertep/web reports.

ExceptionRaised when
ChartInvalidA chart source fails to read or validate. .violations lists every problem with its line and column.
InvalidArgumentA call's arguments are malformed. It is also a ValueError.
NotFoundA chart type, actor or subscription does not exist.
StoreLockedAnother system holds the file open.
ActivationError, DeliveryError, StoreErrorAn actor cannot be activated or replayed; a message cannot be delivered; the store fails.
SystemClosedThe system was closed. It is also a RuntimeError.

What to know before you rely on it

Building from source

You need the Rust toolchain pinned in rust-toolchain.toml, Python 3.9 or later and maturin. The Rust side is the crate crates/vertep-py, a thin PyO3 layer over the actor system's command bridge in vertep-wasm; the Python API is plain Python in bindings/python/python/vertep.

cd bindings/python
python -m venv .venv && . .venv/bin/activate
pip install maturin pytest
maturin develop            # builds the extension and installs it editable
pytest                     # the bindings, and every example against its recorded output

Release builds, wheels for each platform and the PyPI publishing workflow are described in bindings/python/RELEASING.md. bindings/python/scripts/check_version.py fails when the Python package version differs from the Rust workspace version, as check-version.mjs does for the npm packages.