Current File : //proc/self/root/lib/python3/dist-packages/uaclient/cli/formatter.py |
import abc
import os
import re
import sys
import textwrap
from enum import Enum
from typing import Any, Dict, List, Optional # noqa: F401
from uaclient.config import UAConfig
from uaclient.messages import TxtColor
COLOR_FORMATTING_PATTERN = r"\033\[.*?m"
LINK_START_PATTERN = r"\033]8;;.+?\033\\+"
LINK_END = "\033]8;;\033\\"
UTF8_ALTERNATIVES = {
"—": "-",
"✘": "x",
"✔": "*",
} # type: Dict[str, str]
class ContentAlignment(Enum):
LEFT = "l"
RIGHT = "r"
# Class attributes and methods so we don't need singletons or globals for this
class ProOutputFormatterConfig:
use_utf8 = True
use_color = True
# Initializing the class after the import is useful for unit testing
@classmethod
def init(cls, cfg: UAConfig):
cls.use_utf8 = (
sys.stdout.encoding is not None
and "UTF-8" in sys.stdout.encoding.upper()
)
cls.use_color = sys.stdout.isatty() and os.getenv("NO_COLOR") is None
@classmethod
def disable_color(cls) -> None:
cls.use_color = False
ProOutputFormatterConfig.init(cfg=UAConfig())
def create_link(text: str, url: str) -> str:
return "\033]8;;{url}\033\\{text}\033]8;;\033\\".format(url=url, text=text)
def real_len(text: str) -> int:
# ignore colors if existing
result = re.sub(COLOR_FORMATTING_PATTERN, "", text)
# Ignore link control characters and metadata
result = re.sub(LINK_START_PATTERN, "", result)
result = result.replace(LINK_END, "")
return len(result)
def _get_default_length():
if sys.stdout.isatty():
return os.get_terminal_size().columns
# If you're not in a tty, we don't care about string length
# If you have a thousand characters line, well, wow
return 999
def process_formatter_config(text: str) -> str:
output = text
if not ProOutputFormatterConfig.use_color:
output = re.sub(COLOR_FORMATTING_PATTERN, "", text)
if not ProOutputFormatterConfig.use_utf8:
for char, alternative in UTF8_ALTERNATIVES.items():
output = output.replace(char, alternative)
output = output.encode("ascii", "ignore").decode()
if not sys.stdout.isatty():
output = re.sub(LINK_START_PATTERN, "", output)
output = output.replace(LINK_END, "")
return output
# We can't rely on textwrap because of the real_len function
# Textwrap is using a magic regex instead
def wrap_text(text: str, max_width: int) -> List[str]:
if real_len(text) <= max_width:
return [text]
words = text.split()
wrapped_lines = []
current_line = ""
for word in words:
if real_len(current_line) + real_len(word) >= max_width:
wrapped_lines.append(current_line.strip())
current_line = word
else:
current_line += " " + word
if current_line:
wrapped_lines.append(current_line.strip())
return wrapped_lines
class ProOutputFormatter(abc.ABC):
@abc.abstractmethod
def to_string(self, line_length: Optional[int] = None) -> str:
pass
def __str__(self):
return self.to_string()
class Table(ProOutputFormatter):
SEPARATOR = " " * 2
def __init__(
self,
headers: Optional[List[str]] = None,
rows: Optional[List[List[str]]] = None,
alignment: Optional[List[ContentAlignment]] = None,
):
self.headers = headers if headers is not None else []
self.rows = rows if rows is not None else []
self.column_sizes = self._get_column_sizes()
self.alignment = (
alignment
if alignment is not None
else [ContentAlignment.LEFT] * len(self.column_sizes)
)
if len(self.alignment) != len(self.column_sizes):
raise ValueError(
"'alignment' list should have length {}".format(
len(self.column_sizes)
)
)
self.last_column_size = self.column_sizes[-1]
@staticmethod
def ljust(string: str, total_length: int) -> str:
str_length = real_len(string)
if str_length >= total_length:
return string
return string + " " * (total_length - str_length)
@staticmethod
def rjust(string: str, total_length: int) -> str:
str_length = real_len(string)
if str_length >= total_length:
return string
return " " * (total_length - str_length) + string
def _get_column_sizes(self) -> List[int]:
if not self.headers and not self.rows:
raise ValueError(
"Empty table not supported. Please provide headers or rows."
)
if self.rows and any(len(item) == 0 for item in self.rows):
raise ValueError(
"Empty row not supported. Please provide content for each row."
)
all_content = []
if self.headers:
all_content.append(self.headers)
if self.rows:
all_content.extend(self.rows)
expected_length = len(all_content[0])
if not all(len(item) == expected_length for item in all_content):
raise ValueError(
"Mixed lengths in table content. "
"Please provide headers / rows of the same length."
)
column_sizes = []
for i in range(len(all_content[0])):
column_sizes.append(
max(real_len(str(item[i])) for item in all_content)
)
return column_sizes
def to_string(self, line_length: Optional[int] = None) -> str:
if line_length is None:
line_length = _get_default_length()
rows = self.rows
if self._get_line_length() > line_length:
rows = self.wrap_last_column(line_length)
output = ""
if self.headers:
output += (
TxtColor.BOLD
+ self._fill_row(self.headers)
+ TxtColor.ENDC
+ "\n"
)
for row in rows:
output += self._fill_row(row)
output += "\n"
return process_formatter_config(output)
def _get_line_length(self) -> int:
return sum(self.column_sizes) + (len(self.column_sizes) - 1) * len(
self.SEPARATOR
)
def wrap_last_column(self, max_length: int) -> List[List[str]]:
self.last_column_size = max_length - (
sum(self.column_sizes[:-1])
+ (len(self.column_sizes) - 1) * len(self.SEPARATOR)
)
new_rows = []
for row in self.rows:
if len(row[-1]) <= self.last_column_size:
new_rows.append(row)
else:
wrapped_last_column = wrap_text(row[-1], self.last_column_size)
new_rows.append(row[:-1] + [wrapped_last_column[0]])
for extra_line in wrapped_last_column[1:]:
new_row = [" "] * (len(self.column_sizes) - 1) + [
extra_line
]
new_rows.append(new_row)
return new_rows
def _fill_row(self, row: List[str]) -> str:
output = ""
for i in range(len(row) - 1):
if self.alignment[i] == ContentAlignment.LEFT:
output += (
self.ljust(row[i], self.column_sizes[i]) + self.SEPARATOR
)
elif self.alignment[i] == ContentAlignment.RIGHT:
output += (
self.rjust(row[i], self.column_sizes[i]) + self.SEPARATOR
)
if self.alignment[-1] == ContentAlignment.LEFT:
output += row[-1]
elif self.alignment[-1] == ContentAlignment.RIGHT:
output += self.rjust(row[-1], self.last_column_size)
return output
class Block(ProOutputFormatter):
INDENT_SIZE = 4
INDENT_CHAR = " "
def __init__(
self,
title: Optional[str] = None,
content: Optional[List[Any]] = None,
):
self.title = title
self.content = content if content is not None else []
def to_string(self, line_length: Optional[int] = None) -> str:
if line_length is None:
line_length = _get_default_length()
line_length -= self.INDENT_SIZE
output = ""
if self.title:
output += (
TxtColor.BOLD
+ TxtColor.DISABLEGREY
+ self.title
+ TxtColor.ENDC
+ "\n"
)
for item in self.content:
if isinstance(item, ProOutputFormatter):
item_str = item.to_string(line_length=line_length)
else:
item_str = "\n".join(wrap_text(str(item), line_length)) + "\n"
output += textwrap.indent(
item_str, self.INDENT_CHAR * self.INDENT_SIZE
)
return process_formatter_config(output)