diff --git a/README.md b/README.md index 36d1416..fa5c1d4 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,13 @@ The normative `SPEC.md`, the JSON Schema for machine YAML, and the cross-languag **conformance suite** live in the spec repo. This repository implements that spec in Python and is correct **iff it passes the conformance suite**. -Status: **in progress** — YAML 1.2 loading + machine validation (SPEC §2/§4) are -implemented and gated against the full conformance suite. The engine is being -built up the build order in [issue #3][issue]. +Status: **passing the full conformance suite** — all 22 engine cases +(`conformance/01`–`22`) plus `conformance/cli/01`. Implements YAML 1.2 loading ++ validation, the full statechart semantics (RTC dispatch, hierarchy, orthogonal +regions + `done`, shallow/deep history, esvs, CEL guards, structured actions, +active objects + bus, defer, timers, faults), static contracts, snapshot +round-trip + safe-point migration, Mermaid `export`, and the §13 CLI. Built up +the build order in [issue #3][issue]. [issue]: https://github.com/fruwehq/harel-python/issues/3 diff --git a/pyproject.toml b/pyproject.toml index 678b6bb..e6a9ab0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ dev = [ "types-jsonschema", ] +[project.scripts] +harel = "harel.cli:main" + [tool.hatch.build.targets.wheel] packages = ["src/harel"] diff --git a/src/harel/cli.py b/src/harel/cli.py new file mode 100644 index 0000000..695ffd5 --- /dev/null +++ b/src/harel/cli.py @@ -0,0 +1,386 @@ +"""Standard CLI (SPEC §13). + +Every implementation exposes the same command surface so operators and tests +interact with any language's engine identically. State persists in a +file-backed store; a state-changing command loads the affected instances, runs +all to quiescence, and persists. Diagnostics go to stderr; the result to stdout. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any, cast + +from . import collect_errors, load_definitions +from . import export as export_mod +from .contracts import load_contract, validate_contracts +from .engine import Host +from .errors import HarelError +from .instance import Instance, Status +from .model import Machine +from .store import Store, StoreState + +# Exit codes (SPEC §13.2). +EXIT_OK = 0 +EXIT_OTHER = 1 +EXIT_USAGE = 2 +EXIT_VALIDATION = 3 +EXIT_NOT_FOUND = 4 +EXIT_FAULTED = 5 + + +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))) + except HarelError as exc: + print(str(exc), file=sys.stderr) + return EXIT_OTHER + + +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( + "--version", action="version", version=f"harel {_pkg_version()}" + ) + sub = p.add_subparsers(dest="command", required=True) + + # `--json` is accepted per-subcommand (after the positionals). + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--json", action="store_true", help="machine-readable output") + + def add(cmd: str, **kw: Any) -> argparse.ArgumentParser: + return sub.add_parser(cmd, parents=[common], **kw) + + v = add("validate") + v.add_argument("machine") + v.set_defaults(cmd=cmd_validate) + + e = add("export") + e.add_argument("machine") + e.add_argument("--format", default="mermaid") + e.add_argument("--state", default=None) + e.set_defaults(cmd=cmd_export) + + n = add("new") + n.add_argument("id") + n.add_argument("machine") + n.add_argument("--external", action="append", default=[]) + n.set_defaults(cmd=cmd_new) + + s = add("send") + s.add_argument("instance") + s.add_argument("event") + s.add_argument("--payload", action="append", default=[]) + s.add_argument("--payload-json", default=None) + s.set_defaults(cmd=cmd_send) + + a = add("advance") + a.add_argument("duration") + a.set_defaults(cmd=cmd_advance) + + env = add("env") + env.add_argument("instance") + env.add_argument("--changed", required=True) + env.set_defaults(cmd=cmd_env) + + st = add("state") + st.add_argument("instance") + st.set_defaults(cmd=cmd_state) + + add("list").set_defaults(cmd=cmd_list) + + snap = add("snapshot") + snap.add_argument("instance") + snap.set_defaults(cmd=cmd_snapshot) + + r = add("restore") + r.add_argument("snapshot") + r.set_defaults(cmd=cmd_restore) + return p + + +# --- host (de)serialization ------------------------------------------------- +def _build_host(state: StoreState) -> Host: + host = Host() + host.now = state.now + host._spawn_counters = dict(state.spawn_counters) # noqa: SLF001 + for text in state.defs.values(): + host.register_all(load_definitions(text)) + host.restore_all(state.instances) + return host + + +def _persist(store: Store, state: StoreState, host: Host) -> None: + state.instances = host.snapshot_all() + state.now = host.now + state.spawn_counters = dict(host._spawn_counters) # noqa: SLF001 + store.save(state) + + +def _resolve_machine_path(arg: str) -> Path: + return Path(arg) + + +# --- commands --------------------------------------------------------------- +def cmd_validate(args: argparse.Namespace, store: Store) -> int: + raw_text = _resolve_machine_path(args.machine).read_text(encoding="utf-8") + defs = load_definitions(raw_text) + root = defs[0] + errors = list(collect_errors(root.raw)) + cdir = _resolve_machine_path(args.machine).parent / "contracts" + if cdir.exists(): + contracts = {} + for cf in sorted(cdir.glob("*.yaml")): + c = load_contract(cf.read_text(encoding="utf-8")) + contracts[c["id"]] = c + errors.extend(validate_contracts(root.raw, contracts)) + valid = not errors + if args.json: + print( + json.dumps( + { + "valid": valid, + "errors": [{"path": e["path"], "message": e["message"]} for e in errors], + } + ) + ) + elif not valid: + for e in errors: + print(f"{e['path']}: {e['message']}", file=sys.stderr) + return EXIT_OK if valid else EXIT_VALIDATION + + +def cmd_new(args: argparse.Namespace, store: Store) -> int: + state = store.load() + if any(s["id"] == args.id for s in state.instances): + print(f"instance '{args.id}' already exists", file=sys.stderr) + return EXIT_USAGE + text = _resolve_machine_path(args.machine).read_text(encoding="utf-8") + defs = load_definitions(text) + key = f"{defs[0].id}@{defs[0].version}" + state.defs[key] = text + host = _build_host(state) + external = _parse_kv(args.external, _external_types(host.machines[defs[0].id])) + host.create_root(host.machines[defs[0].id], args.id, external=external) + host.run_to_quiescence() + inst = host.instances[args.id] + _print_state(args, host, inst) + _persist(store, state, host) + return EXIT_OK + + +def cmd_send(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + inst = host.instances.get(args.instance) + if inst is None: + print(f"no such instance: {args.instance}", file=sys.stderr) + return EXIT_NOT_FOUND + payload = _build_payload(args, inst.machine) + before = len(host.published) + if not host.deliver(args.instance, args.event, payload): + print(f"rejected: {args.event}", file=sys.stderr) + return EXIT_VALIDATION + host.run_to_quiescence() + if args.json: + obj = _state_json(host, host.instances[args.instance]) + obj["published"] = host.published[before:] + print(json.dumps(obj)) + _persist(store, state, host) + inst = host.instances.get(args.instance) + if inst is not None and inst.status is Status.FAULTED: + return EXIT_FAULTED + return EXIT_OK + + +def cmd_advance(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + host.advance(args.duration) + host.run_to_quiescence() + if args.json: + print(json.dumps({"now": host.now})) + _persist(store, state, host) + return EXIT_OK + + +def cmd_env(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + if args.instance not in host.instances: + print(f"no such instance: {args.instance}", file=sys.stderr) + return EXIT_NOT_FOUND + changed = _parse_csv_kv(args.changed) + host.deliver(args.instance, "env", {"changed": changed}) + host.run_to_quiescence() + _print_state(args, host, host.instances[args.instance]) + _persist(store, state, host) + return EXIT_OK + + +def cmd_state(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + inst = host.instances.get(args.instance) + if inst is None: + print(f"no such instance: {args.instance}", file=sys.stderr) + return EXIT_NOT_FOUND + _print_state(args, host, inst) + return EXIT_OK + + +def cmd_list(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + if args.json: + rows = [ + { + "id": i.id, + "def": f"{i.machine.id}@{i.machine.version}", + "parent": i.parent_id, + "status": i.status.value, + "config": i.active_leaf_names(), + } + for i in sorted(host.instances.values(), key=lambda x: x.id) + ] + print(json.dumps(rows)) + else: + for i in sorted(host.instances.values(), key=lambda x: x.id): + print(f"{i.id}\t{i.status.value}\t{i.active_leaf_names()}") + return EXIT_OK + + +def cmd_snapshot(args: argparse.Namespace, store: Store) -> int: + state = store.load() + host = _build_host(state) + inst = host.instances.get(args.instance) + if inst is None: + print(f"no such instance: {args.instance}", file=sys.stderr) + return EXIT_NOT_FOUND + print(json.dumps(inst.to_snapshot())) + return EXIT_OK + + +def cmd_restore(args: argparse.Namespace, store: Store) -> int: + state = store.load() + snap = json.loads(_resolve_machine_path(args.snapshot).read_text(encoding="utf-8")) + host = _build_host(state) + machine = host.versions.get((snap["def_id"], snap["def_version"])) + if machine is None: + print(f"unknown definition: {snap['def_id']}@{snap['def_version']}", file=sys.stderr) + return EXIT_NOT_FOUND + inst = Instance(machine, snap["id"], snap["parent_id"], host, auto_enter=False) + inst.load_snapshot(snap) + host.instances[snap["id"]] = inst + _persist(store, state, host) + return EXIT_OK + + +def cmd_export(args: argparse.Namespace, store: Store) -> int: + defs = load_definitions(_resolve_machine_path(args.machine).read_text(encoding="utf-8")) + machine = Machine(defs[0]) + state_config = None + if args.state: + st = store.load() + host = _build_host(st) + inst = host.instances.get(args.state) + if inst is None: + print(f"no such instance: {args.state}", file=sys.stderr) + return EXIT_NOT_FOUND + state_config = sorted(inst.config) + print(export_mod.export(machine, format=args.format, state_config=state_config)) + return EXIT_OK + + +# --- output helpers --------------------------------------------------------- +def _state_json(host: Host, inst: Instance) -> dict[str, Any]: + return { + "instance": inst.id, + "def": f"{inst.machine.id}@{inst.machine.version}", + "status": inst.status.value, + "config": inst.active_leaf_names(), + "esvs": inst.resolved_esvs(), + } + + +def _print_state(args: argparse.Namespace, host: Host, inst: Instance) -> None: + if args.json: + print(json.dumps(_state_json(host, inst))) + + +def _build_payload(args: argparse.Namespace, machine: Machine) -> dict[str, Any] | None: + if args.payload_json: + return cast(dict[str, Any], json.loads(args.payload_json)) + if not args.payload: + return None + types = _event_payload_types(machine, args.event) + return _parse_kv(args.payload, types) + + +def _event_payload_types(machine: Machine, event: str) -> dict[str, str]: + decl = (machine.definition.raw.get("events") or {}).get(event) + if not isinstance(decl, dict): + return {} + return {k: v["type"] for k, v in (decl.get("payload") or {}).items()} + + +def _external_types(machine: Machine) -> dict[str, str]: + types: dict[str, str] = {} + for var, decl in (machine.top.raw.get("esvs") or {}).items(): + if decl.get("external"): + types[var] = decl["type"] + return types + + +def _parse_kv(items: list[str], types: dict[str, str]) -> dict[str, Any]: + out: dict[str, Any] = {} + for item in items: + if "=" not in item: + continue + k, v = item.split("=", 1) + out[k] = _coerce(v, types.get(k)) + return out + + +def _parse_csv_kv(items: str) -> dict[str, Any]: + out: dict[str, Any] = {} + for part in items.split(","): + if "=" in part: + k, v = part.split("=", 1) + out[k] = _coerce(v, None) + return out + + +def _coerce(value: str, type_name: str | None) -> Any: + if type_name == "int": + return int(value) + if type_name == "float": + return float(value) + if type_name == "bool": + return value.lower() in {"true", "yes", "1"} + if type_name == "list": + return json.loads(value) + if type_name == "map": + return json.loads(value) + if type_name is None: + for caster in (int, float): + try: + return caster(value) + except ValueError: + continue + if value.lower() in {"true", "false"}: + return value.lower() == "true" + return value + + +def _pkg_version() -> str: + from . import __version__ + + return __version__ diff --git a/src/harel/export.py b/src/harel/export.py new file mode 100644 index 0000000..eeb3c31 --- /dev/null +++ b/src/harel/export.py @@ -0,0 +1,115 @@ +"""Machine/instance visualization (SPEC §12, informative). + +Exporters are pluggable by format; ``mermaid`` (``stateDiagram-v2``) is the +built-in default. Without a state config the static structure is rendered; with +one (from a snapshot/observer) the active leaves and their ancestors are +highlighted for current-state visualization. +""" + +from __future__ import annotations + +from .model import Machine, State + + +def export( + machine: Machine, + format: str = "mermaid", + state_config: list[str] | None = None, +) -> str: + """Render ``machine`` (optionally highlighting ``state_config``).""" + if format != "mermaid": + raise ValueError(f"unsupported export format: {format}") + return _to_mermaid(machine, state_config) + + +def _to_mermaid(machine: Machine, state_config: list[str] | None) -> str: + lines: list[str] = ["stateDiagram-v2"] + _emit_state(machine, machine.top, lines, indent=1, in_root=True) + if state_config: + lines.append(" classDef active fill:#9f9,stroke:#3a3") + for name in sorted(_active_names(machine, state_config)): + lines.append(f" class {name} active") + return "\n".join(lines) + "\n" + + +def _emit_state( + machine: Machine, + state: State, + lines: list[str], + indent: int, + in_root: bool, +) -> None: + pad = " " * indent + composite = state.type in ("composite", "orthogonal") + if in_root: + # top is the diagram root; its initial and transitions emit at top level. + _emit_initial(state, lines, indent) + _emit_transitions(state, lines, indent) + for child in state.children.values(): + _emit_state(machine, child, lines, indent, in_root=False) + return + if composite: + lines.append(f"{pad}state {state.name} {{") + _emit_initial(state, lines, indent + 1) + _emit_transitions(state, lines, indent + 1) + for child in state.children.values(): + _emit_state(machine, child, lines, indent + 1, in_root=False) + if state.type == "orthogonal": + regions = state.raw.get("regions") or [] + for _ in range(len(regions) - 1): + lines.append(f"{pad} --") + lines.append(f"{pad}}}") + else: + _emit_transitions(state, lines, indent) + if state.type == "final": + lines.append(f"{pad}{state.name} --> [*]") + + +def _emit_initial(state: State, lines: list[str], indent: int) -> None: + initial = state.raw.get("initial") + if not isinstance(initial, dict): + return + target = _short(initial["transition_to"]) + label = _label(None, initial.get("guard")) + pad = " " * indent + lines.append(f"{pad}[*] --> {target}{label}") + + +def _emit_transitions(state: State, lines: list[str], indent: int) -> None: + pad = " " * indent + for event, spec in (state.raw.get("on_events") or {}).items(): + transitions = spec if isinstance(spec, list) else [spec] + for t in transitions: + target = t.get("transition_to") + if target is None: + continue # internal transition: no edge + lines.append(f"{pad}{state.name} --> {_short(target)}{_label(event, t.get('guard'))}") + for after in state.raw.get("after") or []: + target = after.get("transition_to") + if target is None: + continue + lines.append(f"{pad}{state.name} --> {_short(target)} : after({after['duration']})") + + +def _label(event: str | None, guard: str | None) -> str: + """Edge label `` : event [guard]`` (§12).""" + text = event or "" + if guard: + text = f"{text} [{guard}]" if text else f"[{guard}]" + return f" : {text}" if text else "" + + +def _short(ref: str) -> str: + """A transition_to ref -> the final (leaf-most) component name.""" + return ref.split(".")[-1] + + +def _active_names(machine: Machine, state_config: list[str]) -> set[str]: + """Names of the active leaves and their ancestors (excluding the root `top`).""" + names: set[str] = set() + for path in state_config: + cur = machine.by_path.get(path) + while cur is not None and cur.parent is not None: + names.add(cur.name) + cur = cur.parent + return names diff --git a/src/harel/store.py b/src/harel/store.py new file mode 100644 index 0000000..9a04482 --- /dev/null +++ b/src/harel/store.py @@ -0,0 +1,63 @@ +"""File-backed store for the CLI (SPEC §13.1). + +A store is a directory (``--store