33import inspect
44import io
55import os
6+ import sys
67import time
78from sys import version as python_version
89from typing import TYPE_CHECKING , Any , Callable , Dict , Optional , Tuple
2223from .base import Backend
2324
2425
26+ def _supports_color ():
27+ """Check if the terminal supports color output."""
28+ if os .environ .get ("NO_COLOR" ):
29+ return False
30+ if os .environ .get ("FORCE_COLOR" ):
31+ return True
32+ # IPython/Jupyter notebooks support ANSI colors
33+ if __IPYTHON__ :
34+ return True
35+ if not hasattr (sys .stdout , "isatty" ) or not sys .stdout .isatty ():
36+ return False
37+ return True
38+
39+
40+ _SUPPORTS_COLOR = _supports_color ()
41+
42+
43+ class JobStatusDisplay :
44+ """Manages single-line status display for remote job execution."""
45+
46+ # ANSI color codes
47+ class Colors :
48+ RESET = "\033 [0m" if _SUPPORTS_COLOR else ""
49+ BOLD = "\033 [1m" if _SUPPORTS_COLOR else ""
50+ DIM = "\033 [2m" if _SUPPORTS_COLOR else ""
51+ CYAN = "\033 [36m" if _SUPPORTS_COLOR else ""
52+ YELLOW = "\033 [33m" if _SUPPORTS_COLOR else ""
53+ GREEN = "\033 [32m" if _SUPPORTS_COLOR else ""
54+ RED = "\033 [31m" if _SUPPORTS_COLOR else ""
55+ MAGENTA = "\033 [35m" if _SUPPORTS_COLOR else ""
56+ BLUE = "\033 [34m" if _SUPPORTS_COLOR else ""
57+ WHITE = "\033 [37m" if _SUPPORTS_COLOR else ""
58+
59+ # Status icons (Unicode)
60+ class Icons :
61+ RECEIVED = "◉"
62+ QUEUED = "◎"
63+ DISPATCHED = "◈"
64+ RUNNING = "●"
65+ COMPLETED = "✓"
66+ ERROR = "✗"
67+ LOG = "ℹ"
68+ STREAM = "⇄"
69+ SPINNER = ["⠋" , "⠙" , "⠹" , "⠸" , "⠼" , "⠴" , "⠦" , "⠧" , "⠇" , "⠏" ]
70+
71+ def __init__ (self , enabled : bool = True , verbose : bool = False ):
72+ self .enabled = enabled
73+ self .verbose = verbose
74+ self .status_start_time : Optional [float ] = None
75+ self .job_id : Optional [str ] = None
76+ self .spinner_idx = 0
77+ self .last_status = None
78+ self ._line_written = False
79+ self ._notebook_display_id : Optional [str ] = None
80+
81+ def _format_elapsed (self ) -> str :
82+ """Format elapsed time in current status."""
83+ if self .status_start_time is None :
84+ return "0.0s"
85+ elapsed = time .time () - self .status_start_time
86+ if elapsed < 60 :
87+ return f"{ elapsed :.1f} s"
88+ elif elapsed < 3600 :
89+ mins = int (elapsed // 60 )
90+ secs = elapsed % 60
91+ return f"{ mins } m { secs :.0f} s"
92+ else :
93+ hours = int (elapsed // 3600 )
94+ mins = int ((elapsed % 3600 ) // 60 )
95+ return f"{ hours } h { mins } m"
96+
97+ def _get_status_style (self , status_name : str ) -> tuple :
98+ """Get icon and color for a status."""
99+ status_map = {
100+ "RECEIVED" : (self .Icons .RECEIVED , self .Colors .CYAN ),
101+ "QUEUED" : (self .Icons .QUEUED , self .Colors .YELLOW ),
102+ "DISPATCHED" : (self .Icons .DISPATCHED , self .Colors .MAGENTA ),
103+ "RUNNING" : (self .Icons .RUNNING , self .Colors .BLUE ),
104+ "COMPLETED" : (self .Icons .COMPLETED , self .Colors .GREEN ),
105+ "ERROR" : (self .Icons .ERROR , self .Colors .RED ),
106+ "NNSIGHT_ERROR" : (self .Icons .ERROR , self .Colors .RED ),
107+ "LOG" : (self .Icons .LOG , self .Colors .DIM ),
108+ "STREAM" : (self .Icons .STREAM , self .Colors .CYAN ),
109+ }
110+ return status_map .get (status_name , ("•" , self .Colors .WHITE ))
111+
112+ def _get_spinner (self ) -> str :
113+ """Get next spinner frame."""
114+ spinner = self .Icons .SPINNER [self .spinner_idx % len (self .Icons .SPINNER )]
115+ self .spinner_idx += 1
116+ return spinner
117+
118+ def update (self , job_id : str , status_name : str , description : str = "" ):
119+ """Update the status display on a single line."""
120+ if not self .enabled :
121+ return
122+
123+ status_changed = status_name != self .last_status
124+
125+ # Reset timer when status changes
126+ if status_changed :
127+ self .status_start_time = time .time ()
128+ self .job_id = job_id
129+
130+ icon , color = self ._get_status_style (status_name )
131+ elapsed = self ._format_elapsed ()
132+
133+ # Build the status line
134+ # Format: ● STATUS (elapsed) [job_id] description
135+
136+ is_terminal = status_name in ("COMPLETED" , "ERROR" , "NNSIGHT_ERROR" )
137+ is_active = status_name in ("QUEUED" , "RUNNING" , "DISPATCHED" )
138+
139+ # For active states, show spinner
140+ if is_active :
141+ prefix = f"{ self .Colors .DIM } { self ._get_spinner ()} { self .Colors .RESET } "
142+ else :
143+ prefix = f"{ color } { icon } { self .Colors .RESET } "
144+
145+ # Build status text - full job ID shown so users can reference it
146+ status_text = (
147+ f"{ prefix } "
148+ f"{ self .Colors .DIM } [{ job_id } ]{ self .Colors .RESET } "
149+ f"{ color } { self .Colors .BOLD } { status_name .ljust (10 )} { self .Colors .RESET } "
150+ f"{ self .Colors .DIM } ({ elapsed } ){ self .Colors .RESET } "
151+ )
152+
153+ if description :
154+ status_text += f" { self .Colors .DIM } { description } { self .Colors .RESET } "
155+
156+ # Display the status
157+ self ._display (status_text , status_changed , is_terminal )
158+
159+ self ._line_written = True
160+ self .last_status = status_name
161+
162+ def _display (self , text : str , status_changed : bool , is_terminal : bool ):
163+ """Display text, handling terminal vs notebook environments."""
164+ if __IPYTHON__ :
165+ self ._display_notebook (text , status_changed , is_terminal )
166+ else :
167+ self ._display_terminal (text , status_changed , is_terminal )
168+
169+ def _display_terminal (self , text : str , status_changed : bool , is_terminal : bool ):
170+ """Display in terminal with in-place updates."""
171+ # In verbose mode, print new line when status changes
172+ if self .verbose and status_changed and self ._line_written :
173+ sys .stdout .write ("\n " )
174+ else :
175+ # Clear current line for in-place update
176+ sys .stdout .write ("\r \033 [K" )
177+
178+ sys .stdout .write (text )
179+
180+ if is_terminal :
181+ sys .stdout .write ("\n " )
182+
183+ sys .stdout .flush ()
184+
185+ def _ansi_to_html (self , text : str ) -> str :
186+ """Convert ANSI color codes to HTML spans."""
187+ import re
188+
189+ # Map ANSI codes to CSS styles
190+ ansi_to_css = {
191+ "0" : "" , # Reset
192+ "1" : "font-weight:bold" , # Bold
193+ "2" : "opacity:0.7" , # Dim
194+ "31" : "color:#e74c3c" , # Red
195+ "32" : "color:#2ecc71" , # Green
196+ "33" : "color:#f39c12" , # Yellow
197+ "34" : "color:#3498db" , # Blue
198+ "35" : "color:#9b59b6" , # Magenta
199+ "36" : "color:#00bcd4" , # Cyan
200+ "37" : "color:#ecf0f1" , # White
201+ }
202+
203+ result = []
204+ open_spans = 0
205+ i = 0
206+
207+ while i < len (text ):
208+ # Match ANSI escape sequence
209+ match = re .match (r"\x1b\[([0-9;]+)m" , text [i :])
210+ if match :
211+ codes = match .group (1 ).split (";" )
212+ for code in codes :
213+ if code == "0" :
214+ # Close all open spans
215+ result .append ("</span>" * open_spans )
216+ open_spans = 0
217+ elif code in ansi_to_css and ansi_to_css [code ]:
218+ result .append (f'<span style="{ ansi_to_css [code ]} ">' )
219+ open_spans += 1
220+ i += len (match .group (0 ))
221+ else :
222+ # Escape HTML special chars
223+ char = text [i ]
224+ if char == "<" :
225+ result .append ("<" )
226+ elif char == ">" :
227+ result .append (">" )
228+ elif char == "&" :
229+ result .append ("&" )
230+ else :
231+ result .append (char )
232+ i += 1
233+
234+ # Close any remaining spans
235+ result .append ("</span>" * open_spans )
236+ return "" .join (result )
237+
238+ def _display_notebook (self , text : str , status_changed : bool , is_terminal : bool ):
239+ """Display in notebook using display_id for flicker-free updates."""
240+ from IPython .display import display , update_display , HTML
241+
242+ html_text = self ._ansi_to_html (text )
243+ html_content = HTML (
244+ f'<pre style="margin:0;font-family:monospace;background:transparent;">{ html_text } </pre>'
245+ )
246+
247+ if self .verbose and status_changed and self ._line_written :
248+ # Verbose mode: create new display for new status, keep old one visible
249+ self ._notebook_display_id = f"nnsight_status_{ id (self )} _{ time .time ()} "
250+ display (html_content , display_id = self ._notebook_display_id )
251+ elif self ._notebook_display_id is None :
252+ # First display
253+ self ._notebook_display_id = f"nnsight_status_{ id (self )} "
254+ display (html_content , display_id = self ._notebook_display_id )
255+ else :
256+ # Update existing display in place (no flicker)
257+ update_display (html_content , display_id = self ._notebook_display_id )
258+
259+ if is_terminal :
260+ # Reset for next job
261+ self ._notebook_display_id = None
262+
263+
25264class RemoteException (Exception ):
26265 pass
27266
@@ -44,6 +283,7 @@ def __init__(
44283 job_id : str = None ,
45284 api_key : str = "" ,
46285 callback : str = "" ,
286+ verbose : bool = False ,
47287 ) -> None :
48288
49289 self .model_key = model_key
@@ -65,6 +305,10 @@ def __init__(
65305 self .ws_address = "ws://" + self .address [7 :]
66306
67307 self .job_status = None
308+ self .status_display = JobStatusDisplay (
309+ enabled = CONFIG .APP .REMOTE_LOGGING ,
310+ verbose = verbose ,
311+ )
68312
69313 def request (self , tracer : Tracer ) -> Tuple [bytes , Dict [str , str ]]:
70314
@@ -124,11 +368,16 @@ def handle_response(
124368 self .job_status = response .status
125369
126370 if response .status == ResponseModel .JobStatus .ERROR :
371+ self .status_display .update (
372+ response .id , response .status .name , response .description or ""
373+ )
127374 raise RemoteException (f"{ response .description } \n Remote exception." )
128375
129- # Log response for user
130- response .log ()
131- self .job_status = response .status
376+ # Log response for user (skip STREAM status - it's internal)
377+ if response .status != ResponseModel .JobStatus .STREAM :
378+ self .status_display .update (
379+ response .id , response .status .name , response .description or ""
380+ )
132381
133382 # If job is completed:
134383 if response .status == ResponseModel .JobStatus .COMPLETED :
@@ -310,8 +559,18 @@ def blocking_request(self, tracer: Tracer) -> Optional[RESULT]:
310559 # Loop until
311560 while True :
312561
313- # Get pickled bytes value from the websocket.
314- response = sio .receive ()[1 ]
562+ # Use timeout only when remote logging is enabled to update spinner/elapsed time
563+ timeout = 0.1 if CONFIG .APP .REMOTE_LOGGING else None
564+ try :
565+ response = sio .receive (timeout = timeout )[1 ]
566+ except socketio .exceptions .TimeoutError :
567+ # Refresh the status display to update spinner and elapsed time
568+ if self .job_id and self .job_status :
569+ self .status_display .update (
570+ self .job_id , self .job_status .name
571+ )
572+ continue
573+
315574 # Convert to pydantic object.
316575 response = ResponseModel .unpickle (response )
317576 # Handle the response.
@@ -365,8 +624,18 @@ async def async_request(self, tracer: Tracer) -> Optional[RESULT]:
365624 # Loop until
366625 while True :
367626
368- # Get pickled bytes value from the websocket.
369- response = (await sio .receive ())[1 ]
627+ # Use timeout only when remote logging is enabled to update spinner/elapsed time
628+ timeout = 0.1 if CONFIG .APP .REMOTE_LOGGING else None
629+ try :
630+ response = (await sio .receive (timeout = timeout ))[1 ]
631+ except socketio .exceptions .TimeoutError :
632+ # Refresh the status display to update spinner and elapsed time
633+ if self .job_id and self .job_status :
634+ self .status_display .update (
635+ self .job_id , self .job_status .name
636+ )
637+ continue
638+
370639 # Convert to pydantic object.
371640 response = ResponseModel .unpickle (response )
372641 # Handle the response.
0 commit comments