295 lines
8.7 KiB
Python
295 lines
8.7 KiB
Python
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)
|