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