_better_exceptions.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. import builtins
  2. import inspect
  3. import io
  4. import keyword
  5. import linecache
  6. import os
  7. import re
  8. import sys
  9. import sysconfig
  10. import tokenize
  11. import traceback
  12. if sys.version_info >= (3, 11):
  13. def is_exception_group(exc):
  14. return isinstance(exc, ExceptionGroup)
  15. else:
  16. try:
  17. from exceptiongroup import ExceptionGroup
  18. except ImportError:
  19. def is_exception_group(exc):
  20. return False
  21. else:
  22. def is_exception_group(exc):
  23. return isinstance(exc, ExceptionGroup)
  24. class SyntaxHighlighter:
  25. _default_style = frozenset(
  26. {
  27. "comment": "\x1b[30m\x1b[1m{}\x1b[0m",
  28. "keyword": "\x1b[35m\x1b[1m{}\x1b[0m",
  29. "builtin": "\x1b[1m{}\x1b[0m",
  30. "string": "\x1b[36m{}\x1b[0m",
  31. "number": "\x1b[34m\x1b[1m{}\x1b[0m",
  32. "operator": "\x1b[35m\x1b[1m{}\x1b[0m",
  33. "punctuation": "\x1b[1m{}\x1b[0m",
  34. "constant": "\x1b[36m\x1b[1m{}\x1b[0m",
  35. "identifier": "\x1b[1m{}\x1b[0m",
  36. "other": "{}",
  37. }.items()
  38. )
  39. _builtins = frozenset(dir(builtins))
  40. _constants = frozenset({"True", "False", "None"})
  41. _punctuation = frozenset({"(", ")", "[", "]", "{", "}", ":", ",", ";"})
  42. if sys.version_info >= (3, 12):
  43. _strings = frozenset(
  44. {tokenize.STRING, tokenize.FSTRING_START, tokenize.FSTRING_MIDDLE, tokenize.FSTRING_END}
  45. )
  46. _fstring_middle = tokenize.FSTRING_MIDDLE
  47. else:
  48. _strings = frozenset({tokenize.STRING})
  49. _fstring_middle = None
  50. def __init__(self, style=None):
  51. self._style = style or dict(self._default_style)
  52. def highlight(self, source):
  53. style = self._style
  54. row, column = 0, 0
  55. output = ""
  56. for token in self.tokenize(source):
  57. type_, string, (start_row, start_column), (_, end_column), line = token
  58. if type_ == self._fstring_middle:
  59. # When an f-string contains "{{" or "}}", they appear as "{" or "}" in the "string"
  60. # attribute of the token. However, they do not count in the column position.
  61. end_column += string.count("{") + string.count("}")
  62. if type_ == tokenize.NAME:
  63. if string in self._constants:
  64. color = style["constant"]
  65. elif keyword.iskeyword(string):
  66. color = style["keyword"]
  67. elif string in self._builtins:
  68. color = style["builtin"]
  69. else:
  70. color = style["identifier"]
  71. elif type_ == tokenize.OP:
  72. if string in self._punctuation:
  73. color = style["punctuation"]
  74. else:
  75. color = style["operator"]
  76. elif type_ == tokenize.NUMBER:
  77. color = style["number"]
  78. elif type_ in self._strings:
  79. color = style["string"]
  80. elif type_ == tokenize.COMMENT:
  81. color = style["comment"]
  82. else:
  83. color = style["other"]
  84. if start_row != row:
  85. source = source[column:]
  86. row, column = start_row, 0
  87. if type_ != tokenize.ENCODING:
  88. output += line[column:start_column]
  89. output += color.format(line[start_column:end_column])
  90. column = end_column
  91. output += source[column:]
  92. return output
  93. @staticmethod
  94. def tokenize(source):
  95. # Worth reading: https://www.asmeurer.com/brown-water-python/
  96. source = source.encode("utf-8")
  97. source = io.BytesIO(source)
  98. try:
  99. yield from tokenize.tokenize(source.readline)
  100. except tokenize.TokenError:
  101. return
  102. class ExceptionFormatter:
  103. _default_theme = frozenset(
  104. {
  105. "introduction": "\x1b[33m\x1b[1m{}\x1b[0m",
  106. "cause": "\x1b[1m{}\x1b[0m",
  107. "context": "\x1b[1m{}\x1b[0m",
  108. "dirname": "\x1b[32m{}\x1b[0m",
  109. "basename": "\x1b[32m\x1b[1m{}\x1b[0m",
  110. "line": "\x1b[33m{}\x1b[0m",
  111. "function": "\x1b[35m{}\x1b[0m",
  112. "exception_type": "\x1b[31m\x1b[1m{}\x1b[0m",
  113. "exception_value": "\x1b[1m{}\x1b[0m",
  114. "arrows": "\x1b[36m{}\x1b[0m",
  115. "value": "\x1b[36m\x1b[1m{}\x1b[0m",
  116. }.items()
  117. )
  118. def __init__(
  119. self,
  120. colorize=False,
  121. backtrace=False,
  122. diagnose=True,
  123. theme=None,
  124. style=None,
  125. max_length=128,
  126. encoding="ascii",
  127. hidden_frames_filename=None,
  128. prefix="",
  129. ):
  130. self._colorize = colorize
  131. self._diagnose = diagnose
  132. self._theme = theme or dict(self._default_theme)
  133. self._backtrace = backtrace
  134. self._syntax_highlighter = SyntaxHighlighter(style)
  135. self._max_length = max_length
  136. self._encoding = encoding
  137. self._hidden_frames_filename = hidden_frames_filename
  138. self._prefix = prefix
  139. self._lib_dirs = self._get_lib_dirs()
  140. self._pipe_char = self._get_char("\u2502", "|")
  141. self._cap_char = self._get_char("\u2514", "->")
  142. self._catch_point_identifier = " <Loguru catch point here>"
  143. @staticmethod
  144. def _get_lib_dirs():
  145. schemes = sysconfig.get_scheme_names()
  146. names = ["stdlib", "platstdlib", "platlib", "purelib"]
  147. paths = {sysconfig.get_path(name, scheme) for scheme in schemes for name in names}
  148. return [os.path.abspath(path).lower() + os.sep for path in paths if path in sys.path]
  149. @staticmethod
  150. def _indent(text, count, *, prefix="| "):
  151. if count == 0:
  152. yield text
  153. return
  154. for line in text.splitlines(True):
  155. indented = " " * count + prefix + line
  156. yield indented.rstrip() + "\n"
  157. def _get_char(self, char, default):
  158. try:
  159. char.encode(self._encoding)
  160. except (UnicodeEncodeError, LookupError):
  161. return default
  162. else:
  163. return char
  164. def _is_file_mine(self, file):
  165. filepath = os.path.abspath(file).lower()
  166. if not filepath.endswith(".py"):
  167. return False
  168. return not any(filepath.startswith(d) for d in self._lib_dirs)
  169. def _extract_frames(self, tb, is_first, *, limit=None, from_decorator=False):
  170. frames, final_source = [], None
  171. if tb is None or (limit is not None and limit <= 0):
  172. return frames, final_source
  173. def is_valid(frame):
  174. return frame.f_code.co_filename != self._hidden_frames_filename
  175. def get_info(frame, lineno):
  176. filename = frame.f_code.co_filename
  177. function = frame.f_code.co_name
  178. source = linecache.getline(filename, lineno).strip()
  179. return filename, lineno, function, source
  180. infos = []
  181. if is_valid(tb.tb_frame):
  182. infos.append((get_info(tb.tb_frame, tb.tb_lineno), tb.tb_frame))
  183. get_parent_only = from_decorator and not self._backtrace
  184. if (self._backtrace and is_first) or get_parent_only:
  185. frame = tb.tb_frame.f_back
  186. while frame:
  187. if is_valid(frame):
  188. infos.insert(0, (get_info(frame, frame.f_lineno), frame))
  189. if get_parent_only:
  190. break
  191. frame = frame.f_back
  192. if infos and not get_parent_only:
  193. (filename, lineno, function, source), frame = infos[-1]
  194. function += self._catch_point_identifier
  195. infos[-1] = ((filename, lineno, function, source), frame)
  196. tb = tb.tb_next
  197. while tb:
  198. if is_valid(tb.tb_frame):
  199. infos.append((get_info(tb.tb_frame, tb.tb_lineno), tb.tb_frame))
  200. tb = tb.tb_next
  201. if limit is not None:
  202. infos = infos[-limit:]
  203. for (filename, lineno, function, source), frame in infos:
  204. final_source = source
  205. if source:
  206. colorize = self._colorize and self._is_file_mine(filename)
  207. lines = []
  208. if colorize:
  209. lines.append(self._syntax_highlighter.highlight(source))
  210. else:
  211. lines.append(source)
  212. if self._diagnose:
  213. relevant_values = self._get_relevant_values(source, frame)
  214. values = self._format_relevant_values(list(relevant_values), colorize)
  215. lines += list(values)
  216. source = "\n ".join(lines)
  217. frames.append((filename, lineno, function, source))
  218. return frames, final_source
  219. def _get_relevant_values(self, source, frame):
  220. value = None
  221. pending = None
  222. is_attribute = False
  223. is_valid_value = False
  224. is_assignment = True
  225. for token in self._syntax_highlighter.tokenize(source):
  226. type_, string, (_, col), *_ = token
  227. if pending is not None:
  228. # Keyword arguments are ignored
  229. if type_ != tokenize.OP or string != "=" or is_assignment:
  230. yield pending
  231. pending = None
  232. if type_ == tokenize.NAME and not keyword.iskeyword(string):
  233. if not is_attribute:
  234. for variables in (frame.f_locals, frame.f_globals):
  235. try:
  236. value = variables[string]
  237. except KeyError:
  238. continue
  239. else:
  240. is_valid_value = True
  241. pending = (col, self._format_value(value))
  242. break
  243. elif is_valid_value:
  244. try:
  245. value = inspect.getattr_static(value, string)
  246. except AttributeError:
  247. is_valid_value = False
  248. else:
  249. yield (col, self._format_value(value))
  250. elif type_ == tokenize.OP and string == ".":
  251. is_attribute = True
  252. is_assignment = False
  253. elif type_ == tokenize.OP and string == ";":
  254. is_assignment = True
  255. is_attribute = False
  256. is_valid_value = False
  257. else:
  258. is_attribute = False
  259. is_valid_value = False
  260. is_assignment = False
  261. if pending is not None:
  262. yield pending
  263. def _format_relevant_values(self, relevant_values, colorize):
  264. for i in reversed(range(len(relevant_values))):
  265. col, value = relevant_values[i]
  266. pipe_cols = [pcol for pcol, _ in relevant_values[:i]]
  267. pre_line = ""
  268. index = 0
  269. for pc in pipe_cols:
  270. pre_line += (" " * (pc - index)) + self._pipe_char
  271. index = pc + 1
  272. pre_line += " " * (col - index)
  273. value_lines = value.split("\n")
  274. for n, value_line in enumerate(value_lines):
  275. if n == 0:
  276. arrows = pre_line + self._cap_char + " "
  277. else:
  278. arrows = pre_line + " " * (len(self._cap_char) + 1)
  279. if colorize:
  280. arrows = self._theme["arrows"].format(arrows)
  281. value_line = self._theme["value"].format(value_line)
  282. yield arrows + value_line
  283. def _format_value(self, v):
  284. try:
  285. v = repr(v)
  286. except Exception:
  287. v = "<unprintable %s object>" % type(v).__name__
  288. max_length = self._max_length
  289. if max_length is not None and len(v) > max_length:
  290. v = v[: max_length - 3] + "..."
  291. return v
  292. def _format_locations(self, frames_lines, *, has_introduction):
  293. prepend_with_new_line = has_introduction
  294. regex = r'^ File "(?P<file>.*?)", line (?P<line>[^,]+)(?:, in (?P<function>.*))?\n'
  295. for frame in frames_lines:
  296. match = re.match(regex, frame)
  297. if match:
  298. file, line, function = match.group("file", "line", "function")
  299. is_mine = self._is_file_mine(file)
  300. if function is not None:
  301. pattern = ' File "{}", line {}, in {}\n'
  302. else:
  303. pattern = ' File "{}", line {}\n'
  304. if self._backtrace and function and function.endswith(self._catch_point_identifier):
  305. function = function[: -len(self._catch_point_identifier)]
  306. pattern = ">" + pattern[1:]
  307. if self._colorize and is_mine:
  308. dirname, basename = os.path.split(file)
  309. if dirname:
  310. dirname += os.sep
  311. dirname = self._theme["dirname"].format(dirname)
  312. basename = self._theme["basename"].format(basename)
  313. file = dirname + basename
  314. line = self._theme["line"].format(line)
  315. function = self._theme["function"].format(function)
  316. if self._diagnose and (is_mine or prepend_with_new_line):
  317. pattern = "\n" + pattern
  318. location = pattern.format(file, line, function)
  319. frame = location + frame[match.end() :]
  320. prepend_with_new_line = is_mine
  321. yield frame
  322. def _format_exception(
  323. self, value, tb, *, seen=None, is_first=False, from_decorator=False, group_nesting=0
  324. ):
  325. # Implemented from built-in traceback module:
  326. # https://github.com/python/cpython/blob/a5b76167/Lib/traceback.py#L468
  327. exc_type, exc_value, exc_traceback = type(value), value, tb
  328. if seen is None:
  329. seen = set()
  330. seen.add(id(exc_value))
  331. if exc_value:
  332. if exc_value.__cause__ is not None and id(exc_value.__cause__) not in seen:
  333. yield from self._format_exception(
  334. exc_value.__cause__,
  335. exc_value.__cause__.__traceback__,
  336. seen=seen,
  337. group_nesting=group_nesting,
  338. )
  339. cause = "The above exception was the direct cause of the following exception:"
  340. if self._colorize:
  341. cause = self._theme["cause"].format(cause)
  342. if self._diagnose:
  343. yield from self._indent("\n\n" + cause + "\n\n\n", group_nesting)
  344. else:
  345. yield from self._indent("\n" + cause + "\n\n", group_nesting)
  346. elif (
  347. exc_value.__context__ is not None
  348. and id(exc_value.__context__) not in seen
  349. and not exc_value.__suppress_context__
  350. ):
  351. yield from self._format_exception(
  352. exc_value.__context__,
  353. exc_value.__context__.__traceback__,
  354. seen=seen,
  355. group_nesting=group_nesting,
  356. )
  357. context = "During handling of the above exception, another exception occurred:"
  358. if self._colorize:
  359. context = self._theme["context"].format(context)
  360. if self._diagnose:
  361. yield from self._indent("\n\n" + context + "\n\n\n", group_nesting)
  362. else:
  363. yield from self._indent("\n" + context + "\n\n", group_nesting)
  364. is_grouped = is_exception_group(value)
  365. if is_grouped and group_nesting == 0:
  366. yield from self._format_exception(
  367. value,
  368. tb,
  369. seen=seen,
  370. group_nesting=1,
  371. is_first=is_first,
  372. from_decorator=from_decorator,
  373. )
  374. return
  375. try:
  376. traceback_limit = sys.tracebacklimit
  377. except AttributeError:
  378. traceback_limit = None
  379. frames, final_source = self._extract_frames(
  380. exc_traceback, is_first, limit=traceback_limit, from_decorator=from_decorator
  381. )
  382. exception_only = traceback.format_exception_only(exc_type, exc_value)
  383. # Determining the correct index for the "Exception: message" part in the formatted exception
  384. # is challenging. This is because it might be preceded by multiple lines specific to
  385. # "SyntaxError" or followed by various notes. However, we can make an educated guess based
  386. # on the indentation; the preliminary context for "SyntaxError" is always indented, while
  387. # the Exception itself is not. This allows us to identify the correct index for the
  388. # exception message.
  389. no_indented_indexes = (i for i, p in enumerate(exception_only) if not p.startswith(" "))
  390. error_message_index = next(no_indented_indexes, None)
  391. if error_message_index is not None:
  392. # Remove final new line temporarily.
  393. error_message = exception_only[error_message_index][:-1]
  394. if self._colorize:
  395. if ":" in error_message:
  396. exception_type, exception_value = error_message.split(":", 1)
  397. exception_type = self._theme["exception_type"].format(exception_type)
  398. exception_value = self._theme["exception_value"].format(exception_value)
  399. error_message = exception_type + ":" + exception_value
  400. else:
  401. error_message = self._theme["exception_type"].format(error_message)
  402. if self._diagnose and frames:
  403. if issubclass(exc_type, AssertionError) and not str(exc_value) and final_source:
  404. if self._colorize:
  405. final_source = self._syntax_highlighter.highlight(final_source)
  406. error_message += ": " + final_source
  407. error_message = "\n" + error_message
  408. exception_only[error_message_index] = error_message + "\n"
  409. if is_first:
  410. yield self._prefix
  411. has_introduction = bool(frames)
  412. if has_introduction:
  413. if is_grouped:
  414. introduction = "Exception Group Traceback (most recent call last):"
  415. else:
  416. introduction = "Traceback (most recent call last):"
  417. if self._colorize:
  418. introduction = self._theme["introduction"].format(introduction)
  419. if group_nesting == 1: # Implies we're processing the root ExceptionGroup.
  420. yield from self._indent(introduction + "\n", group_nesting, prefix="+ ")
  421. else:
  422. yield from self._indent(introduction + "\n", group_nesting)
  423. frames_lines = self._format_list(frames) + exception_only
  424. if self._colorize or self._backtrace or self._diagnose:
  425. frames_lines = self._format_locations(frames_lines, has_introduction=has_introduction)
  426. yield from self._indent("".join(frames_lines), group_nesting)
  427. if is_grouped:
  428. exc = None
  429. for n, exc in enumerate(value.exceptions, start=1):
  430. ruler = "+" + (" %s " % ("..." if n > 15 else n)).center(35, "-")
  431. yield from self._indent(ruler, group_nesting, prefix="+-" if n == 1 else " ")
  432. if n > 15:
  433. message = "and %d more exceptions\n" % (len(value.exceptions) - 15)
  434. yield from self._indent(message, group_nesting + 1)
  435. break
  436. elif group_nesting == 10 and is_exception_group(exc):
  437. message = "... (max_group_depth is 10)\n"
  438. yield from self._indent(message, group_nesting + 1)
  439. else:
  440. yield from self._format_exception(
  441. exc,
  442. exc.__traceback__,
  443. seen=seen,
  444. group_nesting=group_nesting + 1,
  445. )
  446. if not is_exception_group(exc) or group_nesting == 10:
  447. yield from self._indent("-" * 35, group_nesting + 1, prefix="+-")
  448. def _format_list(self, frames):
  449. def source_message(filename, lineno, name, line):
  450. message = ' File "%s", line %d, in %s\n' % (filename, lineno, name)
  451. if line:
  452. message += " %s\n" % line.strip()
  453. return message
  454. def skip_message(count):
  455. plural = "s" if count > 1 else ""
  456. return " [Previous line repeated %d more time%s]\n" % (count, plural)
  457. result = []
  458. count = 0
  459. last_source = None
  460. for *source, line in frames:
  461. if source != last_source and count > 3:
  462. result.append(skip_message(count - 3))
  463. if source == last_source:
  464. count += 1
  465. if count > 3:
  466. continue
  467. else:
  468. count = 1
  469. result.append(source_message(*source, line))
  470. last_source = source
  471. # Add a final skip message if the iteration of frames ended mid-repetition.
  472. if count > 3:
  473. result.append(skip_message(count - 3))
  474. return result
  475. def format_exception(self, type_, value, tb, *, from_decorator=False):
  476. yield from self._format_exception(value, tb, is_first=True, from_decorator=from_decorator)