Skip to content

Commit

Permalink
Output pretty error if possible (#399)
Browse files Browse the repository at this point in the history
Error position and hint are now included by default if present,
overridden by EDGEDB_ERROR_HINT (enabled/disabled).

The output is aslo colored if the stderr refers to a terminal,
overriden by EDGEDB_COLOR_OUTPUT (auto/enabled/disabled).
  • Loading branch information
fantix committed Nov 23, 2022
1 parent 8b28947 commit a2bec18
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 4 deletions.
60 changes: 60 additions & 0 deletions edgedb/color.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import os
import sys
import warnings

COLOR = None


class Color:
HEADER = ""
BLUE = ""
CYAN = ""
GREEN = ""
WARNING = ""
FAIL = ""
ENDC = ""
BOLD = ""
UNDERLINE = ""


def get_color() -> Color:
global COLOR

if COLOR is None:
COLOR = Color()
if type(USE_COLOR) is bool:
use_color = USE_COLOR
else:
try:
use_color = USE_COLOR()
except Exception:
use_color = False
if use_color:
COLOR.HEADER = '\033[95m'
COLOR.BLUE = '\033[94m'
COLOR.CYAN = '\033[96m'
COLOR.GREEN = '\033[92m'
COLOR.WARNING = '\033[93m'
COLOR.FAIL = '\033[91m'
COLOR.ENDC = '\033[0m'
COLOR.BOLD = '\033[1m'
COLOR.UNDERLINE = '\033[4m'

return COLOR


try:
USE_COLOR = {
"default": lambda: sys.stderr.isatty(),
"auto": lambda: sys.stderr.isatty(),
"enabled": True,
"disabled": False,
}[
os.getenv("EDGEDB_COLOR_OUTPUT", "default")
]
except KeyError:
warnings.warn(
"EDGEDB_COLOR_OUTPUT can only be one of: "
"default, auto, enabled or disabled"
)
USE_COLOR = False
138 changes: 134 additions & 4 deletions edgedb/errors/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
#


import io
import os
import traceback
import unicodedata
import warnings

__all__ = (
'EdgeDBError', 'EdgeDBMessage',
)
Expand Down Expand Up @@ -79,6 +85,7 @@ class EdgeDBErrorMeta(Meta):
class EdgeDBError(Exception, metaclass=EdgeDBErrorMeta):

_code = None
_query = None
tags = frozenset()

def __init__(self, *args, **kwargs):
Expand All @@ -93,15 +100,25 @@ def _position(self):
# not a stable API method
return int(self._read_str_field(FIELD_POSITION_START, -1))

@property
def _position_start(self):
# not a stable API method
return int(self._read_str_field(FIELD_CHARACTER_START, -1))

@property
def _position_end(self):
# not a stable API method
return int(self._read_str_field(FIELD_CHARACTER_END, -1))

@property
def _line(self):
# not a stable API method
return int(self._read_str_field(FIELD_LINE, -1))
return int(self._read_str_field(FIELD_LINE_START, -1))

@property
def _col(self):
# not a stable API method
return int(self._read_str_field(FIELD_COLUMN, -1))
return int(self._read_str_field(FIELD_COLUMN_START, -1))

@property
def _hint(self):
Expand All @@ -127,6 +144,35 @@ def _from_code(code, *args, **kwargs):
exc._code = code
return exc

def __str__(self):
msg = super().__str__()
if SHOW_HINT and self._query and self._position_start >= 0:
try:
return _format_error(
msg,
self._query,
self._position_start,
max(1, self._position_end - self._position_start),
self._line if self._line > 0 else "?",
self._col if self._col > 0 else "?",
self._hint or "error",
)
except Exception:
return "".join(
(
msg,
LINESEP,
LINESEP,
"During formatting of the above exception, "
"another exception occurred:",
LINESEP,
LINESEP,
traceback.format_exc(),
)
)
else:
return msg


def _lookup_cls(code: int, *, meta: type, default: type):
try:
Expand Down Expand Up @@ -180,15 +226,83 @@ def _severity_name(severity):
return 'PANIC'


def _format_error(msg, query, start, offset, line, col, hint):
c = get_color()
rv = io.StringIO()
rv.write(f"{c.BOLD}{msg}{c.ENDC}{LINESEP}")
lines = query.splitlines(keepends=True)
num_len = len(str(len(lines)))
rv.write(f"{c.BLUE}{'':>{num_len}} ┌─{c.ENDC} query:{line}:{col}{LINESEP}")
rv.write(f"{c.BLUE}{'':>{num_len}}{c.ENDC}{LINESEP}")
for num, line in enumerate(lines):
length = len(line)
line = line.rstrip() # we'll use our own line separator
if start >= length:
# skip lines before the error
start -= length
continue

if start >= 0:
# Error starts in current line, write the line before the error
first_half = repr(line[:start])[1:-1]
line = line[start:]
length -= start
rv.write(f"{c.BLUE}{num + 1:>{num_len}}{c.ENDC}{first_half}")
start = _unicode_width(first_half)
else:
# Multi-line error continues
rv.write(f"{c.BLUE}{num + 1:>{num_len}}{c.FAIL}{c.ENDC}")

if offset > length:
# Error is ending beyond current line
line = repr(line)[1:-1]
rv.write(f"{c.FAIL}{line}{c.ENDC}{LINESEP}")
if start >= 0:
# Multi-line error starts
rv.write(f"{c.BLUE}{'':>{num_len}} │ "
f"{c.FAIL}╭─{'─' * start}^{c.ENDC}{LINESEP}")
offset -= length
start = -1 # mark multi-line
else:
# Error is ending within current line
first_half = repr(line[:offset])[1:-1]
line = repr(line[offset:])[1:-1]
rv.write(f"{c.FAIL}{first_half}{c.ENDC}{line}{LINESEP}")
size = _unicode_width(first_half)
if start >= 0:
# Mark single-line error
rv.write(f"{c.BLUE}{'':>{num_len}}{' ' * start}"
f"{c.FAIL}{'^' * size} {hint}{c.ENDC}")
else:
# End of multi-line error
rv.write(f"{c.BLUE}{'':>{num_len}} │ "
f"{c.FAIL}╰─{'─' * (size - 1)}^ {hint}{c.ENDC}")
break
return rv.getvalue()


def _unicode_width(text):
return sum(
2 if unicodedata.east_asian_width(c) == "W" else 1
for c in unicodedata.normalize("NFC", text)
)


FIELD_HINT = 0x_00_01
FIELD_DETAILS = 0x_00_02
FIELD_SERVER_TRACEBACK = 0x_01_01

# XXX: Subject to be changed/deprecated.
FIELD_POSITION_START = 0x_FF_F1
FIELD_POSITION_END = 0x_FF_F2
FIELD_LINE = 0x_FF_F3
FIELD_COLUMN = 0x_FF_F4
FIELD_LINE_START = 0x_FF_F3
FIELD_COLUMN_START = 0x_FF_F4
FIELD_UTF16_COLUMN_START = 0x_FF_F5
FIELD_LINE_END = 0x_FF_F6
FIELD_COLUMN_END = 0x_FF_F7
FIELD_UTF16_COLUMN_END = 0x_FF_F8
FIELD_CHARACTER_START = 0x_FF_F9
FIELD_CHARACTER_END = 0x_FF_FA


EDGE_SEVERITY_DEBUG = 20
Expand All @@ -198,3 +312,19 @@ def _severity_name(severity):
EDGE_SEVERITY_ERROR = 120
EDGE_SEVERITY_FATAL = 200
EDGE_SEVERITY_PANIC = 255


LINESEP = os.linesep

try:
SHOW_HINT = {"default": True, "enabled": True, "disabled": False}[
os.getenv("EDGEDB_ERROR_HINT", "default")
]
except KeyError:
warnings.warn(
"EDGEDB_ERROR_HINT can only be one of: default, enabled or disabled"
)
SHOW_HINT = False


from edgedb.color import get_color
2 changes: 2 additions & 0 deletions edgedb/protocol/protocol.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ cdef class SansIOProtocol:

elif mtype == ERROR_RESPONSE_MSG:
exc = self.parse_error_message()
exc._query = query
exc = self._amend_parse_error(
exc, output_format, expect_one, required_one)

Expand Down Expand Up @@ -435,6 +436,7 @@ cdef class SansIOProtocol:

elif mtype == ERROR_RESPONSE_MSG:
exc = self.parse_error_message()
exc._query = query
if exc.get_code() == parameter_type_mismatch_code:
if not isinstance(in_dc, NullCodec):
buf = WriteBuffer.new()
Expand Down

0 comments on commit a2bec18

Please sign in to comment.