|
4 | 4 | import json |
5 | 5 | import math |
6 | 6 | import os |
| 7 | +import shutil |
7 | 8 | import sys |
| 9 | +import threading |
| 10 | +from contextlib import contextmanager |
8 | 11 | from pathlib import Path |
9 | | -from typing import Any, Dict, Optional, Sequence |
| 12 | +from typing import Any, Callable, Dict, Iterator, Optional, Sequence |
10 | 13 |
|
11 | 14 | from . import AuthenticationError, ExecutionTimeout, Subfork, SubforkError, __version__ |
12 | 15 |
|
13 | 16 |
|
| 17 | +@contextmanager |
| 18 | +def execution_status(graph_name: str) -> Iterator[Callable[[Dict[str, Any]], None]]: |
| 19 | + """Animate the active nodes on terminal stderr while preserving JSON stdout.""" |
| 20 | + stream = sys.stderr |
| 21 | + terminal = stream.isatty() and os.environ.get("TERM") != "dumb" |
| 22 | + lock = threading.Lock() |
| 23 | + label = "Graph " + graph_name |
| 24 | + status = "Running" |
| 25 | + titles: Dict[str, str] = {} |
| 26 | + stopped = threading.Event() |
| 27 | + frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" |
| 28 | + try: |
| 29 | + frames.encode(stream.encoding or "ascii") |
| 30 | + except UnicodeError: |
| 31 | + frames = "|/-\\" |
| 32 | + |
| 33 | + def update(snapshot: Dict[str, Any]) -> None: |
| 34 | + """Replace the display state with active node titles from a snapshot.""" |
| 35 | + nonlocal label, status |
| 36 | + definition = snapshot.get("definition") or {} |
| 37 | + for node in definition.get("nodes", []): |
| 38 | + titles[node["node_instance_id"]] = node.get("title") or node["node_instance_id"] |
| 39 | + nodes = snapshot.get("node_executions") or {} |
| 40 | + active = [ |
| 41 | + titles.get(key, key) for key, node in nodes.items() if node.get("status") == "running" |
| 42 | + ] |
| 43 | + with lock: |
| 44 | + label = ( |
| 45 | + ("Node " if len(active) == 1 else "Nodes ") + ", ".join(active) |
| 46 | + if active |
| 47 | + else "Graph " + graph_name |
| 48 | + ) |
| 49 | + status = ( |
| 50 | + "Running" |
| 51 | + if active |
| 52 | + else str(snapshot.get("status", "running")).replace("_", " ").title() |
| 53 | + ) |
| 54 | + |
| 55 | + def render(index: int) -> None: |
| 56 | + """Draw one width-limited line with a yellow spinner and green run status.""" |
| 57 | + with lock: |
| 58 | + name = "".join(char if char.isprintable() else " " for char in label) |
| 59 | + state = "".join(char if char.isprintable() else " " for char in status) |
| 60 | + width = max(1, shutil.get_terminal_size().columns - 1) |
| 61 | + name = name[: max(0, width - len(state) - 7)] |
| 62 | + dots = "." * max(3, min(10, width - len(name) - len(state) - 4)) |
| 63 | + frame = frames[index % len(frames)] |
| 64 | + plain = "{} {} {} {}".format(frame, name, dots, state) |
| 65 | + if len(plain) > width: |
| 66 | + line = plain[:width] |
| 67 | + elif os.environ.get("NO_COLOR"): |
| 68 | + line = plain |
| 69 | + else: |
| 70 | + colored_state = "\033[32m" + state + "\033[0m" if state == "Running" else state |
| 71 | + line = "\033[33m{}\033[0m {} {} {}".format(frame, name, dots, colored_state) |
| 72 | + stream.write("\r\033[2K" + line) |
| 73 | + stream.flush() |
| 74 | + |
| 75 | + def animate() -> None: |
| 76 | + """Refresh the spinner independently of network requests and polling.""" |
| 77 | + index = 1 |
| 78 | + while not stopped.wait(0.1): |
| 79 | + try: |
| 80 | + render(index) |
| 81 | + except (OSError, ValueError): |
| 82 | + return |
| 83 | + index += 1 |
| 84 | + |
| 85 | + if not terminal: |
| 86 | + safe_name = "".join(char if char.isprintable() else " " for char in graph_name) |
| 87 | + print("Graph {} .......... Running".format(safe_name), file=stream) |
| 88 | + yield update |
| 89 | + return |
| 90 | + worker = threading.Thread(target=animate, name="subfork-spinner", daemon=True) |
| 91 | + try: |
| 92 | + render(0) |
| 93 | + worker.start() |
| 94 | + yield update |
| 95 | + finally: |
| 96 | + stopped.set() |
| 97 | + if worker.ident is not None: |
| 98 | + worker.join() |
| 99 | + stream.write("\r\033[2K") |
| 100 | + stream.flush() |
| 101 | + |
| 102 | + |
14 | 103 | def build_parser() -> argparse.ArgumentParser: |
15 | 104 | """Build the public command tree without opening a connection.""" |
16 | 105 | parser = argparse.ArgumentParser(description=__doc__) |
@@ -128,10 +217,20 @@ def graph_command(client: Subfork, args: argparse.Namespace) -> Any: |
128 | 217 | raise ValueError( |
129 | 218 | "Submission returned no execution ID; inspect remote state before retrying." |
130 | 219 | ) |
131 | | - print("subfork: waiting for execution {}".format(execution_id), file=sys.stderr) |
132 | | - result = client.executions.wait( |
133 | | - execution_id, timeout=args.wait_timeout, poll_interval=args.poll_interval |
| 220 | + snapshot_definition = result.get("definition") |
| 221 | + graph_name = ( |
| 222 | + snapshot_definition.get("name") if isinstance(snapshot_definition, dict) else None |
134 | 223 | ) |
| 224 | + with execution_status( |
| 225 | + graph_name if isinstance(graph_name, str) else args.graph_id |
| 226 | + ) as update: |
| 227 | + update(result) |
| 228 | + result = client.executions.wait( |
| 229 | + execution_id, |
| 230 | + timeout=args.wait_timeout, |
| 231 | + poll_interval=args.poll_interval, |
| 232 | + on_update=update, |
| 233 | + ) |
135 | 234 | return result |
136 | 235 |
|
137 | 236 |
|
|
0 commit comments