Animated spinner for long-running operations. WARNING: every frame starts with '\\r' and overwrites the current console row with the frame, message and padding spaces. A spinner that is still (or again) ticking while other code prints can therefore erase chunks of large multi-row ou
| 254 | |
| 255 | |
| 256 | class Spinner: |
| 257 | """Animated spinner for long-running operations. |
| 258 | |
| 259 | WARNING: every frame starts with '\\r' and overwrites the current console |
| 260 | row with the frame, message and padding spaces. A spinner that is still |
| 261 | (or again) ticking while other code prints can therefore erase chunks of |
| 262 | large multi-row output - especially long single-line blobs like base64 |
| 263 | KSM config tokens (`pam project import`/`pam gateway new` access_token), |
| 264 | silently corrupting what the user copies. Callers MUST guarantee stop() |
| 265 | via try/finally, and any change here must keep frames out of stopped |
| 266 | spinners and out of redirected/captured output. |
| 267 | """ |
| 268 | |
| 269 | # Claude-style spinner frames |
| 270 | FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] |
| 271 | |
| 272 | def __init__(self, message=""): |
| 273 | self.message = message |
| 274 | self.running = False |
| 275 | self.thread = None |
| 276 | self._last_visible_len = 0 |
| 277 | |
| 278 | def _animate(self): |
| 279 | idx = 0 |
| 280 | while self.running: |
| 281 | frame = self.FRAMES[idx % len(self.FRAMES)] |
| 282 | message = self.message or '' |
| 283 | visible_len = len(message) + 2 # frame + space + message |
| 284 | pad = max(0, self._last_visible_len - visible_len) |
| 285 | # Re-check right before the write: a stale tick firing after |
| 286 | # stop() has returned (join timed out on a blocked console write) |
| 287 | # would '\r'-overwrite output printed in the meantime - erasing a |
| 288 | # row of large output such as a printed KSM config token. |
| 289 | # Skipping costs only one cosmetic frame. |
| 290 | if not self.running: |
| 291 | break |
| 292 | # Frames go to stderr (codebase convention for '\r' progress, see |
| 293 | # sox/aram/record_totp): on stdout they land inside redirected or |
| 294 | # captured command output, e.g. corrupting the base64 config in |
| 295 | # `pam project import ... > out.json`. |
| 296 | sys.stderr.write(f'\r{Fore.CYAN}{frame}{Fore.RESET} {message}' + (' ' * pad)) |
| 297 | sys.stderr.flush() |
| 298 | self._last_visible_len = visible_len + pad |
| 299 | idx += 1 |
| 300 | time.sleep(0.08) |
| 301 | |
| 302 | def start(self): |
| 303 | # Spin only on a real console; in a redirected/captured stream the |
| 304 | # frames cannot animate and would pile up as '\r' noise in the data. |
| 305 | try: |
| 306 | if not sys.stderr.isatty(): |
| 307 | return |
| 308 | except Exception: |
| 309 | return |
| 310 | self.running = True |
| 311 | self.thread = threading.Thread(target=self._animate, daemon=True) |
| 312 | self.thread.start() |
| 313 |