Add PEP 657-style parse error handling. (#5881)

This commit is contained in:
Tom Rothamel
2025-02-13 00:02:28 -05:00
committed by GitHub
8 changed files with 212 additions and 142 deletions
+1
View File
@@ -255,6 +255,7 @@ name_blacklist = {
"renpy.gl2.assimp.loader",
"renpy.gl2.assimp.loader_lock",
"renpy.gl2.gl2draw.default_position",
"renpy.lexer._python_updatecache",
}
+51 -32
View File
@@ -509,50 +509,69 @@ init python:
return "\n".join(rv)
def __format_parse_errors(s):
def __format_parse_errors(errors: list[str]):
"""
Takes list of ParseError messages and transforms it
to string of sane length to be used as child of viewport.
"""
import re
rv = ""
rv = []
rv_len = 0
for error in errors:
lines = error.split("\n")
lines = s.split("\n")
lines = __limit_lines(lines)
len_lines = len(lines)
# Do not count first line in total to include those as much as
# possible.
rv.append(re.sub(
r'(File "(.*)", line (\d+))',
r'{a=edit:\3:\2}\1{/a}',
lines.pop(0)))
ln = 0
error_rv = []
error_len = 0
for line in lines:
# In case we have single insanely lengthy single error.
error_len += len(line)
if error_len > 65536:
error_rv.clear()
break
while ln < len_lines:
line = lines[ln]
ln += 1
if set(line.strip()) == {"^"}:
idx = line.find("^")
count = line.count("^", idx)
if ln < len_lines and lines[ln].endswith("^"):
highlight = len(lines[ln]) - 1
ln += 1
else:
highlight = -1
prev_line = error_rv[-1]
pos = 0
line = prev_line[:idx]
for c in line:
if pos == highlight:
rv += u"{color=#c00}\u2192{/color}"
highlight = -1
if count == 1:
if len(prev_line) < idx + count:
mark = "\u2190"
else:
mark = "\u2192"
count = 0
else:
mark = prev_line[idx:idx + count]
line += "{b}{color=#a00}"
line += mark
line += "{/color}{/b}"
line += prev_line[idx + count:]
pos += 1
error_rv[-1] = line
continue
if c == "{":
rv += "{{"
else:
rv += c
error_rv.append(line)
if highlight > 0:
rv += u"{color=#c00}\u2190{/color}"
rv_len += error_len
if rv_len > 1048576:
break
rv += "\n"
rv.extend(error_rv)
rv.append("")
rv = re.sub(r'(File "(.*)", line (\d+))', r'{a=edit:\3:\2}\1{/a}', rv)
return rv
return "\n".join(rv)
class _EditFile(Action):
def __init__(self, filename, line=1):
@@ -787,7 +806,7 @@ screen _exception:
key "console" action __EnterConsole()
# The screen that is used for error handling.
screen _parse_errors:
screen _parse_errors(errors, error_fn, reload_action):
modal True
layer config.interface_layer
zorder 1090
+7 -5
View File
@@ -22,7 +22,7 @@
# This file contains code to handle GUI-based error reporting.
from __future__ import division, absolute_import, with_statement, print_function, unicode_literals
from renpy.compat import PY2, basestring, bchr, bord, chr, open, pystr, range, round, str, tobytes, unicode # *
from renpy.compat import PY2, basestring, bchr, bord, chr, open, pystr, range, round, str, tobytes, unicode # *
import os
@@ -143,7 +143,7 @@ def report_exception(short, full, traceback_fn):
reload_action=reload_action,
ignore_action=ignore_action,
traceback_fn=traceback_fn,
)
)
renpy.display.im.ignored_images |= renpy.display.im.images_to_ignore
@@ -162,7 +162,7 @@ def report_exception(short, full, traceback_fn):
raise
def report_parse_errors(errors, error_fn):
def report_parse_errors(errors: list[str], error_fn: str) -> bool:
"""
Reports an exception to the user. Returns True if the exception should
be raised by the normal reporting mechanisms. Otherwise, should raise
@@ -174,7 +174,7 @@ def report_parse_errors(errors, error_fn):
error_dump()
if renpy.game.args.command != "run": # @UndefinedVariable
if renpy.game.args.command != "run":
return True
if "RENPY_SIMPLE_EXCEPTIONS" in os.environ:
@@ -199,7 +199,7 @@ def report_parse_errors(errors, error_fn):
reload_action=reload_action,
errors=errors,
error_fn=error_fn,
)
)
except renpy.game.CONTROL_EXCEPTIONS:
raise
@@ -208,3 +208,5 @@ def report_parse_errors(errors, error_fn):
renpy.display.log.write("While handling exception:")
renpy.display.log.exception()
raise
return False
+135 -86
View File
@@ -20,121 +20,144 @@
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
from __future__ import division, absolute_import, with_statement, print_function, unicode_literals # type: ignore
from renpy.compat import PY2, basestring, bchr, bord, chr, open, pystr, range, round, str, tobytes, unicode # *
from __future__ import division, absolute_import, with_statement, print_function, unicode_literals # type: ignore
from renpy.compat import PY2, basestring, bchr, bord, chr, open, pystr, range, round, str, tobytes, unicode # *
from typing import Any
import codecs
import re
import sys
import os
import time
import linecache
import contextlib
import renpy
from renpy.lexersupport import match_logical_word
# The filename that's in the line text cache.
line_text_filename = ""
# The content of the line text cache.
line_text_cache = [ ]
_python_updatecache = linecache.updatecache
def get_line_text(filename, lineno):
def _updatecache(filename: str, module_globals: dict[str, Any] | None = None) -> list[str]:
"""
Gets the text of a line, in a best-effort way, for debugging purposes. May
return just a newline, if the line doesn't exist.
Monkeypatch internal linecache.updatecache to work around RenPy python code
being in different files even in the same namespace. Assume filename is one
of '_ren.py', '.rpy' or '.rpym'.
This is needed because a lot of Python modules (namely traceback) assume
that the compiled python code is always comes from the python file and uses
linecache to get the source code, which tries to use PEP302 loader, which
for RenPy python code points to renpy.minstore.
"""
global line_text_filename
global line_text_cache
import linecache
full_filename = renpy.exports.unelide_filename(filename)
if full_filename != line_text_filename:
line_text_filename = full_filename
if filename.endswith(("_ren.py", ".rpy", ".rpym")):
# Assume filename also relative to basedir or renpy_base.
full_fn = renpy.lexer.unelide_filename(filename)
# If we can't find absolute path that way, assume we are
# in build, so we can't show source.
if not (os.path.isabs(full_fn) and os.path.exists(full_fn)):
linecache.cache.pop(filename, None)
return []
try:
with open(full_filename, "rb") as f:
with open(full_fn, "rb") as f:
data = f.read().decode("utf-8", "python_strict")
if full_filename.endswith("_ren.py"):
if full_fn.endswith("_ren.py"):
data = ren_py_to_rpy(data, None)
data += "\n\n"
line_text_cache = data.split("\n")
lines = data.split("\n")
if lines and lines[0].startswith("\ufeff"):
lines[0] = lines[0][1:]
lines = [line.removesuffix("\r") + "\n" for line in lines]
stat = os.stat(full_fn)
linecache.cache[filename] = stat.st_size, stat.st_mtime, lines, full_fn
return lines
except Exception:
line_text_cache = [ ]
return []
if lineno <= len(line_text_cache):
return line_text_cache[lineno - 1] + "\n"
else:
return "\n"
return _python_updatecache(filename, module_globals)
class ParseError(Exception):
linecache.updatecache = _updatecache
def __init__(self, filename, number, msg, line=None, pos=None, first=False):
message = u"File \"%s\", line %d: %s" % (unicode_filename(filename), number, msg)
if line:
if isinstance(line, list):
line = "".join(line)
class ParseError(SyntaxError):
"""
Special exception type for syntax errors in Ren'Py.
This exception includes syntax errors of Python code, converted to
appropriate report style, and Ren'Py own syntax errors in user script.
"""
lines = line.split('\n')
_message: str | None = None
if '"""' in lines[0]:
pass
elif "'''" in lines[0]:
pass
elif len(lines) > 1:
open_string = None
i = 0
def __init__(
self,
message: str,
filename: str,
lineno: int,
offset: int | None = None,
text: str | None = None,
end_lineno: int | None = None,
end_offset: int | None = None,
):
super().__init__(message, (
unicode_filename(filename),
lineno, offset,
text,
end_lineno, end_offset))
while i < len(lines[0]):
c = lines[0][i]
@property
def message(self) -> str:
"""
Fully formatted message of the error close to the result of
`traceback.print_exception_only`.
"""
if self._message is None:
message = f'File "{self.filename}", line {self.lineno}: {self.msg}'
if self.text is not None:
# Neither Python nor this class does not support multiline syntax error code.
# Just strip the first line of provided code.
text = self.text.split("\n")[0]
if c == "\\":
i += 1
elif c == open_string:
open_string = None
elif open_string:
pass
elif c == '`' or c == '\'' or c == '"':
open_string = c
# Remove ending escape chars, so we can render it.
text = text.rstrip()
i += 1
# And also replace any escape chars at the start with an indent.
message += f'\n {text.lstrip()}'
if open_string:
message += "\n(Perhaps you left out a %s at the end of the first line.)" % open_string
if self.offset is not None:
offset = self.offset
for l in lines:
message += "\n " + l
if pos is not None:
if pos <= len(l):
message += "\n " + " " * pos + "^"
pos = None
# Fallback to single caret for cases end_offset is before offset.
if self.end_offset is None or self.end_offset <= offset:
end_offset = offset + 1
else:
pos -= len(l)
end_offset = self.end_offset
if first:
break
left_spaces = len(text) - len(text.lstrip())
offset -= left_spaces
end_offset -= left_spaces
self.message = message
if offset >= 1:
caret_space = ' ' * (offset - 1)
carets = '^' * (end_offset - offset)
message += f"\n {caret_space}{carets}"
Exception.__init__(self, message)
for note in getattr(self, "__notes__", ()):
message += f"\n{note}"
def __unicode__(self):
return self.message
self._message = message
return self._message
def defer(self, queue):
renpy.parser.deferred_parse_errors[queue].append(self.message)
@@ -360,7 +383,8 @@ def list_logical_lines(filename, filedata=None, linenumber=1, add_lines=False):
c = data[pos]
if c == u'\t':
raise ParseError(filename, number, "Tab characters are not allowed in Ren'Py scripts.")
raise ParseError("Tab characters are not allowed in Ren'Py scripts.",
filename, number)
if c == u'\n' and not parendepth:
@@ -500,10 +524,19 @@ def list_logical_lines(filename, filedata=None, linenumber=1, add_lines=False):
pos = end
if (pos - startpos) > 65536:
raise ParseError(filename, start_number, "Overly long logical line. (Check strings and parenthesis.)", line=line, first=True)
err = ParseError(
"Overly long logical line.",
filename, start_number,
text="".join(line))
err.add_note("Check strings and parenthesis.")
raise err
if line:
raise ParseError(filename, start_number, "is not terminated with a newline. (Check strings and parenthesis.)", line=line, first=True)
err = ParseError("is not terminated with a newline.",
filename, start_number,
text="".join(line))
err.add_note("Check strings and parenthesis.")
raise err
return rv
@@ -527,6 +560,8 @@ def depth_split(l):
return depth, l[index:]
# i, min_depth -> block, new_i
def gll_core(lines, i, min_depth):
"""
Recursively groups lines into blocks.
@@ -551,7 +586,9 @@ def gll_core(lines, i, min_depth):
depth = line_depth
if depth != line_depth:
raise ParseError(filename, number, "Indentation mismatch.")
raise ParseError(
"Indentation mismatch.",
filename, number, text=text)
# Advance to the next line.
i += 1
@@ -563,6 +600,7 @@ def gll_core(lines, i, min_depth):
return rv, i
def group_logical_lines(lines):
"""
This takes as input the list of logical line triples output from
@@ -577,7 +615,9 @@ def group_logical_lines(lines):
filename, number, text = lines[0]
if depth_split(text)[0] != 0:
raise ParseError(filename, number, "Unexpected indentation at start of file.")
raise ParseError(
"Unexpected indentation at start of file.",
filename, number, text=text)
return gll_core(lines, 0, 0)[0]
@@ -826,7 +866,12 @@ class Lexer(object):
if (self.line == -1) and self.block:
self.filename, self.number, self.text, self.subblock = self.block[0]
raise ParseError(self.filename, self.number, msg, self.text, self.pos)
raise ParseError(
msg,
self.filename,
self.number,
self.pos,
self.text)
def deferred_error(self, queue, msg):
"""
@@ -840,7 +885,10 @@ class Lexer(object):
if (self.line == -1) and self.block:
self.filename, self.number, self.text, self.subblock = self.block[0]
ParseError(self.filename, self.number, msg, self.text, self.pos).defer(queue)
ParseError(
msg, self.filename,
self.number, self.pos + 1,
self.text).defer(queue)
def eol(self):
"""
@@ -895,7 +943,8 @@ class Lexer(object):
init = self.init or init
return Lexer(self.subblock, init=init, init_offset=self.init_offset, global_label=self.global_label, monologue_delimiter=self.monologue_delimiter, subparses=self.subparses)
return Lexer(self.subblock, init=init, init_offset=self.init_offset, global_label=self.global_label,
monologue_delimiter=self.monologue_delimiter, subparses=self.subparses)
def string(self):
"""
@@ -951,7 +1000,7 @@ class Lexer(object):
# Collapse runs of whitespace into single spaces.
s = re.sub(r'[ \n]+', ' ', s)
s = re.sub(r'\\(u([0-9a-fA-F]{1,4})|.)', dequote, s) # type: ignore
s = re.sub(r'\\(u([0-9a-fA-F]{1,4})|.)', dequote, s) # type: ignore
return s
@@ -1030,7 +1079,7 @@ class Lexer(object):
else:
s = re.sub(r' +', ' ', s)
s = re.sub(r'\\(u([0-9a-fA-F]{1,4})|.)', dequote, s) # type: ignore
s = re.sub(r'\\(u([0-9a-fA-F]{1,4})|.)', dequote, s) # type: ignore
rv.append(s)
@@ -1046,7 +1095,7 @@ class Lexer(object):
return self.match(r'(\+|\-)?\d+')
def float(self): # @ReservedAssignment
def float(self):
"""
Tries to parse a number (float). Returns a string containing the
number, or None.
@@ -1168,7 +1217,7 @@ class Lexer(object):
self.pos = oldpos
return None
if (rv in KEYWORDS ) or (rv in IMAGE_KEYWORDS):
if (rv in KEYWORDS) or (rv in IMAGE_KEYWORDS):
self.pos = oldpos
return None
@@ -1187,7 +1236,6 @@ class Lexer(object):
old_pos = self.pos
# Delimiter.
start = self.match(r'[urfURF]*("""|\'\'\'|"|\')')
@@ -1283,7 +1331,7 @@ class Lexer(object):
if not pe:
self.error("expected python_expression")
rv = self.expr(pe.strip(), expr) # E1101
rv = self.expr(pe.strip(), expr)
return rv
@@ -1585,7 +1633,7 @@ class Lexer(object):
return sp
def ren_py_to_rpy(text, filename):
def ren_py_to_rpy(text: str, filename: str | None) -> str:
"""
Transforms an _ren.py file into the equivalent .rpy file. This should retain line numbers.
@@ -1668,8 +1716,9 @@ def ren_py_to_rpy(text, filename):
raise Exception('In {!r}, there are no """renpy blocks, so every line is ignored.'.format(filename))
if state == RENPY:
raise Exception('In {!r}, there is a """renpy block at line {} that is not terminated by """.'.format(filename,
open_linenumber))
raise Exception(
'In {!r}, there is a """renpy block at line {} that is not terminated by """.'.format(
filename, open_linenumber))
rv = "\n".join(result)
+6 -3
View File
@@ -43,7 +43,6 @@ from renpy.lexer import (
munge_filename,
elide_filename,
unelide_filename,
get_line_text,
SubParse,
)
@@ -1773,8 +1772,10 @@ def report_parse_errors():
full_text = ""
f, error_fn = renpy.error.open_error_file("errors.txt", "w")
screen_parse_errors = []
with f:
f.write("\ufeff") # BOM
f.write("\ufeff") # BOM
print("I'm sorry, but errors were detected in your script. Please correct the", file=f)
print("errors listed below, and try again.", file=f)
@@ -1791,6 +1792,8 @@ def report_parse_errors():
print("", file=f)
print(i, file=f)
screen_parse_errors.append(i)
try:
print("")
print(i)
@@ -1801,7 +1804,7 @@ def report_parse_errors():
print("Ren'Py Version:", renpy.version, file=f)
print(str(time.ctime()), file=f)
renpy.display.error.report_parse_errors(full_text, error_fn)
renpy.display.error.report_parse_errors(screen_parse_errors, error_fn)
try:
if renpy.game.args.command == "run" or renpy.game.args.errors_in_editor: # type: ignore
-1
View File
@@ -1114,7 +1114,6 @@ def py_compile(source, mode, filename='<none>', lineno=1, ast_node=False, cache=
with save_warnings():
tree = compile(source, filename, py_mode, ast.PyCF_ONLY_AST | flags, 1)
except SyntaxError as orig_e:
try:
fixed_source = renpy.compat.fixes.fix_tokens(source)
with save_warnings():
+10 -13
View File
@@ -620,12 +620,13 @@ class Script(object):
if renpy.config.allow_duplicate_labels:
return
import linecache
self.duplicate_labels.append(
u'The label {} is defined twice, at File "{}", line {}:\n{}and File "{}", line {}:\n{}'.format(
bad_name, old_node.filename, old_node.linenumber,
renpy.lexer.get_line_text(old_node.filename, old_node.linenumber),
linecache.getline(old_node.filename, old_node.linenumber),
bad_node.filename, bad_node.linenumber,
renpy.lexer.get_line_text(bad_node.filename, bad_node.linenumber),
linecache.getline(bad_node.filename, bad_node.linenumber),
))
self.update_bytecode()
@@ -1057,18 +1058,14 @@ class Script(object):
i.bytecode = renpy.python.py_compile(i.source, i.mode, filename=i.filename, lineno=i.linenumber, py=i.py, hashcode=i.hashcode)
except SyntaxError as e:
text = e.text
if text is None:
text = ''
assert e.filename is not None
assert e.lineno is not None
pem = renpy.parser.ParseError(
filename=e.filename,
number=e.lineno,
msg=e.msg,
line=text,
pos=e.offset)
e.msg,
e.filename,
e.lineno, e.offset,
e.text,
e.end_lineno, e.end_offset)
renpy.parser.parse_errors.append(pem.message)
+2 -2
View File
@@ -170,9 +170,9 @@ class ScriptTranslator(object):
old_node = self.default_translates[n.identifier]
err = renpy.lexer.ParseError(
n.filename, n.linenumber,
f"Line with id {n.identifier} appears twice. "
f"The other line is {old_node.filename}:{old_node.linenumber}")
f"The other line is {old_node.filename}:{old_node.linenumber}",
n.filename, n.linenumber,)
err.defer("duplicate_id")
self.default_translates[n.identifier] = n