diff --git a/Lib/_pyrepl/_threading_handler.py b/Lib/_pyrepl/_threading_handler.py new file mode 100644 index 00000000000000..82f5e8650a2072 --- /dev/null +++ b/Lib/_pyrepl/_threading_handler.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import traceback + + +TYPE_CHECKING = False +if TYPE_CHECKING: + from threading import Thread + from types import TracebackType + from typing import Protocol + + class ExceptHookArgs(Protocol): + @property + def exc_type(self) -> type[BaseException]: ... + @property + def exc_value(self) -> BaseException | None: ... + @property + def exc_traceback(self) -> TracebackType | None: ... + @property + def thread(self) -> Thread | None: ... + + class ShowExceptions(Protocol): + def __call__(self) -> int: ... + def add(self, s: str) -> None: ... + + from .reader import Reader + + +def install_threading_hook(reader: Reader) -> None: + import threading + + @dataclass + class ExceptHookHandler: + lock: threading.Lock = field(default_factory=threading.Lock) + messages: list[str] = field(default_factory=list) + + def show(self) -> int: + count = 0 + with self.lock: + if not self.messages: + return 0 + reader.restore() + for tb in self.messages: + count += 1 + if tb: + print(tb) + self.messages.clear() + reader.scheduled_commands.append("ctrl-c") + reader.prepare() + return count + + def add(self, s: str) -> None: + with self.lock: + self.messages.append(s) + + def exception(self, args: ExceptHookArgs) -> None: + lines = traceback.format_exception( + args.exc_type, + args.exc_value, + args.exc_traceback, + colorize=reader.can_colorize, + ) # type: ignore[call-overload] + pre = f"\nException in {args.thread.name}:\n" if args.thread else "\n" + tb = pre + "".join(lines) + self.add(tb) + + def __call__(self) -> int: + return self.show() + + + handler = ExceptHookHandler() + reader.threading_hook = handler + threading.excepthook = handler.exception diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index aa3f5fd283eb7d..54bd1ea0222a60 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -36,8 +36,7 @@ # types Command = commands.Command -if False: - from .types import Callback, SimpleContextManager, KeySpec, CommandName +from .types import Callback, SimpleContextManager, KeySpec, CommandName def disp_str(buffer: str) -> tuple[str, list[int]]: @@ -247,6 +246,7 @@ class Reader: lxy: tuple[int, int] = field(init=False) scheduled_commands: list[str] = field(default_factory=list) can_colorize: bool = False + threading_hook: Callback | None = None ## cached metadata to speed up screen refreshes @dataclass @@ -722,6 +722,24 @@ def do_cmd(self, cmd: tuple[str, list[str]]) -> None: self.console.finish() self.finish() + def run_hooks(self) -> None: + threading_hook = self.threading_hook + if threading_hook is None and 'threading' in sys.modules: + from ._threading_handler import install_threading_hook + install_threading_hook(self) + if threading_hook is not None: + try: + threading_hook() + except Exception: + pass + + input_hook = self.console.input_hook + if input_hook: + try: + input_hook() + except Exception: + pass + def handle1(self, block: bool = True) -> bool: """Handle a single event. Wait as long as it takes if block is true (the default), otherwise return False if no event is @@ -732,16 +750,13 @@ def handle1(self, block: bool = True) -> bool: self.dirty = True while True: - input_hook = self.console.input_hook - if input_hook: - input_hook() - # We use the same timeout as in readline.c: 100ms - while not self.console.wait(100): - input_hook() - event = self.console.get_event(block=False) - else: - event = self.console.get_event(block) - if not event: # can only happen if we're not blocking + # We use the same timeout as in readline.c: 100ms + self.run_hooks() + self.console.wait(100) + event = self.console.get_event(block=False) + if not event: + if block: + continue return False translate = True @@ -763,8 +778,7 @@ def handle1(self, block: bool = True) -> bool: if cmd is None: if block: continue - else: - return False + return False self.do_cmd(cmd) return True diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index 111b7d92367210..5120140e061691 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -127,6 +127,15 @@ def run(self): loop.call_soon_threadsafe(loop.stop) + def interrupt(self) -> None: + if not CAN_USE_PYREPL: + return + + from _pyrepl.simple_interact import _get_reader + r = _get_reader() + if r.threading_hook is not None: + r.threading_hook.add("") # type: ignore + if __name__ == '__main__': sys.audit("cpython.run_stdin") @@ -184,6 +193,7 @@ def run(self): keyboard_interrupted = True if repl_future and not repl_future.done(): repl_future.cancel() + repl_thread.interrupt() continue else: break diff --git a/Lib/test/test_repl.py b/Lib/test/test_repl.py index cd8ef0f10579f3..5b1ece96462b14 100644 --- a/Lib/test/test_repl.py +++ b/Lib/test/test_repl.py @@ -270,7 +270,7 @@ def test_asyncio_repl_is_ok(self): proc.kill() exit_code = proc.wait() - self.assertEqual(exit_code, 0) + self.assertEqual(exit_code, 0, "".join(output)) class TestInteractiveModeSyntaxErrors(unittest.TestCase):