Aluode/PerceptionLabPortable
0
1import re2import sys3from contextlib import suppress4from typing import Iterable, NamedTuple, Optional5 6from .color import Color7from .style import Style8from .text import Text9 10re_ansi = re.compile(11 r"""12(?:\x1b[0-?])|13(?:\x1b\](.*?)\x1b\\)|14(?:\x1b([(@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))15""",16 re.VERBOSE,17)18 19 20class _AnsiToken(NamedTuple):21 """Result of ansi tokenized string."""22 23 plain: str = ""24 sgr: Optional[str] = ""25 osc: Optional[str] = ""26 27 28def _ansi_tokenize(ansi_text: str) -> Iterable[_AnsiToken]:29 """Tokenize a string in to plain text and ANSI codes.30 31 Args:32 ansi_text (str): A String containing ANSI codes.33 34 Yields:35 AnsiToken: A named tuple of (plain, sgr, osc)36 """37 38 position = 039 sgr: Optional[str]40 osc: Optional[str]41 for match in re_ansi.finditer(ansi_text):42 start, end = match.span(0)43 osc, sgr = match.groups()44 if start > position:45 yield _AnsiToken(ansi_text[position:start])46 if sgr:47 if sgr == "(":48 position = end + 149 continue50 if sgr.endswith("m"):51 yield _AnsiToken("", sgr[1:-1], osc)52 else:53 yield _AnsiToken("", sgr, osc)54 position = end55 if position < len(ansi_text):56 yield _AnsiToken(ansi_text[position:])57 58 59SGR_STYLE_MAP = {60 1: "bold",61 2: "dim",62 3: "italic",63 4: "underline",64 5: "blink",65 6: "blink2",66 7: "reverse",67 8: "conceal",68 9: "strike",69 21: "underline2",70 22: "not dim not bold",71 23: "not italic",72 24: "not underline",73 25: "not blink",74 26: "not blink2",75 27: "not reverse",76 28: "not conceal",77 29: "not strike",78 30: "color(0)",79 31: "color(1)",80 32: "color(2)",81 33: "color(3)",82 34: "color(4)",83 35: "color(5)",84 36: "color(6)",85 37: "color(7)",86 39: "default",87 40: "on color(0)",88 41: "on color(1)",89 42: "on color(2)",90 43: "on color(3)",91 44: "on color(4)",92 45: "on color(5)",93 46: "on color(6)",94 47: "on color(7)",95 49: "on default",96 51: "frame",97 52: "encircle",98 53: "overline",99 54: "not frame not encircle",100 55: "not overline",101 90: "color(8)",102 91: "color(9)",103 92: "color(10)",104 93: "color(11)",105 94: "color(12)",106 95: "color(13)",107 96: "color(14)",108 97: "color(15)",109 100: "on color(8)",110 101: "on color(9)",111 102: "on color(10)",112 103: "on color(11)",113 104: "on color(12)",114 105: "on color(13)",115 106: "on color(14)",116 107: "on color(15)",117}118 119 120class AnsiDecoder:121 """Translate ANSI code in to styled Text."""122 123 def __init__(self) -> None:124 self.style = Style.null()125 126 def decode(self, terminal_text: str) -> Iterable[Text]:127 """Decode ANSI codes in an iterable of lines.128 129 Args:130 lines (Iterable[str]): An iterable of lines of terminal output.131 132 Yields:133 Text: Marked up Text.134 """135 for line in terminal_text.splitlines():136 yield self.decode_line(line)137 138 def decode_line(self, line: str) -> Text:139 """Decode a line containing ansi codes.140 141 Args:142 line (str): A line of terminal output.143 144 Returns:145 Text: A Text instance marked up according to ansi codes.146 """147 from_ansi = Color.from_ansi148 from_rgb = Color.from_rgb149 _Style = Style150 text = Text()151 append = text.append152 line = line.rsplit("\r", 1)[-1]153 for plain_text, sgr, osc in _ansi_tokenize(line):154 if plain_text:155 append(plain_text, self.style or None)156 elif osc is not None:157 if osc.startswith("8;"):158 _params, semicolon, link = osc[2:].partition(";")159 if semicolon:160 self.style = self.style.update_link(link or None)161 elif sgr is not None:162 # Translate in to semi-colon separated codes163 # Ignore invalid codes, because we want to be lenient164 codes = [165 min(255, int(_code) if _code else 0)166 for _code in sgr.split(";")167 if _code.isdigit() or _code == ""168 ]169 iter_codes = iter(codes)170 for code in iter_codes:171 if code == 0:172 # reset173 self.style = _Style.null()174 elif code in SGR_STYLE_MAP:175 # styles176 self.style += _Style.parse(SGR_STYLE_MAP[code])177 elif code == 38:178 # Foreground179 with suppress(StopIteration):180 color_type = next(iter_codes)181 if color_type == 5:182 self.style += _Style.from_color(183 from_ansi(next(iter_codes))184 )185 elif color_type == 2:186 self.style += _Style.from_color(187 from_rgb(188 next(iter_codes),189 next(iter_codes),190 next(iter_codes),191 )192 )193 elif code == 48:194 # Background195 with suppress(StopIteration):196 color_type = next(iter_codes)197 if color_type == 5:198 self.style += _Style.from_color(199 None, from_ansi(next(iter_codes))200 )201 elif color_type == 2:202 self.style += _Style.from_color(203 None,204 from_rgb(205 next(iter_codes),206 next(iter_codes),207 next(iter_codes),208 ),209 )210 211 return text212 213 214if sys.platform != "win32" and __name__ == "__main__": # pragma: no cover215 import io216 import os217 import pty218 import sys219 220 decoder = AnsiDecoder()221 222 stdout = io.BytesIO()223 224 def read(fd: int) -> bytes:225 data = os.read(fd, 1024)226 stdout.write(data)227 return data228 229 pty.spawn(sys.argv[1:], read)230 231 from .console import Console232 233 console = Console(record=True)234 235 stdout_result = stdout.getvalue().decode("utf-8")236 print(stdout_result)237 238 for line in decoder.decode(stdout_result):239 console.print(line)240 241 console.save_html("stdout.html")242 