codekingpro/portable-devtools
114k
1import builtins2import inspect3import io4import keyword5import linecache6import os7import re8import sys9import sysconfig10import tokenize11import traceback12 13if sys.version_info >= (3, 11):14 15 def is_exception_group(exc):16 return isinstance(exc, ExceptionGroup)17 18else:19 try:20 from exceptiongroup import ExceptionGroup21 except ImportError:22 23 def is_exception_group(exc):24 return False25 26 else:27 28 def is_exception_group(exc):29 return isinstance(exc, ExceptionGroup)30 31 32class SyntaxHighlighter:33 _default_style = frozenset(34 {35 "comment": "\x1b[30m\x1b[1m{}\x1b[0m",36 "keyword": "\x1b[35m\x1b[1m{}\x1b[0m",37 "builtin": "\x1b[1m{}\x1b[0m",38 "string": "\x1b[36m{}\x1b[0m",39 "number": "\x1b[34m\x1b[1m{}\x1b[0m",40 "operator": "\x1b[35m\x1b[1m{}\x1b[0m",41 "punctuation": "\x1b[1m{}\x1b[0m",42 "constant": "\x1b[36m\x1b[1m{}\x1b[0m",43 "identifier": "\x1b[1m{}\x1b[0m",44 "other": "{}",45 }.items()46 )47 48 _builtins = frozenset(dir(builtins))49 _constants = frozenset({"True", "False", "None"})50 _punctuation = frozenset({"(", ")", "[", "]", "{", "}", ":", ",", ";"})51 52 if sys.version_info >= (3, 12):53 _strings = frozenset(54 {tokenize.STRING, tokenize.FSTRING_START, tokenize.FSTRING_MIDDLE, tokenize.FSTRING_END}55 )56 _fstring_middle = tokenize.FSTRING_MIDDLE57 else:58 _strings = frozenset({tokenize.STRING})59 _fstring_middle = None60 61 def __init__(self, style=None):62 self._style = style or dict(self._default_style)63 64 def highlight(self, source):65 style = self._style66 row, column = 0, 067 output = ""68 69 for token in self.tokenize(source):70 type_, string, (start_row, start_column), (_, end_column), line = token71 72 if type_ == self._fstring_middle:73 # When an f-string contains "{{" or "}}", they appear as "{" or "}" in the "string"74 # attribute of the token. However, they do not count in the column position.75 end_column += string.count("{") + string.count("}")76 77 if type_ == tokenize.NAME:78 if string in self._constants:79 color = style["constant"]80 elif keyword.iskeyword(string):81 color = style["keyword"]82 elif string in self._builtins:83 color = style["builtin"]84 else:85 color = style["identifier"]86 elif type_ == tokenize.OP:87 if string in self._punctuation:88 color = style["punctuation"]89 else:90 color = style["operator"]91 elif type_ == tokenize.NUMBER:92 color = style["number"]93 elif type_ in self._strings:94 color = style["string"]95 elif type_ == tokenize.COMMENT:96 color = style["comment"]97 else:98 color = style["other"]99 100 if start_row != row:101 source = source[column:]102 row, column = start_row, 0103 104 if type_ != tokenize.ENCODING:105 output += line[column:start_column]106 output += color.format(line[start_column:end_column])107 108 column = end_column109 110 output += source[column:]111 112 return output113 114 @staticmethod115 def tokenize(source):116 # Worth reading: https://www.asmeurer.com/brown-water-python/117 source = source.encode("utf-8")118 source = io.BytesIO(source)119 120 try:121 yield from tokenize.tokenize(source.readline)122 except tokenize.TokenError:123 return124 125 126class ExceptionFormatter:127 _default_theme = frozenset(128 {129 "introduction": "\x1b[33m\x1b[1m{}\x1b[0m",130 "cause": "\x1b[1m{}\x1b[0m",131 "context": "\x1b[1m{}\x1b[0m",132 "dirname": "\x1b[32m{}\x1b[0m",133 "basename": "\x1b[32m\x1b[1m{}\x1b[0m",134 "line": "\x1b[33m{}\x1b[0m",135 "function": "\x1b[35m{}\x1b[0m",136 "exception_type": "\x1b[31m\x1b[1m{}\x1b[0m",137 "exception_value": "\x1b[1m{}\x1b[0m",138 "arrows": "\x1b[36m{}\x1b[0m",139 "value": "\x1b[36m\x1b[1m{}\x1b[0m",140 }.items()141 )142 143 def __init__(144 self,145 colorize=False,146 backtrace=False,147 diagnose=True,148 theme=None,149 style=None,150 max_length=128,151 encoding="ascii",152 hidden_frames_filename=None,153 prefix="",154 ):155 self._colorize = colorize156 self._diagnose = diagnose157 self._theme = theme or dict(self._default_theme)158 self._backtrace = backtrace159 self._syntax_highlighter = SyntaxHighlighter(style)160 self._max_length = max_length161 self._encoding = encoding162 self._hidden_frames_filename = hidden_frames_filename163 self._prefix = prefix164 self._lib_dirs = self._get_lib_dirs()165 self._pipe_char = self._get_char("\u2502", "|")166 self._cap_char = self._get_char("\u2514", "->")167 self._catch_point_identifier = " <Loguru catch point here>"168 169 @staticmethod170 def _get_lib_dirs():171 schemes = sysconfig.get_scheme_names()172 names = ["stdlib", "platstdlib", "platlib", "purelib"]173 paths = {sysconfig.get_path(name, scheme) for scheme in schemes for name in names}174 return [os.path.abspath(path).lower() + os.sep for path in paths if path in sys.path]175 176 @staticmethod177 def _indent(text, count, *, prefix="| "):178 if count == 0:179 yield text180 return181 for line in text.splitlines(True):182 indented = " " * count + prefix + line183 yield indented.rstrip() + "\n"184 185 def _get_char(self, char, default):186 try:187 char.encode(self._encoding)188 except (UnicodeEncodeError, LookupError):189 return default190 else:191 return char192 193 def _is_file_mine(self, file):194 filepath = os.path.abspath(file).lower()195 if not filepath.endswith(".py"):196 return False197 return not any(filepath.startswith(d) for d in self._lib_dirs)198 199 def _extract_frames(self, tb, is_first, *, limit=None, from_decorator=False):200 frames, final_source = [], None201 202 if tb is None or (limit is not None and limit <= 0):203 return frames, final_source204 205 def is_valid(frame):206 return frame.f_code.co_filename != self._hidden_frames_filename207 208 def get_info(frame, lineno):209 filename = frame.f_code.co_filename210 function = frame.f_code.co_name211 source = linecache.getline(filename, lineno).strip()212 return filename, lineno, function, source213 214 infos = []215 216 if is_valid(tb.tb_frame):217 infos.append((get_info(tb.tb_frame, tb.tb_lineno), tb.tb_frame))218 219 get_parent_only = from_decorator and not self._backtrace220 221 if (self._backtrace and is_first) or get_parent_only:222 frame = tb.tb_frame.f_back223 while frame:224 if is_valid(frame):225 infos.insert(0, (get_info(frame, frame.f_lineno), frame))226 if get_parent_only:227 break228 frame = frame.f_back229 230 if infos and not get_parent_only:231 (filename, lineno, function, source), frame = infos[-1]232 function += self._catch_point_identifier233 infos[-1] = ((filename, lineno, function, source), frame)234 235 tb = tb.tb_next236 237 while tb:238 if is_valid(tb.tb_frame):239 infos.append((get_info(tb.tb_frame, tb.tb_lineno), tb.tb_frame))240 tb = tb.tb_next241 242 if limit is not None:243 infos = infos[-limit:]244 245 for (filename, lineno, function, source), frame in infos:246 final_source = source247 if source:248 colorize = self._colorize and self._is_file_mine(filename)249 lines = []250 if colorize:251 lines.append(self._syntax_highlighter.highlight(source))252 else:253 lines.append(source)254 if self._diagnose:255 relevant_values = self._get_relevant_values(source, frame)256 values = self._format_relevant_values(list(relevant_values), colorize)257 lines += list(values)258 source = "\n ".join(lines)259 frames.append((filename, lineno, function, source))260 261 return frames, final_source262 263 def _get_relevant_values(self, source, frame):264 value = None265 pending = None266 is_attribute = False267 is_valid_value = False268 is_assignment = True269 270 for token in self._syntax_highlighter.tokenize(source):271 type_, string, (_, col), *_ = token272 273 if pending is not None:274 # Keyword arguments are ignored275 if type_ != tokenize.OP or string != "=" or is_assignment:276 yield pending277 pending = None278 279 if type_ == tokenize.NAME and not keyword.iskeyword(string):280 if not is_attribute:281 for variables in (frame.f_locals, frame.f_globals):282 try:283 value = variables[string]284 except KeyError:285 continue286 else:287 is_valid_value = True288 pending = (col, self._format_value(value))289 break290 elif is_valid_value:291 try:292 value = inspect.getattr_static(value, string)293 except AttributeError:294 is_valid_value = False295 else:296 yield (col, self._format_value(value))297 elif type_ == tokenize.OP and string == ".":298 is_attribute = True299 is_assignment = False300 elif type_ == tokenize.OP and string == ";":301 is_assignment = True302 is_attribute = False303 is_valid_value = False304 else:305 is_attribute = False306 is_valid_value = False307 is_assignment = False308 309 if pending is not None:310 yield pending311 312 def _format_relevant_values(self, relevant_values, colorize):313 for i in reversed(range(len(relevant_values))):314 col, value = relevant_values[i]315 pipe_cols = [pcol for pcol, _ in relevant_values[:i]]316 pre_line = ""317 index = 0318 319 for pc in pipe_cols:320 pre_line += (" " * (pc - index)) + self._pipe_char321 index = pc + 1322 323 pre_line += " " * (col - index)324 value_lines = value.split("\n")325 326 for n, value_line in enumerate(value_lines):327 if n == 0:328 arrows = pre_line + self._cap_char + " "329 else:330 arrows = pre_line + " " * (len(self._cap_char) + 1)331 332 if colorize:333 arrows = self._theme["arrows"].format(arrows)334 value_line = self._theme["value"].format(value_line)335 336 yield arrows + value_line337 338 def _format_value(self, v):339 try:340 v = repr(v)341 except Exception:342 v = "<unprintable %s object>" % type(v).__name__343 344 max_length = self._max_length345 if max_length is not None and len(v) > max_length:346 v = v[: max_length - 3] + "..."347 return v348 349 def _format_locations(self, frames_lines, *, has_introduction):350 prepend_with_new_line = has_introduction351 regex = r'^ File "(?P<file>.*?)", line (?P<line>[^,]+)(?:, in (?P<function>.*))?\n'352 353 for frame in frames_lines:354 match = re.match(regex, frame)355 356 if match:357 file, line, function = match.group("file", "line", "function")358 359 is_mine = self._is_file_mine(file)360 361 if function is not None:362 pattern = ' File "{}", line {}, in {}\n'363 else:364 pattern = ' File "{}", line {}\n'365 366 if self._backtrace and function and function.endswith(self._catch_point_identifier):367 function = function[: -len(self._catch_point_identifier)]368 pattern = ">" + pattern[1:]369 370 if self._colorize and is_mine:371 dirname, basename = os.path.split(file)372 if dirname:373 dirname += os.sep374 dirname = self._theme["dirname"].format(dirname)375 basename = self._theme["basename"].format(basename)376 file = dirname + basename377 line = self._theme["line"].format(line)378 function = self._theme["function"].format(function)379 380 if self._diagnose and (is_mine or prepend_with_new_line):381 pattern = "\n" + pattern382 383 location = pattern.format(file, line, function)384 frame = location + frame[match.end() :]385 prepend_with_new_line = is_mine386 387 yield frame388 389 def _format_exception(390 self, value, tb, *, seen=None, is_first=False, from_decorator=False, group_nesting=0391 ):392 # Implemented from built-in traceback module:393 # https://github.com/python/cpython/blob/a5b76167/Lib/traceback.py#L468394 exc_type, exc_value, exc_traceback = type(value), value, tb395 396 if seen is None:397 seen = set()398 399 seen.add(id(exc_value))400 401 if exc_value:402 if exc_value.__cause__ is not None and id(exc_value.__cause__) not in seen:403 yield from self._format_exception(404 exc_value.__cause__,405 exc_value.__cause__.__traceback__,406 seen=seen,407 group_nesting=group_nesting,408 )409 cause = "The above exception was the direct cause of the following exception:"410 if self._colorize:411 cause = self._theme["cause"].format(cause)412 if self._diagnose:413 yield from self._indent("\n\n" + cause + "\n\n\n", group_nesting)414 else:415 yield from self._indent("\n" + cause + "\n\n", group_nesting)416 417 elif (418 exc_value.__context__ is not None419 and id(exc_value.__context__) not in seen420 and not exc_value.__suppress_context__421 ):422 yield from self._format_exception(423 exc_value.__context__,424 exc_value.__context__.__traceback__,425 seen=seen,426 group_nesting=group_nesting,427 )428 context = "During handling of the above exception, another exception occurred:"429 if self._colorize:430 context = self._theme["context"].format(context)431 if self._diagnose:432 yield from self._indent("\n\n" + context + "\n\n\n", group_nesting)433 else:434 yield from self._indent("\n" + context + "\n\n", group_nesting)435 436 is_grouped = is_exception_group(value)437 438 if is_grouped and group_nesting == 0:439 yield from self._format_exception(440 value,441 tb,442 seen=seen,443 group_nesting=1,444 is_first=is_first,445 from_decorator=from_decorator,446 )447 return448 449 try:450 traceback_limit = sys.tracebacklimit451 except AttributeError:452 traceback_limit = None453 454 frames, final_source = self._extract_frames(455 exc_traceback, is_first, limit=traceback_limit, from_decorator=from_decorator456 )457 exception_only = traceback.format_exception_only(exc_type, exc_value)458 459 # Determining the correct index for the "Exception: message" part in the formatted exception460 # is challenging. This is because it might be preceded by multiple lines specific to461 # "SyntaxError" or followed by various notes. However, we can make an educated guess based462 # on the indentation; the preliminary context for "SyntaxError" is always indented, while463 # the Exception itself is not. This allows us to identify the correct index for the464 # exception message.465 no_indented_indexes = (i for i, p in enumerate(exception_only) if not p.startswith(" "))466 error_message_index = next(no_indented_indexes, None)467 468 if error_message_index is not None:469 # Remove final new line temporarily.470 error_message = exception_only[error_message_index][:-1]471 472 if self._colorize:473 if ":" in error_message:474 exception_type, exception_value = error_message.split(":", 1)475 exception_type = self._theme["exception_type"].format(exception_type)476 exception_value = self._theme["exception_value"].format(exception_value)477 error_message = exception_type + ":" + exception_value478 else:479 error_message = self._theme["exception_type"].format(error_message)480 481 if self._diagnose and frames:482 if issubclass(exc_type, AssertionError) and not str(exc_value) and final_source:483 if self._colorize:484 final_source = self._syntax_highlighter.highlight(final_source)485 error_message += ": " + final_source486 487 error_message = "\n" + error_message488 489 exception_only[error_message_index] = error_message + "\n"490 491 if is_first:492 yield self._prefix493 494 has_introduction = bool(frames)495 496 if has_introduction:497 if is_grouped:498 introduction = "Exception Group Traceback (most recent call last):"499 else:500 introduction = "Traceback (most recent call last):"501 if self._colorize:502 introduction = self._theme["introduction"].format(introduction)503 if group_nesting == 1: # Implies we're processing the root ExceptionGroup.504 yield from self._indent(introduction + "\n", group_nesting, prefix="+ ")505 else:506 yield from self._indent(introduction + "\n", group_nesting)507 508 frames_lines = self._format_list(frames) + exception_only509 if self._colorize or self._backtrace or self._diagnose:510 frames_lines = self._format_locations(frames_lines, has_introduction=has_introduction)511 512 yield from self._indent("".join(frames_lines), group_nesting)513 514 if is_grouped:515 exc = None516 for n, exc in enumerate(value.exceptions, start=1):517 ruler = "+" + (" %s " % ("..." if n > 15 else n)).center(35, "-")518 yield from self._indent(ruler, group_nesting, prefix="+-" if n == 1 else " ")519 if n > 15:520 message = "and %d more exceptions\n" % (len(value.exceptions) - 15)521 yield from self._indent(message, group_nesting + 1)522 break523 elif group_nesting == 10 and is_exception_group(exc):524 message = "... (max_group_depth is 10)\n"525 yield from self._indent(message, group_nesting + 1)526 else:527 yield from self._format_exception(528 exc,529 exc.__traceback__,530 seen=seen,531 group_nesting=group_nesting + 1,532 )533 if not is_exception_group(exc) or group_nesting == 10:534 yield from self._indent("-" * 35, group_nesting + 1, prefix="+-")535 536 def _format_list(self, frames):537 538 def source_message(filename, lineno, name, line):539 message = ' File "%s", line %d, in %s\n' % (filename, lineno, name)540 if line:541 message += " %s\n" % line.strip()542 return message543 544 def skip_message(count):545 plural = "s" if count > 1 else ""546 return " [Previous line repeated %d more time%s]\n" % (count, plural)547 548 result = []549 count = 0550 last_source = None551 552 for *source, line in frames:553 if source != last_source and count > 3:554 result.append(skip_message(count - 3))555 556 if source == last_source:557 count += 1558 if count > 3:559 continue560 else:561 count = 1562 563 result.append(source_message(*source, line))564 last_source = source565 566 # Add a final skip message if the iteration of frames ended mid-repetition.567 if count > 3:568 result.append(skip_message(count - 3))569 570 return result571 572 def format_exception(self, type_, value, tb, *, from_decorator=False):573 yield from self._format_exception(value, tb, is_first=True, from_decorator=from_decorator)574 