-
Notifications
You must be signed in to change notification settings - Fork 107
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This upgrades the progress output quite a bit: - There's a pretty animation at the start of the line! - Instead of printing the last package interacted with (which made no sense), show as many of the currently-processing packages as can fit on a single line. - Show live download progress for large downloads, so any that are holding up generation don't result in appears to be a hang. In addition, this adds a hidden CLI flag, --traceback-on-interrupt, that prints a full traceback from every active package coroutine on Ctrl-C, which was useful while debugging this (and probably will be in the future!) Signed-off-by: Ryan Gonzalez <ryan.gonzalez@collabora.com>
- Loading branch information
Showing
5 changed files
with
347 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
from typing import IO, Iterator, List, Optional | ||
|
||
import contextlib | ||
import enum | ||
import io | ||
import os | ||
import queue | ||
import sys | ||
import threading | ||
import time | ||
|
||
_MOVE_UP = '\033[A' | ||
_CLEAR_LINE = '\033[K' | ||
|
||
|
||
class _ConsoleState(enum.Enum): | ||
ACTIVE = enum.auto() | ||
INACTIVE = enum.auto() | ||
DISABLED = enum.auto() | ||
|
||
|
||
_console_state = _ConsoleState.INACTIVE | ||
|
||
_lines: List['AnimatedConsoleLine'] = [] | ||
_lines_lock = threading.Lock() | ||
|
||
_print_queue: 'queue.Queue[str]' = queue.Queue() | ||
|
||
_update_event = threading.Event() | ||
_done_event = threading.Event() | ||
|
||
|
||
def _get_next_frame_update_unlocked() -> Optional[float]: | ||
if not _lines: | ||
return None | ||
|
||
return min(l._next_frame_update for l in _lines) | ||
|
||
|
||
def _get_next_frame_update() -> Optional[float]: | ||
with _lines_lock: | ||
return _get_next_frame_update_unlocked() | ||
|
||
|
||
def _flush_print_queue(*, output: IO[str]) -> None: | ||
while True: | ||
try: | ||
content = _print_queue.get_nowait() | ||
except queue.Empty: | ||
break | ||
|
||
output.write(content) | ||
|
||
|
||
class _UpdateThread(threading.Thread): | ||
def __init__(self, output: IO[str]) -> None: | ||
super().__init__() | ||
self.output = output | ||
|
||
def run(self) -> None: | ||
visible_lines = 0 | ||
|
||
while True: | ||
_FRAME_DURATION = 0.05 | ||
|
||
next_frame_update = _get_next_frame_update() | ||
if next_frame_update is not None: | ||
now = time.monotonic() | ||
if now < next_frame_update: | ||
_update_event.wait(next_frame_update - now) | ||
else: | ||
_update_event.wait() | ||
|
||
if _done_event.is_set(): | ||
_flush_print_queue(output=self.output) | ||
self.output.flush() | ||
break | ||
|
||
output = io.StringIO() | ||
output.write((_MOVE_UP + _CLEAR_LINE) * visible_lines) | ||
|
||
now = time.monotonic() | ||
|
||
_flush_print_queue(output=output) | ||
|
||
with _lines_lock: | ||
for line in _lines: | ||
if line._next_frame_update <= now: | ||
line._active_frame = (line._active_frame + 1) % len( | ||
line._FRAMES | ||
) | ||
line._next_frame_update = now + _FRAME_DURATION | ||
|
||
print(line._FRAMES[line._active_frame], line._content, file=output) | ||
|
||
visible_lines = len(_lines) | ||
next_frame_update = _get_next_frame_update_unlocked() | ||
|
||
self.output.write(output.getvalue()) | ||
self.output.flush() | ||
|
||
sleep_duration = 0.05 | ||
if next_frame_update is not None: | ||
sleep_duration = min(sleep_duration, next_frame_update - now) | ||
|
||
_done_event.wait(sleep_duration) | ||
|
||
|
||
class _ForwardThread(threading.Thread): | ||
def __init__(self, input: IO[str]) -> None: | ||
super().__init__() | ||
self.input = input | ||
|
||
def run(self) -> None: | ||
with self.input: | ||
while True: | ||
data = self.input.read(4096) | ||
if not data: | ||
break | ||
|
||
_print_queue.put(data) | ||
_update_event.set() | ||
|
||
|
||
def disable_fancy_console() -> None: | ||
global _console_state | ||
_console_state = _ConsoleState.DISABLED | ||
|
||
|
||
@contextlib.contextmanager | ||
def activate_fancy_console() -> Iterator[None]: | ||
global _console_state | ||
assert _console_state != _ConsoleState.ACTIVE | ||
|
||
with contextlib.ExitStack() as stack: | ||
forward: Optional[_ForwardThread] = None | ||
|
||
if _console_state != _ConsoleState.DISABLED and sys.stderr.isatty(): | ||
_UpdateThread(sys.stderr).start() | ||
|
||
reader_fd, writer_fd = os.pipe() | ||
reader = os.fdopen(reader_fd, 'r') | ||
writer = os.fdopen(writer_fd, 'w') | ||
|
||
stack.enter_context(writer) | ||
stack.enter_context(contextlib.redirect_stderr(writer)) | ||
|
||
if sys.stdout.isatty(): | ||
# Just assume that, if both are a TTY, they're going to the *same* TTY. | ||
stack.enter_context(contextlib.redirect_stdout(writer)) | ||
|
||
forward = _ForwardThread(reader) | ||
forward.start() | ||
|
||
_console_state = _ConsoleState.ACTIVE | ||
|
||
try: | ||
yield | ||
finally: | ||
if _console_state == _ConsoleState.ACTIVE: | ||
_console_state = _ConsoleState.INACTIVE | ||
|
||
stack.close() | ||
|
||
_done_event.set() | ||
_update_event.set() | ||
|
||
if forward is not None: | ||
forward.join() | ||
|
||
|
||
class AnimatedConsoleLine: | ||
_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] | ||
|
||
HEADING_WIDTH = 2 | ||
|
||
def __init__(self) -> None: | ||
self._content = '' | ||
self._next_frame_update = 0.0 | ||
self._active_frame = -1 | ||
|
||
@staticmethod | ||
def claim() -> Optional['AnimatedConsoleLine']: | ||
if _console_state != _ConsoleState.ACTIVE: | ||
return None | ||
|
||
with _lines_lock: | ||
line = AnimatedConsoleLine() | ||
_lines.append(line) | ||
return line | ||
|
||
def release(self) -> None: | ||
with _lines_lock: | ||
_lines.remove(self) | ||
_update_event.set() | ||
|
||
@property | ||
def content(self) -> str: | ||
return self._content | ||
|
||
def update(self, content: str) -> None: | ||
self._content = content | ||
_update_event.set() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.