Skip to content

Commit b57bf26

Browse files
Add animated loading display with tips to CLI infrastructure panels (#76)
1 parent 5d0f1b3 commit b57bf26

File tree

7 files changed

+359
-176
lines changed

7 files changed

+359
-176
lines changed

keras_remote/backend/log_streaming.py

Lines changed: 19 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,14 @@
55
job execution.
66
"""
77

8-
import sys
98
import threading
10-
from collections import deque
119

1210
import urllib3
1311
from absl import logging
1412
from kubernetes.client.rest import ApiException
1513
from rich.console import Console
16-
from rich.live import Live
17-
from rich.panel import Panel
14+
15+
from keras_remote.cli.output import LiveOutputPanel
1816

1917
_MAX_DISPLAY_LINES = 25
2018

@@ -27,14 +25,13 @@ def _stream_pod_logs(core_v1, pod_name, namespace):
2725
2826
In interactive terminals, logs are displayed in a Rich Live panel.
2927
In non-interactive contexts (piped output, CI), logs are streamed
30-
as raw lines with Rich Rule delimiters.
28+
as plain lines with Rule delimiters.
3129
3230
Args:
3331
core_v1: Kubernetes CoreV1Api client.
3432
pod_name: Name of the pod to stream logs from.
3533
namespace: Kubernetes namespace.
3634
"""
37-
console = Console()
3835
resp = None
3936
try:
4037
resp = core_v1.read_namespaced_pod_log(
@@ -43,10 +40,22 @@ def _stream_pod_logs(core_v1, pod_name, namespace):
4340
follow=True,
4441
_preload_content=False,
4542
)
46-
if console.is_terminal:
47-
_render_live_panel(resp, pod_name, console)
48-
else:
49-
_render_plain(resp, pod_name, console)
43+
title = f"Remote logs \u2022 {pod_name}"
44+
with LiveOutputPanel(
45+
title,
46+
max_lines=_MAX_DISPLAY_LINES,
47+
target_console=Console(),
48+
show_subtitle=False,
49+
) as panel:
50+
buffer = ""
51+
for chunk in resp.stream(decode_content=True):
52+
buffer += chunk.decode("utf-8", errors="replace")
53+
while "\n" in buffer:
54+
line, buffer = buffer.split("\n", 1)
55+
panel.on_output(line)
56+
# Flush remaining partial line
57+
if buffer.strip():
58+
panel.on_output(buffer)
5059
except ApiException:
5160
pass # Pod deleted or not found
5261
except urllib3.exceptions.ProtocolError:
@@ -60,45 +69,6 @@ def _stream_pod_logs(core_v1, pod_name, namespace):
6069
resp.release_conn()
6170

6271

63-
def _render_live_panel(resp, pod_name, console):
64-
"""Render streaming logs inside a Rich Live panel."""
65-
lines = deque(maxlen=_MAX_DISPLAY_LINES)
66-
title = f"Remote logs \u2022 {pod_name}"
67-
buffer = ""
68-
69-
with Live(
70-
_make_log_panel(lines, title),
71-
console=console,
72-
refresh_per_second=4,
73-
) as live:
74-
for chunk in resp.stream(decode_content=True):
75-
buffer += chunk.decode("utf-8", errors="replace")
76-
while "\n" in buffer:
77-
line, buffer = buffer.split("\n", 1)
78-
lines.append(line)
79-
live.update(_make_log_panel(lines, title))
80-
81-
# Flush remaining partial line
82-
if buffer.strip():
83-
lines.append(buffer)
84-
live.update(_make_log_panel(lines, title))
85-
86-
87-
def _render_plain(resp, pod_name, console):
88-
"""Render streaming logs as raw lines with Rule delimiters."""
89-
console.rule(f"Remote logs ({pod_name})", style="blue")
90-
for chunk in resp.stream(decode_content=True):
91-
sys.stdout.write(chunk.decode("utf-8", errors="replace"))
92-
sys.stdout.flush()
93-
console.rule("End remote logs", style="blue")
94-
95-
96-
def _make_log_panel(lines, title):
97-
"""Build a Panel renderable from accumulated log lines."""
98-
content = "\n".join(lines) if lines else "Waiting for output..."
99-
return Panel(content, title=title, border_style="blue")
100-
101-
10272
class LogStreamer:
10373
"""Context manager that owns the log-streaming thread lifecycle.
10474

keras_remote/backend/log_streaming_test.py

Lines changed: 17 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Tests for keras_remote.backend.log_streaming — live pod log streaming."""
22

3-
import io
43
from unittest import mock
54
from unittest.mock import MagicMock
65

@@ -9,8 +8,6 @@
98

109
from keras_remote.backend.log_streaming import (
1110
LogStreamer,
12-
_render_live_panel,
13-
_render_plain,
1411
_stream_pod_logs,
1512
)
1613

@@ -40,33 +37,23 @@ def test_calls_log_api_correctly(self):
4037
_preload_content=False,
4138
)
4239

43-
def test_routes_by_terminal(self):
44-
for is_terminal in (True, False):
45-
mock_core = MagicMock()
46-
mock_core.read_namespaced_pod_log.return_value = self._make_mock_resp(
47-
[b"hello\n"]
48-
)
49-
50-
with (
51-
mock.patch(
52-
"keras_remote.backend.log_streaming.Console"
53-
) as mock_console_cls,
54-
mock.patch(
55-
"keras_remote.backend.log_streaming._render_live_panel"
56-
) as mock_live,
57-
mock.patch(
58-
"keras_remote.backend.log_streaming._render_plain"
59-
) as mock_plain,
60-
):
61-
mock_console_cls.return_value.is_terminal = is_terminal
62-
_stream_pod_logs(mock_core, "pod-1", "default")
63-
64-
if is_terminal:
65-
mock_live.assert_called_once()
66-
mock_plain.assert_not_called()
67-
else:
68-
mock_plain.assert_called_once()
69-
mock_live.assert_not_called()
40+
def test_handles_partial_lines(self):
41+
mock_core = MagicMock()
42+
# "hello\nwor" then "ld\n" — "world" is split across chunks
43+
mock_core.read_namespaced_pod_log.return_value = self._make_mock_resp(
44+
[b"hello\nwor", b"ld\n"]
45+
)
46+
47+
with mock.patch(
48+
"keras_remote.backend.log_streaming.LiveOutputPanel"
49+
) as mock_panel_cls:
50+
mock_panel = MagicMock()
51+
mock_panel_cls.return_value.__enter__ = MagicMock(return_value=mock_panel)
52+
mock_panel_cls.return_value.__exit__ = MagicMock(return_value=False)
53+
_stream_pod_logs(mock_core, "pod-1", "default")
54+
55+
lines = [call[0][0] for call in mock_panel.on_output.call_args_list]
56+
self.assertEqual(lines, ["hello", "world"])
7057

7158
def test_releases_conn_on_api_exception(self):
7259
mock_core = MagicMock()
@@ -111,66 +98,6 @@ def test_logs_warning_on_unexpected_error(self):
11198
mock_resp.release_conn.assert_called_once()
11299

113100

114-
class TestRenderPlain(absltest.TestCase):
115-
"""Tests for the non-terminal plain rendering path."""
116-
117-
def test_streams_chunks_to_stdout(self):
118-
mock_resp = MagicMock()
119-
mock_resp.stream.return_value = [b"line 1\n", b"line 2\n"]
120-
console = MagicMock()
121-
122-
with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
123-
_render_plain(mock_resp, "pod-1", console)
124-
125-
self.assertIn("line 1", mock_stdout.getvalue())
126-
self.assertIn("line 2", mock_stdout.getvalue())
127-
128-
def test_prints_rule_delimiters(self):
129-
mock_resp = MagicMock()
130-
mock_resp.stream.return_value = []
131-
console = MagicMock()
132-
133-
_render_plain(mock_resp, "pod-1", console)
134-
135-
self.assertEqual(console.rule.call_count, 2)
136-
# Opening rule contains pod name
137-
self.assertIn("pod-1", console.rule.call_args_list[0][0][0])
138-
139-
def test_handles_utf8_decode_errors(self):
140-
mock_resp = MagicMock()
141-
mock_resp.stream.return_value = [b"valid\n", b"\xff\xfe invalid\n"]
142-
console = MagicMock()
143-
144-
with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
145-
_render_plain(mock_resp, "pod-1", console)
146-
147-
output = mock_stdout.getvalue()
148-
self.assertIn("valid", output)
149-
self.assertIn("invalid", output)
150-
151-
152-
class TestRenderLivePanel(absltest.TestCase):
153-
"""Tests for the terminal Live panel rendering path."""
154-
155-
def test_handles_partial_lines(self):
156-
mock_resp = MagicMock()
157-
# "hello\nwor" then "ld\n" — "world" is split across chunks
158-
mock_resp.stream.return_value = [b"hello\nwor", b"ld\n"]
159-
console = MagicMock()
160-
161-
with mock.patch("keras_remote.backend.log_streaming.Live") as mock_live_cls:
162-
mock_live = MagicMock()
163-
mock_live_cls.return_value.__enter__ = MagicMock(return_value=mock_live)
164-
mock_live_cls.return_value.__exit__ = MagicMock(return_value=False)
165-
_render_live_panel(mock_resp, "pod-1", console)
166-
167-
# Check the final panel contains both complete lines
168-
last_panel = mock_live.update.call_args_list[-1][0][0]
169-
panel_content = last_panel.renderable
170-
self.assertIn("hello", panel_content)
171-
self.assertIn("world", panel_content)
172-
173-
174101
class TestLogStreamer(absltest.TestCase):
175102
def test_start_launches_daemon_thread(self):
176103
mock_core = MagicMock()

keras_remote/cli/commands/up.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
from keras_remote.cli.infra.state import apply_update, load_state
1515
from keras_remote.cli.options import common_options
1616
from keras_remote.cli.output import (
17+
LiveOutputPanel,
1718
banner,
1819
config_summary,
1920
console,
20-
success,
2121
warning,
2222
)
2323
from keras_remote.cli.prerequisites_check import check_all
@@ -89,17 +89,13 @@ def up(project, zone, accelerator, cluster_name, yes):
8989

9090
console.print()
9191

92-
# Run Pulumi
93-
console.print("[bold]Provisioning infrastructure...[/bold]\n")
9492
pulumi_ok = apply_update(config)
9593
pulumi_failed = not pulumi_ok
9694

9795
if pulumi_failed:
9896
warning("Attempting post-deploy configuration anyway...")
9997

10098
# Post-deploy steps
101-
console.print("\n[bold]Running post-deploy configuration...[/bold]\n")
102-
10399
steps = [
104100
(
105101
"kubectl configuration",
@@ -115,14 +111,21 @@ def up(project, zone, accelerator, cluster_name, yes):
115111
steps.append(("GPU driver installation", install_gpu_drivers))
116112

117113
failures = []
118-
for name, fn in steps:
119-
console.print(f"{name}...")
120-
try:
121-
fn()
122-
success(f"{name} complete.")
123-
except subprocess.CalledProcessError as e:
124-
failures.append(name)
125-
warning(f"{name} failed: {e}")
114+
with LiveOutputPanel("Post-deploy configuration", transient=True) as panel:
115+
for name, fn in steps:
116+
panel.on_output(f"{name}...")
117+
try:
118+
fn()
119+
panel.on_output(f"{name} complete.")
120+
except subprocess.CalledProcessError as e:
121+
failures.append(name)
122+
panel.on_output(f"{name} failed: {e}")
123+
if e.stderr:
124+
stderr_text = e.stderr.decode("utf-8", errors="replace").strip()
125+
if stderr_text:
126+
for line in stderr_text.splitlines():
127+
panel.on_output(f" {line}")
128+
panel.mark_error()
126129

127130
# Final summary
128131
console.print()

keras_remote/cli/infra/post_deploy.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def configure_kubectl(cluster_name, zone, project):
3333
f"--project={project}",
3434
],
3535
check=True,
36+
capture_output=True,
3637
env=env,
3738
)
3839

@@ -46,6 +47,7 @@ def install_gpu_drivers():
4647
subprocess.run(
4748
["kubectl", "apply", "-f", NVIDIA_DRIVER_DAEMONSET_URL],
4849
check=True,
50+
capture_output=True,
4951
)
5052

5153

@@ -57,4 +59,5 @@ def install_lws():
5759
subprocess.run(
5860
["kubectl", "apply", "--server-side", "-f", LWS_INSTALL_URL],
5961
check=True,
62+
capture_output=True,
6063
)

0 commit comments

Comments
 (0)