Format: text/x-python
⬇ Download File (20.4 KB)
   1  # traceback_exception_init() adapted from trio
   2  #
   3  # _ExceptionPrintContext and traceback_exception_format() copied from the standard
   4  # library
   5  from __future__ import annotations
   6  
   7  import collections.abc
   8  import sys
   9  import textwrap
  10  import traceback
  11  from functools import singledispatch
  12  from types import TracebackType
  13  from typing import Any, List, Optional
  14  
  15  from ._exceptions import BaseExceptionGroup
  16  
  17  max_group_width = 15
  18  max_group_depth = 10
  19  _cause_message = (
  20      "\nThe above exception was the direct cause of the following exception:\n\n"
  21  )
  22  
  23  _context_message = (
  24      "\nDuring handling of the above exception, another exception occurred:\n\n"
  25  )
  26  
  27  
  28  def _format_final_exc_line(etype, value):
  29      valuestr = _safe_string(value, "exception")
  30      if value is None or not valuestr:
  31          line = f"{etype}\n"
  32      else:
  33          line = f"{etype}: {valuestr}\n"
  34  
  35      return line
  36  
  37  
  38  def _safe_string(value, what, func=str):
  39      try:
  40          return func(value)
  41      except BaseException:
  42          return f"<{what} {func.__name__}() failed>"
  43  
  44  
  45  class _ExceptionPrintContext:
  46      def __init__(self):
  47          self.seen = set()
  48          self.exception_group_depth = 0
  49          self.need_close = False
  50  
  51      def indent(self):
  52          return " " * (2 * self.exception_group_depth)
  53  
  54      def emit(self, text_gen, margin_char=None):
  55          if margin_char is None:
  56              margin_char = "|"
  57          indent_str = self.indent()
  58          if self.exception_group_depth:
  59              indent_str += margin_char + " "
  60  
  61          if isinstance(text_gen, str):
  62              yield textwrap.indent(text_gen, indent_str, lambda line: True)
  63          else:
  64              for text in text_gen:
  65                  yield textwrap.indent(text, indent_str, lambda line: True)
  66  
  67  
  68  def exceptiongroup_excepthook(
  69      etype: type[BaseException], value: BaseException, tb: TracebackType | None
  70  ) -> None:
  71      sys.stderr.write("".join(traceback.format_exception(etype, value, tb)))
  72  
  73  
  74  class PatchedTracebackException(traceback.TracebackException):
  75      def __init__(
  76          self,
  77          exc_type: type[BaseException],
  78          exc_value: BaseException,
  79          exc_traceback: TracebackType | None,
  80          *,
  81          limit: int | None = None,
  82          lookup_lines: bool = True,
  83          capture_locals: bool = False,
  84          compact: bool = False,
  85          _seen: set[int] | None = None,
  86      ) -> None:
  87          kwargs: dict[str, Any] = {}
  88          if sys.version_info >= (3, 10):
  89              kwargs["compact"] = compact
  90  
  91          is_recursive_call = _seen is not None
  92          if _seen is None:
  93              _seen = set()
  94          _seen.add(id(exc_value))
  95  
  96          self.stack = traceback.StackSummary.extract(
  97              traceback.walk_tb(exc_traceback),
  98              limit=limit,
  99              lookup_lines=lookup_lines,
 100              capture_locals=capture_locals,
 101          )
 102          self.exc_type = exc_type
 103          # Capture now to permit freeing resources: only complication is in the
 104          # unofficial API _format_final_exc_line
 105          self._str = _safe_string(exc_value, "exception")
 106          try:
 107              self.__notes__ = getattr(exc_value, "__notes__", None)
 108          except KeyError:
 109              # Workaround for https://github.com/python/cpython/issues/98778 on Python
 110              # <= 3.9, and some 3.10 and 3.11 patch versions.
 111              HTTPError = getattr(sys.modules.get("urllib.error", None), "HTTPError", ())
 112              if sys.version_info[:2] <= (3, 11) and isinstance(exc_value, HTTPError):
 113                  self.__notes__ = None
 114              else:
 115                  raise
 116  
 117          if exc_type and issubclass(exc_type, SyntaxError):
 118              # Handle SyntaxError's specially
 119              self.filename = exc_value.filename
 120              lno = exc_value.lineno
 121              self.lineno = str(lno) if lno is not None else None
 122              self.text = exc_value.text
 123              self.offset = exc_value.offset
 124              self.msg = exc_value.msg
 125              if sys.version_info >= (3, 10):
 126                  end_lno = exc_value.end_lineno
 127                  self.end_lineno = str(end_lno) if end_lno is not None else None
 128                  self.end_offset = exc_value.end_offset
 129          elif (
 130              exc_type
 131              and issubclass(exc_type, (NameError, AttributeError))
 132              and getattr(exc_value, "name", None) is not None
 133          ):
 134              suggestion = _compute_suggestion_error(exc_value, exc_traceback)
 135              if suggestion:
 136                  self._str += f". Did you mean: '{suggestion}'?"
 137  
 138          if lookup_lines:
 139              # Force all lines in the stack to be loaded
 140              for frame in self.stack:
 141                  frame.line
 142  
 143          self.__suppress_context__ = (
 144              exc_value.__suppress_context__ if exc_value is not None else False
 145          )
 146  
 147          # Convert __cause__ and __context__ to `TracebackExceptions`s, use a
 148          # queue to avoid recursion (only the top-level call gets _seen == None)
 149          if not is_recursive_call:
 150              queue = [(self, exc_value)]
 151              while queue:
 152                  te, e = queue.pop()
 153  
 154                  if e and e.__cause__ is not None and id(e.__cause__) not in _seen:
 155                      cause = PatchedTracebackException(
 156                          type(e.__cause__),
 157                          e.__cause__,
 158                          e.__cause__.__traceback__,
 159                          limit=limit,
 160                          lookup_lines=lookup_lines,
 161                          capture_locals=capture_locals,
 162                          _seen=_seen,
 163                      )
 164                  else:
 165                      cause = None
 166  
 167                  if compact:
 168                      need_context = (
 169                          cause is None and e is not None and not e.__suppress_context__
 170                      )
 171                  else:
 172                      need_context = True
 173                  if (
 174                      e
 175                      and e.__context__ is not None
 176                      and need_context
 177                      and id(e.__context__) not in _seen
 178                  ):
 179                      context = PatchedTracebackException(
 180                          type(e.__context__),
 181                          e.__context__,
 182                          e.__context__.__traceback__,
 183                          limit=limit,
 184                          lookup_lines=lookup_lines,
 185                          capture_locals=capture_locals,
 186                          _seen=_seen,
 187                      )
 188                  else:
 189                      context = None
 190  
 191                  # Capture each of the exceptions in the ExceptionGroup along with each
 192                  # of their causes and contexts
 193                  if e and isinstance(e, BaseExceptionGroup):
 194                      exceptions = []
 195                      for exc in e.exceptions:
 196                          texc = PatchedTracebackException(
 197                              type(exc),
 198                              exc,
 199                              exc.__traceback__,
 200                              lookup_lines=lookup_lines,
 201                              capture_locals=capture_locals,
 202                              _seen=_seen,
 203                          )
 204                          exceptions.append(texc)
 205                  else:
 206                      exceptions = None
 207  
 208                  te.__cause__ = cause
 209                  te.__context__ = context
 210                  te.exceptions = exceptions
 211                  if cause:
 212                      queue.append((te.__cause__, e.__cause__))
 213                  if context:
 214                      queue.append((te.__context__, e.__context__))
 215                  if exceptions:
 216                      queue.extend(zip(te.exceptions, e.exceptions))
 217  
 218      def format(self, *, chain=True, _ctx=None):
 219          if _ctx is None:
 220              _ctx = _ExceptionPrintContext()
 221  
 222          output = []
 223          exc = self
 224          if chain:
 225              while exc:
 226                  if exc.__cause__ is not None:
 227                      chained_msg = _cause_message
 228                      chained_exc = exc.__cause__
 229                  elif exc.__context__ is not None and not exc.__suppress_context__:
 230                      chained_msg = _context_message
 231                      chained_exc = exc.__context__
 232                  else:
 233                      chained_msg = None
 234                      chained_exc = None
 235  
 236                  output.append((chained_msg, exc))
 237                  exc = chained_exc
 238          else:
 239              output.append((None, exc))
 240  
 241          for msg, exc in reversed(output):
 242              if msg is not None:
 243                  yield from _ctx.emit(msg)
 244              if exc.exceptions is None:
 245                  if exc.stack:
 246                      yield from _ctx.emit("Traceback (most recent call last):\n")
 247                      yield from _ctx.emit(exc.stack.format())
 248                  yield from _ctx.emit(exc.format_exception_only())
 249              elif _ctx.exception_group_depth > max_group_depth:
 250                  # exception group, but depth exceeds limit
 251                  yield from _ctx.emit(f"... (max_group_depth is {max_group_depth})\n")
 252              else:
 253                  # format exception group
 254                  is_toplevel = _ctx.exception_group_depth == 0
 255                  if is_toplevel:
 256                      _ctx.exception_group_depth += 1
 257  
 258                  if exc.stack:
 259                      yield from _ctx.emit(
 260                          "Exception Group Traceback (most recent call last):\n",
 261                          margin_char="+" if is_toplevel else None,
 262                      )
 263                      yield from _ctx.emit(exc.stack.format())
 264  
 265                  yield from _ctx.emit(exc.format_exception_only())
 266                  num_excs = len(exc.exceptions)
 267                  if num_excs <= max_group_width:
 268                      n = num_excs
 269                  else:
 270                      n = max_group_width + 1
 271                  _ctx.need_close = False
 272                  for i in range(n):
 273                      last_exc = i == n - 1
 274                      if last_exc:
 275                          # The closing frame may be added by a recursive call
 276                          _ctx.need_close = True
 277  
 278                      if max_group_width is not None:
 279                          truncated = i >= max_group_width
 280                      else:
 281                          truncated = False
 282                      title = f"{i + 1}" if not truncated else "..."
 283                      yield (
 284                          _ctx.indent()
 285                          + ("+-" if i == 0 else "  ")
 286                          + f"+---------------- {title} ----------------\n"
 287                      )
 288                      _ctx.exception_group_depth += 1
 289                      if not truncated:
 290                          yield from exc.exceptions[i].format(chain=chain, _ctx=_ctx)
 291                      else:
 292                          remaining = num_excs - max_group_width
 293                          plural = "s" if remaining > 1 else ""
 294                          yield from _ctx.emit(
 295                              f"and {remaining} more exception{plural}\n"
 296                          )
 297  
 298                      if last_exc and _ctx.need_close:
 299                          yield _ctx.indent() + "+------------------------------------\n"
 300                          _ctx.need_close = False
 301                      _ctx.exception_group_depth -= 1
 302  
 303                  if is_toplevel:
 304                      assert _ctx.exception_group_depth == 1
 305                      _ctx.exception_group_depth = 0
 306  
 307      def format_exception_only(self):
 308          """Format the exception part of the traceback.
 309          The return value is a generator of strings, each ending in a newline.
 310          Normally, the generator emits a single string; however, for
 311          SyntaxError exceptions, it emits several lines that (when
 312          printed) display detailed information about where the syntax
 313          error occurred.
 314          The message indicating which exception occurred is always the last
 315          string in the output.
 316          """
 317          if self.exc_type is None:
 318              yield traceback._format_final_exc_line(None, self._str)
 319              return
 320  
 321          stype = self.exc_type.__qualname__
 322          smod = self.exc_type.__module__
 323          if smod not in ("__main__", "builtins"):
 324              if not isinstance(smod, str):
 325                  smod = "<unknown>"
 326              stype = smod + "." + stype
 327  
 328          if not issubclass(self.exc_type, SyntaxError):
 329              yield _format_final_exc_line(stype, self._str)
 330          elif traceback_exception_format_syntax_error is not None:
 331              yield from traceback_exception_format_syntax_error(self, stype)
 332          else:
 333              yield from traceback_exception_original_format_exception_only(self)
 334  
 335          if isinstance(self.__notes__, collections.abc.Sequence):
 336              for note in self.__notes__:
 337                  note = _safe_string(note, "note")
 338                  yield from [line + "\n" for line in note.split("\n")]
 339          elif self.__notes__ is not None:
 340              yield _safe_string(self.__notes__, "__notes__", func=repr)
 341  
 342  
 343  traceback_exception_original_format = traceback.TracebackException.format
 344  traceback_exception_original_format_exception_only = (
 345      traceback.TracebackException.format_exception_only
 346  )
 347  traceback_exception_format_syntax_error = getattr(
 348      traceback.TracebackException, "_format_syntax_error", None
 349  )
 350  if sys.excepthook is sys.__excepthook__:
 351      traceback.TracebackException.__init__ = (  # type: ignore[assignment]
 352          PatchedTracebackException.__init__
 353      )
 354      traceback.TracebackException.format = (  # type: ignore[assignment]
 355          PatchedTracebackException.format
 356      )
 357      traceback.TracebackException.format_exception_only = (  # type: ignore[assignment]
 358          PatchedTracebackException.format_exception_only
 359      )
 360      sys.excepthook = exceptiongroup_excepthook
 361  
 362  # Ubuntu's system Python has a sitecustomize.py file that imports
 363  # apport_python_hook and replaces sys.excepthook.
 364  #
 365  # The custom hook captures the error for crash reporting, and then calls
 366  # sys.__excepthook__ to actually print the error.
 367  #
 368  # We don't mind it capturing the error for crash reporting, but we want to
 369  # take over printing the error. So we monkeypatch the apport_python_hook
 370  # module so that instead of calling sys.__excepthook__, it calls our custom
 371  # hook.
 372  #
 373  # More details: https://github.com/python-trio/trio/issues/1065
 374  if getattr(sys.excepthook, "__name__", None) in (
 375      "apport_excepthook",
 376      # on ubuntu 22.10 the hook was renamed to partial_apport_excepthook
 377      "partial_apport_excepthook",
 378  ):
 379      # patch traceback like above
 380      traceback.TracebackException.__init__ = (  # type: ignore[assignment]
 381          PatchedTracebackException.__init__
 382      )
 383      traceback.TracebackException.format = (  # type: ignore[assignment]
 384          PatchedTracebackException.format
 385      )
 386      traceback.TracebackException.format_exception_only = (  # type: ignore[assignment]
 387          PatchedTracebackException.format_exception_only
 388      )
 389  
 390      from types import ModuleType
 391  
 392      import apport_python_hook
 393  
 394      # monkeypatch the sys module that apport has imported
 395      fake_sys = ModuleType("exceptiongroup_fake_sys")
 396      fake_sys.__dict__.update(sys.__dict__)
 397      fake_sys.__excepthook__ = exceptiongroup_excepthook
 398      apport_python_hook.sys = fake_sys
 399  
 400  
 401  @singledispatch
 402  def format_exception_only(__exc: BaseException) -> List[str]:
 403      return list(
 404          PatchedTracebackException(
 405              type(__exc), __exc, None, compact=True
 406          ).format_exception_only()
 407      )
 408  
 409  
 410  @format_exception_only.register
 411  def _(__exc: type, value: BaseException) -> List[str]:
 412      return format_exception_only(value)
 413  
 414  
 415  @singledispatch
 416  def format_exception(
 417      __exc: BaseException,
 418      limit: Optional[int] = None,
 419      chain: bool = True,
 420  ) -> List[str]:
 421      return list(
 422          PatchedTracebackException(
 423              type(__exc), __exc, __exc.__traceback__, limit=limit, compact=True
 424          ).format(chain=chain)
 425      )
 426  
 427  
 428  @format_exception.register
 429  def _(
 430      __exc: type,
 431      value: BaseException,
 432      tb: TracebackType,
 433      limit: Optional[int] = None,
 434      chain: bool = True,
 435  ) -> List[str]:
 436      return format_exception(value, limit, chain)
 437  
 438  
 439  @singledispatch
 440  def print_exception(
 441      __exc: BaseException,
 442      limit: Optional[int] = None,
 443      file: Any = None,
 444      chain: bool = True,
 445  ) -> None:
 446      if file is None:
 447          file = sys.stderr
 448  
 449      for line in PatchedTracebackException(
 450          type(__exc), __exc, __exc.__traceback__, limit=limit
 451      ).format(chain=chain):
 452          print(line, file=file, end="")
 453  
 454  
 455  @print_exception.register
 456  def _(
 457      __exc: type,
 458      value: BaseException,
 459      tb: TracebackType,
 460      limit: Optional[int] = None,
 461      file: Any = None,
 462      chain: bool = True,
 463  ) -> None:
 464      print_exception(value, limit, file, chain)
 465  
 466  
 467  def print_exc(
 468      limit: Optional[int] = None,
 469      file: Any | None = None,
 470      chain: bool = True,
 471  ) -> None:
 472      value = sys.exc_info()[1]
 473      print_exception(value, limit, file, chain)
 474  
 475  
 476  # Python levenshtein edit distance code for NameError/AttributeError
 477  # suggestions, backported from 3.12
 478  
 479  _MAX_CANDIDATE_ITEMS = 750
 480  _MAX_STRING_SIZE = 40
 481  _MOVE_COST = 2
 482  _CASE_COST = 1
 483  _SENTINEL = object()
 484  
 485  
 486  def _substitution_cost(ch_a, ch_b):
 487      if ch_a == ch_b:
 488          return 0
 489      if ch_a.lower() == ch_b.lower():
 490          return _CASE_COST
 491      return _MOVE_COST
 492  
 493  
 494  def _compute_suggestion_error(exc_value, tb):
 495      wrong_name = getattr(exc_value, "name", None)
 496      if wrong_name is None or not isinstance(wrong_name, str):
 497          return None
 498      if isinstance(exc_value, AttributeError):
 499          obj = getattr(exc_value, "obj", _SENTINEL)
 500          if obj is _SENTINEL:
 501              return None
 502          obj = exc_value.obj
 503          try:
 504              d = dir(obj)
 505          except Exception:
 506              return None
 507      else:
 508          assert isinstance(exc_value, NameError)
 509          # find most recent frame
 510          if tb is None:
 511              return None
 512          while tb.tb_next is not None:
 513              tb = tb.tb_next
 514          frame = tb.tb_frame
 515  
 516          d = list(frame.f_locals) + list(frame.f_globals) + list(frame.f_builtins)
 517      if len(d) > _MAX_CANDIDATE_ITEMS:
 518          return None
 519      wrong_name_len = len(wrong_name)
 520      if wrong_name_len > _MAX_STRING_SIZE:
 521          return None
 522      best_distance = wrong_name_len
 523      suggestion = None
 524      for possible_name in d:
 525          if possible_name == wrong_name:
 526              # A missing attribute is "found". Don't suggest it (see GH-88821).
 527              continue
 528          # No more than 1/3 of the involved characters should need changed.
 529          max_distance = (len(possible_name) + wrong_name_len + 3) * _MOVE_COST // 6
 530          # Don't take matches we've already beaten.
 531          max_distance = min(max_distance, best_distance - 1)
 532          current_distance = _levenshtein_distance(
 533              wrong_name, possible_name, max_distance
 534          )
 535          if current_distance > max_distance:
 536              continue
 537          if not suggestion or current_distance < best_distance:
 538              suggestion = possible_name
 539              best_distance = current_distance
 540      return suggestion
 541  
 542  
 543  def _levenshtein_distance(a, b, max_cost):
 544      # A Python implementation of Python/suggestions.c:levenshtein_distance.
 545  
 546      # Both strings are the same
 547      if a == b:
 548          return 0
 549  
 550      # Trim away common affixes
 551      pre = 0
 552      while a[pre:] and b[pre:] and a[pre] == b[pre]:
 553          pre += 1
 554      a = a[pre:]
 555      b = b[pre:]
 556      post = 0
 557      while a[: post or None] and b[: post or None] and a[post - 1] == b[post - 1]:
 558          post -= 1
 559      a = a[: post or None]
 560      b = b[: post or None]
 561      if not a or not b:
 562          return _MOVE_COST * (len(a) + len(b))
 563      if len(a) > _MAX_STRING_SIZE or len(b) > _MAX_STRING_SIZE:
 564          return max_cost + 1
 565  
 566      # Prefer shorter buffer
 567      if len(b) < len(a):
 568          a, b = b, a
 569  
 570      # Quick fail when a match is impossible
 571      if (len(b) - len(a)) * _MOVE_COST > max_cost:
 572          return max_cost + 1
 573  
 574      # Instead of producing the whole traditional len(a)-by-len(b)
 575      # matrix, we can update just one row in place.
 576      # Initialize the buffer row
 577      row = list(range(_MOVE_COST, _MOVE_COST * (len(a) + 1), _MOVE_COST))
 578  
 579      result = 0
 580      for bindex in range(len(b)):
 581          bchar = b[bindex]
 582          distance = result = bindex * _MOVE_COST
 583          minimum = sys.maxsize
 584          for index in range(len(a)):
 585              # 1) Previous distance in this row is cost(b[:b_index], a[:index])
 586              substitute = distance + _substitution_cost(bchar, a[index])
 587              # 2) cost(b[:b_index], a[:index+1]) from previous row
 588              distance = row[index]
 589              # 3) existing result is cost(b[:b_index+1], a[index])
 590  
 591              insert_delete = min(result, distance) + _MOVE_COST
 592              result = min(insert_delete, substitute)
 593  
 594              # cost(b[:b_index+1], a[:index+1])
 595              row[index] = result
 596              if result < minimum:
 597                  minimum = result
 598          if minimum > max_cost:
 599              # Everything in this row is too big, so bail early.
 600              return max_cost + 1
 601      return result
 602