@@ -23,7 +23,14 @@ def execution_status(graph_name: str) -> Iterator[Callable[[Dict[str, Any]], Non
2323 label = "Graph " + graph_name
2424 status = "Running"
2525 titles : Dict [str , str ] = {}
26+ reported : Dict [str , str ] = {}
27+ name_width = len (label )
2628 stopped = threading .Event ()
29+ completed_marker = "✓"
30+ try :
31+ completed_marker .encode (stream .encoding or "ascii" )
32+ except UnicodeError :
33+ completed_marker = "+"
2734 frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
2835 try :
2936 frames .encode (stream .encoding or "ascii" )
@@ -32,7 +39,7 @@ def execution_status(graph_name: str) -> Iterator[Callable[[Dict[str, Any]], Non
3239
3340 def update (snapshot : Dict [str , Any ]) -> None :
3441 """Replace the display state with active node titles from a snapshot."""
35- nonlocal label , status
42+ nonlocal label , status , name_width
3643 definition = snapshot .get ("definition" ) or {}
3744 for node in definition .get ("nodes" , []):
3845 titles [node ["node_instance_id" ]] = node .get ("title" ) or node ["node_instance_id" ]
@@ -41,6 +48,43 @@ def update(snapshot: Dict[str, Any]) -> None:
4148 titles .get (key , key ) for key , node in nodes .items () if node .get ("status" ) == "running"
4249 ]
4350 with lock :
51+ name_width = max (
52+ [name_width ]
53+ + [len ("Node " + title ) for title in titles .values ()]
54+ + [len ("Node " + key ) for key in nodes if key not in titles ]
55+ )
56+ for key , node in nodes .items ():
57+ outcome = node .get ("status" )
58+ if outcome not in {
59+ "completed" ,
60+ "failed" ,
61+ "canceled" ,
62+ "cancelled" ,
63+ "skipped" ,
64+ "outcome_unknown" ,
65+ }:
66+ continue
67+ if reported .get (key ) == outcome :
68+ continue
69+ reported [key ] = outcome
70+ state = "Canceled" if outcome == "cancelled" else outcome .replace ("_" , " " ).title ()
71+ name = "Node " + titles .get (key , key )
72+ if terminal :
73+ stream .write (
74+ "\r \033 [2K"
75+ + format_line (
76+ completed_marker if outcome == "completed" else "-" , name , state
77+ )
78+ + "\n "
79+ )
80+ else :
81+ stream .write (
82+ format_line (
83+ completed_marker if outcome == "completed" else "-" , name , state
84+ )
85+ + "\n "
86+ )
87+ stream .flush ()
4488 label = (
4589 ("Node " if len (active ) == 1 else "Nodes " ) + ", " .join (active )
4690 if active
@@ -52,25 +96,30 @@ def update(snapshot: Dict[str, Any]) -> None:
5296 else str (snapshot .get ("status" , "running" )).replace ("_" , " " ).title ()
5397 )
5498
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 )
99+ def format_line (frame : str , name : str , state : str ) -> str :
100+ """Format a width-limited progress row without splitting color escapes."""
101+ name = "" .join (char if char .isprintable () else " " for char in name )
102+ state = "" .join (char if char .isprintable () else " " for char in state )
60103 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 )]
104+ # Reserve space for the longest status so different outcomes align too.
105+ status_column = min (name_width + 14 , max (7 , width - len ("Outcome Unknown" )))
106+ name = name [: max (0 , status_column - 7 )]
107+ dots = "." * max (3 , status_column - len (name ) - 4 )
64108 plain = "{} {} {} {}" .format (frame , name , dots , state )
65109 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 ()
110+ return plain [:width ]
111+ if not terminal or os .environ .get ("NO_COLOR" ):
112+ return plain
113+ color = "32" if state in {"Running" , "Completed" } else "31" if state == "Failed" else "33"
114+ colored_state = "\033 [" + color + "m" + state + "\033 [0m"
115+ marker_color = "32" if state == "Completed" else "33"
116+ return "\033 [{}m{}\033 [0m {} {} {}" .format (marker_color , frame , name , dots , colored_state )
117+
118+ def render (index : int ) -> None :
119+ """Draw the active row without interleaving retained completion lines."""
120+ with lock :
121+ stream .write ("\r \033 [2K" + format_line (frames [index % len (frames )], label , status ))
122+ stream .flush ()
74123
75124 def animate () -> None :
76125 """Refresh the spinner independently of network requests and polling."""
@@ -122,6 +171,9 @@ def build_parser() -> argparse.ArgumentParser:
122171 child .add_argument ("--comment" , default = "" )
123172 elif command == "execute" :
124173 child .add_argument ("--version" , default = "draft" )
174+ child .add_argument (
175+ "-o" , "--out" , help = "Write result JSON to a new file instead of stdout"
176+ )
125177 child .add_argument (
126178 "--no-wait" , action = "store_true" , help = "Return submission status immediately"
127179 )
@@ -205,13 +257,14 @@ def graph_command(client: Subfork, args: argparse.Namespace) -> Any:
205257 ):
206258 raise ValueError ("Wait timeout and poll interval must be positive finite numbers." )
207259 result = graphs .execute (args .graph_id , version = args .version , inputs = inputs )
208- if not args . no_wait and result . get ( "status" ) not in {
260+ terminal_statuses = {
209261 "completed" ,
210262 "failed" ,
211263 "canceled" ,
212264 "cancelled" ,
213265 "outcome_unknown" ,
214- }:
266+ }
267+ if not args .no_wait :
215268 execution_id = result .get ("id" ) or result .get ("execution_id" )
216269 if not isinstance (execution_id , str ) or not execution_id :
217270 raise ValueError (
@@ -225,12 +278,13 @@ def graph_command(client: Subfork, args: argparse.Namespace) -> Any:
225278 graph_name if isinstance (graph_name , str ) else args .graph_id
226279 ) as update :
227280 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- )
281+ if result .get ("status" ) not in terminal_statuses :
282+ result = client .executions .wait (
283+ execution_id ,
284+ timeout = args .wait_timeout ,
285+ poll_interval = args .poll_interval ,
286+ on_update = update ,
287+ )
234288 return result
235289
236290
@@ -242,6 +296,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
242296 """
243297 args = build_parser ().parse_args (argv )
244298 try :
299+ result_file = getattr (args , "out" , None )
300+ if result_file is not None and Path (result_file ).exists ():
301+ raise ValueError ("Output file already exists; choose a new path." )
245302 with Subfork (base_url = args .base_url , timeout = args .timeout ) as client :
246303 result = graph_command (client , args )
247304 failed = args .command == "execute" and result .get ("status" ) in {
@@ -261,11 +318,13 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
261318 display = result .get ("outputs" , {})
262319 rendered = json .dumps (display , indent = 2 , ensure_ascii = False ) + "\n "
263320 output = getattr (args , "output" , "-" )
264- if output == "-" :
321+ if result_file is None and output == "-" :
265322 sys .stdout .write (rendered )
266323 else :
267- # Exclusive creation avoids silently overwriting a local definition.
268- with Path (output ).open ("x" , encoding = "utf-8" , newline = "\n " ) as stream :
324+ # Exclusive creation avoids silently overwriting an existing file.
325+ with Path (result_file if result_file is not None else output ).open (
326+ "x" , encoding = "utf-8" , newline = "\n "
327+ ) as stream :
269328 stream .write (rendered )
270329 if failed :
271330 print (
0 commit comments