This commit is contained in:
Iuri Matias
2026-05-29 16:25:06 +00:00
committed by GitHub
3 changed files with 366 additions and 11 deletions
+14
View File
@@ -72,6 +72,20 @@ python3 tools/tutorial_runner.py generate tests/tutorial-wrapping-c-library.test
It covers every step type (file writes, shell commands, `check_file`, and headless `ui_test` runs). Pair it with `--continue-on-fail` so the report captures the whole run instead of stopping at the first failure. CI publishes this report for every run — see [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
### Watching it live in a terminal (`--tui`)
`--tui` runs the same two-column view live in your terminal instead of writing a file: the left pane shows the rendered tutorial for the current step, the right pane shows the command being run and its output, updating as the run proceeds.
```bash
# Auto-advancing: steps run one after another
python3 tools/tutorial_runner.py run tests/tutorial-cpp-ui-app.test.yaml --tui
# Iterative: press the down/right arrow (or space) to execute each next step
python3 tools/tutorial_runner.py run tests/tutorial-cpp-ui-app.test.yaml --tui --iterative
```
Press `q` to quit at any time. `--tui` needs an interactive terminal and the [`rich`](https://github.com/Textualize/rich) package (`pip3 install rich`) — it's the only optional dependency; everything else needs just `pyyaml`.
The `--release` flag (or the `release` field in the YAML) pins all `{release}` placeholders in GitHub URLs to a git tag, so `github:logos-co/repo{release}#output` becomes `github:logos-co/repo/tutorial-v2#output`. Set it to `""` or omit it for latest.
## Example Modules
+9
View File
@@ -438,6 +438,15 @@ When a chain runs (`requires:`), every tutorial in the chain is included; a drop
Pair `--report` with `--continue-on-fail` so the report captures the full run rather than stopping at the first failure. The CI workflow ([`.github/workflows/ci.yml`](../.github/workflows/ci.yml)) runs with `--report` and publishes the result to GitHub Pages, linked from a PR comment.
### Live TUI (`--tui` / `--iterative`)
`run --tui` shows the same two-column view live in the terminal instead of (or in addition to) writing an HTML file. The left pane renders the current step's tutorial markdown; the right pane shows the command being executed and its output, updating as the run proceeds. Execution goes through the exact same handlers as a normal run, so behaviour is identical — only the presentation differs.
- **Auto mode (default):** steps execute back-to-back; failed steps pause briefly so they're readable.
- **`--iterative`:** the runner waits for a keypress before executing each step — **down arrow**, **right arrow**, or **space** to advance; **`q`** to quit. (Using `--iterative` without `--tui` is an error.)
`--tui` requires an interactive terminal (a TTY) and the [`rich`](https://github.com/Textualize/rich) package. `rich` is an **optional** dependency used only for the TUI; if it's not installed the runner prints an install hint and exits. All other functionality needs only `pyyaml`.
## Generator behavior
- Walks the same YAML structure
+343 -11
View File
@@ -18,6 +18,8 @@ Run options:
(standalone only; requires: chains are not run)
--report <path> Write a two-column HTML report (rendered tutorial +
the commands actually run and their output) to <path>
--tui Run in an interactive two-column TUI (needs 'rich')
--iterative With --tui, advance one step per keypress (arrow/space)
--verbose Print commands as they execute
--basecamp-bin <path> Path to LogosBasecamp binary (for basecamp sections)
--qt-mcp <path> Path to logos-qt-mcp package (for basecamp/ui_test sections)
@@ -876,6 +878,25 @@ def find_module_name(spec):
# RUN COMMAND
# ══════════════════════════════════════════════════════════════════════════════
def _dispatch_step(step, workdir, results, args, override_flags, spec):
"""Execute one step via the appropriate handler. Returns True if the step
was an executable action (file/run/ui_test/check_file), False if it was
prose-only. Shared by the normal run loop and the TUI so execution logic
never diverges between them."""
if step.get("file"):
handle_file(step, workdir, results, args.verbose)
elif step.get("run"):
handle_run(step, workdir, results, args.verbose, override_flags)
elif step.get("ui_test"):
handle_ui_test(step, workdir, results, args.verbose,
override_flags, args.qt_mcp, spec)
elif step.get("check_file"):
handle_check_file(step, workdir, results, args.verbose)
else:
return False
return True
def run_single_spec(spec, spec_path, workdir, args, results):
"""Run a single tutorial spec in the given workdir. May raise StopEarly."""
spec_dir = os.path.dirname(spec_path)
@@ -906,6 +927,12 @@ def run_single_spec(spec, spec_path, workdir, args, results):
"release": release or "(none)",
})
# In TUI mode, hand off to the rich-driven two-column view. It walks the
# same spec and uses the same _dispatch_step path, so execution is identical.
if getattr(args, "tui", False):
run_tui(_REPORT, spec, spec_path, workdir, args, results, override_flags)
return
sections = spec.get("sections", [])
for si, section in enumerate(sections):
@@ -942,16 +969,9 @@ def run_single_spec(spec, spec_path, workdir, args, results):
continue
for step in steps:
if step.get("file"):
handle_file(step, workdir, results, args.verbose)
elif step.get("run"):
handle_run(step, workdir, results, args.verbose, override_flags)
elif step.get("ui_test"):
handle_ui_test(step, workdir, results, args.verbose,
override_flags, args.qt_mcp, spec)
elif step.get("check_file"):
handle_check_file(step, workdir, results, args.verbose)
elif args.verbose:
if _dispatch_step(step, workdir, results, args, override_flags, spec):
continue
if args.verbose:
title = step.get("title", "untitled")
print(f" {dim(f'(prose-only step: {title})')}")
@@ -969,8 +989,27 @@ def cmd_run(args):
fail_fast = not args.continue_on_fail
results = Results(fail_fast=fail_fast)
tui = getattr(args, "tui", False)
if getattr(args, "iterative", False) and not tui:
print("ERROR: --iterative only applies with --tui", file=sys.stderr)
sys.exit(2)
if tui:
try:
import rich # noqa: F401
except ImportError:
print("ERROR: --tui requires the 'rich' package. Install it with:\n"
" pip3 install rich", file=sys.stderr)
sys.exit(2)
if not sys.stdin.isatty() or not sys.stdout.isatty():
print("ERROR: --tui requires an interactive terminal (a TTY).",
file=sys.stderr)
sys.exit(2)
global _REPORT
if getattr(args, "report", None):
# The TUI reads per-step execution records from the collector, so it needs
# one active even without --report.
if getattr(args, "report", None) or tui:
_REPORT = ReportCollector()
output_dir = getattr(args, "output_dir", None)
@@ -1267,6 +1306,291 @@ def build_report_model(collector):
return tutorials
# ══════════════════════════════════════════════════════════════════════════════
# TUI (run --tui)
# ══════════════════════════════════════════════════════════════════════════════
def _read_single_key():
"""Block for one keypress, return a normalized name: 'next', 'quit', or ''.
Arrow keys / space / enter advance; q / Ctrl-C quit."""
import termios, tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
if ch == "\x1b": # escape sequence (arrow keys)
seq = sys.stdin.read(2)
# Down (\x1b[B) / Right (\x1b[C) advance; others ignored
if seq in ("[B", "[C"):
return "next"
return ""
if ch in ("q", "Q", "\x03"): # q or Ctrl-C
return "quit"
if ch in (" ", "\r", "\n", "j", "l"):
return "next"
return ""
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _iter_tui_units(spec):
"""Yield render units for the TUI in execution order. Each unit is a dict:
{kind: 'preamble'|'section'|'step', md: <markdown>, step: <step or None>}
Mirrors build_report_model's walk so the left pane matches the tutorial."""
units = []
# Preamble (title + intro + objectives + prerequisites)
pre = [f"# {spec.get('name', 'Tutorial')}", ""]
if spec.get("intro"):
pre += [spec["intro"].rstrip("\n"), ""]
if spec.get("what_you_build"):
pre += [f"**What you'll build:** {spec['what_you_build']}", ""]
if spec.get("prerequisites"):
pre += ["## Prerequisites", ""] + [f"- {p}" for p in spec["prerequisites"]]
units.append({"kind": "preamble", "md": "\n".join(pre).strip(), "step": None})
step_number = 1
for section in spec.get("sections", []):
is_step = section.get("step", False)
units.append({
"kind": "section",
"md": _section_preamble_markdown(section, step_number, is_step),
"step": None,
})
if is_step:
step_number += 1
sec_num = (step_number - 1) if is_step else None
sub_step = 1
for step in section.get("steps", []):
md, sub_step = _step_to_markdown(step, sec_num, sub_step)
if not md:
md = "*(verification step)*"
units.append({"kind": "step", "md": md, "step": step})
return units
def _pending_commands(step):
"""What this step is *about* to execute, derived from the step dict (the
collector only has records *after* a handler runs). Returns a list of
(kind, command_text) so the right pane can show the actual command while
it's still running, instead of a bare 'running…'."""
cmds = []
if step.get("file"):
path = expand_vars(step["file"].get("path", ""))
cmds.append(("file", f"write {path}"))
if step.get("run"):
cmds.append(("run", expand_vars(step["run"])))
extra = step.get("extra_run", {})
if extra.get("run"):
cmds.append(("run", expand_vars(extra["run"])))
if step.get("check_file"):
cmds.append(("check_file", f"check file: {expand_vars(step['check_file'])}"))
ui = step.get("ui_test", {})
if ui:
if ui.get("launch"):
cmds.append(("ui_test", expand_vars(ui["launch"])))
elif ui.get("build"):
cmds.append(("ui_test", expand_vars(ui["build"])))
n = len(ui.get("tests", []))
if n:
cmds.append(("ui_test", f"# then run {n} UI test action(s)"))
return cmds
def _running_renderable(step):
"""Right-pane content shown *while* a step executes: a clear 'Running'
banner followed by the exact command(s) about to run."""
from rich.console import Group
from rich.text import Text
from rich.syntax import Syntax
parts = [Text("running…", style="bold yellow")]
pending = _pending_commands(step)
if pending:
for kind, cmd in pending:
label = Text()
label.append(" RUN ", style="bold black on yellow")
label.append(f" {kind}", style="dim")
parts.append(label)
parts.append(Syntax(cmd, "bash", theme="ansi_dark",
word_wrap=True, background_color="default"))
else:
parts.append(Text("(preparing…)", style="dim italic"))
return Group(*parts)
def _execs_to_renderable(execs, running_label=None):
"""Build a rich renderable for the right pane from exec records."""
from rich.console import Group
from rich.panel import Panel
from rich.text import Text
from rich.syntax import Syntax
parts = []
if running_label is not None:
parts.append(Text(running_label, style="bold yellow"))
for e in execs:
status = e.get("status", "info")
color = {"pass": "green", "fail": "red"}.get(status, "yellow")
head = Text()
head.append(f" {status.upper()} ", style=f"bold white on {color}")
head.append(f" {e.get('kind', '')}", style="dim")
if e.get("exit_code") is not None:
head.append(f" exit {e['exit_code']}", style="dim")
parts.append(head)
if e.get("note"):
parts.append(Text(e["note"], style="italic dim"))
if e.get("cmd"):
parts.append(Syntax(e["cmd"], "bash", theme="ansi_dark",
word_wrap=True, background_color="default"))
out = (e.get("output") or "").strip()
if out:
# Show a tail so long build logs don't blow past the pane.
lines = out.splitlines()
if len(lines) > 30:
out = "...\n" + "\n".join(lines[-30:])
parts.append(Text(out, style="grey70"))
parts.append(Text(""))
if not parts:
parts.append(Text("(no commands for this step)", style="dim italic"))
return Group(*parts)
def run_tui(collector, spec, spec_path, workdir, args, results, override_flags):
"""Drive execution of one spec inside a two-column rich TUI.
Left pane: rendered markdown for the current unit. Right pane: the commands
executed for the current step and their captured output. Execution goes
through the same _dispatch_step path as a normal run; handler stdout/stderr
is captured to a temp file so it doesn't corrupt the display."""
from rich.console import Console, Group
from rich.layout import Layout
from rich.live import Live
from rich.markdown import Markdown
from rich.panel import Panel
from rich.text import Text
console = Console()
units = _iter_tui_units(spec)
spec_rel = os.path.relpath(spec_path) if spec_path else "(spec)"
tutorial_name = spec.get("name", "Tutorial")
def make_layout(unit, right_renderable, footer, right_title="Executed"):
layout = Layout()
layout.split_column(
Layout(name="header", size=1),
Layout(name="body", ratio=1),
Layout(name="footer", size=1),
)
header = Text()
header.append(" spec: ", style="bold cyan")
header.append(spec_rel, style="bold white")
header.append(f" {tutorial_name}", style="dim")
layout["header"].update(header)
layout["body"].split_row(
Layout(Panel(Markdown(unit["md"] or ""), title="Tutorial",
border_style="cyan"), name="left"),
Layout(Panel(right_renderable, title=right_title,
border_style="magenta"), name="right"),
)
layout["footer"].update(Text(footer, style="dim"))
return layout
iterative = getattr(args, "iterative", False)
keyhint = "[↓/→/space] next [q] quit" if iterative else "[q] quit (auto-advancing)"
quit_requested = {"v": False}
with Live(console=console, screen=True, auto_refresh=False, transient=False) as live:
for i, unit in enumerate(units):
title = unit.get("md", "").splitlines()[0] if unit.get("md") else ""
footer = f"{i + 1}/{len(units)} {keyhint}"
if unit["kind"] != "step" or unit["step"] is None:
# Prose-only: show it, then advance (wait for key if iterative).
live.update(make_layout(unit, Text("", style="dim"), footer), refresh=True)
if iterative and not _wait_or_quit(live, quit_requested):
break
continue
step = unit["step"]
# Show the step with the actual command(s) about to run before
# executing, so it's clear *what* is running (not just "running…").
live.update(make_layout(unit, _running_renderable(step),
footer, right_title="Running"), refresh=True)
# Execute via the shared dispatch path, capturing handler output so
# prints/subprocess noise don't corrupt the Live display.
try:
with _capture_fds():
_dispatch_step(step, workdir, results, args, override_flags, spec)
except StopEarly:
# Surface the failure in the pane, then stop.
live.update(make_layout(unit, _execs_to_renderable(
collector.execs_for(step)), footer + " — stopped (fail-fast)"),
refresh=True)
if iterative:
_wait_or_quit(live, quit_requested)
raise
live.update(make_layout(unit, _execs_to_renderable(
collector.execs_for(step)), footer), refresh=True)
if iterative:
if not _wait_or_quit(live, quit_requested):
break
else:
# Brief pause so fast steps are perceptible; longer if it failed.
failed = any(e.get("status") == "fail" for e in collector.execs_for(step))
time.sleep(1.2 if failed else 0.35)
if not quit_requested["v"]:
# Final summary screen.
s = results
summary = Text()
summary.append(f"\n {s.passed} passed", style="bold green")
if s.failed:
summary.append(f" {s.failed} failed", style="bold red")
if s.skipped:
summary.append(f" {s.skipped} skipped", style="bold yellow")
summary.append("\n\n Press any key to exit.", style="dim")
live.update(Panel(summary, title="Done", border_style="cyan"), refresh=True)
_read_single_key()
def _wait_or_quit(live, quit_requested):
"""Block for a keypress in iterative mode. Returns False if the user quit."""
key = _read_single_key()
if key == "quit":
quit_requested["v"] = True
return False
return True
import contextlib
@contextlib.contextmanager
def _capture_fds():
"""Redirect stdout+stderr (at the fd level, so subprocesses are caught too)
to a temp file for the duration of the block. Keeps handler/subprocess
output from corrupting the rich Live display."""
import tempfile as _tf
saved_out, saved_err = os.dup(1), os.dup(2)
tmp = _tf.TemporaryFile(mode="w+b")
try:
os.dup2(tmp.fileno(), 1)
os.dup2(tmp.fileno(), 2)
yield tmp
finally:
os.dup2(saved_out, 1)
os.dup2(saved_err, 2)
os.close(saved_out)
os.close(saved_err)
tmp.close()
def write_html_report(collector, output_path, results):
import json as _json
model = build_report_model(collector)
@@ -1766,6 +2090,14 @@ def main():
run_parser.add_argument("--report", default=None, metavar="PATH",
help="Write a two-column HTML report (rendered tutorial + "
"the commands actually run and their output) to PATH")
run_parser.add_argument("--tui", action="store_true",
help="Run in an interactive two-column TUI (left: rendered "
"tutorial, right: the command running and its output). "
"Requires the 'rich' package.")
run_parser.add_argument("--iterative", action="store_true",
help="With --tui, wait for a keypress (down/right arrow or "
"space) before executing each step instead of "
"auto-advancing")
# ── generate ──────────────────────────────────────────────────────────
gen_parser = subparsers.add_parser("generate", help="Generate markdown from a spec")