From 0c6c09b03b4f1373d6ce586c960ad17f4a75f341 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 1 Nov 2020 16:47:31 +0000 Subject: [PATCH 01/12] ansi decoder --- rich/ansi_decoder.py | 110 +++++++++++++++++++++++++++++++++++++++++++ rich/color.py | 16 +++++++ 2 files changed, 126 insertions(+) create mode 100644 rich/ansi_decoder.py diff --git a/rich/ansi_decoder.py b/rich/ansi_decoder.py new file mode 100644 index 00000000..db0c124a --- /dev/null +++ b/rich/ansi_decoder.py @@ -0,0 +1,110 @@ +import re +from typing import Iterable, Optional, Tuple + +from .color import Color +from .style import Style +from .text import Text + +re_ansi = re.compile("\x1b(.*?)m") + + +def ansi_tokenize(ansi_text: str) -> Iterable[Tuple[Optional[str], Optional[str]]]: + """Tokenize a string in to plain text and ANSI codes. + + Args: + ansi_text (str): A String containing ANSI codes. + + Yields: + Tuple[Optional[str], Optional[str]]: A tuple of plain text, ansi codes. + """ + position = 0 + for match in re_ansi.finditer(ansi_text): + start, end = match.span(0) + ansi_code = match.group(1) + if start > position: + yield ansi_text[position:start], None + yield None, ansi_code + position = end + if position < len(ansi_text): + yield ansi_text[position:], None + + +class AnsiDecoder: + """Translate ANSI code in to styled Text.""" + + def __init__(self): + self.style = Style() + + def decode(self, lines: Iterable[str]) -> Iterable[Text]: + + for line in lines: + text = Text() + append = text.append + for plain_text, ansi_codes in ansi_tokenize(line): + print(repr(plain_text), repr(ansi_codes)) + if plain_text is not None: + append(plain_text, self.style or None) + elif ansi_codes is not None and ansi_codes.startswith("["): + codes = ansi_codes[1:].split(";") + iter_codes = iter(codes) + while True: + try: + code = int(next(iter_codes)) + except StopIteration: + break + + if code == 0: + self.style = Style() + elif code == 39: + self.style += Style(color="default") + elif code == 49: + self.style += Style(bgcolor="default") + elif 38 > code >= 30: + self.style += Style(color=Color.from_ansi(code - 30)) + elif 48 > code >= 40: + self.style += Style(bgcolor=Color.from_ansi(code - 40)) + elif code in (38, 48): + color_type = next(iter_codes) + if color_type == "5": + code = next(iter_codes) + number = int(code) + if code == 38: + self.style += Style(color=Color.from_ansi(number)) + else: + self.style += Style(bgcolor=Color.from_ansi(number)) + elif color_type == "2": + red, green, blue = ( + int(_code) + for _code in ( + next(iter_codes), + next(iter_codes), + next(iter_codes), + ) + ) + if code == 38: + self.style = Style( + color=Color.from_rgb(red, green, blue) + ) + else: + self.style = Style( + bgcolor=Color.from_rgb(red, green, blue) + ) + yield text + + +if __name__ == "__main__": + from .console import Console + from .text import Text + + console = Console() + console.begin_capture() + console.print("Hello [bold Magenta]World[/]!") + ansi = console.end_capture() + + print(ansi) + + ansi_decoder = AnsiDecoder() + for line in ansi_decoder.decode(ansi.splitlines()): + print("*", repr(line)) + print(line) + console.print(line) \ No newline at end of file diff --git a/rich/color.py b/rich/color.py index 223a5c40..7d76cfaf 100644 --- a/rich/color.py +++ b/rich/color.py @@ -327,6 +327,22 @@ class Color(NamedTuple): assert self.number is None return theme.foreground_color if foreground else theme.background_color + @classmethod + def from_ansi(cls, number: int) -> "Color": + """Create a Color number from it's 8-bit ansi number. + + Args: + number (int): A number between 0-255 inclusive. + + Returns: + Color: A new Color isntance. + """ + return cls( + name=f"color({number})", + type=(ColorType.STANDARD if number < 8 else ColorType.EIGHT_BIT), + number=number, + ) + @classmethod def from_triplet(cls, triplet: "ColorTriplet") -> "Color": """Create a truecolor RGB color from a triplet of values. From a4f7e247a9499744b3d23b72887bd68bac3c19fb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 1 Nov 2020 18:06:16 +0000 Subject: [PATCH 02/12] more complete support --- rich/ansi_decoder.py | 153 ++++++++++++++++++++++++++++--------------- 1 file changed, 100 insertions(+), 53 deletions(-) diff --git a/rich/ansi_decoder.py b/rich/ansi_decoder.py index db0c124a..c1855ba2 100644 --- a/rich/ansi_decoder.py +++ b/rich/ansi_decoder.py @@ -29,82 +29,129 @@ def ansi_tokenize(ansi_text: str) -> Iterable[Tuple[Optional[str], Optional[str] yield ansi_text[position:], None +STYLE_MAP = { + 1: "bold", + 21: "not bold", + 2: "dim", + 22: "not dim", + 3: "italic", + 23: "not italic", + 4: "underline", + 24: "not underline", + 5: "blink", + 25: "not blink", + 6: "blink2", + 26: "not blink2", + 7: "reverse", + 27: "not reverse", + 8: "conceal", + 28: "not conceal", + 9: "strike", + 29: "not strike", + 21: "underline2", + 51: "frame", + 54: "not frame not encircle", + 52: "encircle", + 53: "overline", + 55: "not overline", +} +SGR_CODES = set(STYLE_MAP.keys()) + + class AnsiDecoder: """Translate ANSI code in to styled Text.""" def __init__(self): self.style = Style() - def decode(self, lines: Iterable[str]) -> Iterable[Text]: + def decode(self, terminal_text: str) -> Iterable[Text]: + """Decode ANSI codes in an interable of lines. - for line in lines: - text = Text() - append = text.append - for plain_text, ansi_codes in ansi_tokenize(line): - print(repr(plain_text), repr(ansi_codes)) - if plain_text is not None: - append(plain_text, self.style or None) - elif ansi_codes is not None and ansi_codes.startswith("["): - codes = ansi_codes[1:].split(";") - iter_codes = iter(codes) - while True: + Args: + lines (Iterable[str]): An iterable of lines of terminal output. + + + Yields: + Text: Marked up Text. + """ + for line in terminal_text.splitlines(): + yield self.decode_line(line) + + def decode_line(self, line: str) -> Text: + """Decode a line containing ansi codes. + + Args: + line (str): A line of terminal output. + + Returns: + Text: A Text instance marked up according to ansi codes. + """ + _Color = Color + _Style = Style + text = Text() + append = text.append + for plain_text, ansi_codes in ansi_tokenize(line): + if plain_text is not None: + append(plain_text, self.style or None) + elif ansi_codes is not None and ansi_codes.startswith("["): + codes = [ + int(_code) for _code in ansi_codes[1:].split(";") if _code.isdigit() + ] + iter_codes = iter(codes) + for code in iter_codes: + if code == 0: + self.style = _Style() + elif code in SGR_CODES: + self.style += _Style.parse(STYLE_MAP[code]) + elif code == 39: + self.style += _Style(color="default") + elif code == 49: + self.style += _Style(bgcolor="default") + elif 38 > code >= 30: + self.style += _Style(color=_Color.from_ansi(code - 30)) + elif 48 > code >= 40: + self.style += _Style(bgcolor=_Color.from_ansi(code - 40)) + elif code in (38, 48): try: - code = int(next(iter_codes)) - except StopIteration: - break - - if code == 0: - self.style = Style() - elif code == 39: - self.style += Style(color="default") - elif code == 49: - self.style += Style(bgcolor="default") - elif 38 > code >= 30: - self.style += Style(color=Color.from_ansi(code - 30)) - elif 48 > code >= 40: - self.style += Style(bgcolor=Color.from_ansi(code - 40)) - elif code in (38, 48): color_type = next(iter_codes) if color_type == "5": - code = next(iter_codes) - number = int(code) - if code == 38: - self.style += Style(color=Color.from_ansi(number)) - else: - self.style += Style(bgcolor=Color.from_ansi(number)) - elif color_type == "2": - red, green, blue = ( - int(_code) - for _code in ( - next(iter_codes), - next(iter_codes), - next(iter_codes), - ) + number = next(iter_codes) + color = _Color.from_ansi(number) + self.style += ( + _Style(color=color) + if code == 38 + else _Style(bgcolor=color) ) - if code == 38: - self.style = Style( - color=Color.from_rgb(red, green, blue) - ) - else: - self.style = Style( - bgcolor=Color.from_rgb(red, green, blue) - ) - yield text + elif color_type == "2": + color = _Color.from_rgb( + next(iter_codes), next(iter_codes), next(iter_codes) + ) + self.style = ( + _Style(color=color) + if code == 38 + else _Style(bgcolor=color) + ) + except StopIteration: + # Unexpected end of codes + break + return text -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover from .console import Console from .text import Text console = Console() console.begin_capture() - console.print("Hello [bold Magenta]World[/]!") + console.print( + "[u]H[s]ell[/s]o[/u] [bold Magenta][blink]World[/][/]!\n[reverse]Reverse" + ) ansi = console.end_capture() print(ansi) ansi_decoder = AnsiDecoder() - for line in ansi_decoder.decode(ansi.splitlines()): + for line in ansi_decoder.decode(ansi): print("*", repr(line)) print(line) console.print(line) \ No newline at end of file From 2d6d360425b45a6fe5507ff8bfe9161034680690 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 1 Nov 2020 18:09:53 +0000 Subject: [PATCH 03/12] docstring --- rich/ansi_decoder.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rich/ansi_decoder.py b/rich/ansi_decoder.py index c1855ba2..5835a9a7 100644 --- a/rich/ansi_decoder.py +++ b/rich/ansi_decoder.py @@ -94,9 +94,12 @@ class AnsiDecoder: if plain_text is not None: append(plain_text, self.style or None) elif ansi_codes is not None and ansi_codes.startswith("["): + # Translate in to semi-colon separated codes + # Ignore invalid codes, because we want to be lenient codes = [ int(_code) for _code in ansi_codes[1:].split(";") if _code.isdigit() ] + codes = [code for code in codes if code <= 255] iter_codes = iter(codes) for code in iter_codes: if code == 0: From 7fad399ac40476410aadeba33e0e4f2c952c6d83 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 3 Nov 2020 09:52:49 +0000 Subject: [PATCH 04/12] docstring --- rich/color.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rich/color.py b/rich/color.py index 7d76cfaf..5bdb43f7 100644 --- a/rich/color.py +++ b/rich/color.py @@ -335,7 +335,7 @@ class Color(NamedTuple): number (int): A number between 0-255 inclusive. Returns: - Color: A new Color isntance. + Color: A new Color instance. """ return cls( name=f"color({number})", From 4bfb8618f8fe5f94ceb243c27e9c29c6b68e1149 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 4 Nov 2020 18:08:26 +0000 Subject: [PATCH 05/12] ansi decoder --- rich/ansi_decoder.py | 111 +++++++++++++++++++++++++++++++------------ rich/color.py | 2 +- 2 files changed, 82 insertions(+), 31 deletions(-) diff --git a/rich/ansi_decoder.py b/rich/ansi_decoder.py index 5835a9a7..7610d4e7 100644 --- a/rich/ansi_decoder.py +++ b/rich/ansi_decoder.py @@ -1,14 +1,23 @@ import re -from typing import Iterable, Optional, Tuple +from typing import Iterable, NamedTuple from .color import Color from .style import Style from .text import Text -re_ansi = re.compile("\x1b(.*?)m") +re_ansi = re.compile(r"(?:\x1b\[(.*?)m)|(?:\x1b\](.*?)\x1b\\)") +re_csi = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") -def ansi_tokenize(ansi_text: str) -> Iterable[Tuple[Optional[str], Optional[str]]]: +class AnsiToken(NamedTuple): + """Result of ansi tokenized string.""" + + plain: str = "" + sgr: str = "" + osc: str = "" + + +def _ansi_tokenize(ansi_text: str) -> Iterable[AnsiToken]: """Tokenize a string in to plain text and ANSI codes. Args: @@ -17,16 +26,20 @@ def ansi_tokenize(ansi_text: str) -> Iterable[Tuple[Optional[str], Optional[str] Yields: Tuple[Optional[str], Optional[str]]: A tuple of plain text, ansi codes. """ + + def remove_csi(ansi_text: str) -> str: + return re_csi.sub("", ansi_text) + position = 0 for match in re_ansi.finditer(ansi_text): start, end = match.span(0) - ansi_code = match.group(1) + sgr, osc = match.groups() if start > position: - yield ansi_text[position:start], None - yield None, ansi_code + yield AnsiToken(remove_csi(ansi_text[position:start])) + yield AnsiToken("", sgr, osc) position = end if position < len(ansi_text): - yield ansi_text[position:], None + yield AnsiToken(remove_csi(ansi_text[position:])) STYLE_MAP = { @@ -90,16 +103,21 @@ class AnsiDecoder: _Style = Style text = Text() append = text.append - for plain_text, ansi_codes in ansi_tokenize(line): - if plain_text is not None: + for token in _ansi_tokenize(line): + plain_text, sgr, osc = token + if plain_text: append(plain_text, self.style or None) - elif ansi_codes is not None and ansi_codes.startswith("["): + elif osc: + if not osc.startswith("8;"): + continue + _params, semicolon, link = osc[2:].partition(";") + if semicolon: + self.style = self.style.update_link(link) + elif sgr: # Translate in to semi-colon separated codes # Ignore invalid codes, because we want to be lenient - codes = [ - int(_code) for _code in ansi_codes[1:].split(";") if _code.isdigit() - ] - codes = [code for code in codes if code <= 255] + codes = [int(_code) for _code in sgr.split(";") if _code.isdigit()] + # codes = [code for code in codes if code <= 255] iter_codes = iter(codes) for code in iter_codes: if code == 0: @@ -114,10 +132,14 @@ class AnsiDecoder: self.style += _Style(color=_Color.from_ansi(code - 30)) elif 48 > code >= 40: self.style += _Style(bgcolor=_Color.from_ansi(code - 40)) + elif 99 > code >= 90: + self.style += _Style(color=_Color.from_ansi(code - 90 + 8)) + elif 108 > code >= 100: + self.style += _Style(bgcolor=_Color.from_ansi(code - 100 + 8)) elif code in (38, 48): try: color_type = next(iter_codes) - if color_type == "5": + if color_type == 5: number = next(iter_codes) color = _Color.from_ansi(number) self.style += ( @@ -125,11 +147,11 @@ class AnsiDecoder: if code == 38 else _Style(bgcolor=color) ) - elif color_type == "2": + elif color_type == 2: color = _Color.from_rgb( next(iter_codes), next(iter_codes), next(iter_codes) ) - self.style = ( + self.style += ( _Style(color=color) if code == 38 else _Style(bgcolor=color) @@ -140,21 +162,50 @@ class AnsiDecoder: return text +# if __name__ == "__main__": # pragma: no cover +# from .console import Console +# from .text import Text + +# console = Console() +# console.begin_capture() +# console.print( +# "[bold magenta]bold magenta [i]italic[/i][/] [link http://example.org]Hello World[/] not linked" +# ) +# ansi = console.end_capture() + +# print(ansi) + +# ansi_decoder = AnsiDecoder() +# for line in ansi_decoder.decode(ansi): +# print("*", repr(line)) +# print(line) +# console.print(line) + if __name__ == "__main__": # pragma: no cover + import pty + import io + import os + import sys + + decoder = AnsiDecoder() + + stdout = io.BytesIO() + + def read(fd): + data = os.read(fd, 1024) + stdout.write(data) + return data + + pty.spawn(sys.argv[1:], read) + from .console import Console - from .text import Text - console = Console() - console.begin_capture() - console.print( - "[u]H[s]ell[/s]o[/u] [bold Magenta][blink]World[/][/]!\n[reverse]Reverse" - ) - ansi = console.end_capture() + console = Console(record=True) - print(ansi) + stdout_result = stdout.getvalue().decode("utf-8") + print(stdout_result) - ansi_decoder = AnsiDecoder() - for line in ansi_decoder.decode(ansi): - print("*", repr(line)) - print(line) - console.print(line) \ No newline at end of file + for line in decoder.decode(stdout_result): + console.print(line) + + console.save_html("stdout.html") diff --git a/rich/color.py b/rich/color.py index 5bdb43f7..8110eb5c 100644 --- a/rich/color.py +++ b/rich/color.py @@ -339,7 +339,7 @@ class Color(NamedTuple): """ return cls( name=f"color({number})", - type=(ColorType.STANDARD if number < 8 else ColorType.EIGHT_BIT), + type=(ColorType.STANDARD if number < 16 else ColorType.EIGHT_BIT), number=number, ) From b7c42f37a5462aa3c79034685e037d6652d97500 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 4 Nov 2020 22:09:58 +0000 Subject: [PATCH 06/12] simplify --- rich/ansi_decoder.py | 136 +++++++++++++++++++++---------------------- 1 file changed, 65 insertions(+), 71 deletions(-) diff --git a/rich/ansi_decoder.py b/rich/ansi_decoder.py index 7610d4e7..a1ad19dc 100644 --- a/rich/ansi_decoder.py +++ b/rich/ansi_decoder.py @@ -1,3 +1,4 @@ +from contextlib import suppress import re from typing import Iterable, NamedTuple @@ -24,10 +25,11 @@ def _ansi_tokenize(ansi_text: str) -> Iterable[AnsiToken]: ansi_text (str): A String containing ANSI codes. Yields: - Tuple[Optional[str], Optional[str]]: A tuple of plain text, ansi codes. + AnsiToken: A named tuple of (plain, sgr, osc) """ def remove_csi(ansi_text: str) -> str: + """Remove unknown CSI sequences.""" return re_csi.sub("", ansi_text) position = 0 @@ -42,33 +44,65 @@ def _ansi_tokenize(ansi_text: str) -> Iterable[AnsiToken]: yield AnsiToken(remove_csi(ansi_text[position:])) -STYLE_MAP = { +SGR_STYLE_MAP = { 1: "bold", - 21: "not bold", 2: "dim", - 22: "not dim", 3: "italic", - 23: "not italic", 4: "underline", - 24: "not underline", 5: "blink", - 25: "not blink", 6: "blink2", - 26: "not blink2", 7: "reverse", - 27: "not reverse", 8: "conceal", - 28: "not conceal", 9: "strike", - 29: "not strike", 21: "underline2", + 22: "not dim not bold", + 23: "not italic", + 24: "not underline", + 25: "not blink", + 26: "not blink2", + 27: "not reverse", + 28: "not conceal", + 29: "not strike", + 30: "color(0)", + 31: "color(1)", + 32: "color(2)", + 33: "color(3)", + 34: "color(4)", + 35: "color(5)", + 36: "color(6)", + 37: "color(7)", + 39: "default", + 40: "on color(0)", + 41: "on color(1)", + 42: "on color(2)", + 43: "on color(3)", + 44: "on color(4)", + 45: "on color(5)", + 46: "on color(6)", + 47: "on color(7)", + 49: "on default", 51: "frame", - 54: "not frame not encircle", 52: "encircle", 53: "overline", + 54: "not frame not encircle", 55: "not overline", + 90: "color(8)", + 91: "color(9)", + 92: "color(10)", + 93: "color(11)", + 94: "color(12)", + 95: "color(13)", + 96: "color(14)", + 97: "color(15)", + 100: "on color(8)", + 101: "on color(9)", + 102: "on color(10)", + 103: "on color(11)", + 104: "on color(12)", + 105: "on color(13)", + 106: "on color(14)", + 107: "on color(15)", } -SGR_CODES = set(STYLE_MAP.keys()) class AnsiDecoder: @@ -99,7 +133,8 @@ class AnsiDecoder: Returns: Text: A Text instance marked up according to ansi codes. """ - _Color = Color + from_ansi = Color.from_ansi + from_rgb = Color.from_rgb _Style = Style text = Text() append = text.append @@ -108,79 +143,38 @@ class AnsiDecoder: if plain_text: append(plain_text, self.style or None) elif osc: - if not osc.startswith("8;"): - continue - _params, semicolon, link = osc[2:].partition(";") - if semicolon: - self.style = self.style.update_link(link) + if osc.startswith("8;"): + _params, semicolon, link = osc[2:].partition(";") + if semicolon: + self.style = self.style.update_link(link) elif sgr: # Translate in to semi-colon separated codes # Ignore invalid codes, because we want to be lenient codes = [int(_code) for _code in sgr.split(";") if _code.isdigit()] - # codes = [code for code in codes if code <= 255] + codes = [code for code in codes if code <= 255] iter_codes = iter(codes) for code in iter_codes: if code == 0: - self.style = _Style() - elif code in SGR_CODES: - self.style += _Style.parse(STYLE_MAP[code]) - elif code == 39: - self.style += _Style(color="default") - elif code == 49: - self.style += _Style(bgcolor="default") - elif 38 > code >= 30: - self.style += _Style(color=_Color.from_ansi(code - 30)) - elif 48 > code >= 40: - self.style += _Style(bgcolor=_Color.from_ansi(code - 40)) - elif 99 > code >= 90: - self.style += _Style(color=_Color.from_ansi(code - 90 + 8)) - elif 108 > code >= 100: - self.style += _Style(bgcolor=_Color.from_ansi(code - 100 + 8)) + self.style = _Style.null() + elif code in SGR_STYLE_MAP: + self.style += _Style.parse(SGR_STYLE_MAP[code]) elif code in (38, 48): - try: + with suppress(StopIteration): color_type = next(iter_codes) if color_type == 5: - number = next(iter_codes) - color = _Color.from_ansi(number) - self.style += ( - _Style(color=color) - if code == 38 - else _Style(bgcolor=color) - ) + color = from_ansi(next(iter_codes)) elif color_type == 2: - color = _Color.from_rgb( + color = from_rgb( next(iter_codes), next(iter_codes), next(iter_codes) ) - self.style += ( - _Style(color=color) - if code == 38 - else _Style(bgcolor=color) - ) - except StopIteration: - # Unexpected end of codes - break + else: + continue + self.style += ( + _Style(color=color) if code == 38 else _Style(bgcolor=color) + ) return text -# if __name__ == "__main__": # pragma: no cover -# from .console import Console -# from .text import Text - -# console = Console() -# console.begin_capture() -# console.print( -# "[bold magenta]bold magenta [i]italic[/i][/] [link http://example.org]Hello World[/] not linked" -# ) -# ansi = console.end_capture() - -# print(ansi) - -# ansi_decoder = AnsiDecoder() -# for line in ansi_decoder.decode(ansi): -# print("*", repr(line)) -# print(line) -# console.print(line) - if __name__ == "__main__": # pragma: no cover import pty import io From 5e77841948f4ad4c9aec404caaf1f11fd92a8f1c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 4 Nov 2020 22:12:09 +0000 Subject: [PATCH 07/12] cache tweak --- rich/style.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rich/style.py b/rich/style.py index 23932097..7e008e9a 100644 --- a/rich/style.py +++ b/rich/style.py @@ -355,7 +355,7 @@ class Style: return Style(bgcolor=self.bgcolor) @classmethod - @lru_cache(maxsize=1024) + @lru_cache(maxsize=4096) def parse(cls, style_definition: str) -> "Style": """Parse a style definition. @@ -369,7 +369,7 @@ class Style: `Style`: A Style instance. """ if style_definition.strip() == "none": - return cls() + return cls.null() style_attributes = { "dim": "dim", From d88a08eaf6f2c7089bfe52cd07a747fa54205fe3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 4 Nov 2020 22:14:03 +0000 Subject: [PATCH 08/12] optimization --- rich/ansi_decoder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rich/ansi_decoder.py b/rich/ansi_decoder.py index a1ad19dc..c8735b70 100644 --- a/rich/ansi_decoder.py +++ b/rich/ansi_decoder.py @@ -109,7 +109,7 @@ class AnsiDecoder: """Translate ANSI code in to styled Text.""" def __init__(self): - self.style = Style() + self.style = Style.null() def decode(self, terminal_text: str) -> Iterable[Text]: """Decode ANSI codes in an interable of lines. From 8294fee3614878fe6bf6f8e6da9dd350ccdf7888 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 5 Nov 2020 18:41:49 +0000 Subject: [PATCH 09/12] ansi and tests --- CHANGELOG.md | 1 + docs/source/reference.rst | 1 + docs/source/reference/pretty.rst | 6 ++++++ rich/{ansi_decoder.py => ansi.py} | 23 +++++++++++++++-------- rich/progress.py | 14 +++++++++----- tests/test_ansi.py | 29 +++++++++++++++++++++++++++++ tests/test_color.py | 4 ++++ 7 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 docs/source/reference/pretty.rst rename rich/{ansi_decoder.py => ansi.py} (89%) create mode 100644 tests/test_ansi.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 583c24f2..29c0c411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added tracebacks_show_locals parameter to RichHandler - Applied dim=True to indent guide styles - Added max_string to Pretty +- Added rich.ansi.AnsiDecoder ## [9.1.0] - 2020-10-23 diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 2fd7bec1..a785e79f 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -18,6 +18,7 @@ Reference reference/measure.rst reference/padding.rst reference/panel.rst + reference/pretty.rst reference/progress.rst reference/progress_bar.rst reference/prompt.rst diff --git a/docs/source/reference/pretty.rst b/docs/source/reference/pretty.rst new file mode 100644 index 00000000..4290dbd7 --- /dev/null +++ b/docs/source/reference/pretty.rst @@ -0,0 +1,6 @@ +rich.pretty +=========== + +.. automodule:: rich.pretty + :members: + diff --git a/rich/ansi_decoder.py b/rich/ansi.py similarity index 89% rename from rich/ansi_decoder.py rename to rich/ansi.py index c8735b70..307623c6 100644 --- a/rich/ansi_decoder.py +++ b/rich/ansi.py @@ -146,12 +146,13 @@ class AnsiDecoder: if osc.startswith("8;"): _params, semicolon, link = osc[2:].partition(";") if semicolon: - self.style = self.style.update_link(link) + self.style = self.style.update_link(link or None) elif sgr: # Translate in to semi-colon separated codes # Ignore invalid codes, because we want to be lenient - codes = [int(_code) for _code in sgr.split(";") if _code.isdigit()] - codes = [code for code in codes if code <= 255] + codes = [ + min(255, int(_code)) for _code in sgr.split(";") if _code.isdigit() + ] iter_codes = iter(codes) for code in iter_codes: if code == 0: @@ -163,15 +164,21 @@ class AnsiDecoder: color_type = next(iter_codes) if color_type == 5: color = from_ansi(next(iter_codes)) + self.style += ( + _Style(color=color) + if code == 38 + else _Style(bgcolor=color) + ) elif color_type == 2: color = from_rgb( next(iter_codes), next(iter_codes), next(iter_codes) ) - else: - continue - self.style += ( - _Style(color=color) if code == 38 else _Style(bgcolor=color) - ) + self.style += ( + _Style(color=color) + if code == 38 + else _Style(bgcolor=color) + ) + return text diff --git a/rich/progress.py b/rich/progress.py index 7a3ad0cb..97175900 100644 --- a/rich/progress.py +++ b/rich/progress.py @@ -27,7 +27,7 @@ from typing import ( ) from . import filesize, get_console -from .progress_bar import ProgressBar +from .ansi import AnsiDecoder from .console import ( Console, ConsoleRenderable, @@ -40,6 +40,7 @@ from .control import Control from .highlighter import Highlighter from .jupyter import JupyterMixin from .live_render import LiveRender +from .progress_bar import ProgressBar from .style import StyleType from .table import Table from .text import Text @@ -480,6 +481,7 @@ class _FileProxy(io.TextIOBase): self.__console = console self.__file = file self.__buffer: List[str] = [] + self.__ansi_decoder = AnsiDecoder() def __getattr__(self, name: str) -> Any: return getattr(self.__file, name) @@ -498,7 +500,9 @@ class _FileProxy(io.TextIOBase): if lines: console = self.__console with console: - output = "\n".join(lines) + output = Text("\n").join( + self.__ansi_decoder.decode_line(line) for line in lines + ) console.print(output, markup=False, emoji=False, highlight=False) return len(text) @@ -846,8 +850,8 @@ class Progress(JupyterMixin, RenderHook): """Refresh (render) the progress information.""" if self.console.is_jupyter: # pragma: no cover try: - from ipywidgets import Output from IPython.display import display + from ipywidgets import Output except ImportError: import warnings @@ -974,13 +978,13 @@ class Progress(JupyterMixin, RenderHook): if __name__ == "__main__": # pragma: no coverage - import time import random + import time from .panel import Panel + from .rule import Rule from .syntax import Syntax from .table import Table - from .rule import Rule syntax = Syntax( '''def loop_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: diff --git a/tests/test_ansi.py b/tests/test_ansi.py new file mode 100644 index 00000000..4d0dcb04 --- /dev/null +++ b/tests/test_ansi.py @@ -0,0 +1,29 @@ +import io + +from rich.ansi import AnsiDecoder +from rich.console import Console +from rich.style import Style +from rich.text import Span, Text + + +def test_decode(): + console = Console(force_terminal=True, color_system="truecolor") + console.begin_capture() + console.print("Hello") + console.print("[b]foo[/b]") + console.print("[link http://example.org]bar") + console.print("[#ff0000 on color(200)]red") + terminal_codes = console.end_capture() + + decoder = AnsiDecoder() + lines = list(decoder.decode(terminal_codes)) + + parse = Style.parse + expected = [ + Text("Hello"), + Text("foo", spans=[Span(0, 3, parse("bold"))]), + Text("bar", spans=[Span(0, 3, parse("link http://example.org"))]), + Text("red", spans=[Span(0, 3, parse("#ff0000 on color(200)"))]), + ] + + assert lines == expected \ No newline at end of file diff --git a/tests/test_color.py b/tests/test_color.py index d5ecffd5..97ef6a30 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -73,6 +73,10 @@ def test_from_rgb() -> None: ) +def test_from_ansi() -> None: + assert Color.from_ansi(1) == Color("color(1)", ColorType.STANDARD, 1) + + def test_default() -> None: assert Color.default() == Color("default", ColorType.DEFAULT, None, None) From 6be428dab89500b1026d7d1507d469455154be97 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 7 Nov 2020 15:19:22 +0000 Subject: [PATCH 10/12] ansi decoder --- CHANGELOG.md | 2 ++ rich/ansi.py | 53 ++++++++++++++++++++++++++------------- rich/console.py | 2 +- rich/live_render.py | 10 ++++---- rich/progress.py | 25 ++++++++++-------- rich/segment.py | 18 ++++++++++--- rich/style.py | 29 +++++++++++++++++++++ tests/test_ansi.py | 9 ++++--- tests/test_live_render.py | 4 +-- 9 files changed, 107 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c0c411..0416e448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Applied dim=True to indent guide styles - Added max_string to Pretty - Added rich.ansi.AnsiDecoder +- Added decoding of ansi codes to captured stdout in Progress +- Hid progress bars from html export ## [9.1.0] - 2020-10-23 diff --git a/rich/ansi.py b/rich/ansi.py index 307623c6..c2ec3581 100644 --- a/rich/ansi.py +++ b/rich/ansi.py @@ -10,7 +10,7 @@ re_ansi = re.compile(r"(?:\x1b\[(.*?)m)|(?:\x1b\](.*?)\x1b\\)") re_csi = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") -class AnsiToken(NamedTuple): +class _AnsiToken(NamedTuple): """Result of ansi tokenized string.""" plain: str = "" @@ -18,7 +18,7 @@ class AnsiToken(NamedTuple): osc: str = "" -def _ansi_tokenize(ansi_text: str) -> Iterable[AnsiToken]: +def _ansi_tokenize(ansi_text: str) -> Iterable[_AnsiToken]: """Tokenize a string in to plain text and ANSI codes. Args: @@ -37,11 +37,11 @@ def _ansi_tokenize(ansi_text: str) -> Iterable[AnsiToken]: start, end = match.span(0) sgr, osc = match.groups() if start > position: - yield AnsiToken(remove_csi(ansi_text[position:start])) - yield AnsiToken("", sgr, osc) + yield _AnsiToken(remove_csi(ansi_text[position:start])) + yield _AnsiToken("", sgr, osc) position = end if position < len(ansi_text): - yield AnsiToken(remove_csi(ansi_text[position:])) + yield _AnsiToken(remove_csi(ansi_text[position:])) SGR_STYLE_MAP = { @@ -108,7 +108,7 @@ SGR_STYLE_MAP = { class AnsiDecoder: """Translate ANSI code in to styled Text.""" - def __init__(self): + def __init__(self) -> None: self.style = Style.null() def decode(self, terminal_text: str) -> Iterable[Text]: @@ -138,6 +138,7 @@ class AnsiDecoder: _Style = Style text = Text() append = text.append + line = line.rsplit("\r", 1)[-1] for token in _ansi_tokenize(line): plain_text, sgr, osc = token if plain_text: @@ -156,27 +157,43 @@ class AnsiDecoder: iter_codes = iter(codes) for code in iter_codes: if code == 0: + # reset self.style = _Style.null() elif code in SGR_STYLE_MAP: + # styles self.style += _Style.parse(SGR_STYLE_MAP[code]) - elif code in (38, 48): + elif code == 38: + #  Foreground with suppress(StopIteration): color_type = next(iter_codes) if color_type == 5: - color = from_ansi(next(iter_codes)) - self.style += ( - _Style(color=color) - if code == 38 - else _Style(bgcolor=color) + self.style += _Style.from_color( + from_ansi(next(iter_codes)) ) elif color_type == 2: - color = from_rgb( - next(iter_codes), next(iter_codes), next(iter_codes) + self.style += _Style.from_color( + from_rgb( + next(iter_codes), + next(iter_codes), + next(iter_codes), + ) ) - self.style += ( - _Style(color=color) - if code == 38 - else _Style(bgcolor=color) + elif code == 48: + # Background + with suppress(StopIteration): + color_type = next(iter_codes) + if color_type == 5: + self.style += _Style.from_color( + None, from_ansi(next(iter_codes)) + ) + elif color_type == 2: + self.style += _Style.from_color( + None, + from_rgb( + next(iter_codes), + next(iter_codes), + next(iter_codes), + ), ) return text diff --git a/rich/console.py b/rich/console.py index 3b6fe5fe..51fb61c1 100644 --- a/rich/console.py +++ b/rich/console.py @@ -1274,7 +1274,7 @@ class Console: self._record_buffer.extend(buffer) not_terminal = not self.is_terminal for text, style, is_control in buffer: - if style and not is_control: + if style: append( style.render( text, diff --git a/rich/live_render.py b/rich/live_render.py index 719ca509..1c2a8019 100644 --- a/rich/live_render.py +++ b/rich/live_render.py @@ -55,8 +55,8 @@ class LiveRender: ) -> RenderResult: style = console.get_style(self.style) lines = console.render_lines(self.renderable, options, style=style, pad=False) - - shape = Segment.get_shape(lines) + _Segment = Segment + shape = _Segment.get_shape(lines) if self._shape is None: self._shape = shape else: @@ -68,8 +68,8 @@ class LiveRender: ) width, height = self._shape - lines = Segment.set_shape(lines, width, height) + lines = _Segment.set_shape(lines, width, height) for last, line in loop_last(lines): - yield from line + yield from _Segment.make_control(line) if not last: - yield Segment.line() + yield _Segment.line(is_control=True) diff --git a/rich/progress.py b/rich/progress.py index 97175900..3939048c 100644 --- a/rich/progress.py +++ b/rich/progress.py @@ -1021,16 +1021,19 @@ if __name__ == "__main__": # pragma: no coverage examples = cycle(progress_renderables) - console = Console() - with Progress(console=console, transient=True) as progress: + console = Console(record=True) + try: + with Progress(console=console, transient=True) as progress: - task1 = progress.add_task("[red]Downloading", total=1000) - task2 = progress.add_task("[green]Processing", total=1000) - task3 = progress.add_task("[yellow]Thinking", total=1000, start=False) + task1 = progress.add_task("[red]Downloading", total=1000) + task2 = progress.add_task("[green]Processing", total=1000) + task3 = progress.add_task("[yellow]Thinking", total=1000, start=False) - while not progress.finished: - progress.update(task1, advance=0.5) - progress.update(task2, advance=0.3) - time.sleep(0.01) - if random.randint(0, 100) < 1: - progress.log(next(examples)) + while not progress.finished: + progress.update(task1, advance=0.5) + progress.update(task2, advance=0.3) + time.sleep(0.01) + if random.randint(0, 100) < 1: + progress.log(next(examples)) + except: + console.save_html("progress.html") \ No newline at end of file diff --git a/rich/segment.py b/rich/segment.py index 3222f3d2..5c708447 100644 --- a/rich/segment.py +++ b/rich/segment.py @@ -41,21 +41,31 @@ class Segment(NamedTuple): return 0 if self.is_control else cell_len(self.text) @classmethod - def control(cls, text: str) -> "Segment": + def control(cls, text: str, style: Optional[Style] = None) -> "Segment": """Create a Segment with control codes. Args: text (str): Text containing non-printable control codes. + style (Optional[style]): Optional style. Returns: Segment: A Segment instance with ``is_control=True``. """ - return Segment(text, is_control=True) + return Segment(text, style, is_control=True) @classmethod - def line(cls) -> "Segment": + def make_control(cls, segments: Iterable["Segment"]) -> Iterable["Segment"]: + """Convert all segments in to control segments. + + Returns: + Iterable[Segments]: Segments with is_control=True + """ + return [cls(text, style, True) for text, style, _ in segments] + + @classmethod + def line(cls, is_control: bool = False) -> "Segment": """Make a new line segment.""" - return Segment("\n") + return Segment("\n", is_control=is_control) @classmethod def apply_style( diff --git a/rich/style.py b/rich/style.py index 7e008e9a..5a802d07 100644 --- a/rich/style.py +++ b/rich/style.py @@ -175,6 +175,35 @@ class Style: """Create an 'null' style, equivalent to Style(), but more performant.""" return NULL_STYLE + @classmethod + def from_color(cls, color: Color = None, bgcolor: Color = None) -> "Style": + """Create a new style with colors and no attributes. + + Returns: + color (Optional[Color]): A (foreground) color, or None for no color. Defaults to None. + bgcolor (Optional[Color]): A (background) color, or None for no color. Defaults to None. + """ + style = cls.__new__(Style) + style._ansi = None + style._style_definition = None + style._color = color + style._bgcolor = bgcolor + style._set_attributes = 0 + style._attributes = 0 + style._link = None + style._link_id = "" + style._hash = hash( + ( + color, + bgcolor, + None, + None, + None, + ) + ) + style._null = not (color or bgcolor) + return style + bold = _Bit(0) dim = _Bit(1) italic = _Bit(2) diff --git a/tests/test_ansi.py b/tests/test_ansi.py index 4d0dcb04..74678b8d 100644 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -13,17 +13,18 @@ def test_decode(): console.print("[b]foo[/b]") console.print("[link http://example.org]bar") console.print("[#ff0000 on color(200)]red") + console.print("[color(200) on #ff0000]red") terminal_codes = console.end_capture() decoder = AnsiDecoder() lines = list(decoder.decode(terminal_codes)) - parse = Style.parse expected = [ Text("Hello"), - Text("foo", spans=[Span(0, 3, parse("bold"))]), - Text("bar", spans=[Span(0, 3, parse("link http://example.org"))]), - Text("red", spans=[Span(0, 3, parse("#ff0000 on color(200)"))]), + Text("foo", spans=[Span(0, 3, Style.parse("bold"))]), + Text("bar", spans=[Span(0, 3, Style.parse("link http://example.org"))]), + Text("red", spans=[Span(0, 3, Style.parse("#ff0000 on color(200)"))]), + Text("red", spans=[Span(0, 3, Style.parse("color(200) on #ff0000"))]), ] assert lines == expected \ No newline at end of file diff --git a/tests/test_live_render.py b/tests/test_live_render.py index 0d47880c..46b5e80f 100644 --- a/tests/test_live_render.py +++ b/tests/test_live_render.py @@ -37,7 +37,7 @@ def test_rich_console(live_render): encoding="utf-8", ) rich_console = live_render.__rich_console__(Console(), options) - assert [Segment("my string", Style.parse("none"))] == list(rich_console) + assert [Segment.control("my string", Style.parse("none"))] == list(rich_console) live_render.style = "red" rich_console = live_render.__rich_console__(Console(), options) - assert [Segment("my string", Style.parse("red"))] == list(rich_console) + assert [Segment.control("my string", Style.parse("red"))] == list(rich_console) From 66bb97f2c4020536da2d92dd117633bf3a8012f6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 7 Nov 2020 15:23:18 +0000 Subject: [PATCH 11/12] newlines --- rich/progress.py | 3 ++- tests/test_ansi.py | 2 +- tests/test_pretty.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/rich/progress.py b/rich/progress.py index 3939048c..58171041 100644 --- a/rich/progress.py +++ b/rich/progress.py @@ -1036,4 +1036,5 @@ if __name__ == "__main__": # pragma: no coverage if random.randint(0, 100) < 1: progress.log(next(examples)) except: - console.save_html("progress.html") \ No newline at end of file + console.save_html("progress.html") + print("wrote progress.html") diff --git a/tests/test_ansi.py b/tests/test_ansi.py index 74678b8d..b452142b 100644 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -27,4 +27,4 @@ def test_decode(): Text("red", spans=[Span(0, 3, Style.parse("color(200) on #ff0000"))]), ] - assert lines == expected \ No newline at end of file + assert lines == expected diff --git a/tests/test_pretty.py b/tests/test_pretty.py index 205775ab..0823a9e3 100644 --- a/tests/test_pretty.py +++ b/tests/test_pretty.py @@ -120,4 +120,4 @@ def test_pprint_max_string(): console = Console(color_system=None) console.begin_capture() pprint(["Hello" * 20], console=console, max_string=8) - assert console.end_capture() == """['HelloHel'+92]\n""" \ No newline at end of file + assert console.end_capture() == """['HelloHel'+92]\n""" From 149af242d057435a15bce0798efcce6420bc3158 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 7 Nov 2020 15:27:37 +0000 Subject: [PATCH 12/12] disable test on windows --- tests/test_ansi.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_ansi.py b/tests/test_ansi.py index b452142b..898286cf 100644 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -7,7 +7,9 @@ from rich.text import Span, Text def test_decode(): - console = Console(force_terminal=True, color_system="truecolor") + console = Console( + force_terminal=True, legacy_windows=False, color_system="truecolor" + ) console.begin_capture() console.print("Hello") console.print("[b]foo[/b]")