diff --git a/src/harel/cli.py b/src/harel/cli.py index b9403ef..7e1c835 100644 --- a/src/harel/cli.py +++ b/src/harel/cli.py @@ -24,7 +24,7 @@ from .errors import HarelError from .instance import Instance, Status from .model import Machine -from .store import Store, StoreState +from .store import Store, StoreState, open_store # Exit codes (SPEC §13.2). EXIT_OK = 0 @@ -39,7 +39,7 @@ def main(argv: list[str] | None = None) -> int: args = _build_parser().parse_args(argv) store_dir = args.store or os.environ.get("HAREL_STORE", "./.harel") try: - return int(args.cmd(args, Store(store_dir))) + return int(args.cmd(args, open_store(store_dir))) except HarelError as exc: print(str(exc), file=sys.stderr) return EXIT_OTHER @@ -47,7 +47,11 @@ def main(argv: list[str] | None = None) -> int: def _build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="harel", description="harel statechart engine") - p.add_argument("--store", default=None, help="store directory (default ./.harel)") + p.add_argument( + "--store", + default=None, + help="store spec: file: | mem: | sqlite: (default ./.harel)", + ) p.add_argument( "--version", action="version", version=f"harel {_pkg_version()}" ) diff --git a/src/harel/store.py b/src/harel/store.py index 3a8bdbc..b8cca45 100644 --- a/src/harel/store.py +++ b/src/harel/store.py @@ -1,19 +1,31 @@ -"""File-backed store for the CLI (SPEC §13.1). +"""Store backends for the CLI (SPEC §8, §13.1). -A store is a directory (``--store ``, default ``$HAREL_STORE`` or -``./.harel``) holding the registered definitions, instance snapshots, and the -virtual clock. The on-disk layout is an implementation detail; the normative -contract is CLI behaviour + the JSON I/O (§13.4). State-changing commands load -the affected instances, run all instances to quiescence, and persist atomically. +A store holds the registered definitions, instance snapshots, the virtual clock, +and the processing mode (§14). It is selected by a ``--store `` scheme: + +- ``file:`` (or a bare ````) — JSON snapshot files under a directory. + **Default** (``./.harel``). +- ``mem:`` — in-memory, ephemeral; only meaningful within a single process + (e.g. one ``run`` batch/streaming session, §13.7, or a test). +- ``sqlite:`` — a single-file SQLite database. + +All backends are behaviorally identical (same CLI results, same snapshot JSON, §8); +the on-disk/in-memory layout is an implementation detail. ``open_store(spec)`` +parses the scheme. """ from __future__ import annotations +import abc +import copy import json +import sqlite3 from dataclasses import dataclass, field from pathlib import Path from typing import Any +_SCHEMES = {"file", "mem", "sqlite"} + @dataclass class StoreState: @@ -24,7 +36,38 @@ class StoreState: mode: str = "auto" # processing mode, auto|manual (SPEC §14) -class Store: +def _state_from_parts( + defs: dict[str, Any] | None, + instances: list[dict[str, Any]] | None, + meta: dict[str, Any] | None, +) -> StoreState: + meta = meta or {} + return StoreState( + defs=defs or {}, + instances=instances or [], + now=int(meta.get("now", 0)), + spawn_counters=dict(meta.get("spawn_counters") or {}), + mode=str(meta.get("mode", "auto")), + ) + + +def _meta_json(state: StoreState) -> dict[str, Any]: + return {"now": state.now, "spawn_counters": state.spawn_counters, "mode": state.mode} + + +class Store(abc.ABC): + """The store adapter interface (SPEC §8): load/save a ``StoreState``.""" + + @abc.abstractmethod + def load(self) -> StoreState: ... + + @abc.abstractmethod + def save(self, state: StoreState) -> None: ... + + +class FileStore(Store): + """JSON snapshot files under a directory (``file:`` / bare ````).""" + def __init__(self, path: str | Path) -> None: self.path = Path(path) @@ -38,15 +81,10 @@ def _read_json(self, name: str) -> Any: return json.loads(p.read_text(encoding="utf-8")) def load(self) -> StoreState: - defs = self._read_json("defs.json") or {} - instances = self._read_json("instances.json") or [] - meta = self._read_json("meta.json") or {} - return StoreState( - defs=defs, - instances=instances, - now=int(meta.get("now", 0)), - spawn_counters=dict(meta.get("spawn_counters") or {}), - mode=str(meta.get("mode", "auto")), + return _state_from_parts( + self._read_json("defs.json"), + self._read_json("instances.json"), + self._read_json("meta.json"), ) def save(self, state: StoreState) -> None: @@ -58,13 +96,80 @@ def save(self, state: StoreState) -> None: json.dumps(state.instances, indent=2), encoding="utf-8" ) (self.path / "meta.json").write_text( - json.dumps( - { - "now": state.now, - "spawn_counters": state.spawn_counters, - "mode": state.mode, - }, - indent=2, - ), - encoding="utf-8", + json.dumps(_meta_json(state), indent=2), encoding="utf-8" ) + + +class MemoryStore(Store): + """In-process, ephemeral store (``mem:``); not shared across processes.""" + + def __init__(self) -> None: + self._state: StoreState = StoreState() + + def load(self) -> StoreState: + return copy.deepcopy(self._state) + + def save(self, state: StoreState) -> None: + self._state = copy.deepcopy(state) + + +class SqliteStore(Store): + """A single-file SQLite database (``sqlite:``); ``sqlite3`` is stdlib.""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + if self.path.parent and str(self.path.parent) not in ("", "."): + self.path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(str(self.path)) + self._conn.execute( + "CREATE TABLE IF NOT EXISTS harel_state (" + " key TEXT PRIMARY KEY," + " value TEXT NOT NULL" + ")" + ) + self._conn.commit() + + def _get(self, key: str) -> str | None: + row = self._conn.execute( + "SELECT value FROM harel_state WHERE key = ?", (key,) + ).fetchone() + return row[0] if row is not None else None + + def _set(self, key: str, value: str) -> None: + self._conn.execute( + "INSERT INTO harel_state (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + + def load(self) -> StoreState: + defs = json.loads(self._get("defs") or "{}") + instances = json.loads(self._get("instances") or "[]") + meta = json.loads(self._get("meta") or "{}") + return _state_from_parts(defs, instances, meta) + + def save(self, state: StoreState) -> None: + self._set("defs", json.dumps(state.defs)) + self._set("instances", json.dumps(state.instances)) + self._set("meta", json.dumps(_meta_json(state))) + self._conn.commit() + + def close(self) -> None: + self._conn.close() + + +def _split_scheme(spec: str) -> tuple[str, str]: + scheme, sep, rest = spec.partition(":") + if sep and scheme in _SCHEMES: + return scheme, rest + return "file", spec + + +def open_store(spec: str) -> Store: + """Select a backend from a ``--store `` scheme (SPEC §13.1).""" + scheme, rest = _split_scheme(spec) + if scheme == "mem": + return MemoryStore() + if scheme == "sqlite": + return SqliteStore(rest) + return FileStore(rest) # "file:" or a bare "" diff --git a/tests/test_stores.py b/tests/test_stores.py new file mode 100644 index 0000000..d8d8ebf --- /dev/null +++ b/tests/test_stores.py @@ -0,0 +1,193 @@ +"""Store backends + the --store scheme (SPEC §8, §13.1). + +All backends (``file``, ``mem``, ``sqlite``) round-trip an instance identically; +``sqlite:`` persists across CLI invocations; ``mem:`` is isolated per process (one +``run`` session). ``open_store`` parses the scheme. +""" + +from __future__ import annotations + +import io +import json + +import pytest + +import harel +import harel.cli as cli +from harel.store import FileStore, MemoryStore, SqliteStore, StoreState, open_store + +TURNSTILE = """\ +id: turnstile +events: + coin: { payload: { amount: { type: int, required: true } } } + push: {} +top: + esvs: + fare: { type: int, init: 50 } + initial: { transition_to: locked } + states: + locked: + on_events: + coin: { transition_to: unlocked, guard: "event.payload.amount >= fare" } + unlocked: + on_events: + push: { transition_to: locked } +""" + + +# --- open_store scheme parsing ---------------------------------------------- +def test_open_store_parses_each_scheme(tmp_path) -> None: + assert isinstance(open_store(str(tmp_path / "f")), FileStore) + assert isinstance(open_store(f"file:{tmp_path / 'f2'}"), FileStore) + assert isinstance(open_store("mem:"), MemoryStore) + assert isinstance(open_store(f"sqlite:{tmp_path / 's.db'}"), SqliteStore) + + +def test_open_store_bare_path_is_file_for_backcompat(tmp_path) -> None: + store = open_store(str(tmp_path / "bare")) + assert isinstance(store, FileStore) + + +# --- round-trip parity across backends -------------------------------------- +def _state() -> StoreState: + host = harel.Host() + host.register_all(harel.load_definitions(TURNSTILE)) + host.create_root(host.machines["turnstile"], "t1") + host.run_to_quiescence() + return StoreState( + defs={"turnstile@1": TURNSTILE}, + instances=host.snapshot_all(), + now=12_000, + spawn_counters={"t1": 3}, + mode="manual", + ) + + +@pytest.mark.parametrize( + "factory", + [ + pytest.param(lambda tmp: FileStore(tmp / "f"), id="file"), + pytest.param(lambda tmp: MemoryStore(), id="mem"), + pytest.param(lambda tmp: SqliteStore(tmp / "s.db"), id="sqlite"), + ], +) +def test_round_trip_state_identical(factory, tmp_path) -> None: + store = factory(tmp_path) + store.save(_state()) + loaded = store.load() + # the snapshot JSON (§8) is identical across backends. + assert loaded.defs == {"turnstile@1": TURNSTILE} + assert loaded.instances == _state().instances + assert loaded.now == 12_000 + assert loaded.spawn_counters == {"t1": 3} + assert loaded.mode == "manual" + + +def test_file_store_writes_snapshot_json_files(tmp_path) -> None: + store = FileStore(tmp_path / "f") + store.save(_state()) + files = sorted(p.name for p in (tmp_path / "f").iterdir()) + assert files == ["defs.json", "instances.json", "meta.json"] + snap = json.loads((tmp_path / "f" / "instances.json").read_text()) + assert snap[0]["def_id"] == "turnstile" + + +def test_sqlite_store_persists_across_handles(tmp_path) -> None: + path = tmp_path / "s.db" + SqliteStore(path).save(_state()) + # a fresh handle on the same file reads it back (a new CLI invocation). + loaded = SqliteStore(path).load() + assert loaded.instances == _state().instances + assert loaded.mode == "manual" + + +def test_memory_store_is_ephemeral_per_instance() -> None: + a = MemoryStore() + a.save(_state()) + b = MemoryStore() # a separate process has no state + assert b.load().instances == [] + + +# --- end-to-end through the CLI --------------------------------------------- +def _run( + store_spec: str, + argv: list[str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> tuple[int, str]: + rc = cli.main(["--store", store_spec, *argv]) + return rc, capsys.readouterr().out + + +def _run_batch( + store_spec: str, + lines: list[list[str]], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> tuple[int, list[dict]]: + stdin = "".join(json.dumps(line) + "\n" for line in lines) + monkeypatch.setattr("sys.stdin", io.StringIO(stdin)) + rc = cli.main(["--store", store_spec, "run", "-"]) + out = capsys.readouterr().out + return rc, [json.loads(x) for x in out.splitlines() if x.strip()] + + +def test_mem_store_holds_state_within_one_run_session(tmp_path, monkeypatch, capsys): + machine = tmp_path / "m.yaml" + machine.write_text(TURNSTILE) + # one process: state persists across batch lines via the in-memory store. + rc, results = _run_batch( + "mem:", + [ + ["new", "t1", str(machine)], + ["send", "t1", "coin", "--payload", "amount=100"], + ["state", "t1"], + ], + monkeypatch, + capsys, + ) + assert rc == 0 + assert results[0]["result"]["config"] == ["locked"] + assert results[1]["result"]["config"] == ["unlocked"] + assert results[2]["result"]["config"] == ["unlocked"] + + +def test_mem_store_does_not_persist_across_invocations(tmp_path, monkeypatch, capsys): + machine = tmp_path / "m.yaml" + machine.write_text(TURNSTILE) + rc, _ = _run("mem:", ["new", "t1", str(machine)], monkeypatch, capsys) + assert rc == 0 + # a separate process: the mem store is empty, so the instance is gone. + rc, _ = _run("mem:", ["state", "t1"], monkeypatch, capsys) + assert rc == 4 # not found + + +def test_sqlite_store_persists_across_cli_invocations(tmp_path, monkeypatch, capsys): + machine = tmp_path / "m.yaml" + machine.write_text(TURNSTILE) + db = f"sqlite:{tmp_path / 's.db'}" + rc, out = _run(db, ["new", "t1", str(machine), "--json"], monkeypatch, capsys) + assert rc == 0 and json.loads(out)["config"] == ["locked"] + rc, out = _run( + db, ["send", "t1", "coin", "--payload", "amount=100", "--json"], monkeypatch, capsys + ) + assert rc == 0 and json.loads(out)["config"] == ["unlocked"] + # a third invocation reads the persisted state. + rc, out = _run(db, ["state", "t1", "--json"], monkeypatch, capsys) + assert rc == 0 and json.loads(out)["config"] == ["unlocked"] + + +def test_backends_produce_identical_cli_results(tmp_path, monkeypatch, capsys): + machine = tmp_path / "m.yaml" + machine.write_text(TURNSTILE) + + def drive(spec: str) -> list[dict]: + _run(spec, ["new", "t1", str(machine)], monkeypatch, capsys) + _, out = _run( + spec, ["send", "t1", "coin", "--payload", "amount=100", "--json"], monkeypatch, capsys + ) + return json.loads(out) + + file_result = drive(str(tmp_path / "file")) + sqlite_result = drive(f"sqlite:{tmp_path / 's.db'}") + assert file_result == sqlite_result