Source code for framed_text.utils

import re
from os import isatty
from shutil import get_terminal_size
from sys import stdout, stderr
from typing import Any, Literal

from termcolor import COLORS, ATTRIBUTES
from termcolor import colored as col

# Setup valid attributes tuple
_VALID_ATTRS: set[str] = set(ATTRIBUTES.keys())

# Units
SI_UNITS: list[str] = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'RB', 'QB']
IEC_UNITS: list[str] = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB', 'RiB', 'QiB']


[docs] class ANSIEscape: """ ANSI Escape Codes used throughout framed-text """ # Position SAVE_POS: str = '\x1b7' RESTORE_POS: str = '\x1b8' # Erase ERASE_LINE: str = '\x1b[2K' ERASE_SCREEN: str = '\x1b[2J' # Cursor HIDE_CURSOR: str = '\x1b[?25l' SHOW_CURSOR: str = '\x1b[?25h' # Alternative Buffer ALT_BUFFER_ON: str = '\x1b[?1049h' ALT_BUFFER_OFF: str = '\x1b[?1049l' # --------------------------------------- # MOVEMENT # ---------------------------------------
[docs] @staticmethod def move_up(lines: int = 1) -> str: """ ANSI Code to move a set number of lines up :param lines: number of lines. Must be greater than 1 :return: ANSI code """ if lines < 1: raise ValueError("Lines must be greater than 1") return f'\x1b[{lines}A'
[docs] @staticmethod def move_down(lines: int = 1): """ ANSI Code to move a set number of lines down :param lines: number of lines. Must be greater than 1 :return: ANSI code """ if lines < 1: raise ValueError("Lines must be greater than 1") return f'\x1b[{lines}B'
[docs] @staticmethod def move_to(line: int = 0, column: int = 0) -> str: """ ANSI Code to move to a specific line and column Line and column must be positive :param line: Line to move to (row) :param column: Column to move to (char) :return: ANSI code """ if line < 0 or column < 0: raise ValueError("Line and column must be positive") return f'\x1b[{line};{column}H'
[docs] @staticmethod def move_to_col(column: int = 0) -> str: """ ANSI Code to move to a specific column on the same line Line and column must be positive :param column: Column to move to (char) :return: ANSI code """ if column < 0: raise ValueError("Column must be positive") return f'\x1b[{column}G'
# --------------------------------------- # SCROLLABLE REGIONS # ---------------------------------------
[docs] @staticmethod def set_scrollable_region(top: int, bottom: int) -> str: """ ANSI code to set a scrollable region. Top and bottom must be positive :param top: Line number to be used as top region :param bottom: Line number to be used as bottom region :return: ANSI code """ if top < 0 or bottom < 0: raise ValueError("Top and bottom must be positive") return f'\x1b[{top};{bottom}r'
[docs] @staticmethod def reset_scrollable_region() -> str: """ ANSI code to reset scrollable region :return: ANSI code """ return f'\x1b[0;{get_terminal_size().lines}r'
def _format_big_number(num: int | float) -> str: """ Format any type of number to E-Notation (Ne+X) :param num: Any type of number :return: E-Notation string """ return f"{num:.2e}" def _human_readable_bytes(value: int, unit_type: Literal["iec", "si"] = "iec") -> tuple[float, str]: """ Convert bytes to a human-readable format with unit :param value: Bytes integer :param unit_type: Unit type: IEC is base 2, SI is base 10 :return: Bytes with the highest unit and unit """ units: list[str] = IEC_UNITS if unit_type == "iec" else SI_UNITS unit_div: int = 1024 if unit_type == "iec" else 1000 # Convert bytes to unit for u in units: if abs(value) < unit_div: # Highest unit achieved return value, u value /= unit_div # Fallback return value, units[-1]
[docs] class InvalidColorError(Exception):
[docs] def __init__(self, color: Any | None = None, message: str | None = None): """ Exception raised for invalid colors :param color: Invalid color :param message: Exception message """ self.color = color self.message = f"Not a valid color: '{self.color}'" if not message else message super().__init__(self.message)
def _calculate_dividers(width: int, offset: int = 6, title_len: int = 0) -> tuple[int, int]: """ Calculates the length of the dividers for FramedText and FramedHeader. Uses terminal's width. :param width: Width limit to use :param offset: Integer offset to account for chars to add/remove. Default 6 for FramedText: +2 spaces, +2 edges, +2 title pipes :param title_len: Integer length of the title. Set to 0 for no title :return: Starting divider length, End divider length. If title length is 0, returns (terminal width - offset, 0) """ end_len: int = 0 if title_len >= 0: # Calculate needed space space_needed: int = width - title_len - offset # Calculate start and end lengths start_len: int = space_needed // 2 # Ensure end length + start length equals space needed end_len = start_len + 1 if 2 * start_len != space_needed else start_len elif title_len < 0: raise ValueError("Title length must be 0 or greater") else: start_len: int = width - offset return start_len, end_len # ========================================================================= # ANSI FUNCTIONS # ========================================================================= def _ansi_pipe_clean(text: str) -> str: """ Check if output is being piped somewhere other than STDOUT or STDERR. If so, strip all ANSI :param text: Text :return: stripped text if output is not STDOUT or STDERR, else original text """ if not isatty(stdout.fileno()) or not isatty(stderr.fileno()): return _remove_ansi(text=text) return text def _remove_ansi(text: str) -> str: """ Removes all ANSI from text. Mostly used for calculating length of a line since len counts each char, even ANSI :param text: Text to remove ANSI from :return: Text without ANSI """ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') return ansi_escape.sub('', text) def _validate_color(color: str | tuple[int, int, int]) -> None: """ Checks if a color is a valid termcolor color :param color: termcolor color code or RGB tuple """ if isinstance(color, tuple) and not all(0 <= c <= 255 for c in color): raise InvalidColorError(message="RGB values must be between 0 and 255") elif isinstance(color, str) and color.lower() not in list(COLORS.keys()): raise InvalidColorError(message=color) def _validate_attrs(attrs: list[str]) -> None: """ Validate attributes :param attrs: List of attributes """ for attr in attrs: if attr not in _VALID_ATTRS: raise ValueError(f"Not a valid attribute: {attr}\n" f"Valid attributes: {_VALID_ATTRS}") def _format_col_text(text: str, color: str | tuple[int, int, int] | None = None, attrs: list[str] | None = None) -> str: """ Create a termcolor formatted string based on provided formatting options :param text: String text to format :param color: Valid termcolor color. String code or RGB tuple :param attrs: Valid termcolor attribute(s). :return: Formatted string """ # Validation if color: _validate_color(color=color) if attrs: _validate_attrs(attrs=attrs) # Strip ANSI from text text = _remove_ansi(text=text) _output: str = '' if attrs and all(attr in list(ATTRIBUTES.keys()) for attr in attrs): if color: _output = col(text=text, color=color, attrs=attrs) else: _output = col(text=text, attrs=attrs) elif color: _output = col(text=text, color=color) else: _output = text return _ansi_pipe_clean(text=_output) # ========================================================================= # FORMATTING FUNCTIONS # ========================================================================= def _get_terminal_width() -> int: """ Try to get the terminal width using ``shutil.get_terminal_size.columns``. If not possible, uses default value (80) :return: Terminal width or default """ try: return get_terminal_size().columns except OSError: return 80 def _get_terminal_height() -> int: """ Try to get the terminal height using ``shutil.get_terminal_size.lines``. If not possible, uses default value (24) :return: Terminal width or default """ try: return get_terminal_size().lines except OSError: return 24 def _shorten_framed_title(title: str, limit: int) -> str: """ Shortens a title to fit in a limit. Used for FramedHeader and FramedText :param title: Title to shorten :param limit: Limit to shorten to :return: Shortened title """ if len(title) > limit: return title[:limit - 1] + '…' return title def _truncate_text_middle(text: str, limit: int) -> str: """ Shortens a text to fit in a limit by truncating the middle :param text: Text to shorten :param limit: Limit to shorten to :return: Shortened text """ # Calculate difference diff: int = len(text) - limit diff_start: int = diff // 2 diff_end: int = diff_start + 1 txt_len: int = len(text) ### RANGE CALCULATION # Start: middle - diff_start (Start Length = 0 + Start --> Start Length = Start) # End: middle + diff_end. (End Length = Original length - End) # Length Relationships will be either Start = End or Start = End - 1 # Original length = Start + End return text[:(txt_len // 2) - diff_start] + "…" + text[ (txt_len // 2) + diff_end:] def _center_text(text: str, line_len: int) -> str: """ Centers text. Only adds leading spaces as trailing is handled by FramedText :param text: Text to center :param line_len: Length of the line :return: Centered text """ return text.center(line_len).rstrip()