Skip to content

Commit eb58297

Browse files
refactor(logging): consolidate job status display into remote backend
- Removed the log.py file and integrated the job status display functionality directly into the remote backend. - Enhanced the status display with support for terminal and notebook environments, including color-coded updates and spinners. - Updated response handling to utilize the new status display system for improved user feedback during job execution. - Streamlined the logging process by eliminating the previous remote logger implementation.
1 parent 28c673a commit eb58297

File tree

3 files changed

+280
-208
lines changed

3 files changed

+280
-208
lines changed

src/nnsight/intervention/backends/remote.py

Lines changed: 276 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import inspect
44
import io
55
import os
6+
import sys
67
import time
78
from sys import version as python_version
89
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
@@ -22,6 +23,244 @@
2223
from .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("&lt;")
226+
elif char == ">":
227+
result.append("&gt;")
228+
elif char == "&":
229+
result.append("&amp;")
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+
25264
class 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}\nRemote 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

Comments
 (0)