mirror of https://github.com/Delgan/loguru.git
541 lines
20 KiB
Python
541 lines
20 KiB
Python
import builtins
|
|
import inspect
|
|
import io
|
|
import keyword
|
|
import linecache
|
|
import os
|
|
import re
|
|
import sys
|
|
import sysconfig
|
|
import tokenize
|
|
import traceback
|
|
|
|
if sys.version_info >= (3, 11):
|
|
|
|
def is_exception_group(exc):
|
|
return isinstance(exc, ExceptionGroup)
|
|
|
|
else:
|
|
try:
|
|
from exceptiongroup import ExceptionGroup
|
|
except ImportError:
|
|
|
|
def is_exception_group(exc):
|
|
return False
|
|
|
|
else:
|
|
|
|
def is_exception_group(exc):
|
|
return isinstance(exc, ExceptionGroup)
|
|
|
|
|
|
class SyntaxHighlighter:
|
|
_default_style = {
|
|
"comment": "\x1b[30m\x1b[1m{}\x1b[0m",
|
|
"keyword": "\x1b[35m\x1b[1m{}\x1b[0m",
|
|
"builtin": "\x1b[1m{}\x1b[0m",
|
|
"string": "\x1b[36m{}\x1b[0m",
|
|
"number": "\x1b[34m\x1b[1m{}\x1b[0m",
|
|
"operator": "\x1b[35m\x1b[1m{}\x1b[0m",
|
|
"punctuation": "\x1b[1m{}\x1b[0m",
|
|
"constant": "\x1b[36m\x1b[1m{}\x1b[0m",
|
|
"identifier": "\x1b[1m{}\x1b[0m",
|
|
"other": "{}",
|
|
}
|
|
|
|
_builtins = set(dir(builtins))
|
|
_constants = {"True", "False", "None"}
|
|
_punctuation = {"(", ")", "[", "]", "{", "}", ":", ",", ";"}
|
|
_strings = {tokenize.STRING}
|
|
_fstring_middle = None
|
|
|
|
if sys.version_info >= (3, 12):
|
|
_strings.update({tokenize.FSTRING_START, tokenize.FSTRING_MIDDLE, tokenize.FSTRING_END})
|
|
_fstring_middle = tokenize.FSTRING_MIDDLE
|
|
|
|
def __init__(self, style=None):
|
|
self._style = style or self._default_style
|
|
|
|
def highlight(self, source):
|
|
style = self._style
|
|
row, column = 0, 0
|
|
output = ""
|
|
|
|
for token in self.tokenize(source):
|
|
type_, string, (start_row, start_column), (_, end_column), line = token
|
|
|
|
if type_ == self._fstring_middle:
|
|
# When an f-string contains "{{" or "}}", they appear as "{" or "}" in the "string"
|
|
# attribute of the token. However, they do not count in the column position.
|
|
end_column += string.count("{") + string.count("}")
|
|
|
|
if type_ == tokenize.NAME:
|
|
if string in self._constants:
|
|
color = style["constant"]
|
|
elif keyword.iskeyword(string):
|
|
color = style["keyword"]
|
|
elif string in self._builtins:
|
|
color = style["builtin"]
|
|
else:
|
|
color = style["identifier"]
|
|
elif type_ == tokenize.OP:
|
|
if string in self._punctuation:
|
|
color = style["punctuation"]
|
|
else:
|
|
color = style["operator"]
|
|
elif type_ == tokenize.NUMBER:
|
|
color = style["number"]
|
|
elif type_ in self._strings:
|
|
color = style["string"]
|
|
elif type_ == tokenize.COMMENT:
|
|
color = style["comment"]
|
|
else:
|
|
color = style["other"]
|
|
|
|
if start_row != row:
|
|
source = source[column:]
|
|
row, column = start_row, 0
|
|
|
|
if type_ != tokenize.ENCODING:
|
|
output += line[column:start_column]
|
|
output += color.format(line[start_column:end_column])
|
|
|
|
column = end_column
|
|
|
|
output += source[column:]
|
|
|
|
return output
|
|
|
|
@staticmethod
|
|
def tokenize(source):
|
|
# Worth reading: https://www.asmeurer.com/brown-water-python/
|
|
source = source.encode("utf-8")
|
|
source = io.BytesIO(source)
|
|
|
|
try:
|
|
yield from tokenize.tokenize(source.readline)
|
|
except tokenize.TokenError:
|
|
return
|
|
|
|
|
|
class ExceptionFormatter:
|
|
_default_theme = {
|
|
"introduction": "\x1b[33m\x1b[1m{}\x1b[0m",
|
|
"cause": "\x1b[1m{}\x1b[0m",
|
|
"context": "\x1b[1m{}\x1b[0m",
|
|
"dirname": "\x1b[32m{}\x1b[0m",
|
|
"basename": "\x1b[32m\x1b[1m{}\x1b[0m",
|
|
"line": "\x1b[33m{}\x1b[0m",
|
|
"function": "\x1b[35m{}\x1b[0m",
|
|
"exception_type": "\x1b[31m\x1b[1m{}\x1b[0m",
|
|
"exception_value": "\x1b[1m{}\x1b[0m",
|
|
"arrows": "\x1b[36m{}\x1b[0m",
|
|
"value": "\x1b[36m\x1b[1m{}\x1b[0m",
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
colorize=False,
|
|
backtrace=False,
|
|
diagnose=True,
|
|
theme=None,
|
|
style=None,
|
|
max_length=128,
|
|
encoding="ascii",
|
|
hidden_frames_filename=None,
|
|
prefix="",
|
|
):
|
|
self._colorize = colorize
|
|
self._diagnose = diagnose
|
|
self._theme = theme or self._default_theme
|
|
self._backtrace = backtrace
|
|
self._syntax_highlighter = SyntaxHighlighter(style)
|
|
self._max_length = max_length
|
|
self._encoding = encoding
|
|
self._hidden_frames_filename = hidden_frames_filename
|
|
self._prefix = prefix
|
|
self._lib_dirs = self._get_lib_dirs()
|
|
self._pipe_char = self._get_char("\u2502", "|")
|
|
self._cap_char = self._get_char("\u2514", "->")
|
|
self._catch_point_identifier = " <Loguru catch point here>"
|
|
|
|
@staticmethod
|
|
def _get_lib_dirs():
|
|
schemes = sysconfig.get_scheme_names()
|
|
names = ["stdlib", "platstdlib", "platlib", "purelib"]
|
|
paths = {sysconfig.get_path(name, scheme) for scheme in schemes for name in names}
|
|
return [os.path.abspath(path).lower() + os.sep for path in paths if path in sys.path]
|
|
|
|
@staticmethod
|
|
def _indent(text, count, *, prefix="| "):
|
|
if count == 0:
|
|
yield text
|
|
return
|
|
for line in text.splitlines(True):
|
|
indented = " " * count + prefix + line
|
|
yield indented.rstrip() + "\n"
|
|
|
|
def _get_char(self, char, default):
|
|
try:
|
|
char.encode(self._encoding)
|
|
except (UnicodeEncodeError, LookupError):
|
|
return default
|
|
else:
|
|
return char
|
|
|
|
def _is_file_mine(self, file):
|
|
filepath = os.path.abspath(file).lower()
|
|
if not filepath.endswith(".py"):
|
|
return False
|
|
return not any(filepath.startswith(d) for d in self._lib_dirs)
|
|
|
|
def _extract_frames(self, tb, is_first, *, limit=None, from_decorator=False):
|
|
frames, final_source = [], None
|
|
|
|
if tb is None or (limit is not None and limit <= 0):
|
|
return frames, final_source
|
|
|
|
def is_valid(frame):
|
|
return frame.f_code.co_filename != self._hidden_frames_filename
|
|
|
|
def get_info(frame, lineno):
|
|
filename = frame.f_code.co_filename
|
|
function = frame.f_code.co_name
|
|
source = linecache.getline(filename, lineno).strip()
|
|
return filename, lineno, function, source
|
|
|
|
infos = []
|
|
|
|
if is_valid(tb.tb_frame):
|
|
infos.append((get_info(tb.tb_frame, tb.tb_lineno), tb.tb_frame))
|
|
|
|
get_parent_only = from_decorator and not self._backtrace
|
|
|
|
if (self._backtrace and is_first) or get_parent_only:
|
|
frame = tb.tb_frame.f_back
|
|
while frame:
|
|
if is_valid(frame):
|
|
infos.insert(0, (get_info(frame, frame.f_lineno), frame))
|
|
if get_parent_only:
|
|
break
|
|
frame = frame.f_back
|
|
|
|
if infos and not get_parent_only:
|
|
(filename, lineno, function, source), frame = infos[-1]
|
|
function += self._catch_point_identifier
|
|
infos[-1] = ((filename, lineno, function, source), frame)
|
|
|
|
tb = tb.tb_next
|
|
|
|
while tb:
|
|
if is_valid(tb.tb_frame):
|
|
infos.append((get_info(tb.tb_frame, tb.tb_lineno), tb.tb_frame))
|
|
tb = tb.tb_next
|
|
|
|
if limit is not None:
|
|
infos = infos[-limit:]
|
|
|
|
for (filename, lineno, function, source), frame in infos:
|
|
final_source = source
|
|
if source:
|
|
colorize = self._colorize and self._is_file_mine(filename)
|
|
lines = []
|
|
if colorize:
|
|
lines.append(self._syntax_highlighter.highlight(source))
|
|
else:
|
|
lines.append(source)
|
|
if self._diagnose:
|
|
relevant_values = self._get_relevant_values(source, frame)
|
|
values = self._format_relevant_values(list(relevant_values), colorize)
|
|
lines += list(values)
|
|
source = "\n ".join(lines)
|
|
frames.append((filename, lineno, function, source))
|
|
|
|
return frames, final_source
|
|
|
|
def _get_relevant_values(self, source, frame):
|
|
value = None
|
|
pending = None
|
|
is_attribute = False
|
|
is_valid_value = False
|
|
is_assignment = True
|
|
|
|
for token in self._syntax_highlighter.tokenize(source):
|
|
type_, string, (_, col), *_ = token
|
|
|
|
if pending is not None:
|
|
# Keyword arguments are ignored
|
|
if type_ != tokenize.OP or string != "=" or is_assignment:
|
|
yield pending
|
|
pending = None
|
|
|
|
if type_ == tokenize.NAME and not keyword.iskeyword(string):
|
|
if not is_attribute:
|
|
for variables in (frame.f_locals, frame.f_globals):
|
|
try:
|
|
value = variables[string]
|
|
except KeyError:
|
|
continue
|
|
else:
|
|
is_valid_value = True
|
|
pending = (col, self._format_value(value))
|
|
break
|
|
elif is_valid_value:
|
|
try:
|
|
value = inspect.getattr_static(value, string)
|
|
except AttributeError:
|
|
is_valid_value = False
|
|
else:
|
|
yield (col, self._format_value(value))
|
|
elif type_ == tokenize.OP and string == ".":
|
|
is_attribute = True
|
|
is_assignment = False
|
|
elif type_ == tokenize.OP and string == ";":
|
|
is_assignment = True
|
|
is_attribute = False
|
|
is_valid_value = False
|
|
else:
|
|
is_attribute = False
|
|
is_valid_value = False
|
|
is_assignment = False
|
|
|
|
if pending is not None:
|
|
yield pending
|
|
|
|
def _format_relevant_values(self, relevant_values, colorize):
|
|
for i in reversed(range(len(relevant_values))):
|
|
col, value = relevant_values[i]
|
|
pipe_cols = [pcol for pcol, _ in relevant_values[:i]]
|
|
pre_line = ""
|
|
index = 0
|
|
|
|
for pc in pipe_cols:
|
|
pre_line += (" " * (pc - index)) + self._pipe_char
|
|
index = pc + 1
|
|
|
|
pre_line += " " * (col - index)
|
|
value_lines = value.split("\n")
|
|
|
|
for n, value_line in enumerate(value_lines):
|
|
if n == 0:
|
|
arrows = pre_line + self._cap_char + " "
|
|
else:
|
|
arrows = pre_line + " " * (len(self._cap_char) + 1)
|
|
|
|
if colorize:
|
|
arrows = self._theme["arrows"].format(arrows)
|
|
value_line = self._theme["value"].format(value_line)
|
|
|
|
yield arrows + value_line
|
|
|
|
def _format_value(self, v):
|
|
try:
|
|
v = repr(v)
|
|
except Exception:
|
|
v = "<unprintable %s object>" % type(v).__name__
|
|
|
|
max_length = self._max_length
|
|
if max_length is not None and len(v) > max_length:
|
|
v = v[: max_length - 3] + "..."
|
|
return v
|
|
|
|
def _format_locations(self, frames_lines, *, has_introduction):
|
|
prepend_with_new_line = has_introduction
|
|
regex = r'^ File "(?P<file>.*?)", line (?P<line>[^,]+)(?:, in (?P<function>.*))?\n'
|
|
|
|
for frame in frames_lines:
|
|
match = re.match(regex, frame)
|
|
|
|
if match:
|
|
file, line, function = match.group("file", "line", "function")
|
|
|
|
is_mine = self._is_file_mine(file)
|
|
|
|
if function is not None:
|
|
pattern = ' File "{}", line {}, in {}\n'
|
|
else:
|
|
pattern = ' File "{}", line {}\n'
|
|
|
|
if self._backtrace and function and function.endswith(self._catch_point_identifier):
|
|
function = function[: -len(self._catch_point_identifier)]
|
|
pattern = ">" + pattern[1:]
|
|
|
|
if self._colorize and is_mine:
|
|
dirname, basename = os.path.split(file)
|
|
if dirname:
|
|
dirname += os.sep
|
|
dirname = self._theme["dirname"].format(dirname)
|
|
basename = self._theme["basename"].format(basename)
|
|
file = dirname + basename
|
|
line = self._theme["line"].format(line)
|
|
function = self._theme["function"].format(function)
|
|
|
|
if self._diagnose and (is_mine or prepend_with_new_line):
|
|
pattern = "\n" + pattern
|
|
|
|
location = pattern.format(file, line, function)
|
|
frame = location + frame[match.end() :]
|
|
prepend_with_new_line = is_mine
|
|
|
|
yield frame
|
|
|
|
def _format_exception(
|
|
self, value, tb, *, seen=None, is_first=False, from_decorator=False, group_nesting=0
|
|
):
|
|
# Implemented from built-in traceback module:
|
|
# https://github.com/python/cpython/blob/a5b76167/Lib/traceback.py#L468
|
|
exc_type, exc_value, exc_traceback = type(value), value, tb
|
|
|
|
if seen is None:
|
|
seen = set()
|
|
|
|
seen.add(id(exc_value))
|
|
|
|
if exc_value:
|
|
if exc_value.__cause__ is not None and id(exc_value.__cause__) not in seen:
|
|
yield from self._format_exception(
|
|
exc_value.__cause__,
|
|
exc_value.__cause__.__traceback__,
|
|
seen=seen,
|
|
group_nesting=group_nesting,
|
|
)
|
|
cause = "The above exception was the direct cause of the following exception:"
|
|
if self._colorize:
|
|
cause = self._theme["cause"].format(cause)
|
|
if self._diagnose:
|
|
yield from self._indent("\n\n" + cause + "\n\n\n", group_nesting)
|
|
else:
|
|
yield from self._indent("\n" + cause + "\n\n", group_nesting)
|
|
|
|
elif (
|
|
exc_value.__context__ is not None
|
|
and id(exc_value.__context__) not in seen
|
|
and not exc_value.__suppress_context__
|
|
):
|
|
yield from self._format_exception(
|
|
exc_value.__context__,
|
|
exc_value.__context__.__traceback__,
|
|
seen=seen,
|
|
group_nesting=group_nesting,
|
|
)
|
|
context = "During handling of the above exception, another exception occurred:"
|
|
if self._colorize:
|
|
context = self._theme["context"].format(context)
|
|
if self._diagnose:
|
|
yield from self._indent("\n\n" + context + "\n\n\n", group_nesting)
|
|
else:
|
|
yield from self._indent("\n" + context + "\n\n", group_nesting)
|
|
|
|
is_grouped = is_exception_group(value)
|
|
|
|
if is_grouped and group_nesting == 0:
|
|
yield from self._format_exception(
|
|
value,
|
|
tb,
|
|
seen=seen,
|
|
group_nesting=1,
|
|
is_first=is_first,
|
|
from_decorator=from_decorator,
|
|
)
|
|
return
|
|
|
|
try:
|
|
traceback_limit = sys.tracebacklimit
|
|
except AttributeError:
|
|
traceback_limit = None
|
|
|
|
frames, final_source = self._extract_frames(
|
|
exc_traceback, is_first, limit=traceback_limit, from_decorator=from_decorator
|
|
)
|
|
exception_only = traceback.format_exception_only(exc_type, exc_value)
|
|
|
|
# Determining the correct index for the "Exception: message" part in the formatted exception
|
|
# is challenging. This is because it might be preceded by multiple lines specific to
|
|
# "SyntaxError" or followed by various notes. However, we can make an educated guess based
|
|
# on the indentation; the preliminary context for "SyntaxError" is always indented, while
|
|
# the Exception itself is not. This allows us to identify the correct index for the
|
|
# exception message.
|
|
no_indented_indexes = (i for i, p in enumerate(exception_only) if not p.startswith(" "))
|
|
error_message_index = next(no_indented_indexes, None)
|
|
|
|
if error_message_index is not None:
|
|
# Remove final new line temporarily.
|
|
error_message = exception_only[error_message_index][:-1]
|
|
|
|
if self._colorize:
|
|
if ":" in error_message:
|
|
exception_type, exception_value = error_message.split(":", 1)
|
|
exception_type = self._theme["exception_type"].format(exception_type)
|
|
exception_value = self._theme["exception_value"].format(exception_value)
|
|
error_message = exception_type + ":" + exception_value
|
|
else:
|
|
error_message = self._theme["exception_type"].format(error_message)
|
|
|
|
if self._diagnose and frames:
|
|
if issubclass(exc_type, AssertionError) and not str(exc_value) and final_source:
|
|
if self._colorize:
|
|
final_source = self._syntax_highlighter.highlight(final_source)
|
|
error_message += ": " + final_source
|
|
|
|
error_message = "\n" + error_message
|
|
|
|
exception_only[error_message_index] = error_message + "\n"
|
|
|
|
if is_first:
|
|
yield self._prefix
|
|
|
|
has_introduction = bool(frames)
|
|
|
|
if has_introduction:
|
|
if is_grouped:
|
|
introduction = "Exception Group Traceback (most recent call last):"
|
|
else:
|
|
introduction = "Traceback (most recent call last):"
|
|
if self._colorize:
|
|
introduction = self._theme["introduction"].format(introduction)
|
|
if group_nesting == 1: # Implies we're processing the root ExceptionGroup.
|
|
yield from self._indent(introduction + "\n", group_nesting, prefix="+ ")
|
|
else:
|
|
yield from self._indent(introduction + "\n", group_nesting)
|
|
|
|
frames_lines = self._format_list(frames) + exception_only
|
|
if self._colorize or self._backtrace or self._diagnose:
|
|
frames_lines = self._format_locations(frames_lines, has_introduction=has_introduction)
|
|
|
|
yield from self._indent("".join(frames_lines), group_nesting)
|
|
|
|
if is_grouped:
|
|
exc = None
|
|
for n, exc in enumerate(value.exceptions, start=1):
|
|
ruler = "+" + (" %s " % ("..." if n > 15 else n)).center(35, "-")
|
|
yield from self._indent(ruler, group_nesting, prefix="+-" if n == 1 else " ")
|
|
if n > 15:
|
|
message = "and %d more exceptions\n" % (len(value.exceptions) - 15)
|
|
yield from self._indent(message, group_nesting + 1)
|
|
break
|
|
elif group_nesting == 10 and is_exception_group(exc):
|
|
message = "... (max_group_depth is 10)\n"
|
|
yield from self._indent(message, group_nesting + 1)
|
|
else:
|
|
yield from self._format_exception(
|
|
exc,
|
|
exc.__traceback__,
|
|
seen=seen,
|
|
group_nesting=group_nesting + 1,
|
|
)
|
|
if not is_exception_group(exc) or group_nesting == 10:
|
|
yield from self._indent("-" * 35, group_nesting + 1, prefix="+-")
|
|
|
|
def _format_list(self, frames):
|
|
result = []
|
|
for filename, lineno, name, line in frames:
|
|
row = []
|
|
row.append(' File "{}", line {}, in {}\n'.format(filename, lineno, name))
|
|
if line:
|
|
row.append(" {}\n".format(line.strip()))
|
|
result.append("".join(row))
|
|
return result
|
|
|
|
def format_exception(self, type_, value, tb, *, from_decorator=False):
|
|
yield from self._format_exception(value, tb, is_first=True, from_decorator=from_decorator)
|