Improve correspondence with Python errors and console behavior

Compiler and command-line error messages now reflect their Python counterparts.
E.g. where Python emits a `SyntaxError`, so does Hy; same for `TypeError`s.
Multiple tests have been added that check the format and type of raised
exceptions over varying command-line invocations (e.g. interactive and not).

A new exception type for `require` errors was added so that they can be treated
like normal run-time errors and not compiler errors.

The Hy REPL has been further refactored to better match the class-structured
API.  Now, different error types are handled separately and leverage more base
class-provided functionality.

Closes hylang/hy#1486.
This commit is contained in:
Brandon T. Willard 2018-10-12 23:25:43 -05:00 committed by Kodi Arfer
parent cadfa4152b
commit fb6feaf082
17 changed files with 630 additions and 388 deletions

View File

@ -40,6 +40,10 @@ if PY3:
finally:
traceback = None
code_obj_args = ['argcount', 'kwonlyargcount', 'nlocals', 'stacksize',
'flags', 'code', 'consts', 'names', 'varnames',
'filename', 'name', 'firstlineno', 'lnotab', 'freevars',
'cellvars']
else:
def raise_from(value, from_value=None):
raise value
@ -52,10 +56,30 @@ else:
traceback = None
''')
code_obj_args = ['argcount', 'nlocals', 'stacksize', 'flags', 'code',
'consts', 'names', 'varnames', 'filename', 'name',
'firstlineno', 'lnotab', 'freevars', 'cellvars']
raise_code = compile(raise_src, __file__, 'exec')
exec(raise_code)
def rename_function(func, new_name):
"""Creates a copy of a function and [re]sets the name at the code-object
level.
"""
c = func.__code__
new_code = type(c)(*[getattr(c, 'co_{}'.format(a))
if a != 'name' else str(new_name)
for a in code_obj_args])
_fn = type(func)(new_code, func.__globals__, str(new_name),
func.__defaults__, func.__closure__)
_fn.__dict__.update(func.__dict__)
return _fn
def isidentifier(x):
if x in ('True', 'False', 'None', 'print'):
# `print` is special-cased here because Python 2's

View File

@ -12,6 +12,7 @@ import os
import io
import importlib
import py_compile
import traceback
import runpy
import types
@ -20,8 +21,9 @@ import astor.code_gen
import hy
from hy.lex import hy_parse, mangle
from hy.lex.exceptions import PrematureEndOfInput
from hy.compiler import HyASTCompiler, hy_compile, hy_eval
from hy.errors import HySyntaxError, filtered_hy_exceptions
from hy.compiler import HyASTCompiler, hy_eval, hy_compile, ast_compile
from hy.errors import (HyLanguageError, HyRequireError, HyMacroExpansionError,
filtered_hy_exceptions, hy_exc_handler)
from hy.importer import runhy
from hy.completer import completion, Completer
from hy.macros import macro, require
@ -50,29 +52,70 @@ builtins.quit = HyQuitter('quit')
builtins.exit = HyQuitter('exit')
class HyCommandCompiler(object):
def __init__(self, module, ast_callback=None, hy_compiler=None):
self.module = module
self.ast_callback = ast_callback
self.hy_compiler = hy_compiler
def __call__(self, source, filename="<input>", symbol="single"):
try:
hy_ast = hy_parse(source, filename=filename)
root_ast = ast.Interactive if symbol == 'single' else ast.Module
# Our compiler doesn't correspond to a real, fixed source file, so
# we need to [re]set these.
self.hy_compiler.filename = filename
self.hy_compiler.source = source
exec_ast, eval_ast = hy_compile(hy_ast, self.module, root=root_ast,
get_expr=True,
compiler=self.hy_compiler,
filename=filename, source=source)
if self.ast_callback:
self.ast_callback(exec_ast, eval_ast)
exec_code = ast_compile(exec_ast, filename, symbol)
eval_code = ast_compile(eval_ast, filename, 'eval')
return exec_code, eval_code
except PrematureEndOfInput:
# Save these so that we can reraise/display when an incomplete
# interactive command is given at the prompt.
sys.last_type, sys.last_value, sys.last_traceback = sys.exc_info()
return None
class HyREPL(code.InteractiveConsole, object):
def __init__(self, spy=False, output_fn=None, locals=None,
filename="<input>"):
super(HyREPL, self).__init__(locals=locals,
filename=filename)
filename="<stdin>"):
# Create a proper module for this REPL so that we can obtain it easily
# (e.g. using `importlib.import_module`).
# Also, make sure it's properly introduced to `sys.modules` and
# consistently use its namespace as `locals` from here on.
# We let `InteractiveConsole` initialize `self.locals` when it's
# `None`.
super(HyREPL, self).__init__(locals=locals,
filename=filename)
module_name = self.locals.get('__name__', '__console__')
# Make sure our newly created module is properly introduced to
# `sys.modules`, and consistently use its namespace as `self.locals`
# from here on.
self.module = sys.modules.setdefault(module_name,
types.ModuleType(module_name))
self.module.__dict__.update(self.locals)
self.locals = self.module.__dict__
# Load cmdline-specific macros.
require('hy.cmdline', module_name, assignments='ALL')
require('hy.cmdline', self.module, assignments='ALL')
self.hy_compiler = HyASTCompiler(self.module)
self.compile = HyCommandCompiler(self.module, self.ast_callback,
self.hy_compiler)
self.spy = spy
self.last_value = None
if output_fn is None:
self.output_fn = repr
@ -90,13 +133,18 @@ class HyREPL(code.InteractiveConsole, object):
self._repl_results_symbols = [mangle("*{}".format(i + 1)) for i in range(3)]
self.locals.update({sym: None for sym in self._repl_results_symbols})
def ast_callback(self, main_ast, expr_ast):
def ast_callback(self, exec_ast, eval_ast):
if self.spy:
try:
# Mush the two AST chunks into a single module for
# conversion into Python.
new_ast = ast.Module(main_ast.body +
[ast.Expr(expr_ast.body)])
new_ast = ast.Module(exec_ast.body +
[ast.Expr(eval_ast.body)])
print(astor.to_source(new_ast))
except Exception:
msg = 'Exception in AST callback:\n{}\n'.format(
traceback.format_exc())
self.write(msg)
def _error_wrap(self, error_fn, *args, **kwargs):
sys.last_type, sys.last_value, sys.last_traceback = sys.exc_info()
@ -120,46 +168,50 @@ class HyREPL(code.InteractiveConsole, object):
def showtraceback(self):
self._error_wrap(super(HyREPL, self).showtraceback)
def runsource(self, source, filename='<input>', symbol='single'):
def runcode(self, code):
try:
do = hy_parse(source, filename=filename)
except PrematureEndOfInput:
return True
except HySyntaxError as e:
self.showsyntaxerror(filename=filename)
return False
try:
# Our compiler doesn't correspond to a real, fixed source file, so
# we need to [re]set these.
self.hy_compiler.filename = filename
self.hy_compiler.source = source
value = hy_eval(do, self.locals, self.module, self.ast_callback,
compiler=self.hy_compiler, filename=filename,
source=source)
eval(code[0], self.locals)
self.last_value = eval(code[1], self.locals)
# Don't print `None` values.
self.print_last_value = self.last_value is not None
except SystemExit:
raise
except Exception as e:
# Set this to avoid a print-out of the last value on errors.
self.print_last_value = False
self.showtraceback()
def runsource(self, source, filename='<stdin>', symbol='exec'):
try:
res = super(HyREPL, self).runsource(source, filename, symbol)
except (HyMacroExpansionError, HyRequireError):
# We need to handle these exceptions ourselves, because the base
# method only handles `OverflowError`, `SyntaxError` and
# `ValueError`.
self.showsyntaxerror(filename)
return False
except (HyLanguageError):
# Our compiler will also raise `TypeError`s
self.showtraceback()
return False
if value is not None:
# Shift exisitng REPL results
next_result = value
if not res:
next_result = self.last_value
for sym in self._repl_results_symbols:
self.locals[sym], next_result = next_result, self.locals[sym]
# Print the value.
if self.print_last_value:
try:
output = self.output_fn(value)
output = self.output_fn(self.last_value)
except Exception:
self.showtraceback()
return False
print(output)
return False
return res
@macro("koan")
@ -215,9 +267,14 @@ def ideas_macro(ETname):
def run_command(source, filename=None):
tree = hy_parse(source, filename=filename)
__main__ = importlib.import_module('__main__')
require("hy.cmdline", __main__, assignments="ALL")
try:
tree = hy_parse(source, filename=filename)
except HyLanguageError:
hy_exc_handler(*sys.exc_info())
return 1
with filtered_hy_exceptions():
hy_eval(tree, None, __main__, filename=filename, source=source)
return 0
@ -259,11 +316,17 @@ def run_icommand(source, **kwargs):
source = f.read()
filename = source
else:
filename = '<input>'
filename = '<string>'
with filtered_hy_exceptions():
hr = HyREPL(**kwargs)
hr.runsource(source, filename=filename, symbol='single')
with filtered_hy_exceptions():
res = hr.runsource(source, filename=filename)
# If the command was prematurely ended, show an error (just like Python
# does).
if res:
hy_exc_handler(sys.last_type, sys.last_value, sys.last_traceback)
return run_repl(hr)
@ -371,6 +434,9 @@ def cmdline_handler(scriptname, argv):
print("hy: Can't open file '{0}': [Errno {1}] {2}".format(
e.filename, e.errno, e.strerror), file=sys.stderr)
sys.exit(e.errno)
except HyLanguageError:
hy_exc_handler(*sys.exc_info())
sys.exit(1)
# User did NOTHING!
return run_repl(spy=options.spy, output_fn=options.repl_output_fn)
@ -440,14 +506,15 @@ def hy2py_main():
options = parser.parse_args(sys.argv[1:])
if options.FILE is None or options.FILE == '-':
filename = '<stdin>'
source = sys.stdin.read()
with filtered_hy_exceptions():
hst = hy_parse(source, filename='<stdin>')
else:
with filtered_hy_exceptions(), \
io.open(options.FILE, 'r', encoding='utf-8') as source_file:
filename = options.FILE
with io.open(options.FILE, 'r', encoding='utf-8') as source_file:
source = source_file.read()
hst = hy_parse(source, filename=options.FILE)
with filtered_hy_exceptions():
hst = hy_parse(source, filename=filename)
if options.with_source:
# need special printing on Windows in case the
@ -464,7 +531,7 @@ def hy2py_main():
print()
with filtered_hy_exceptions():
_ast = hy_compile(hst, '__main__')
_ast = hy_compile(hst, '__main__', filename=filename, source=source)
if options.with_ast:
if PY3 and platform.system() == "Windows":

View File

@ -9,8 +9,8 @@ from hy.models import (HyObject, HyExpression, HyKeyword, HyInteger, HyComplex,
from hy.model_patterns import (FORM, SYM, KEYWORD, STR, sym, brackets, whole,
notpexpr, dolike, pexpr, times, Tag, tag, unpack)
from funcparserlib.parser import some, many, oneplus, maybe, NoParseError
from hy.errors import (HyCompileError, HyTypeError, HyEvalError,
HyInternalError)
from hy.errors import (HyCompileError, HyTypeError, HyLanguageError,
HySyntaxError, HyEvalError, HyInternalError)
from hy.lex import mangle, unmangle
@ -443,15 +443,18 @@ class HyASTCompiler(object):
# nested; so let's re-raise this exception, let's not wrap it in
# another HyCompileError!
raise
except HyTypeError as e:
reraise(type(e), e, None)
except HyLanguageError as e:
# These are expected errors that should be passed to the user.
reraise(type(e), e, sys.exc_info()[2])
except Exception as e:
# These are unexpected errors that will--hopefully--never be seen
# by the user.
f_exc = traceback.format_exc()
exc_msg = "Internal Compiler Bug 😱\n{}".format(f_exc)
reraise(HyCompileError, HyCompileError(exc_msg), sys.exc_info()[2])
def _syntax_error(self, expr, message):
return HyTypeError(message, self.filename, expr, self.source)
return HySyntaxError(message, expr, self.filename, self.source)
def _compile_collect(self, exprs, with_kwargs=False, dict_display=False,
oldpy_unpack=False):

View File

@ -15,13 +15,13 @@
(raise
(hy.errors.HyTypeError
(% "received a `%s' instead of a symbol for macro name"
(. (type name) --name--))
--file-- macro-name None)))
(. (type name) __name__))
None --file-- None)))
(for [kw '[&kwonly &kwargs]]
(if* (in kw lambda-list)
(raise (hy.errors.HyTypeError (% "macros cannot use %s"
kw)
--file-- macro-name None))))
macro-name --file-- None))))
;; this looks familiar...
`(eval-and-compile
(import hy)
@ -46,10 +46,10 @@
(raise (hy.errors.HyTypeError
(% "received a `%s' instead of a symbol for tag macro name"
(. (type tag-name) --name--))
--file-- tag-name None)))
tag-name --file-- None)))
(if (or (= tag-name ":")
(= tag-name "&"))
(raise (NameError (% "%s can't be used as a tag macro name" tag-name))))
(raise (hy.errors.HyNameError (% "%s can't be used as a tag macro name" tag-name))))
(setv tag-name (.replace (hy.models.HyString tag-name)
tag-name))
`(eval-and-compile

View File

@ -3,6 +3,7 @@
# This file is part of Hy, which is free software licensed under the Expat
# license. See the LICENSE.
import os
import re
import sys
import traceback
import pkgutil
@ -19,9 +20,7 @@ _hy_colored_errors = _initialize_env_var('HY_COLORED_ERRORS', False)
class HyError(Exception):
def __init__(self, message, *args):
self.message = message
super(HyError, self).__init__(message, *args)
pass
class HyInternalError(HyError):
@ -31,9 +30,6 @@ class HyInternalError(HyError):
hopefully, never be seen by users!
"""
def __init__(self, message, *args):
super(HyInternalError, self).__init__(message, *args)
class HyLanguageError(HyError):
"""Errors caused by invalid use of the Hy language.
@ -41,8 +37,127 @@ class HyLanguageError(HyError):
This, and any errors inheriting from this, are user-facing.
"""
def __init__(self, message, *args):
super(HyLanguageError, self).__init__(message, *args)
def __init__(self, message, expression=None, filename=None, source=None,
lineno=1, colno=1):
"""
Parameters
----------
message: str
The message to display for this error.
expression: HyObject, optional
The Hy expression generating this error.
filename: str, optional
The filename for the source code generating this error.
Expression-provided information will take precedence of this value.
source: str, optional
The actual source code generating this error. Expression-provided
information will take precedence of this value.
lineno: int, optional
The line number of the error. Expression-provided information will
take precedence of this value.
colno: int, optional
The column number of the error. Expression-provided information
will take precedence of this value.
"""
self.msg = message
self.compute_lineinfo(expression, filename, source, lineno, colno)
if isinstance(self, SyntaxError):
syntax_error_args = (self.filename, self.lineno, self.offset,
self.text)
super(HyLanguageError, self).__init__(message, syntax_error_args)
else:
super(HyLanguageError, self).__init__(message)
def compute_lineinfo(self, expression, filename, source, lineno, colno):
# NOTE: We use `SyntaxError`'s field names (i.e. `text`, `offset`,
# `msg`) for compatibility and print-outs.
self.text = getattr(expression, 'source', source)
self.filename = getattr(expression, 'filename', filename)
if self.text:
lines = self.text.splitlines()
self.lineno = getattr(expression, 'start_line', lineno)
self.offset = getattr(expression, 'start_column', colno)
end_column = getattr(expression, 'end_column',
len(lines[self.lineno-1]))
end_line = getattr(expression, 'end_line', self.lineno)
# Trim the source down to the essentials.
self.text = '\n'.join(lines[self.lineno-1:end_line])
if end_column:
if self.lineno == end_line:
self.arrow_offset = end_column
else:
self.arrow_offset = len(self.text[0])
self.arrow_offset -= self.offset
else:
self.arrow_offset = None
else:
# We could attempt to extract the source given a filename, but we
# don't.
self.lineno = lineno
self.offset = colno
self.arrow_offset = None
def __str__(self):
"""Provide an exception message that includes SyntaxError-like source
line information when available.
"""
global _hy_colored_errors
# Syntax errors are special and annotate the traceback (instead of what
# we would do in the message that follows the traceback).
if isinstance(self, SyntaxError):
return super(HyLanguageError, self).__str__()
# When there isn't extra source information, use the normal message.
if not isinstance(self, SyntaxError) and not self.text:
return super(HyLanguageError, self).__str__()
# Re-purpose Python's builtin syntax error formatting.
output = traceback.format_exception_only(
SyntaxError,
SyntaxError(self.msg, (self.filename, self.lineno, self.offset,
self.text)))
arrow_idx, _ = next(((i, x) for i, x in enumerate(output)
if x.strip() == '^'),
(None, None))
if arrow_idx:
msg_idx = arrow_idx + 1
else:
msg_idx, _ = next((i, x) for i, x in enumerate(output)
if x.startswith('SyntaxError: '))
# Get rid of erroneous error-type label.
output[msg_idx] = re.sub('^SyntaxError: ', '', output[msg_idx])
# Extend the text arrow, when given enough source info.
if arrow_idx and self.arrow_offset:
output[arrow_idx] = '{}{}^\n'.format(output[arrow_idx].rstrip('\n'),
'-' * (self.arrow_offset - 1))
if _hy_colored_errors:
from clint.textui import colored
output[msg_idx:] = [colored.yellow(o) for o in output[msg_idx:]]
if arrow_idx:
output[arrow_idx] = colored.green(output[arrow_idx])
for idx, line in enumerate(output[::msg_idx]):
if line.strip().startswith(
'File "{}", line'.format(self.filename)):
output[idx] = colored.red(line)
# This resulting string will come after a "<class-name>:" prompt, so
# put it down a line.
output.insert(0, '\n')
# Avoid "...expected str instance, ColoredString found"
return reduce(lambda x, y: x + y, output)
class HyCompileError(HyInternalError):
@ -50,88 +165,21 @@ class HyCompileError(HyInternalError):
class HyTypeError(HyLanguageError, TypeError):
"""TypeErrors occurring during the normal use of Hy."""
"""TypeError occurring during the normal use of Hy."""
def __init__(self, message, filename=None, expression=None, source=None):
class HyNameError(HyLanguageError, NameError):
"""NameError occurring during the normal use of Hy."""
class HyRequireError(HyLanguageError):
"""Errors arising during the use of `require`
This, and any errors inheriting from this, are user-facing.
"""
Parameters
----------
message: str
The message to display for this error.
filename: str, optional
The filename for the source code generating this error.
expression: HyObject, optional
The Hy expression generating this error.
source: str, optional
The actual source code generating this error.
"""
self.message = message
self.filename = filename
self.expression = expression
self.source = source
super(HyTypeError, self).__init__(message, filename, expression,
source)
def __str__(self):
global _hy_colored_errors
result = ""
if _hy_colored_errors:
from clint.textui import colored
red, green, yellow = colored.red, colored.green, colored.yellow
else:
red = green = yellow = lambda x: x
if all(getattr(self.expression, x, None) is not None
for x in ("start_line", "start_column", "end_column")):
line = self.expression.start_line
start = self.expression.start_column
end = self.expression.end_column
source = []
if self.source is not None:
source = self.source.split("\n")[line-1:self.expression.end_line]
if line == self.expression.end_line:
length = end - start
else:
length = len(source[0]) - start
result += ' File "%s", line %d, column %d\n\n' % (self.filename,
line,
start)
if len(source) == 1:
result += ' %s\n' % red(source[0])
result += ' %s%s\n' % (' '*(start-1),
green('^' + '-'*(length-1) + '^'))
if len(source) > 1:
result += ' %s\n' % red(source[0])
result += ' %s%s\n' % (' '*(start-1),
green('^' + '-'*length))
if len(source) > 2: # write the middle lines
for line in source[1:-1]:
result += ' %s\n' % red("".join(line))
result += ' %s\n' % green("-"*len(line))
# write the last line
result += ' %s\n' % red("".join(source[-1]))
result += ' %s\n' % green('-'*(end-1) + '^')
else:
result += ' File "%s", unknown location\n' % self.filename
result += yellow("%s: %s\n\n" %
(self.__class__.__name__,
self.message))
return result
class HyMacroExpansionError(HyTypeError):
class HyMacroExpansionError(HyLanguageError):
"""Errors caused by invalid use of Hy macros.
This, and any errors inheriting from this, are user-facing.
@ -158,97 +206,39 @@ class HyIOError(HyInternalError, IOError):
class HySyntaxError(HyLanguageError, SyntaxError):
"""Error during the Lexing of a Hython expression."""
def __init__(self, message, filename=None, lineno=-1, colno=-1,
source=None):
"""
Parameters
----------
message: str
The exception's message.
filename: str, optional
The filename for the source code generating this error.
lineno: int, optional
The line number of the error.
colno: int, optional
The column number of the error.
source: str, optional
The actual source code generating this error.
"""
self.message = message
self.filename = filename
self.lineno = lineno
self.colno = colno
self.source = source
super(HySyntaxError, self).__init__(message,
# The builtin `SyntaxError` needs a
# tuple.
(filename, lineno, colno, source))
@staticmethod
def from_expression(message, expression, filename=None, source=None):
if not source:
# Maybe the expression object has its own source.
source = getattr(expression, 'source', None)
def _module_filter_name(module_name):
try:
compiler_loader = pkgutil.get_loader(module_name)
if not compiler_loader:
return None
filename = compiler_loader.get_filename(module_name)
if not filename:
filename = getattr(expression, 'filename', None)
return None
if source:
lineno = expression.start_line
colno = expression.start_column
end_line = getattr(expression, 'end_line', len(source))
lines = source.splitlines()
source = '\n'.join(lines[lineno-1:end_line])
else:
# We could attempt to extract the source given a filename, but we
# don't.
lineno = colno = -1
return HySyntaxError(message, filename, lineno, colno, source)
def __str__(self):
global _hy_colored_errors
output = traceback.format_exception_only(SyntaxError,
SyntaxError(*self.args))
if _hy_colored_errors:
from hy.errors import colored
output[-1] = colored.yellow(output[-1])
if len(self.source) > 0:
output[-2] = colored.green(output[-2])
for line in output[::-2]:
if line.strip().startswith(
'File "{}", line'.format(self.filename)):
break
output[-3] = colored.red(output[-3])
# Avoid "...expected str instance, ColoredString found"
return reduce(lambda x, y: x + y, output)
def _get_module_info(module):
compiler_loader = pkgutil.get_loader(module)
is_pkg = compiler_loader.is_package(module)
filename = compiler_loader.get_filename()
if is_pkg:
# Use package directory
if compiler_loader.is_package(module_name):
# Use the package directory (e.g. instead of `.../__init__.py`) so
# that we can filter all modules in a package.
return os.path.dirname(filename)
else:
# Normalize filename endings, because tracebacks will use `pyc` when
# the loader says `py`.
return filename.replace('.pyc', '.py')
except Exception:
return None
_tb_hidden_modules = {_get_module_info(m)
for m in ['hy.compiler', 'hy.lex',
_tb_hidden_modules = {m for m in map(_module_filter_name,
['hy.compiler', 'hy.lex',
'hy.cmdline', 'hy.lex.parser',
'hy.importer', 'hy._compat',
'hy.macros', 'hy.models',
'rply']}
'rply'])
if m is not None}
def hy_exc_handler(exc_type, exc_value, exc_traceback):
def hy_exc_filter(exc_type, exc_value, exc_traceback):
"""Produce exceptions print-outs with all frames originating from the
modules in `_tb_hidden_modules` filtered out.
@ -258,11 +248,12 @@ def hy_exc_handler(exc_type, exc_value, exc_traceback):
This does not remove the frames from the actual tracebacks, so debugging
will show everything.
"""
try:
# frame = (filename, line number, function name*, text)
new_tb = [frame for frame in traceback.extract_tb(exc_traceback)
new_tb = []
for frame in traceback.extract_tb(exc_traceback):
if not (frame[0].replace('.pyc', '.py') in _tb_hidden_modules or
os.path.dirname(frame[0]) in _tb_hidden_modules)]
os.path.dirname(frame[0]) in _tb_hidden_modules):
new_tb += [frame]
lines = traceback.format_list(new_tb)
@ -272,6 +263,18 @@ def hy_exc_handler(exc_type, exc_value, exc_traceback):
lines.extend(traceback.format_exception_only(exc_type, exc_value))
output = ''.join(lines)
return output
def hy_exc_handler(exc_type, exc_value, exc_traceback):
"""A `sys.excepthook` handler that uses `hy_exc_filter` to
remove internal Hy frames from a traceback print-out.
"""
if os.environ.get('HY_DEBUG', False):
return sys.__excepthook__(exc_type, exc_value, exc_traceback)
try:
output = hy_exc_filter(exc_type, exc_value, exc_traceback)
sys.stderr.write(output)
sys.stderr.flush()
except Exception:

View File

@ -8,10 +8,9 @@ import re
import sys
import unicodedata
from hy._compat import str_type, isidentifier, UCS4, reraise
from hy._compat import str_type, isidentifier, UCS4
from hy.lex.exceptions import PrematureEndOfInput, LexException # NOQA
from hy.models import HyExpression, HySymbol
from hy.errors import HySyntaxError
try:
from io import StringIO
@ -35,15 +34,12 @@ def hy_parse(source, filename='<string>'):
out : HyExpression
"""
_source = re.sub(r'\A#!.*', '', source)
try:
res = HyExpression([HySymbol("do")] +
tokenize(_source + "\n",
filename=filename))
res.source = source
res.filename = filename
return res
except HySyntaxError as e:
reraise(type(e), e, None)
class ParserState(object):
@ -70,8 +66,12 @@ def tokenize(source, filename=None):
state=ParserState(source, filename))
except LexingError as e:
pos = e.getsourcepos()
raise LexException("Could not identify the next token.", filename,
pos.lineno, pos.colno, source)
raise LexException("Could not identify the next token.",
None, filename, source,
max(pos.lineno, 1),
max(pos.colno, 1))
except LexException as e:
raise e
mangle_delim = 'X'

View File

@ -8,26 +8,31 @@ class LexException(HySyntaxError):
@classmethod
def from_lexer(cls, message, state, token):
lineno = None
colno = None
source = state.source
source_pos = token.getsourcepos()
if token.source_pos:
if source_pos:
lineno = source_pos.lineno
colno = source_pos.colno
elif source:
# Use the end of the last line of source for `PrematureEndOfInput`.
# We get rid of empty lines and spaces so that the error matches
# with the last piece of visible code.
lines = source.rstrip().splitlines()
lineno = lineno or len(lines)
colno = colno or len(lines[lineno - 1])
else:
lineno = -1
colno = -1
lineno = lineno or 1
colno = colno or 1
if state.source:
lines = state.source.splitlines()
if lines[-1] == '':
del lines[-1]
if lineno < 1:
lineno = len(lines)
if colno < 1:
colno = len(lines[-1])
source = lines[lineno - 1]
return cls(message, state.filename, lineno, colno, source)
return cls(message,
None,
state.filename,
source,
lineno,
colno)
class PrematureEndOfInput(LexException):

View File

@ -6,7 +6,6 @@
from __future__ import unicode_literals
from functools import wraps
import re, unicodedata
from rply import ParserGenerator
@ -278,15 +277,6 @@ def symbol_like(obj):
def error_handler(state, token):
tokentype = token.gettokentype()
if tokentype == '$end':
source_pos = token.source_pos or token.getsourcepos()
source = state.source
if source_pos:
lineno = source_pos.lineno
colno = source_pos.colno
else:
lineno = -1
colno = -1
raise PrematureEndOfInput.from_lexer("Premature end of input", state,
token)
else:

View File

@ -5,13 +5,15 @@ import sys
import importlib
import inspect
import pkgutil
import traceback
from contextlib import contextmanager
from hy._compat import PY3, string_types, reraise
from hy._compat import PY3, string_types, reraise, rename_function
from hy.models import replace_hy_obj, HyExpression, HySymbol, wrap_value
from hy.lex import mangle
from hy.errors import HyTypeError, HyMacroExpansionError
from hy.errors import (HyLanguageError, HyMacroExpansionError, HyTypeError,
HyRequireError)
try:
# Check if we have the newer inspect.signature available.
@ -50,7 +52,7 @@ def macro(name):
"""
name = mangle(name)
def _(fn):
fn.__name__ = '({})'.format(name)
fn = rename_function(fn, name)
try:
fn._hy_macro_pass_compiler = has_kwargs(fn)
except Exception:
@ -75,7 +77,7 @@ def tag(name):
if not PY3:
_name = _name.encode('UTF-8')
fn.__name__ = _name
fn = rename_function(fn, _name)
module = inspect.getmodule(fn)
@ -150,7 +152,6 @@ def require(source_module, target_module, assignments, prefix=""):
out: boolean
Whether or not macros and tags were actually transferred.
"""
if target_module is None:
parent_frame = inspect.stack()[1][0]
target_namespace = parent_frame.f_globals
@ -161,7 +162,7 @@ def require(source_module, target_module, assignments, prefix=""):
elif inspect.ismodule(target_module):
target_namespace = target_module.__dict__
else:
raise TypeError('`target_module` is not a recognized type: {}'.format(
raise HyTypeError('`target_module` is not a recognized type: {}'.format(
type(target_module)))
# Let's do a quick check to make sure the source module isn't actually
@ -173,14 +174,17 @@ def require(source_module, target_module, assignments, prefix=""):
return False
if not inspect.ismodule(source_module):
try:
source_module = importlib.import_module(source_module)
except ImportError as e:
reraise(HyRequireError, HyRequireError(e.args[0]), None)
source_macros = source_module.__dict__.setdefault('__macros__', {})
source_tags = source_module.__dict__.setdefault('__tags__', {})
if len(source_module.__macros__) + len(source_module.__tags__) == 0:
if assignments != "ALL":
raise ImportError('The module {} has no macros or tags'.format(
raise HyRequireError('The module {} has no macros or tags'.format(
source_module))
else:
return False
@ -205,7 +209,7 @@ def require(source_module, target_module, assignments, prefix=""):
elif _name in source_module.__tags__:
target_tags[alias] = source_tags[_name]
else:
raise ImportError('Could not require name {} from {}'.format(
raise HyRequireError('Could not require name {} from {}'.format(
_name, source_module))
return True
@ -239,50 +243,33 @@ def load_macros(module):
if k not in module_tags})
def make_empty_fn_copy(fn):
try:
# This might fail if fn has parameters with funny names, like o!n. In
# such a case, we return a generic function that ensures the program
# can continue running. Unfortunately, the error message that might get
# raised later on while expanding a macro might not make sense at all.
formatted_args = format_args(fn)
fn_str = 'lambda {}: None'.format(
formatted_args.lstrip('(').rstrip(')'))
empty_fn = eval(fn_str)
except Exception:
def empty_fn(*args, **kwargs):
None
return empty_fn
@contextmanager
def macro_exceptions(module, macro_tree, compiler=None):
try:
yield
except HyLanguageError as e:
# These are user-level Hy errors occurring in the macro.
# We want to pass them up to the user.
reraise(type(e), e, sys.exc_info()[2])
except Exception as e:
try:
filename = inspect.getsourcefile(module)
source = inspect.getsource(module)
except TypeError:
if compiler:
filename = compiler.filename
source = compiler.source
if not isinstance(e, HyTypeError):
exc_type = HyMacroExpansionError
msg = "expanding `{}': ".format(macro_tree[0])
msg += str(e).replace("<lambda>()", "", 1).strip()
else:
exc_type = HyTypeError
msg = e.message
filename = None
source = None
reraise(exc_type,
exc_type(msg, filename, macro_tree, source),
sys.exc_info()[2].tb_next)
exc_msg = ' '.join(traceback.format_exception_only(
sys.exc_info()[0], sys.exc_info()[1]))
msg = "expanding macro {}\n ".format(str(macro_tree[0]))
msg += exc_msg
reraise(HyMacroExpansionError,
HyMacroExpansionError(
msg, macro_tree, filename, source),
sys.exc_info()[2])
def macroexpand(tree, module, compiler=None, once=False):
@ -353,8 +340,6 @@ def macroexpand(tree, module, compiler=None, once=False):
opts['compiler'] = compiler
with macro_exceptions(module, tree, compiler):
m_copy = make_empty_fn_copy(m)
m_copy(module.__name__, *tree[1:], **opts)
obj = m(module.__name__, *tree[1:], **opts)
if isinstance(obj, HyExpression):

View File

@ -6,9 +6,8 @@
from __future__ import unicode_literals
from hy import HyString
from hy.models import HyObject
from hy.compiler import hy_compile, hy_eval
from hy.errors import HyCompileError, HyTypeError
from hy.errors import HyCompileError, HyLanguageError, HyError
from hy.lex import hy_parse
from hy.lex.exceptions import LexException, PrematureEndOfInput
from hy._compat import PY3
@ -27,7 +26,7 @@ def _ast_spotcheck(arg, root, secondary):
def can_compile(expr):
return hy_compile(hy_parse(expr), "__main__")
return hy_compile(hy_parse(expr), __name__)
def can_eval(expr):
@ -35,21 +34,16 @@ def can_eval(expr):
def cant_compile(expr):
try:
hy_compile(hy_parse(expr), "__main__")
assert False
except HyTypeError as e:
with pytest.raises(HyError) as excinfo:
hy_compile(hy_parse(expr), __name__)
if issubclass(excinfo.type, HyLanguageError):
assert excinfo.value.msg
return excinfo.value
elif issubclass(excinfo.type, HyCompileError):
# Anything that can't be compiled should raise a user friendly
# error, otherwise it's a compiler bug.
assert isinstance(e.expression, HyObject)
assert e.message
return e
except HyCompileError as e:
# Anything that can't be compiled should raise a user friendly
# error, otherwise it's a compiler bug.
assert isinstance(e.exception, HyTypeError)
assert e.traceback
return e
return excinfo.value
def s(x):
@ -60,11 +54,9 @@ def test_ast_bad_type():
"Make sure AST breakage can happen"
class C:
pass
try:
hy_compile(C(), "__main__")
assert True is False
except TypeError:
pass
with pytest.raises(TypeError):
hy_compile(C(), __name__, filename='<string>', source='')
def test_empty_expr():
@ -473,7 +465,7 @@ def test_lambda_list_keywords_kwonly():
assert code.body[0].args.kw_defaults[1].n == 2
else:
exception = cant_compile(kwonly_demo)
assert isinstance(exception, HyTypeError)
assert isinstance(exception, HyLanguageError)
message = exception.args[0]
assert message == "&kwonly parameters require Python 3"
@ -489,9 +481,9 @@ def test_lambda_list_keywords_mixed():
def test_missing_keyword_argument_value():
"""Ensure the compiler chokes on missing keyword argument values."""
with pytest.raises(HyTypeError) as excinfo:
with pytest.raises(HyLanguageError) as excinfo:
can_compile("((fn [x] x) :x)")
assert excinfo.value.message == "Keyword argument :x needs a value."
assert excinfo.value.msg == "Keyword argument :x needs a value."
def test_ast_unicode_strings():
@ -500,7 +492,7 @@ def test_ast_unicode_strings():
def _compile_string(s):
hy_s = HyString(s)
code = hy_compile([hy_s], "__main__")
code = hy_compile([hy_s], __name__, filename='<string>', source=s)
# We put hy_s in a list so it isn't interpreted as a docstring.
# code == ast.Module(body=[ast.Expr(value=ast.List(elts=[ast.Str(s=xxx)]))])
@ -541,7 +533,7 @@ Only one leading newline should be removed.
def test_compile_error():
"""Ensure we get compile error in tricky cases"""
with pytest.raises(HyTypeError) as excinfo:
with pytest.raises(HyLanguageError) as excinfo:
can_compile("(fn [] (in [1 2 3]))")
@ -549,11 +541,11 @@ def test_for_compile_error():
"""Ensure we get compile error in tricky 'for' cases"""
with pytest.raises(PrematureEndOfInput) as excinfo:
can_compile("(fn [] (for)")
assert excinfo.value.message == "Premature end of input"
assert excinfo.value.msg == "Premature end of input"
with pytest.raises(LexException) as excinfo:
can_compile("(fn [] (for)))")
assert excinfo.value.message == "Ran into a RPAREN where it wasn't expected."
assert excinfo.value.msg == "Ran into a RPAREN where it wasn't expected."
cant_compile("(fn [] (for [x] x))")
@ -605,13 +597,13 @@ def test_setv_builtins():
def test_top_level_unquote():
with pytest.raises(HyTypeError) as excinfo:
with pytest.raises(HyLanguageError) as excinfo:
can_compile("(unquote)")
assert excinfo.value.message == "The special form 'unquote' is not allowed here"
assert excinfo.value.msg == "The special form 'unquote' is not allowed here"
with pytest.raises(HyTypeError) as excinfo:
with pytest.raises(HyLanguageError) as excinfo:
can_compile("(unquote-splice)")
assert excinfo.value.message == "The special form 'unquote-splice' is not allowed here"
assert excinfo.value.msg == "The special form 'unquote-splice' is not allowed here"
def test_lots_of_comment_lines():

View File

@ -50,8 +50,7 @@ def test_preprocessor_exceptions():
""" Test that macro expansion raises appropriate exceptions"""
with pytest.raises(HyMacroExpansionError) as excinfo:
macroexpand(tokenize('(defn)')[0], __name__, HyASTCompiler(__name__))
assert "_hy_anon_fn_" not in excinfo.value.message
assert "TypeError" not in excinfo.value.message
assert "_hy_anon_" not in excinfo.value.msg
def test_macroexpand_nan():

View File

@ -687,5 +687,5 @@ result['y in globals'] = 'y' in globals()")
(doc doc)
(setv out_err (.readouterr capsys))
(assert (.startswith (.strip (first out_err))
"Help on function (doc) in module hy.core.macros:"))
"Help on function doc in module hy.core.macros:"))
(assert (empty? (second out_err))))

View File

@ -7,7 +7,7 @@
[sys :as systest]
re
[operator [or_]]
[hy.errors [HyTypeError]]
[hy.errors [HyLanguageError]]
pytest)
(import sys)
@ -68,16 +68,16 @@
"NATIVE: test that setv doesn't work on names Python can't assign to
and that we can't mangle"
(try (eval '(setv None 1))
(except [e [TypeError]] (assert (in "Can't assign to" (str e)))))
(except [e [SyntaxError]] (assert (in "Can't assign to" (str e)))))
(try (eval '(defn None [] (print "hello")))
(except [e [TypeError]] (assert (in "Can't assign to" (str e)))))
(except [e [SyntaxError]] (assert (in "Can't assign to" (str e)))))
(when PY3
(try (eval '(setv False 1))
(except [e [TypeError]] (assert (in "Can't assign to" (str e)))))
(except [e [SyntaxError]] (assert (in "Can't assign to" (str e)))))
(try (eval '(setv True 0))
(except [e [TypeError]] (assert (in "Can't assign to" (str e)))))
(except [e [SyntaxError]] (assert (in "Can't assign to" (str e)))))
(try (eval '(defn True [] (print "hello")))
(except [e [TypeError]] (assert (in "Can't assign to" (str e)))))))
(except [e [SyntaxError]] (assert (in "Can't assign to" (str e)))))))
(defn test-setv-pairs []
@ -87,7 +87,7 @@
(assert (= b 2))
(setv y 0 x 1 y x)
(assert (= y 1))
(with [(pytest.raises HyTypeError)]
(with [(pytest.raises HyLanguageError)]
(eval '(setv a 1 b))))
@ -144,29 +144,29 @@
(do
(eval '(setv (do 1 2) 1))
(assert False))
(except [e HyTypeError]
(assert (= e.message "Can't assign or delete a non-expression"))))
(except [e HyLanguageError]
(assert (= e.msg "Can't assign or delete a non-expression"))))
(try
(do
(eval '(setv 1 1))
(assert False))
(except [e HyTypeError]
(assert (= e.message "Can't assign or delete a HyInteger"))))
(except [e HyLanguageError]
(assert (= e.msg "Can't assign or delete a HyInteger"))))
(try
(do
(eval '(setv {1 2} 1))
(assert False))
(except [e HyTypeError]
(assert (= e.message "Can't assign or delete a HyDict"))))
(except [e HyLanguageError]
(assert (= e.msg "Can't assign or delete a HyDict"))))
(try
(do
(eval '(del 1 1))
(assert False))
(except [e HyTypeError]
(assert (= e.message "Can't assign or delete a HyInteger")))))
(except [e HyLanguageError]
(assert (= e.msg "Can't assign or delete a HyInteger")))))
(defn test-no-str-as-sym []

View File

@ -3,7 +3,7 @@
;; license. See the LICENSE.
(import pytest
[hy.errors [HyTypeError]])
[hy.errors [HyTypeError HyMacroExpansionError]])
(defmacro rev [&rest body]
"Execute the `body` statements in reverse"
@ -66,13 +66,13 @@
(try
(eval '(defmacro f [&kwonly a b]))
(except [e HyTypeError]
(assert (= e.message "macros cannot use &kwonly")))
(assert (= e.msg "macros cannot use &kwonly")))
(else (assert False)))
(try
(eval '(defmacro f [&kwargs kw]))
(except [e HyTypeError]
(assert (= e.message "macros cannot use &kwargs")))
(assert (= e.msg "macros cannot use &kwargs")))
(else (assert False))))
(defn test-fn-calling-macro []
@ -483,3 +483,28 @@ in expansions."
(test-macro)
(assert (= blah 1)))
(defn test-macro-errors []
(import traceback
[hy.importer [hy-parse]])
(setv test-expr (hy-parse "(defmacro blah [x] `(print ~@z)) (blah y)"))
(with [excinfo (pytest.raises HyMacroExpansionError)]
(eval test-expr))
(setv output (traceback.format_exception_only
excinfo.type excinfo.value))
(setv output (cut (.splitlines (.strip (first output))) 1))
(setv expected [" File \"<string>\", line 1"
" (defmacro blah [x] `(print ~@z)) (blah y)"
" ^------^"
"expanding macro blah"
" NameError: global name 'z' is not defined"])
(assert (= (cut expected 0 -1) (cut output 0 -1)))
(assert (or (= (get expected -1) (get output -1))
;; Handle PyPy's peculiarities
(= (.replace (get expected -1) "global " "") (get output -1)))))

View File

@ -28,7 +28,7 @@
(defmacro forbid [expr]
`(assert (try
(eval '~expr)
(except [TypeError] True)
(except [[TypeError SyntaxError]] True)
(else (raise AssertionError)))))

View File

@ -11,6 +11,7 @@ import shlex
import subprocess
from hy.importer import cache_from_source
from hy._compat import PY3
import pytest
@ -123,7 +124,16 @@ def test_bin_hy_stdin_as_arrow():
def test_bin_hy_stdin_error_underline_alignment():
_, err = run_cmd("hy", "(defmacro mabcdefghi [x] x)\n(mabcdefghi)")
assert "\n (mabcdefghi)\n ^----------^" in err
msg_idx = err.rindex(" (mabcdefghi)")
assert msg_idx
err_parts = err[msg_idx:].splitlines()
assert err_parts[1].startswith(" ^----------^")
assert err_parts[2].startswith("expanding macro mabcdefghi")
assert (err_parts[3].startswith(" TypeError: mabcdefghi") or
# PyPy can use a function's `__name__` instead of
# `__code__.co_name`.
err_parts[3].startswith(" TypeError: (mabcdefghi)"))
def test_bin_hy_stdin_except_do():
@ -153,6 +163,62 @@ def test_bin_hy_stdin_unlocatable_hytypeerror():
assert "AZ" in err
def test_bin_hy_error_parts_length():
"""Confirm that exception messages print arrows surrounding the affected
expression."""
prg_str = """
(import hy.errors
[hy.importer [hy-parse]])
(setv test-expr (hy-parse "(+ 1\n\n'a 2 3\n\n 1)"))
(setv test-expr.start-line {})
(setv test-expr.start-column {})
(setv test-expr.end-column {})
(raise (hy.errors.HyLanguageError
"this\nis\na\nmessage"
test-expr
None
None))
"""
# Up-arrows right next to each other.
_, err = run_cmd("hy", prg_str.format(3, 1, 2))
msg_idx = err.rindex("HyLanguageError:")
assert msg_idx
err_parts = err[msg_idx:].splitlines()[1:]
expected = [' File "<string>", line 3',
' \'a 2 3',
' ^^',
'this',
'is',
'a',
'message']
for obs, exp in zip(err_parts, expected):
assert obs.startswith(exp)
# Make sure only one up-arrow is printed
_, err = run_cmd("hy", prg_str.format(3, 1, 1))
msg_idx = err.rindex("HyLanguageError:")
assert msg_idx
err_parts = err[msg_idx:].splitlines()[1:]
assert err_parts[2] == ' ^'
# Make sure lines are printed in between arrows separated by more than one
# character.
_, err = run_cmd("hy", prg_str.format(3, 1, 6))
print(err)
msg_idx = err.rindex("HyLanguageError:")
assert msg_idx
err_parts = err[msg_idx:].splitlines()[1:]
assert err_parts[2] == ' ^----^'
def test_bin_hy_stdin_bad_repr():
# https://github.com/hylang/hy/issues/1389
output, err = run_cmd("hy", """
@ -423,3 +489,86 @@ def test_bin_hy_macro_require():
assert os.path.exists(cache_from_source(test_file))
output, _ = run_cmd("hy {}".format(test_file))
assert "abc" == output.strip()
def test_bin_hy_tracebacks():
"""Make sure the printed tracebacks are correct."""
# We want the filtered tracebacks.
os.environ['HY_DEBUG'] = ''
def req_err(x):
assert x == '{}HyRequireError: No module named {}'.format(
'hy.errors.' if PY3 else '',
(repr if PY3 else str)('not_a_real_module'))
# Modeled after
# > python -c 'import not_a_real_module'
# Traceback (most recent call last):
# File "<string>", line 1, in <module>
# ImportError: No module named not_a_real_module
_, error = run_cmd('hy', '(require not-a-real-module)')
error_lines = error.splitlines()
if error_lines[-1] == '':
del error_lines[-1]
assert len(error_lines) <= 10
# Rough check for the internal traceback filtering
req_err(error_lines[4 if PY3 else -1])
_, error = run_cmd('hy -c "(require not-a-real-module)"', expect=1)
error_lines = error.splitlines()
assert len(error_lines) <= 4
req_err(error_lines[-1])
output, error = run_cmd('hy -i "(require not-a-real-module)"')
assert output.startswith('=> ')
print(error.splitlines())
req_err(error.splitlines()[3 if PY3 else -3])
# Modeled after
# > python -c 'print("hi'
# File "<string>", line 1
# print("hi
# ^
# SyntaxError: EOL while scanning string literal
_, error = run_cmd(r'hy -c "(print \""', expect=1)
peoi = (
' File "<string>", line 1\n'
' (print "\n'
' ^\n' +
'{}PrematureEndOfInput: Partial string literal\n'.format(
'hy.lex.exceptions.' if PY3 else ''))
assert error == peoi
# Modeled after
# > python -i -c "print('"
# File "<string>", line 1
# print('
# ^
# SyntaxError: EOL while scanning string literal
# >>>
output, error = run_cmd(r'hy -i "(print \""')
assert output.startswith('=> ')
assert error.startswith(peoi)
# Modeled after
# > python -c 'print(a)'
# Traceback (most recent call last):
# File "<string>", line 1, in <module>
# NameError: name 'a' is not defined
output, error = run_cmd('hy -c "(print a)"', expect=1)
error_lines = error.splitlines()
assert error_lines[3] == ' File "<string>", line 1, in <module>'
# PyPy will add "global" to this error message, so we work around that.
assert error_lines[-1].strip().replace(' global', '') == (
"NameError: name 'a' is not defined")
# Modeled after
# > python -c 'compile()'
# Traceback (most recent call last):
# File "<string>", line 1, in <module>
# TypeError: Required argument 'source' (pos 1) not found
output, error = run_cmd('hy -c "(compile)"', expect=1)
error_lines = error.splitlines()
assert error_lines[-2] == ' File "<string>", line 1, in <module>'
assert error_lines[-1].startswith('TypeError')

View File

@ -61,7 +61,7 @@ def test_lex_single_quote_err():
with lexe() as execinfo:
tokenize("' ")
check_ex(execinfo, [
' File "<string>", line -1\n',
' File "<string>", line 1\n',
" '\n",
' ^\n',
'LexException: Could not identify the next token.\n'])
@ -472,7 +472,7 @@ def test_lex_exception_filtering(capsys):
# First, test for PrematureEndOfInput
with peoi() as execinfo:
tokenize(" \n (foo")
tokenize(" \n (foo\n \n")
check_trace_output(capsys, execinfo, [
' File "<string>", line 2',
' (foo',