Option C Tools
Loading...
Searching...
No Matches
progress.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/core/progress.py
4
5"""
6Purpose
7-------
8Provide reusable progress display for long-running OCT commands.
9
10Responsibilities
11----------------
12- ProgressTracker: scrolling task checklist with per-task progress bar.
13- Dashboard: fixed-size terminal display with in-place line updates and a
14 background timer thread that refreshes the footer every second.
15- Auto-detect TTY vs pipe; Dashboard prints final state on non-TTY.
16- Thread-safe for concurrent callers (e.g. linter ThreadPoolExecutor).
17
18Diagnostics
19-----------
20Domain: OCT-CORE
21Levels:
22 L3 — task lifecycle events
23 L4 — tick and render calls
24
25Contracts
26---------
27- ProgressTracker outputs to the provided stream (default: stderr).
28- Dashboard outputs to the provided stream (default: stdout).
29- quiet=True suppresses all output (for --json modes).
30- Dashboard.finish() prints final line state on non-TTY streams.
31"""
32
33from __future__ import annotations
34
35import sys
36import threading
37import time
38from contextlib import contextmanager
39from typing import TextIO
40
41
42_BAR_WIDTH = 20
43
44
46 """Task checklist with optional per-task progress bar."""
47
49 self,
50 tasks: list[str],
51 *,
52 title: str = "",
53 stream: TextIO | None = None,
54 quiet: bool = False,
55 ) -> None:
56 self._tasks = list(tasks)
57 self._title = title
58 self._stream = stream or sys.stderr
59 self._quiet = quiet
60 self._is_tty = hasattr(self._stream, "isatty") and self._stream.isatty()
61
62 self._lock = threading.Lock()
63 self._current: str | None = None
64 self._progress: int = 0
65 self._total: int = 0
66 self._task_start: float = 0.0
67 self._done: dict[str, tuple[str, str]] = {}
68
69 if not self._quiet and self._title:
70 self._write_line(self._title)
71
72 def start_task(self, name: str, total: int = 0) -> None:
73 if self._quiet or name not in self._tasks:
74 return
75 with self._lock:
76 self._current = name
77 self._progress = 0
78 self._total = total
79 self._task_start = time.monotonic()
80 if self._is_tty:
81 self._render_status()
82 else:
83 self._write_line(f" [start] {name}")
84
85 def tick(self, n: int = 1) -> None:
86 if self._quiet:
87 return
88 with self._lock:
89 self._progress += n
90 if self._is_tty:
91 self._render_status()
92
93 def complete_task(self, name: str, *, summary: str = "") -> None:
94 if self._quiet or name not in self._tasks:
95 return
96 with self._lock:
97 self._current = None
98 marker = "[x]"
99 line = f" {marker} {name}"
100 if summary:
101 line += f" {summary}"
102 self._done[name] = ("done", summary)
103 if self._is_tty:
104 self._clear_status()
105 self._write_line(line)
106
107 def fail_task(self, name: str, *, summary: str = "") -> None:
108 if self._quiet or name not in self._tasks:
109 return
110 with self._lock:
111 self._current = None
112 marker = "[!]"
113 line = f" {marker} {name}"
114 if summary:
115 line += f" {summary}"
116 self._done[name] = ("fail", summary)
117 if self._is_tty:
118 self._clear_status()
119 self._write_line(line)
120
121 def skip_task(self, name: str, *, summary: str = "") -> None:
122 if self._quiet or name not in self._tasks:
123 return
124 with self._lock:
125 self._current = None
126 marker = "[-]"
127 line = f" {marker} {name}"
128 if summary:
129 line += f" {summary}"
130 self._done[name] = ("skip", summary)
131 if self._is_tty:
132 self._clear_status()
133 self._write_line(line)
134
135 @contextmanager
136 def task(self, name: str, total: int = 0):
137 self.start_task(name, total=total)
138 try:
139 yield self
140 self.complete_task(name)
141 except Exception:
142 self.fail_task(name)
143 raise
144
145 def finish(self) -> None:
146 if self._quiet:
147 return
148 if self._is_tty:
149 self._clear_status()
150 for t in self._tasks:
151 if t not in self._done:
152 self._write_line(f" [ ] {t}")
153
154 # -- internal rendering --------------------------------------------------
155
156 def _write_line(self, text: str) -> None:
157 try:
158 self._stream.write(text + "\n")
159 self._stream.flush()
160 except (OSError, ValueError):
161 pass
162
163 def _render_status(self) -> None:
164 with self._lock:
165 name = self._current
166 progress = self._progress
167 total = self._total
168 start = self._task_start
169 if name is None:
170 return
171 if total > 0:
172 pct = min(progress / total, 1.0)
173 filled = int(pct * _BAR_WIDTH)
174 bar = "=" * filled + ">" * (1 if filled < _BAR_WIDTH else 0)
175 bar = bar.ljust(_BAR_WIDTH)
176 text = f" Running: {name} [{bar}] {int(pct * 100)}% {progress}/{total}"
177 else:
178 elapsed = time.monotonic() - start
179 text = f" Running: {name} ... elapsed {int(elapsed)}s"
180 try:
181 self._stream.write(f"\r{text}\033[K")
182 self._stream.flush()
183 except (OSError, ValueError):
184 pass
185
186 def _clear_status(self) -> None:
187 try:
188 self._stream.write("\r\033[K")
189 self._stream.flush()
190 except (OSError, ValueError):
191 pass
192
193
195 """Fixed-size terminal display with in-place line updates and live timer.
196
197 Prints a full template on render(), then overwrites individual lines via
198 ANSI cursor movement as data becomes available. A background daemon thread
199 refreshes the footer line every second so elapsed-time counters stay live.
200
201 On non-TTY streams the template is NOT printed during render(); instead,
202 update_line() silently accumulates changes and finish() prints the final
203 state in one shot — clean output for CI and pipes.
204 """
205
207 self,
208 *,
209 stream: TextIO | None = None,
210 quiet: bool = False,
211 ) -> None:
212 self._stream = stream or sys.stdout
213 self._quiet = quiet
214 self._is_tty = hasattr(self._stream, "isatty") and self._stream.isatty()
215 self._lock = threading.Lock()
216 self._lines: list[str] = []
217 self._line_count: int = 0
218 self._footer_line: int = -1
219 self._task_name: str | None = None
220 self._task_start: float = 0.0
221 self._overall_start: float = 0.0
222 self._tasks_done: int = 0
223 self._tasks_total: int = 0
224 self._stop_event = threading.Event()
225 self._timer_thread: threading.Thread | None = None
226
227 def render(self, lines: list[str], *, footer_line: int = -1,
228 tasks_total: int = 0) -> None:
229 """Print all lines. *footer_line* is the index auto-updated by the
230 background timer (default: third-from-last)."""
231 self._lines = list(lines)
232 self._line_count = len(lines)
233 self._footer_line = footer_line if footer_line >= 0 else max(self._line_count - 3, 0)
234 self._tasks_total = tasks_total
235 self._overall_start = time.monotonic()
236 if self._quiet:
237 return
238 if self._is_tty:
239 for line in self._lines:
240 self._stream.write(line + "\n")
241 self._stream.flush()
242 self._stop_event.clear()
243 self._timer_thread = threading.Thread(
244 target=self._timer_loop, daemon=True,
245 )
246 self._timer_thread.start()
247
248 def update_line(self, index: int, text: str) -> None:
249 """Replace the line at *index* with *text*."""
250 if index < 0 or index >= self._line_count:
251 return
252 with self._lock:
253 self._lines[index] = text
254 if self._is_tty and not self._quiet:
255 up = self._line_count - index
256 self._stream.write(
257 f"\033[{up}A\r{text}\033[K\033[{up}B\r",
258 )
259 self._stream.flush()
260
261 def set_task(self, name: str) -> None:
262 """Set current task name and reset the per-task elapsed timer."""
263 with self._lock:
264 self._task_name = name
265 self._task_start = time.monotonic()
266 self._update_footer()
267
268 def complete_task(self) -> None:
269 """Increment done counter and clear the current task name."""
270 with self._lock:
271 self._tasks_done += 1
272 self._task_name = None
273
274 def finish(self) -> None:
275 """Stop the timer thread and render final state."""
276 self._stop_event.set()
277 if self._timer_thread:
278 self._timer_thread.join(timeout=2)
279 self._timer_thread = None
280 if self._quiet:
281 return
282 elapsed = time.monotonic() - self._overall_start
283 final = f" All {self._tasks_total} checks completed in {elapsed:.1f}s"
284 self.update_line(self._footer_line, final)
285 if not self._is_tty:
286 for line in self._lines:
287 self._stream.write(line + "\n")
288 self._stream.flush()
289
290 # -- background timer ----------------------------------------------------
291
292 def _timer_loop(self) -> None:
293 while not self._stop_event.is_set():
294 self._stop_event.wait(1.0)
295 if not self._stop_event.is_set():
296 self._update_footer()
297
298 def _update_footer(self) -> None:
299 with self._lock:
300 name = self._task_name
301 start = self._task_start
302 done = self._tasks_done
303 total = self._tasks_total
304 if name is None:
305 return
306 elapsed = time.monotonic() - start
307 text = f" [{done}/{total}] {name} ... elapsed {int(elapsed)}s"
308 self.update_line(self._footer_line, text)
threading.Thread|None _timer_thread
Definition progress.py:225
None _timer_loop(self)
Definition progress.py:292
None set_task(self, str name)
Definition progress.py:261
None update_line(self, int index, str text)
Definition progress.py:248
None finish(self)
Definition progress.py:274
None complete_task(self)
Definition progress.py:268
str|None _task_name
Definition progress.py:219
None render(self, list[str] lines, *, int footer_line=-1, int tasks_total=0)
Definition progress.py:228
None __init__(self, *, TextIO|None stream=None, bool quiet=False)
Definition progress.py:211
None _update_footer(self)
Definition progress.py:298
None start_task(self, str name, int total=0)
Definition progress.py:72
None skip_task(self, str name, *, str summary="")
Definition progress.py:121
None tick(self, int n=1)
Definition progress.py:85
None _render_status(self)
Definition progress.py:163
None complete_task(self, str name, *, str summary="")
Definition progress.py:93
None _write_line(self, str text)
Definition progress.py:156
None fail_task(self, str name, *, str summary="")
Definition progress.py:107
None __init__(self, list[str] tasks, *, str title="", TextIO|None stream=None, bool quiet=False)
Definition progress.py:55
task(self, str name, int total=0)
Definition progress.py:136