From c2fd19dd88e54a12c5a56d3b1b88f440047698c1 Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Tue, 7 Jul 2026 02:20:00 +0900 Subject: [PATCH] cli: grouped/sorted help with per-command examples Top-level --help now groups subcommands gcloud-style (Authoring, Instances, Stepping, Persistence, Batch), sorted alphabetically within each group, each with a one-line description. Every subcommand's --help carries a description and a usage example (RawDescription epilog). 'export --help' documents that --format currently accepts only 'mermaid' and shows an example. --store is documented prominently with the file:/mem:/sqlite: schemes, the ./.determa default, and DETERMA_STORE, plus store examples. Help text is non-normative; no command behavior changes. --- src/determa/state/cli.py | 161 ++++++++++++++++++++++++++++----------- 1 file changed, 115 insertions(+), 46 deletions(-) diff --git a/src/determa/state/cli.py b/src/determa/state/cli.py index 40f3bc5..66ec8bc 100644 --- a/src/determa/state/cli.py +++ b/src/determa/state/cli.py @@ -46,100 +46,169 @@ def main(argv: list[str] | None = None) -> int: def _build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="determa-state", description="Determa State statechart engine") + p = argparse.ArgumentParser( + prog="determa-state", + description="Determa State statechart engine", + formatter_class=_GroupedHelpFormatter, + epilog=( + "store specification (--store / DETERMA_STORE):\n" + " file: portable snapshot files (the default, ./.determa)\n" + " mem: in-memory, ephemeral\n" + " sqlite: a single-file database\n" + "examples:\n" + " determa-state --store mem: new t1 machine.yaml\n" + " determa-state --store sqlite:./state.db list --json" + ), + ) p.add_argument( "--store", default=None, - help="store spec: file: | mem: | sqlite: (default ./.determa)", - ) - p.add_argument( - "--version", action="version", version=f"determa-state {_pkg_version()}" + help="store spec: file: | mem: | sqlite: (default ./.determa or $DETERMA_STORE)", ) - sub = p.add_subparsers(dest="command", required=True) + p.add_argument("--version", action="version", version=f"determa-state {_pkg_version()}") + sub = p.add_subparsers(dest="command", required=True, metavar="") # `--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) + def add(cmd: str, group: str, desc: str, example: str, **kw: Any) -> argparse.ArgumentParser: + sp = sub.add_parser( + cmd, + parents=[common], + help=desc, + description=desc, + epilog=f"example:\n {example}", + formatter_class=argparse.RawDescriptionHelpFormatter, + **kw, + ) + sp._cli_group = group # type: ignore[attr-defined] + sp._cli_help = desc # type: ignore[attr-defined] + return sp - v = add("validate") + v = add("validate", "Authoring", "validate a machine definition file", + "determa-state validate machine.yaml") v.add_argument("machine") v.set_defaults(cmd=cmd_validate) - e = add("export") + e = add("export", "Authoring", "render a machine to a diagram", + "determa-state export machine.yaml --format mermaid") e.add_argument("machine") - e.add_argument("--format", default="mermaid") - e.add_argument("--state", default=None) + e.add_argument("--format", default="mermaid", choices=["mermaid"], + help="output format (currently only 'mermaid')") + e.add_argument("--state", default=None, help="instance id whose active config to highlight") e.set_defaults(cmd=cmd_export) - n = add("new") + n = add("new", "Instances", "create a new instance from a machine", + "determa-state new t1 machine.yaml --external token=abc") n.add_argument("id") n.add_argument("machine") - n.add_argument("--external", action="append", default=[]) + n.add_argument("--external", action="append", default=[], + help="seed an external esv: k=v (repeatable)") n.set_defaults(cmd=cmd_new) - s = add("send") + s = add("send", "Instances", "deliver an event to an instance", + "determa-state send t1 coin --payload amount=100") s.add_argument("instance") s.add_argument("event") - s.add_argument("--payload", action="append", default=[]) - s.add_argument("--payload-json", default=None) + s.add_argument("--payload", action="append", default=[], help="event field k=v (repeatable)") + s.add_argument("--payload-json", default=None, help="whole payload as one JSON object") s.set_defaults(cmd=cmd_send) - a = add("advance") - a.add_argument("duration") + a = add("advance", "Instances", "advance the virtual clock by a duration", + "determa-state advance 5s") + a.add_argument("duration", help="e.g. 500ms, 5s, 2m") a.set_defaults(cmd=cmd_advance) - env = add("env") + env = add("env", "Instances", "notify an instance of environment changes", + "determa-state env t1 --changed level=high") env.add_argument("instance") - env.add_argument("--changed", required=True) + env.add_argument("--changed", required=True, help="comma-separated k=v pairs") env.set_defaults(cmd=cmd_env) - st = add("state") + st = add("state", "Instances", "print an instance's current state", + "determa-state state t1 --json") 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) + en = add("enabled", "Instances", "list events an instance can currently handle", + "determa-state enabled t1") + en.add_argument("instance") + en.set_defaults(cmd=cmd_enabled) - run = add("run") - run.add_argument("source", nargs="?", default="-", help="'-' for stdin, or an NDJSON file") - run.set_defaults(cmd=cmd_run) + ip = add("inspect", "Instances", "show full internal state for debugging", + "determa-state inspect t1 --json") + ip.add_argument("instance") + ip.set_defaults(cmd=cmd_inspect) - md = add("mode") + md = add("mode", "Stepping", "get or set auto vs manual processing mode", + "determa-state mode manual") md.add_argument("mode", nargs="?", choices=["auto", "manual"]) md.set_defaults(cmd=cmd_mode) - ij = add("inject") + ij = add("inject", "Stepping", "enqueue an event without processing (manual mode)", + "determa-state inject t1 coin --payload amount=100") ij.add_argument("instance") ij.add_argument("event") - ij.add_argument("--payload", action="append", default=[]) - ij.add_argument("--payload-json", default=None) + ij.add_argument("--payload", action="append", default=[], help="event field k=v (repeatable)") + ij.add_argument("--payload-json", default=None, help="whole payload as one JSON object") ij.set_defaults(cmd=cmd_inject) - sp = add("step") + sp = add("step", "Stepping", "process N RTC steps (manual mode)", + "determa-state step t1 --steps 1") sp.add_argument("instance") - sp.add_argument("--steps", type=int, default=1) + sp.add_argument("--steps", type=int, default=1, help="number of RTC steps (default 1)") sp.set_defaults(cmd=cmd_step) - en = add("enabled") - en.add_argument("instance") - en.set_defaults(cmd=cmd_enabled) + snap = add("snapshot", "Persistence", "serialize an instance to a snapshot", + "determa-state snapshot t1 > t1.json") + snap.add_argument("instance") + snap.set_defaults(cmd=cmd_snapshot) - ip = add("inspect") - ip.add_argument("instance") - ip.set_defaults(cmd=cmd_inspect) + r = add("restore", "Persistence", "recreate an instance from a snapshot", + "determa-state restore t1.json") + r.add_argument("snapshot") + r.set_defaults(cmd=cmd_restore) + + ls = add("list", "Persistence", "list all instances", + "determa-state list --json") + ls.set_defaults(cmd=cmd_list) + + run = add("run", "Batch", "drive many commands from NDJSON stdin (ยง13.7)", + "echo '[\"new\",\"t1\",\"machine.yaml\"]' | determa-state run -") + run.add_argument("source", nargs="?", default="-", help="'-' for stdin, or an NDJSON file") + run.set_defaults(cmd=cmd_run) return p +# Command groups, shown gcloud-style in --help (alphabetical within each group). +_GROUP_ORDER = ["Authoring", "Instances", "Stepping", "Persistence", "Batch"] + + +class _GroupedHelpFormatter(argparse.RawDescriptionHelpFormatter): + """Renders the subcommand list grouped by category (gcloud-style) in top-level help.""" + + def _format_action(self, action: argparse.Action) -> str: + if not isinstance(action, argparse._SubParsersAction): + return super()._format_action(action) + groups: dict[str, list[tuple[str, argparse.ArgumentParser]]] = {} + for name, parser in action.choices.items(): + groups.setdefault(getattr(parser, "_cli_group", "Commands"), []).append( + (name, parser) + ) + width = max((len(n) for items in groups.values() for n, _ in items), default=0) + lines: list[str] = [] + ordered = _GROUP_ORDER + sorted(g for g in groups if g not in _GROUP_ORDER) + for group_name in ordered: + items = groups.get(group_name) + if not items: + continue + lines.append(f" {group_name}:") + for name, parser in sorted(items): + lines.append(f" {name:<{width}} {getattr(parser, '_cli_help', '')}") + return "\n".join(lines) + "\n" + + # --- host (de)serialization ------------------------------------------------- def _build_host(state: StoreState) -> Host: host = Host()