diff --git a/uaclient/cli/formatter.py b/uaclient/cli/formatter.py index 2da8867f4a..dc9e344f81 100644 --- a/uaclient/cli/formatter.py +++ b/uaclient/cli/formatter.py @@ -10,6 +10,8 @@ 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", @@ -56,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(): @@ -78,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() @@ -92,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: @@ -140,14 +156,14 @@ def __init__( @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 = len_no_color(string) + str_length = real_len(string) if str_length >= total_length: return string return " " * (total_length - str_length) + string @@ -179,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 diff --git a/uaclient/cli/tests/test_cli_formatter.py b/uaclient/cli/tests/test_cli_formatter.py index d16bdf2934..1e50e2dc6b 100644 --- a/uaclient/cli/tests/test_cli_formatter.py +++ b/uaclient/cli/tests/test_cli_formatter.py @@ -8,7 +8,8 @@ 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( @@ -290,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"], ], @@ -298,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 @@ -319,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",