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

Add links and alignment functionality to the CLI formatter tooling #3327

Merged
merged 3 commits into from
Oct 1, 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
76 changes: 64 additions & 12 deletions uaclient/cli/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,27 @@
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
Expand Down Expand Up @@ -50,8 +58,18 @@ def disable_suggestions(cls) -> None:
ProOutputFormatterConfig.init(cfg=UAConfig())


def len_no_color(text: str) -> int:
return len(re.sub(COLOR_FORMATTING_PATTERN, "", text))
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():
Expand All @@ -72,21 +90,25 @@ def process_formatter_config(text: str) -> str:
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 len_no_color function
# 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 len_no_color(text) < max_width:
if real_len(text) <= max_width:
return [text]

words = text.split()
wrapped_lines = []
current_line = ""

for word in words:
if len_no_color(current_line) + len_no_color(word) >= max_width:
if real_len(current_line) + real_len(word) >= max_width:
wrapped_lines.append(current_line.strip())
current_line = word
else:
Expand Down Expand Up @@ -114,18 +136,38 @@ 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 = len_no_color(string)
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(
Expand Down Expand Up @@ -153,7 +195,7 @@ def _get_column_sizes(self) -> List[int]:
column_sizes = []
for i in range(len(all_content[0])):
column_sizes.append(
max(len_no_color(str(item[i])) for item in all_content)
max(real_len(str(item[i])) for item in all_content)
)

return column_sizes
Expand Down Expand Up @@ -185,16 +227,16 @@ def _get_line_length(self) -> int:
)

def wrap_last_column(self, max_length: int) -> List[List[str]]:
last_column_size = max_length - (
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]) <= last_column_size:
if len(row[-1]) <= self.last_column_size:
new_rows.append(row)
else:
wrapped_last_column = wrap_text(row[-1], last_column_size)
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) + [
Expand All @@ -206,8 +248,18 @@ def wrap_last_column(self, max_length: int) -> List[List[str]]:
def _fill_row(self, row: List[str]) -> str:
output = ""
for i in range(len(row) - 1):
output += self.ljust(row[i], self.column_sizes[i]) + self.SEPARATOR
output += 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


Expand Down
111 changes: 99 additions & 12 deletions uaclient/cli/tests/test_cli_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import mock
import pytest

from uaclient.cli.formatter import Block
from uaclient.cli.formatter import Block, ContentAlignment
from uaclient.cli.formatter import ProOutputFormatterConfig as POFC
from uaclient.cli.formatter import (
SuggestionBlock,
Table,
len_no_color,
create_link,
real_len,
wrap_text,
)

Expand Down Expand Up @@ -74,7 +75,15 @@ def test_suggestions_config(self, config_value, FakeConfig):
assert POFC.show_suggestions is False


class TestLenNoColor:
class TestCreateLink:
def test_creates_link(self):
assert (
"\x1b]8;;http://example.test\x1b\\some link\x1b]8;;\x1b\\"
== create_link(text="some link", url="http://example.test")
)


class TestRealLen:
@pytest.mark.parametrize(
"input_string,expected_length",
(
Expand All @@ -87,7 +96,15 @@ class TestLenNoColor:
),
)
def test_length_ignores_color(self, input_string, expected_length):
assert expected_length == len_no_color(input_string)
assert expected_length == real_len(input_string)

def test_length_ignores_links(self):
str_with_link = (
"There is an "
"\x1b]8;;https://example.com\x1b\\example link here\x1b]8;;\x1b\\"
" to be clicked"
)
assert 43 == real_len(str_with_link)


class TestWrapText:
Expand Down Expand Up @@ -119,6 +136,19 @@ def test_single_line_wrapped(self):
"too",
] == wrap_text(colored_string, 20)

string_with_link = (
"There is an "
"\x1b]8;;https://example.com\x1b\\example link here\x1b]8;;\x1b\\"
" to be clicked"
)
assert len(string_with_link) == 76
assert [string_with_link] == wrap_text(string_with_link, 45)
assert [
"There is an \x1b]8;;https://example.com\x1b\\example",
"link here\x1b]8;;\x1b\\ to be",
"clicked",
] == wrap_text(string_with_link, 20)


class TestTable:
@pytest.mark.parametrize(
Expand All @@ -135,27 +165,53 @@ def test_ljust(self, input_str, input_len, expected_value):
assert expected_value == Table.ljust(input_str, input_len)

@pytest.mark.parametrize(
"headers,rows,expected_msg",
"input_str,input_len,expected_value",
(
(None, None, "Empty table not supported."),
(None, [["a", "b"], [], ["c", "d"]], "Empty row not supported."),
("", 0, ""),
("", 5, " "),
("test", 2, "test"),
("test", 4, "test"),
("test", 10, " test"),
),
)
def test_rjust(self, input_str, input_len, expected_value):
assert expected_value == Table.rjust(input_str, input_len)

@pytest.mark.parametrize(
"headers,rows,alignment,expected_msg",
(
(None, None, None, "Empty table not supported."),
(
None,
[["a", "b"], [], ["c", "d"]],
None,
"Empty row not supported.",
),
(
None,
[["a", "b"], ["c", "d", "e"], ["f", "g"]],
None,
"Mixed lengths in table content.",
),
(
["h1", "h2", "h3"],
[["a", "b"], ["c", "d"], ["e", "f"]],
None,
"Mixed lengths in table content.",
),
(
["h1", "h2"],
[["a", "b"], ["c", "d"], ["e", "f"]],
[ContentAlignment.RIGHT],
"'alignment' list should have length 2",
),
),
)
def test_validate_rejects_invalid_entries(
self, headers, rows, expected_msg
self, headers, rows, alignment, expected_msg
):
with pytest.raises(ValueError) as error:
Table(headers=headers, rows=rows)
Table(headers=headers, rows=rows, alignment=alignment)

assert expected_msg in str(error.value)

Expand Down Expand Up @@ -221,6 +277,37 @@ def test_to_string_wraps_to_length(self, _m_is_tty, FakeConfig):
"""
)

@mock.patch(M_PATH + "sys.stdout.isatty", return_value=False)
def test_columns_align_to_the_right(self, _m_is_tty, FakeConfig):
POFC.init(FakeConfig())
table = Table(
["header1", "h2", "h3", "h4"],
[
["a", "bc", "de", "f"],
["b", "de", "fg", "wow this is a really big string of data"],
["c", "fg", "hijkl", "m"],
],
alignment=[ContentAlignment.RIGHT] * 4,
)
assert table.to_string() == textwrap.dedent(
"""\
header1 h2 h3 h4
a bc de f
b de fg wow this is a really big string of data
c fg hijkl m
""" # noqa: E501
)
assert table.to_string(line_length=40) == textwrap.dedent(
"""\
header1 h2 h3 h4
a bc de f
b de fg wow this is a
really big string of
data
c fg hijkl m
"""
)

@mock.patch(M_PATH + "sys.stdout.isatty", return_value=True)
@mock.patch(
M_PATH + "os.get_terminal_size",
Expand All @@ -233,15 +320,15 @@ def test_to_string_wraps_to_terminal_size(
table = Table(
["header1", "h2", "h3", "h4"],
[
["a", "bc", "de", "f"],
[create_link(text="a", url="example.com"), "bc", "de", "f"],
["b", "de", "fg", "wow this is a really big string of data"],
["c", "fg", "hijkl", "m"],
],
)
assert table.to_string() == textwrap.dedent(
"""\
\x1b[1mheader1 h2 h3 h4\x1b[0m
a bc de f
\x1b]8;;example.com\x1b\\a\x1b]8;;\x1b\\ bc de f
b de fg wow this is a
really big string of
data
Expand All @@ -262,7 +349,7 @@ def test_to_string_no_wrap_if_no_tty(
table = Table(
["header1", "h2", "h3", "h4"],
[
["a", "bc", "de", "f"],
[create_link(text="a", url="example.com"), "bc", "de", "f"],
[
"b",
"de",
Expand Down
Loading