diff --git a/uaclient/cli/formatter.py b/uaclient/cli/formatter.py index 99304cc2f4..dc9e344f81 100644 --- a/uaclient/cli/formatter.py +++ b/uaclient/cli/formatter.py @@ -3,12 +3,15 @@ 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", @@ -16,6 +19,11 @@ } # 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 @@ -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(): @@ -72,13 +90,17 @@ 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() @@ -86,7 +108,7 @@ def wrap_text(text: str, max_width: int) -> List[str]: 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: @@ -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( @@ -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 @@ -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) + [ @@ -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 diff --git a/uaclient/cli/tests/test_cli_formatter.py b/uaclient/cli/tests/test_cli_formatter.py index f071dede1b..1e50e2dc6b 100644 --- a/uaclient/cli/tests/test_cli_formatter.py +++ b/uaclient/cli/tests/test_cli_formatter.py @@ -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, ) @@ -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", ( @@ -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: @@ -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( @@ -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) @@ -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", @@ -233,7 +320,7 @@ 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"], ], @@ -241,7 +328,7 @@ def test_to_string_wraps_to_terminal_size( 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 @@ -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",