Skip to content

Commit 3c04096

Browse files
committed
feat(export): add csv export option
Adds the ability to save tabular data as a CSV file. - New commands `csv` and `:csv` are introduced to trigger the export. - The `LiveFrameTracker` is updated to hold structured data (headers and rows) in addition to the rendered snapshot. - All monitoring functions that display tables now provide this structured data to the tracker. - A new `_save_csv_snapshot` function handles the file writing using the `csv` module. - Tests were updated to reflect the refactoring of the snapshot handling logic.
1 parent fe57837 commit 3c04096

File tree

2 files changed

+147
-26
lines changed

2 files changed

+147
-26
lines changed

kubernetes_monitoring.py

Lines changed: 138 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22

33
import contextlib
4+
import csv
45
import datetime
56
import os
67
import shlex
@@ -111,6 +112,7 @@ def start_kube_config_watcher() -> None:
111112

112113
SNAPSHOT_EXPORT_DIR = Path("/var/tmp/kmp")
113114
SNAPSHOT_SAVE_COMMANDS = {"s", ":s", "save", ":save", ":export"}
115+
CSV_SAVE_COMMANDS = {"csv", ":csv"}
114116

115117
WINDOWS_INPUT_BUFFER: List[str] = []
116118
POSIX_INPUT_BUFFER: List[str] = []
@@ -368,6 +370,24 @@ def _save_markdown_snapshot(markdown: str) -> Path:
368370
return file_path
369371

370372

373+
def _save_csv_snapshot(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> Path:
374+
"""CSV 파일을 저장하고 경로 반환."""
375+
try:
376+
SNAPSHOT_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
377+
except OSError as exc:
378+
raise SnapshotSaveError(SNAPSHOT_EXPORT_DIR, exc) from exc
379+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
380+
file_path = SNAPSHOT_EXPORT_DIR / f"{timestamp}.csv"
381+
try:
382+
with file_path.open("w", newline="", encoding="utf-8") as csvfile:
383+
writer = csv.writer(csvfile)
384+
writer.writerow(headers)
385+
writer.writerows(rows)
386+
except OSError as exc:
387+
raise SnapshotSaveError(file_path, exc) from exc
388+
return file_path
389+
390+
371391
def _read_nonblocking_command() -> Optional[str]:
372392
"""사용자 입력(line 단위)을 블로킹 없이 읽어 Slack 저장 요청 감지."""
373393
if not sys.stdin.isatty():
@@ -456,48 +476,83 @@ def _read_nonblocking_command() -> Optional[str]:
456476
return None
457477

458478

459-
def _handle_snapshot_command(
460-
live: Live, markdown: Optional[str], command: Optional[str]
461-
) -> None:
462-
"""저장 요청이 있는 경우 Markdown을 파일로 기록."""
463-
if command is None:
464-
return
465-
display_command = command.strip() or "<empty>"
466-
normalized = command.lower()
467-
if normalized not in SNAPSHOT_SAVE_COMMANDS:
479+
def _handle_csv_save(live: Live, tracker: "LiveFrameTracker", command: str) -> None:
480+
"""CSV 저장 요청을 처리."""
481+
if not tracker.latest_structured_data:
468482
live.console.print(
469-
f"\n입력 '{display_command}' 은(는) 지원하지 않는 명령입니다. "
470-
"사용 가능한 입력: s, :s, save, :save, :export",
471-
style="bold yellow",
472-
)
473-
return
474-
if not markdown:
475-
live.console.print(
476-
f"\n입력 '{display_command}' 처리 실패: 저장할 데이터가 없습니다.",
483+
f"\n입력 '{command}' 처리 실패: CSV로 저장할 수 있는 테이블 데이터가 없습니다.",
477484
style="bold yellow",
478485
)
479486
return
487+
480488
try:
481-
path = _save_markdown_snapshot(markdown)
482-
except SnapshotSaveError as exc:
489+
headers = tracker.latest_structured_data["headers"]
490+
rows = tracker.latest_structured_data["rows"]
491+
path = _save_csv_snapshot(headers, rows)
492+
except (SnapshotSaveError, KeyError) as exc:
483493
live.console.print(
484-
f"\n입력 '{display_command}' 처리 실패: {exc}",
494+
f"\n입력 '{command}' 처리 실패: {exc}",
485495
style="bold red",
486496
)
487497
_clear_input_display()
488498
return
499+
489500
live.console.print(
490-
f"\n입력 '{display_command}' 처리 성공: Slack Markdown 스냅샷 저장 완료 → {path}",
501+
f"\n입력 '{command}' 처리 성공: CSV 스냅샷 저장 완료 → {path}",
491502
style="bold green",
492503
)
493504
_clear_input_display()
494505

495506

496-
def _tick_iteration(live: Live, markdown: Optional[str]) -> None:
507+
def _handle_snapshot_command(
508+
live: Live, tracker: "LiveFrameTracker", command: Optional[str]
509+
) -> None:
510+
"""저장 요청이 있는 경우 Markdown 또는 CSV를 파일로 기록."""
511+
if command is None:
512+
return
513+
514+
display_command = command.strip() or "<empty>"
515+
normalized = command.lower()
516+
517+
if normalized in CSV_SAVE_COMMANDS:
518+
_handle_csv_save(live, tracker, display_command)
519+
return
520+
521+
if normalized in SNAPSHOT_SAVE_COMMANDS:
522+
if not tracker.latest_snapshot:
523+
live.console.print(
524+
f"\n입력 '{display_command}' 처리 실패: 저장할 데이터가 없습니다.",
525+
style="bold yellow",
526+
)
527+
return
528+
try:
529+
path = _save_markdown_snapshot(tracker.latest_snapshot)
530+
except SnapshotSaveError as exc:
531+
live.console.print(
532+
f"\n입력 '{display_command}' 처리 실패: {exc}",
533+
style="bold red",
534+
)
535+
_clear_input_display()
536+
return
537+
live.console.print(
538+
f"\n입력 '{display_command}' 처리 성공: Slack Markdown 스냅샷 저장 완료 → {path}",
539+
style="bold green",
540+
)
541+
_clear_input_display()
542+
return
543+
544+
live.console.print(
545+
f"\n입력 '{display_command}' 은(는) 지원하지 않는 명령입니다. "
546+
"사용 가능한 입력: s, save, csv, ...",
547+
style="bold yellow",
548+
)
549+
550+
551+
def _tick_iteration(live: Live, tracker: "LiveFrameTracker") -> None:
497552
"""루프 종료 전 저장 요청을 처리."""
498553
user_command = _read_nonblocking_command()
499554
if user_command:
500-
_handle_snapshot_command(live, markdown, user_command)
555+
_handle_snapshot_command(live, tracker, user_command)
501556

502557

503558
class LiveFrameTracker:
@@ -522,6 +577,7 @@ def __init__(self, live: Live) -> None:
522577
"footer": None,
523578
}
524579
self.latest_snapshot: Optional[str] = None
580+
self.latest_structured_data: Optional[Dict[str, Any]] = None
525581
self.last_input_state: str = ""
526582
self.live.update(self.layout)
527583

@@ -549,6 +605,7 @@ def update(
549605
frame_key: FrameKey,
550606
renderable: RenderableType,
551607
snapshot_markdown: Optional[str],
608+
structured_data: Optional[Dict[str, Any]] = None,
552609
input_state: Optional[str] = None,
553610
) -> None:
554611
current_input_state = (
@@ -591,6 +648,10 @@ def update(
591648
self.live.refresh()
592649
if snapshot_markdown is not None:
593650
self.latest_snapshot = snapshot_markdown
651+
if structured_data is not None:
652+
self.latest_structured_data = structured_data
653+
else:
654+
self.latest_structured_data = None
594655

595656
def tick(self, interval: float = LIVE_REFRESH_INTERVAL) -> None:
596657
global TERMINAL_RESIZED
@@ -600,7 +661,7 @@ def tick(self, interval: float = LIVE_REFRESH_INTERVAL) -> None:
600661
TERMINAL_RESIZED = False
601662
self.live.refresh()
602663

603-
_tick_iteration(self.live, self.latest_snapshot)
664+
_tick_iteration(self.live, self)
604665
if self._sync_input_panel(CURRENT_INPUT_DISPLAY):
605666
self.live.refresh()
606667
remaining = deadline - time.monotonic()
@@ -1345,10 +1406,22 @@ def watch_event_monitoring() -> None:
13451406
command=command_descriptor,
13461407
status="success",
13471408
)
1409+
structured_data = {
1410+
"headers": [
1411+
"Namespace",
1412+
"LastSeen",
1413+
"Type",
1414+
"Reason",
1415+
"Object",
1416+
"Message",
1417+
],
1418+
"rows": markdown_rows,
1419+
}
13481420
tracker.update(
13491421
frame_key,
13501422
_compose_group(command_descriptor, table),
13511423
snapshot,
1424+
structured_data=structured_data,
13521425
input_state=CURRENT_INPUT_DISPLAY,
13531426
)
13541427
tracker.tick()
@@ -1705,10 +1778,12 @@ def watch_pod_monitoring_by_creation() -> None:
17051778
command=command_descriptor,
17061779
status="success",
17071780
)
1781+
structured_data = {"headers": headers, "rows": markdown_rows}
17081782
tracker.update(
17091783
frame_key,
17101784
_compose_group(command_descriptor, table),
17111785
snapshot,
1786+
structured_data=structured_data,
17121787
input_state=CURRENT_INPUT_DISPLAY,
17131788
)
17141789
tracker.tick()
@@ -1983,10 +2058,12 @@ def _is_non_running(pod: V1Pod) -> bool:
19832058
command=command_descriptor,
19842059
status="success",
19852060
)
2061+
structured_data = {"headers": headers, "rows": markdown_rows}
19862062
tracker.update(
19872063
frame_key,
19882064
_compose_group(command_descriptor, table),
19892065
snapshot,
2066+
structured_data=structured_data,
19902067
input_state=CURRENT_INPUT_DISPLAY,
19912068
)
19922069
tracker.tick()
@@ -2301,10 +2378,23 @@ def watch_node_monitoring_by_creation() -> None:
23012378
command=command_descriptor,
23022379
status="success",
23032380
)
2381+
structured_data = {
2382+
"headers": [
2383+
"Name",
2384+
"Status",
2385+
"Roles",
2386+
"NodeGroup",
2387+
"Zone",
2388+
"Version",
2389+
"CreatedAt",
2390+
],
2391+
"rows": markdown_rows,
2392+
}
23042393
tracker.update(
23052394
frame_key,
23062395
_compose_group(command_descriptor, table),
23072396
snapshot,
2397+
structured_data=structured_data,
23082398
input_state=CURRENT_INPUT_DISPLAY,
23092399
)
23102400
tracker.tick()
@@ -2546,10 +2636,23 @@ def watch_unhealthy_nodes() -> None:
25462636
command=command_descriptor,
25472637
status="warning",
25482638
)
2639+
structured_data = {
2640+
"headers": [
2641+
"Name",
2642+
"Status",
2643+
"Reason",
2644+
"NodeGroup",
2645+
"Zone",
2646+
"Version",
2647+
"CreatedAt",
2648+
],
2649+
"rows": markdown_rows,
2650+
}
25492651
tracker.update(
25502652
frame_key,
25512653
_compose_group(command_descriptor, table),
25522654
snapshot,
2655+
structured_data=structured_data,
25532656
input_state=CURRENT_INPUT_DISPLAY,
25542657
)
25552658
tracker.tick()
@@ -2914,10 +3017,21 @@ def watch_pod_resources() -> None:
29143017
command=kubectl_cmd,
29153018
status="success",
29163019
)
3020+
structured_data = {
3021+
"headers": [
3022+
"Namespace",
3023+
"Pod",
3024+
"CPU(cores)",
3025+
"Memory(bytes)",
3026+
"Node",
3027+
],
3028+
"rows": markdown_rows,
3029+
}
29173030
tracker.update(
29183031
frame_key,
29193032
_compose_group(kubectl_cmd, header, table),
29203033
snapshot,
3034+
structured_data=structured_data,
29213035
input_state=CURRENT_INPUT_DISPLAY,
29223036
)
29233037
tracker.tick()

tests/test_kubernetes_monitoring.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,12 @@ def test_handle_snapshot_command_messages(monkeypatch, tmp_path):
172172
kubernetes_monitoring._clear_input_display()
173173
invalid_console = Console(record=True)
174174
invalid_live = _DummyLive(invalid_console)
175-
kubernetes_monitoring._handle_snapshot_command(invalid_live, "data", "invalid")
175+
mock_tracker = MagicMock()
176+
mock_tracker.latest_snapshot = None
177+
mock_tracker.latest_structured_data = None
178+
kubernetes_monitoring._handle_snapshot_command(
179+
invalid_live, mock_tracker, "invalid"
180+
)
176181
text_output = invalid_console.export_text()
177182
assert "입력 'invalid' 은(는) 지원하지 않는 명령입니다." in text_output
178183

@@ -189,9 +194,11 @@ def test_handle_snapshot_command_messages(monkeypatch, tmp_path):
189194
fake_save_with_path,
190195
raising=False,
191196
)
192-
kubernetes_monitoring._handle_snapshot_command(success_live, "data", ":save")
197+
mock_tracker.latest_snapshot = "some data"
198+
kubernetes_monitoring._handle_snapshot_command(success_live, mock_tracker, ":save")
193199
text_output = success_console.export_text()
194200
assert "입력 ':save' 처리 성공" in text_output
201+
195202
assert "Slack Markdown 스냅샷 저장 완료" in text_output
196203
assert str(saved_path) in text_output
197204

0 commit comments

Comments
 (0)