diff --git a/CHANGELOG.md b/CHANGELOG.md index 583c24f2..0416e448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ 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 +- 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/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.py b/rich/ansi.py new file mode 100644 index 00000000..c2ec3581 --- /dev/null +++ b/rich/ansi.py @@ -0,0 +1,229 @@ +from contextlib import suppress +import re +from typing import Iterable, NamedTuple + +from .color import Color +from .style import Style +from .text import Text + +re_ansi = re.compile(r"(?:\x1b\[(.*?)m)|(?:\x1b\](.*?)\x1b\\)") +re_csi = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + + +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: + ansi_text (str): A String containing ANSI codes. + + Yields: + 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 + for match in re_ansi.finditer(ansi_text): + start, end = match.span(0) + sgr, osc = match.groups() + if start > position: + 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:])) + + +SGR_STYLE_MAP = { + 1: "bold", + 2: "dim", + 3: "italic", + 4: "underline", + 5: "blink", + 6: "blink2", + 7: "reverse", + 8: "conceal", + 9: "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", + 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)", +} + + +class AnsiDecoder: + """Translate ANSI code in to styled Text.""" + + def __init__(self) -> None: + self.style = Style.null() + + def decode(self, terminal_text: str) -> Iterable[Text]: + """Decode ANSI codes in an interable of lines. + + 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. + """ + from_ansi = Color.from_ansi + from_rgb = Color.from_rgb + _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: + append(plain_text, self.style or None) + elif osc: + if osc.startswith("8;"): + _params, semicolon, link = osc[2:].partition(";") + if semicolon: + 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 = [ + min(255, int(_code)) for _code in sgr.split(";") if _code.isdigit() + ] + 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 == 38: + #  Foreground + with suppress(StopIteration): + color_type = next(iter_codes) + if color_type == 5: + self.style += _Style.from_color( + from_ansi(next(iter_codes)) + ) + elif color_type == 2: + self.style += _Style.from_color( + from_rgb( + next(iter_codes), + next(iter_codes), + next(iter_codes), + ) + ) + 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 + + +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 + + console = Console(record=True) + + stdout_result = stdout.getvalue().decode("utf-8") + print(stdout_result) + + 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 223a5c40..8110eb5c 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 instance. + """ + return cls( + name=f"color({number})", + type=(ColorType.STANDARD if number < 16 else ColorType.EIGHT_BIT), + number=number, + ) + @classmethod def from_triplet(cls, triplet: "ColorTriplet") -> "Color": """Create a truecolor RGB color from a triplet of values. 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 7a3ad0cb..58171041 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]]: @@ -1017,16 +1021,20 @@ 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") + print("wrote progress.html") 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 23932097..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) @@ -355,7 +384,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 +398,7 @@ class Style: `Style`: A Style instance. """ if style_definition.strip() == "none": - return cls() + return cls.null() style_attributes = { "dim": "dim", diff --git a/tests/test_ansi.py b/tests/test_ansi.py new file mode 100644 index 00000000..898286cf --- /dev/null +++ b/tests/test_ansi.py @@ -0,0 +1,32 @@ +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, legacy_windows=False, 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") + console.print("[color(200) on #ff0000]red") + terminal_codes = console.end_capture() + + decoder = AnsiDecoder() + lines = list(decoder.decode(terminal_codes)) + + expected = [ + Text("Hello"), + 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 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) 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)