Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Printing standardized output for CLI commands #3323

Merged
merged 6 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 109 additions & 24 deletions uaclient/cli/formatter.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import abc
import os
import re
import sys
from typing import List, Optional
import textwrap
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"
UTF8_ALTERNATIVES = {
"—": "-",
"✘": "x",
"✔": "*",
} # type: Dict[str, str]


# Class attributes and methods so we don't need singletons or globals for this
Expand Down Expand Up @@ -47,6 +54,27 @@ def len_no_color(text: str) -> int:
return len(re.sub(COLOR_FORMATTING_PATTERN, "", text))


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

return output


# We can't rely on textwrap because of the len_no_color function
# Textwrap is using a magic regex instead
def wrap_text(text: str, max_width: int) -> List[str]:
Expand All @@ -70,28 +98,26 @@ def wrap_text(text: str, max_width: int) -> List[str]:
return wrapped_lines


class Table:
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,
max_length: Optional[int] = 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()
if sys.stdout.isatty():
self.max_length = (
os.get_terminal_size().columns
if max_length is None
else max_length
)
else:
# If you're not in a tty, we don't care about wrapping
# If you have a thousand characters line on the table, well, wow
self.max_length = 999

@staticmethod
def ljust(string: str, total_length: int) -> str:
Expand Down Expand Up @@ -132,23 +158,34 @@ def _get_column_sizes(self) -> List[int]:

return column_sizes

def __str__(self) -> str:
if self._get_line_length() > self.max_length:
self.rows = self.wrap_last_column()
def to_string(self, line_length: Optional[int] = None) -> str:
if line_length is None:
line_length = _get_default_length()

rows = self.rows
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need the rows variable if the fix is applied on new_rows.append call ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the new_rows thing is only done if the table exceeds the line length. If there is no line exceeding the available space, then the whole thing short-circuits to using the actual content in self.rows, without change.

if self._get_line_length() > line_length:
rows = self.wrap_last_column(line_length)
output = ""
output += TxtColor.BOLD + self._fill_row(self.headers) + TxtColor.ENDC
for row in self.rows:
output += "\n"
if self.headers:
output += (
TxtColor.BOLD
+ self._fill_row(self.headers)
+ TxtColor.ENDC
+ "\n"
)
for row in rows:
output += self._fill_row(row)
return output
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) -> List[List[str]]:
last_column_size = self.max_length - (
def wrap_last_column(self, max_length: int) -> List[List[str]]:
last_column_size = max_length - (
sum(self.column_sizes[:-1])
+ (len(self.column_sizes) - 1) * len(self.SEPARATOR)
)
Expand All @@ -158,8 +195,7 @@ def wrap_last_column(self) -> List[List[str]]:
new_rows.append(row)
else:
wrapped_last_column = wrap_text(row[-1], last_column_size)
row[-1] = wrapped_last_column[0]
new_rows.append(row)
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
Expand All @@ -173,3 +209,52 @@ def _fill_row(self, row: List[str]) -> str:
output += self.ljust(row[i], self.column_sizes[i]) + self.SEPARATOR
output += row[-1]
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)


class SuggestionBlock(Block):
def to_string(self, line_length: Optional[int] = None) -> str:
if ProOutputFormatterConfig.show_suggestions:
return super().to_string(line_length)
return ""
Loading
Loading