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
# ---------------------------------------
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()