diff --git a/CHANGELOG.md b/CHANGELOG.md index 8407e4a1b..45cfaa0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `==` as an alias to `=` (#859) * Added custom exception formatting for `basilisp.lang.compiler.exception.CompilerException` and `basilisp.lang.reader.SyntaxError` to show more useful details to users on errors (#870) * Added `merge-with` core function (#860) + * Added `basilisp.core/*except-hook*` and `basilisp.core/*repl-except-hook*` to allow users the ability to customize behaviors for printing unhandled exceptions (#873) ### Changed * Cause exceptions arising from compilation issues during macroexpansion will no longer be nested for each level of macroexpansion (#852) diff --git a/pyproject.toml b/pyproject.toml index 6a8050590..69776f15d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ branch = true omit = [ "*/__version__.py", "*/basilisp/contrib/sphinx/*", + "*/basilisp/sitecustomize.py", ] [tool.coverage.paths] diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index b7e7daf17..421dcdff2 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -14,7 +14,6 @@ from basilisp.lang import runtime as runtime from basilisp.lang import symbol as sym from basilisp.lang import vector as vec -from basilisp.lang.exception import print_exception from basilisp.prompt import get_prompter CLI_INPUT_FILE_PATH = "" @@ -396,8 +395,9 @@ def repl( warn_on_var_indirection=args.warn_on_var_indirection, ) basilisp.init(opts) + print_exception = runtime.get_basilisp_repl_exception_hook() ctx = compiler.CompilerContext(filename=REPL_INPUT_FILE_PATH, opts=opts) - prompter = get_prompter() + prompter = get_prompter(print_exception=print_exception) eof = object() # Bind user-settable dynamic Vars to their existing value to allow users to @@ -449,15 +449,15 @@ def repl( prompter.print(runtime.lrepr(result)) repl_module.mark_repl_result(result) except reader.SyntaxError as e: - print_exception(e, reader.SyntaxError, e.__traceback__) + print_exception(reader.SyntaxError, e, e.__traceback__) repl_module.mark_exception(e) continue except compiler.CompilerException as e: - print_exception(e, compiler.CompilerException, e.__traceback__) + print_exception(compiler.CompilerException, e, e.__traceback__) repl_module.mark_exception(e) continue except Exception as e: # pylint: disable=broad-exception-caught - print_exception(e, Exception, e.__traceback__) + print_exception(type(e), e, e.__traceback__) repl_module.mark_exception(e) continue diff --git a/src/basilisp/contrib/nrepl_server.lpy b/src/basilisp/contrib/nrepl_server.lpy index 4255b1819..b46f3e26d 100644 --- a/src/basilisp/contrib/nrepl_server.lpy +++ b/src/basilisp/contrib/nrepl_server.lpy @@ -355,13 +355,14 @@ log-response-mw response-for-mw)) -(defn- on-connect [tcp-req-handler opts] +(defn- on-connect "Serve a new nREPL connection as found in ``tcp-req-handler`` according to ``opts``. ``opts`` is a map of options with the following optional keys. :recv-buffer-size The buffer size to using for incoming nREPL messages." + [tcp-req-handler opts] (let [{:keys [recv-buffer-size] :or {recv-buffer-size 1024}} opts socket (.-request tcp-req-handler) diff --git a/src/basilisp/lang/exception.py b/src/basilisp/lang/exception.py index f7959aff7..4a499662f 100644 --- a/src/basilisp/lang/exception.py +++ b/src/basilisp/lang/exception.py @@ -1,8 +1,9 @@ import functools import sys import traceback +import types from types import TracebackType -from typing import List, Optional, Type +from typing import Callable, List, Optional, Type import attr @@ -24,6 +25,11 @@ def __str__(self): return f"{self.message} {lrepr(self.data)}" +ExceptionPrinter = Callable[ + [Type[BaseException], BaseException, Optional[types.TracebackType]], None +] + + @functools.singledispatch def format_exception( e: Optional[BaseException], @@ -36,7 +42,7 @@ def format_exception( For the majority of Python exceptions, this will just be the result from calling `traceback.format_exception`. For Basilisp specific compilation errors, a custom output will be returned.""" - if isinstance(e, BaseException): + if isinstance(e, BaseException): # pragma: no cover if tp is None: tp = type(e) if tb is None: @@ -45,9 +51,9 @@ def format_exception( def print_exception( + tp: Optional[Type[BaseException]], e: Optional[BaseException], - tp: Optional[Type[BaseException]] = None, - tb: Optional[TracebackType] = None, + tb: Optional[TracebackType], ) -> None: """Print the given exception `e` using Basilisp's own exception formatting. diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index 75b12b35d..6ba7501a4 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -12,6 +12,7 @@ import platform import re import sys +import textwrap import threading import types from collections.abc import Sequence, Sized @@ -46,6 +47,7 @@ from basilisp.lang import symbol as sym from basilisp.lang import vector as vec from basilisp.lang.atom import Atom +from basilisp.lang.exception import ExceptionPrinter, print_exception from basilisp.lang.interfaces import ( IAssociative, IBlockingDeref, @@ -84,6 +86,8 @@ COMPILER_OPTIONS_VAR_NAME = "*compiler-options*" COMMAND_LINE_ARGS_VAR_NAME = "*command-line-args*" DEFAULT_READER_FEATURES_VAR_NAME = "*default-reader-features*" +EXCEPT_HOOK_VAR_NAME = "*except-hook*" +REPL_EXCEPT_HOOK_VAR_NAME = "*repl-except-hook*" GENERATED_PYTHON_VAR_NAME = "*generated-python*" PRINT_GENERATED_PY_VAR_NAME = "*print-generated-python*" MAIN_NS_VAR_NAME = "*main-ns*" @@ -2030,6 +2034,60 @@ def print_generated_python() -> bool: ) +################### +# Traceback Hooks # +################### + + +def _create_except_hook_fn(hook_var: Var) -> ExceptionPrinter: + """Create an except hook function which is mostly compatible with + `traceback.print_exception` which uses the function from the Var `hook_var`.""" + + def basilisp_except_hook( + tp: Type[BaseException], + e: BaseException, + tb: Optional[types.TracebackType], + ) -> None: + if (hook := hook_var.value) is not None: + try: + hook(tp, e, tb) + except Exception: # pylint: disable=broad-exception-caught + print( + f"Exception occurred in Basilisp except hook {hook_var}", + file=sys.stderr, + ) + sys.__excepthook__(tp, e, tb) + else: + # Emit an error to stderr and fall back to the default Python except hook + # if no hook is currently defined in Basilisp. + print(f"Dynamic Var {hook_var} not bound!", file=sys.stderr) + sys.__excepthook__(tp, e, tb) + + return basilisp_except_hook + + +def create_basilisp_except_hook() -> ExceptionPrinter: + """Create an except hook which is suitable to being installed as `sys.excepthook` + to print any unhandled tracebacks. + + This function must be called after bootstrapping `basilisp.core`.""" + ns_sym = sym.symbol(EXCEPT_HOOK_VAR_NAME, ns=CORE_NS) + hook_var = Var.find(ns_sym) + assert hook_var is not None + return _create_except_hook_fn(hook_var) + + +def get_basilisp_repl_exception_hook() -> ExceptionPrinter: + """Create an exception hook which can be used to print exception tracebacks for + interactive REPL sessions. + + This function must be called after bootstrapping `basilisp.core`.""" + ns_sym = sym.symbol(REPL_EXCEPT_HOOK_VAR_NAME, ns=CORE_NS) + hook_var = Var.find(ns_sym) + assert hook_var is not None + return _create_except_hook_fn(hook_var) + + ######################### # Bootstrap the Runtime # ######################### @@ -2127,7 +2185,7 @@ def in_ns(s: sym.Symbol): ), ) - # Dynamic Var containing command line arguments passed via `basilisp run` + # Dynamic Var containing the name of the main namespace (if one is given) Var.intern( CORE_NS_SYM, sym.symbol(MAIN_NS_VAR_NAME), @@ -2162,6 +2220,76 @@ def in_ns(s: sym.Symbol): ), ) + # Dynamic Vars containing the current traceback printers + Var.intern( + CORE_NS_SYM, + sym.symbol(EXCEPT_HOOK_VAR_NAME), + print_exception, + dynamic=True, + meta=lmap.map( + { + _DOC_META_KEY: textwrap.indent( + textwrap.dedent( + f""" + A function of 3 arguments which controls the printing of unhandled + exceptions. + + The three arguments should be in order: an exception *type*, an + exception *instance*, and an exception *traceback* (e.g. the value + of the ``__traceback__`` attribute). In the majority of cases, only + the second argument is needed but nevertheless ``sys.excepthook`` + is called with all 3 in that order. + + The default function has special handling for compiler errors + and reader syntax errors. All other exception types are printed + as by `traceback.print_exception `_. + + After bootstrapping the runtime, Python's `sys.excepthook `_ + will use the function bound to this Var for printing any unhandled + exceptions, including those not originating from Basilisp code. + + For controlling the display of exceptions occurring during REPL + sessions, see :lpy:var:`{REPL_EXCEPT_HOOK_VAR_NAME}`. + """ + ).strip(), + " ", + ) + } + ), + ) + Var.intern( + CORE_NS_SYM, + sym.symbol(REPL_EXCEPT_HOOK_VAR_NAME), + print_exception, + dynamic=True, + meta=lmap.map( + { + _DOC_META_KEY: textwrap.indent( + textwrap.dedent( + f""" + A function of 3 arguments which controls the printing of exceptions + thrown during interactive REPL sessions. + + The three arguments should be in order: an exception *type*, an + exception *instance*, and an exception *traceback* (e.g. the value + of the ``__traceback__`` attribute). In the majority of cases, only + the second argument is needed but nevertheless ``sys.excepthook`` + is called with all 3 in that order. + + The default function has special handling for compiler errors + and reader syntax errors. All other exception types are printed + as by `traceback.print_exception `_. + + This Var is distinct from :lpy:var:`{EXCEPT_HOOK_VAR_NAME}`, which controls + printing unhandled exceptions occurring outside the REPL context. + """ + ).strip(), + " ", + ) + } + ), + ) + # Dynamic Vars examined by the compiler for generating Python code for debugging Var.intern( CORE_NS_SYM, diff --git a/src/basilisp/main.py b/src/basilisp/main.py index 671efee85..6b0fe7bef 100644 --- a/src/basilisp/main.py +++ b/src/basilisp/main.py @@ -1,6 +1,7 @@ import importlib import logging import site +import sys from pathlib import Path from typing import List, Optional @@ -29,6 +30,7 @@ def init(opts: Optional[CompilerOpts] = None) -> None: """ runtime.init_ns_var() runtime.bootstrap_core(opts if opts is not None else compiler_opts()) + sys.excepthook = runtime.create_basilisp_except_hook() importer.hook_imports() importlib.import_module("basilisp.core") diff --git a/src/basilisp/prompt.py b/src/basilisp/prompt.py index ca6ad3ee9..1d8404043 100644 --- a/src/basilisp/prompt.py +++ b/src/basilisp/prompt.py @@ -2,6 +2,7 @@ import os import re +import traceback from functools import partial from types import MappingProxyType from typing import Any, Iterable, Mapping, Optional, Type @@ -17,7 +18,7 @@ from basilisp.lang import reader as reader from basilisp.lang import runtime as runtime -from basilisp.lang.exception import print_exception +from basilisp.lang.exception import ExceptionPrinter _USER_DATA_HOME = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) BASILISP_USER_DATA = os.path.abspath(os.path.join(_USER_DATA_HOME, "basilisp")) @@ -30,7 +31,10 @@ class Prompter: - __slots__ = () + __slots__ = ("_key_bindings",) + + def __init__(self, key_bindings: KeyBindings): + self._key_bindings = key_bindings def prompt(self, msg: str) -> str: """Prompt the user for input with the input string `msg`.""" @@ -41,6 +45,46 @@ def print(self, msg: str) -> None: print(msg) +def create_key_bindings( + print_exception: ExceptionPrinter = traceback.print_exception, +) -> KeyBindings: + """Return `KeyBindings` which override the builtin `enter` handler to + allow multi-line input. + + Inputs are read by the reader to determine if they represent valid + Basilisp syntax. If an `UnexpectedEOFError` is raised, then allow multiline + input. If a more general `SyntaxError` is raised, then the exception will + be printed to the terminal. In all other cases, handle the input normally.""" + kb = KeyBindings() + _eof = object() + + @kb.add("enter") + def _(event: KeyPressEvent) -> None: + try: + list( + reader.read_str( + event.current_buffer.text, + resolver=runtime.resolve_alias, + eof=_eof, + ) + ) + except reader.UnexpectedEOFError: + event.current_buffer.insert_text("\n") + except reader.SyntaxError as e: + run_in_terminal( + partial( + print_exception, + reader.SyntaxError, + e, + e.__traceback__, + ) + ) + else: + event.current_buffer.validate_and_handle() + + return kb + + _DELIMITED_WORD_PATTERN = re.compile(r"([^\[\](){\}\s]+)") @@ -65,55 +109,18 @@ class PromptToolkitPrompter(Prompter): __slots__ = ("_session",) - def __init__(self): + def __init__(self, key_bindings: KeyBindings): + super().__init__(key_bindings) self._session: PromptSession = PromptSession( auto_suggest=AutoSuggestFromHistory(), completer=REPLCompleter(), history=FileHistory(BASILISP_REPL_HISTORY_FILE_PATH), - key_bindings=self._get_key_bindings(), + key_bindings=self._key_bindings, lexer=self._prompt_toolkit_lexer, multiline=True, **self._style_settings, ) - @staticmethod - def _get_key_bindings() -> KeyBindings: - """Return `KeyBindings` which override the builtin `enter` handler to - allow multi-line input. - - Inputs are read by the reader to determine if they represent valid - Basilisp syntax. If an `UnexpectedEOFError` is raised, then allow multiline - input. If a more general `SyntaxError` is raised, then the exception will - be printed to the terminal. In all other cases, handle the input normally.""" - kb = KeyBindings() - _eof = object() - - @kb.add("enter") - def _(event: KeyPressEvent) -> None: - try: - list( - reader.read_str( - event.current_buffer.text, - resolver=runtime.resolve_alias, - eof=_eof, - ) - ) - except reader.UnexpectedEOFError: - event.current_buffer.insert_text("\n") - except reader.SyntaxError as e: - run_in_terminal( - partial( - print_exception, - reader.SyntaxError, - e, - e.__traceback__, - ) - ) - else: - event.current_buffer.validate_and_handle() - - return kb - _prompt_toolkit_lexer: Optional["PygmentsLexer"] = None _style_settings: Mapping[str, Any] = MappingProxyType({}) @@ -162,12 +169,15 @@ def print(self, msg: str) -> None: _DEFAULT_PROMPTER = StyledPromptToolkitPrompter -def get_prompter() -> Prompter: +def get_prompter( + print_exception: ExceptionPrinter = traceback.print_exception, +) -> Prompter: """Return a Prompter instance for reading user input from the REPL. Prompter instances may be stateful, so the Prompter instance returned by this function can be reused within a single REPL session.""" - return _DEFAULT_PROMPTER() + key_bindings = create_key_bindings(print_exception) + return _DEFAULT_PROMPTER(key_bindings) __all__ = ["Prompter", "get_prompter"] diff --git a/src/basilisp/stacktrace.lpy b/src/basilisp/stacktrace.lpy index da0c5c86e..c53308621 100644 --- a/src/basilisp/stacktrace.lpy +++ b/src/basilisp/stacktrace.lpy @@ -1,5 +1,9 @@ (ns basilisp.stacktrace - "Utility functions for printing stack traces." + "Utility functions for printing stack traces. + + Stack traces printed by the functions in this namespace do not respect the function + bound to :lpy:var:`*except-hook*`. There is no special handling for reader syntax + errors or compiler exceptions." (:require [basilisp.string :as str]) (:import [traceback :as tb])) diff --git a/tests/basilisp/cli_test.py b/tests/basilisp/cli_test.py index 801597dc4..58aa346f8 100644 --- a/tests/basilisp/cli_test.py +++ b/tests/basilisp/cli_test.py @@ -14,8 +14,8 @@ import attr import pytest +from basilisp import prompt from basilisp.cli import BOOL_FALSE, BOOL_TRUE, invoke_cli -from basilisp.prompt import Prompter @pytest.fixture(autouse=True) @@ -172,7 +172,10 @@ def test_run_nrepl(self, run_cli): class TestREPL: @pytest.fixture(scope="class", autouse=True) def prompter(self): - with patch("basilisp.cli.get_prompter", return_value=Prompter()): + key_bindings = prompt.create_key_bindings() + with patch( + "basilisp.cli.get_prompter", return_value=prompt.Prompter(key_bindings) + ): yield def test_no_input(self, run_cli): diff --git a/tests/basilisp/prompt_test.py b/tests/basilisp/prompt_test.py index a920f0926..1ee0d91af 100644 --- a/tests/basilisp/prompt_test.py +++ b/tests/basilisp/prompt_test.py @@ -8,7 +8,12 @@ from prompt_toolkit.keys import Keys from basilisp.lang.runtime import Namespace -from basilisp.prompt import PromptToolkitPrompter, REPLCompleter, get_prompter +from basilisp.prompt import ( + PromptToolkitPrompter, + REPLCompleter, + create_key_bindings, + get_prompter, +) try: import pygments @@ -178,7 +183,7 @@ def make_key_press_event(self, text: str): @pytest.fixture(scope="class") def handler(self) -> Binding: - kb = PromptToolkitPrompter._get_key_bindings() + kb = create_key_bindings() handler, *_ = kb.get_bindings_for_keys((Keys.ControlM,)) return handler diff --git a/tests/basilisp/runtime_test.py b/tests/basilisp/runtime_test.py index 680e8dc28..d5fb3b59a 100644 --- a/tests/basilisp/runtime_test.py +++ b/tests/basilisp/runtime_test.py @@ -1,5 +1,6 @@ import platform import sys +import traceback from decimal import Decimal from fractions import Fraction @@ -747,3 +748,52 @@ def test_resolve_alias(self, core_ns): ) == runtime.resolve_alias( sym.symbol("non-existent-alias-var", ns="wee.woo"), ns=ns ) + + +class TestExceptHook: + @pytest.fixture + def hook_var(self) -> runtime.Var: + ns_name = "basilisp.except-hook-test" + ns_sym = sym.symbol(ns_name) + yield runtime.Var.intern(ns_sym, sym.symbol("*except-hook*"), None) + + def test_hook_value_is_nil(self, hook_var: runtime.Var, capsys): + hook = runtime._create_except_hook_fn(hook_var) + + try: + raise ValueError("Woo!") + except ValueError as e: + hook(ValueError, e, e.__traceback__) + + res = capsys.readouterr() + assert f"Dynamic Var {hook_var} not bound!" in res.err + + def test_hook_throws_exception(self, hook_var: runtime.Var, capsys): + def _bad_except_hook(tp, e, tb): + raise TypeError("I'm bad at my job!") + + hook_var.bind_root(_bad_except_hook) + hook = runtime._create_except_hook_fn(hook_var) + + try: + raise ValueError("Woo!") + except ValueError as e: + hook(ValueError, e, e.__traceback__) + + res = capsys.readouterr() + assert f"Exception occurred in Basilisp except hook {hook_var}" in res.err + + def test_hook_success(self, hook_var: runtime.Var, capsys): + hook_var.bind_root(traceback.print_exception) + hook = runtime._create_except_hook_fn(hook_var) + + try: + raise ValueError("Woo!") + except ValueError as e: + hook(ValueError, e, e.__traceback__) + + res = capsys.readouterr() + assert ( + "".join(traceback.format_exception(ValueError, e, e.__traceback__)) + == res.err + )