"""Terminal progress bar spinners."""
import atexit
import logging
import random
import sys
from typing import IO, Any, Sequence
__all__ = ["Spinner", "SpinnerHandler"]
SPINNER_ARC: Sequence[str] = [
"◜",
"◠",
"◝",
"◞",
"◡",
"◟",
]
SPINNER_ARROW: Sequence[str] = [
"←",
"↖",
"↑",
"↗",
"→",
"↘",
"↓",
"↙",
]
SPINNER_CIRCLE: Sequence[str] = [
"•",
"◦",
"●",
"○",
"◎",
"◉",
"⦿",
]
SPINNER_SQUARE: Sequence[str] = [
"◢",
"◣",
"◤",
"◥",
]
SPINNER_MOON: Sequence[str] = [
"🌑 ",
"🌒 ",
"🌓 ",
"🌔 ",
"🌕 ",
"🌖 ",
"🌗 ",
"🌘 ",
]
SPINNERS: Sequence[Sequence[str]] = [
SPINNER_ARC,
SPINNER_ARROW,
SPINNER_CIRCLE,
SPINNER_SQUARE,
SPINNER_MOON,
]
ACTIVE_SPINNER: Sequence[str] = random.choice(SPINNERS) # nosec B311
[docs]class Spinner:
"""Progress bar spinner."""
bell = "\b"
sprites: Sequence[str] = ACTIVE_SPINNER
cursor_hide: str = "\x1b[?25l"
cursor_show: str = "\x1b[?25h"
hide_cursor: bool = True
stopped: bool = False
def __init__(self, file: IO = sys.stderr) -> None:
self.file: IO = file
self.width: int = 0
self.count = 0
self.stopped = False
[docs] def update(self) -> None:
"""Draw spinner, single iteration."""
if not self.stopped:
if not self.count:
self.begin()
i = self.count % len(self.sprites)
self.count += 1
self.write(self.sprites[i])
[docs] def stop(self) -> None:
"""Stop spinner from being emitted."""
self.stopped = True
[docs] def reset(self) -> None:
"""Reset state or allow restart."""
self.stopped = False
self.count = 0
[docs] def write(self, s: str) -> None:
"""Write spinner character to terminal."""
if self.file.isatty():
self._print(f"{self.bell * self.width}{s.ljust(self.width)}")
self.width = max(self.width, len(s))
def _print(self, s: str) -> None:
print(s, end="", file=self.file)
self.file.flush()
[docs] def begin(self) -> None:
"""Prepare terminal for spinner starting."""
atexit.register(type(self)._finish, self.file, at_exit=True)
self._print(self.cursor_hide)
[docs] def finish(self) -> None:
"""Finish spinner and reset terminal."""
print(f"{self.bell * (self.width + 1)}", end="", file=self.file)
self._finish(self.file)
self.stop()
@classmethod
def _finish(cls, file: IO, *, at_exit: bool = False) -> None:
print(cls.cursor_show, end="", file=file)
file.flush()
[docs]class SpinnerHandler(logging.Handler):
"""A logger handler that iterates our progress spinner for each log."""
spinner: Spinner
# For every logging call we advance the terminal spinner (-\/-)
def __init__(self, spinner: Spinner, **kwargs: Any) -> None:
self.spinner = spinner
super().__init__(**kwargs)
[docs] def emit(self, _record: logging.LogRecord) -> None:
"""Emit the next spinner character."""
# the spinner is only in effect with WARN level and below.
if self.spinner and not self.spinner.stopped:
self.spinner.update()