From fb6feaf08298d67045583e4dcae090f1e80b2b94 Mon Sep 17 00:00:00 2001 From: "Brandon T. Willard" Date: Fri, 12 Oct 2018 23:25:43 -0500 Subject: [PATCH] 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. --- hy/_compat.py | 24 ++ hy/cmdline.py | 173 +++++++++---- hy/compiler.py | 13 +- hy/core/bootstrap.hy | 10 +- hy/errors.py | 369 ++++++++++++++------------- hy/lex/__init__.py | 26 +- hy/lex/exceptions.py | 35 +-- hy/lex/parser.py | 10 - hy/macros.py | 79 +++--- tests/compilers/test_ast.py | 56 ++-- tests/macros/test_macro_processor.py | 3 +- tests/native_tests/core.hy | 2 +- tests/native_tests/language.hy | 30 +-- tests/native_tests/native_macros.hy | 31 ++- tests/native_tests/operators.hy | 2 +- tests/test_bin.py | 151 ++++++++++- tests/test_lex.py | 4 +- 17 files changed, 630 insertions(+), 388 deletions(-) diff --git a/hy/_compat.py b/hy/_compat.py index 55180ca..2711445 100644 --- a/hy/_compat.py +++ b/hy/_compat.py @@ -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 diff --git a/hy/cmdline.py b/hy/cmdline.py index f7cca52..a9f3af3 100644 --- a/hy/cmdline.py +++ b/hy/cmdline.py @@ -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="", 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=""): - - super(HyREPL, self).__init__(locals=locals, - filename=filename) + filename=""): # 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: - # 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)]) - print(astor.to_source(new_ast)) + try: + # Mush the two AST chunks into a single module for + # conversion into Python. + 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='', 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='', 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 + # Shift exisitng REPL results + 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. - try: - output = self.output_fn(value) - except Exception: - self.showtraceback() - return False + if self.print_last_value: + try: + output = self.output_fn(self.last_value) + except Exception: + self.showtraceback() + return False - print(output) + 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,12 +316,18 @@ def run_icommand(source, **kwargs): source = f.read() filename = source else: - filename = '' + filename = '' + hr = HyREPL(**kwargs) with filtered_hy_exceptions(): - hr = HyREPL(**kwargs) - hr.runsource(source, filename=filename, symbol='single') - return run_repl(hr) + 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) USAGE = "%(prog)s [-h | -i cmd | -c cmd | -m module | file | -] [arg] ..." @@ -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 = '' source = sys.stdin.read() - with filtered_hy_exceptions(): - hst = hy_parse(source, filename='') 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": diff --git a/hy/compiler.py b/hy/compiler.py index a02e71a..08e0c98 100755 --- a/hy/compiler.py +++ b/hy/compiler.py @@ -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): diff --git a/hy/core/bootstrap.hy b/hy/core/bootstrap.hy index 0c98f60..0d3d737 100644 --- a/hy/core/bootstrap.hy +++ b/hy/core/bootstrap.hy @@ -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 diff --git a/hy/errors.py b/hy/errors.py index 9ca823e..0579e96 100644 --- a/hy/errors.py +++ b/hy/errors.py @@ -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 ":" 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.""" - - def __init__(self, message, filename=None, expression=None, source=None): - """ - 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 + """TypeError occurring during the normal use of Hy.""" -class HyMacroExpansionError(HyTypeError): +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. + """ + + +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]) + 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: - # 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) + # Normalize filename endings, because tracebacks will use `pyc` when + # the loader says `py`. + return filename.replace('.pyc', '.py') + except Exception: + return None -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 - return os.path.dirname(filename) - else: - # Normalize filename endings, because tracebacks will use `pyc` when - # the loader says `py`. - return filename.replace('.pyc', '.py') +_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']) + if m is not None} -_tb_hidden_modules = {_get_module_info(m) - for m in ['hy.compiler', 'hy.lex', - 'hy.cmdline', 'hy.lex.parser', - 'hy.importer', 'hy._compat', - 'hy.macros', 'hy.models', - 'rply']} - - -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,20 +248,33 @@ 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. """ + # frame = (filename, line number, function name*, text) + 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): + new_tb += [frame] + + lines = traceback.format_list(new_tb) + + if lines: + lines.insert(0, "Traceback (most recent call last):\n") + + 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: - # frame = (filename, line number, function name*, text) - new_tb = [frame 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)] - - lines = traceback.format_list(new_tb) - - if lines: - lines.insert(0, "Traceback (most recent call last):\n") - - lines.extend(traceback.format_exception_only(exc_type, exc_value)) - output = ''.join(lines) - + output = hy_exc_filter(exc_type, exc_value, exc_traceback) sys.stderr.write(output) sys.stderr.flush() except Exception: diff --git a/hy/lex/__init__.py b/hy/lex/__init__.py index f1465cd..eb3ac41 100644 --- a/hy/lex/__init__.py +++ b/hy/lex/__init__.py @@ -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=''): 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) + res = HyExpression([HySymbol("do")] + + tokenize(_source + "\n", + filename=filename)) + res.source = source + res.filename = filename + return res 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' diff --git a/hy/lex/exceptions.py b/hy/lex/exceptions.py index fb9aa7a..449119a 100644 --- a/hy/lex/exceptions.py +++ b/hy/lex/exceptions.py @@ -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): diff --git a/hy/lex/parser.py b/hy/lex/parser.py index f5cd5e5..c4df2a5 100755 --- a/hy/lex/parser.py +++ b/hy/lex/parser.py @@ -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: diff --git a/hy/macros.py b/hy/macros.py index 274702f..d668077 100644 --- a/hy/macros.py +++ b/hy/macros.py @@ -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): - source_module = importlib.import_module(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("()", "", 1).strip() + if compiler: + filename = compiler.filename + source = compiler.source 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): diff --git a/tests/compilers/test_ast.py b/tests/compilers/test_ast.py index 0c7bcd5..9311eef 100644 --- a/tests/compilers/test_ast.py +++ b/tests/compilers/test_ast.py @@ -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='', 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='', 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(): diff --git a/tests/macros/test_macro_processor.py b/tests/macros/test_macro_processor.py index 309ca49..134bd5b 100644 --- a/tests/macros/test_macro_processor.py +++ b/tests/macros/test_macro_processor.py @@ -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(): diff --git a/tests/native_tests/core.hy b/tests/native_tests/core.hy index a89eece..ab8eaf4 100644 --- a/tests/native_tests/core.hy +++ b/tests/native_tests/core.hy @@ -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)))) diff --git a/tests/native_tests/language.hy b/tests/native_tests/language.hy index c237a5a..65c629a 100644 --- a/tests/native_tests/language.hy +++ b/tests/native_tests/language.hy @@ -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 [] diff --git a/tests/native_tests/native_macros.hy b/tests/native_tests/native_macros.hy index 1700b5d..6759f42 100644 --- a/tests/native_tests/native_macros.hy +++ b/tests/native_tests/native_macros.hy @@ -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 \"\", 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))))) diff --git a/tests/native_tests/operators.hy b/tests/native_tests/operators.hy index 716eb77..e08edbb 100644 --- a/tests/native_tests/operators.hy +++ b/tests/native_tests/operators.hy @@ -28,7 +28,7 @@ (defmacro forbid [expr] `(assert (try (eval '~expr) - (except [TypeError] True) + (except [[TypeError SyntaxError]] True) (else (raise AssertionError))))) diff --git a/tests/test_bin.py b/tests/test_bin.py index 8aef923..aad45e5 100644 --- a/tests/test_bin.py +++ b/tests/test_bin.py @@ -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 "", 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 "", line 1, in + # 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 "", line 1 + # print("hi + # ^ + # SyntaxError: EOL while scanning string literal + _, error = run_cmd(r'hy -c "(print \""', expect=1) + peoi = ( + ' File "", 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 "", 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 "", line 1, in + # 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 "", line 1, in ' + # 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 "", line 1, in + # 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 "", line 1, in ' + assert error_lines[-1].startswith('TypeError') diff --git a/tests/test_lex.py b/tests/test_lex.py index b0a03dc..f709719 100644 --- a/tests/test_lex.py +++ b/tests/test_lex.py @@ -61,7 +61,7 @@ def test_lex_single_quote_err(): with lexe() as execinfo: tokenize("' ") check_ex(execinfo, [ - ' File "", line -1\n', + ' File "", 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 "", line 2', ' (foo',