Source code for cliasi.cliasi

import builtins
import logging
import sys
import textwrap
from collections.abc import Callable
from getpass import getpass
from random import randint
from threading import Event, Lock, Thread
from time import sleep
from typing import TextIO

from .constants import (
    ANIMATION_SYMBOLS_PROGRESSBAR,
    ANIMATIONS_MAIN,
    ANIMATIONS_SYMBOLS,
    DEFAULT_TERMINAL_SIZE,
    UNICORN,
    CursorPos,
    PBCalculationMode,
    TextColor,
)

_print_lock: Lock = Lock()

STDOUT_STREAM: TextIO
"""Default stdout stream for cliasi messages. Used by all Cliasi instances"""
STDOUT_STREAM = sys.stdout
STDERR_STREAM: TextIO
"""Default stderr stream for cliasi messages. Used by all Cliasi instances"""
STDERR_STREAM = sys.stderr

# Try to get the terminal size
try:
    import os
    import shutil

    cols = int(os.environ.get("COLUMNS", 80))
    rows = int(os.environ.get("LINES", 24))

    def _terminal_size() -> int:
        return shutil.get_terminal_size(fallback=(cols, rows))[0] - 1
        # - 1 to avoid printing to the terminal edge.
        # [0] for cols

    _terminal_size()  # Try if getting terminal size works

except Exception as e:
    print("! [cliasi] Error: Could not retrieve terminal size!", e)

    def _terminal_size() -> int:
        return DEFAULT_TERMINAL_SIZE


[docs] class Cliasi: """A Cliasi CLI instance. Stores display settings and a minimum verbosity threshold.""" min_verbose_level: int messages_stay_in_one_line: bool enable_colors: bool max_dead_space: int | None __prefix_seperator: str __space_before_message: int # Number of spaces before message start (for alignment) def __init__( self, prefix: str = "", messages_stay_in_one_line: bool = False, colors: bool = True, min_verbose_level: int | None = None, seperator: str = "|", max_dead_space: int | None = 150, ): """ Initialize a cliasi instance. :param prefix: Message Prefix [prefix] message :param messages_stay_in_one_line: Have all messages appear in one line by default :param colors: Enable color display :param min_verbose_level: Only displays messages with verbose level higher than this value (default is logging.INFO), None will result in the verbosity level getting set to the value of the global instance which is by default 0 :param seperator: Seperator between prefix and message :param max_dead_space: Sets the maximum dead space (space characters) allowed when a message uses alignments. Too much dead space will cause people to not read aligned messages. Set to None to disable. """ self.__prefix = "" self.messages_stay_in_one_line = messages_stay_in_one_line self.enable_colors = colors self.min_verbose_level = ( min_verbose_level if min_verbose_level is not None else cli.min_verbose_level ) self.__prefix_seperator = seperator self.max_dead_space = max_dead_space self.set_prefix(prefix) def __compute_space_before_message(self) -> None: """ Compute empty space before message for alignment WITHOUT symbol! :return: None """ # symbol + space (1) + prefix + space(2) + separator + space (3) -> message self.__space_before_message = ( 3 + len(self.__prefix) + len(self.__prefix_seperator) )
[docs] def set_seperator(self, seperator: str) -> None: """ Set the seperator between prefix and message :param seperator: Seperator, usually only one character :return: None """ self.__prefix_seperator = seperator self.__compute_space_before_message()
[docs] def set_prefix(self, prefix: str) -> None: """ Update the message prefix of this instance. Prefixes should be three letters long but do as you wish. :param prefix: New message prefix without brackets [] :return: None """ self.__prefix = f"[{prefix}]" self.__compute_space_before_message()
def __verbose_check(self, level: int) -> bool: """ Check if message should be interrupted by verbose level. :param level: given verbosity level :return: False if message should be sent, true if message should not be sent """ return level < self.min_verbose_level def __print( self, color: TextColor | str, symbol: str, message_left: str | bool | None, messages_stay_in_one_line: bool | None, message_center: str | bool | None = False, message_right: str | bool | None = False, color_message: bool = True, write_to_stderr: bool = False, ) -> dict[str, int] | None: """ Print message to the console with word wrapping and customizable separators. If parameters left, center, and right are None, this will do nothing. :param color: Color to print message and symbol with (ASCII escape code) :param symbol: Symbol to print at the start of the message :param message_left: Message or bool flag to print on left side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param color_message: Print the main message with color :param write_to_stderr: Write message to stderr instead of stdout :return: Metadata about the printed line (alignments) or None """ oneline = ( self.messages_stay_in_one_line if messages_stay_in_one_line is None else messages_stay_in_one_line ) preamble_len = self.__space_before_message + len(symbol) + 1 content_space = max(1, _terminal_size() - preamble_len) # space(1) + symbol + space_before_message (prefix + seperator) -> message reset_message_left = False if isinstance(message_center, bool) and message_center: # flag set message_center = message_left reset_message_left = True if isinstance(message_right, bool) and message_right: # flag set message_right = message_left reset_message_left = True if reset_message_left: message_left = False content_total = message_left if isinstance(message_left, str) else "" content_total += message_center if isinstance(message_center, str) else "" content_total += message_right if isinstance(message_right, str) else "" separating_space = ( 1 if (isinstance(message_left, str) and message_left != "") else 0 ) separating_space += ( 1 if (isinstance(message_center, str) and message_center != "") else 0 ) separating_space += ( 1 if (isinstance(message_right, str) and message_right != "") else 0 ) separating_space -= 1 # elements - 1 = separating_space lines = [] if content_total == "": # Nothing to print return None # content_space - separating space (needed to separate left, right etc) if (content_space - separating_space) < len( content_total ) or "\n" in content_total: # Can't print in one line. content_to_split = "" if isinstance(message_left, str): content_to_split += message_left if isinstance(message_center, str): content_to_split += ( " " + message_center if isinstance(message_left, str) and message_left != "" else message_center ) # Add space only if message_left is set if isinstance(message_right, str) and ( len(message_right) > content_space # too long -> no alignment or "\n" in message_right # multiline -> no alignment attempt ): # message_right won't be aligned due to it being multiline content_to_split += ( " " + message_right if ( (isinstance(message_left, str) and message_left != "") or (isinstance(message_center, str) and message_center != "") # Only if one of the previous ones exists ) else message_right ) for paragraph in content_to_split.splitlines(): wrapped = textwrap.wrap(paragraph, width=content_space) if wrapped: lines.extend(wrapped) else: lines.append("") # right aligned content not alignable -> no alignment. left_end = len(lines[-1]) center_end = len(lines[-1]) right_end = len(lines[-1]) # CursorPos alignment not possible because message_right is multiline # we are done test_right_multiline_too_long else: # right message might be alignable for paragraph in content_to_split.splitlines(): wrapped = textwrap.wrap(paragraph, width=content_space) if wrapped: lines.extend(wrapped) else: lines.append("") # left and center are together in one string # see if the last line has space for right message_right if ( isinstance(message_right, str) and len(lines[-1]) + len(message_right) + 1 <= content_space ): # Alignment possible on the last row left_end = len(lines[-1]) # left and center end at len of last line center_end = len(lines[-1]) lines[-1] = ( lines[-1] + " " * (content_space - len(lines[-1]) - len(message_right)) + message_right ) # right_aligned content aligned on last line right_end = len(lines[-1]) # CursorPos alignment not possible because message_right is multiline # we are done test_right_fit_last_line elif isinstance(message_right, str): # Alignment not possible, append message_right for paragraph in (lines.pop() + " " + message_right).splitlines(): wrapped = textwrap.wrap(paragraph, width=content_space) if wrapped: lines.extend(wrapped) else: lines.append("") # right aligned content not alignable -> no alignment. left_end = len(lines[-1]) center_end = len(lines[-1]) right_end = len(lines[-1]) # CursorPos alignment not possible because message_right got # multiline due to left + center being too long # we are done test_right_no_fit_last_line else: left_end = len(lines[-1]) center_end = len(lines[-1]) right_end = len(lines[-1]) # else: # All content is alignable in one line m_left = message_left if isinstance(message_left, str) else "" m_center = message_center if isinstance(message_center, str) else "" m_right = message_right if isinstance(message_right, str) else "" if ( isinstance(self.max_dead_space, int) # Logic is enabled and message_left is not False # Left message not disabled deliberately and (content_space - len(content_total) - separating_space) > self.max_dead_space ): # Too much dead space, print one after the other lines.append( m_left + (" " if m_left and m_center else "") + m_center + (" " if (m_left or m_center) and m_right else "") + m_right ) left_end = len(lines[-1]) center_end = len(lines[-1]) right_end = len(lines[-1]) else: line = m_left left_end = len(line) if m_center: center_start = (content_space - len(m_center)) // 2 needed_space = 1 if m_left else 0 if center_start >= len(line) + needed_space: line += " " * (center_start - len(line)) + m_center else: line += (" " if m_left else "") + m_center center_end = len(line) else: center_end = left_end if m_right: right_start = content_space - len(m_right) needed_space = 1 if (m_left or m_center) else 0 if right_start >= len(line) + needed_space: line += " " * (right_start - len(line)) + m_right else: line += (" " if (m_left or m_center) else "") + m_right right_end = len(line) else: right_end = center_end lines.append(line) with _print_lock: index = 0 for line in lines: index += 1 is_last = index == len(lines) end_str = ("" if (oneline and is_last) else "\n") + TextColor.RESET if index == 1: # Printing first / last line print( "\r\x1b[2K\r", (color if self.enable_colors else "") + symbol, TextColor.DIM + self.__prefix + TextColor.RESET, self.__prefix_seperator + (color if self.enable_colors and color_message else ""), line, file=STDERR_STREAM if write_to_stderr else STDOUT_STREAM, end=end_str, flush=True, ) else: # Printing multiline strings # prefix, and symbol are replaced with spaces print( "\r\x1b[2K\r", # space (done because new print function argument) # + symbol # + space (1) # + prefix # + space (not done because new argument) " " * (len(self.__prefix) + len(symbol) + 1), self.__prefix_seperator, (color if self.enable_colors and color_message else "") + line, file=STDERR_STREAM if write_to_stderr else STDOUT_STREAM, end=end_str, flush=True, ) return { "left_end": preamble_len + left_end, "center_end": preamble_len + center_end, "right_end": preamble_len + right_end, "total_length": preamble_len + len(lines[-1]), }
[docs] def message( self, message_left: str | bool | None, verbosity: int = logging.INFO, message_center: str | bool | None = False, message_right: str | bool | None = False, messages_stay_in_one_line: bool | None = None, ) -> None: """ Send a message in format # [prefix] message :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param verbosity: Verbosity of this message :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: None """ if self.__verbose_check(verbosity): return self.__print( TextColor.WHITE + TextColor.DIM, "#", message_left, messages_stay_in_one_line, message_center=message_center, message_right=message_right, color_message=False, )
[docs] def info( self, message_left: str | bool | None, verbosity: int = logging.INFO, message_center: str | bool | None = False, message_right: str | bool | None = False, messages_stay_in_one_line: bool | None = None, ) -> None: """ Print an informational message. Send an info message in format i [prefix] message :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param verbosity: Verbosity of this message :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: None """ if self.__verbose_check(verbosity): return self.__print( TextColor.BRIGHT_WHITE, "i", message_left, messages_stay_in_one_line, message_center=message_center, message_right=message_right, color_message=False, )
[docs] def log( self, message_left: str | bool | None, verbosity: int = logging.DEBUG, message_center: str | bool | None = False, message_right: str | bool | None = False, messages_stay_in_one_line: bool | None = None, ) -> None: """ Send a log message in format LOG [prefix] message :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param verbosity: Verbosity of this message :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: None """ if self.__verbose_check(verbosity): return self.__print( TextColor.WHITE + TextColor.DIM, "LOG", message_left, messages_stay_in_one_line, message_center=message_center, message_right=message_right, color_message=False, )
[docs] def log_small( self, message_left: str | bool | None, verbosity: int = logging.DEBUG, message_center: str | bool | None = False, message_right: str | bool | None = False, messages_stay_in_one_line: bool | None = None, ) -> None: """ Send a log message in format LOG [prefix] message :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param verbosity: Verbosity of this message :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: None """ if self.__verbose_check(verbosity): return self.__print( TextColor.WHITE + TextColor.DIM, "L", message_left, messages_stay_in_one_line, message_center=message_center, message_right=message_right, color_message=False, )
[docs] def list( self, message_left: str | bool | None, verbosity: int = logging.INFO, message_center: str | bool | None = False, message_right: str | bool | None = False, messages_stay_in_one_line: bool | None = None, ) -> None: """ Send a list style message in format * [prefix] message :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param verbosity: Verbosity of this message :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: None """ if self.__verbose_check(verbosity): return self.__print( TextColor.BRIGHT_WHITE, "-", message_left, messages_stay_in_one_line, message_center=message_center, message_right=message_right, color_message=False, )
[docs] def warn( self, message_left: str | bool | None, verbosity: int = logging.WARNING, message_center: str | bool | None = False, message_right: str | bool | None = False, messages_stay_in_one_line: bool | None = None, ) -> None: """ Send a warning message in format ! [prefix] message :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param verbosity: Verbosity of this message :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: None """ if self.__verbose_check(verbosity): return self.__print( TextColor.BRIGHT_YELLOW, "!", message_left, messages_stay_in_one_line, message_center=message_center, message_right=message_right, )
[docs] def fail( self, message_left: str | bool | None, verbosity: int = logging.CRITICAL, message_center: str | bool | None = False, message_right: str | bool | None = False, messages_stay_in_one_line: bool | None = None, ) -> None: """ Send a failure message in format X [prefix] message :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param verbosity: Verbosity of this message :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: None """ if self.__verbose_check(verbosity): return self.__print( TextColor.BRIGHT_RED, "X", message_left, messages_stay_in_one_line, message_center=message_center, message_right=message_right, write_to_stderr=True, )
[docs] def success( self, message_left: str | bool | None, verbosity: int = logging.INFO, message_center: str | bool | None = False, message_right: str | bool | None = False, messages_stay_in_one_line: bool | None = None, ) -> None: """ Send a success message in format ✔ [prefix] message :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param verbosity: Verbosity of this message :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: None """ if self.__verbose_check(verbosity): return self.__print( TextColor.BRIGHT_GREEN, "✔", message_left, messages_stay_in_one_line, message_center=message_center, message_right=message_right, )
[docs] @staticmethod def newline() -> None: """ Print a newline. :return: None """ print("", file=STDOUT_STREAM, flush=True)
[docs] def ask( self, message_left: str | bool | None, hide_input: bool = False, message_center: str | bool | None = False, message_right: str | bool | None = False, messages_stay_in_one_line: bool | None = None, cursor_position: CursorPos = CursorPos.LEFT, ) -> str: """ Ask for input in format ? [prefix] message :param message_left: Question to ask Can also be a bool flag to print left-aligned. :param hide_input: True hides user input :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param messages_stay_in_one_line: Override the message to stay in one line :param cursor_position: Cursor position of input cursor Only available when output is not longer than one line OR if last line has space for message_right and the cursor position is LEFT or RIGHT :return: The user input as a string. """ metadata = self.__print( TextColor.BRIGHT_MAGENTA if hide_input else TextColor.MAGENTA, "?", message_left, True, message_center=message_center, message_right=message_right, ) if metadata and cursor_position != CursorPos.RIGHT: target = metadata[f"{cursor_position.lower()}_end"] walk_back = metadata["total_length"] - target if walk_back > 0: print(f"\x1b[{walk_back}D", end="", flush=True) if hide_input: result = getpass(" ") else: result = input(" ") oneline = ( self.messages_stay_in_one_line if messages_stay_in_one_line is None else messages_stay_in_one_line ) if oneline: print("\x1b[1A\x1b[2K", end="") return result
def __show_animation_frame( self, message_left: str | bool | None, message_center: str | bool | None, message_right: str | bool | None, color: TextColor | str, current_symbol_frame: str, current_animation_frame: str, ) -> None: """ Show a single animation frame based on the total index :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param color: Color of message :param current_symbol_frame: Current symbol animation to show :param current_animation_frame: Current animation frame to show :return: None """ self.__print( color, current_symbol_frame, current_animation_frame + ("" if current_animation_frame == "" else " ") + message_left if isinstance(message_left, str) else "", True, message_center=message_center, message_right=message_right, )
[docs] def animate_message_blocking( self, message_left: str | bool | None, time: int | float, message_center: str | bool | None = False, message_right: str | bool | None = False, verbosity: int = logging.INFO, interval: int | float = 0.25, unicorn: bool = False, messages_stay_in_one_line: bool | None = None, ) -> None: """ Display a loading animation for a fixed time This will block the main thread using time.sleep :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param time: Time to display for :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param verbosity: Verbosity of this message :param interval: Interval between changes in loading animation :param unicorn: Enable unicorn mode :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: None """ if self.__verbose_check(verbosity): return remaining = time selection_symbol, selection_animation = ( randint(0, len(ANIMATIONS_SYMBOLS) - 1), randint(0, len(ANIMATIONS_MAIN) - 1), ) symbol_frames = ANIMATIONS_SYMBOLS[selection_symbol] frames_val = ANIMATIONS_MAIN[selection_animation]["frames"] if not isinstance(frames_val, list): self.warn( f"CLIASI error: " f"Animation frames must be a list, got {type(frames_val).__name__}." f" Falling back to default frames.", messages_stay_in_one_line=False, ) animation_frames: list[str] = ( frames_val if isinstance(frames_val, list) else ["*", "-"] ) frame_every_val = ANIMATIONS_MAIN[selection_animation]["frame_every"] if not isinstance(frame_every_val, int): self.warn( f"CLIASI error: " f"frame_every must be an int, got {type(frame_every_val).__name__}." f" Falling back to 1.", messages_stay_in_one_line=False, ) frame_every: int = frame_every_val if isinstance(frame_every_val, int) else 1 index_total = 0 while remaining > 0: self.__show_animation_frame( message_left, message_center, message_right, TextColor.BRIGHT_MAGENTA if not unicorn else UNICORN[index_total % len(UNICORN)], symbol_frames[index_total % len(symbol_frames)], animation_frames[(index_total // frame_every) % len(animation_frames)], ) index_total += 1 remaining -= interval if remaining < interval: break sleep(interval) sleep(remaining) oneline = ( self.messages_stay_in_one_line if messages_stay_in_one_line is None else messages_stay_in_one_line ) if not oneline: print("")
def __format_progressbar_to_screen_width( self, message_left: str | bool | None, message_center: str | bool | None, message_right: str | bool | None, cover_dead_space_with_bar: bool, calculation_mode: PBCalculationMode, symbol: str, progress: int, show_percent: bool, ) -> str: """ Returns a string representation of the progress bar Like this [====message===] xx% If the text is too long, it will get cut off. Percentage will then be shown. :param message_left: Message to display on the left side of the bar or bool flag to disable :param message_center: Message to display in the center of the bar or bool flag to disable :param message_right: Message to display on the right side of the bar or bool flag to disable :param cover_dead_space_with_bar: Cover the space between messages with the progressbar If True, looks like this: [=message== ... ] If False, looks like this: [ message ===== ... ] :param calculation_mode: How to fill the progressbar. Set to FULL_WIDTH for bar to go trough messages. Set to ONLY_EMPTY to fill empty space between messages only. Set to FULL_WIDTH_OVERWRITE to overwrite messages with the bar. :param symbol: Symbol to get symbol length :param progress: Progress to display :param show_percent: Show percentage at end of bar :return: String representation of the progress bar """ try: p = int(progress) except ValueError: p = 0 p = max(0, min(100, p)) # Apply left/center/right flag semantics similar to __print reset_message_left = False if isinstance(message_center, bool) and message_center: message_center = message_left reset_message_left = True if isinstance(message_right, bool) and message_right: message_right = message_left reset_message_left = True if reset_message_left: message_left = False m_left = message_left if isinstance(message_left, str) else "" m_center = message_center if isinstance(message_center, str) else "" m_right = message_right if isinstance(message_right, str) else "" message_len = len(m_left) + len(m_center) + len(m_right) def build_bar( show_percent_flag: bool, ) -> tuple[list[str], set[int], int, bool]: percent_suffix = f" {p}%" if show_percent_flag else "" width = max( 8, _terminal_size() - max( 0, 1 + len(symbol) + self.__space_before_message + len(percent_suffix), ) - 3, ) bar_chars = [" "] * width occupied: set[int] = set() if not message_len or width <= 0: return bar_chars, occupied, width, False separating_space = ( (1 if m_left else 0) + (1 if m_center else 0) + (1 if m_right else 0) ) separating_space = max(0, separating_space - 1) content_total = m_left + m_center + m_right truncated = False if len(content_total) + separating_space > width: # Need to truncate combined = ( m_left + (" " if m_left and m_center else "") + m_center + (" " if (m_left or m_center) and m_right else "") + m_right ) truncated = True if width >= 3: combined = combined[: width - 1] + "…" else: combined = combined[:width] for idx, ch in enumerate(combined): if idx >= width: break bar_chars[idx] = ch occupied.add(idx) return bar_chars, occupied, width, truncated dead_space_too_large = ( isinstance(self.max_dead_space, int) and message_left is not False and (width - len(content_total) - separating_space) > self.max_dead_space ) if dead_space_too_large: # Dead space too large, print one after the other cursor = 0 for m in (m_left, m_center, m_right): if not m: continue if cursor < width: bar_chars[cursor] = " " if not cover_dead_space_with_bar: occupied.add(cursor) cursor += 1 for idx, ch in enumerate(m): pos = cursor + idx if pos >= width: break bar_chars[pos] = ch occupied.add(pos) cursor += len(m) if not cover_dead_space_with_bar: occupied.add(cursor) return bar_chars, occupied, width, truncated # All three messages have space, align them current_end = 0 if m_left: left_padding = 1 if width > 1 else 0 start = left_padding if not cover_dead_space_with_bar and width > 1: occupied.add(0) for idx, ch in enumerate(m_left): pos = start + idx if pos >= width: break bar_chars[pos] = ch occupied.add(pos) current_end = start + len(m_left) if not cover_dead_space_with_bar: occupied.add(current_end) if m_center: center_start = max(0, (width - len(m_center)) // 2) needed_space = 1 if m_left else 0 if center_start < (current_end + needed_space): # center message directly after left message if m_left and current_end < width: bar_chars[current_end] = " " occupied.add(current_end) current_end += 1 center_start = current_end for idx, ch in enumerate(m_center): pos = center_start + idx if pos >= width: break bar_chars[pos] = ch occupied.add(pos) current_end = max(current_end, center_start + len(m_center)) if m_right: needed_space = 1 if (m_left or m_center) else 0 trailing_space = 1 if width > len(m_right) else 0 right_start = max(0, width - len(m_right) - trailing_space) if right_start < current_end + needed_space: if (m_left or m_center) and current_end < width: bar_chars[current_end] = " " occupied.add(current_end) current_end += 1 right_start = current_end if not cover_dead_space_with_bar: occupied.add(max(right_start - 1, 0)) for idx, ch in enumerate(m_right): pos = right_start + idx if pos >= width: break bar_chars[pos] = ch occupied.add(pos) # trailing space stays unoccupied so fill can reach the end if not cover_dead_space_with_bar: occupied.add(right_start + (len(m_right) - 1) + trailing_space) return bar_chars, occupied, width, truncated bar_chars, occupied, inside_width, was_truncated = build_bar(show_percent) force_percent = False if message_len and inside_width > 0 and (message_len / inside_width) >= 0.7: force_percent = True effective_show_percent = show_percent or was_truncated or force_percent if effective_show_percent and not show_percent: bar_chars, occupied, inside_width, was_truncated = build_bar(True) fillable = max( 0, inside_width - (0 if calculation_mode.startswith("FULL") else len(occupied)), ) target_fill = round((p / 100.0) * fillable) filled = 0 index = 0 while filled < target_fill and index < inside_width: if ( index in occupied and calculation_mode != PBCalculationMode.FULL_WIDTH_OVERWRITE ): index += 1 continue bar_chars[index] = "=" filled += 1 index += 1 return ( "[" + "".join(bar_chars) + "]" + (f" {p}%" if effective_show_percent else "") )
[docs] def progressbar( self, message_left: str | bool | None, message_center: str | bool | None = False, message_right: str | bool | None = False, cover_dead_space_with_bar: bool = False, calculation_mode: PBCalculationMode = PBCalculationMode.FULL_WIDTH, verbosity: int = logging.INFO, progress: int = 0, messages_stay_in_one_line: bool | None = True, show_percent: bool = False, ) -> None: """ Display a progress bar with specified progress This is not animated. Call it multiple times to update :param message_left: Message to display on the left side of the bar or bool flag to enable / disable :param message_center: Message to display in the center of the bar or bool flag to enable / disable :param message_right: Message to display on the right side of the bar or bool flag to enable / disable :param cover_dead_space_with_bar: Cover the space between messages with the progressbar If True, looks like this: [=message== ... ] If False, looks like this: [ message ===== ... ] :param calculation_mode: How to fill the progressbar. Set to FULL_WIDTH for bar to go trough messages. Set to ONLY_EMPTY to fill empty space between messages only. Set to FULL_WIDTH_OVERWRITE to overwrite messages with the bar. :param verbosity: Verbosity to display :param progress: Progress to display :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :param show_percent: Show percent next to the progressbar :return: None """ if self.__verbose_check(verbosity): return # Print the bar. # Keep it on one line unless overridden by messages_stay_in_one_line. self.__print( TextColor.BLUE, "#", self.__format_progressbar_to_screen_width( message_left, message_center, message_right, cover_dead_space_with_bar, calculation_mode, "#", progress, show_percent, ), messages_stay_in_one_line, )
[docs] def progressbar_download( self, message_left: str | bool | None, message_center: str | bool | None = False, message_right: str | bool | None = False, cover_dead_space_with_bar: bool = False, calculation_mode: PBCalculationMode = PBCalculationMode.FULL_WIDTH, verbosity: int = logging.INFO, progress: int = 0, show_percent: bool = False, messages_stay_in_one_line: bool | None = True, ) -> None: """ Display a download bar with specified progress This is not animated. Call it multiple times to update :param message_left: Message to display on the left side of the bar or bool flag to enable / disable :param message_center: Message to display in the center of the bar or bool flag to enable / disable :param message_right: Message to display on the right side of the bar or bool flag to enable / disable :param cover_dead_space_with_bar: Cover the space between messages with the progressbar If True, looks like this: [=message== ... ] If False, looks like this: [ message ===== ... ] :param calculation_mode: How to fill the progressbar. Set to FULL_WIDTH for bar to go trough messages. Set to ONLY_EMPTY to fill empty space between messages only. Set to FULL_WIDTH_OVERWRITE to overwrite messages with the bar. :param verbosity: Verbosity to display :param progress: Progress to display :param show_percent: Show percent next to the progressbar :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: None """ if self.__verbose_check(verbosity): return self.__print( TextColor.BRIGHT_CYAN, "⤓", self.__format_progressbar_to_screen_width( message_left, message_center, message_right, cover_dead_space_with_bar, calculation_mode, "⤓", progress, show_percent, ), messages_stay_in_one_line, )
[docs] class NonBlockingAnimationTask: """ Defines a non-blocking animation task run on another Thread """ _message_stays_in_one_line: bool _condition: Event _message_left: str | bool | None # Current message to display _message_center: str | bool | None # Current message to display _message_right: str | bool | None # Current message to display _index: int = 0 # Animation frame total index _thread: Thread _update: Callable[ [], None ] # Update call to update with current animation frame def __init__( self, message_left: str | bool | None, message_center: str | bool | None, message_right: str | bool | None, stop_condition: Event, message_stays_in_one_line: bool, ) -> None: self._message_left = message_left self._message_center = message_center self._message_right = message_right self._message_stays_in_one_line = message_stays_in_one_line self._condition = stop_condition
[docs] def stop(self) -> None: """ Stop the current animation task :return: """ self._condition.set() self._thread.join() if not self._message_stays_in_one_line: print("")
[docs] def update( self, message_left: str | bool | None = None, message_center: str | bool | None = None, message_right: str | bool | None = None, ) -> None: """ Update message of animation :param message_left: Message or bool flag to update to (None for no update) :param message_center: Message or bool flag to update to (None for no update) :param message_right: Message or bool flag to update to (None for no update) :return: None """ self._message_left = ( message_left if message_left is not None else self._message_left ) self._message_center = ( message_center if message_center is not None else self._message_center ) self._message_right = ( message_right if message_right is not None else self._message_right ) self._update()
def __get_animation_task( self, message_left: str | bool | None, message_center: str | bool | None, message_right: str | bool | None, color: TextColor, symbol_animation: builtins.list[str], main_animation: dict[str, int | builtins.list[str]], interval: int | float, unicorn: bool = False, messages_stay_in_one_line: bool | None = True, ) -> NonBlockingAnimationTask: """ Create an animation task :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param color: Color of message :param symbol_animation: The symbol animation to display as string frames in a list :param main_animation: The main animation to display as string frames in a list :param interval: The interval to display as string frames in a list :param unicorn: Enable unicorn mode :param messages_stay_in_one_line: Override message to stay in one line :return A NonBlockingAnimationTask """ condition = Event() task = Cliasi.NonBlockingAnimationTask( message_left, message_center, message_right, condition, messages_stay_in_one_line if messages_stay_in_one_line is not None else self.messages_stay_in_one_line, ) frames_val = main_animation["frames"] if not isinstance(frames_val, list): self.warn( f"CLIASI error: " f"Animation frames must be a list, got {type(frames_val).__name__}." f" Falling back to default frames.", messages_stay_in_one_line=False, ) frames: list[str] = frames_val if isinstance(frames_val, list) else ["*", "-"] frame_every_val = main_animation["frame_every"] if not isinstance(frame_every_val, int): self.warn( f"CLIASI error: " f"frame_every must be an int, got {type(frame_every_val).__name__}." f" Falling back to 1.", messages_stay_in_one_line=False, ) frame_every: int = frame_every_val if isinstance(frame_every_val, int) else 1 def update() -> None: """ Update the animation to the current frame :return: None """ self.__show_animation_frame( task._message_left, task._message_center, task._message_right, color if not unicorn else UNICORN[task._index % len(UNICORN)], symbol_animation[task._index % len(symbol_animation)], frames[(task._index // frame_every) % len(frames)], ) def animate() -> None: """ Main animation task to be run in thread :return: None """ while not condition.is_set(): task.update() task._index += 1 condition.wait(timeout=interval) thread = Thread(target=animate, daemon=True) task._thread = thread task._update = update thread.start() return task
[docs] def animate_message_non_blocking( self, message_left: str | bool | None, message_center: str | bool | None = False, message_right: str | bool | None = False, verbosity: int = logging.INFO, interval: int | float = 0.25, unicorn: bool = False, messages_stay_in_one_line: bool | None = None, ) -> NonBlockingAnimationTask | None: """ Display a loading animation in the background Stop animation by calling .stop() on the returned object :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param verbosity: Verbosity of message :param interval: Interval for animation to play :param unicorn: Enable unicorn mode :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: NonBlockingAnimationTask if verbosity requirement is met. """ if self.__verbose_check(verbosity): return self.__get_null_task() selection_symbol, selection_animation = ( randint(0, len(ANIMATIONS_SYMBOLS) - 1), randint(0, len(ANIMATIONS_MAIN) - 1), ) return self.__get_animation_task( message_left, message_center, message_right, TextColor.BRIGHT_MAGENTA, ANIMATIONS_SYMBOLS[selection_symbol], ANIMATIONS_MAIN[selection_animation], interval, unicorn, messages_stay_in_one_line, )
[docs] def animate_message_download_non_blocking( self, message_left: str | bool | None, message_center: str | bool | None = False, message_right: str | bool | None = False, verbosity: int = logging.INFO, interval: int | float = 0.25, unicorn: bool = False, messages_stay_in_one_line: bool | None = True, ) -> NonBlockingAnimationTask | None: """ Display a downloading animation in the background :param message_left: Message to send or bool flag to print left-aligned. See message_center and message_right for details. :param message_center: Message or bool flag to print centered to terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param message_right: Message or bool flag to print on right side of terminal If messages do not fit into their sections or messages are multiline, they will be outputted one after the other (except for right aligned content) thus destroying any alignment. :param verbosity: Verbosity of message :param interval: Interval for animation to play :param unicorn: Enable unicorn mode :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: A NonBlockingAnimationTask if verbosity requirement is met. """ if self.__verbose_check(verbosity): return self.__get_null_task() selection_animation = randint(0, len(ANIMATIONS_MAIN) - 1) return self.__get_animation_task( message_left, message_center, message_right, TextColor.BRIGHT_CYAN, ANIMATION_SYMBOLS_PROGRESSBAR["download"][ randint(0, len(ANIMATION_SYMBOLS_PROGRESSBAR["download"]) - 1) ], ANIMATIONS_MAIN[selection_animation], interval, unicorn, messages_stay_in_one_line, )
[docs] class NonBlockingProgressTask(NonBlockingAnimationTask): """ Defines a non-blocking animation task with a progress bar run on another Thread """ _progress: int def __init__( self, message_left: str | bool | None, message_center: str | bool | None, message_right: str | bool | None, stop_condition: Event, messages_stay_in_one_line: bool, progress: int, ) -> None: super().__init__( message_left, message_center, message_right, stop_condition, messages_stay_in_one_line, ) self._progress = progress
[docs] def update( self, message_left: str | bool | None = None, message_center: str | bool | None = None, message_right: str | bool | None = None, progress: int | None = None, *args: object, **kwargs: object, ) -> None: """ Update progressbar message and progress :param message_left: Message or bool flag to update to (None for no update) :param message_center: Message or bool flag to update to (None for no update) :param message_right: Message or bool flag to update to (None for no update) :param progress: Progress to update to (None for no update) :return: None """ self._progress = progress if progress is not None else self._progress super(Cliasi.NonBlockingProgressTask, self).update( message_left, message_center, message_right )
@staticmethod def __get_null_task() -> NonBlockingProgressTask: """ Get a null progressbar task to return when verbosity is not met to not return None :return: "fake" NonBlockingProgressTask """ task = Cliasi.NonBlockingProgressTask("", False, False, Event(), False, 0) def _null_update(*args: object, **kwargs: object) -> None: pass task._update = _null_update def _null_stop() -> None: pass task.stop = _null_stop # type: ignore[method-assign] return task def __get_progressbar_task( self, message_left: str | bool | None, message_center: str | bool | None, message_right: str | bool | None, progress: int, cover_dead_space_with_bar: bool, calculation_mode: PBCalculationMode, symbol_animation: builtins.list[str], show_percent: bool, interval: int | float, color: TextColor, unicorn: bool = False, messages_stay_in_one_line: bool | None = True, ) -> NonBlockingProgressTask: """ Get a progressbar task :param message_left: Message to display on the left side of the bar or bool flag to enable /disable :param message_center: Message to display in the center of the bar or bool flag to enable /disable (default True) :param message_right: Message to display on the right side of the bar or bool flag to enable / disable :param progress: Initial progress :param cover_dead_space_with_bar: Cover the space between messages with the progressbar If True, looks like this: [=message== ... ] If False, looks like this: [ message ===== ... ] :param calculation_mode: How to fill the progressbar. Set to FULL_WIDTH for bar to go trough messages. Set to ONLY_EMPTY to fill empty space between messages only. Set to FULL_WIDTH_OVERWRITE to overwrite messages with the bar. :param symbol_animation: List of string for symbol animation :param show_percent: Show percent at end of progressbar :param interval: Interval for animation to play :param color: Color of progressbar :param unicorn: Enable unicorn mode :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: NonBlockingProgressTask """ condition = Event() task = Cliasi.NonBlockingProgressTask( message_left, message_center, message_right, condition, messages_stay_in_one_line if messages_stay_in_one_line is not None else self.messages_stay_in_one_line, progress, ) def update_bar() -> None: """ Update only the progressbar section of the animation. :return: None """ current_symbol = symbol_animation[task._index % len(symbol_animation)] self.__show_animation_frame( self.__format_progressbar_to_screen_width( task._message_left, task._message_center, task._message_right, cover_dead_space_with_bar, calculation_mode, current_symbol, task._progress, show_percent, ), False, False, color if not unicorn else UNICORN[task._index % len(UNICORN)], current_symbol, current_animation_frame="", ) def animate() -> None: """ Animate the progressbar :return: None """ while not condition.is_set(): task.update() task._index += 1 condition.wait(timeout=interval) thread = Thread(target=animate, args=(), daemon=True) task._thread = thread task._update = update_bar thread.start() return task
[docs] def progressbar_animated_normal( self, message_left: str | bool | None, message_center: str | bool | None = False, message_right: str | bool | None = False, cover_dead_space_with_bar: bool = False, calculation_mode: PBCalculationMode = PBCalculationMode.FULL_WIDTH, verbosity: int = logging.INFO, progress: int = 0, interval: int | float = 0.25, show_percent: bool = False, unicorn: bool = False, messages_stay_in_one_line: bool | None = True, ) -> NonBlockingProgressTask: """ Display an animated progressbar Update progress using the returned Task object :param message_left: Message to display on the left side of the bar or bool flag to enable / disable :param message_center: Message to display in the center of the bar or bool flag to enable / disable :param message_right: Message to display on the right side of the bar or bool flag to enable / disable :param cover_dead_space_with_bar: Cover the space between messages with the progressbar If True, looks like this: [=message== ... ] If False, looks like this: [ message ===== ... ] :param calculation_mode: How to fill the progressbar. Set to FULL_WIDTH for bar to go trough messages. Set to ONLY_EMPTY to fill empty space between messages only. Set to FULL_WIDTH_OVERWRITE to overwrite messages with the bar. :param verbosity: Verbosity of message :param interval: Interval between animation frames :param progress: Current Progress to display :param show_percent: Show percent next to the progressbar :param unicorn: Enable unicorn mode :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: :class:`NonBlockingProgressTask` on which you can call :meth:`~cliasi.Cliasi.NonBlockingProgressTask.update()` and :meth:`~cliasi.Cliasi.NonBlockingProgressTask.stop()` :rtype: ~cliasi.Cliasi.NonBlockingProgressTask """ if self.__verbose_check(verbosity): return self.__get_null_task() return self.__get_progressbar_task( message_left, message_center, message_right, progress, cover_dead_space_with_bar, calculation_mode, ANIMATION_SYMBOLS_PROGRESSBAR["default"][ randint(0, len(ANIMATION_SYMBOLS_PROGRESSBAR["default"]) - 1) ], show_percent, interval, TextColor.BLUE, unicorn, messages_stay_in_one_line, )
[docs] def progressbar_animated_download( self, message_left: str | bool | None, message_center: str | bool | None = False, message_right: str | bool | None = False, cover_dead_space_with_bar: bool = False, calculation_mode: PBCalculationMode = PBCalculationMode.FULL_WIDTH, verbosity: int = logging.INFO, progress: int = 0, interval: int | float = 0.25, show_percent: bool = False, unicorn: bool = False, messages_stay_in_one_line: bool | None = True, ) -> NonBlockingProgressTask: """ Display an animated progressbar Update progress using the returned Task object :param message_left: Message to display on the left side of the bar or bool flag to enable / disable :param message_center: Message to display in the center of the bar or bool flag to enable / disable :param message_right: Message to display on the right side of the bar or bool flag to enable / disable :param cover_dead_space_with_bar: Cover the space between messages with the progressbar If True, looks like this: [=message== ... ] If False, looks like this: [ message ===== ... ] :param calculation_mode: How to fill the progressbar. Set to FULL_WIDTH for bar to go trough messages. Set to ONLY_EMPTY to fill empty space between messages only. Set to FULL_WIDTH_OVERWRITE to overwrite messages with the bar. :param verbosity: Verbosity of message :param interval: Interval between animation frames :param progress: Current Progress to display :param show_percent: Show percent next to the progressbar :param unicorn: Enable unicorn mode :param messages_stay_in_one_line: Override the message to stay in one line (None to use cliasi instance setting) :return: :class:`NonBlockingProgressTask` on which you can call :meth:`~cliasi.Cliasi.NonBlockingProgressTask.update()` and :meth:`~cliasi.Cliasi.NonBlockingProgressTask.stop()` :rtype: ~cliasi.Cliasi.NonBlockingProgressTask """ if self.__verbose_check(verbosity): return self.__get_null_task() return self.__get_progressbar_task( message_left, message_center, message_right, progress, cover_dead_space_with_bar, calculation_mode, ANIMATION_SYMBOLS_PROGRESSBAR["download"][ randint(0, len(ANIMATION_SYMBOLS_PROGRESSBAR["download"]) - 1) ], show_percent, interval, TextColor.BRIGHT_CYAN, unicorn, messages_stay_in_one_line, )
cli: Cliasi """Default Cliasi instance (shows INFO and above by default)""" cli = Cliasi("CLI", min_verbose_level=logging.INFO)