Files
hate_crack/hate_crack/progress.py
T
Justin BollingerandClaude 142c1fc99f refactor: address code-quality review findings on ui-polish branch
- Remove review-artifact "Defect 2" comment in main.py:2076; replace with
  accurate description of the invalid-cap fallback.  Same label removed from
  the test section header in test_ollama_wordlist_sampling.py.
- Lift nested _usable helper to module-level _usable_plaintext in main.py,
  placed alongside the other private wordlist helpers after _wordlist_path.
  Add six direct unit tests covering blank, whitespace, plain, hash:pw,
  multi-colon, and empty-plaintext cases.
- Move spinner line-clear before thread.join() in progress.py so the terminal
  is cleaned up even if join() is interrupted by a DoubleInterrupt/second Ctrl-C.
  Add test_tty_clears_line_even_if_join_raises to cover this path.
- Replace time.sleep-based elapsed-counter test with a deterministic version
  that patches time.monotonic; assert on actual rendered format with r"\d+s".
  Drop the misleading 0.05s sleep in test_tty_clears_line_on_exit (shorter
  than one tick, so no frame was ever guaranteed).
- Add missing ollama* keys to hate_crack/config.json.example (package-data
  copy); root config.json.example already had them.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 18:08:11 -04:00

71 lines
2.4 KiB
Python

"""Generic terminal progress utilities for hate_crack.
Provides a context manager that shows a live spinner with elapsed-seconds
counter while a blocking operation runs. Safe to use in non-TTY environments:
if stdout is not a TTY the message is printed once and no background thread is
started.
"""
from __future__ import annotations
import sys
import threading
import time
from collections.abc import Generator
from contextlib import contextmanager
_SPINNER_FRAMES = ["|", "/", "-", "\\"]
_TICK_INTERVAL = 0.12 # seconds between repaints (~120 ms)
@contextmanager
def spinner(message: str) -> "Generator[None, None, None]":
"""Context manager that shows *message* plus a live elapsed-seconds counter.
While the body executes a daemon thread repaints a single terminal line
roughly every 120 ms showing:
| Generating password candidates via Ollama (model)... 3s
On exit (normal or exceptional) the spinner line is erased so subsequent
output starts on a clean line.
TTY guard: if ``sys.stdout.isatty()`` is False the message is printed once
via ``print()`` and no thread is started, keeping piped output and the test
suite clean.
"""
if not sys.stdout.isatty():
print(message)
yield
return
stop_event = threading.Event()
start_time = time.monotonic()
def _run() -> None:
frame_idx = 0
while not stop_event.is_set():
elapsed = int(time.monotonic() - start_time)
frame = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)]
line = f"\r{frame} {message} {elapsed}s"
sys.stdout.write(line)
sys.stdout.flush()
frame_idx += 1
stop_event.wait(_TICK_INTERVAL)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
try:
yield
finally:
stop_event.set()
# Clear the spinner line *before* joining so the terminal is left clean
# even if join() is interrupted by a second Ctrl-C (DoubleInterrupt).
# hate_crack's _sigint_handler raises DoubleInterrupt on a second SIGINT
# within 2 s, and join() can block up to _TICK_INTERVAL (0.12 s) — a
# narrow but real window. Writing the escape sequence first ensures the
# line is always erased regardless of what happens to the join.
sys.stdout.write("\033[2K\r")
sys.stdout.flush()
thread.join()