Skip to content

Commit d9e93ca

Browse files
committed
Add animated execution progress with active node names
1 parent 22ca1a4 commit d9e93ca

5 files changed

Lines changed: 213 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ Use `execute --no-wait` for a submission summary or `execute --raw` for the full
5858
execution snapshot. Waiting requires read and run scopes; submission alone requires
5959
run scope. `--wait-timeout` (default 120) and `--poll-interval` (default 2) are in
6060
seconds. A timeout stops waiting without canceling the remote run.
61+
A yellow spinner precedes `Graph <name> .......... Running`, with `Running` in
62+
green. As status snapshots arrive, the line shows the currently running node titles
63+
(or multiple titles for parallel nodes). Short-lived nodes may finish between polls.
64+
Set `NO_COLOR` to disable colors. Progress goes to stderr; stdout remains JSON.
65+
Redirected progress uses a single plain-text line.
6166

6267
Other commands include `get`, `published`, and `versions`. Use `--help` on any
6368
command. Node discovery and execution monitoring are available through the Python API.

‎src/subfork/cli.py‎

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,102 @@
44
import json
55
import math
66
import os
7+
import shutil
78
import sys
9+
import threading
10+
from contextlib import contextmanager
811
from pathlib import Path
9-
from typing import Any, Dict, Optional, Sequence
12+
from typing import Any, Callable, Dict, Iterator, Optional, Sequence
1013

1114
from . import AuthenticationError, ExecutionTimeout, Subfork, SubforkError, __version__
1215

1316

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+
14103
def build_parser() -> argparse.ArgumentParser:
15104
"""Build the public command tree without opening a connection."""
16105
parser = argparse.ArgumentParser(description=__doc__)
@@ -128,10 +217,20 @@ def graph_command(client: Subfork, args: argparse.Namespace) -> Any:
128217
raise ValueError(
129218
"Submission returned no execution ID; inspect remote state before retrying."
130219
)
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
134223
)
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+
)
135234
return result
136235

137236

‎src/subfork/resources.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import math
66
import time
7-
from typing import TYPE_CHECKING, Any, Dict, List, Optional
7+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
88
from urllib.parse import quote
99

1010
from .errors import ExecutionTimeout
@@ -173,7 +173,12 @@ def cancel(self, execution_id: str) -> Dict[str, Any]:
173173
return self._client._request("POST", "/executions/" + segment(execution_id) + "/cancel")
174174

175175
def wait(
176-
self, execution_id: str, *, timeout: float = 120, poll_interval: float = 2
176+
self,
177+
execution_id: str,
178+
*,
179+
timeout: float = 120,
180+
poll_interval: float = 2,
181+
on_update: Optional[Callable[[Dict[str, Any]], None]] = None,
177182
) -> Dict[str, Any]:
178183
"""Poll until a terminal status or the polling deadline.
179184
@@ -182,6 +187,8 @@ def wait(
182187
timeout: Positive deadline in seconds. Remaining time caps HTTP
183188
timeouts, which apply per I/O phase, not to the entire request.
184189
poll_interval: Positive delay in seconds between status requests.
190+
on_update: Optional synchronous callback for each retrieved snapshot.
191+
Callback exceptions propagate to the caller without canceling the run.
185192
186193
Returns:
187194
The terminal snapshot, including failed or canceled executions.
@@ -208,6 +215,8 @@ def wait(
208215
"/executions/" + segment(execution_id),
209216
timeout=min(self._client.timeout, remaining),
210217
)
218+
if on_update is not None:
219+
on_update(result)
211220
if result["status"] in {
212221
"completed",
213222
"failed",

‎tests/test_cli.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,82 @@ def timeout(*args: Any, **kwargs: Any) -> dict:
181181
"status": "failed" if mode == "failed" else "running",
182182
}
183183
assert len(seen) == (1 if mode == "no-wait" else 2)
184+
185+
186+
@pytest.mark.parametrize("interrupted", [False, True])
187+
@pytest.mark.parametrize("no_color", [False, True])
188+
def test_status_cleanup(monkeypatch: pytest.MonkeyPatch, interrupted: bool, no_color: bool) -> None:
189+
"""Show the graph name and restore terminal output on success or interruption."""
190+
191+
class Terminal(io.StringIO):
192+
"""Capture writes as an interactive terminal."""
193+
194+
def isatty(self) -> bool:
195+
"""Identify this stream as a terminal."""
196+
return True
197+
198+
terminal = Terminal()
199+
monkeypatch.setattr("sys.stderr", terminal)
200+
monkeypatch.setenv("TERM", "xterm")
201+
monkeypatch.setenv("NO_COLOR", "1" if no_color else "")
202+
try:
203+
with cli.execution_status("URL Extract Pipe"):
204+
expected = "Running" if no_color else "\033[32mRunning\033[0m"
205+
assert "Graph URL Extract Pipe .......... " + expected in terminal.getvalue()
206+
if interrupted:
207+
raise KeyboardInterrupt
208+
except KeyboardInterrupt:
209+
assert interrupted
210+
assert terminal.getvalue().endswith("\r\033[2K")
211+
212+
213+
def test_status_redirected_stderr(monkeypatch: pytest.MonkeyPatch) -> None:
214+
"""Keep redirected progress plain and sanitize control characters in names."""
215+
stream = io.StringIO()
216+
monkeypatch.setattr("sys.stderr", stream)
217+
with cli.execution_status("Greeting\nGraph"):
218+
pass
219+
assert stream.getvalue() == "Graph Greeting Graph .......... Running\n"
220+
221+
222+
def test_active_node_progress(monkeypatch: pytest.MonkeyPatch) -> None:
223+
"""Animate named parallel nodes and clean up the rendering thread."""
224+
import threading
225+
226+
refreshed = threading.Event()
227+
228+
class Terminal(io.StringIO):
229+
"""Capture animated frames and signal when node titles appear."""
230+
231+
def isatty(self) -> bool:
232+
"""Enable interactive progress rendering."""
233+
return True
234+
235+
def write(self, value: str) -> int:
236+
"""Signal a node frame without timing-dependent sleeps."""
237+
count = super().write(value)
238+
if "Nodes Fetch, Parse" in value:
239+
refreshed.set()
240+
return count
241+
242+
terminal = Terminal()
243+
monkeypatch.setattr("sys.stderr", terminal)
244+
monkeypatch.setenv("TERM", "xterm")
245+
monkeypatch.delenv("NO_COLOR", raising=False)
246+
with cli.execution_status("Pipeline") as update:
247+
update(
248+
{
249+
"status": "running",
250+
"definition": {
251+
"nodes": [
252+
{"node_instance_id": "a", "title": "Fetch"},
253+
{"node_instance_id": "b", "title": "Parse"},
254+
]
255+
},
256+
"node_executions": {"a": {"status": "running"}, "b": {"status": "running"}},
257+
}
258+
)
259+
assert refreshed.wait(2)
260+
assert "\033[33m" in terminal.getvalue()
261+
assert "\033[32mRunning\033[0m" in terminal.getvalue()
262+
assert not any(thread.name == "subfork-spinner" for thread in threading.enumerate())

‎tests/test_client.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,18 @@ def handle(request: httpx.Request) -> httpx.Response:
185185
with pytest.raises(ExecutionTimeout):
186186
client.executions.wait("e_test", timeout=1)
187187
assert len(seen) == 1 and seen[0].method == "GET"
188+
189+
190+
def test_wait_reports_snapshots() -> None:
191+
"""Deliver both active and terminal snapshots to an optional progress callback."""
192+
snapshots = []
193+
statuses = iter(["running", "completed"])
194+
with Subfork(
195+
"synthetic-key",
196+
transport=httpx.MockTransport(
197+
lambda request: httpx.Response(200, json={"status": next(statuses)})
198+
),
199+
) as client:
200+
result = client.executions.wait("e_test", poll_interval=0.001, on_update=snapshots.append)
201+
assert [snapshot["status"] for snapshot in snapshots] == ["running", "completed"]
202+
assert result == snapshots[-1]

0 commit comments

Comments
 (0)